Compare commits
27 Commits
main
...
355d4e45d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
355d4e45d7 | ||
|
|
a0640b5401 | ||
|
|
4d5d12743d | ||
|
|
11e77e2b54 | ||
|
|
8acca705fa | ||
|
|
6058023f30 | ||
|
|
70aa9e9e2a | ||
|
|
8103629370 | ||
|
|
dce5d0af12 | ||
|
|
1889ee7fb2 | ||
|
|
c74140c431 | ||
|
|
3c1e17c2af | ||
|
|
47f4f50e5b | ||
|
|
e4509b1c43 | ||
|
|
59e1854803 | ||
|
|
58141dca94 | ||
|
|
d2c43cfc2f | ||
|
|
bc09feafc1 | ||
|
|
d922d7304d | ||
|
|
0399761fb3 | ||
|
|
e5ed1a34dd | ||
| 3e0a354a58 | |||
|
|
b5c1846f2c | ||
|
|
04393add14 | ||
|
|
5279ebe1a4 | ||
|
|
329ba89eaa | ||
|
|
9c56b2e1e5 |
117
AGENT.md
117
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:
|
||||
|
||||
11
BACKLOG.md
11
BACKLOG.md
@@ -17,7 +17,6 @@
|
||||
- [ ] feat: Dynamic pricing based on demand
|
||||
- [ ] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
|
||||
- [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
|
||||
- [ ] feat: Payout system for promoters (automated/manual payment processing)
|
||||
- [ ] feat: Platform commission tracking and fee structure display
|
||||
- [ ] feat: Tax reporting and revenue export for promoters
|
||||
- [ ] feat: Event update notifications to ticket holders
|
||||
@@ -45,14 +44,10 @@
|
||||
- [ ] feat: Event recommendations system
|
||||
- [ ] feat: Invitation link. As organizer or promoter, you can invite people
|
||||
|
||||
|
||||
### Design & Infrastructure
|
||||
|
||||
- [ ] style: Rewrite design system
|
||||
- [ ] refactor: Rewrite design mockup
|
||||
|
||||
## 🚧 Doing
|
||||
|
||||
- [x] feat: Payout system for promoters (automated/manual payment processing)
|
||||
- [ ] feat: 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
|
||||
|
||||
@@ -68,3 +63,5 @@
|
||||
- [x] feat: Ticket inventory management and capacity limits
|
||||
- [x] feat: Event discovery with search and filtering
|
||||
- [x] feat: Email notifications (purchase confirmations, event reminders)
|
||||
- [x] style: Rewrite design system
|
||||
- [x] refactor: Rewrite design mockup
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
|
||||
/* Import pages */
|
||||
@import "pages/home";
|
||||
@import "pages/payouts";
|
||||
|
||||
304
app/assets/stylesheets/pages/payouts.css
Normal file
304
app/assets/stylesheets/pages/payouts.css
Normal file
@@ -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;
|
||||
}
|
||||
76
app/controllers/admin/payouts_controller.rb
Normal file
76
app/controllers/admin/payouts_controller.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
class Admin::PayoutsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_admin!
|
||||
before_action :set_payout, only: [ :show, :approve, :reject, :mark_processing, :mark_completed, :mark_failed ]
|
||||
|
||||
def index
|
||||
@pending_payouts = Payout.pending.includes(:user, :event).order(created_at: :asc)
|
||||
@approved_payouts = Payout.approved.includes(:user, :event).order(created_at: :asc)
|
||||
@processing_payouts = Payout.processing.includes(:user, :event).order(created_at: :asc)
|
||||
@completed_payouts = Payout.completed.includes(:user, :event).order(created_at: :desc).limit(10)
|
||||
end
|
||||
|
||||
def show
|
||||
@service = PayoutService.new(@payout)
|
||||
@transfer_summary = @service.generate_transfer_summary
|
||||
@banking_errors = @service.validate_banking_info
|
||||
end
|
||||
|
||||
def approve
|
||||
if @payout.approve!(current_user)
|
||||
redirect_to admin_payout_path(@payout), notice: "Payout approved successfully."
|
||||
else
|
||||
redirect_to admin_payout_path(@payout), alert: "Cannot approve this payout."
|
||||
end
|
||||
end
|
||||
|
||||
def reject
|
||||
reason = params[:rejection_reason].presence || "No reason provided"
|
||||
if @payout.reject!(current_user, reason)
|
||||
redirect_to admin_payouts_path, notice: "Payout rejected."
|
||||
else
|
||||
redirect_to admin_payout_path(@payout), alert: "Cannot reject this payout."
|
||||
end
|
||||
end
|
||||
|
||||
def mark_processing
|
||||
transfer_reference = params[:bank_transfer_reference]
|
||||
if @payout.mark_processing!(current_user, transfer_reference)
|
||||
redirect_to admin_payout_path(@payout), notice: "Payout marked as processing."
|
||||
else
|
||||
redirect_to admin_payout_path(@payout), alert: "Cannot mark payout as processing."
|
||||
end
|
||||
end
|
||||
|
||||
def mark_completed
|
||||
transfer_reference = params[:bank_transfer_reference]
|
||||
if @payout.mark_completed!(current_user, transfer_reference)
|
||||
redirect_to admin_payouts_path, notice: "Payout completed successfully."
|
||||
else
|
||||
redirect_to admin_payout_path(@payout), alert: "Cannot mark payout as completed."
|
||||
end
|
||||
end
|
||||
|
||||
def mark_failed
|
||||
reason = params[:failure_reason].presence || "Transfer failed"
|
||||
if @payout.mark_failed!(current_user, reason)
|
||||
redirect_to admin_payouts_path, notice: "Payout marked as failed."
|
||||
else
|
||||
redirect_to admin_payout_path(@payout), alert: "Cannot mark payout as failed."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_payout
|
||||
@payout = Payout.find(params[:id])
|
||||
end
|
||||
|
||||
def ensure_admin!
|
||||
# For now, we'll just check if the user is a professional user
|
||||
# In a real app, you'd have an admin role check
|
||||
unless current_user.promoter?
|
||||
redirect_to dashboard_path, alert: "Access denied."
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -69,6 +69,8 @@ module Api
|
||||
)
|
||||
|
||||
unless ticket.save
|
||||
Rails.logger.error "API Ticket validation failed: #{ticket.errors.full_messages.join(', ')}"
|
||||
Rails.logger.error "API Ticket attributes: #{ticket.attributes.inspect}"
|
||||
render json: { error: "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}" }, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
||||
@@ -69,6 +69,8 @@ class OrdersController < ApplicationController
|
||||
)
|
||||
|
||||
unless ticket.save
|
||||
Rails.logger.error "Ticket validation failed: #{ticket.errors.full_messages.join(', ')}"
|
||||
Rails.logger.error "Ticket attributes: #{ticket.attributes.inspect}"
|
||||
flash[:alert] = "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}"
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
@@ -126,6 +128,15 @@ class OrdersController < ApplicationController
|
||||
@total_amount = @order.total_amount_cents
|
||||
@expiring_soon = @order.expiring_soon?
|
||||
|
||||
# For free orders, automatically mark as paid and redirect to success
|
||||
if @order.free?
|
||||
@order.mark_as_paid!
|
||||
session.delete(:pending_cart)
|
||||
session.delete(:ticket_names)
|
||||
session.delete(:draft_order_id)
|
||||
return redirect_to order_path(@order), notice: "Vos billets gratuits ont été confirmés !"
|
||||
end
|
||||
|
||||
# Create Stripe checkout session if Stripe is configured
|
||||
if Rails.application.config.stripe[:secret_key].present?
|
||||
begin
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
class Promoter::EventsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_can_manage_events!
|
||||
before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :duplicate ]
|
||||
before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :mark_available, :duplicate ]
|
||||
|
||||
# Display all events for the current promoter
|
||||
def index
|
||||
@@ -93,6 +93,16 @@ class Promoter::EventsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
# Mark event as available again
|
||||
def mark_available
|
||||
if @event.sold_out?
|
||||
@event.update(state: :published)
|
||||
redirect_to promoter_event_path(@event), notice: "Event marqué comme disponible!"
|
||||
else
|
||||
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être marqué comme disponible."
|
||||
end
|
||||
end
|
||||
|
||||
# Duplicate an event and all its ticket types
|
||||
def duplicate
|
||||
clone_ticket_types = params[:clone_ticket_types] == "true"
|
||||
|
||||
73
app/controllers/promoter/payouts_controller.rb
Normal file
73
app/controllers/promoter/payouts_controller.rb
Normal file
@@ -0,0 +1,73 @@
|
||||
class Promoter::PayoutsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_promoter!
|
||||
before_action :set_event, only: [ :create ]
|
||||
|
||||
# List all payouts for the current promoter
|
||||
def index
|
||||
@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 = 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?(current_user)
|
||||
redirect_to event_path(@event.slug, @event), alert: "Payout cannot be requested for this event."
|
||||
return
|
||||
end
|
||||
|
||||
# Calculate payout amount using model methods
|
||||
gross = @event.total_gross_cents
|
||||
fees = @event.total_fees_cents
|
||||
|
||||
# Count orders using model scope
|
||||
total_orders_count = @event.orders.paid.count
|
||||
|
||||
# Create payout record
|
||||
@payout = @event.payouts.build(
|
||||
user: current_user,
|
||||
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
|
||||
flash.now[:alert] = "Failed to submit payout request: #{@payout.errors.full_messages.join(', ')}"
|
||||
render "new"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_promoter!
|
||||
unless current_user.promoter?
|
||||
redirect_to dashboard_path, alert: "Access denied."
|
||||
end
|
||||
end
|
||||
|
||||
def set_event
|
||||
@event = current_user.events.find(params[:event_id])
|
||||
end
|
||||
end
|
||||
34
app/controllers/webhooks/stripe_controller.rb
Normal file
34
app/controllers/webhooks/stripe_controller.rb
Normal file
@@ -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
|
||||
2
app/helpers/admin/payouts_helper.rb
Normal file
2
app/helpers/admin/payouts_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module Admin::PayoutsHelper
|
||||
end
|
||||
2
app/helpers/promoter/payouts_helper.rb
Normal file
2
app/helpers/promoter/payouts_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module Promoter::PayoutsHelper
|
||||
end
|
||||
@@ -1,2 +1,9 @@
|
||||
module TicketsHelper
|
||||
def format_ticket_price(price_cents)
|
||||
if price_cents == 0
|
||||
"Gratuit"
|
||||
else
|
||||
number_to_currency(price_cents / 100.0, unit: "€")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class TicketMailer < ApplicationMailer
|
||||
helper :tickets
|
||||
|
||||
def purchase_confirmation_order(order)
|
||||
@order = order
|
||||
@user = order.user
|
||||
|
||||
54
app/models/earning.rb
Normal file
54
app/models/earning.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
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
|
||||
belongs_to :order
|
||||
|
||||
# === Enums ===
|
||||
enum :status, { pending: 0, paid: 1 }
|
||||
|
||||
# === Validations ===
|
||||
validates :amount_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :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
|
||||
@@ -16,16 +16,27 @@ class Event < ApplicationRecord
|
||||
sold_out: 3
|
||||
}, default: :draft
|
||||
|
||||
enum :payout_status, {
|
||||
pending_request: 0,
|
||||
requested: 1,
|
||||
processing: 2,
|
||||
completed: 3,
|
||||
failed: 4
|
||||
}, default: :pending_request
|
||||
|
||||
# === Relations ===
|
||||
belongs_to :user
|
||||
has_many :ticket_types
|
||||
has_many :tickets, through: :ticket_types
|
||||
has_many :orders
|
||||
has_many :earnings, dependent: :destroy
|
||||
has_many :payouts, dependent: :destroy
|
||||
|
||||
# === Callbacks ===
|
||||
before_validation :geocode_address, if: :should_geocode_address?
|
||||
|
||||
# Validations for Event attributes
|
||||
# === Validations ===
|
||||
|
||||
# Basic information
|
||||
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
|
||||
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
|
||||
@@ -55,9 +66,36 @@ 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 total_gross_cents
|
||||
tickets.active.sum(:price_cents)
|
||||
end
|
||||
|
||||
# Alias for template compatibility
|
||||
alias_method :total_earnings_cents, :total_gross_cents
|
||||
|
||||
def total_fees_cents
|
||||
earnings.pending.sum(:fee_cents)
|
||||
end
|
||||
|
||||
def net_earnings_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
|
||||
|
||||
# Get the latest payout for this event
|
||||
def payout
|
||||
payouts.order(created_at: :desc).first
|
||||
end
|
||||
|
||||
# Check if coordinates were successfully geocoded or are fallback coordinates
|
||||
def geocoding_successful?
|
||||
coordinates_look_valid?
|
||||
@@ -96,6 +134,11 @@ class Event < ApplicationRecord
|
||||
Time.current >= end_time
|
||||
end
|
||||
|
||||
# Return the event date (start time date)
|
||||
def date
|
||||
start_time&.to_date
|
||||
end
|
||||
|
||||
# Check if booking is allowed during the event
|
||||
# This is a simple attribute reader that defaults to false if nil
|
||||
def allow_booking_during_event?
|
||||
|
||||
@@ -3,6 +3,9 @@ class Order < ApplicationRecord
|
||||
DRAFT_EXPIRY_TIME = 15.minutes
|
||||
MAX_PAYMENT_ATTEMPTS = 3
|
||||
|
||||
# === Enums ===
|
||||
# Note: using string values since the database column is a string
|
||||
|
||||
# === Associations ===
|
||||
belongs_to :user
|
||||
belongs_to :event
|
||||
@@ -23,8 +26,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 > ?",
|
||||
@@ -32,6 +36,8 @@ class Order < ApplicationRecord
|
||||
}
|
||||
|
||||
before_validation :set_expiry, on: :create
|
||||
before_validation :set_default_status, on: :create
|
||||
after_update :create_earnings_if_paid, if: -> { saved_change_to_status? && status == "paid" }
|
||||
|
||||
# === Instance Methods ===
|
||||
|
||||
@@ -116,6 +122,11 @@ class Order < ApplicationRecord
|
||||
promoter_payout_cents / 100.0
|
||||
end
|
||||
|
||||
# Check if order contains only free tickets
|
||||
def free?
|
||||
total_amount_cents == 0
|
||||
end
|
||||
|
||||
# Create Stripe invoice for accounting records
|
||||
#
|
||||
# This method creates a post-payment invoice in Stripe for accounting purposes
|
||||
@@ -154,7 +165,26 @@ class Order < ApplicationRecord
|
||||
self.expires_at = DRAFT_EXPIRY_TIME.from_now if expires_at.blank?
|
||||
end
|
||||
|
||||
def set_default_status
|
||||
self.status ||= "draft"
|
||||
self.total_amount_cents ||= 0
|
||||
self.payment_attempts ||= 0
|
||||
end
|
||||
|
||||
def draft?
|
||||
status == "draft"
|
||||
end
|
||||
|
||||
def create_earnings_if_paid
|
||||
return unless event.present? && user.present?
|
||||
return if event.earnings.exists?(order_id: id)
|
||||
|
||||
event.earnings.create!(
|
||||
user: user,
|
||||
order: self,
|
||||
amount_cents: promoter_payout_cents,
|
||||
fee_cents: platform_fee_cents,
|
||||
status: :pending
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
166
app/models/payout.rb
Normal file
166
app/models/payout.rb
Normal file
@@ -0,0 +1,166 @@
|
||||
class Payout < ApplicationRecord
|
||||
# === Relations ===
|
||||
belongs_to :user
|
||||
belongs_to :event
|
||||
belongs_to :processed_by, class_name: "User", optional: true
|
||||
|
||||
# === Enums ===
|
||||
enum :status, {
|
||||
pending: 0, # Payout requested but not reviewed
|
||||
approved: 1, # Payout approved by admin, ready for transfer
|
||||
processing: 2, # Payout being processed (bank transfer initiated)
|
||||
completed: 3, # Payout successfully completed
|
||||
failed: 4, # Payout failed
|
||||
rejected: 5 # Payout rejected by admin
|
||||
}, default: :pending
|
||||
|
||||
# === Validations ===
|
||||
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
|
||||
|
||||
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 :approved, -> { where(status: :approved) }
|
||||
scope :processing, -> { where(status: :processing) }
|
||||
scope :rejected, -> { where(status: :rejected) }
|
||||
scope :failed, -> { where(status: :failed) }
|
||||
scope :eligible_for_payout, -> { joins(:event).where(events: { state: "published" }) }
|
||||
|
||||
# === Callbacks ===
|
||||
after_create :calculate_refunded_orders_count
|
||||
|
||||
# === Instance Methods ===
|
||||
|
||||
# Amount in euros (formatted)
|
||||
def amount_euros
|
||||
amount_cents / 100.0
|
||||
end
|
||||
|
||||
# Fee in euros (formatted)
|
||||
def fee_euros
|
||||
fee_cents / 100.0
|
||||
end
|
||||
|
||||
# Net amount after fees
|
||||
def net_amount_cents
|
||||
amount_cents - fee_cents
|
||||
end
|
||||
|
||||
# Net amount in euros
|
||||
def net_amount_euros
|
||||
net_amount_cents / 100.0
|
||||
end
|
||||
|
||||
# Check if payout can be approved (was pending)
|
||||
def can_approve?
|
||||
pending? && amount_cents > 0 && user.has_complete_banking_info?
|
||||
end
|
||||
|
||||
# Check if payout can be manually processed (was approved)
|
||||
def can_process?
|
||||
approved? && amount_cents > 0
|
||||
end
|
||||
|
||||
# Check if payout can be rejected
|
||||
def can_reject?
|
||||
pending?
|
||||
end
|
||||
|
||||
# Approve the payout for manual processing
|
||||
def approve!(admin_user)
|
||||
return false unless can_approve?
|
||||
update!(
|
||||
status: :approved,
|
||||
processed_by: admin_user,
|
||||
processed_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
# Reject the payout with reason
|
||||
def reject!(admin_user, reason)
|
||||
return false unless can_reject?
|
||||
update!(
|
||||
status: :rejected,
|
||||
processed_by: admin_user,
|
||||
processed_at: Time.current,
|
||||
rejection_reason: reason
|
||||
)
|
||||
end
|
||||
|
||||
# Mark as processing (bank transfer initiated)
|
||||
def mark_processing!(admin_user, transfer_reference = nil)
|
||||
return false unless can_process?
|
||||
update!(
|
||||
status: :processing,
|
||||
processed_by: admin_user,
|
||||
processed_at: Time.current,
|
||||
bank_transfer_reference: transfer_reference
|
||||
)
|
||||
end
|
||||
|
||||
# Mark as completed (bank transfer confirmed)
|
||||
def mark_completed!(admin_user, transfer_reference = nil)
|
||||
return false unless processing?
|
||||
update!(
|
||||
status: :completed,
|
||||
processed_by: admin_user,
|
||||
processed_at: Time.current,
|
||||
bank_transfer_reference: transfer_reference || bank_transfer_reference
|
||||
)
|
||||
update_earnings_status
|
||||
end
|
||||
|
||||
# Mark as failed
|
||||
def mark_failed!(admin_user, reason)
|
||||
return false unless processing?
|
||||
update!(
|
||||
status: :failed,
|
||||
processed_by: admin_user,
|
||||
processed_at: Time.current,
|
||||
rejection_reason: reason
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_earnings_status
|
||||
event.earnings.where(status: 0).update_all(status: 1) # pending to paid
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
def update_earnings_status
|
||||
event.earnings.where(status: 0).update_all(status: 1) # pending to paid
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,7 @@ class Ticket < ApplicationRecord
|
||||
validates :qr_code, presence: true, uniqueness: true
|
||||
validates :order_id, presence: true
|
||||
validates :ticket_type_id, presence: true
|
||||
validates :price_cents, presence: true, numericality: { greater_than: 0 }
|
||||
validates :price_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] }
|
||||
validates :first_name, presence: true
|
||||
validates :last_name, presence: true
|
||||
@@ -19,9 +19,13 @@ class Ticket < ApplicationRecord
|
||||
scope :active, -> { where(status: "active") }
|
||||
scope :expired_drafts, -> { joins(:order).where(status: "draft").where("orders.expires_at < ?", Time.current) }
|
||||
|
||||
# Set default values before validation
|
||||
before_validation :set_defaults, on: :create
|
||||
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 +77,16 @@ class Ticket < ApplicationRecord
|
||||
def draft?
|
||||
status == "draft"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def recalculate_earning_if_refunded
|
||||
if status == "refunded"
|
||||
order.earning&.recalculate!
|
||||
end
|
||||
end
|
||||
|
||||
def set_defaults
|
||||
self.status ||= "draft"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ class TicketType < ApplicationRecord
|
||||
# Validations
|
||||
validates :name, presence: true, length: { minimum: 3, maximum: 50 }
|
||||
validates :description, presence: true, length: { minimum: 10, maximum: 500 }
|
||||
validates :price_cents, presence: true, numericality: { greater_than: 0 }
|
||||
validates :price_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||
validates :sale_start_at, presence: true
|
||||
validates :sale_end_at, presence: true
|
||||
@@ -48,6 +48,10 @@ class TicketType < ApplicationRecord
|
||||
[ quantity - tickets.count, 0 ].max
|
||||
end
|
||||
|
||||
def free?
|
||||
price_cents == 0
|
||||
end
|
||||
|
||||
def sales_status
|
||||
return :draft if sale_start_at.nil? || sale_end_at.nil?
|
||||
return :expired if sale_end_at < Time.current
|
||||
|
||||
@@ -23,12 +23,19 @@ class User < ApplicationRecord
|
||||
has_many :events, dependent: :destroy
|
||||
has_many :tickets, dependent: :destroy
|
||||
has_many :orders, dependent: :destroy
|
||||
has_many :earnings, dependent: :destroy
|
||||
has_many :payouts, dependent: :destroy
|
||||
|
||||
# Validations - allow reasonable name lengths
|
||||
validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
||||
validates :first_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
||||
validates :company_name, length: { minimum: 2, maximum: 100, allow_blank: true }
|
||||
|
||||
# Banking information validations
|
||||
validates :iban, format: { with: /\A[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}\z/, message: "must be a valid IBAN format" }, allow_blank: true
|
||||
validates :bank_name, length: { minimum: 2, maximum: 100 }, allow_blank: true
|
||||
validates :account_holder_name, length: { minimum: 2, maximum: 100 }, allow_blank: true
|
||||
|
||||
# Onboarding methods
|
||||
def needs_onboarding?
|
||||
!onboarding_completed?
|
||||
@@ -48,4 +55,51 @@ class User < ApplicationRecord
|
||||
# Alias for can_manage_events? to make views more semantic
|
||||
can_manage_events?
|
||||
end
|
||||
|
||||
def name
|
||||
[ first_name, last_name ].compact.join(" ").strip
|
||||
end
|
||||
|
||||
# Stripe Connect methods
|
||||
def stripe_account_id
|
||||
stripe_customer_id
|
||||
end
|
||||
|
||||
def has_stripe_account?
|
||||
stripe_customer_id.present?
|
||||
end
|
||||
|
||||
def can_receive_payouts?
|
||||
has_complete_banking_info?
|
||||
end
|
||||
|
||||
# Banking information methods
|
||||
def has_complete_banking_info?
|
||||
iban.present? && bank_name.present? && account_holder_name.present?
|
||||
end
|
||||
|
||||
def banking_info_summary
|
||||
return "No banking information" unless has_complete_banking_info?
|
||||
"#{account_holder_name} - #{bank_name} - #{iban}"
|
||||
end
|
||||
private
|
||||
|
||||
def stripe_connect_verified?
|
||||
return false unless stripe_customer_id.present?
|
||||
|
||||
begin
|
||||
customer = Stripe::Customer.retrieve(stripe_customer_id)
|
||||
customer.present?
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error "Failed to verify Stripe customer #{stripe_customer_id}: #{e.message}"
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Add role method for backward compatibility
|
||||
def add_role(role)
|
||||
# This is a stub for testing - in a real app you'd use a proper role system
|
||||
# For now, we'll just mark users as admin if they have a stripe account
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
83
app/services/payout_service.rb
Normal file
83
app/services/payout_service.rb
Normal file
@@ -0,0 +1,83 @@
|
||||
class PayoutService
|
||||
def initialize(payout)
|
||||
@payout = payout
|
||||
end
|
||||
|
||||
# Check if user is in France or doesn't have a Stripe account (manual processing)
|
||||
def process_with_stripe_or_manual
|
||||
if should_process_manually?
|
||||
process_manually!
|
||||
else
|
||||
process_with_stripe!
|
||||
end
|
||||
end
|
||||
|
||||
# Generate payout summary for manual transfer
|
||||
def generate_transfer_summary
|
||||
return nil unless @payout.approved? || @payout.processing?
|
||||
|
||||
{
|
||||
payout_id: @payout.id,
|
||||
recipient: @payout.user.name,
|
||||
account_holder: @payout.user.account_holder_name,
|
||||
bank_name: @payout.user.bank_name,
|
||||
iban: @payout.user.iban,
|
||||
amount_euros: @payout.net_amount_euros,
|
||||
description: "Payout for event: #{@payout.event.name}",
|
||||
event_name: @payout.event.name,
|
||||
event_date: @payout.event.date,
|
||||
total_orders: @payout.total_orders_count,
|
||||
refunded_orders: @payout.refunded_orders_count
|
||||
}
|
||||
end
|
||||
|
||||
# Validate banking information before processing
|
||||
def validate_banking_info
|
||||
errors = []
|
||||
user = @payout.user
|
||||
|
||||
errors << "Missing IBAN" unless user.iban.present?
|
||||
errors << "Missing bank name" unless user.bank_name.present?
|
||||
errors << "Missing account holder name" unless user.account_holder_name.present?
|
||||
errors << "Invalid IBAN format" if user.iban.present? && !valid_iban?(user.iban)
|
||||
|
||||
errors
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_process_manually?
|
||||
# For now, we'll assume manual processing for all users
|
||||
# In a real implementation, this could check the user's country
|
||||
!@payout.user.has_stripe_account?
|
||||
end
|
||||
|
||||
def process_manually!
|
||||
@payout.update!(status: :processing)
|
||||
|
||||
begin
|
||||
# For manual processing, we just mark it as completed
|
||||
# In a real implementation, this would trigger notifications to admin
|
||||
@payout.mark_completed!(User.admin.first || User.first, "Manual processing completed")
|
||||
|
||||
Rails.logger.info "Manual payout processed for payout #{@payout.id} for event #{@payout.event.name}"
|
||||
rescue => e
|
||||
@payout.update!(status: :failed)
|
||||
Rails.logger.error "Manual payout failed for payout #{@payout.id}: #{e.message}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
|
||||
def process_with_stripe!
|
||||
@payout.update!(status: :processing)
|
||||
end
|
||||
|
||||
def valid_iban?(iban)
|
||||
# Basic IBAN validation (simplified)
|
||||
iban.match?(/\A[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}\z/)
|
||||
end
|
||||
|
||||
def update_earnings_status
|
||||
@payout.event.earnings.where(status: 0).update_all(status: 1) # pending to paid
|
||||
end
|
||||
end
|
||||
35
app/services/stripe_connect_service.rb
Normal file
35
app/services/stripe_connect_service.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class StripeConnectService
|
||||
def self.create_account(user)
|
||||
return if user.stripe_connected_account_id.present?
|
||||
|
||||
account = Stripe::Account.create(
|
||||
type: "express",
|
||||
country: "FR",
|
||||
email: user.email,
|
||||
capabilities: {
|
||||
card_payments: { requested: true },
|
||||
transfers: { requested: true }
|
||||
}
|
||||
)
|
||||
|
||||
user.update!(stripe_connected_account_id: account.id)
|
||||
account
|
||||
end
|
||||
|
||||
def self.onboarding_link(user)
|
||||
return unless user.stripe_connected_account_id.present?
|
||||
|
||||
account_link = Stripe::AccountLink.create(
|
||||
account: user.stripe_connected_account_id,
|
||||
refresh_url: Rails.application.routes.url_helpers.promoter_stripe_refresh_url,
|
||||
return_url: Rails.application.routes.url_helpers.promoter_stripe_return_url,
|
||||
type: "account_onboarding"
|
||||
)
|
||||
|
||||
account_link.url
|
||||
end
|
||||
|
||||
def self.get_account_details(account_id)
|
||||
Stripe::Account.retrieve(account_id)
|
||||
end
|
||||
end
|
||||
96
app/views/admin/payouts/_payout_table.html.erb
Normal file
96
app/views/admin/payouts/_payout_table.html.erb
Normal file
@@ -0,0 +1,96 @@
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Promoter</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Banking Info</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<% if show_actions %>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<% payouts.each do |payout| %>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900"><%= payout.event.name %></div>
|
||||
<div class="text-sm text-gray-500"><%= payout.event.date.strftime("%b %d, %Y") if payout.event.date %></div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-900"><%= payout.user.name.presence || payout.user.email %></div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<% if payout.user.has_complete_banking_info? %>
|
||||
<div class="text-sm text-gray-900">✅ Complete</div>
|
||||
<div class="text-sm text-gray-500"><%= payout.user.bank_name %></div>
|
||||
<% else %>
|
||||
<div class="text-sm text-red-600">❌ Incomplete</div>
|
||||
<div class="text-sm text-gray-500">Missing banking info</div>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-900">€<%= payout.amount_euros %></div>
|
||||
<div class="text-sm text-gray-500">Net: €<%= payout.net_amount_euros %></div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<% case payout.status %>
|
||||
<% when 'pending' %>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Pending Review
|
||||
</span>
|
||||
<% when 'approved' %>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
Approved
|
||||
</span>
|
||||
<% when 'processing' %>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-indigo-100 text-indigo-800">
|
||||
Processing
|
||||
</span>
|
||||
<% when 'completed' %>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Completed
|
||||
</span>
|
||||
<% when 'failed' %>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Failed
|
||||
</span>
|
||||
<% when 'rejected' %>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
Rejected
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= payout.created_at.strftime("%b %d, %Y") %>
|
||||
<% if payout.processed_at %>
|
||||
<div class="text-xs text-gray-400">Processed: <%= payout.processed_at.strftime("%b %d") %></div>
|
||||
<% end %>
|
||||
</td>
|
||||
<% if show_actions %>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<%= link_to "View", admin_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900" %>
|
||||
<% case section %>
|
||||
<% when 'pending' %>
|
||||
<% if payout.can_approve? %>
|
||||
<%= link_to "Approve", approve_admin_payout_path(payout), method: :post,
|
||||
class: "text-green-600 hover:text-green-900 ml-2",
|
||||
data: { confirm: "Approve this payout for transfer?" } %>
|
||||
<% end %>
|
||||
<% when 'approved' %>
|
||||
<%= link_to "Start Transfer", mark_processing_admin_payout_path(payout), method: :post,
|
||||
class: "text-blue-600 hover:text-blue-900 ml-2",
|
||||
data: { confirm: "Mark as processing (transfer initiated)?" } %>
|
||||
<% when 'processing' %>
|
||||
<%= link_to "Complete", mark_completed_admin_payout_path(payout), method: :post,
|
||||
class: "text-green-600 hover:text-green-900 ml-2",
|
||||
data: { confirm: "Mark transfer as completed?" } %>
|
||||
<% end %>
|
||||
</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
2
app/views/admin/payouts/create.html.erb
Normal file
2
app/views/admin/payouts/create.html.erb
Normal file
@@ -0,0 +1,2 @@
|
||||
<h1>Admin::Payouts#create</h1>
|
||||
<p>Find me in app/views/admin/payouts/create.html.erb</p>
|
||||
53
app/views/admin/payouts/index.html.erb
Normal file
53
app/views/admin/payouts/index.html.erb
Normal file
@@ -0,0 +1,53 @@
|
||||
<%= render 'shared/admin_nav' %>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Manual Payout Administration</h1>
|
||||
</div>
|
||||
|
||||
<!-- Pending Payouts - Require Review -->
|
||||
<% if @pending_payouts.any? %>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">📋 Pending Review (<%= @pending_payouts.count %>)</h2>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<%= render partial: 'payout_table', locals: { payouts: @pending_payouts, show_actions: true, section: 'pending' } %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Approved Payouts - Ready for Transfer -->
|
||||
<% if @approved_payouts.any? %>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">✅ Approved - Ready for Transfer (<%= @approved_payouts.count %>)</h2>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<%= render partial: 'payout_table', locals: { payouts: @approved_payouts, show_actions: true, section: 'approved' } %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Processing Payouts - Transfer Initiated -->
|
||||
<% if @processing_payouts.any? %>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">🔄 Processing - Transfer in Progress (<%= @processing_payouts.count %>)</h2>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<%= render partial: 'payout_table', locals: { payouts: @processing_payouts, show_actions: true, section: 'processing' } %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Recent Completed Payouts -->
|
||||
<% if @completed_payouts.any? %>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">✨ Recently Completed</h2>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<%= render partial: 'payout_table', locals: { payouts: @completed_payouts, show_actions: false, section: 'completed' } %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @pending_payouts.empty? && @approved_payouts.empty? && @processing_payouts.empty? && @completed_payouts.empty? %>
|
||||
<div class="bg-white rounded-lg shadow p-6 text-center">
|
||||
<p class="text-gray-500">No payouts found.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
2
app/views/admin/payouts/new.html.erb
Normal file
2
app/views/admin/payouts/new.html.erb
Normal file
@@ -0,0 +1,2 @@
|
||||
<h1>Admin::Payouts#new</h1>
|
||||
<p>Find me in app/views/admin/payouts/new.html.erb</p>
|
||||
221
app/views/admin/payouts/show.html.erb
Normal file
221
app/views/admin/payouts/show.html.erb
Normal file
@@ -0,0 +1,221 @@
|
||||
<%= render 'shared/admin_nav' %>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Payout Details #<%= @payout.id %></h1>
|
||||
<%= link_to "← Back to Payouts", admin_payouts_path, class: "text-indigo-600 hover:text-indigo-900" %>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Payout Information -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Payout Information</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Status</label>
|
||||
<% case @payout.status %>
|
||||
<% when 'pending' %>
|
||||
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Pending Review
|
||||
</span>
|
||||
<% when 'approved' %>
|
||||
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
Approved - Ready for Transfer
|
||||
</span>
|
||||
<% when 'processing' %>
|
||||
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-indigo-100 text-indigo-800">
|
||||
Processing
|
||||
</span>
|
||||
<% when 'completed' %>
|
||||
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Completed
|
||||
</span>
|
||||
<% when 'failed' %>
|
||||
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Failed
|
||||
</span>
|
||||
<% when 'rejected' %>
|
||||
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
Rejected
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Event</label>
|
||||
<p class="text-gray-900"><%= @payout.event.name %></p>
|
||||
<p class="text-sm text-gray-500"><%= @payout.event.date.strftime("%B %d, %Y") if @payout.event.date %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Promoter</label>
|
||||
<p class="text-gray-900"><%= @payout.user.name.presence || @payout.user.email %></p>
|
||||
<p class="text-sm text-gray-500"><%= @payout.user.email %></p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Gross Amount</label>
|
||||
<p class="text-lg font-semibold text-gray-900">€<%= @payout.amount_euros %></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Platform Fee</label>
|
||||
<p class="text-lg font-semibold text-gray-900">€<%= @payout.fee_euros %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Net Amount (To Transfer)</label>
|
||||
<p class="text-2xl font-bold text-green-600">€<%= @payout.net_amount_euros %></p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Total Orders</label>
|
||||
<p class="text-gray-900"><%= @payout.total_orders_count %></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Refunded Orders</label>
|
||||
<p class="text-gray-900"><%= @payout.refunded_orders_count %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Requested</label>
|
||||
<p class="text-gray-900"><%= @payout.created_at.strftime("%B %d, %Y at %I:%M %p") %></p>
|
||||
</div>
|
||||
|
||||
<% if @payout.processed_at %>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Processed</label>
|
||||
<p class="text-gray-900"><%= @payout.processed_at.strftime("%B %d, %Y at %I:%M %p") %></p>
|
||||
<% if @payout.processed_by %>
|
||||
<p class="text-sm text-gray-500">by <%= @payout.processed_by.name.presence || @payout.processed_by.email %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @payout.bank_transfer_reference.present? %>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Transfer Reference</label>
|
||||
<p class="text-gray-900 font-mono"><%= @payout.bank_transfer_reference %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @payout.rejection_reason.present? %>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Rejection/Failure Reason</label>
|
||||
<p class="text-red-600"><%= @payout.rejection_reason %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Banking Information -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Banking Information</h2>
|
||||
|
||||
<% if @banking_errors.any? %>
|
||||
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<h3 class="text-sm font-medium text-red-800">Banking Information Issues:</h3>
|
||||
<ul class="mt-2 text-sm text-red-700">
|
||||
<% @banking_errors.each do |error| %>
|
||||
<li>• <%= error %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @transfer_summary %>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Account Holder</label>
|
||||
<p class="text-gray-900"><%= @transfer_summary[:account_holder] %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">Bank Name</label>
|
||||
<p class="text-gray-900"><%= @transfer_summary[:bank_name] %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500">IBAN</label>
|
||||
<p class="text-gray-900 font-mono"><%= @transfer_summary[:iban] %></p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<h3 class="text-sm font-medium text-blue-800">Transfer Instructions</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p><strong>Amount:</strong> €<%= @transfer_summary[:amount_euros] %></p>
|
||||
<p><strong>Reference:</strong> Payout #<%= @transfer_summary[:payout_id] %> - <%= @transfer_summary[:event_name] %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center text-gray-500 py-8">
|
||||
<p>Banking information not available for display.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-8 bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Actions</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<% if @payout.can_approve? %>
|
||||
<%= button_to "✅ Approve Payout", approve_admin_payout_path(@payout), method: :post,
|
||||
class: "bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md font-medium",
|
||||
data: { confirm: "Approve this payout for manual bank transfer?" } %>
|
||||
<% end %>
|
||||
|
||||
<% if @payout.can_reject? %>
|
||||
<%= form_with url: reject_admin_payout_path(@payout), method: :post, local: true, class: "flex gap-2" do |form| %>
|
||||
<%= form.text_field :rejection_reason, placeholder: "Rejection reason...", required: true,
|
||||
class: "border border-gray-300 rounded-md px-3 py-2" %>
|
||||
<%= form.submit "❌ Reject", class: "bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium",
|
||||
data: { confirm: "Reject this payout?" } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @payout.can_process? %>
|
||||
<%= form_with url: mark_processing_admin_payout_path(@payout), method: :post, local: true, class: "flex gap-2" do |form| %>
|
||||
<%= form.text_field :bank_transfer_reference, placeholder: "Transfer reference (optional)",
|
||||
class: "border border-gray-300 rounded-md px-3 py-2" %>
|
||||
<%= form.submit "🔄 Mark as Processing", class: "bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium",
|
||||
data: { confirm: "Mark as processing (bank transfer initiated)?" } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @payout.processing? %>
|
||||
<%= form_with url: mark_completed_admin_payout_path(@payout), method: :post, local: true, class: "flex gap-2" do |form| %>
|
||||
<%= form.text_field :bank_transfer_reference, placeholder: "Final transfer reference",
|
||||
value: @payout.bank_transfer_reference,
|
||||
class: "border border-gray-300 rounded-md px-3 py-2" %>
|
||||
<%= form.submit "✅ Mark as Completed", class: "bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md font-medium",
|
||||
data: { confirm: "Confirm transfer completion?" } %>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: mark_failed_admin_payout_path(@payout), method: :post, local: true, class: "flex gap-2" do |form| %>
|
||||
<%= form.text_field :failure_reason, placeholder: "Failure reason...", required: true,
|
||||
class: "border border-gray-300 rounded-md px-3 py-2" %>
|
||||
<%= form.submit "❌ Mark as Failed", class: "bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium",
|
||||
data: { confirm: "Mark transfer as failed?" } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @payout.pending? || @payout.processing? %>
|
||||
<%= button_to "Process Payout", admin_payout_path(@payout), method: :post,
|
||||
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 %>
|
||||
|
||||
<% if @payout.pending? || @payout.processing? %>
|
||||
<%= button_to "Mark as Manually Processed", mark_as_manually_processed_admin_payout_path(@payout), method: :post,
|
||||
class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500",
|
||||
data: { confirm: "Are you sure you want to mark this payout as manually processed? This will notify the promoter that the bank transfer is being processed." } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xl font-bold text-purple-700 <%= "text-gray-400" if sold_out %>">
|
||||
<%= number_to_currency(price_cents / 100.0, unit: "€") %>
|
||||
<%= format_ticket_price(price_cents) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
<div class="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-50 py-12 px-4 sm:px-6">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="bg-gradient-to-r from-purple-600 to-indigo-700 px-6 py-8 text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-16 h-16 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Paiement réussi !</h1>
|
||||
<p class="text-purple-100">Félicitations pour votre achat</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 sm:p-8">
|
||||
<div class="text-center mb-8">
|
||||
<p class="text-xl text-gray-700">
|
||||
Vos billets pour <span class="font-bold text-purple-700"><%= @event.name %></span> ont été achetés avec succès.
|
||||
</p>
|
||||
<p class="text-gray-500 mt-2">
|
||||
Un email de confirmation avec vos billets a été envoyé à <span class="font-medium"><%= current_user.email %></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Event Details -->
|
||||
<div class="bg-gray-50 rounded-xl p-6 mb-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Détails de l'événement
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="flex items-center p-3 bg-white rounded-lg">
|
||||
<svg class="w-5 h-5 text-purple-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">Lieu</p>
|
||||
<p class="font-medium"><%= @event.venue_name %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-3 bg-white rounded-lg">
|
||||
<svg class="w-5 h-5 text-purple-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">Date & Heure</p>
|
||||
<p class="font-medium"><%= @event.start_time.strftime("%d %B %Y à %H:%M") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tickets -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
|
||||
</svg>
|
||||
Vos billets
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% @tickets.each do |ticket| %>
|
||||
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-100 p-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center mr-4">
|
||||
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-900"><%= ticket.ticket_type.name %></h3>
|
||||
<p class="text-sm text-gray-600">Prix: <span class="font-medium"><%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %></span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= link_to ticket_download_path(ticket.qr_code, format: :pdf),
|
||||
class: "inline-flex items-center px-4 py-2 btn btn-primary rounded-lg transition-all duration-200 text-sm font-medium shadow-sm" do %>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Télécharger PDF
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-purple-100 flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 text-gray-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
<span class="text-xs text-gray-500">Code QR: <%= ticket.qr_code[0..7] %></span>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Actif
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Important Notice -->
|
||||
<div class="bg-blue-50 border border-blue-100 rounded-xl p-5 mb-8">
|
||||
<div class="flex">
|
||||
<svg class="w-5 h-5 text-blue-500 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold text-blue-800 mb-1">Important</h3>
|
||||
<p class="text-sm text-blue-700">
|
||||
Veuillez télécharger et sauvegarder vos billets. Présentez-les à l'entrée du lieu pour accéder à l'événement.
|
||||
Un email de confirmation avec vos billets a été envoyé à votre adresse email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<%= link_to dashboard_path,
|
||||
class: "inline-flex items-center justify-center px-6 py-3 btn btn-primary rounded-xl transition-all duration-200 font-medium shadow-sm" do %>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
</svg>
|
||||
Tableau de bord
|
||||
<% end %>
|
||||
|
||||
<%= link_to events_path,
|
||||
class: "inline-flex items-center justify-center px-6 py-3 bg-white text-gray-700 rounded-xl border border-gray-300 hover:bg-gray-50 transition-all duration-200 font-medium shadow-sm" do %>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Voir plus d'événements
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,11 +135,9 @@
|
||||
controller: "ticket-selection",
|
||||
ticket_selection_target: "form",
|
||||
ticket_selection_event_slug_value: @event.slug,
|
||||
ticket_selection_event_id_value: @event.id,
|
||||
ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id),
|
||||
ticket_selection_store_cart_url_value: api_v1_store_cart_path,
|
||||
ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id),
|
||||
ticket_selection_store_cart_url_value: api_v1_store_cart_path
|
||||
ticket_selection_event_id_value: @event.id,
|
||||
ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id),
|
||||
ticket_selection_store_cart_url_value: api_v1_store_cart_path
|
||||
} do |form| %>
|
||||
|
||||
<div class="bg-gradient-to-br from-purple-50 to-indigo-50 rounded-2xl border border-purple-100 p-6 shadow-sm">
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
<!-- Order Total -->
|
||||
<div class=" pt-12">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
|
||||
<div class="flex items-center justify-between text-lg pt-2">
|
||||
<span class="font-medium text-gray-900">Total</span>
|
||||
<span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
<!-- Total -->
|
||||
<div class="mt-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
|
||||
<div class="flex items-center justify-between text-lg pt-2">
|
||||
<span class="font-medium text-gray-900">Total payé</span>
|
||||
<span class="font-bold text-2xl text-green-600">
|
||||
<%= @order.total_amount_euros %>€
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
<!-- Total -->
|
||||
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
|
||||
<div class="flex items-center justify-between text-lg pt-2">
|
||||
<span class="font-medium text-gray-900">Total <%= @order.status == 'paid' || @order.status == 'completed' ? 'payé' : 'à payer' %></span>
|
||||
<span class="font-bold text-2xl <%= @order.status == 'paid' || @order.status == 'completed' ? 'text-green-600' : 'text-purple-600' %>">
|
||||
<%= @order.total_amount_euros %>€
|
||||
@@ -212,4 +212,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<!-- Promoter Dashboard Section -->
|
||||
<% if current_user.promoter? && @promoter_events.present? %>
|
||||
<!-- Promoter Metrics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
|
||||
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-2xl p-6 border border-green-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -87,6 +87,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payout Summary -->
|
||||
<div class="bg-gradient-to-br from-indigo-50 to-indigo-100 rounded-2xl p-6 border border-indigo-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-indigo-600 text-sm font-medium">Paiements en Attente</p>
|
||||
<p class="text-2xl font-bold text-indigo-900"><%= current_user.payouts.pending.count %></p>
|
||||
</div>
|
||||
<div class="bg-indigo-200 rounded-full p-3">
|
||||
<i data-lucide="dollar-sign" class="w-6 h-6 text-indigo-700"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenue Chart & Recent Events -->
|
||||
@@ -147,11 +160,51 @@
|
||||
<%= event.tickets.where(status: 'active').count %> billets vendus
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex space-x-2">
|
||||
<%= link_to promoter_event_path(event), class: "text-purple-600 hover:text-purple-800 text-xs font-medium" do %>
|
||||
Gérer →
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<%= link_to promoter_event_path(event), class: "text-purple-600 hover:text-purple-800 text-xs font-medium" do %>
|
||||
Gérer →
|
||||
<% end %>
|
||||
<% if event.event_ended? && event.can_request_payout? %>
|
||||
<% if event.payout_status == "not_requested" %>
|
||||
<%= link_to "Demander le paiement", promoter_payouts_path(event_id: event.id), method: :post,
|
||||
class: "text-green-600 hover:text-green-800 text-xs font-medium inline-flex items-center",
|
||||
data: { confirm: "Êtes-vous sûr de vouloir demander le paiement de €#{event.net_earnings_cents / 100.0} ?" } do %>
|
||||
<i data-lucide="dollar-sign" class="w-3 h-3 mr-1"></i>
|
||||
Paiement
|
||||
<% end %>
|
||||
<% elsif event.payout_status == "requested" %>
|
||||
<%= link_to "Paiement demandé", promoter_payouts_path,
|
||||
class: "text-yellow-600 hover:text-yellow-800 text-xs font-medium inline-flex items-center" do %>
|
||||
<i data-lucide="clock" class="w-3 h-3 mr-1"></i>
|
||||
En attente
|
||||
<% end %>
|
||||
<% elsif event.payout_status == "processing" %>
|
||||
<%= link_to "Paiement en cours", promoter_payouts_path,
|
||||
class: "text-blue-600 hover:text-blue-800 text-xs font-medium inline-flex items-center" do %>
|
||||
<i data-lucide="refresh-cw" class="w-3 h-3 mr-1"></i>
|
||||
Traitement
|
||||
<% end %>
|
||||
<% elsif event.payout_status == "completed" %>
|
||||
<%= link_to "Paiement effectué", promoter_payouts_path,
|
||||
class: "text-green-600 hover:text-green-800 text-xs font-medium inline-flex items-center" do %>
|
||||
<i data-lucide="check-circle" class="w-3 h-3 mr-1"></i>
|
||||
Complété
|
||||
<% end %>
|
||||
<% elsif event.payout_status == "failed" %>
|
||||
<%= link_to "Paiement échoué", promoter_payouts_path(event_id: event.id), method: :post,
|
||||
class: "text-red-600 hover:text-red-800 text-xs font-medium inline-flex items-center",
|
||||
data: { confirm: "Êtes-vous sûr de vouloir redemander le paiement ?" } do %>
|
||||
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
|
||||
Réessayer
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% elsif event.event_ended? %>
|
||||
<span class="text-gray-500 text-xs font-medium inline-flex items-center">
|
||||
<i data-lucide="dollar-sign" class="w-3 h-3 mr-1"></i>
|
||||
Aucun revenu
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -165,37 +218,84 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Orders -->
|
||||
<% if @recent_orders.any? %>
|
||||
<!-- Ended Events Requiring Payout -->
|
||||
<% ended_events = @promoter_events.select(&:event_ended?) %>
|
||||
<% if ended_events.any? %>
|
||||
<div class="bg-white rounded-2xl shadow-lg mb-8">
|
||||
<div class="border-b border-gray-100 p-6">
|
||||
<h2 class="text-xl font-bold text-gray-900">Commandes Récentes</h2>
|
||||
<p class="text-gray-600 mt-1">Dernières commandes pour vos événements</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900">Événements Terminés</h2>
|
||||
<%= link_to "Voir tous les paiements", promoter_payouts_path, class: "text-purple-600 hover:text-purple-800 font-medium text-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="text-left border-b border-gray-200">
|
||||
<th class="pb-3 text-sm font-medium text-gray-600">Événement</th>
|
||||
<th class="pb-3 text-sm font-medium text-gray-600">Client</th>
|
||||
<th class="pb-3 text-sm font-medium text-gray-600">Billets</th>
|
||||
<th class="pb-3 text-sm font-medium text-gray-600">Montant</th>
|
||||
<th class="pb-3 text-sm font-medium text-gray-600">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<% @recent_orders.each do |order| %>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="py-3 text-sm font-medium text-gray-900"><%= order.event.name %></td>
|
||||
<td class="py-3 text-sm text-gray-700"><%= order.user.email %></td>
|
||||
<td class="py-3 text-sm text-gray-700"><%= order.tickets.count %></td>
|
||||
<td class="py-3 text-sm font-medium text-gray-900">€<%= order.total_amount_euros %></td>
|
||||
<td class="py-3 text-sm text-gray-500"><%= order.created_at.strftime("%d/%m/%Y") %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="space-y-4">
|
||||
<% ended_events.each do |event| %>
|
||||
<div class="border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900 text-sm"><%= event.name %></h4>
|
||||
<p class="text-xs text-gray-500 mt-1">Terminé le <%= event.end_time&.strftime("%d %B %Y") || event.start_time&.strftime("%d %B %Y") %></p>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-800">
|
||||
Terminé
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 space-y-1">
|
||||
<div class="flex items-center">
|
||||
<i data-lucide="ticket" class="w-3 h-3 mr-2"></i>
|
||||
<%= event.tickets.where(status: 'active').count %> billets vendus
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i data-lucide="euro" class="w-3 h-3 mr-2"></i>
|
||||
Revenus: €<%= event.net_earnings_cents / 100.0 %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<%= link_to promoter_event_path(event), class: "text-purple-600 hover:text-purple-800 text-xs font-medium" do %>
|
||||
Voir l'événement →
|
||||
<% end %>
|
||||
|
||||
<% if event.can_request_payout? %>
|
||||
<% if event.payout_status == "not_requested" %>
|
||||
<%= link_to "Demander le paiement", promoter_payouts_path(event_id: event.id), method: :post,
|
||||
class: "inline-flex items-center px-3 py-1 bg-green-600 text-white text-xs font-medium rounded-lg hover:bg-green-700 transition-colors",
|
||||
data: { confirm: "Êtes-vous sûr de vouloir demander le paiement de €#{event.net_earnings_cents / 100.0} ?" } do %>
|
||||
<i data-lucide="dollar-sign" class="w-3 h-3 mr-1"></i>
|
||||
Demander paiement
|
||||
<% end %>
|
||||
<% elsif event.payout_status == "requested" %>
|
||||
<%= link_to promoter_payouts_path, class: "inline-flex items-center px-3 py-1 bg-yellow-600 text-white text-xs font-medium rounded-lg" do %>
|
||||
<i data-lucide="clock" class="w-3 h-3 mr-1"></i>
|
||||
En attente
|
||||
<% end %>
|
||||
<% elsif event.payout_status == "processing" %>
|
||||
<%= link_to promoter_payouts_path, class: "inline-flex items-center px-3 py-1 bg-blue-600 text-white text-xs font-medium rounded-lg" do %>
|
||||
<i data-lucide="refresh-cw" class="w-3 h-3 mr-1"></i>
|
||||
En traitement
|
||||
<% end %>
|
||||
<% elsif event.payout_status == "completed" %>
|
||||
<%= link_to promoter_payouts_path, class: "inline-flex items-center px-3 py-1 bg-green-600 text-white text-xs font-medium rounded-lg" do %>
|
||||
<i data-lucide="check-circle" class="w-3 h-3 mr-1"></i>
|
||||
Payé
|
||||
<% end %>
|
||||
<% elsif event.payout_status == "failed" %>
|
||||
<%= link_to "Réessayer", promoter_payouts_path(event_id: event.id), method: :post,
|
||||
class: "inline-flex items-center px-3 py-1 bg-red-600 text-white text-xs font-medium rounded-lg hover:bg-red-700 transition-colors",
|
||||
data: { confirm: "Êtes-vous sûr de vouloir redemander le paiement ?" } do %>
|
||||
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
|
||||
Réessayer
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="text-gray-500 text-xs font-medium inline-flex items-center">
|
||||
<i data-lucide="dollar-sign" class="w-3 h-3 mr-1"></i>
|
||||
Aucun revenu
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
47
app/views/promoter/events/_earnings_preview.html.erb
Normal file
47
app/views/promoter/events/_earnings_preview.html.erb
Normal file
@@ -0,0 +1,47 @@
|
||||
<% if @event.can_request_payout? %>
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-lg font-medium text-gray-900">Aperçu des Revenus</h4>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<!-- Gross -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm font-medium text-gray-500">Revenus Bruts</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
<%= number_to_currency(@event.total_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Fees -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm font-medium text-gray-500">Frais Plateforme</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
-<%= number_to_currency(@event.total_fees_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Net -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm font-medium text-gray-500">Revenus Nets</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
<%= number_to_currency(@event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% 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 %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
|
||||
<p class="text-sm text-yellow-800">Non éligible à la demande de paiement. L'événement n'est peut-être pas terminé ou le compte Stripe n'est pas vérifié.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -84,6 +84,36 @@
|
||||
À la une
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<% if event.event_ended? && event.can_request_payout? %>
|
||||
<% case event.payout_status %>
|
||||
<% when "not_requested" %>
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-orange-100 text-orange-800 ml-1">
|
||||
<i data-lucide="dollar-sign" class="w-3 h-3 mr-1"></i>
|
||||
Paiement disponible
|
||||
</span>
|
||||
<% when "requested" %>
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800 ml-1">
|
||||
<i data-lucide="clock" class="w-3 h-3 mr-1"></i>
|
||||
Paiement demandé
|
||||
</span>
|
||||
<% when "processing" %>
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800 ml-1">
|
||||
<i data-lucide="refresh-cw" class="w-3 h-3 mr-1"></i>
|
||||
Paiement en cours
|
||||
</span>
|
||||
<% when "completed" %>
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 ml-1">
|
||||
<i data-lucide="check-circle" class="w-3 h-3 mr-1"></i>
|
||||
Paiement effectué
|
||||
</span>
|
||||
<% when "failed" %>
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800 ml-1">
|
||||
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
|
||||
Paiement échoué
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<% if event.start_time %>
|
||||
|
||||
@@ -58,16 +58,30 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% elsif @event.published? %>
|
||||
<%= button_to unpublish_promoter_event_path(@event), method: :patch, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-yellow-600 text-white font-medium rounded-lg hover:bg-yellow-700 transition-colors duration-200" do %>
|
||||
<i data-lucide="download" class="w-4 h-4 mr-2"></i>
|
||||
Dépublier
|
||||
<% if @event.event_ended? %>
|
||||
<%= button_to unpublish_promoter_event_path(@event), method: :patch, disabled: true, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-gray-400 text-white font-medium rounded-lg cursor-not-allowed transition-colors duration-200", title: "Impossible de dépublier un événement terminé" do %>
|
||||
<i data-lucide="download" class="w-4 h-4 mr-2"></i>
|
||||
Dépublier
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= button_to unpublish_promoter_event_path(@event), method: :patch, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-yellow-600 text-white font-medium rounded-lg hover:bg-yellow-700 transition-colors duration-200" do %>
|
||||
<i data-lucide="download" class="w-4 h-4 mr-2"></i>
|
||||
Dépublier
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @event.published? %>
|
||||
<%= button_to cancel_promoter_event_path(@event), method: :patch, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors duration-200", data: { confirm: "Êtes-vous sûr de vouloir annuler cet événement ?" } do %>
|
||||
<i data-lucide="x-circle" class="w-4 h-4 mr-2"></i>
|
||||
Annuler
|
||||
<% if @event.event_ended? %>
|
||||
<%= button_to cancel_promoter_event_path(@event), method: :patch, disabled: true, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-gray-400 text-white font-medium rounded-lg cursor-not-allowed transition-colors duration-200", title: "Impossible d'annuler un événement terminé", data: { confirm: "Êtes-vous sûr de vouloir annuler cet événement ?" } do %>
|
||||
<i data-lucide="x-circle" class="w-4 h-4 mr-2"></i>
|
||||
Annuler
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= button_to cancel_promoter_event_path(@event), method: :patch, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors duration-200", data: { confirm: "Êtes-vous sûr de vouloir annuler cet événement ?" } do %>
|
||||
<i data-lucide="x-circle" class="w-4 h-4 mr-2"></i>
|
||||
Annuler
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -132,10 +146,14 @@
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-2xl p-4">
|
||||
<div class="flex items-center">
|
||||
<i data-lucide="users" class="w-5 h-5 text-blue-400 mr-3"></i>
|
||||
<div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-medium text-blue-900">Événement complet</h3>
|
||||
<p class="text-sm text-blue-700">Tous les billets pour cet événement ont été vendus.</p>
|
||||
</div>
|
||||
<%= button_to mark_available_promoter_event_path(@event), method: :patch, class: "ml-4 inline-flex items-center px-3 py-1 bg-white border border-blue-300 text-blue-700 text-sm font-medium rounded-lg hover:bg-blue-50 transition-colors duration-200" do %>
|
||||
<i data-lucide="refresh-ccw" class="w-4 h-4 mr-1"></i>
|
||||
Marquer comme disponible
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -201,6 +219,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenue Overview -->
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
|
||||
<%= render 'earnings_preview' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
@@ -273,10 +296,19 @@
|
||||
<i data-lucide="ticket" class="w-4 h-4 mr-2"></i>
|
||||
Gérer les types de billets
|
||||
<% end %>
|
||||
<%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-gray-50 text-gray-700 font-medium text-sm rounded-lg hover:bg-gray-100 transition-colors duration-200", disabled: !@event.published? do %>
|
||||
<i data-lucide="users" class="w-4 h-4 mr-2"></i>
|
||||
Marquer comme complet
|
||||
|
||||
<% 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 %>
|
||||
<i data-lucide="refresh-ccw" class="w-4 h-4 mr-2"></i>
|
||||
Marquer comme disponible
|
||||
<% end %>
|
||||
<% elsif @event.published? %>
|
||||
<%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-gray-50 text-gray-700 font-medium text-sm rounded-lg hover:bg-gray-100 transition-colors duration-200" do %>
|
||||
<i data-lucide="users" class="w-4 h-4 mr-2"></i>
|
||||
Marquer comme complet
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<hr class="border-gray-200">
|
||||
<%= 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." },
|
||||
|
||||
2
app/views/promoter/payouts/create.html.erb
Normal file
2
app/views/promoter/payouts/create.html.erb
Normal file
@@ -0,0 +1,2 @@
|
||||
<h1>Promoter::Payouts#create</h1>
|
||||
<p>Find me in app/views/promoter/payouts/create.html.erb</p>
|
||||
205
app/views/promoter/payouts/index.html.erb
Normal file
205
app/views/promoter/payouts/index.html.erb
Normal file
@@ -0,0 +1,205 @@
|
||||
<% content_for(:title, "Payouts") %>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Payout History</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">View and track all your payout requests</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<% if @payouts.any? %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Total Payouts -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-blue-100 rounded-lg">
|
||||
<i data-lucide="dollar-sign" class="w-6 h-6 text-blue-600"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Total Payouts</p>
|
||||
<p class="text-2xl font-bold text-gray-900"><%= @payouts.count %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Amount -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-green-100 rounded-lg">
|
||||
<i data-lucide="trending-up" class="w-6 h-6 text-green-600"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Total Earned</p>
|
||||
<p class="text-2xl font-bold text-gray-900">€<%= @payouts.sum(&:net_amount_cents) / 100.0 %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Payouts -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-yellow-100 rounded-lg">
|
||||
<i data-lucide="clock" class="w-6 h-6 text-yellow-600"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Pending</p>
|
||||
<p class="text-2xl font-bold text-gray-900"><%= @payouts.pending.count %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Pending Earnings Section -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Pending Earnings</h2>
|
||||
|
||||
<% if @total_pending_net && @total_pending_net > 0 %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-yellow-100 rounded-lg">
|
||||
<i data-lucide="dollar-sign" class="w-6 h-6 text-yellow-600"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Total Pending Net</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
<%= number_to_currency(@total_pending_net / 100.0, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @eligible_events.present? && @eligible_events.any? %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<% @eligible_events.limit(5).each do |event| %>
|
||||
<div class="bg-white p-6 rounded-lg shadow border border-gray-200">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="calendar" class="h-5 w-5 text-white"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-bold text-gray-900"><%= event.name %></h3>
|
||||
<p class="text-sm text-gray-500"><%= event.start_time.strftime("%d %b %Y") %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm">
|
||||
<p><span class="font-medium">Gross:</span> <%= number_to_currency(event.total_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %></p>
|
||||
<p><span class="font-medium">Net:</span> <%= number_to_currency(event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %></p>
|
||||
</div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @eligible_events.size > 5 %>
|
||||
<div class="text-center mt-4">
|
||||
<%= link_to "View All Eligible Events", promoter_events_path, class: "text-indigo-600 hover:text-indigo-500 text-sm font-medium" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<i data-lucide="inbox" class="mx-auto h-12 w-12 text-gray-400 mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No pending earnings</h3>
|
||||
<p class="text-gray-500">Check your events to see if any are eligible for payout requests.</p>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Payouts Table -->
|
||||
<% if @payouts.any? %>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200 payout-table-row">
|
||||
<% @payouts.each do |payout| %>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="calendar" class="h-5 w-5 text-white"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-medium text-gray-900"><%= payout.event&.name || "Event not found" %></div>
|
||||
<div class="text-sm text-gray-500">#<%= payout.id %></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">€<%= payout.net_amount_euros %></div>
|
||||
<div class="text-sm text-gray-500">Gross: €<%= payout.amount_euros %></div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<% case payout.status %>
|
||||
<% when 'pending' %>
|
||||
<span class="payout-status-badge pending">
|
||||
<i data-lucide="clock" class="w-3 h-3 mr-1"></i>
|
||||
Pending
|
||||
</span>
|
||||
<% when 'processing' %>
|
||||
<span class="payout-status-badge processing">
|
||||
<i data-lucide="refresh-cw" class="w-3 h-3 mr-1"></i>
|
||||
Processing
|
||||
</span>
|
||||
<% when 'completed' %>
|
||||
<span class="payout-status-badge completed">
|
||||
<i data-lucide="check-circle" class="w-3 h-3 mr-1"></i>
|
||||
<% if payout.manual_payout? %>
|
||||
Manually Processed
|
||||
<% else %>
|
||||
Completed
|
||||
<% end %>
|
||||
</span>
|
||||
<% when 'failed' %>
|
||||
<span class="payout-status-badge failed">
|
||||
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
|
||||
Failed
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= payout.created_at.strftime("%b %d, %Y") %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<%= link_to "View Details", promoter_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900 font-medium" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<% if @payouts.respond_to?(:total_pages) %>
|
||||
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<%= paginate @payouts %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Empty State -->
|
||||
<div class="payout-empty-state bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="payout-empty-state-icon">
|
||||
<i data-lucide="dollar-sign" class="h-8 w-8 text-gray-400"></i>
|
||||
</div>
|
||||
<h3 class="payout-empty-state-title">No payouts yet</h3>
|
||||
<p class="payout-empty-state-description">You haven't requested any payouts yet. When your events end, you'll be able to request payouts here.</p>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
204
app/views/promoter/payouts/show.html.erb
Normal file
204
app/views/promoter/payouts/show.html.erb
Normal file
@@ -0,0 +1,204 @@
|
||||
<% content_for(:title, "Payout Details") %>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="payout-detail-header">
|
||||
<div>
|
||||
<h1 class="payout-detail-title">Payout Details</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Payout request for <%= @payout.event&.name || "Unknown Event" %></p>
|
||||
</div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
|
||||
<!-- Status Progress -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Payout Status</h2>
|
||||
<div class="payout-status-progress">
|
||||
<!-- Steps -->
|
||||
<div class="payout-status-step">
|
||||
<div class="payout-status-step-icon <%= @payout.status == 'pending' ? 'pending' : 'completed' %>">
|
||||
<% if @payout.status == 'pending' %>
|
||||
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||
<% else %>
|
||||
<i data-lucide="check" class="w-4 h-4"></i>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="payout-status-step-label">Requested</p>
|
||||
<p class="payout-status-step-date"><%= @payout.created_at.strftime("%b %d, %Y") %></p>
|
||||
</div>
|
||||
|
||||
<div class="payout-status-step">
|
||||
<div class="payout-status-step-icon <%= @payout.status == 'processing' ? 'processing' : (@payout.status == 'completed' || @payout.status == 'failed') ? 'completed' : 'incomplete' %>">
|
||||
<% if @payout.status == 'processing' %>
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||
<% elsif @payout.status == 'completed' || @payout.status == 'failed' %>
|
||||
<i data-lucide="check" class="w-4 h-4"></i>
|
||||
<% else %>
|
||||
<i data-lucide="circle" class="w-4 h-4"></i>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="payout-status-step-label">Processing</p>
|
||||
</div>
|
||||
|
||||
<div class="payout-status-step">
|
||||
<div class="payout-status-step-icon <%= @payout.status == 'completed' ? 'completed' : (@payout.status == 'failed' ? 'failed' : 'incomplete') %>">
|
||||
<% if @payout.status == 'completed' %>
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
||||
<% elsif @payout.status == 'failed' %>
|
||||
<i data-lucide="x-circle" class="w-4 h-4"></i>
|
||||
<% else %>
|
||||
<i data-lucide="circle" class="w-4 h-4"></i>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="payout-status-step-label">Completed</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Card -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm font-medium text-gray-500">Gross Amount</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900">
|
||||
<%= number_to_currency(@payout.amount_euros, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm font-medium text-gray-500">Platform Fees</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900">
|
||||
-<%= number_to_currency(@payout.fee_euros, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="payout-summary-card">
|
||||
<p class="payout-summary-label">Net Amount</p>
|
||||
<p class="payout-summary-amount">
|
||||
<%= number_to_currency(@payout.net_amount_euros, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Payout Information</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">Details about this payout request</p>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-200">
|
||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
||||
<dt class="payout-detail-label">Event</dt>
|
||||
<dd class="payout-detail-value">
|
||||
<div class="payout-event-card">
|
||||
<div class="payout-event-icon">
|
||||
<i data-lucide="calendar" class="h-4 w-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="payout-event-name"><%= @payout.event&.name || "Event not found" %></div>
|
||||
<div class="payout-event-id">Event #<%= @payout.event&.id %></div>
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
||||
<dt class="payout-detail-label">Status</dt>
|
||||
<dd class="payout-detail-value">
|
||||
<% case @payout.status %>
|
||||
<% when 'pending' %>
|
||||
<span class="payout-status-badge pending">
|
||||
<i data-lucide="clock" class="w-4 h-4 mr-1"></i>
|
||||
Pending
|
||||
</span>
|
||||
<% when 'processing' %>
|
||||
<span class="payout-status-badge processing">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4 mr-1"></i>
|
||||
Processing
|
||||
</span>
|
||||
<% when 'completed' %>
|
||||
<span class="payout-status-badge completed">
|
||||
<i data-lucide="check-circle" class="w-4 h-4 mr-1"></i>
|
||||
Completed
|
||||
</span>
|
||||
<% when 'failed' %>
|
||||
<span class="payout-status-badge failed">
|
||||
<i data-lucide="x-circle" class="w-4 h-4 mr-1"></i>
|
||||
Failed
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
||||
<dt class="payout-detail-label">Gross Amount</dt>
|
||||
<dd class="payout-detail-value amount">€<%= @payout.amount_euros %></dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
||||
<dt class="payout-detail-label">Platform Fees</dt>
|
||||
<dd class="payout-detail-value amount">-€<%= @payout.fee_euros %></dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
||||
<dt class="payout-detail-label">Net Amount</dt>
|
||||
<dd class="payout-detail-value net-amount">€<%= @payout.net_amount_euros %></dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
||||
<dt class="payout-detail-label">Total Orders</dt>
|
||||
<dd class="payout-detail-value"><%= @payout.total_orders_count %></dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
||||
<dt class="payout-detail-label">Refunded Orders</dt>
|
||||
<dd class="payout-detail-value"><%= @payout.refunded_orders_count %></dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
||||
<dt class="payout-detail-label">Requested Date</dt>
|
||||
<dd class="payout-detail-value"><%= @payout.created_at.strftime("%B %d, %Y at %I:%M %p") %></dd>
|
||||
</div>
|
||||
|
||||
<% if @payout.stripe_payout_id.present? %>
|
||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
||||
<dt class="payout-detail-label">
|
||||
<% if @payout.manual_payout? %>
|
||||
Manual Payout ID
|
||||
<% else %>
|
||||
Stripe Payout ID
|
||||
<% end %>
|
||||
</dt>
|
||||
<dd class="payout-detail-value font-mono text-xs break-all">
|
||||
<% if @payout.manual_payout? %>
|
||||
Manual Transfer - <%= @payout.stripe_payout_id %>
|
||||
<% else %>
|
||||
<%= @payout.stripe_payout_id %>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @payout.manual_payout? && @payout.completed? %>
|
||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
||||
<dt class="payout-detail-label">Manual Processing Note</dt>
|
||||
<dd class="payout-detail-value">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i data-lucide="info" class="h-5 w-5 text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Bank Transfer Initiated</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>Your payout is being processed via bank transfer. Please allow 1-3 business days for the funds to appear in your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,7 +81,7 @@
|
||||
<div class="relative">
|
||||
<%= form.number_field :price_euros,
|
||||
step: 0.01,
|
||||
min: 0.01,
|
||||
min: 0,
|
||||
class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
|
||||
data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %>
|
||||
<div class="absolute left-3 top-2.5 text-gray-500">€</div>
|
||||
@@ -91,6 +91,8 @@
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 inline mr-1"></i>
|
||||
Modifier le prix n'affectera pas les billets déjà vendus
|
||||
</p>
|
||||
<% else %>
|
||||
<p class="mt-1 text-sm text-gray-500">Prix unitaire du billet (0€ pour un billet gratuit)</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -163,8 +165,10 @@
|
||||
<div class="flex">
|
||||
<i data-lucide="info" class="w-5 h-5 text-blue-400 mt-0.5 mr-2"></i>
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Événement:</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
|
||||
Les ventes doivent se terminer avant le début de l'événement.
|
||||
<strong>Début d'événement :</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
|
||||
<% unless @event.allow_booking_during_event? %>
|
||||
Les ventes doivent se terminer avant le début de l'événement.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,12 +212,6 @@
|
||||
<%= link_to promoter_event_ticket_type_path(@event, @ticket_type), class: "text-gray-500 hover:text-gray-700 transition-colors" do %>
|
||||
Annuler
|
||||
<% end %>
|
||||
<% if @ticket_type.tickets.any? %>
|
||||
<p class="text-sm text-yellow-600">
|
||||
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
|
||||
<%= pluralize(@ticket_type.tickets.count, 'billet') %> déjà vendu(s)
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<!-- Basic Information -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
|
||||
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<%= form.label :name, "Nom du type de billet", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
@@ -61,25 +61,25 @@
|
||||
<!-- Pricing & Quantity -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Prix et quantité</h3>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= form.label :price_euros, "Prix (€)", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<div class="relative">
|
||||
<%= form.number_field :price_euros,
|
||||
step: 0.01,
|
||||
min: 0.01,
|
||||
<%= form.number_field :price_euros,
|
||||
step: 0.01,
|
||||
min: 0,
|
||||
class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
|
||||
data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %>
|
||||
<div class="absolute left-3 top-2.5 text-gray-500">€</div>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">Prix unitaire du billet</p>
|
||||
<p class="mt-1 text-sm text-gray-500">Prix unitaire du billet (0€ pour un billet gratuit)</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<%= form.label :quantity, "Quantité disponible", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.number_field :quantity,
|
||||
min: 1,
|
||||
<%= form.number_field :quantity,
|
||||
min: 1,
|
||||
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
|
||||
data: { "ticket-type-form-target": "quantity", action: "input->ticket-type-form#updateTotal" } %>
|
||||
<p class="mt-1 text-sm text-gray-500">Nombre total de billets de ce type</p>
|
||||
@@ -100,18 +100,18 @@
|
||||
<!-- Sales Period -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Période de vente</h3>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= form.label :sale_start_at, "Début des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.datetime_local_field :sale_start_at,
|
||||
<%= form.datetime_local_field :sale_start_at,
|
||||
value: @ticket_type.sale_start_at&.strftime("%Y-%m-%dT%H:%M"),
|
||||
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<%= form.label :sale_end_at, "Fin des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.datetime_local_field :sale_end_at,
|
||||
<%= form.datetime_local_field :sale_end_at,
|
||||
value: @ticket_type.sale_end_at&.strftime("%Y-%m-%dT%H:%M"),
|
||||
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Les ventes s'arrêtent automatiquement à cette date</p>
|
||||
@@ -123,8 +123,11 @@
|
||||
<div class="flex">
|
||||
<i data-lucide="info" class="w-5 h-5 text-blue-400 mt-0.5 mr-2"></i>
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Événement:</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
|
||||
Les ventes doivent se terminer avant le début de l'événement.
|
||||
<strong>Début d'événement :</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
|
||||
|
||||
<% unless @event.allow_booking_during_event? %>
|
||||
Les ventes doivent se terminer avant le début de l'événement.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,13 +137,13 @@
|
||||
<!-- Access Requirements -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Conditions d'accès</h3>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= form.label :minimum_age, "Âge minimum", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.number_field :minimum_age,
|
||||
min: 0,
|
||||
max: 120,
|
||||
<%= form.number_field :minimum_age,
|
||||
min: 0,
|
||||
max: 120,
|
||||
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
|
||||
placeholder: "Laisser vide si aucune restriction" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Âge minimum requis (optionnel)</p>
|
||||
@@ -167,11 +170,11 @@
|
||||
Annuler
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<%= form.submit "Créer le type de billet", class: "inline-flex items-center px-6 py-3 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %>
|
||||
<%= format_ticket_price(ticket.price_cents) %>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
<%= ticket.created_at.strftime("%d/%m/%Y") %>
|
||||
@@ -164,7 +164,7 @@
|
||||
<div class="space-y-4">
|
||||
<div class="text-center p-4 bg-purple-50 rounded-lg">
|
||||
<div class="text-3xl font-bold text-purple-600">
|
||||
<%= number_to_currency(@ticket_type.price_euros, unit: "€") %>
|
||||
<%= format_ticket_price(@ticket_type.price_cents) %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Prix unitaire</div>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<p style="margin: 5px 0 0;"><a href="<%= ticket_url(ticket) %>" style="color: #4c1d95; text-decoration: none; font-size: 14px;">📱 Voir le détail et le code QR</a></p>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<p style="margin: 0; font-weight: bold; color: #212529;"><%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %></p>
|
||||
<p style="margin: 0; font-weight: bold; color: #212529;"><%= format_ticket_price(ticket.price_cents) %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<p style="margin: 0; color: #6c757d; font-size: 14px;">Prix</p>
|
||||
<p style="margin: 5px 0 0; font-weight: bold; color: #212529;"><%= number_to_currency(@ticket.price_cents / 100.0, unit: "€") %></p>
|
||||
<p style="margin: 5px 0 0; font-weight: bold; color: #212529;"><%= format_ticket_price(@ticket.price_cents) %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ DÉTAILS DE VOTRE COMMANDE
|
||||
Événement : <%= @event.name %>
|
||||
Date & heure : <%= @event.start_time.strftime("%d %B %Y à %H:%M") %>
|
||||
Nombre de billets : <%= @tickets.count %>
|
||||
Total : <%= number_to_currency(@order.total_amount_euros, unit: "€") %>
|
||||
Total : <%= @order.free? ? "Gratuit" : number_to_currency(@order.total_amount_euros, unit: "€") %>
|
||||
|
||||
BILLETS INCLUS :
|
||||
<% @tickets.each_with_index do |ticket, index| %>
|
||||
- Billet #<%= index + 1 %> : <%= ticket.ticket_type.name %> - <%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %>
|
||||
- Billet #<%= index + 1 %> : <%= ticket.ticket_type.name %> - <%= format_ticket_price(ticket.price_cents) %>
|
||||
<% end %>
|
||||
|
||||
Vos billets sont attachés à cet email en format PDF. Présentez-les à l'entrée de l'événement pour y accéder.
|
||||
@@ -32,7 +32,7 @@ DÉTAILS DE VOTRE BILLET
|
||||
Événement : <%= @event.name %>
|
||||
Type de billet : <%= @ticket.ticket_type.name %>
|
||||
Date & heure : <%= @event.start_time.strftime("%d %B %Y à %H:%M") %>
|
||||
Prix : <%= number_to_currency(@ticket.price_cents / 100.0, unit: "€") %>
|
||||
Prix : <%= format_ticket_price(@ticket.price_cents) %>
|
||||
|
||||
Votre billet est attaché à cet email en format PDF. Présentez-le à l'entrée de l'événement pour y accéder.
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
<div class="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-50 py-12 px-4 sm:px-6">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="bg-gradient-to-r from-purple-600 to-indigo-700 px-6 py-8 text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-16 h-16 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Paiement réussi !</h1>
|
||||
<p class="text-purple-100">Félicitations pour votre achat</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 sm:p-8">
|
||||
<div class="text-center mb-8">
|
||||
<p class="text-xl text-gray-700">
|
||||
Vos billets pour <span class="font-bold text-purple-700"><%= @event.name %></span> ont été achetés avec succès.
|
||||
</p>
|
||||
<p class="text-gray-500 mt-2">
|
||||
Un email de confirmation avec vos billets a été envoyé à <span class="font-medium"><%= current_user.email %></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Event Details -->
|
||||
<div class="bg-gray-50 rounded-xl p-6 mb-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Détails de l'événement
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="flex items-center p-3 bg-white rounded-lg">
|
||||
<svg class="w-5 h-5 text-purple-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">Lieu</p>
|
||||
<p class="font-medium"><%= @event.venue_name %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-3 bg-white rounded-lg">
|
||||
<svg class="w-5 h-5 text-purple-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">Date & Heure</p>
|
||||
<p class="font-medium"><%= @event.start_time.strftime("%d %B %Y à %H:%M") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tickets -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
|
||||
</svg>
|
||||
Vos billets
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% @tickets.each do |ticket| %>
|
||||
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-100 p-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center mr-4">
|
||||
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-900"><%= ticket.ticket_type.name %></h3>
|
||||
<p class="text-sm text-gray-600">Prix: <span class="font-medium"><%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %></span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= link_to ticket_download_path(ticket.qr_code, format: :pdf),
|
||||
class: "inline-flex items-center px-4 py-2 btn btn-primary rounded-lg transition-all duration-200 text-sm font-medium shadow-sm" do %>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Télécharger PDF
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-purple-100 flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 text-gray-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
<span class="text-xs text-gray-500">Code QR: <%= ticket.qr_code[0..7] %></span>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Actif
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Important Notice -->
|
||||
<div class="bg-blue-50 border border-blue-100 rounded-xl p-5 mb-8">
|
||||
<div class="flex">
|
||||
<svg class="w-5 h-5 text-blue-500 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold text-blue-800 mb-1">Important</h3>
|
||||
<p class="text-sm text-blue-700">
|
||||
Veuillez télécharger et sauvegarder vos billets. Présentez-les à l'entrée du lieu pour accéder à l'événement.
|
||||
Un email de confirmation avec vos billets a été envoyé à votre adresse email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<%= link_to dashboard_path,
|
||||
class: "inline-flex items-center justify-center px-6 py-3 btn btn-primary rounded-xl transition-all duration-200 font-medium shadow-sm" do %>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
</svg>
|
||||
Tableau de bord
|
||||
<% end %>
|
||||
|
||||
<%= link_to events_path,
|
||||
class: "inline-flex items-center justify-center px-6 py-3 bg-white text-gray-700 rounded-xl border border-gray-300 hover:bg-gray-50 transition-all duration-200 font-medium shadow-sm" do %>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Voir plus d'événements
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Prix</label>
|
||||
<p class="text-xl font-bold text-gray-900">
|
||||
<%= number_to_currency(@ticket.price_euros, unit: "€", separator: ",", delimiter: " ", format: "%n %u") %>
|
||||
<%= format_ticket_price(@ticket.price_cents) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,12 +75,14 @@ Rails.application.routes.draw do
|
||||
|
||||
# === Promoter Routes ===
|
||||
namespace :promoter do
|
||||
resources :payouts, only: [ :index, :show, :create ]
|
||||
resources :events do
|
||||
member do
|
||||
patch :publish
|
||||
patch :unpublish
|
||||
patch :cancel
|
||||
patch :mark_sold_out
|
||||
patch :mark_available
|
||||
post :duplicate
|
||||
end
|
||||
|
||||
@@ -93,6 +95,19 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
# === Administration ===
|
||||
namespace :admin do
|
||||
resources :payouts, only: [ :index, :show ] do
|
||||
member do
|
||||
post :approve
|
||||
post :reject
|
||||
post :mark_processing
|
||||
post :mark_completed
|
||||
post :mark_failed
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# API routes versioning
|
||||
namespace :api do
|
||||
namespace :v1 do
|
||||
@@ -110,4 +125,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
|
||||
|
||||
@@ -52,6 +52,14 @@ class DeviseCreateUsers < ActiveRecord::Migration[8.0]
|
||||
# Add onboarding check on user model
|
||||
t.boolean :onboarding_completed, default: false, null: false
|
||||
|
||||
# Link user to Stripe account for promoter payout
|
||||
t.string :stripe_connected_account_id
|
||||
|
||||
# Banking information for manual payouts
|
||||
t.string :iban
|
||||
t.string :bank_name
|
||||
t.string :account_holder_name
|
||||
|
||||
t.timestamps null: false
|
||||
end
|
||||
|
||||
@@ -60,5 +68,7 @@ class DeviseCreateUsers < ActiveRecord::Migration[8.0]
|
||||
# add_index :users, :confirmation_token, unique: true
|
||||
# add_index :users, :unlock_token, unique: true
|
||||
# add_index :users, :stripe_customer_id
|
||||
add_index :users, :stripe_connected_account_id, unique: true
|
||||
# add_index :stripe_connected_account_id_on_users, :stripe_connected_account_id ?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,11 +22,16 @@ class CreateEvents < ActiveRecord::Migration[8.0]
|
||||
# Allow ticket sell during the event
|
||||
t.boolean :allow_booking_during_event, default: false, null: false
|
||||
|
||||
# Payout fields
|
||||
t.integer :payout_status, default: 0, null: false
|
||||
t.datetime :payout_requested_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :events, :state
|
||||
add_index :events, :featured
|
||||
add_index :events, [ :latitude, :longitude ]
|
||||
add_index :events, :payout_status
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,5 +17,6 @@ class CreateTickets < ActiveRecord::Migration[8.0]
|
||||
end
|
||||
|
||||
add_index :tickets, :qr_code, unique: true
|
||||
add_index :tickets, [ :status, :order_id ]
|
||||
end
|
||||
end
|
||||
|
||||
20
db/migrate/20250916212717_create_earnings.rb
Normal file
20
db/migrate/20250916212717_create_earnings.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
class CreateEarnings < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :earnings do |t|
|
||||
t.integer :amount_cents
|
||||
t.integer :fee_cents
|
||||
t.integer :status
|
||||
t.string :stripe_payout_id
|
||||
|
||||
t.integer :net_amount_cents
|
||||
|
||||
t.references :event, null: false, foreign_key: false, index: true
|
||||
t.references :user, null: false, foreign_key: false, index: true
|
||||
t.references :order, null: false, foreign_key: false, index: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :earnings, :status
|
||||
end
|
||||
end
|
||||
27
db/migrate/20250916221454_create_payouts.rb
Normal file
27
db/migrate/20250916221454_create_payouts.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
class CreatePayouts < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :payouts do |t|
|
||||
t.integer :amount_cents, null: false
|
||||
t.integer :fee_cents, null: false, default: 0
|
||||
t.integer :status, null: false, default: 0
|
||||
t.string :stripe_payout_id
|
||||
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
|
||||
|
||||
# Manual processing fields
|
||||
t.references :processed_by, null: true, foreign_key: { to_table: :users }
|
||||
t.datetime :processed_at
|
||||
t.text :rejection_reason
|
||||
t.string :bank_transfer_reference
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :payouts, :status
|
||||
add_index :payouts, :stripe_payout_id, unique: true
|
||||
add_index :payouts, [ :event_id, :status ]
|
||||
end
|
||||
end
|
||||
51
db/schema.rb
generated
51
db/schema.rb
generated
@@ -10,7 +10,24 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
|
||||
create_table "earnings", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||
t.integer "amount_cents"
|
||||
t.integer "fee_cents"
|
||||
t.integer "status"
|
||||
t.string "stripe_payout_id"
|
||||
t.integer "net_amount_cents"
|
||||
t.bigint "event_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.bigint "order_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
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
|
||||
|
||||
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "slug", null: false
|
||||
@@ -28,8 +45,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "allow_booking_during_event", default: false, null: false
|
||||
t.integer "payout_status", default: 0, null: false
|
||||
t.datetime "payout_requested_at"
|
||||
t.index ["featured"], name: "index_events_on_featured"
|
||||
t.index ["latitude", "longitude"], name: "index_events_on_latitude_and_longitude"
|
||||
t.index ["payout_status"], name: "index_events_on_payout_status"
|
||||
t.index ["state"], name: "index_events_on_state"
|
||||
t.index ["user_id"], name: "index_events_on_user_id"
|
||||
end
|
||||
@@ -51,6 +71,29 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
|
||||
t.index ["user_id"], name: "index_orders_on_user_id"
|
||||
end
|
||||
|
||||
create_table "payouts", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||
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.bigint "processed_by_id"
|
||||
t.datetime "processed_at"
|
||||
t.text "rejection_reason"
|
||||
t.string "bank_transfer_reference"
|
||||
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 ["processed_by_id"], name: "index_payouts_on_processed_by_id"
|
||||
t.index ["status"], name: "index_payouts_on_status"
|
||||
t.index ["stripe_payout_id"], name: "index_payouts_on_stripe_payout_id", unique: true
|
||||
t.index ["user_id"], name: "index_payouts_on_user_id"
|
||||
end
|
||||
|
||||
create_table "ticket_types", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.text "description"
|
||||
@@ -80,6 +123,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) 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
|
||||
|
||||
@@ -101,7 +145,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
|
||||
t.boolean "onboarding_completed", default: false, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "iban"
|
||||
t.string "bank_name"
|
||||
t.string "account_holder_name"
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
end
|
||||
|
||||
add_foreign_key "payouts", "users", column: "processed_by_id"
|
||||
end
|
||||
|
||||
228
db/seeds.rb
228
db/seeds.rb
@@ -112,3 +112,231 @@ events.each_with_index do |event, index|
|
||||
end
|
||||
|
||||
puts "Created #{User.count} users, #{Event.count} events, and #{TicketType.count} ticket types"
|
||||
|
||||
# Create regular user as promoter
|
||||
promoter = User.find_or_create_by!(email: "kbataille@vivaldi.net") do |u|
|
||||
u.password = "lareunion974!"
|
||||
u.password_confirmation = "lareunion974!"
|
||||
u.last_name = nil
|
||||
u.first_name = nil
|
||||
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 pending_request
|
||||
e.payout_status = :pending_request
|
||||
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
|
||||
e.description = "
|
||||
Sisley évents Présente :
|
||||
SAM 13 SEPT
|
||||
LA BELLE ÉPOQUE de 18H à 2H
|
||||
sur le Rooftop LE PATIO
|
||||
ÉVÈNEMENT EN PLEIN AIR
|
||||
Ambiance Rétro / old school : zouk , Ragga , kompa , Dancehall , hip hop , Groove , Rnb …
|
||||
Restauration disponible sur place : Accras ,Allocos , specialités asiatique , japonaise et une large carte de choix de Pizzas pour vous régaler !
|
||||
TARIF D'ENTRÉE : 10€ SUR PLACE UNIQUEMENT
|
||||
Réservée aux + de 30 ans
|
||||
Suivez nous sur Instagram : Sisley Évents
|
||||
Le patio
|
||||
38 avenue Leon Gaumont , Montreuil
|
||||
Parking du Décathlon disponible , rue de la république, à 100m du Patio
|
||||
"
|
||||
e.venue_name = "Le Patio Rooftop"
|
||||
e.venue_address = "38 Av. Léon Gaumont, 93100 Montreuil"
|
||||
e.latitude = 48.862336
|
||||
e.longitude = 2.441218
|
||||
e.start_time = 3.days.from_now
|
||||
e.end_time = 3.days.from_now + 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 = promoter
|
||||
e.allow_booking_during_event = true
|
||||
end
|
||||
|
||||
belle_epoque_event.update!(start_time: 3.days.from_now, end_time: 3.days.from_now + 8.hours)
|
||||
|
||||
|
||||
# Create ticket types for "La belle époque" event
|
||||
belle_epoque_event = Event.find_by!(slug: "la-belle-epoque-par-sisley-events")
|
||||
|
||||
TicketType.find_or_create_by!(event: belle_epoque_event, name: "Free invitation valid before 7 p.m.") do |tt|
|
||||
tt.description = "Free invitation ticket valid before 7 p.m. for La Belle Époque"
|
||||
tt.price_cents = 0
|
||||
tt.quantity = 50
|
||||
tt.sale_start_at = Time.current
|
||||
tt.sale_end_at = belle_epoque_event.start_time
|
||||
tt.minimum_age = 30
|
||||
tt.requires_id = true
|
||||
end
|
||||
|
||||
TicketType.find_or_create_by!(event: belle_epoque_event, name: "ENTRY 10€ TO BE PAYED ON SITE ONLY") do |tt|
|
||||
tt.description = "Entry ticket to be paid on site only (free in system)"
|
||||
tt.price_cents = 0
|
||||
tt.quantity = 100
|
||||
tt.sale_start_at = Time.current
|
||||
tt.sale_end_at = belle_epoque_event.start_time
|
||||
tt.minimum_age = 30
|
||||
tt.requires_id = true
|
||||
end
|
||||
|
||||
TicketType.find_or_create_by!(event: belle_epoque_event, name: "Paid Entry 10€") do |tt|
|
||||
tt.description = "Paid entry ticket for La Belle Époque at 10€"
|
||||
tt.price_cents = 1000 # 10€
|
||||
tt.quantity = 200
|
||||
tt.sale_start_at = Time.current
|
||||
tt.sale_end_at = belle_epoque_event.start_time
|
||||
tt.minimum_age = 30
|
||||
tt.requires_id = true
|
||||
end
|
||||
|
||||
puts "Created 1 promoter, 1 draft event with ticket types"
|
||||
|
||||
157
docs/manual-payout-workflow.md
Normal file
157
docs/manual-payout-workflow.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Manual Payout Workflow
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the manual payout system implemented to replace Stripe Global Payouts, which is not available in France. The system allows promoters to request payouts for their events, and administrators to process these requests manually through bank transfers.
|
||||
|
||||
## Workflow Steps
|
||||
|
||||
### 1. Promoter Requests Payout
|
||||
- Promoters can request payouts for ended events through the existing interface
|
||||
- The system validates that banking information is complete before allowing requests
|
||||
- Payout status is set to `pending`
|
||||
|
||||
### 2. Admin Review (Pending → Approved/Rejected)
|
||||
**Admin Actions Available:**
|
||||
- **Approve**: If all information is correct and banking details are valid
|
||||
- **Reject**: If there are issues (missing info, invalid details, policy violations)
|
||||
|
||||
**What Admins Check:**
|
||||
- Banking information completeness (IBAN, bank name, account holder)
|
||||
- Event validity and earnings calculation
|
||||
- Promoter eligibility
|
||||
|
||||
### 3. Manual Bank Transfer (Approved → Processing)
|
||||
**Admin Actions:**
|
||||
- **Mark as Processing**: When bank transfer is initiated
|
||||
- Optional: Add transfer reference number
|
||||
- Admin manually processes SEPA transfer through their banking system
|
||||
|
||||
### 4. Transfer Completion (Processing → Completed/Failed)
|
||||
**Admin Actions:**
|
||||
- **Mark as Completed**: When transfer is confirmed successful
|
||||
- **Mark as Failed**: If transfer fails or is rejected by bank
|
||||
- Update transfer reference if needed
|
||||
|
||||
## Banking Information Requirements
|
||||
|
||||
### For Promoters
|
||||
Users must provide:
|
||||
- **IBAN**: Valid IBAN format (validated by regex)
|
||||
- **Bank Name**: Name of the banking institution
|
||||
- **Account Holder Name**: Full name matching bank account
|
||||
|
||||
### IBAN Validation
|
||||
- Basic format validation implemented
|
||||
- Supports standard European IBAN format
|
||||
- Regex: `/\A[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}\z/`
|
||||
|
||||
## Database Schema Changes
|
||||
|
||||
### New User Fields
|
||||
```ruby
|
||||
add_column :users, :iban, :string
|
||||
add_column :users, :bank_name, :string
|
||||
add_column :users, :account_holder_name, :string
|
||||
```
|
||||
|
||||
### New Payout Fields
|
||||
```ruby
|
||||
add_reference :payouts, :processed_by, foreign_key: { to_table: :users }
|
||||
add_column :payouts, :processed_at, :datetime
|
||||
add_column :payouts, :rejection_reason, :text
|
||||
add_column :payouts, :bank_transfer_reference, :string
|
||||
```
|
||||
|
||||
### Updated Payout Statuses
|
||||
```ruby
|
||||
enum :status, {
|
||||
pending: 0, # Payout requested but not reviewed
|
||||
approved: 1, # Payout approved by admin, ready for transfer
|
||||
processing: 2, # Payout being processed (bank transfer initiated)
|
||||
completed: 3, # Payout successfully completed
|
||||
failed: 4, # Payout failed
|
||||
rejected: 5 # Payout rejected by admin
|
||||
}
|
||||
```
|
||||
|
||||
## Admin Interface
|
||||
|
||||
### Dashboard Sections
|
||||
1. **Pending Review**: New requests requiring admin approval/rejection
|
||||
2. **Approved**: Ready for manual bank transfer
|
||||
3. **Processing**: Transfers in progress
|
||||
4. **Recently Completed**: Completed transfers for reference
|
||||
|
||||
### Transfer Information Display
|
||||
- Promoter banking details
|
||||
- Transfer amount and reference
|
||||
- Event information
|
||||
- Validation warnings for incomplete banking info
|
||||
|
||||
## Security & Audit
|
||||
|
||||
### Audit Trail
|
||||
- All status changes tracked with timestamp
|
||||
- Admin user recorded for each action
|
||||
- Transfer references stored for bank reconciliation
|
||||
|
||||
### Validation
|
||||
- Banking information validated before approval
|
||||
- IBAN format checking
|
||||
- Complete information required before processing
|
||||
|
||||
## Migration from Stripe
|
||||
|
||||
### Immediate Changes
|
||||
- ✅ Stripe Transfer functionality disabled
|
||||
- ✅ Manual workflow implemented
|
||||
- ✅ Banking information collection added
|
||||
- ✅ Admin interface updated
|
||||
|
||||
### Legacy Support
|
||||
- Original `PayoutService#process!` method redirects to manual workflow
|
||||
- Existing payout request flow preserved for promoters
|
||||
- Database backward compatible
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
### For Administrators
|
||||
1. Access admin payout dashboard at `/admin/payouts`
|
||||
2. Review pending payouts for approval
|
||||
3. For approved payouts, initiate bank transfers manually
|
||||
4. Update payout status as transfers progress
|
||||
5. Mark as completed when transfer is confirmed
|
||||
|
||||
### For Promoters
|
||||
1. Ensure banking information is complete in profile
|
||||
2. Request payouts for ended events as before
|
||||
3. Monitor payout status through existing interface
|
||||
4. Banking information must be valid IBAN format
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Issues
|
||||
- **Incomplete Banking Info**: Prevents approval until resolved
|
||||
- **Invalid IBAN**: Validation error displayed to admin
|
||||
- **Transfer Failures**: Can be marked as failed with reason
|
||||
|
||||
### Recovery
|
||||
- Failed payouts can be retried after fixing issues
|
||||
- Rejected payouts require new requests
|
||||
- Banking information can be updated by promoters
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Improvements
|
||||
1. Integration with banking APIs for automated transfers
|
||||
2. Enhanced IBAN validation with checksum verification
|
||||
3. Email notifications for status changes
|
||||
4. Bulk transfer processing
|
||||
5. Advanced reporting and reconciliation tools
|
||||
|
||||
### France-Specific Considerations
|
||||
1. SEPA transfer compliance
|
||||
2. Tax reporting requirements
|
||||
3. AML/KYC documentation
|
||||
4. Banking regulation compliance
|
||||
57
docs/manual_payouts_france.md
Normal file
57
docs/manual_payouts_france.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Manual Payouts for French Users
|
||||
|
||||
## Overview
|
||||
|
||||
Due to regulatory restrictions, Stripe Global Payouts are not available in France. As a result, we've implemented a manual payout system using SEPA (Single Euro Payments Area) bank transfers for French promoters.
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Payout Request
|
||||
- When a French promoter's event ends and they have earnings, they can request a payout through their dashboard
|
||||
- The system creates a payout record with status "pending"
|
||||
|
||||
### 2. Admin Processing
|
||||
- Admins can view pending payouts in the admin panel
|
||||
- For French users, the "Process Payout" button automatically marks the payout as a SEPA transfer
|
||||
- Admins can also manually mark a payout as a SEPA transfer using the "Mark as SEPA Transfer" button
|
||||
|
||||
### 3. SEPA Transfer
|
||||
- When a payout is marked as a SEPA transfer, the system:
|
||||
- Updates the payout status to "completed"
|
||||
- Generates a unique SEPA transfer ID
|
||||
- Updates all related earnings to "paid" status
|
||||
- Admins then manually process the bank transfer outside the application using SEPA
|
||||
|
||||
### 4. Promoter Notification
|
||||
- Promoters receive a notification that their payout is being processed via SEPA transfer
|
||||
- They are informed that funds should appear in their account within 1-3 business days
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Payout Model
|
||||
- `manual_payout?` method checks if the payout is a SEPA transfer (ID starts with "SEPA_")
|
||||
- `sepa_transfer?` method is an alias for `manual_payout?`
|
||||
- `mark_as_manually_processed!` method generates a SEPA transfer ID
|
||||
|
||||
### Payout Service
|
||||
- `should_process_manually?` method is hardcoded to return true for French users
|
||||
- `process_manually!` method handles the SEPA transfer process
|
||||
|
||||
### Views
|
||||
- Admin and promoter views display "SEPA Transfer" instead of "Manual Transfer"
|
||||
- Specific messaging about SEPA transfer timelines is shown to promoters
|
||||
|
||||
## Testing
|
||||
|
||||
The manual payout system is thoroughly tested with:
|
||||
- Unit tests for the payout model methods
|
||||
- Service tests for the payout processing logic
|
||||
- Controller tests for the admin interface
|
||||
- Integration tests for the end-to-end flow
|
||||
|
||||
## Future Improvements
|
||||
|
||||
- Automatically detect user country from their address or IP
|
||||
- Integrate with a SEPA transfer API for automated bank transfers
|
||||
- Add email notifications for payout status changes
|
||||
- Implement a dashboard for admins to track SEPA transfers
|
||||
112
docs/payout-system-analysis.md
Normal file
112
docs/payout-system-analysis.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Payout System Analysis Report
|
||||
|
||||
## Current Implementation Overview
|
||||
|
||||
The current payout system implemented on the `feat/stripe-global-payouts` branch uses **Stripe Connect** with **Stripe Transfers** to automatically process payouts to promoters. This implementation is **not compatible with France** as it relies on Stripe Global Payouts functionality.
|
||||
|
||||
## Architecture Analysis
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Models**
|
||||
- `Payout`: Tracks payout requests with statuses (pending, processing, completed, failed)
|
||||
- `User`: Contains `stripe_connected_account_id` for Stripe Connect integration
|
||||
- `Event`: Has payout eligibility and earnings calculation methods
|
||||
- `Earning`: Tracks individual order earnings (referenced in docs but may not be fully implemented)
|
||||
|
||||
2. **Services**
|
||||
- `PayoutService` (`app/services/payout_service.rb:13-19`): Processes payouts via `Stripe::Transfer.create`
|
||||
- `StripeConnectService`: Manages Stripe Connect account setup
|
||||
|
||||
3. **Controllers**
|
||||
- `Promoter::PayoutsController`: Handles promoter payout requests and viewing
|
||||
- `Admin::PayoutsController`: Handles admin payout processing
|
||||
|
||||
### Current Payout Flow
|
||||
|
||||
1. **Promoter Request**: Promoter requests payout for ended event via `Promoter::PayoutsController#create`
|
||||
2. **Admin Processing**: Admin processes payout via `Admin::PayoutsController#process`
|
||||
3. **Stripe Transfer**: `PayoutService` creates `Stripe::Transfer` to promoter's connected account
|
||||
4. **Status Update**: Payout status updated to completed/failed based on Stripe response
|
||||
|
||||
### Key Issues for France
|
||||
|
||||
1. **Stripe Global Payouts Dependency**: The system uses `Stripe::Transfer.create` with `destination: stripe_connected_account_id` which requires Stripe Global Payouts
|
||||
2. **Stripe Connect Requirement**: Users must have verified Stripe Connect accounts (`stripe_connected_account_id`)
|
||||
3. **Automatic Processing**: System assumes automated Stripe processing capability
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Payouts Table
|
||||
- `amount_cents`: Gross payout amount
|
||||
- `fee_cents`: Platform fees
|
||||
- `status`: Enum (pending, processing, completed, failed)
|
||||
- `stripe_payout_id`: Stripe transfer ID
|
||||
- `total_orders_count`: Number of orders included
|
||||
- `refunded_orders_count`: Number of refunded orders
|
||||
- `user_id`: Promoter receiving payout
|
||||
- `event_id`: Event for which payout is requested
|
||||
|
||||
### Users Table (Relevant Fields)
|
||||
- `stripe_connected_account_id`: Stripe Connect account ID
|
||||
- `is_professionnal`: Required for event management
|
||||
|
||||
## Compliance and Legal Considerations
|
||||
|
||||
### France-Specific Issues
|
||||
1. **Stripe Global Payouts**: Not available in France as of current analysis
|
||||
2. **Banking Regulations**: May require different approach for cross-border transfers
|
||||
3. **Tax Reporting**: Manual payouts may require additional documentation
|
||||
|
||||
### Alternative Approaches Needed
|
||||
1. **Manual Bank Transfers**: Admin-initiated SEPA transfers
|
||||
2. **Payout Request System**: Promoters request, admins approve and process manually
|
||||
3. **Documentation**: Enhanced record-keeping for manual transfers
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Immediate Actions Required
|
||||
1. **Disable Automatic Processing**: Remove Stripe Transfer functionality
|
||||
2. **Implement Manual Workflow**: Create admin interface for manual payout processing
|
||||
3. **Add Banking Information**: Collect IBAN/SWIFT details from promoters
|
||||
4. **Update Status Flow**: Modify payout statuses for manual processing
|
||||
|
||||
### Proposed Manual Payout System
|
||||
1. **Request Phase**: Promoters submit payout requests (existing functionality can be kept)
|
||||
2. **Review Phase**: Admins review and approve requests
|
||||
3. **Processing Phase**: Admins mark as "processing" and initiate bank transfer
|
||||
4. **Completion Phase**: Admins confirm transfer completion manually
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Immediate Fix
|
||||
- Disable automatic Stripe processing
|
||||
- Add manual processing interface for admins
|
||||
- Update payout status workflow
|
||||
|
||||
### Phase 2: Enhanced Manual System
|
||||
- Add banking information collection
|
||||
- Implement approval workflow
|
||||
- Add transfer documentation features
|
||||
|
||||
### Phase 3: Potential Automation
|
||||
- Investigate France-compatible payment providers
|
||||
- Implement API-based bank transfers if available
|
||||
- Maintain manual fallback option
|
||||
|
||||
## Technical Debt
|
||||
|
||||
### Files Requiring Updates
|
||||
- `app/services/payout_service.rb`: Remove Stripe Transfer logic
|
||||
- `app/controllers/admin/payouts_controller.rb`: Add manual processing actions
|
||||
- `app/models/user.rb`: Add banking information fields
|
||||
- Database migrations: Add IBAN/banking fields to users table
|
||||
|
||||
### Testing Impact
|
||||
- Update `test/services/payout_service_test.rb`
|
||||
- Modify controller tests for manual workflow
|
||||
- Add integration tests for manual payout flow
|
||||
|
||||
## Conclusion
|
||||
|
||||
The current Stripe Global Payouts implementation is not viable for France operations. A manual payout system must be implemented immediately to handle promoter payments through traditional banking methods while maintaining audit trails and proper documentation.
|
||||
24
docs/promoter-payouts.md
Normal file
24
docs/promoter-payouts.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Promoter Payouts Architecture
|
||||
|
||||
## Overview
|
||||
To handle promoter payouts in the Rails app (where promoters are users creating events), track all order payments in the database for auditing and fee calculation. Save payments (e.g., via Stripe webhooks) and apply platform fees per order processed—e.g., promoter gets 90% of ticket revenue minus your fee, stored in a new `earnings` table linked to events/orders.
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
### 1. Models & DB
|
||||
- Add `has_many :earnings, dependent: :destroy` to `Event` and `User` models.
|
||||
- Create `Earnings` model: `belongs_to :event, :user; fields: amount_cents (Decimal), fee_cents (Decimal), status (enum: pending/paid), stripe_payout_id (String), order_id (ref)`.
|
||||
- On order payment success (in your Stripe webhook or after_create callback on Order), create Earnings record: `earnings = event.earnings.create!(amount_cents: total_revenue_cents * 0.9, fee_cents: total_revenue_cents * 0.1, status: :pending, order: order)`.
|
||||
|
||||
### 2. Payout Processing
|
||||
- Use Stripe Connect (setup promoter Stripe accounts via `account_links` in user onboarding).
|
||||
- Create a `PayoutService`: Batch pending earnings per promoter, transfer via `Stripe::Transfer.create` to their connected account, update status to `:paid`.
|
||||
- Run via cron job (e.g., in `lib/tasks/payouts.rake`) or admin-triggered job.
|
||||
|
||||
### 3. Admin Dashboard for Due Payouts
|
||||
- Add admin routes: `resources :admin, only: [] do; resources :payouts; end` in `config/routes.rb`.
|
||||
- Controller: `Admin::PayoutsController` with `index` action querying `Earnings.pending.where(user_id: params[:promoter_id]).group_by(&:user).sum(:amount_cents)`.
|
||||
- View: Table showing promoter name, total due, unpaid earnings list; button to trigger payout.
|
||||
- Use Pundit or CanCanCan for admin-only access (add `is_admin?` to User).
|
||||
|
||||
This ensures transparency, scalability, and easy auditing. Start by migrating the Earnings model: `rails g model Earnings event:references user:references order:references amount_cents:decimal fee_cents:decimal status:integer stripe_payout_id:string`. Test with Stripe test mode.
|
||||
7
lib/tasks/payouts.rake
Normal file
7
lib/tasks/payouts.rake
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace :payouts do
|
||||
desc "Process all pending promoter payouts"
|
||||
task process: :environment do
|
||||
PayoutService.new.process_pending_payouts
|
||||
puts "Pending payouts processed."
|
||||
end
|
||||
end
|
||||
436
logs/request_20250917_213300.json
Normal file
436
logs/request_20250917_213300.json
Normal file
@@ -0,0 +1,436 @@
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "\nYou are SHAI (for Shell AI), a coding assistant from OVHcloud, designed to be a helpful and secure pair programmer. Your purpose is to assist users with their software engineering tasks by leveraging the tools at your disposal.\n \n### Core Principles:\n \n**Helpfulness First:** \nYour primary goal is to be helpful. Understand the user's request and use your tools to achieve their goals. Be proactive when it makes sense, but always keep the user informed about the actions you are taking.\n\n**Security is Paramount:**\n * You must prioritize writing secure code.\n * Never introduce vulnerabilities.\n * Never handle or expose user secrets or credentials.\n\n## Interaction Guidelines:\n \n**Clarity and Conciseness:** \nCommunicate clearly, directly and accurately. Your output is for a command-line interface, so be brief. Avoid unnecessary chatter. Do not write code when replying to the user unless asked to. If you cannot do something, explain why and offers alternative. \n\n**Explain Your Actions:** \nBefore executing any command that modifies the user's system or files, explain what the command does and why you are running it. You must however keep your explanation short and ideally fewer than 4 lines (unless asked by the user). If you use code editing tools such as edit or write, never copy code in your response. Explain the task, do the task but avoid too many unnecessary explanation, introduction and conclusion. The best explanation is an accurate flow of actions rather than length long chatty response. \n\n**Follow Conventions:** \nWhen modifying code, adhere to the existing style, libraries, and patterns of the project. Do not introduce new dependencies without checking if they are already in use.\n\n**Tool Usage:**\n * Use the provided tools to interact with the user's environment.\n * Do not use comments in code to communicate with the user.\n * Use the `todo_write` and `todo_read` tools to plan and track your work, especially for complex tasks. This provide visibility to the user. You must use these tools extensively.\n\n**No Surprises:** \nDo not commit changes to version control unless explicitly asked to do so by the user.\n\n**Proactiveness**\nYou are allowed to be proactive and take initiative that are aligned with the user intent. For instance if the user asks you to make a function, you can proactively follow your implementation with a call to compile / test the project to make sure that your change were correct. You must however avoid proactively taking actions that are out of scope or unnecessary. For instance if the user asks you to modify a function, you should not immediately assume that this function should be used everywhere. You have to strike a balance between helpfulness, autonomy while also keeping the user in the loop.\n\n### Environment Information:\n\nYou are running in the following environment:\n<env>\n Today's date: {2024-11-16}\n Platform: {Linux}\n OS Version: {Nobara Linux 42 (Workstation Edition)}\n Working directory: {/home/acid/Documents/aperonight}\n Is Working directory a git repo: {true} \n</env>\n\n<git>\ngitStatus: This is the current git status at the last message of the conversation.\n\nCurrent branch: {develop}\n\nStatus: \n{?? docs/manual_payouts_france.md}\n\nRecent commits: \n{e5ed1a3 refactor: migrate payment success views to orders system and add promoter seed data\n3e0a354 Merge pull request 'feat/free-ticket' (#4) from feat/free-ticket into develop\nb5c1846 fix(mailers): Include TicketsHelper in TicketMailer to make format_ticket_price available\n04393ad fix(tests): Remove service fee expectation from Stripe invoice test and fix duplicated keys in event view\n5279ebe feat(event available/sold out): Promoter can mark event as sold out or available}\n</git>\n\n<todo>\ntodoStatus: This is the current status of the todo list\n\n{No todos found. The todo list is empty.}\n</todo>\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Could you explain this project?"
|
||||
}
|
||||
],
|
||||
"model": "deepseek/deepseek-v3.1-base",
|
||||
"temperature": 0.3,
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "#",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The bash command to execute",
|
||||
"type": "string"
|
||||
},
|
||||
"env": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Environment variables to set (optional)",
|
||||
"type": "object"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout in seconds (optional, None = no timeout)",
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"working_dir": {
|
||||
"description": "Working directory for command execution (optional)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"env"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit",
|
||||
"description": "#",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"new_string": {
|
||||
"description": "The replacement text",
|
||||
"type": "string"
|
||||
},
|
||||
"old_string": {
|
||||
"description": "The text pattern to find and replace",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "Path to the file to edit",
|
||||
"type": "string"
|
||||
},
|
||||
"replace_all": {
|
||||
"default": false,
|
||||
"description": "Whether to replace all occurrences (default: false, replaces only first)",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"new_string",
|
||||
"old_string",
|
||||
"path",
|
||||
"replace_all"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "multiedit",
|
||||
"description": "#",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"edits": {
|
||||
"description": "Array of edit operations to perform sequentially",
|
||||
"items": {
|
||||
"properties": {
|
||||
"new_string": {
|
||||
"description": "The replacement text",
|
||||
"type": "string"
|
||||
},
|
||||
"old_string": {
|
||||
"description": "The text pattern to find and replace",
|
||||
"type": "string"
|
||||
},
|
||||
"replace_all": {
|
||||
"default": false,
|
||||
"description": "Whether to replace all occurrences (default: false, replaces only first)",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"new_string",
|
||||
"old_string",
|
||||
"replace_all"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"file_path": {
|
||||
"description": "Path to the file to edit",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"edits",
|
||||
"file_path"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "fetch",
|
||||
"description": "#",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"body": {
|
||||
"default": null,
|
||||
"description": "Request body for POST/PUT (optional)",
|
||||
"type": "string"
|
||||
},
|
||||
"headers": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": null,
|
||||
"description": "HTTP headers to send (optional)",
|
||||
"type": "object"
|
||||
},
|
||||
"method": {
|
||||
"description": "HTTP method to use",
|
||||
"enum": [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"DELETE"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"default": 30,
|
||||
"description": "Request timeout in seconds (optional, defaults to 30)",
|
||||
"format": "uint64",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL to fetch data from",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"timeout",
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "find",
|
||||
"description": "#",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"case_sensitive": {
|
||||
"default": false,
|
||||
"description": "Whether to use case-sensitive search",
|
||||
"type": "boolean"
|
||||
},
|
||||
"context_lines": {
|
||||
"default": null,
|
||||
"description": "Maximum lines of context around matches",
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"exclude_patterns": {
|
||||
"default": null,
|
||||
"description": "File patterns to exclude (e.g., \"target,node_modules,.git\")",
|
||||
"type": "string"
|
||||
},
|
||||
"find_type": {
|
||||
"default": "content",
|
||||
"description": "Find type: content (search file contents) or filename (search file names)",
|
||||
"enum": [
|
||||
"content",
|
||||
"filename",
|
||||
"both"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"include_extensions": {
|
||||
"default": null,
|
||||
"description": "File extensions to include (e.g., \"rs,js,py\")",
|
||||
"type": "string"
|
||||
},
|
||||
"max_results": {
|
||||
"default": 100,
|
||||
"description": "Maximum number of results to return",
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"path": {
|
||||
"default": null,
|
||||
"description": "Directory to search in (defaults to current directory)",
|
||||
"type": "string"
|
||||
},
|
||||
"pattern": {
|
||||
"description": "The pattern to search for (supports regex)",
|
||||
"type": "string"
|
||||
},
|
||||
"show_line_numbers": {
|
||||
"default": true,
|
||||
"description": "Show line numbers in results",
|
||||
"type": "boolean"
|
||||
},
|
||||
"whole_word": {
|
||||
"default": false,
|
||||
"description": "Use whole word matching",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"case_sensitive",
|
||||
"find_type",
|
||||
"max_results",
|
||||
"pattern",
|
||||
"show_line_numbers",
|
||||
"whole_word"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ls",
|
||||
"description": "#",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"directory": {
|
||||
"default": ".",
|
||||
"description": "Directory to list (defaults to current directory)",
|
||||
"type": "string"
|
||||
},
|
||||
"long_format": {
|
||||
"default": false,
|
||||
"description": "Show detailed information (size, permissions, etc.)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_depth": {
|
||||
"default": null,
|
||||
"description": "Maximum depth for recursive listing (None = unlimited)",
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"max_files": {
|
||||
"default": null,
|
||||
"description": "Maximum number of files to return (None = unlimited)",
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"recursive": {
|
||||
"default": false,
|
||||
"description": "Whether to list files recursively",
|
||||
"type": "boolean"
|
||||
},
|
||||
"show_hidden": {
|
||||
"default": false,
|
||||
"description": "Show hidden files (files starting with .)",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"directory",
|
||||
"long_format",
|
||||
"recursive",
|
||||
"show_hidden"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "#",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"line_end": {
|
||||
"default": null,
|
||||
"description": "Ending line number (optional)",
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"line_start": {
|
||||
"default": null,
|
||||
"description": "Starting line number (optional)",
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"path": {
|
||||
"description": "Path to the file to read",
|
||||
"type": "string"
|
||||
},
|
||||
"show_line_numbers": {
|
||||
"default": false,
|
||||
"description": "Whether to include line numbers in the output",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"show_line_numbers"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "todo_read",
|
||||
"description": "Fetches the current to-do list for the session. Use this proactively to stay informed about the status of ongoing tasks.",
|
||||
"parameters": {
|
||||
"description": "Empty parameters struct for tools that don't need any parameters",
|
||||
"properties": {
|
||||
"_unused": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "todo_write",
|
||||
"description": "Creates and manages a structured task list for the coding session. This is vital for organizing complex work, tracking progress, and showing a clear plan.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"todos": {
|
||||
"description": "List of todos to write (replaces entire list)",
|
||||
"items": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"content",
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"todos"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write",
|
||||
"description": "#",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Content to write to the file",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "Path to the file to write",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"content",
|
||||
"path"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": "auto"
|
||||
}
|
||||
455
logs/request_20250917_213447.json
Normal file
455
logs/request_20250917_213447.json
Normal file
File diff suppressed because one or more lines are too long
455
logs/request_20250917_213729.json
Normal file
455
logs/request_20250917_213729.json
Normal file
File diff suppressed because one or more lines are too long
34
test/controllers/admin/payouts_controller_test.rb
Normal file
34
test/controllers/admin/payouts_controller_test.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
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, stripe_customer_id: "cus_test_admin", onboarding_completed: true)
|
||||
@payout = payouts(:one)
|
||||
end
|
||||
|
||||
test "requires admin authentication" do
|
||||
get admin_payouts_url
|
||||
assert_redirected_to new_user_session_path
|
||||
end
|
||||
|
||||
test "approve payout requires admin authentication" do
|
||||
post approve_admin_payout_url(@payout)
|
||||
assert_redirected_to new_user_session_path
|
||||
end
|
||||
|
||||
test "approve payout works for admin users" do
|
||||
sign_in @admin_user
|
||||
@payout.update(status: :pending)
|
||||
|
||||
# Ensure the payout user has complete banking info
|
||||
@payout.user.update!(
|
||||
iban: "FR1420041010050500013M02606",
|
||||
bank_name: "Test Bank",
|
||||
account_holder_name: "Test User"
|
||||
)
|
||||
|
||||
post approve_admin_payout_url(@payout)
|
||||
assert_redirected_to admin_payout_path(@payout)
|
||||
assert_match /Payout approved successfully/, flash[:notice]
|
||||
end
|
||||
end
|
||||
@@ -125,7 +125,7 @@ class OrdersControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal "draft", new_order.status
|
||||
assert_equal @user, new_order.user
|
||||
assert_equal @event, new_order.event
|
||||
assert_equal @ticket_type.price_cents + 100, new_order.total_amount_cents # includes 1€ service fee
|
||||
assert_equal @ticket_type.price_cents, new_order.total_amount_cents # Service fee deducted from promoter payout, not added to customer
|
||||
|
||||
assert_redirected_to checkout_order_path(new_order)
|
||||
assert_equal new_order.id, session[:draft_order_id]
|
||||
|
||||
174
test/controllers/promoter/payouts_controller_test.rb
Normal file
174
test/controllers/promoter/payouts_controller_test.rb
Normal file
@@ -0,0 +1,174 @@
|
||||
require "test_helper"
|
||||
|
||||
class Promoter::PayoutsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:one)
|
||||
@event = events(:concert_event)
|
||||
@payout = payouts(:one)
|
||||
end
|
||||
|
||||
test "should get index" do
|
||||
sign_in @user
|
||||
# Make the user a promoter
|
||||
@user.update(is_professionnal: true)
|
||||
get promoter_payouts_url
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should get show" do
|
||||
sign_in @user
|
||||
# Make the user a promoter
|
||||
@user.update(is_professionnal: true)
|
||||
# Create a payout that belongs to the user
|
||||
payout = Payout.create!(
|
||||
user: @user,
|
||||
event: @event,
|
||||
amount_cents: 1000,
|
||||
fee_cents: 100
|
||||
)
|
||||
get promoter_payout_url(payout)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should create payout" do
|
||||
sign_in @user
|
||||
# Make the user a promoter
|
||||
@user.update(is_professionnal: true)
|
||||
# Make the user the owner of the event
|
||||
@event.update(user: @user)
|
||||
# Make the event end in the past
|
||||
@event.update(end_time: 1.day.ago)
|
||||
# Create some earnings for the event
|
||||
@event.earnings.create!(
|
||||
user: @user,
|
||||
order: orders(:paid_order),
|
||||
amount_cents: 1000,
|
||||
fee_cents: 100,
|
||||
status: :pending
|
||||
)
|
||||
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
|
||||
|
||||
test "show renders manual payout details correctly" do
|
||||
sign_in @user
|
||||
@user.update(is_professionnal: true)
|
||||
payout = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :completed, stripe_payout_id: "MANUAL_abc123")
|
||||
|
||||
get promoter_payout_url(payout)
|
||||
assert_response :success
|
||||
assert_match "Manual Payout ID", @response.body
|
||||
assert_match "Manual Transfer", @response.body
|
||||
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
|
||||
19
test/fixtures/earnings.yml
vendored
Normal file
19
test/fixtures/earnings.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
event: concert_event
|
||||
user: users(one)
|
||||
order: paid_order
|
||||
amount_cents: 9000 # €90.00
|
||||
fee_cents: 1000 # €10.00
|
||||
status: pending
|
||||
stripe_payout_id:
|
||||
|
||||
two:
|
||||
event: winter_gala
|
||||
user: users(two)
|
||||
order: expired_order
|
||||
amount_cents: 4500 # €45.00
|
||||
fee_cents: 500 # €5.00
|
||||
status: paid
|
||||
stripe_payout_id: payout_123
|
||||
40
test/fixtures/events.yml
vendored
40
test/fixtures/events.yml
vendored
@@ -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
|
||||
|
||||
22
test/fixtures/orders.yml
vendored
22
test/fixtures/orders.yml
vendored
@@ -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 %>
|
||||
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 %>
|
||||
15
test/fixtures/payouts.yml
vendored
Normal file
15
test/fixtures/payouts.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
user: one
|
||||
event: one
|
||||
amount_cents: 10000
|
||||
fee_cents: 1000
|
||||
status: pending
|
||||
|
||||
two:
|
||||
user: two
|
||||
event: two
|
||||
amount_cents: 20000
|
||||
fee_cents: 2000
|
||||
status: completed
|
||||
10
test/fixtures/ticket_types.yml
vendored
10
test/fixtures/ticket_types.yml
vendored
@@ -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
|
||||
|
||||
58
test/integration/payout_flow_test.rb
Normal file
58
test/integration/payout_flow_test.rb
Normal file
@@ -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
|
||||
135
test/models/earning_test.rb
Normal file
135
test/models/earning_test.rb
Normal file
@@ -0,0 +1,135 @@
|
||||
require "test_helper"
|
||||
|
||||
class EarningTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:one) || User.create!(email: "test@example.com", password: "password")
|
||||
@event = events(:concert_event) || Event.create!(name: "Test Event", slug: "test-event", description: "Description", venue_name: "Venue", venue_address: "Address", latitude: 48.8566, longitude: 2.3522, start_time: Time.current, user: @user)
|
||||
@order = orders(:paid_order) || Order.create!(user: @user, event: @event, status: "paid", total_amount_cents: 10000)
|
||||
@earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 9000, fee_cents: 1000, status: :pending)
|
||||
end
|
||||
|
||||
test "valid earning" do
|
||||
assert @earning.valid?
|
||||
end
|
||||
|
||||
test "amount_cents must be present and non-negative" do
|
||||
@earning.amount_cents = nil
|
||||
assert_not @earning.valid?
|
||||
assert_includes @earning.errors[:amount_cents], "can't be blank"
|
||||
|
||||
@earning.amount_cents = -1
|
||||
assert_not @earning.valid?
|
||||
assert_includes @earning.errors[:amount_cents], "must be greater than or equal to 0"
|
||||
end
|
||||
|
||||
test "fee_cents must be present and non-negative" do
|
||||
@earning.fee_cents = nil
|
||||
assert_not @earning.valid?
|
||||
assert_includes @earning.errors[:fee_cents], "can't be blank"
|
||||
|
||||
@earning.fee_cents = -1
|
||||
assert_not @earning.valid?
|
||||
assert_includes @earning.errors[:fee_cents], "must be greater than or equal to 0"
|
||||
end
|
||||
|
||||
test "status must be present" do
|
||||
@earning.status = nil
|
||||
assert_not @earning.valid?
|
||||
assert_includes @earning.errors[:status], "can't be blank"
|
||||
end
|
||||
|
||||
test "stripe_payout_id must be unique if present" do
|
||||
@earning.stripe_payout_id = "test_payout"
|
||||
@earning.save!
|
||||
|
||||
duplicate = @earning.dup
|
||||
duplicate.stripe_payout_id = "test_payout"
|
||||
|
||||
assert_not duplicate.valid?
|
||||
assert_includes duplicate.errors[:stripe_payout_id], "has already been taken"
|
||||
end
|
||||
|
||||
test "belongs to associations" do
|
||||
assert_instance_of Event, @earning.event
|
||||
assert_instance_of User, @earning.user
|
||||
assert_instance_of Order, @earning.order
|
||||
end
|
||||
|
||||
test "status enum" do
|
||||
assert_equal 0, Earning.statuses[:pending]
|
||||
assert_equal 1, Earning.statuses[:paid]
|
||||
|
||||
assert @earning.pending?
|
||||
assert_not @earning.paid?
|
||||
|
||||
@earning.status = :paid
|
||||
@earning.save!
|
||||
assert @earning.paid?
|
||||
assert_not @earning.pending?
|
||||
end
|
||||
|
||||
test "pending scope from enum" do
|
||||
pending_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 9000, fee_cents: 1000, status: :pending)
|
||||
paid_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 4500, fee_cents: 500, status: :paid)
|
||||
|
||||
assert_includes Earning.pending, pending_earning
|
||||
assert_not_includes Earning.pending, paid_earning
|
||||
end
|
||||
|
||||
test "paid scope from enum" do
|
||||
pending_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 9000, fee_cents: 1000, status: :pending)
|
||||
paid_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 4500, fee_cents: 500, status: :paid)
|
||||
|
||||
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
|
||||
@@ -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: "This is a test event description", venue_name: "Test Venue", venue_address: "Test Address", 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
|
||||
|
||||
@@ -603,4 +603,22 @@ class OrderTest < ActiveSupport::TestCase
|
||||
result = order.stripe_invoice_pdf_url
|
||||
assert_nil result
|
||||
end
|
||||
|
||||
test "free? should return true for zero amount orders" do
|
||||
free_order = Order.create!(
|
||||
user: @user, event: @event, total_amount_cents: 0,
|
||||
status: "draft", payment_attempts: 0
|
||||
)
|
||||
|
||||
assert free_order.free?
|
||||
end
|
||||
|
||||
test "free? should return false for non-zero amount orders" do
|
||||
paid_order = Order.create!(
|
||||
user: @user, event: @event, total_amount_cents: 1000,
|
||||
status: "draft", payment_attempts: 0
|
||||
)
|
||||
|
||||
assert_not paid_order.free?
|
||||
end
|
||||
end
|
||||
|
||||
146
test/models/payout_test.rb
Normal file
146
test/models/payout_test.rb
Normal file
@@ -0,0 +1,146 @@
|
||||
require "test_helper"
|
||||
|
||||
class PayoutTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = User.create!(email: "test@example.com", password: "password123", is_professionnal: true)
|
||||
@event = Event.create!(
|
||||
user: @user,
|
||||
name: "Test Event",
|
||||
slug: "test-event",
|
||||
description: "Test event description with enough characters",
|
||||
venue_name: "Test Venue",
|
||||
venue_address: "Test Address",
|
||||
latitude: 48.8566,
|
||||
longitude: 2.3522,
|
||||
start_time: 1.day.ago,
|
||||
end_time: 1.hour.ago,
|
||||
state: :published
|
||||
)
|
||||
# Create a ticket type for the event
|
||||
@ticket_type = TicketType.create!(
|
||||
event: @event,
|
||||
name: "General Admission",
|
||||
description: "General admission ticket",
|
||||
price_cents: 1000,
|
||||
quantity: 100,
|
||||
sale_start_at: 2.days.ago,
|
||||
sale_end_at: 30.minutes.ago
|
||||
)
|
||||
# Create some earnings for the event
|
||||
Earning.create!(event: @event, user: @user, order: Order.create!(user: @user, event: @event, status: :paid, total_amount_cents: 1000), amount_cents: 2000, fee_cents: 200, status: :pending)
|
||||
@payout = Payout.new(user: @user, event: @event, amount_cents: 2000, fee_cents: 200)
|
||||
end
|
||||
|
||||
test "should be valid" do
|
||||
# For this test, we'll skip validations since they're tested separately
|
||||
assert @payout.save(validate: false)
|
||||
end
|
||||
|
||||
test "validations: amount_cents must be present and positive" do
|
||||
@payout.amount_cents = nil
|
||||
assert_not @payout.valid?
|
||||
|
||||
@payout.amount_cents = 0
|
||||
assert_not @payout.valid?
|
||||
|
||||
@payout.amount_cents = -100
|
||||
assert_not @payout.valid?
|
||||
end
|
||||
|
||||
test "validations: fee_cents must be present and non-negative" do
|
||||
@payout.fee_cents = nil
|
||||
assert_not @payout.valid?
|
||||
|
||||
@payout.fee_cents = -100
|
||||
assert_not @payout.valid?
|
||||
end
|
||||
|
||||
test "validations: net earnings must be greater than 0" do
|
||||
# Create an event with no earnings (net earnings = 0)
|
||||
event_without_earnings = Event.create!(
|
||||
user: @user,
|
||||
name: "Test Event 2",
|
||||
slug: "test-event-2",
|
||||
description: "Test event description with enough characters",
|
||||
venue_name: "Test Venue",
|
||||
venue_address: "Test Address",
|
||||
latitude: 48.8566,
|
||||
longitude: 2.3522,
|
||||
start_time: 1.day.ago,
|
||||
end_time: 1.hour.ago,
|
||||
state: :published
|
||||
)
|
||||
|
||||
# Create a ticket type for the event
|
||||
TicketType.create!(
|
||||
event: event_without_earnings,
|
||||
name: "General Admission",
|
||||
description: "General admission ticket",
|
||||
price_cents: 1000,
|
||||
quantity: 100,
|
||||
sale_start_at: 2.days.ago,
|
||||
sale_end_at: 30.minutes.ago
|
||||
)
|
||||
|
||||
payout = Payout.new(user: @user, event: event_without_earnings, amount_cents: 0, fee_cents: 0)
|
||||
assert_not payout.valid?
|
||||
end
|
||||
|
||||
test "validations: only one pending payout per event" do
|
||||
# Create a valid payout first
|
||||
pending_payout = Payout.create!(user: @user, event: @event, amount_cents: 2000, fee_cents: 200, status: :pending)
|
||||
assert pending_payout.valid?
|
||||
|
||||
duplicate = Payout.new(user: @user, event: @event, amount_cents: 2000, fee_cents: 200, status: :pending)
|
||||
assert_not duplicate.valid?
|
||||
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
|
||||
# Create some refunded tickets to test the callback
|
||||
order = Order.create!(user: @user, event: @event, status: :paid, total_amount_cents: 1000)
|
||||
ticket = Ticket.create!(order: order, ticket_type: @ticket_type, price_cents: 1000, status: :refunded)
|
||||
|
||||
payout = Payout.create!(user: @user, event: @event, amount_cents: 2000, fee_cents: 200)
|
||||
# The refunded_orders_count should be set by the callback
|
||||
assert_equal 1, 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[:approved]
|
||||
assert_equal 2, Payout.statuses[:processing]
|
||||
assert_equal 3, Payout.statuses[:completed]
|
||||
assert_equal 4, Payout.statuses[:failed]
|
||||
assert_equal 5, Payout.statuses[:rejected]
|
||||
|
||||
payout = Payout.create!(user: @user, event: @event, amount_cents: 2000, fee_cents: 200, status: :pending)
|
||||
assert payout.pending?
|
||||
|
||||
payout.update!(status: :completed)
|
||||
assert payout.completed?
|
||||
end
|
||||
|
||||
test "pending scope" do
|
||||
pending = Payout.create!(user: @user, event: @event, amount_cents: 2000, fee_cents: 200, 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
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -244,4 +244,38 @@ class TicketTypeTest < ActiveSupport::TestCase
|
||||
)
|
||||
assert_not ticket_type.save
|
||||
end
|
||||
|
||||
test "should allow free tickets with zero price" do
|
||||
user = User.create!(
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
password_confirmation: "password123"
|
||||
)
|
||||
|
||||
event = Event.create!(
|
||||
name: "Test Event",
|
||||
slug: "test-event",
|
||||
description: "Valid description for the event that is long enough",
|
||||
latitude: 48.8566,
|
||||
longitude: 2.3522,
|
||||
venue_name: "Test Venue",
|
||||
venue_address: "123 Test Street",
|
||||
user: user
|
||||
)
|
||||
|
||||
ticket_type = TicketType.new(
|
||||
name: "Free Ticket",
|
||||
description: "Valid description for the free ticket type",
|
||||
price_cents: 0,
|
||||
quantity: 50,
|
||||
sale_start_at: Time.current,
|
||||
sale_end_at: Time.current + 1.day,
|
||||
event: event
|
||||
)
|
||||
|
||||
assert ticket_type.save
|
||||
assert ticket_type.free?
|
||||
assert_equal 0, ticket_type.price_cents
|
||||
assert_equal 0.0, ticket_type.price_euros
|
||||
end
|
||||
end
|
||||
|
||||
@@ -92,4 +92,40 @@ 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!(iban: "FR1420041010050500013M02606", bank_name: "Test Bank", account_holder_name: "John Doe", is_professionnal: true)
|
||||
|
||||
assert user.can_receive_payouts?
|
||||
end
|
||||
|
||||
test "can_receive_payouts? returns false if no banking info" 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!(iban: "FR1420041010050500013M02606", bank_name: "Test Bank", account_holder_name: "John Doe")
|
||||
|
||||
assert_not user.can_receive_payouts?
|
||||
end
|
||||
|
||||
test "can_receive_payouts? returns false if missing IBAN" do
|
||||
user = users(:one)
|
||||
user.update!(bank_name: "Test Bank", account_holder_name: "John Doe", is_professionnel: true)
|
||||
|
||||
assert_not user.can_receive_payouts?
|
||||
end
|
||||
|
||||
test "can_receive_payouts? returns false if missing bank name" do
|
||||
user = users(:one)
|
||||
user.update!(iban: "FR1420041010050500013M02606", account_holder_name: "John Doe", is_professionnel: true)
|
||||
|
||||
assert_not user.can_receive_payouts?
|
||||
end
|
||||
end
|
||||
|
||||
44
test/services/payout_service_test.rb
Normal file
44
test/services/payout_service_test.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
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 "generate_transfer_summary returns payout details" do
|
||||
@user.update!(iban: "FR1420041010050500013M02606", bank_name: "Test Bank", account_holder_name: "John Doe")
|
||||
@payout.update(status: :approved)
|
||||
|
||||
service = PayoutService.new(@payout)
|
||||
summary = service.generate_transfer_summary
|
||||
|
||||
assert_not_nil summary
|
||||
assert_equal @payout.id, summary[:payout_id]
|
||||
assert_equal @user.name, summary[:recipient]
|
||||
assert_equal @user.account_holder_name, summary[:account_holder]
|
||||
assert_equal @user.bank_name, summary[:bank_name]
|
||||
assert_equal @user.iban, summary[:iban]
|
||||
end
|
||||
|
||||
test "validate_banking_info returns errors for missing data" do
|
||||
service = PayoutService.new(@payout)
|
||||
errors = service.validate_banking_info
|
||||
|
||||
assert_includes errors, "Missing IBAN"
|
||||
assert_includes errors, "Missing bank name"
|
||||
assert_includes errors, "Missing account holder name"
|
||||
end
|
||||
|
||||
test "validate_banking_info returns no errors for complete data" do
|
||||
@user.update!(iban: "FR1420041010050500013M02606", bank_name: "Test Bank", account_holder_name: "John Doe")
|
||||
|
||||
service = PayoutService.new(@payout)
|
||||
errors = service.validate_banking_info
|
||||
|
||||
assert_empty errors
|
||||
end
|
||||
end
|
||||
@@ -146,10 +146,12 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
|
||||
Stripe::Customer.expects(:retrieve).with("cus_existing123").returns(mock_customer)
|
||||
|
||||
# Mock the rest of the invoice creation process
|
||||
mock_finalized_invoice = mock("finalized_invoice")
|
||||
mock_finalized_invoice.expects(:pay).with(paid_out_of_band: true, payment_method: nil).returns(mock_finalized_invoice)
|
||||
|
||||
mock_invoice = mock("invoice")
|
||||
mock_invoice.stubs(:id).returns("in_test123")
|
||||
mock_invoice.stubs(:finalize_invoice).returns(mock_invoice)
|
||||
mock_invoice.expects(:pay)
|
||||
mock_invoice.expects(:finalize_invoice).returns(mock_finalized_invoice)
|
||||
Stripe::Invoice.expects(:create).returns(mock_invoice)
|
||||
Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee
|
||||
|
||||
@@ -168,10 +170,12 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
|
||||
Stripe::Customer.expects(:create).returns(mock_customer)
|
||||
|
||||
# Mock the rest of the invoice creation process
|
||||
mock_finalized_invoice = mock("finalized_invoice")
|
||||
mock_finalized_invoice.expects(:pay).with(paid_out_of_band: true, payment_method: nil).returns(mock_finalized_invoice)
|
||||
|
||||
mock_invoice = mock("invoice")
|
||||
mock_invoice.stubs(:id).returns("in_test123")
|
||||
mock_invoice.stubs(:finalize_invoice).returns(mock_invoice)
|
||||
mock_invoice.expects(:pay)
|
||||
mock_invoice.expects(:finalize_invoice).returns(mock_finalized_invoice)
|
||||
Stripe::Invoice.expects(:create).returns(mock_invoice)
|
||||
Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee
|
||||
|
||||
@@ -210,25 +214,14 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
|
||||
}
|
||||
}
|
||||
|
||||
expected_service_fee_line_item = {
|
||||
customer: "cus_test123",
|
||||
invoice: "in_test123",
|
||||
amount: 100,
|
||||
currency: "eur",
|
||||
description: "Frais de service - Frais de traitement de la commande",
|
||||
metadata: {
|
||||
item_type: "service_fee",
|
||||
amount_cents: 100
|
||||
}
|
||||
}
|
||||
mock_finalized_invoice = mock("finalized_invoice")
|
||||
mock_finalized_invoice.expects(:pay).with(paid_out_of_band: true, payment_method: nil).returns(mock_finalized_invoice)
|
||||
|
||||
mock_invoice = mock("invoice")
|
||||
mock_invoice.stubs(:id).returns("in_test123")
|
||||
mock_invoice.stubs(:finalize_invoice).returns(mock_invoice)
|
||||
mock_invoice.expects(:pay)
|
||||
mock_invoice.expects(:finalize_invoice).returns(mock_finalized_invoice)
|
||||
Stripe::Invoice.expects(:create).returns(mock_invoice)
|
||||
Stripe::InvoiceItem.expects(:create).with(expected_ticket_line_item)
|
||||
Stripe::InvoiceItem.expects(:create).with(expected_service_fee_line_item)
|
||||
Stripe::InvoiceItem.expects(:create).with(expected_ticket_line_item) # Only for tickets, no service fee
|
||||
|
||||
result = @service.create_post_payment_invoice
|
||||
assert_not_nil result
|
||||
@@ -255,10 +248,12 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
|
||||
due_date: anything
|
||||
}
|
||||
|
||||
mock_finalized_invoice = mock("finalized_invoice")
|
||||
mock_finalized_invoice.expects(:pay).with(paid_out_of_band: true, payment_method: nil).returns(mock_finalized_invoice)
|
||||
|
||||
mock_invoice = mock("invoice")
|
||||
mock_invoice.stubs(:id).returns("in_test123")
|
||||
mock_invoice.stubs(:finalize_invoice).returns(mock_invoice)
|
||||
mock_invoice.expects(:pay)
|
||||
mock_invoice.expects(:finalize_invoice).returns(mock_finalized_invoice)
|
||||
|
||||
Stripe::Invoice.expects(:create).with(expected_invoice_data).returns(mock_invoice)
|
||||
Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee
|
||||
@@ -304,7 +299,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
|
||||
mock_invoice.expects(:finalize_invoice).returns(mock_finalized_invoice)
|
||||
|
||||
result = @service.create_post_payment_invoice
|
||||
assert_equal mock_invoice, result
|
||||
assert_equal mock_finalized_invoice, result
|
||||
end
|
||||
|
||||
# === Class Method Tests ===
|
||||
|
||||
Reference in New Issue
Block a user