Compare commits
32 Commits
24126eb834
...
feature/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47f4f50e5b | ||
|
|
e4509b1c43 | ||
|
|
59e1854803 | ||
|
|
58141dca94 | ||
|
|
d2c43cfc2f | ||
|
|
bc09feafc1 | ||
|
|
d922d7304d | ||
|
|
0399761fb3 | ||
|
|
e5ed1a34dd | ||
| 3e0a354a58 | |||
|
|
b5c1846f2c | ||
|
|
04393add14 | ||
|
|
5279ebe1a4 | ||
|
|
329ba89eaa | ||
|
|
9c56b2e1e5 | ||
| a8d3bc12ae | |||
|
|
b228d5a174 | ||
|
|
61ad8c64d4 | ||
|
|
4e06f91acb | ||
|
|
28eddb22ab | ||
|
|
a34eb7aa38 | ||
|
|
aa68885b84 | ||
|
|
c1dde7914c | ||
|
|
dbb972e490 | ||
|
|
049e5505ef | ||
|
|
d6184b6c84 | ||
|
|
4cde466f9a | ||
|
|
ee43996a77 | ||
|
|
f0d32bf3f1 | ||
|
|
20f926cd7a | ||
|
|
d1ef962f74 | ||
|
|
e84d9aad5b |
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
|
||||
|
||||
1
Gemfile
1
Gemfile
@@ -75,6 +75,7 @@ group :test do
|
||||
gem "rails-controller-testing"
|
||||
# For mocking and stubbing
|
||||
gem "mocha"
|
||||
gem "timecop"
|
||||
end
|
||||
|
||||
gem "devise", "~> 4.9"
|
||||
|
||||
@@ -381,6 +381,7 @@ GEM
|
||||
thruster (0.1.15-aarch64-linux)
|
||||
thruster (0.1.15-x86_64-darwin)
|
||||
thruster (0.1.15-x86_64-linux)
|
||||
timecop (0.9.10)
|
||||
timeout (0.4.3)
|
||||
ttfunk (1.8.0)
|
||||
bigdecimal (~> 3.1)
|
||||
@@ -452,6 +453,7 @@ DEPENDENCIES
|
||||
stimulus-rails
|
||||
stripe (~> 15.5)
|
||||
thruster
|
||||
timecop
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
web-console
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
31
app/controllers/admin/payouts_controller.rb
Normal file
31
app/controllers/admin/payouts_controller.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
class Admin::PayoutsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_admin!
|
||||
|
||||
def index
|
||||
@payouts = Payout.includes(:event, :user)
|
||||
.order(created_at: :desc)
|
||||
.page(params[:page])
|
||||
end
|
||||
|
||||
def create
|
||||
@payout = Payout.find(params[:id])
|
||||
|
||||
begin
|
||||
@payout.process_payout!
|
||||
redirect_to admin_payouts_path, notice: "Payout processed successfully."
|
||||
rescue => e
|
||||
redirect_to admin_payouts_path, alert: "Failed to process payout: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_admin!
|
||||
# For now, we'll just check if the user has a stripe account
|
||||
# In a real app, you'd have an admin role check
|
||||
unless current_user.has_stripe_account?
|
||||
redirect_to dashboard_path, alert: "Access denied."
|
||||
end
|
||||
end
|
||||
end
|
||||
25
app/controllers/api/v1/carts_controller.rb
Normal file
25
app/controllers/api/v1/carts_controller.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
module Api
|
||||
module V1
|
||||
class CartsController < ApiController
|
||||
# Skip API key authentication for store_cart action (used by frontend forms)
|
||||
skip_before_action :authenticate_api_key, only: [ :store ]
|
||||
|
||||
def store
|
||||
event_id = params[:event_id]
|
||||
@event = Event.find(event_id)
|
||||
|
||||
cart_data = params[:cart] || {}
|
||||
session[:pending_cart] = cart_data
|
||||
session[:event_id] = @event.id
|
||||
|
||||
render json: { status: "success", message: "Cart stored successfully" }
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { status: "error", message: "Event not found" }, status: :not_found
|
||||
rescue => e
|
||||
error_message = e.message.present? ? e.message : "Unknown error"
|
||||
Rails.logger.error "Error storing cart: #{error_message}"
|
||||
render json: { status: "error", message: "Failed to store cart" }, status: 500
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
# Contrôleur API pour la gestion des ressources d'événements
|
||||
# Fournit des points de terminaison RESTful pour les opérations CRUD sur le modèle Event
|
||||
# API Controller for managing event resources
|
||||
# Provides RESTful endpoints for CRUD operations on the Event model
|
||||
|
||||
module Api
|
||||
module V1
|
||||
@@ -7,27 +7,27 @@ module Api
|
||||
# Skip API key authentication for store_cart action (used by frontend forms)
|
||||
skip_before_action :authenticate_api_key, only: [ :store_cart ]
|
||||
|
||||
# Charge l'évén avant certaines actions pour réduire les duplications
|
||||
# Loads the event before certain actions to reduce duplications
|
||||
before_action :set_event, only: [ :show, :update, :destroy, :store_cart ]
|
||||
|
||||
# GET /api/v1/events
|
||||
# Récupère tous les événements triés par date de création (du plus récent au plus ancien)
|
||||
# Retrieves all events sorted by creation date (most recent first)
|
||||
def index
|
||||
@events = Event.all.order(created_at: :desc)
|
||||
render json: @events, status: :ok
|
||||
end
|
||||
|
||||
# GET /api/v1/events/:id
|
||||
# Récupère un seul événement par son ID
|
||||
# Retourne 404 si l'événement n'est pas trouvé
|
||||
# Retrieves a single event by its ID
|
||||
# Returns 404 if the event is not found
|
||||
def show
|
||||
render json: @event, status: :ok
|
||||
end
|
||||
|
||||
# POST /api/v1/events
|
||||
# Crée un nouvel événement avec les attributs fournis
|
||||
# Retourne 201 Created en cas de succès avec les données de l'événement
|
||||
# Retourne 422 Unprocessable Entity avec les messages d'erreur en cas d'échec
|
||||
# Creates a new event with the provided attributes
|
||||
# Returns 201 Created on success with the event data
|
||||
# Returns 422 Unprocessable Entity with error messages on failure
|
||||
def create
|
||||
@event = Event.new(event_params)
|
||||
if @event.save
|
||||
@@ -38,9 +38,9 @@ module Api
|
||||
end
|
||||
|
||||
# PATCH/PUT /api/v1/events/:id
|
||||
# Met à jour un événement existant avec les attributs fournis
|
||||
# Retourne 200 OK avec les données mises à jour en cas de succès
|
||||
# Retourne 422 Unprocessable Entity avec les messages d'erreur en cas d'échec
|
||||
# Updates an existing event with the provided attributes
|
||||
# Returns 200 OK with updated data on success
|
||||
# Returns 422 Unprocessable Entity with error messages on failure
|
||||
def update
|
||||
if @event.update(event_params)
|
||||
render json: @event, status: :ok
|
||||
@@ -50,8 +50,8 @@ module Api
|
||||
end
|
||||
|
||||
# DELETE /api/v1/events/:id
|
||||
# Supprime définitivement un événement
|
||||
# Retourne 204 No Content en cas de succès
|
||||
# Permanently deletes an event
|
||||
# Returns 204 No Content on success
|
||||
def destroy
|
||||
@event.destroy
|
||||
head :no_content
|
||||
@@ -66,33 +66,37 @@ module Api
|
||||
|
||||
render json: { status: "success", message: "Cart stored successfully" }
|
||||
rescue => e
|
||||
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
||||
error_message = e.message.present? ? e.message : "Unknown error"
|
||||
Rails.logger.error "Error storing cart: #{error_message}"
|
||||
render json: { status: "error", message: "Failed to store cart" }, status: 500
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Trouve un événement par son ID ou retourne 404 Introuvable
|
||||
# Utilisé comme before_action pour les actions show, update et destroy
|
||||
# Finds an event by its ID or returns 404 Not Found
|
||||
# Used as before_action for the show, update, and destroy actions
|
||||
def set_event
|
||||
@event = Event.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "Événement non trouvé" }, status: :not_found
|
||||
render json: { error: "Event not found" }, status: :not_found
|
||||
end
|
||||
|
||||
# Paramètres forts pour la création et la mise à jour des événements
|
||||
# Liste blanche des attributs autorisés pour éviter les vulnérabilités de mass assignment
|
||||
# Strong parameters for creating and updating events
|
||||
# Whitelist of allowed attributes to avoid mass assignment vulnerabilities
|
||||
def event_params
|
||||
params.require(:event).permit(
|
||||
:name,
|
||||
:slug,
|
||||
:description,
|
||||
:state,
|
||||
:venue_name,
|
||||
:venue_address,
|
||||
:start_time,
|
||||
:end_time,
|
||||
:latitude,
|
||||
:longitude,
|
||||
:featured
|
||||
:featured,
|
||||
:user_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
module Api
|
||||
module V1
|
||||
class OrdersController < ApiController
|
||||
# Skip API key authentication for store_cart action (used by frontend forms)
|
||||
skip_before_action :authenticate_api_key, only: [ :store_cart ]
|
||||
|
||||
before_action :set_order, only: [ :show, :checkout, :retry_payment, :increment_payment_attempt ]
|
||||
before_action :set_event, only: [ :new, :create ]
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Provides authentication and common functionality for API controllers
|
||||
class ApiController < ApplicationController
|
||||
# Disable CSRF protection for API requests (token-based authentication instead)
|
||||
protect_from_forgery with: :null_session
|
||||
protect_from_forgery prepend: true
|
||||
|
||||
# Authenticate all API requests using API key
|
||||
# Must be called before any API action
|
||||
|
||||
@@ -38,8 +38,6 @@ class ApplicationController < ActionController::Base
|
||||
# Skip for API endpoints
|
||||
controller_name.start_with?("api/") ||
|
||||
# Skip for health checks
|
||||
controller_name == "rails/health" ||
|
||||
# Skip for home page (when not signed in)
|
||||
(controller_name == "pages" && action_name == "home")
|
||||
controller_name == "rails/health"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -126,6 +126,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
|
||||
@@ -293,18 +302,7 @@ class OrdersController < ApplicationController
|
||||
}
|
||||
end
|
||||
|
||||
# Add service fee as a separate line item
|
||||
line_items << {
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
product_data: {
|
||||
name: "Frais de service",
|
||||
description: "Frais de traitement de la commande"
|
||||
},
|
||||
unit_amount: 100 # 1€ in cents
|
||||
},
|
||||
quantity: 1
|
||||
}
|
||||
# No service fee added to customer; deducted from promoter payout
|
||||
|
||||
Stripe::Checkout::Session.create(
|
||||
payment_method_types: [ "card" ],
|
||||
|
||||
@@ -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 ]
|
||||
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,28 @@ 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"
|
||||
@new_event = @event.duplicate(clone_ticket_types: clone_ticket_types)
|
||||
|
||||
if @new_event
|
||||
redirect_to edit_promoter_event_path(@new_event), notice: "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié."
|
||||
else
|
||||
redirect_to promoter_event_path(@event), alert: "Erreur lors de la duplication de l'événement."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_can_manage_events!
|
||||
|
||||
66
app/controllers/promoter/payouts_controller.rb
Normal file
66
app/controllers/promoter/payouts_controller.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
class Promoter::PayoutsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_promoter!
|
||||
before_action :set_event, only: [:show, :create]
|
||||
|
||||
# List all payouts for the current promoter
|
||||
def index
|
||||
@payouts = current_user.payouts
|
||||
.includes(:event)
|
||||
.order(created_at: :desc)
|
||||
.page(params[:page])
|
||||
end
|
||||
|
||||
# Show payout details
|
||||
def show
|
||||
@payout = @event.payouts.find(params[:id])
|
||||
end
|
||||
|
||||
# Create a new payout request
|
||||
def create
|
||||
# Check if event can request payout
|
||||
unless @event.can_request_payout?
|
||||
redirect_to promoter_event_path(@event), alert: "Payout cannot be requested for this event."
|
||||
return
|
||||
end
|
||||
|
||||
# Calculate payout amount
|
||||
total_earnings_cents = @event.total_earnings_cents
|
||||
total_fees_cents = @event.total_fees_cents
|
||||
net_earnings_cents = @event.net_earnings_cents
|
||||
|
||||
# Count orders
|
||||
total_orders_count = @event.orders.where(status: ['paid', 'completed']).count
|
||||
refunded_orders_count = @event.tickets.where(status: 'refunded').joins(:order).where(orders: {status: ['paid', 'completed']}).count
|
||||
|
||||
# Create payout record
|
||||
@payout = @event.payouts.build(
|
||||
user: current_user,
|
||||
amount_cents: total_earnings_cents,
|
||||
fee_cents: total_fees_cents,
|
||||
total_orders_count: total_orders_count,
|
||||
refunded_orders_count: refunded_orders_count
|
||||
)
|
||||
|
||||
if @payout.save
|
||||
# Update event payout status
|
||||
@event.update!(payout_status: :requested, payout_requested_at: Time.current)
|
||||
|
||||
redirect_to promoter_payout_path(@payout), notice: "Payout request submitted successfully."
|
||||
else
|
||||
redirect_to promoter_event_path(@event), alert: "Failed to submit payout request: #{@payout.errors.full_messages.join(', ')}"
|
||||
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
|
||||
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
|
||||
|
||||
71
app/javascript/controllers/countdown_controller.js
Normal file
71
app/javascript/controllers/countdown_controller.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Countdown controller for displaying remaining time until order expiration
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
expiresAt: String, // ISO timestamp when the order expires
|
||||
orderId: Number // Order ID for identification
|
||||
}
|
||||
|
||||
connect() {
|
||||
// Parse the expiration timestamp
|
||||
this.expirationTime = new Date(this.expiresAtValue).getTime()
|
||||
|
||||
// Find the countdown element
|
||||
this.countdownElement = this.element.querySelector('.countdown-timer')
|
||||
|
||||
if (this.countdownElement && !isNaN(this.expirationTime)) {
|
||||
// Start the countdown
|
||||
this.updateCountdown()
|
||||
this.timer = setInterval(() => this.updateCountdown(), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// Clean up the interval when the controller disconnects
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
}
|
||||
}
|
||||
|
||||
updateCountdown() {
|
||||
const now = new Date().getTime()
|
||||
const distance = this.expirationTime - now
|
||||
|
||||
// If the countdown is finished
|
||||
if (distance < 0) {
|
||||
this.countdownElement.innerHTML = "EXPIRÉ"
|
||||
this.countdownElement.classList.add("text-red-600", "font-bold")
|
||||
this.countdownElement.classList.remove("text-orange-600")
|
||||
|
||||
// Add a more urgent visual indicator
|
||||
this.element.classList.add("bg-red-50", "border-red-200")
|
||||
this.element.classList.remove("bg-orange-50", "border-orange-200")
|
||||
|
||||
// Stop the timer
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate time components
|
||||
const seconds = Math.floor(distance / 1000)
|
||||
|
||||
// Display the result
|
||||
this.countdownElement.innerHTML = `${seconds} secondes`
|
||||
|
||||
// Add urgency styling when time is running low
|
||||
if (seconds < 60) {
|
||||
this.countdownElement.classList.add("text-red-600", "font-bold")
|
||||
this.countdownElement.classList.remove("text-orange-600")
|
||||
|
||||
// Add background warning for extra urgency
|
||||
this.element.classList.add("bg-red-50", "border-red-200")
|
||||
this.element.classList.remove("bg-orange-50", "border-orange-200")
|
||||
} else if (seconds < 300) { // Less than 5 minutes
|
||||
this.countdownElement.classList.add("text-orange-600", "font-bold")
|
||||
this.element.classList.add("bg-orange-50", "border-orange-200")
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/javascript/controllers/event_duplication_controller.js
Normal file
53
app/javascript/controllers/event_duplication_controller.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["cloneTicketTypes"]
|
||||
static values = {
|
||||
duplicateUrl: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
// Get modal element from the document
|
||||
this.modalElement = document.querySelector('[data-event-duplication-target="modal"]')
|
||||
}
|
||||
|
||||
open() {
|
||||
this.modalElement.classList.remove('hidden')
|
||||
document.body.classList.add('overflow-hidden')
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modalElement.classList.add('hidden')
|
||||
document.body.classList.remove('overflow-hidden')
|
||||
}
|
||||
|
||||
duplicate() {
|
||||
const cloneTicketTypes = this.cloneTicketTypesTarget.checked
|
||||
|
||||
// Create form data
|
||||
const formData = new FormData()
|
||||
formData.append('clone_ticket_types', cloneTicketTypes)
|
||||
formData.append('authenticity_token', document.querySelector('meta[name="csrf-token"]').getAttribute('content'))
|
||||
|
||||
// Send request to duplicate endpoint
|
||||
fetch(this.duplicateUrlValue, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url
|
||||
} else {
|
||||
return response.json()
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error)
|
||||
alert('Erreur lors de la duplication de l\'événement.')
|
||||
this.close()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,9 @@ application.register("qr-code", QrCodeController);
|
||||
|
||||
import EventFormController from "./event_form_controller";
|
||||
application.register("event-form", EventFormController);
|
||||
|
||||
import CountdownController from "./countdown_controller";
|
||||
application.register("countdown", CountdownController);
|
||||
|
||||
import EventDuplicationController from "./event_duplication_controller";
|
||||
application.register("event-duplication", EventDuplicationController);
|
||||
|
||||
@@ -10,7 +10,7 @@ export default class extends Controller {
|
||||
"checkoutButton",
|
||||
"form",
|
||||
];
|
||||
static values = { eventSlug: String, eventId: String };
|
||||
static values = { eventSlug: String, eventId: String, orderNewUrl: String, storeCartUrl: String };
|
||||
|
||||
// Initialize the controller and update the cart summary
|
||||
connect() {
|
||||
@@ -117,9 +117,9 @@ export default class extends Controller {
|
||||
// Store cart data in session
|
||||
await this.storeCartInSession(cartData);
|
||||
|
||||
// Redirect to event-scoped orders/new page
|
||||
const OrderNewUrl = `/orders/new/events/${this.eventSlugValue}.${this.eventIdValue}`;
|
||||
window.location.href = OrderNewUrl;
|
||||
// Redirect to event-scoped orders/new page
|
||||
const orderNewUrl = this.orderNewUrlValue;
|
||||
window.location.href = orderNewUrl;
|
||||
} catch (error) {
|
||||
console.error("Error storing cart:", error);
|
||||
alert("Une erreur est survenue. Veuillez réessayer.");
|
||||
@@ -145,7 +145,7 @@ export default class extends Controller {
|
||||
|
||||
// Store cart data in session via AJAX
|
||||
async storeCartInSession(cartData) {
|
||||
const storeCartUrl = `/api/v1/events/${this.eventIdValue}/store_cart`;
|
||||
const storeCartUrl = this.storeCartUrlValue;
|
||||
|
||||
const response = await fetch(storeCartUrl, {
|
||||
method: "POST",
|
||||
@@ -155,7 +155,7 @@ export default class extends Controller {
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
.getAttribute("content"),
|
||||
},
|
||||
body: JSON.stringify({ cart: cartData }),
|
||||
body: JSON.stringify({ cart: cartData, event_id: this.eventIdValue }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class TicketMailer < ApplicationMailer
|
||||
helper :tickets
|
||||
|
||||
def purchase_confirmation_order(order)
|
||||
@order = order
|
||||
@user = order.user
|
||||
|
||||
16
app/models/earning.rb
Normal file
16
app/models/earning.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
class Earning < ApplicationRecord
|
||||
# === 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
|
||||
end
|
||||
@@ -16,16 +16,27 @@ class Event < ApplicationRecord
|
||||
sold_out: 3
|
||||
}, default: :draft
|
||||
|
||||
enum :payout_status, {
|
||||
not_requested: 0,
|
||||
requested: 1,
|
||||
processing: 2,
|
||||
completed: 3,
|
||||
failed: 4
|
||||
}, default: :not_requested
|
||||
|
||||
# === 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,26 @@ class Event < ApplicationRecord
|
||||
# Scope for published events ordered by start time
|
||||
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
|
||||
|
||||
|
||||
# === Instance Methods ===
|
||||
|
||||
# Payout methods
|
||||
def can_request_payout?
|
||||
event_ended? && earnings.pending.any? && user.can_receive_payouts?
|
||||
end
|
||||
|
||||
def total_earnings_cents
|
||||
# Only count earnings from non-refunded tickets
|
||||
earnings.pending.sum(:amount_cents)
|
||||
end
|
||||
|
||||
def total_fees_cents
|
||||
(total_earnings_cents * 0.1).to_i # 10% platform fee
|
||||
end
|
||||
|
||||
def net_earnings_cents
|
||||
total_earnings_cents - total_fees_cents
|
||||
end
|
||||
|
||||
# Check if coordinates were successfully geocoded or are fallback coordinates
|
||||
def geocoding_successful?
|
||||
coordinates_look_valid?
|
||||
@@ -96,6 +124,41 @@ class Event < ApplicationRecord
|
||||
Time.current >= end_time
|
||||
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?
|
||||
!!allow_booking_during_event
|
||||
end
|
||||
|
||||
# Duplicate an event with all its ticket types
|
||||
def duplicate(clone_ticket_types: true)
|
||||
# Duplicate the event
|
||||
new_event = self.dup
|
||||
new_event.name = "Copie de #{name}"
|
||||
new_event.slug = "#{slug}-copy-#{Time.current.to_i}"
|
||||
new_event.state = :draft
|
||||
new_event.created_at = Time.current
|
||||
new_event.updated_at = Time.current
|
||||
|
||||
Event.transaction do
|
||||
if new_event.save
|
||||
# Duplicate all ticket types if requested
|
||||
if clone_ticket_types
|
||||
ticket_types.each do |ticket_type|
|
||||
new_ticket_type = ticket_type.dup
|
||||
new_ticket_type.event = new_event
|
||||
new_ticket_type.save!
|
||||
end
|
||||
end
|
||||
new_event
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Determine if we should perform server-side geocoding
|
||||
|
||||
@@ -32,6 +32,7 @@ class Order < ApplicationRecord
|
||||
}
|
||||
|
||||
before_validation :set_expiry, on: :create
|
||||
after_update :create_earnings_if_paid, if: -> { saved_change_to_status? && status == "paid" }
|
||||
|
||||
# === Instance Methods ===
|
||||
|
||||
@@ -88,11 +89,37 @@ class Order < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate total from tickets plus 1€ service fee
|
||||
# Calculate total from ticket prices only (platform fee deducted from promoter payout)
|
||||
def calculate_total!
|
||||
ticket_total = tickets.sum(:price_cents)
|
||||
fee_cents = 100 # 1€ in cents
|
||||
update!(total_amount_cents: ticket_total + fee_cents)
|
||||
update!(total_amount_cents: ticket_total)
|
||||
end
|
||||
|
||||
# Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket
|
||||
def platform_fee_cents
|
||||
tickets.sum do |ticket|
|
||||
fixed_fee = 50 # €0.50 in cents
|
||||
percentage_fee = (ticket.price_cents * 0.015).to_i
|
||||
fixed_fee + percentage_fee
|
||||
end
|
||||
end
|
||||
|
||||
# Promoter payout amount after platform fee deduction
|
||||
def promoter_payout_cents
|
||||
total_amount_cents - platform_fee_cents
|
||||
end
|
||||
|
||||
def platform_fee_euros
|
||||
platform_fee_cents / 100.0
|
||||
end
|
||||
|
||||
def promoter_payout_euros
|
||||
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
|
||||
@@ -136,4 +163,17 @@ class Order < ApplicationRecord
|
||||
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
|
||||
|
||||
59
app/models/payout.rb
Normal file
59
app/models/payout.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
class Payout < ApplicationRecord
|
||||
# === Relations ===
|
||||
belongs_to :user
|
||||
belongs_to :event
|
||||
|
||||
# === Enums ===
|
||||
enum :status, {
|
||||
pending: 0, # Payout requested but not processed
|
||||
processing: 1, # Payout being processed
|
||||
completed: 2, # Payout successfully completed
|
||||
failed: 3 # Payout failed
|
||||
}, default: :pending
|
||||
|
||||
# === 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 :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
|
||||
|
||||
# === Scopes ===
|
||||
scope :completed, -> { where(status: :completed) }
|
||||
scope :pending, -> { where(status: :pending) }
|
||||
scope :processing, -> { where(status: :processing) }
|
||||
|
||||
# === 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 processed
|
||||
def can_process?
|
||||
pending? && amount_cents > 0
|
||||
end
|
||||
|
||||
# Process the payout through Stripe
|
||||
def process_payout!
|
||||
service = PayoutService.new(self)
|
||||
service.process!
|
||||
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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
class TicketType < ApplicationRecord
|
||||
# Associations
|
||||
belongs_to :event
|
||||
has_many :tickets, dependent: :destroy
|
||||
has_many :tickets, dependent: :destroy # Cannot delete ticket types if already tickets sold
|
||||
|
||||
# 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
|
||||
@@ -80,6 +84,10 @@ class TicketType < ApplicationRecord
|
||||
|
||||
def sale_times_within_event_period
|
||||
return unless event&.start_time && sale_end_at
|
||||
errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time
|
||||
|
||||
# Only enforce this restriction if booking during event is not allowed
|
||||
unless event.allow_booking_during_event?
|
||||
errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,6 +23,8 @@ 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 }
|
||||
@@ -48,4 +50,21 @@ 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_connected_account_id
|
||||
end
|
||||
|
||||
def has_stripe_account?
|
||||
stripe_connected_account_id.present?
|
||||
end
|
||||
|
||||
def can_receive_payouts?
|
||||
has_stripe_account? && promoter?
|
||||
end
|
||||
end
|
||||
|
||||
30
app/services/payout_service.rb
Normal file
30
app/services/payout_service.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
class PayoutService
|
||||
def initialize(payout)
|
||||
@payout = payout
|
||||
end
|
||||
|
||||
def process!
|
||||
return unless @payout.can_process?
|
||||
|
||||
@payout.update!(status: :processing)
|
||||
|
||||
# Create Stripe payout
|
||||
begin
|
||||
stripe_payout = Stripe::Payout.create({
|
||||
amount: @payout.amount_cents,
|
||||
currency: 'eur',
|
||||
destination: @payout.user.stripe_account_id,
|
||||
description: "Payout for event: #{@payout.event.name}"
|
||||
})
|
||||
|
||||
@payout.update!(
|
||||
status: :completed,
|
||||
stripe_payout_id: stripe_payout.id
|
||||
)
|
||||
rescue Stripe::StripeError => e
|
||||
@payout.update!(status: :failed)
|
||||
Rails.logger.error "Stripe payout failed for payout #{@payout.id}: #{e.message}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
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
|
||||
@@ -166,19 +166,7 @@ class StripeInvoiceService
|
||||
})
|
||||
end
|
||||
|
||||
# Add service fee line item
|
||||
service_fee_cents = 100 # 1€ service fee
|
||||
Stripe::InvoiceItem.create({
|
||||
customer: customer.id,
|
||||
invoice: invoice.id,
|
||||
amount: service_fee_cents,
|
||||
currency: "eur",
|
||||
description: "Frais de service - Frais de traitement de la commande",
|
||||
metadata: {
|
||||
item_type: "service_fee",
|
||||
amount_cents: service_fee_cents
|
||||
}
|
||||
})
|
||||
# No service fee on customer invoice; platform fee deducted from promoter payout
|
||||
end
|
||||
|
||||
def build_line_item_description(ticket_type, tickets)
|
||||
|
||||
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>
|
||||
78
app/views/admin/payouts/index.html.erb
Normal file
78
app/views/admin/payouts/index.html.erb
Normal file
@@ -0,0 +1,78 @@
|
||||
<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">Admin Payouts</h1>
|
||||
</div>
|
||||
|
||||
<% if @payouts.any? %>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<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">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">
|
||||
<% @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>
|
||||
</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">
|
||||
<div class="text-sm text-gray-900">€<%= payout.amount_euros %></div>
|
||||
<div class="text-sm text-gray-500">Net: €<%= payout.net_amount_euros %> (Fee: €<%= payout.fee_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
|
||||
</span>
|
||||
<% when 'processing' %>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-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>
|
||||
<% 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">
|
||||
<% if payout.can_process? %>
|
||||
<%= button_to "Process", admin_payout_path(payout), method: :post,
|
||||
class: "text-indigo-600 hover:text-indigo-900 bg-indigo-100 hover:bg-indigo-200 px-3 py-1 rounded" %>
|
||||
<% end %>
|
||||
<%= link_to "View", promoter_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900 ml-2" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<% if @payouts.respond_to?(:total_pages) %>
|
||||
<div class="mt-6">
|
||||
<%= paginate @payouts %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<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>
|
||||
2
app/views/admin/payouts/show.html.erb
Normal file
2
app/views/admin/payouts/show.html.erb
Normal file
@@ -0,0 +1,2 @@
|
||||
<h1>Admin::Payouts#show</h1>
|
||||
<p>Find me in app/views/admin/payouts/show.html.erb</p>
|
||||
@@ -6,26 +6,41 @@
|
||||
<%# ] %>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="inline-flex items-center gap-2 bg-white px-4 py-3 rounded-xl shadow-sm border border-gray-100 mb-8" aria-label="Breadcrumb">
|
||||
<% crumbs.each_with_index do |crumb, index| %>
|
||||
<% if crumb[:path].present? %>
|
||||
<%# Crumb with link %>
|
||||
<%= link_to crumb[:path], class: "inline-flex items-center text-sm font-medium text-gray-700 hover:text-primary-600 transition-colors duration-200" do %>
|
||||
<% if index == 0 %>
|
||||
<i data-lucide="home" class="w-4 h-4 mr-2"></i>
|
||||
<nav class="w-full bg-white px-3 sm:px-4 py-3 rounded-xl shadow-sm border border-gray-100 mb-6 sm:mb-8 overflow-hidden" aria-label="Breadcrumb">
|
||||
<div class="flex items-center gap-1 sm:gap-2 min-w-0">
|
||||
<% crumbs.each_with_index do |crumb, index| %>
|
||||
<% if crumb[:path].present? %>
|
||||
<%# Crumb with link %>
|
||||
<%= link_to crumb[:path], class: "inline-flex items-center text-xs sm:text-sm font-medium text-gray-700 hover:text-primary-600 transition-colors duration-200 flex-shrink-0" do %>
|
||||
<% if index == 0 %>
|
||||
<i data-lucide="home" class="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0"></i>
|
||||
<% end %>
|
||||
<span class="<%= 'hidden sm:inline' if index > 0 && index < crumbs.length - 2 %>">
|
||||
<%= crumb[:name] %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%# Current page (no link) %>
|
||||
<span class="text-xs sm:text-sm font-medium text-primary-600 truncate min-w-0 flex-1" aria-current="page">
|
||||
<%= crumb[:name] %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%# Separator (except for the last item) %>
|
||||
<% if index < crumbs.length - 1 %>
|
||||
<% if index == 0 || index >= crumbs.length - 2 %>
|
||||
<i data-lucide="chevron-right" class="w-3 h-3 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0"></i>
|
||||
<% else %>
|
||||
<span class="hidden sm:inline">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400 flex-shrink-0"></i>
|
||||
</span>
|
||||
<% end %>
|
||||
<%= crumb[:name] %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%# Current page (no link) %>
|
||||
<span class="text-sm font-medium text-primary-600 truncate max-w-xs" aria-current="page">
|
||||
<%= crumb[:name] %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%# Separator (except for the last item) %>
|
||||
<% if index < crumbs.length - 1 %>
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
|
||||
|
||||
<%# Show ellipsis on mobile when there are more than 3 items %>
|
||||
<% if crumbs.length > 3 %>
|
||||
<span class="text-gray-400 text-xs font-medium sm:hidden flex-shrink-0">...</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -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>
|
||||
|
||||
@@ -107,10 +107,7 @@
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-4">Aucun événement disponible</h3>
|
||||
<p class="text-gray-600 mb-8 max-w-md mx-auto">Il n'y a aucun événement à venir pour le moment. Revenez bientôt pour découvrir de nouvelles sorties!</p>
|
||||
<%= link_to "Retour à l'accueil", root_path, class: "inline-flex items-center bg-purple-600 text-white px-6 py-3 rounded-full font-semibold hover:bg-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
|
||||
<i data-lucide="home" class="w-4 h-4 mr-2"></i>
|
||||
Retour à l'accueil
|
||||
<% end %>
|
||||
<%= link_to "<i data-lucide=\"home\" class=\"w-4 h-4 mr-2\"></i> Retour à l'accueil".html_safe, root_path, class: "inline-flex items-center bg-purple-600 text-white px-6 py-3 rounded-full font-semibold hover:bg-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</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,8 +135,10 @@
|
||||
controller: "ticket-selection",
|
||||
ticket_selection_target: "form",
|
||||
ticket_selection_event_slug_value: @event.slug,
|
||||
ticket_selection_event_id_value: @event.id
|
||||
} do |form| %>
|
||||
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">
|
||||
<div class="flex justify-center sm:justify-start mb-6">
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<div>
|
||||
<h3 class="font-medium text-blue-800 mb-1">Nouvelle tentative de paiement</h3>
|
||||
<p class="text-blue-700 text-sm">
|
||||
Tentative <%= @order.payment_attempts + 1 %> sur <%= @order.class::MAX_PAYMENT_ATTEMPTS %>.
|
||||
Tentative <%= @order.payment_attempts + 1 %> sur <%= @order.class::MAX_PAYMENT_ATTEMPTS %>.
|
||||
<% if @order.payment_attempts >= @order.class::MAX_PAYMENT_ATTEMPTS - 1 %>
|
||||
<strong>Dernière tentative avant expiration !</strong>
|
||||
<% end %>
|
||||
@@ -79,7 +79,7 @@
|
||||
<!-- Order Items -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Récapitulatif de votre commande</h3>
|
||||
|
||||
|
||||
<% @tickets.each do |ticket| %>
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -100,17 +100,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Order Total -->
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<div class=" pt-12">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Sous-total</span>
|
||||
<span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Frais de service</span>
|
||||
<span class="text-gray-900">1.00€</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -139,10 +131,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="checkout-button"
|
||||
class="w-full btn btn-primary py-4 px-6 rounded-xl transition-all duration-200 transform hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<button
|
||||
id="checkout-button"
|
||||
data-order-id="<%= @order.id %>"
|
||||
data-increment-url="/api/v1/orders/<%= @order.id %>/increment_payment_attempt"
|
||||
data-session-id="<%= @checkout_session.id if @checkout_session.present? %>"
|
||||
class="w-full btn btn-primary py-4 px-6 rounded-xl transition-all duration-200 transform hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<i data-lucide="credit-card" class="w-5 h-5 mr-2"></i>
|
||||
Payer <%= @order.total_amount_euros %>€
|
||||
@@ -176,7 +171,7 @@
|
||||
|
||||
console.log('Initializing Stripe with publishable key:', '<%= Rails.application.config.stripe[:publishable_key] %>');
|
||||
const stripe = Stripe('<%= Rails.application.config.stripe[:publishable_key] %>');
|
||||
|
||||
|
||||
const checkoutButton = document.getElementById('checkout-button');
|
||||
if (!checkoutButton) {
|
||||
console.error('Checkout button not found');
|
||||
@@ -199,13 +194,16 @@
|
||||
|
||||
try {
|
||||
// Increment payment attempt counter
|
||||
console.log('Incrementing payment attempt for order:', '<%= @order.id %>');
|
||||
const response = await fetch('/api/v1/orders/<%= @order.id %>/increment_payment_attempt', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const orderId = checkoutButton.dataset.orderId;
|
||||
const incrementUrl = checkoutButton.dataset.incrementUrl;
|
||||
console.log('Incrementing payment attempt for order:', orderId);
|
||||
const response = await fetch(incrementUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('[name=csrf-token]').content
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Payment attempt increment failed:', response.status, response.statusText);
|
||||
@@ -224,12 +222,13 @@
|
||||
Redirection vers le paiement...
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
// Redirect to Stripe
|
||||
console.log('Redirecting to Stripe with session ID:', '<%= @checkout_session&.id %>');
|
||||
const stripeResult = await stripe.redirectToCheckout({
|
||||
sessionId: '<%= @checkout_session.id %>'
|
||||
});
|
||||
const sessionId = checkoutButton.dataset.sessionId;
|
||||
console.log('Redirecting to Stripe with session ID:', sessionId);
|
||||
const stripeResult = await stripe.redirectToCheckout({
|
||||
sessionId: sessionId
|
||||
});
|
||||
|
||||
if (stripeResult.error) {
|
||||
throw new Error(stripeResult.error.message);
|
||||
@@ -283,4 +282,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -119,12 +119,6 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 text-right"><%= "%.2f" % (tickets.count * ticket_type.price_cents / 100.0) %>€</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">Frais de service</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">1</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">1.00€</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 text-right">1.00€</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot class="bg-gray-50">
|
||||
<tr>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
{ name: 'Accueil', path: root_path },
|
||||
{ name: 'Événements', path: events_path },
|
||||
@@ -16,7 +16,7 @@
|
||||
<!-- Order Summary -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Résumé de votre commande</h2>
|
||||
|
||||
|
||||
<% if @event %>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-800"><%= @event.name %></h3>
|
||||
@@ -114,4 +114,4 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,10 +135,6 @@
|
||||
<span class="text-gray-600">Sous-total</span>
|
||||
<span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Frais de service</span>
|
||||
<span class="text-gray-900">1.00€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
|
||||
<span class="font-medium text-gray-900">Total à payer</span>
|
||||
<span class="font-bold text-2xl text-red-600">
|
||||
@@ -195,4 +191,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,17 +124,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||
<div class="mt-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Sous-total</span>
|
||||
<span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Frais de service</span>
|
||||
<span class="text-gray-900">1.00€</span>
|
||||
</div>
|
||||
<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 %>€
|
||||
@@ -205,7 +197,7 @@
|
||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Retour au Tableau de Bord
|
||||
Retour au tableau de bord
|
||||
</div>
|
||||
<% end %>
|
||||
<%= link_to event_path(@order.event.slug, @order.event), class: "block w-full text-center py-3 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" do %>
|
||||
@@ -213,7 +205,7 @@
|
||||
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
Voir l'Événement Complet
|
||||
Voir la fiche de l'événement
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -96,22 +96,12 @@
|
||||
|
||||
<!-- Total -->
|
||||
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Sous-total</span>
|
||||
<span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Frais de service</span>
|
||||
<span class="text-gray-900">1.00€</span>
|
||||
</div>
|
||||
<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 %>€
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Invoice -->
|
||||
@@ -222,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 -->
|
||||
@@ -129,7 +142,10 @@
|
||||
<% @promoter_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">
|
||||
<h4 class="font-semibold text-gray-900 text-sm"><%= event.name %></h4>
|
||||
<%= link_to promoter_event_path(event) do %>
|
||||
<h4 class="font-semibold text-gray-900 text-sm"><%= event.name %></h4>
|
||||
<% end %>
|
||||
|
||||
<span class="text-xs px-2 py-1 rounded-full <%= event.state == 'published' ? 'bg-green-100 text-green-800' : event.state == 'draft' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800' %>">
|
||||
<%= event.state.humanize %>
|
||||
</span>
|
||||
@@ -144,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>
|
||||
@@ -162,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>
|
||||
@@ -246,7 +349,12 @@
|
||||
Tentatives: <%= order.payment_attempts %>/3
|
||||
</div>
|
||||
<% if order.expiring_soon? %>
|
||||
<span class="text-orange-600 font-medium">⚠️ Expire dans <%= time_ago_in_words(order.expires_at) %></span>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-orange-50 border border-orange-200 text-orange-600"
|
||||
data-controller="countdown"
|
||||
data-countdown-expires-at-value="<%= order.expires_at.iso8601 %>"
|
||||
data-countdown-order-id-value="<%= order.id %>">
|
||||
⚠️ Expire dans <span class="countdown-timer ml-1 font-bold"></span>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-gray-500">Expire dans <%= time_ago_in_words(order.expires_at) %></span>
|
||||
<% end %>
|
||||
@@ -385,6 +493,7 @@
|
||||
<% @upcoming_preview_events.each do |event| %>
|
||||
<div class="bg-gray-50 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<h4 class="font-medium text-gray-900 mb-2 text-base"><%= event.name %></h4>
|
||||
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<div class="flex items-center">
|
||||
<i data-lucide="calendar" class="w-4 h-4 mr-2"></i>
|
||||
|
||||
@@ -9,19 +9,22 @@
|
||||
{ name: 'Mes événements' }
|
||||
] %>
|
||||
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Mes événements</h1>
|
||||
<p class="text-gray-600">Gérez tous vos événements depuis cette interface</p>
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">Mes événements</h1>
|
||||
<p class="text-gray-600">Gérez tous vos événements depuis cette interface</p>
|
||||
</div>
|
||||
<%= link_to new_promoter_event_path, class: "inline-flex items-center justify-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200 w-full sm:w-auto" do %>
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
|
||||
Créer un événement
|
||||
<% end %>
|
||||
</div>
|
||||
<%= link_to new_promoter_event_path, class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
|
||||
Créer un événement
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @events.any? %>
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-200 overflow-hidden">
|
||||
<!-- Desktop Table View -->
|
||||
<div class="hidden lg:block bg-white rounded-2xl shadow-xl border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
@@ -30,7 +33,7 @@
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lieu</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-48 lg:w-auto">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@@ -81,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 %>
|
||||
@@ -94,27 +127,32 @@
|
||||
<div><%= event.venue_name %></div>
|
||||
<div class="text-xs text-gray-400 truncate max-w-xs"><%= event.venue_address %></div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= link_to promoter_event_path(event), class: "text-gray-400 hover:text-gray-600 transition-colors", title: "Voir" do %>
|
||||
<i data-lucide="eye" class="w-4 h-4"></i>
|
||||
<td class="px-6 py-4 w-48 lg:w-auto">
|
||||
<div class="flex flex-col lg:flex-row items-stretch lg:items-center space-y-2 lg:space-y-0 lg:space-x-2 min-w-0">
|
||||
<%= link_to promoter_event_path(event), class: "flex-1 lg:flex-initial inline-flex items-center justify-center px-3 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors lg:bg-transparent lg:text-gray-400 lg:hover:text-gray-600 lg:p-0 lg:rounded-none whitespace-nowrap", title: "Voir" do %>
|
||||
<i data-lucide="eye" class="w-4 h-4 lg:mr-0 mr-2"></i>
|
||||
<span class="lg:hidden">Voir</span>
|
||||
<% end %>
|
||||
<%= link_to edit_promoter_event_path(event), class: "text-gray-400 hover:text-blue-600 transition-colors", title: "Modifier" do %>
|
||||
<i data-lucide="edit" class="w-4 h-4"></i>
|
||||
<%= link_to edit_promoter_event_path(event), class: "flex-1 lg:flex-initial inline-flex items-center justify-center px-3 py-2 bg-blue-100 text-blue-700 text-sm font-medium rounded-lg hover:bg-blue-200 transition-colors lg:bg-transparent lg:text-gray-400 lg:hover:text-blue-600 lg:p-0 lg:rounded-none whitespace-nowrap", title: "Modifier" do %>
|
||||
<i data-lucide="edit" class="w-4 h-4 lg:mr-0 mr-2"></i>
|
||||
<span class="lg:hidden">Modifier</span>
|
||||
<% end %>
|
||||
<% if event.draft? %>
|
||||
<%= button_to publish_promoter_event_path(event), method: :patch, class: "text-gray-400 hover:text-green-600 transition-colors", title: "Publier" do %>
|
||||
<i data-lucide="upload" class="w-4 h-4"></i>
|
||||
<%= button_to publish_promoter_event_path(event), method: :patch, class: "flex-1 lg:flex-initial inline-flex items-center justify-center px-3 py-2 bg-green-100 text-green-700 text-sm font-medium rounded-lg hover:bg-green-200 transition-colors lg:bg-transparent lg:text-gray-400 lg:hover:text-green-600 lg:p-0 lg:rounded-none whitespace-nowrap", title: "Publier" do %>
|
||||
<i data-lucide="upload" class="w-4 h-4 lg:mr-0 mr-2"></i>
|
||||
<span class="lg:hidden">Publier</span>
|
||||
<% end %>
|
||||
<% elsif event.published? %>
|
||||
<%= button_to unpublish_promoter_event_path(event), method: :patch, class: "text-gray-400 hover:text-yellow-600 transition-colors", title: "Dépublier" do %>
|
||||
<i data-lucide="download" class="w-4 h-4"></i>
|
||||
<%= button_to unpublish_promoter_event_path(event), method: :patch, class: "flex-1 lg:flex-initial inline-flex items-center justify-center px-3 py-2 bg-yellow-100 text-yellow-700 text-sm font-medium rounded-lg hover:bg-yellow-200 transition-colors lg:bg-transparent lg:text-gray-400 lg:hover:text-yellow-600 lg:p-0 lg:rounded-none whitespace-nowrap", title: "Dépublier" do %>
|
||||
<i data-lucide="download" class="w-4 h-4 lg:mr-0 mr-2"></i>
|
||||
<span class="lg:hidden">Dépublier</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= button_to promoter_event_path(event), method: :delete,
|
||||
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ?" },
|
||||
class: "text-gray-400 hover:text-red-600 transition-colors", title: "Supprimer" do %>
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
class: "flex-1 lg:flex-initial inline-flex items-center justify-center px-3 py-2 bg-red-100 text-red-700 text-sm font-medium rounded-lg hover:bg-red-200 transition-colors lg:bg-transparent lg:text-gray-400 lg:hover:text-red-600 lg:p-0 lg:rounded-none whitespace-nowrap", title: "Supprimer" do %>
|
||||
<i data-lucide="trash-2" class="w-4 h-4 lg:mr-0 mr-2"></i>
|
||||
<span class="lg:hidden">Supprimer</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
@@ -125,17 +163,108 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Card View -->
|
||||
<div class="lg:hidden space-y-4">
|
||||
<% @events.each do |event| %>
|
||||
<div class="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<!-- Event Header -->
|
||||
<div class="flex items-start space-x-4 mb-4">
|
||||
<div class="h-12 w-12 rounded-lg bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="calendar" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">
|
||||
<%= link_to event.name, promoter_event_path(event), class: "hover:text-purple-600 transition-colors" %>
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 line-clamp-2">
|
||||
<%= event.description.truncate(100) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<% case event.state %>
|
||||
<% when "draft" %>
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
<i data-lucide="edit-3" class="w-3 h-3 mr-1"></i>
|
||||
Brouillon
|
||||
</span>
|
||||
<% when "published" %>
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
||||
<i data-lucide="eye" class="w-3 h-3 mr-1"></i>
|
||||
Publié
|
||||
</span>
|
||||
<% when "canceled" %>
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
|
||||
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
|
||||
Annulé
|
||||
</span>
|
||||
<% when "sold_out" %>
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
<i data-lucide="users" class="w-3 h-3 mr-1"></i>
|
||||
Complet
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<% if event.featured? %>
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
<i data-lucide="star" class="w-3 h-3 mr-1"></i>
|
||||
À la une
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Details Grid -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-4 text-sm">
|
||||
<div>
|
||||
<dt class="font-medium text-gray-500 mb-1">Date</dt>
|
||||
<dd class="text-gray-900">
|
||||
<% if event.start_time %>
|
||||
<div><%= event.start_time.strftime("%d/%m/%Y") %></div>
|
||||
<div class="text-xs text-gray-500"><%= event.start_time.strftime("%H:%M") %></div>
|
||||
<% else %>
|
||||
<span class="text-gray-400">Non définie</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-gray-500 mb-1">Lieu</dt>
|
||||
<dd class="text-gray-900">
|
||||
<div class="truncate"><%= event.venue_name %></div>
|
||||
<div class="text-xs text-gray-500 truncate"><%= event.venue_address %></div>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="space-y-2 pt-4 border-t border-gray-100">
|
||||
<%= link_to promoter_event_path(event), class: "w-full inline-flex items-center justify-center px-3 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors" do %>
|
||||
<i data-lucide="eye" class="w-4 h-4 mr-2"></i>
|
||||
Voir
|
||||
<% end %>
|
||||
<%= link_to edit_promoter_event_path(event), class: "w-full inline-flex items-center justify-center px-3 py-2 bg-blue-100 text-blue-700 text-sm font-medium rounded-lg hover:bg-blue-200 transition-colors" do %>
|
||||
<i data-lucide="edit" class="w-4 h-4 mr-2"></i>
|
||||
Modifier
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<%= paginate @events if respond_to?(:paginate) %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white rounded-2xl border-2 border-dashed border-gray-300 p-12 text-center">
|
||||
<div class="mx-auto h-24 w-24 rounded-full bg-gray-100 flex items-center justify-center mb-6">
|
||||
<i data-lucide="calendar-plus" class="w-12 h-12 text-gray-400"></i>
|
||||
<div class="bg-white rounded-2xl border-2 border-dashed border-gray-300 p-6 sm:p-12 text-center">
|
||||
<div class="mx-auto h-20 w-20 sm:h-24 sm:w-24 rounded-full bg-gray-100 flex items-center justify-center mb-6">
|
||||
<i data-lucide="calendar-plus" class="w-10 h-10 sm:w-12 sm:h-12 text-gray-400"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Aucun événement</h3>
|
||||
<p class="text-gray-500 mb-6">Vous n'avez pas encore créé d'événement. Commencez dès maintenant !</p>
|
||||
<%= link_to new_promoter_event_path, class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
|
||||
<h3 class="text-lg sm:text-xl font-semibold text-gray-900 mb-2">Aucun événement</h3>
|
||||
<p class="text-gray-500 mb-6 px-4">Vous n'avez pas encore créé d'événement. Commencez dès maintenant !</p>
|
||||
<%= link_to new_promoter_event_path, class: "inline-flex items-center justify-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200 w-full sm:w-auto" do %>
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
|
||||
Créer mon premier événement
|
||||
<% end %>
|
||||
|
||||
@@ -40,13 +40,13 @@
|
||||
<!-- 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="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Soirée d'ouverture", data: { "event-form-target": "name", action: "input->event-form#generateSlug" } %>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<%= form.label :slug, "Slug (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.text_field :slug, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "soiree-ouverture", data: { "event-form-target": "slug" } %>
|
||||
@@ -69,13 +69,13 @@
|
||||
<!-- Date & Time -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Date et heure</h3>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= form.label :start_time, "Date et heure de début", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.datetime_local_field :start_time, 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 :end_time, "Date et heure de fin", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.datetime_local_field :end_time, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
|
||||
@@ -86,55 +86,39 @@
|
||||
<!-- Venue Information -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Lieu de l'événement</h3>
|
||||
|
||||
|
||||
<!-- Geocoding Messages Container -->
|
||||
<div data-event-form-target="messagesContainer" class="space-y-3 mb-6 empty:mb-0 empty:hidden"></div>
|
||||
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.text_field :venue_name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Le Grand Rex" %>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<%= form.label :venue_address, "Adresse complète", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<div class="space-y-2">
|
||||
<div class="relative">
|
||||
<%= form.text_field :venue_address, class: "w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: 1 Boulevard Poissonnière, 75002 Paris", data: { "event-form-target": "address", action: "input->event-form#addressChanged" } %>
|
||||
|
||||
|
||||
<!-- Geocoding Loading Spinner -->
|
||||
<div data-event-form-target="geocodingSpinner" class="absolute right-3 top-1/2 transform -translate-y-1/2 hidden">
|
||||
<div class="w-5 h-5 border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Actions -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" data-action="click->event-form#getCurrentLocation" data-event-form-target="getCurrentLocationBtn" class="inline-flex items-center px-3 py-2 text-xs font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span data-event-form-target="getCurrentLocationIcon">
|
||||
<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>
|
||||
</span>
|
||||
<span data-event-form-target="getCurrentLocationText">Ma position</span>
|
||||
</button>
|
||||
<button type="button" data-action="click->event-form#previewLocation" data-event-form-target="previewLocationBtn" class="inline-flex items-center px-3 py-2 text-xs font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span data-event-form-target="previewLocationIcon">
|
||||
<i data-lucide="map" class="w-3 h-3 mr-1"></i>
|
||||
</span>
|
||||
<span data-event-form-target="previewLocationText">Prévisualiser</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
|
||||
Les coordonnées GPS seront automatiquement calculées à partir de cette adresse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Hidden coordinate fields populated by JavaScript geocoding -->
|
||||
<%= form.hidden_field :latitude, data: { "event-form-target": "latitude" } %>
|
||||
<%= form.hidden_field :longitude, data: { "event-form-target": "longitude" } %>
|
||||
|
||||
|
||||
<!-- Map Links Container (shown when address is valid) -->
|
||||
<div data-event-form-target="mapLinksContainer" class="empty:hidden bg-gray-50 rounded-lg p-3 border border-gray-200"></div>
|
||||
</div>
|
||||
@@ -143,12 +127,25 @@
|
||||
<!-- Options -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Options</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :featured, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" %>
|
||||
<%= form.label :featured, "Mettre en avant sur la page d'accueil", class: "ml-2 text-sm text-gray-700" %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :featured, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" %>
|
||||
<%= form.label :featured, "Mettre en avant sur la page d'accueil", class: "ml-2 text-sm text-gray-700" %>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">Les événements mis en avant apparaissent en premier sur la page d'accueil.</p>
|
||||
|
||||
<div class="flex items-start">
|
||||
<%= form.check_box :allow_booking_during_event, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 mt-1" %>
|
||||
<div class="ml-2">
|
||||
<%= form.label :allow_booking_during_event, "Autoriser la réservation pendant l'événement", class: "text-sm text-gray-700 font-medium" %>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Si activé, les participants pourront acheter des billets même après le début de l'événement.
|
||||
Si désactivé, la vente de billets s'arrêtera automatiquement à l'heure de début.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">Les événements mis en avant apparaissent en premier sur la page d'accueil.</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
@@ -158,7 +155,7 @@
|
||||
Annuler
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<%= form.submit "Créer en brouillon", class: "inline-flex items-center px-6 py-3 bg-gray-600 text-white font-medium rounded-lg hover:bg-gray-700 transition-colors duration-200" %>
|
||||
</div>
|
||||
@@ -166,4 +163,3 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<% content_for(:title, @event.name) %>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div data-controller="event-duplication" data-event-duplication-duplicate-url-value="<%= duplicate_promoter_event_path(@event) %>">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
@@ -12,57 +13,63 @@
|
||||
|
||||
<!-- Header with actions -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<%= link_to promoter_events_path, class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
|
||||
<i data-lucide="arrow-left" class="w-5 h-5"></i>
|
||||
<% end %>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2"><%= @event.name %></h1>
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span class="flex items-center">
|
||||
<i data-lucide="calendar" class="w-4 h-4 mr-1"></i>
|
||||
<%= @event.start_time&.strftime("%d/%m/%Y à %H:%M") || "Date non définie" %>
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<i data-lucide="map-pin" class="w-4 h-4 mr-1"></i>
|
||||
<%= @event.venue_name %>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Back button and title -->
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<%= link_to promoter_events_path, class: "text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0" do %>
|
||||
<i data-lucide="arrow-left" class="w-5 h-5"></i>
|
||||
<% end %>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-2 truncate"><%= @event.name %></h1>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 text-sm text-gray-500">
|
||||
<span class="flex items-center">
|
||||
<i data-lucide="calendar" class="w-4 h-4 mr-1 flex-shrink-0"></i>
|
||||
<span class="truncate"><%= @event.start_time&.strftime("%d/%m/%Y à %H:%M") || "Date non définie" %></span>
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<i data-lucide="map-pin" class="w-4 h-4 mr-1 flex-shrink-0"></i>
|
||||
<span class="truncate"><%= @event.venue_name %></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<%= link_to edit_promoter_event_path(@event), class: "inline-flex items-center px-4 py-2 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
|
||||
<i data-lucide="edit" class="w-4 h-4 mr-2"></i>
|
||||
Modifier
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @event.draft? %>
|
||||
<% if @event.ticket_types.blank? %>
|
||||
<%= button_to publish_promoter_event_path(@event), method: :patch, disabled: true, class: "inline-flex items-center px-4 py-2 bg-gray-400 text-white font-medium rounded-lg cursor-not-allowed transition-colors duration-200", title: "Vous devez créer au moins un type de billet avant de publier" do %>
|
||||
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
|
||||
Publier
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= button_to publish_promoter_event_path(@event), method: :patch, class: "inline-flex items-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
|
||||
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
|
||||
Publier
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% elsif @event.published? %>
|
||||
<%= button_to unpublish_promoter_event_path(@event), method: :patch, class: "inline-flex items-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 %>
|
||||
<!-- Action buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<%= link_to edit_promoter_event_path(@event), class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
|
||||
<i data-lucide="edit" class="w-4 h-4 mr-2"></i>
|
||||
Modifier
|
||||
<% end %>
|
||||
|
||||
<% if @event.published? %>
|
||||
<%= button_to cancel_promoter_event_path(@event), method: :patch, class: "inline-flex items-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
|
||||
<button type="button" data-action="click->event-duplication#open" class="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200">
|
||||
<i data-lucide="copy" class="w-4 h-4 mr-2"></i>
|
||||
Dupliquer
|
||||
</button>
|
||||
|
||||
<% if @event.draft? %>
|
||||
<% if @event.ticket_types.blank? %>
|
||||
<%= button_to publish_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: "Vous devez créer au moins un type de billet avant de publier" do %>
|
||||
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
|
||||
Publier
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= button_to publish_promoter_event_path(@event), method: :patch, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
|
||||
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
|
||||
Publier
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% 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
|
||||
<% 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
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,17 +86,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<% if @event.ticket_types.blank? %>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-2xl p-4 mt-4">
|
||||
<div class="flex items-center">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5 text-amber-400 mr-3"></i>
|
||||
<div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5 text-amber-400 flex-shrink-0"></i>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-medium text-amber-900">Aucun type de billet configuré</h3>
|
||||
<p class="text-sm text-amber-700">Vous devez créer au moins un type de billet avant de pouvoir publier cet événement.</p>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<%= link_to promoter_event_ticket_types_path(@event), class: "text-amber-600 hover:text-amber-800 font-medium text-sm" do %>
|
||||
<div class="flex-shrink-0">
|
||||
<%= link_to promoter_event_ticket_types_path(@event), class: "text-amber-600 hover:text-amber-800 font-medium text-sm whitespace-nowrap" do %>
|
||||
Configurer les billets <i data-lucide="external-link" class="w-4 h-4 inline ml-1"></i>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -98,15 +105,15 @@
|
||||
<% end %>
|
||||
<% when "published" %>
|
||||
<div class="bg-green-50 border border-green-200 rounded-2xl p-4">
|
||||
<div class="flex items-center">
|
||||
<i data-lucide="eye" class="w-5 h-5 text-green-400 mr-3"></i>
|
||||
<div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<i data-lucide="eye" class="w-5 h-5 text-green-400 flex-shrink-0"></i>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-medium text-green-900">Événement publié</h3>
|
||||
<p class="text-sm text-green-700">Cet événement est visible publiquement et les utilisateurs peuvent acheter des billets.</p>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<%= link_to event_path(@event.slug, @event), target: "_blank", class: "text-green-600 hover:text-green-800 font-medium text-sm" do %>
|
||||
Voir publiquement <i data-lucide="external-link" class="w-4 h-4 inline ml-1"></i>
|
||||
<div class="flex-shrink-0">
|
||||
<%= link_to event_path(@event.slug, @event), target: "_blank", class: "text-green-600 hover:text-green-800 font-medium text-sm whitespace-nowrap" do %>
|
||||
Voir la fiche publique <i data-lucide="external-link" class="w-4 h-4 inline ml-1"></i>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,10 +132,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 %>
|
||||
@@ -159,9 +170,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Event details -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
<!-- Main content -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<div class="lg:col-span-2 space-y-6 lg:space-y-8">
|
||||
<!-- Event image -->
|
||||
<% if @event.image.present? %>
|
||||
<div class="aspect-video bg-gray-100 rounded-2xl overflow-hidden">
|
||||
@@ -170,27 +181,27 @@
|
||||
<% end %>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Description</h3>
|
||||
<div class="prose prose-gray max-w-none">
|
||||
<div class="prose prose-gray prose-sm sm:prose-base max-w-none">
|
||||
<%= simple_format(@event.description) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location details -->
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Lieu</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start space-x-3">
|
||||
<i data-lucide="building" class="w-5 h-5 text-gray-400 mt-0.5"></i>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900"><%= @event.venue_name %></p>
|
||||
<p class="text-gray-500"><%= @event.venue_address %></p>
|
||||
<i data-lucide="building" class="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0"></i>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-gray-900 break-words"><%= @event.venue_name %></p>
|
||||
<p class="text-gray-500 break-words"><%= @event.venue_address %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-sm text-gray-500">
|
||||
<i data-lucide="map-pin" class="w-4 h-4"></i>
|
||||
<span><%= @event.latitude %>, <%= @event.longitude %></span>
|
||||
<i data-lucide="map-pin" class="w-4 h-4 flex-shrink-0"></i>
|
||||
<span class="break-all"><%= @event.latitude %>, <%= @event.longitude %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,20 +210,20 @@
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Event stats -->
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Statistiques</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">Types de billets</span>
|
||||
<span class="text-gray-500 text-sm sm:text-base">Types de billets</span>
|
||||
<span class="font-medium"><%= @event.ticket_types.count %></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">Billets vendus</span>
|
||||
<span class="text-gray-500 text-sm sm:text-base">Billets vendus</span>
|
||||
<span class="font-medium"><%= @event.tickets.count %></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">Revenus</span>
|
||||
<span class="font-medium">
|
||||
<span class="text-gray-500 text-sm sm:text-base">Revenus</span>
|
||||
<span class="font-medium text-sm sm:text-base">
|
||||
<%= number_to_currency(@event.tickets.sum(:price_cents) / 100.0, unit: "€") %>
|
||||
</span>
|
||||
</div>
|
||||
@@ -220,25 +231,25 @@
|
||||
</div>
|
||||
|
||||
<!-- Event info -->
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Informations</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Créé le</span>
|
||||
<p class="text-sm"><%= @event.created_at.strftime("%d/%m/%Y à %H:%M") %></p>
|
||||
<p class="text-sm break-words"><%= @event.created_at.strftime("%d/%m/%Y à %H:%M") %></p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Modifié le</span>
|
||||
<p class="text-sm"><%= @event.updated_at.strftime("%d/%m/%Y à %H:%M") %></p>
|
||||
<p class="text-sm break-words"><%= @event.updated_at.strftime("%d/%m/%Y à %H:%M") %></p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Réservation pendant l'événement</span>
|
||||
<p class="text-sm flex items-center">
|
||||
<% if @event.allow_booking_during_event? %>
|
||||
<i data-lucide="check-circle" class="w-4 h-4 text-green-500 mr-1"></i>
|
||||
<i data-lucide="check-circle" class="w-4 h-4 text-green-500 mr-1 flex-shrink-0"></i>
|
||||
Autorisée
|
||||
<% else %>
|
||||
<i data-lucide="x-circle" class="w-4 h-4 text-red-500 mr-1"></i>
|
||||
<i data-lucide="x-circle" class="w-4 h-4 text-red-500 mr-1 flex-shrink-0"></i>
|
||||
Interdite
|
||||
<% end %>
|
||||
</p>
|
||||
@@ -246,34 +257,112 @@
|
||||
<% if @event.start_time %>
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Début</span>
|
||||
<p class="text-sm"><%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %></p>
|
||||
<p class="text-sm break-words"><%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @event.end_time %>
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Fin</span>
|
||||
<p class="text-sm"><%= @event.end_time.strftime("%d/%m/%Y à %H:%M") %></p>
|
||||
<p class="text-sm break-words"><%= @event.end_time.strftime("%d/%m/%Y à %H:%M") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions -->
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Actions rapides</h3>
|
||||
<div class="space-y-3">
|
||||
<%= link_to promoter_event_ticket_types_path(@event), class: "w-full inline-flex items-center px-4 py-2 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" do %>
|
||||
<%= link_to promoter_event_ticket_types_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-purple-600 text-white font-medium text-sm rounded-lg hover:bg-purple-700 transition-colors duration-200" do %>
|
||||
<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 px-4 py-2 bg-gray-50 text-gray-700 font-medium 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 %>
|
||||
|
||||
<!-- Payout section -->
|
||||
<% if @event.event_ended? && @event.can_request_payout? %>
|
||||
<hr class="border-gray-200">
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-lg font-medium text-gray-900">Paiement des Revenus</h4>
|
||||
|
||||
<!-- Earnings Summary -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-500">Revenus Bruts</p>
|
||||
<p class="text-lg font-bold text-gray-900">€<%= @event.total_earnings_cents / 100.0 %></p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-500">Frais Plateforme</p>
|
||||
<p class="text-lg font-bold text-gray-900">-€<%= @event.total_fees_cents / 100.0 %></p>
|
||||
</div>
|
||||
|
||||
<div class="payout-summary-card">
|
||||
<p class="payout-summary-label">Revenus Nets</p>
|
||||
<p class="payout-summary-amount">€<%= @event.net_earnings_cents / 100.0 %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payout Status -->
|
||||
<% if @event.payout_status != "not_requested" %>
|
||||
<div class="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
||||
<div class="flex items-center">
|
||||
<% case @event.payout_status %>
|
||||
<% when "requested" %>
|
||||
<i data-lucide="clock" class="w-5 h-5 text-blue-500 mr-2"></i>
|
||||
<span class="font-medium text-blue-800">Paiement Demandé</span>
|
||||
<% when "processing" %>
|
||||
<i data-lucide="refresh-cw" class="w-5 h-5 text-blue-500 mr-2"></i>
|
||||
<span class="font-medium text-blue-800">Paiement en Traitement</span>
|
||||
<% when "completed" %>
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-green-500 mr-2"></i>
|
||||
<span class="font-medium text-green-800">Paiement Complété</span>
|
||||
<% when "failed" %>
|
||||
<i data-lucide="x-circle" class="w-5 h-5 text-red-500 mr-2"></i>
|
||||
<span class="font-medium text-red-800">Paiement Échoué</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mt-1">Votre demande de paiement est en cours de traitement. Vous recevrez un email quand elle sera terminée.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Payout Action -->
|
||||
<% if @event.payout_status == "not_requested" %>
|
||||
<%= button_to promoter_payouts_path(event_id: @event.id), method: :post,
|
||||
data: { confirm: "Êtes-vous sûr de vouloir demander un paiement de €#{@event.net_earnings_cents / 100.0} ? Cette action ne peut pas être annulée." },
|
||||
class: "payout-action-button primary" do %>
|
||||
<i data-lucide="dollar-sign" class="w-5 h-5 mr-2"></i>
|
||||
Demander le Paiement de €<%= @event.net_earnings_cents / 100.0 %>
|
||||
<% end %>
|
||||
<% elsif @event.payout_status == "failed" %>
|
||||
<%= button_to promoter_payouts_path(event_id: @event.id), method: :post,
|
||||
data: { confirm: "Êtes-vous sûr de vouloir demander un nouveau paiement de €#{@event.net_earnings_cents / 100.0} ?" },
|
||||
class: "payout-action-button warning" do %>
|
||||
<i data-lucide="refresh-ccw" class="w-5 h-5 mr-2"></i>
|
||||
Réessayer le Paiement
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= link_to "Voir les Détails du Paiement", promoter_payouts_path,
|
||||
class: "payout-action-button secondary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% 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." },
|
||||
class: "w-full inline-flex items-center px-4 py-2 text-red-600 font-medium rounded-lg hover:bg-red-50 transition-colors duration-200" do %>
|
||||
class: "w-full inline-flex items-center justify-center px-4 py-3 text-red-600 font-medium text-sm rounded-lg hover:bg-red-50 transition-colors duration-200" do %>
|
||||
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
|
||||
Supprimer l'événement
|
||||
<% end %>
|
||||
@@ -281,4 +370,52 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div data-event-duplication-target="modal" class="hidden relative z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<!-- Background backdrop, show/hide based on modal state -->
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
||||
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<!-- Modal container -->
|
||||
<div class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<i data-lucide="copy" class="h-6 w-6 text-blue-600"></i>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
Dupliquer l'événement
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
Choisissez les options de duplication pour "<%= @event.name %>".
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center">
|
||||
<input data-event-duplication-target="cloneTicketTypes" id="cloneTicketTypes" type="checkbox" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" checked>
|
||||
<label for="cloneTicketTypes" class="ml-2 block text-sm text-gray-900">
|
||||
Dupliquer également les types de billets (<%= @event.ticket_types.count %> type(s))
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
|
||||
<button type="button" data-action="click->event-duplication#duplicate" class="inline-flex w-full justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Dupliquer
|
||||
</button>
|
||||
<button type="button" data-action="click->event-duplication#close" class="mt-3 inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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>
|
||||
142
app/views/promoter/payouts/index.html.erb
Normal file
142
app/views/promoter/payouts/index.html.erb
Normal file
@@ -0,0 +1,142 @@
|
||||
<% 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 %>
|
||||
|
||||
<!-- 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>
|
||||
Completed
|
||||
</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>
|
||||
165
app/views/promoter/payouts/show.html.erb
Normal file
165
app/views/promoter/payouts/show.html.erb
Normal file
@@ -0,0 +1,165 @@
|
||||
<% 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">€<%= @payout.amount_euros %></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">-€<%= @payout.fee_euros %></p>
|
||||
</div>
|
||||
|
||||
<div class="payout-summary-card">
|
||||
<p class="payout-summary-label">Net Amount</p>
|
||||
<p class="payout-summary-amount">€<%= @payout.net_amount_euros %></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">Stripe Payout ID</dt>
|
||||
<dd class="payout-detail-value font-mono text-xs break-all"><%= @payout.stripe_payout_id %></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>
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
<% content_for(:title, "#{@ticket_type.name} - #{@event.name}") %>
|
||||
|
||||
<div class="container py-8">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
{ name: 'Dashboard', path: dashboard_path },
|
||||
{ name: 'Événements', path: promoter_events_path },
|
||||
{ name: @event.name, path: promoter_event_path(@event) },
|
||||
{ name: 'Types de billets', path: promoter_event_ticket_types_path(@event) },
|
||||
{ name: @ticket_type.name, path: nil }
|
||||
] %>
|
||||
|
||||
<!-- Header with actions -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<%= link_to promoter_event_ticket_types_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
|
||||
<i data-lucide="arrow-left" class="w-5 h-5"></i>
|
||||
<% end %>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2"><%= @ticket_type.name %></h1>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-2"><%= @ticket_type.name %></h1>
|
||||
<p class="text-gray-600">
|
||||
<%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<%= link_to edit_promoter_event_ticket_type_path(@event, @ticket_type), class: "inline-flex items-center px-4 py-2 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
|
||||
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||
<%= link_to edit_promoter_event_ticket_type_path(@event, @ticket_type), class: "w-full inline-flex items-center justify-center px-4 py-2 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
|
||||
<i data-lucide="edit" class="w-4 h-4 mr-2"></i>
|
||||
Modifier
|
||||
<% end %>
|
||||
|
||||
<%= button_to duplicate_promoter_event_ticket_type_path(@event, @ticket_type), method: :post, class: "inline-flex items-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
|
||||
|
||||
<%= button_to duplicate_promoter_event_ticket_type_path(@event, @ticket_type), method: :post, class: "w-full inline-flex items-center justify-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
|
||||
<i data-lucide="copy" class="w-4 h-4 mr-2"></i>
|
||||
Dupliquer
|
||||
<% end %>
|
||||
@@ -129,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") %>
|
||||
@@ -155,11 +164,11 @@
|
||||
<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>
|
||||
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-gray-900">
|
||||
@@ -174,14 +183,14 @@
|
||||
<div class="text-xs text-gray-500">Restants</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center p-4 bg-green-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-green-600">
|
||||
<%= number_to_currency(@ticket_type.current_revenue, unit: "€") %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Revenus générés</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-blue-600">
|
||||
<%= number_to_currency(@ticket_type.total_potential_revenue, unit: "€") %>
|
||||
@@ -205,11 +214,11 @@
|
||||
<% end %>
|
||||
<hr class="border-gray-200">
|
||||
<% if @ticket_type.tickets.empty? %>
|
||||
<%= button_to promoter_event_ticket_type_path(@event, @ticket_type), method: :delete,
|
||||
<%= button_to promoter_event_ticket_type_path(@event, @ticket_type), method: :delete,
|
||||
data: { confirm: "Êtes-vous sûr de vouloir supprimer ce type de billet ? Cette action est irréversible." },
|
||||
class: "w-full inline-flex items-center px-4 py-2 text-red-600 font-medium rounded-lg hover:bg-red-50 transition-colors duration-200" do %>
|
||||
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
|
||||
Supprimer le type
|
||||
Supprimer le type de billet
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="w-full inline-flex items-center px-4 py-2 text-gray-400 font-medium rounded-lg cursor-not-allowed">
|
||||
@@ -237,4 +246,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -28,6 +28,7 @@ development:
|
||||
test:
|
||||
<<: *default
|
||||
database: aperonight_test
|
||||
isolation_level: READ UNCOMMITTED
|
||||
# adapter: sqlite3
|
||||
# pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
||||
# database: data/test.sqlite3
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
Rails.application.routes.draw do
|
||||
namespace :admin do
|
||||
resources :payouts, only: [ :index, :create ]
|
||||
end
|
||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||
|
||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||
@@ -75,12 +78,15 @@ 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
|
||||
|
||||
# Nested ticket types routes
|
||||
@@ -96,11 +102,8 @@ Rails.application.routes.draw do
|
||||
namespace :api do
|
||||
namespace :v1 do
|
||||
# RESTful routes for event management
|
||||
resources :events, only: [ :index, :show, :create, :update, :destroy ] do
|
||||
member do
|
||||
post :store_cart
|
||||
end
|
||||
end
|
||||
resources :events, only: [ :index, :show, :create, :update, :destroy ]
|
||||
post "carts/store", to: "carts#store", as: "store_cart"
|
||||
|
||||
# RESTful routes for order management
|
||||
resources :orders, only: [] do
|
||||
|
||||
@@ -52,6 +52,9 @@ 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
|
||||
|
||||
t.timestamps null: false
|
||||
end
|
||||
|
||||
@@ -60,5 +63,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
|
||||
|
||||
@@ -19,11 +19,19 @@ class CreateEvents < ActiveRecord::Migration[8.0]
|
||||
t.boolean :featured, default: false, null: false
|
||||
t.references :user, null: false, foreign_key: false
|
||||
|
||||
# Allow ticket sell during the event
|
||||
t.boolean :allow_booking_during_event, default: false, null: false
|
||||
|
||||
# Payout fields
|
||||
t.integer :payout_status
|
||||
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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class AddAllowBookingDuringEventToEvents < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :events, :allow_booking_during_event, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
||||
18
db/migrate/20250916212717_create_earnings.rb
Normal file
18
db/migrate/20250916212717_create_earnings.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
end
|
||||
end
|
||||
20
db/migrate/20250916221454_create_payouts.rb
Normal file
20
db/migrate/20250916221454_create_payouts.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
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
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :payouts, :status
|
||||
add_index :payouts, :stripe_payout_id, unique: true
|
||||
end
|
||||
end
|
||||
42
db/schema.rb
generated
42
db/schema.rb
generated
@@ -10,7 +10,23 @@
|
||||
#
|
||||
# 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 ["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
|
||||
@@ -25,11 +41,14 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
|
||||
t.decimal "longitude", precision: 10, scale: 6, null: false
|
||||
t.boolean "featured", default: false, null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.boolean "allow_booking_during_event", default: false, null: false
|
||||
t.integer "payout_status"
|
||||
t.datetime "payout_requested_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "allow_booking_during_event", default: false, null: false
|
||||
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 +70,23 @@ 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.index ["event_id"], name: "index_payouts_on_event_id"
|
||||
t.index ["status"], name: "index_payouts_on_status"
|
||||
t.index ["stripe_payout_id"], name: "index_payouts_on_stripe_payout_id", unique: true
|
||||
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"
|
||||
@@ -99,9 +135,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
|
||||
t.string "company_website"
|
||||
t.string "stripe_customer_id"
|
||||
t.boolean "onboarding_completed", default: false, null: false
|
||||
t.string "stripe_connected_account_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
t.index ["stripe_connected_account_id"], name: "index_users_on_stripe_connected_account_id", unique: true
|
||||
end
|
||||
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 not_requested
|
||||
e.payout_status = :not_requested
|
||||
end
|
||||
|
||||
# Create ticket types for the completed event
|
||||
general_ticket_type = TicketType.find_or_create_by!(event: completed_event, name: "General Admission") do |tt|
|
||||
tt.description = "General admission ticket for the Completed Music Festival"
|
||||
tt.price_cents = 5000 # $50.00
|
||||
tt.quantity = 200
|
||||
tt.sale_start_at = 1.month.ago
|
||||
tt.sale_end_at = completed_event.start_time - 1.hour
|
||||
tt.minimum_age = 18
|
||||
end
|
||||
|
||||
vip_ticket_type = TicketType.find_or_create_by!(event: completed_event, name: "VIP") do |tt|
|
||||
tt.description = "VIP access ticket for the Completed Music Festival"
|
||||
tt.price_cents = 15000 # $150.00
|
||||
tt.quantity = 50
|
||||
tt.sale_start_at = 1.month.ago
|
||||
tt.sale_end_at = completed_event.start_time - 1.hour
|
||||
tt.minimum_age = 21
|
||||
end
|
||||
|
||||
# Create some orders and tickets for the completed event to generate earnings
|
||||
buyer_user = User.find_or_create_by!(email: "buyer@example.com") do |u|
|
||||
u.password = "password"
|
||||
u.password_confirmation = "password"
|
||||
u.first_name = "Ticket"
|
||||
u.last_name = "Buyer"
|
||||
end
|
||||
|
||||
# Create multiple orders with different statuses to demonstrate the payout system
|
||||
# Order 1: Paid order with general admission tickets
|
||||
order1 = Order.find_or_create_by!(user: buyer_user, event: completed_event) do |o|
|
||||
o.status = "paid"
|
||||
o.total_amount_cents = 15000 # $150.00 for 3 general admission tickets ($50.00 each)
|
||||
end
|
||||
|
||||
# Create tickets for order 1
|
||||
3.times do |i|
|
||||
Ticket.find_or_create_by!(order: order1, ticket_type: general_ticket_type) do |t|
|
||||
t.qr_code = "ORDER1-TICKET#{i + 1}"
|
||||
t.price_cents = 5000 # $50.00
|
||||
t.status = "active"
|
||||
t.first_name = "Attendee"
|
||||
t.last_name = "#{i + 1}"
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate platform fees using the actual model: €0.50 + 1.5% per ticket
|
||||
# For 3 tickets at $50.00 each:
|
||||
# Fixed fee: 3 tickets × $0.50 = $1.50 (150 cents)
|
||||
# Percentage fee: 3 tickets × ($50.00 × 1.5%) = 3 × $0.75 = $2.25 (225 cents)
|
||||
# Total platform fee: $1.50 + $2.25 = $3.75 (375 cents)
|
||||
# Promoter payout: $150.00 - $3.75 = $146.25 (14625 cents)
|
||||
|
||||
# Create earnings for this paid order (this would normally happen automatically)
|
||||
Earning.find_or_create_by!(event: completed_event, user: completed_event_promoter, order: order1) do |e|
|
||||
e.amount_cents = 14625 # $146.25 (promoter payout after fees)
|
||||
e.fee_cents = 375 # $3.75 platform fee
|
||||
e.status = "pending"
|
||||
end
|
||||
|
||||
# Order 2: Paid order with VIP tickets
|
||||
order2 = Order.find_or_create_by!(user: buyer_user, event: completed_event) do |o|
|
||||
o.status = "paid"
|
||||
o.total_amount_cents = 30000 # $300.00 for 2 VIP tickets ($150.00 each)
|
||||
end
|
||||
|
||||
# Create tickets for order 2
|
||||
2.times do |i|
|
||||
Ticket.find_or_create_by!(order: order2, ticket_type: vip_ticket_type) do |t|
|
||||
t.qr_code = "ORDER2-TICKET#{i + 1}"
|
||||
t.price_cents = 15000 # $150.00
|
||||
t.status = "active"
|
||||
t.first_name = "VIP"
|
||||
t.last_name = "Attendee #{i + 1}"
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate platform fees using the actual model: €0.50 + 1.5% per ticket
|
||||
# For 2 tickets at $150.00 each:
|
||||
# Fixed fee: 2 tickets × $0.50 = $1.00 (100 cents)
|
||||
# Percentage fee: 2 tickets × ($150.00 × 1.5%) = 2 × $2.25 = $4.50 (450 cents)
|
||||
# Total platform fee: $1.00 + $4.50 = $5.50 (550 cents)
|
||||
# Promoter payout: $300.00 - $5.50 = $294.50 (29450 cents)
|
||||
|
||||
# Create earnings for this paid order (this would normally happen automatically)
|
||||
Earning.find_or_create_by!(event: completed_event, user: completed_event_promoter, order: order2) do |e|
|
||||
e.amount_cents = 29450 # $294.50 (promoter payout after fees)
|
||||
e.fee_cents = 550 # $5.50 platform fee
|
||||
e.status = "pending"
|
||||
end
|
||||
|
||||
# Order 3: Refunded order to demonstrate that refunded tickets are excluded
|
||||
order3 = Order.find_or_create_by!(user: buyer_user, event: completed_event) do |o|
|
||||
o.status = "paid"
|
||||
o.total_amount_cents = 5000 # $50.00 for 1 general admission ticket
|
||||
end
|
||||
|
||||
# Create ticket for order 3 (will be refunded)
|
||||
refunded_ticket = Ticket.find_or_create_by!(order: order3, ticket_type: general_ticket_type) do |t|
|
||||
t.qr_code = "ORDER3-TICKET1"
|
||||
t.price_cents = 5000 # $50.00
|
||||
t.status = "refunded" # This ticket was refunded
|
||||
t.first_name = "Refunded"
|
||||
t.last_name = "Customer"
|
||||
end
|
||||
|
||||
# Calculate platform fees using the actual model: €0.50 + 1.5% per ticket
|
||||
# For 1 ticket at $50.00:
|
||||
# Fixed fee: 1 ticket × $0.50 = $0.50 (50 cents)
|
||||
# Percentage fee: 1 ticket × ($50.00 × 1.5%) = $0.75 (75 cents)
|
||||
# Total platform fee: $0.50 + $0.75 = $1.25 (125 cents)
|
||||
# Promoter payout: $50.00 - $1.25 = $48.75 (4875 cents)
|
||||
|
||||
# Create earnings for this refunded order (this would normally happen automatically)
|
||||
Earning.find_or_create_by!(event: completed_event, user: completed_event_promoter, order: order3) do |e|
|
||||
e.amount_cents = 4875 # $48.75 (promoter payout after fees)
|
||||
e.fee_cents = 125 # $1.25 platform fee
|
||||
e.status = "pending"
|
||||
end
|
||||
|
||||
puts "Created 1 completed event with sample orders and earnings for payout demonstration"
|
||||
|
||||
|
||||
belle_epoque_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PAR SISLEY ÉVENTS") do |e|
|
||||
e.slug = "la-belle-epoque-par-sisley-events"
|
||||
e.state = :draft
|
||||
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"
|
||||
|
||||
@@ -3,28 +3,32 @@
|
||||
## Current Problem Analysis
|
||||
|
||||
### Current Model: €1 Fixed Fee Per Order
|
||||
|
||||
- **Revenue**: €1.00 per order (regardless of ticket price)
|
||||
- **Stripe Costs**: 1.15% + €0.25 per transaction
|
||||
- **Result**: Losing money on higher-priced tickets
|
||||
|
||||
### Financial Impact Examples
|
||||
|
||||
| Ticket Price | Current Revenue | Stripe Fees | Net Profit/Loss |
|
||||
|--------------|----------------|-------------|-----------------|
|
||||
| €20 | €1.00 | €0.48 | **+€0.52** ✅ |
|
||||
| €50 | €1.00 | €0.83 | **+€0.17** ⚠️ |
|
||||
| €100 | €1.00 | €1.40 | **-€0.40** ❌ |
|
||||
| €200 | €1.00 | €2.55 | **-€1.55** ❌ |
|
||||
| ------------ | --------------- | ----------- | --------------- |
|
||||
| €20 | €1.00 | €0.48 | **+€0.52** ✅ |
|
||||
| €50 | €1.00 | €0.83 | **+€0.17** ⚠️ |
|
||||
| €100 | €1.00 | €1.40 | **-€0.40** ❌ |
|
||||
| €200 | €1.00 | €2.55 | **-€1.55** ❌ |
|
||||
|
||||
**Critical Issue**: We lose money on any ticket above €65 (€1.00 - (€65 × 1.15% + €0.25) = -€0.00)
|
||||
|
||||
## Recommended Pricing Models
|
||||
|
||||
### Model 1: Percentage-Based (Recommended)
|
||||
|
||||
```
|
||||
Platform Fee = 3-5% of ticket price
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
|
||||
- Always profitable regardless of ticket price
|
||||
- Scales naturally with event value
|
||||
- Simple for promoters to understand
|
||||
@@ -33,17 +37,19 @@ Platform Fee = 3-5% of ticket price
|
||||
**Financial Impact:**
|
||||
| Ticket Price | 3.5% Fee | Stripe Costs | Net Profit |
|
||||
|--------------|----------|--------------|------------|
|
||||
| €20 | €0.70 | €0.48 | €0.22 |
|
||||
| €50 | €1.75 | €0.83 | €0.92 |
|
||||
| €100 | €3.50 | €1.40 | €2.10 |
|
||||
| €200 | €7.00 | €2.55 | €4.45 |
|
||||
| €20 | €0.70 | €0.48 | €0.22 |
|
||||
| €50 | €1.75 | €0.83 | €0.92 |
|
||||
| €100 | €3.50 | €1.40 | €2.10 |
|
||||
| €200 | €7.00 | €2.55 | €4.45 |
|
||||
|
||||
### Model 2: Hybrid Model (Best)
|
||||
|
||||
```
|
||||
Platform Fee = €1.50 + 2% of ticket price
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
|
||||
- Higher base fee covers Stripe fixed costs
|
||||
- Percentage component scales with value
|
||||
- Better margins on low-priced tickets
|
||||
@@ -52,12 +58,13 @@ Platform Fee = €1.50 + 2% of ticket price
|
||||
**Financial Impact:**
|
||||
| Ticket Price | Hybrid Fee | Stripe Costs | Net Profit |
|
||||
|--------------|------------|--------------|------------|
|
||||
| €20 | €1.90 | €0.48 | €1.42 |
|
||||
| €50 | €2.50 | €0.83 | €1.67 |
|
||||
| €100 | €3.50 | €1.40 | €2.10 |
|
||||
| €200 | €5.50 | €2.55 | €2.95 |
|
||||
| €20 | €1.90 | €0.48 | €1.42 |
|
||||
| €50 | €2.50 | €0.83 | €1.67 |
|
||||
| €100 | €3.50 | €1.40 | €2.10 |
|
||||
| €200 | €5.50 | €2.55 | €2.95 |
|
||||
|
||||
### Model 3: Tiered Flat Fees
|
||||
|
||||
```
|
||||
€0-25: €1.50 fee
|
||||
€26-75: €2.50 fee
|
||||
@@ -66,32 +73,36 @@ Platform Fee = €1.50 + 2% of ticket price
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
|
||||
- Predictable fees for promoters
|
||||
- Simple pricing tiers
|
||||
- Better than current model
|
||||
|
||||
**Disadvantages:**
|
||||
|
||||
- Less scalable than percentage
|
||||
- Requires tier management
|
||||
|
||||
## Industry Benchmarks
|
||||
|
||||
| Platform | Fee Structure | Effective Rate (€50 ticket) |
|
||||
|-------------|----------------------------|------------------------------|
|
||||
| Eventbrite | 3.7% + €0.59 | €2.44 |
|
||||
| Universe | 2.5% + €0.49 | €1.74 |
|
||||
| Ticketfly | 4% + €0.99 | €2.99 |
|
||||
| **Recommended** | **3.5%** | **€1.75** |
|
||||
| Platform | Fee Structure | Effective Rate (€50 ticket) |
|
||||
| --------------- | ------------- | --------------------------- |
|
||||
| Eventbrite | 3.7% + €0.59 | €2.44 |
|
||||
| Universe | 2.5% + €0.49 | €1.74 |
|
||||
| Ticketfly | 4% + €0.99 | €2.99 |
|
||||
| **Recommended** | **3.5%** | **€1.75** |
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase 1: Immediate Implementation (Week 1-2)
|
||||
|
||||
1. **Switch to 3.5% percentage model** for new events
|
||||
2. **Grandfathering**: Existing published events keep €1 pricing
|
||||
3. **Communication**: Notify promoters 1 week before change
|
||||
4. **Update pricing display** on event creation and checkout pages
|
||||
|
||||
### Phase 2: Optimization (Month 2-3)
|
||||
|
||||
1. **A/B Test different rates**: 3%, 3.5%, 4%
|
||||
2. **Monitor metrics**:
|
||||
- Promoter signup rate
|
||||
@@ -101,6 +112,7 @@ Platform Fee = €1.50 + 2% of ticket price
|
||||
3. **Adjust based on data**
|
||||
|
||||
### Phase 3: Premium Tiers (Month 4-6)
|
||||
|
||||
1. **Volume discounts** for high-performing promoters:
|
||||
- Standard: 3.5%
|
||||
- Bronze (€5K+ monthly): 3%
|
||||
@@ -112,6 +124,7 @@ Platform Fee = €1.50 + 2% of ticket price
|
||||
## Revenue Projections
|
||||
|
||||
### Current State (€1 fixed)
|
||||
|
||||
- Average ticket price: €35
|
||||
- Monthly orders: 1,000
|
||||
- Monthly revenue: €1,000
|
||||
@@ -119,11 +132,13 @@ Platform Fee = €1.50 + 2% of ticket price
|
||||
- **Net monthly profit: €372**
|
||||
|
||||
### With 3.5% Model
|
||||
|
||||
- Monthly revenue: €1,225 (3.5% × €35 × 1,000)
|
||||
- Monthly Stripe costs: €628
|
||||
- **Net monthly profit: €597** (+60% increase)
|
||||
|
||||
### With Growth (3.5% model + 20% more events)
|
||||
|
||||
- Monthly orders: 1,200 (20% growth from competitive pricing)
|
||||
- Monthly revenue: €1,470
|
||||
- **Net monthly profit: €842** (+126% increase)
|
||||
@@ -131,6 +146,7 @@ Platform Fee = €1.50 + 2% of ticket price
|
||||
## Technical Implementation
|
||||
|
||||
### Database Changes
|
||||
|
||||
```ruby
|
||||
# Add to events table
|
||||
add_column :events, :commission_rate, :decimal, precision: 5, scale: 4, default: 0.035
|
||||
@@ -140,6 +156,7 @@ add_column :users, :commission_tier, :string, default: 'standard'
|
||||
```
|
||||
|
||||
### Fee Calculation Logic
|
||||
|
||||
```ruby
|
||||
class Order < ApplicationRecord
|
||||
def calculate_platform_fee
|
||||
@@ -148,7 +165,7 @@ class Order < ApplicationRecord
|
||||
base_amount = total_amount_cents / 100.0
|
||||
(base_amount * commission_rate * 100).to_i # return in cents
|
||||
end
|
||||
|
||||
|
||||
def platform_fee_euros
|
||||
calculate_platform_fee / 100.0
|
||||
end
|
||||
@@ -156,6 +173,7 @@ end
|
||||
```
|
||||
|
||||
### Promoter Dashboard Updates
|
||||
|
||||
- Show fee breakdown on event creation
|
||||
- Display projected fees during ticket setup
|
||||
- Add revenue vs. fees analytics
|
||||
@@ -164,9 +182,11 @@ end
|
||||
## Communication Plan
|
||||
|
||||
### Email to Existing Promoters
|
||||
|
||||
**Subject**: "Important Pricing Update - New Fair Fee Structure"
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Current model loses money on higher-priced tickets
|
||||
- New model ensures platform sustainability
|
||||
- Better features and support with improved revenue
|
||||
@@ -174,6 +194,7 @@ end
|
||||
- Competitive with industry standards
|
||||
|
||||
### Website Updates
|
||||
|
||||
- Update pricing page with clear fee calculator
|
||||
- Add FAQ about fee structure
|
||||
- Transparency about what fees cover (development, support, payment processing)
|
||||
@@ -181,11 +202,13 @@ end
|
||||
## Risk Mitigation
|
||||
|
||||
### Potential Issues
|
||||
|
||||
1. **Promoter backlash**: Higher fees on expensive tickets
|
||||
2. **Competitor advantage**: Other platforms with lower fees
|
||||
3. **Reduced event creation**: Promoters may create fewer events
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
1. **Value communication**: Emphasize platform improvements and reliability
|
||||
2. **Competitive analysis**: Position as "fair and sustainable" vs. competitors
|
||||
3. **Volume incentives**: Quick path to reduced rates for active promoters
|
||||
@@ -194,12 +217,14 @@ end
|
||||
## Success Metrics
|
||||
|
||||
### Financial KPIs
|
||||
|
||||
- Monthly recurring revenue growth
|
||||
- Average revenue per transaction
|
||||
- Profit margin improvement
|
||||
- Customer acquisition cost vs. lifetime value
|
||||
|
||||
### Product KPIs
|
||||
|
||||
- Promoter retention rate
|
||||
- New event creation volume
|
||||
- Average ticket prices
|
||||
@@ -221,6 +246,7 @@ The current €1 fixed fee model is financially unsustainable and actually costs
|
||||
### Current Approach vs Industry Standard
|
||||
|
||||
**Current Model (Not Recommended)**:
|
||||
|
||||
```
|
||||
Customer pays: €50 + €1 fee = €51
|
||||
Promoter receives: €50
|
||||
@@ -228,6 +254,7 @@ Platform keeps: €1
|
||||
```
|
||||
|
||||
**Industry Standard (Recommended)**:
|
||||
|
||||
```
|
||||
Customer pays: €50 (clean price)
|
||||
Platform keeps: €1.75 (3.5% of €50)
|
||||
@@ -237,18 +264,21 @@ Promoter receives: €48.25
|
||||
### How Major Platforms Handle Fees
|
||||
|
||||
**Eventbrite:**
|
||||
|
||||
- Promoter sets: €50 ticket
|
||||
- Customer pays: €50
|
||||
- Customer pays: €50
|
||||
- Eventbrite keeps: €1.85 (3.7%)
|
||||
- Promoter receives: €48.15
|
||||
|
||||
**Ticketmaster:**
|
||||
- Promoter sets: €50 ticket
|
||||
|
||||
- Promoter sets: €50 ticket
|
||||
- Customer pays: €50
|
||||
- Ticketmaster keeps: €5-7.50 (10-15%)
|
||||
- Promoter receives: €42.50-45
|
||||
|
||||
**Universe (by Ticketmaster):**
|
||||
|
||||
- Promoter sets: €50 ticket
|
||||
- Customer pays: €50
|
||||
- Universe keeps: €1.74 (2.5% + €0.49)
|
||||
@@ -257,16 +287,19 @@ Promoter receives: €48.25
|
||||
### Why Deducting from Payout is Better
|
||||
|
||||
#### 1. Customer Experience
|
||||
|
||||
- **Price transparency**: Customer sees exactly what they expect to pay
|
||||
- **No surprise fees**: Reduces cart abandonment
|
||||
- **Competitive pricing**: Easier to compare with other events
|
||||
|
||||
#### 2. Promoter Benefits
|
||||
|
||||
- **Marketing simplicity**: Can advertise clean prices
|
||||
- **Psychological pricing**: €50 sounds better than €51.75
|
||||
- **Competitive advantage**: Not adding extra fees to customer
|
||||
|
||||
#### 3. Platform Benefits
|
||||
|
||||
- **Higher conversion rates**: No fee-shock at checkout
|
||||
- **Better promoter adoption**: Easier to sell to event organizers
|
||||
- **Industry standard**: Follows established practices
|
||||
@@ -274,14 +307,17 @@ Promoter receives: €48.25
|
||||
### Psychological Impact
|
||||
|
||||
**Adding Fees to Customer (Current)**:
|
||||
|
||||
- Customer thinks: "€50 ticket... oh wait, €51.75 total" 😤
|
||||
- Cart abandonment risk
|
||||
|
||||
**Deducting from Payout (Recommended)**:
|
||||
**Deducting from Payout (Recommended)**:
|
||||
|
||||
- Customer thinks: "€50 ticket, €50 total" 😊
|
||||
- Smooth purchase experience
|
||||
|
||||
### Promoter Dashboard Display
|
||||
|
||||
```
|
||||
Ticket Price: €50.00
|
||||
Platform Fee (3.5%): -€1.75
|
||||
@@ -289,10 +325,12 @@ Your Earnings per Ticket: €48.25
|
||||
```
|
||||
|
||||
### Communication to Promoters
|
||||
|
||||
**Before:** "Platform charges €1 per order to customers"
|
||||
**After:** "Set your desired revenue per ticket, we handle the rest"
|
||||
|
||||
**Example:**
|
||||
|
||||
- Promoter wants €48.25 net per ticket
|
||||
- They should set ticket price at €50
|
||||
- Customer pays €50, promoter gets €48.25
|
||||
@@ -306,52 +344,56 @@ Our main competitor charges a simple €1 flat fee per order. Here's how our mod
|
||||
|
||||
### Hybrid Model vs Competitor Analysis
|
||||
|
||||
**Hybrid Model**: €1.50 + 2% of ticket price
|
||||
**Hybrid Model**: €1.50 + 2% of ticket price
|
||||
**Competitor**: €1.00 flat fee
|
||||
|
||||
| Ticket Price | Competitor Fee | Hybrid Fee | Difference | Competitive Position |
|
||||
|--------------|----------------|------------|------------|---------------------|
|
||||
| €10 | €1.00 | €1.70 | +€0.70 | More expensive |
|
||||
| €25 | €1.00 | €2.00 | +€1.00 | More expensive |
|
||||
| €50 | €1.00 | €2.50 | +€1.50 | More expensive |
|
||||
| ------------ | -------------- | ---------- | ---------- | -------------------- |
|
||||
| €10 | €1.00 | €1.70 | +€0.70 | More expensive |
|
||||
| €25 | €1.00 | €2.00 | +€1.00 | More expensive |
|
||||
| €50 | €1.00 | €2.50 | +€1.50 | More expensive |
|
||||
| **€75** | **€1.00** | **€3.00** | **+€2.00** | **Break-even point** |
|
||||
| €100 | €1.00 | €3.50 | +€2.50 | Much more expensive |
|
||||
| €100 | €1.00 | €3.50 | +€2.50 | Much more expensive |
|
||||
|
||||
### Alternative Competitive Models
|
||||
|
||||
#### Option 1: Low-End Competitive Model
|
||||
|
||||
```
|
||||
Platform Fee = €0.50 + 1.5% of ticket price
|
||||
Platform Fee = €0.50 + 1.5% of ticket price
|
||||
```
|
||||
|
||||
| Ticket Price | Competitor Fee | Our Fee | Difference | Position |
|
||||
|--------------|----------------|---------|------------|----------|
|
||||
| €10 | €1.00 | €0.65 | **-€0.35** | ✅ **Cheaper** |
|
||||
| €25 | €1.00 | €0.88 | **-€0.12** | ✅ **Cheaper** |
|
||||
| Ticket Price | Competitor Fee | Our Fee | Difference | Position |
|
||||
| ------------ | -------------- | ------- | ---------- | ------------------ |
|
||||
| €10 | €1.00 | €0.65 | **-€0.35** | ✅ **Cheaper** |
|
||||
| €25 | €1.00 | €0.88 | **-€0.12** | ✅ **Cheaper** |
|
||||
| €50 | €1.00 | €1.25 | +€0.25 | ⚠️ Slightly higher |
|
||||
| €100 | €1.00 | €2.00 | +€1.00 | More expensive |
|
||||
| €100 | €1.00 | €2.00 | +€1.00 | More expensive |
|
||||
|
||||
#### Option 2: Modified Hybrid Model
|
||||
|
||||
```
|
||||
Platform Fee = €0.75 + 2.5% of ticket price
|
||||
```
|
||||
|
||||
| Ticket Price | Competitor Fee | Our Fee | Difference | Position |
|
||||
|--------------|----------------|---------|------------|----------|
|
||||
| €10 | €1.00 | €1.00 | **Equal** | ✅ Competitive |
|
||||
| €25 | €1.00 | €1.38 | +€0.38 | ⚠️ Slightly higher |
|
||||
| €40 | €1.00 | €1.75 | +€0.75 | **Break-even** |
|
||||
| Ticket Price | Competitor Fee | Our Fee | Difference | Position |
|
||||
| ------------ | -------------- | ------- | ---------- | ------------------- |
|
||||
| €10 | €1.00 | €1.00 | **Equal** | ✅ Competitive |
|
||||
| €25 | €1.00 | €1.38 | +€0.38 | ⚠️ Slightly higher |
|
||||
| €40 | €1.00 | €1.75 | +€0.75 | **Break-even** |
|
||||
| €75 | €1.00 | €2.63 | +€1.63 | Much more expensive |
|
||||
|
||||
### Competitive Strategy Recommendations
|
||||
|
||||
#### 1. Value Differentiation Approach
|
||||
|
||||
Since we'll be more expensive on higher-priced tickets, focus on premium positioning:
|
||||
|
||||
**Value Proposition:**
|
||||
|
||||
- "We're not the cheapest, we're the most complete"
|
||||
- Advanced analytics dashboard
|
||||
- Real-time sales tracking
|
||||
- Real-time sales tracking
|
||||
- Professional promoter tools
|
||||
- Superior customer support
|
||||
- Platform reliability and sustainability
|
||||
@@ -359,12 +401,14 @@ Since we'll be more expensive on higher-priced tickets, focus on premium positio
|
||||
#### 2. Market Segmentation Strategy
|
||||
|
||||
**Target Market Positioning:**
|
||||
|
||||
- **Competitor**: Best for small, simple events (€10-30 tickets)
|
||||
- **Us**: Best for professional events (€40+ tickets) with serious promoters
|
||||
|
||||
#### 3. Hybrid Competitive Approach
|
||||
|
||||
**Tiered Offering:**
|
||||
|
||||
- **Basic Plan**: Match competitor at €1 flat fee (limited features)
|
||||
- **Professional Plan**: Hybrid model with premium features
|
||||
- **Enterprise Plan**: Custom pricing with full feature set
|
||||
@@ -372,6 +416,7 @@ Since we'll be more expensive on higher-priced tickets, focus on premium positio
|
||||
#### 4. Volume-Based Competitive Response
|
||||
|
||||
**Free Tier Strategy:**
|
||||
|
||||
- First 3 events per month at competitor's €1 rate
|
||||
- Volume discounts for high-activity promoters
|
||||
- Loyalty rewards for long-term customers
|
||||
@@ -381,15 +426,18 @@ Since we'll be more expensive on higher-priced tickets, focus on premium positio
|
||||
#### "Choose Your Business Model" Campaign
|
||||
|
||||
**For Simple Events (Under €40):**
|
||||
|
||||
- "Need basic ticketing? Our competitor works fine"
|
||||
- "Pay €1 flat fee for simple events"
|
||||
|
||||
**For Professional Events (€40+):**
|
||||
|
||||
- "Serious about your business? You need serious tools"
|
||||
- "Fair percentage-based pricing"
|
||||
- "Advanced analytics, marketing tools, priority support"
|
||||
|
||||
#### Brand Positioning Statement
|
||||
|
||||
**"We're the Shopify of Events - Built for Growth"**
|
||||
|
||||
This positions us as the premium option for serious promoters while acknowledging the competitor's advantage on small events.
|
||||
@@ -399,12 +447,14 @@ This positions us as the premium option for serious promoters while acknowledgin
|
||||
Given the competitive landscape, we recommend **Option 1** (€0.50 + 1.5%):
|
||||
|
||||
**Advantages:**
|
||||
|
||||
- Competitive on low-priced tickets
|
||||
- Still profitable at all price points
|
||||
- Better positioning against main competitor
|
||||
- Appeals to both small and large event organizers
|
||||
|
||||
**Financial Impact:**
|
||||
|
||||
- Lower fees on tickets under €33
|
||||
- Competitive fees on tickets €33-66
|
||||
- Premium pricing on high-value tickets justified by features
|
||||
- Premium pricing on high-value tickets justified by features
|
||||
|
||||
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
|
||||
58
test/controllers/api/v1/events_controller_test.rb
Normal file
58
test/controllers/api/v1/events_controller_test.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
require "test_helper"
|
||||
|
||||
class Api::V1::EventsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
ENV["API_KEY"] = "test_key"
|
||||
@user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
@event = Event.create!(name: "Test Event", slug: "test-event", description: "A description that is long enough for validation", latitude: 48.8566, longitude: 2.3522, venue_name: "Venue", venue_address: "Address", user: @user, start_time: 1.week.from_now, end_time: 1.week.from_now + 3.hours, state: :published)
|
||||
end
|
||||
|
||||
test "should get index" do
|
||||
get api_v1_events_url, headers: headers_api_key
|
||||
assert_response :success
|
||||
assert_kind_of Array, json_response
|
||||
end
|
||||
|
||||
test "should show event" do
|
||||
get api_v1_event_url(@event.id), headers: headers_api_key
|
||||
assert_response :success
|
||||
assert_equal @event.id, json_response["id"]
|
||||
end
|
||||
|
||||
test "should create event" do
|
||||
assert_difference("Event.count") do
|
||||
post api_v1_events_url, params: { event: { name: "New Event", slug: "new-event", description: "New description that is long enough", latitude: 48.8566, longitude: 2.3522, venue_name: "New Venue", venue_address: "New Address", user_id: @user.id, start_time: "2024-01-01 10:00:00", end_time: "2024-01-01 13:00:00", state: "published" } }, as: :json, headers: headers_api_key
|
||||
end
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
test "should update event" do
|
||||
patch api_v1_event_url(@event.id), params: { event: { name: "Updated Event" } }, as: :json, headers: headers_api_key
|
||||
assert_response :ok
|
||||
@event.reload
|
||||
assert_equal "Updated Event", @event.name
|
||||
end
|
||||
|
||||
test "should destroy event" do
|
||||
assert_difference("Event.count", -1) do
|
||||
delete api_v1_event_url(@event.id), headers: headers_api_key
|
||||
end
|
||||
assert_response :no_content
|
||||
end
|
||||
|
||||
test "should store cart" do
|
||||
post api_v1_store_cart_path, params: { cart: { ticket_type_id: 1, quantity: 2 }, event_id: @event.id }, as: :json, headers: headers_api_key
|
||||
assert_response :success
|
||||
assert_equal @event.id, session[:event_id]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
|
||||
def headers_api_key
|
||||
{ "X-API-Key" => "test_key" }
|
||||
end
|
||||
end
|
||||
@@ -41,7 +41,7 @@ class ApplicationControllerOnboardingTest < ActionDispatch::IntegrationTest
|
||||
test "should redirect signed in incomplete users from home to onboarding" do
|
||||
sign_in @user_without_onboarding
|
||||
get root_path
|
||||
assert_redirected_to dashboard_path # Home redirects to dashboard for signed in users
|
||||
assert_redirected_to onboarding_path
|
||||
end
|
||||
|
||||
test "should not interfere with devise controllers" do
|
||||
|
||||
33
test/controllers/concerns/stripe_concern_test.rb
Normal file
33
test/controllers/concerns/stripe_concern_test.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
require "test_helper"
|
||||
|
||||
class StripeConcernTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
Rails.application.config.stripe = { secret_key: nil }
|
||||
end
|
||||
|
||||
test "stripe_configured? returns false when no secret key" do
|
||||
controller = ApplicationController.new
|
||||
controller.extend StripeConcern
|
||||
assert_not controller.stripe_configured?
|
||||
end
|
||||
|
||||
test "stripe_configured? returns true when secret key present" do
|
||||
Rails.application.config.stripe = { secret_key: "sk_test_key" }
|
||||
controller = ApplicationController.new
|
||||
controller.extend StripeConcern
|
||||
assert controller.stripe_configured?
|
||||
end
|
||||
|
||||
test "initialize_stripe returns false when not configured" do
|
||||
controller = ApplicationController.new
|
||||
controller.extend StripeConcern
|
||||
assert_not controller.initialize_stripe
|
||||
end
|
||||
|
||||
test "initialize_stripe returns true when configured" do
|
||||
Rails.application.config.stripe = { secret_key: "sk_test_key" }
|
||||
controller = ApplicationController.new
|
||||
controller.extend StripeConcern
|
||||
assert controller.initialize_stripe
|
||||
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]
|
||||
|
||||
128
test/controllers/promoter/events_controller_test.rb
Normal file
128
test/controllers/promoter/events_controller_test.rb
Normal file
@@ -0,0 +1,128 @@
|
||||
require "test_helper"
|
||||
|
||||
class Promoter::EventsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@promoter = User.create!(email: "promoter@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true, onboarding_completed: true)
|
||||
@event = Event.create!(name: "Test Event", slug: "test-event", description: "A valid description for the test event that is long enough to meet the minimum character requirement", latitude: 48.8566, longitude: 2.3522, venue_name: "Venue", venue_address: "Address", user: @promoter, start_time: 1.week.from_now, end_time: 1.week.from_now + 3.hours, state: :draft)
|
||||
end
|
||||
|
||||
test "should require authentication for index" do
|
||||
get promoter_events_path
|
||||
assert_redirected_to new_user_session_path
|
||||
end
|
||||
|
||||
test "should get index for authenticated promoter" do
|
||||
sign_in @promoter
|
||||
get promoter_events_path
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should show promoter's events only" do
|
||||
sign_in @promoter
|
||||
other_event = Event.create!(name: "Other Event", slug: "other", description: "Valid description for the event", latitude: 48.0, longitude: 2.0, venue_name: "V", venue_address: "A", user_id: users(:one).id, start_time: 1.day.from_now, end_time: 2.days.from_now, state: :draft)
|
||||
get promoter_events_path
|
||||
assert_response :success
|
||||
assert_includes assigns(:events), @event
|
||||
assert_not_includes assigns(:events), other_event
|
||||
end
|
||||
|
||||
test "should duplicate an event with ticket types" do
|
||||
sign_in @promoter
|
||||
|
||||
# Create ticket types for the event
|
||||
ticket_type1 = TicketType.create!(
|
||||
name: "Standard Ticket",
|
||||
description: "A standard ticket for the event with all the basic access",
|
||||
price_cents: 2000,
|
||||
quantity: 100,
|
||||
sale_start_at: 1.day.ago,
|
||||
sale_end_at: @event.start_time - 1.hour,
|
||||
event: @event
|
||||
)
|
||||
|
||||
ticket_type2 = TicketType.create!(
|
||||
name: "VIP Ticket",
|
||||
description: "A VIP ticket for the event with special access",
|
||||
price_cents: 5000,
|
||||
quantity: 50,
|
||||
sale_start_at: 1.day.ago,
|
||||
sale_end_at: @event.start_time - 1.hour,
|
||||
event: @event
|
||||
)
|
||||
|
||||
# Verify that ticket types were created successfully
|
||||
assert ticket_type1.valid?
|
||||
assert ticket_type2.valid?
|
||||
|
||||
# Duplicate the event
|
||||
assert_difference("Event.count", 1) do
|
||||
post duplicate_promoter_event_path(@event), params: { clone_ticket_types: "true" }
|
||||
end
|
||||
|
||||
# Check that the new event was created
|
||||
assert_redirected_to edit_promoter_event_path(Event.last)
|
||||
assert_equal "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié.", flash[:notice]
|
||||
|
||||
# Check that the new event has the correct attributes
|
||||
new_event = Event.last
|
||||
assert_equal "Copie de #{@event.name}", new_event.name
|
||||
assert_equal "draft", new_event.state
|
||||
assert_equal @event.venue_name, new_event.venue_name
|
||||
assert_equal @event.venue_address, new_event.venue_address
|
||||
|
||||
# Check that ticket types were duplicated
|
||||
assert_equal 2, new_event.ticket_types.count
|
||||
assert_equal "Standard Ticket", new_event.ticket_types.find_by(name: "Standard Ticket").name
|
||||
assert_equal "VIP Ticket", new_event.ticket_types.find_by(name: "VIP Ticket").name
|
||||
end
|
||||
|
||||
test "should duplicate an event without ticket types" do
|
||||
sign_in @promoter
|
||||
|
||||
# Create ticket types for the event
|
||||
ticket_type1 = TicketType.create!(
|
||||
name: "Standard Ticket",
|
||||
description: "A standard ticket for the event with all the basic access",
|
||||
price_cents: 2000,
|
||||
quantity: 100,
|
||||
sale_start_at: 1.day.ago,
|
||||
sale_end_at: @event.start_time - 1.hour,
|
||||
event: @event
|
||||
)
|
||||
|
||||
ticket_type2 = TicketType.create!(
|
||||
name: "VIP Ticket",
|
||||
description: "A VIP ticket for the event with special access",
|
||||
price_cents: 5000,
|
||||
quantity: 50,
|
||||
sale_start_at: 1.day.ago,
|
||||
sale_end_at: @event.start_time - 1.hour,
|
||||
event: @event
|
||||
)
|
||||
|
||||
# Verify that ticket types were created successfully
|
||||
assert ticket_type1.valid?
|
||||
assert ticket_type2.valid?
|
||||
|
||||
# Duplicate the event without ticket types
|
||||
assert_difference("Event.count", 1) do
|
||||
post duplicate_promoter_event_path(@event), params: { clone_ticket_types: "false" }
|
||||
end
|
||||
|
||||
# Check that the new event was created
|
||||
assert_redirected_to edit_promoter_event_path(Event.last)
|
||||
assert_equal "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié.", flash[:notice]
|
||||
|
||||
# Check that the new event has the correct attributes
|
||||
new_event = Event.last
|
||||
assert_equal "Copie de #{@event.name}", new_event.name
|
||||
assert_equal "draft", new_event.state
|
||||
assert_equal @event.venue_name, new_event.venue_name
|
||||
assert_equal @event.venue_address, new_event.venue_address
|
||||
|
||||
# Check that ticket types were NOT duplicated
|
||||
assert_equal 0, new_event.ticket_types.count
|
||||
end
|
||||
|
||||
# Add tests for new, create, etc. as needed
|
||||
end
|
||||
54
test/controllers/promoter/payouts_controller_test.rb
Normal file
54
test/controllers/promoter/payouts_controller_test.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
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
|
||||
end
|
||||
22
test/controllers/promoter/ticket_types_controller_test.rb
Normal file
22
test/controllers/promoter/ticket_types_controller_test.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
require "test_helper"
|
||||
|
||||
class Promoter::TicketTypesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@promoter = User.create!(email: "promoter@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true, onboarding_completed: true)
|
||||
@event = Event.create!(name: "Test Event", slug: "test-event", description: "A valid description for the test event that is long enough to meet the minimum character requirement", latitude: 48.8566, longitude: 2.3522, venue_name: "Venue", venue_address: "Address", user: @promoter, start_time: 1.week.from_now, end_time: 1.week.from_now + 3.hours, state: :draft)
|
||||
@ticket_type = TicketType.create!(name: "General", description: "General admission", price_cents: 2500, quantity: 100, sale_start_at: Time.current, sale_end_at: @event.start_time, event: @event)
|
||||
end
|
||||
|
||||
test "should require authentication for index" do
|
||||
get promoter_event_ticket_types_path(@event)
|
||||
assert_redirected_to new_user_session_path
|
||||
end
|
||||
|
||||
test "should get index for promoter's event" do
|
||||
sign_in @promoter
|
||||
get promoter_event_ticket_types_path(@event)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
# Add more tests for create, update, destroy
|
||||
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
|
||||
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
|
||||
86
test/models/earning_test.rb
Normal file
86
test/models/earning_test.rb
Normal file
@@ -0,0 +1,86 @@
|
||||
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
|
||||
end
|
||||
@@ -1,4 +1,5 @@
|
||||
require "test_helper"
|
||||
require "timecop"
|
||||
|
||||
class EventTest < ActiveSupport::TestCase
|
||||
# Test that Event model exists
|
||||
@@ -160,4 +161,160 @@ class EventTest < ActiveSupport::TestCase
|
||||
test "should respond to search_by_name scope" do
|
||||
assert_respond_to Event, :search_by_name
|
||||
end
|
||||
|
||||
test "upcoming scope should return only published future events" do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
future_published = Event.create!(name: "Future", slug: "future", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, state: :published)
|
||||
past_published = Event.create!(name: "Past", slug: "past", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.ago, state: :published)
|
||||
future_draft = Event.create!(name: "Draft", slug: "draft", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, state: :draft)
|
||||
|
||||
upcoming = Event.upcoming
|
||||
assert_includes upcoming, future_published
|
||||
assert_not_includes upcoming, past_published
|
||||
assert_not_includes upcoming, future_draft
|
||||
end
|
||||
|
||||
test "geocoding_successful? should return true for valid coordinates" do
|
||||
event = Event.new(latitude: 48.8566, longitude: 2.3522, name: "Test", slug: "test", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a")
|
||||
assert event.geocoding_successful?
|
||||
end
|
||||
|
||||
test "geocoding_successful? should return false for fallback coordinates" do
|
||||
event = Event.new(latitude: 46.603354, longitude: 1.888334, name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a")
|
||||
assert_not event.geocoding_successful?
|
||||
end
|
||||
|
||||
test "geocoding_status_message should return message when not successful" do
|
||||
event = Event.new(latitude: 46.603354, longitude: 1.888334, name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a")
|
||||
assert_match(/coordonnées/, event.geocoding_status_message)
|
||||
end
|
||||
|
||||
test "geocoding_status_message should return nil when successful" do
|
||||
event = Event.new(latitude: 48.8566, longitude: 2.3522, name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a")
|
||||
assert_nil event.geocoding_status_message
|
||||
end
|
||||
|
||||
test "booking_allowed? should be true for published future event" do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
event = Event.create!(name: "Test", slug: "test", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, state: :published)
|
||||
assert event.booking_allowed?
|
||||
end
|
||||
|
||||
test "booking_allowed? should be false for draft event" do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
event = Event.create!(name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, state: :draft)
|
||||
assert_not event.booking_allowed?
|
||||
end
|
||||
|
||||
test "booking_allowed? should be false for canceled event" do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
event = Event.create!(name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, state: :canceled)
|
||||
assert_not event.booking_allowed?
|
||||
end
|
||||
|
||||
test "booking_allowed? should be false for sold_out event" do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
event = Event.create!(name: "Test", slug: "test", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, state: :sold_out)
|
||||
assert_not event.booking_allowed?
|
||||
end
|
||||
|
||||
test "booking_allowed? should be false during event without allow_booking_during_event" do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
event = Event.create!(name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.hour.ago, end_time: 2.hours.from_now, state: :published, allow_booking_during_event: false)
|
||||
assert_not event.booking_allowed?
|
||||
end
|
||||
|
||||
test "booking_allowed? should be true during event with allow_booking_during_event" do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
event = Event.create!(name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.hour.ago, end_time: 2.hours.from_now, state: :published, allow_booking_during_event: true)
|
||||
assert event.booking_allowed?
|
||||
end
|
||||
|
||||
test "event_started? should be true after start_time" do
|
||||
Timecop.freeze(1.hour.from_now) do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
event = Event.create!(name: "Test", slug: "test", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.hour.ago)
|
||||
assert event.event_started?
|
||||
end
|
||||
end
|
||||
|
||||
test "event_started? should be false before start_time" do
|
||||
Timecop.freeze(1.hour.ago) do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
event = Event.create!(name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.hour.from_now)
|
||||
assert_not event.event_started?
|
||||
end
|
||||
end
|
||||
|
||||
test "event_ended? should be true after end_time" do
|
||||
Timecop.freeze(1.hour.from_now) do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
event = Event.create!(name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.hour.ago, end_time: 30.minutes.ago)
|
||||
assert event.event_ended?
|
||||
end
|
||||
end
|
||||
|
||||
test "event_ended? should be false before end_time" do
|
||||
Timecop.freeze(1.hour.ago) do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
event = Event.create!(name: "Test", slug: "test", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.hour.ago, end_time: 1.hour.from_now)
|
||||
assert_not event.event_ended?
|
||||
end
|
||||
end
|
||||
|
||||
test "allow_booking_during_event? should return true when set to true" do
|
||||
event = Event.new(allow_booking_during_event: true)
|
||||
assert event.allow_booking_during_event?
|
||||
end
|
||||
|
||||
test "allow_booking_during_event? should return false when nil" do
|
||||
event = Event.new
|
||||
assert_not event.allow_booking_during_event?
|
||||
end
|
||||
|
||||
test "should duplicate event with ticket types" do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
event = Event.create!(name: "Original Event", slug: "original", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.week.from_now, state: :published)
|
||||
|
||||
# Create ticket types
|
||||
ticket_type1 = TicketType.create!(name: "Standard", description: "A standard ticket for the event", price_cents: 2000, quantity: 100, sale_start_at: 1.day.ago, sale_end_at: event.start_time - 1.hour, event: event)
|
||||
ticket_type2 = TicketType.create!(name: "VIP", description: "A VIP ticket for the event", price_cents: 5000, quantity: 50, sale_start_at: 1.day.ago, sale_end_at: event.start_time - 1.hour, event: event)
|
||||
|
||||
# Duplicate the event
|
||||
duplicated_event = event.duplicate
|
||||
|
||||
# Check that duplication was successful
|
||||
assert_not_nil duplicated_event
|
||||
assert_equal "Copie de #{event.name}", duplicated_event.name
|
||||
assert_equal "draft", duplicated_event.state
|
||||
assert_equal event.venue_name, duplicated_event.venue_name
|
||||
assert_equal event.venue_address, duplicated_event.venue_address
|
||||
|
||||
# Check that ticket types were duplicated
|
||||
assert_equal 2, duplicated_event.ticket_types.count
|
||||
assert_equal "Standard", duplicated_event.ticket_types.find_by(name: "Standard").name
|
||||
assert_equal "VIP", duplicated_event.ticket_types.find_by(name: "VIP").name
|
||||
end
|
||||
|
||||
test "should duplicate event without ticket types" do
|
||||
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
|
||||
event = Event.create!(name: "Original Event", slug: "original", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.week.from_now, state: :published)
|
||||
|
||||
# Create ticket types
|
||||
ticket_type1 = TicketType.create!(name: "Standard", description: "A standard ticket for the event", price_cents: 2000, quantity: 100, sale_start_at: 1.day.ago, sale_end_at: event.start_time - 1.hour, event: event)
|
||||
ticket_type2 = TicketType.create!(name: "VIP", description: "A VIP ticket for the event", price_cents: 5000, quantity: 50, sale_start_at: 1.day.ago, sale_end_at: event.start_time - 1.hour, event: event)
|
||||
|
||||
# Duplicate the event without ticket types
|
||||
duplicated_event = event.duplicate(clone_ticket_types: false)
|
||||
|
||||
# Check that duplication was successful
|
||||
assert_not_nil duplicated_event
|
||||
assert_equal "Copie de #{event.name}", duplicated_event.name
|
||||
assert_equal "draft", duplicated_event.state
|
||||
assert_equal event.venue_name, duplicated_event.venue_name
|
||||
assert_equal event.venue_address, duplicated_event.venue_address
|
||||
|
||||
# Check that ticket types were NOT duplicated
|
||||
assert_equal 0, duplicated_event.ticket_types.count
|
||||
end
|
||||
end
|
||||
|
||||
@@ -469,7 +469,7 @@ class OrderTest < ActiveSupport::TestCase
|
||||
assert_equal "active", ticket2.status
|
||||
end
|
||||
|
||||
test "calculate_total! should sum ticket prices plus 1€ service fee" do
|
||||
test "calculate_total! should sum ticket prices only (platform fee deducted from promoter payout)" do
|
||||
order = Order.create!(
|
||||
user: @user, event: @event, total_amount_cents: 0,
|
||||
status: "draft", payment_attempts: 0
|
||||
@@ -506,7 +506,80 @@ class OrderTest < ActiveSupport::TestCase
|
||||
order.calculate_total!
|
||||
order.reload
|
||||
|
||||
assert_equal 3100, order.total_amount_cents # 2 tickets * 1500 cents + 100 cents (1€ fee)
|
||||
assert_equal 3000, order.total_amount_cents # 2 tickets * 1500 cents (no service fee added to customer)
|
||||
end
|
||||
|
||||
test "platform_fee_cents should calculate €0.50 + 1.5% per ticket" do
|
||||
order = Order.create!(
|
||||
user: @user, event: @event, total_amount_cents: 0,
|
||||
status: "draft", payment_attempts: 0
|
||||
)
|
||||
|
||||
ticket_type1 = TicketType.create!(
|
||||
name: "Cheap Ticket",
|
||||
description: "Cheap ticket type",
|
||||
price_cents: 1000, # €10
|
||||
quantity: 10,
|
||||
sale_start_at: Time.current,
|
||||
sale_end_at: Time.current + 1.day,
|
||||
requires_id: false,
|
||||
event: @event
|
||||
)
|
||||
|
||||
ticket_type2 = TicketType.create!(
|
||||
name: "Expensive Ticket",
|
||||
description: "Expensive ticket type",
|
||||
price_cents: 5000, # €50
|
||||
quantity: 10,
|
||||
sale_start_at: Time.current,
|
||||
sale_end_at: Time.current + 1.day,
|
||||
requires_id: false,
|
||||
event: @event
|
||||
)
|
||||
|
||||
ticket1 = Ticket.create!(order: order, ticket_type: ticket_type1, status: "draft", first_name: "John", last_name: "Doe")
|
||||
ticket2 = Ticket.create!(order: order, ticket_type: ticket_type2, status: "draft", first_name: "Jane", last_name: "Doe")
|
||||
|
||||
expected_fee = (50 + (1000 * 0.015).to_i) + (50 + (5000 * 0.015).to_i) # 50+15 + 50+75 = 190
|
||||
assert_equal 190, order.platform_fee_cents
|
||||
end
|
||||
|
||||
test "promoter_payout_cents should be total minus platform fee" do
|
||||
order = Order.create!(
|
||||
user: @user, event: @event, total_amount_cents: 3000,
|
||||
status: "paid", payment_attempts: 0
|
||||
)
|
||||
|
||||
ticket_type = TicketType.create!(
|
||||
name: "Test Ticket",
|
||||
description: "Test ticket",
|
||||
price_cents: 1500,
|
||||
quantity: 10,
|
||||
sale_start_at: Time.current,
|
||||
sale_end_at: Time.current + 1.day,
|
||||
requires_id: false,
|
||||
event: @event
|
||||
)
|
||||
|
||||
Ticket.create!(order: order, ticket_type: ticket_type, status: "active", first_name: "John", last_name: "Doe")
|
||||
Ticket.create!(order: order, ticket_type: ticket_type, status: "active", first_name: "Jane", last_name: "Doe")
|
||||
|
||||
order.calculate_total! # Should still be 3000
|
||||
expected_payout = 3000 - (50 + (1500 * 0.015).to_i) * 2 # 3000 - (50+22.5≈22)*2 = 3000 - 144 = 2856
|
||||
assert_equal 2856, order.promoter_payout_cents
|
||||
end
|
||||
|
||||
test "platform_fee_euros should convert cents to euros" do
|
||||
order = Order.new(total_amount_cents: 0)
|
||||
# Assuming one €10 ticket: 50 + 150 = 200 cents = €2.00
|
||||
def order.platform_fee_cents; 200; end
|
||||
assert_equal 2.0, order.platform_fee_euros
|
||||
end
|
||||
|
||||
test "promoter_payout_euros should convert cents to euros" do
|
||||
order = Order.new(total_amount_cents: 10000)
|
||||
def order.platform_fee_cents; 500; end
|
||||
assert_equal 95.0, order.promoter_payout_euros
|
||||
end
|
||||
|
||||
# === Stripe Integration Tests (Mock) ===
|
||||
@@ -530,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -151,7 +151,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
|
||||
mock_invoice.stubs(:finalize_invoice).returns(mock_invoice)
|
||||
mock_invoice.expects(:pay)
|
||||
Stripe::Invoice.expects(:create).returns(mock_invoice)
|
||||
Stripe::InvoiceItem.expects(:create).twice # Once for tickets, once for service fee
|
||||
Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee
|
||||
|
||||
result = @service.create_post_payment_invoice
|
||||
assert_not_nil result
|
||||
@@ -173,7 +173,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
|
||||
mock_invoice.stubs(:finalize_invoice).returns(mock_invoice)
|
||||
mock_invoice.expects(:pay)
|
||||
Stripe::Invoice.expects(:create).returns(mock_invoice)
|
||||
Stripe::InvoiceItem.expects(:create).twice # Once for tickets, once for service fee
|
||||
Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee
|
||||
|
||||
result = @service.create_post_payment_invoice
|
||||
assert_not_nil result
|
||||
@@ -210,25 +210,12 @@ 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_invoice = mock("invoice")
|
||||
mock_invoice.stubs(:id).returns("in_test123")
|
||||
mock_invoice.stubs(:finalize_invoice).returns(mock_invoice)
|
||||
mock_invoice.expects(:pay)
|
||||
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
|
||||
@@ -261,7 +248,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
|
||||
mock_invoice.expects(:pay)
|
||||
|
||||
Stripe::Invoice.expects(:create).with(expected_invoice_data).returns(mock_invoice)
|
||||
Stripe::InvoiceItem.expects(:create).twice # Once for tickets, once for service fee
|
||||
Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee
|
||||
|
||||
result = @service.create_post_payment_invoice
|
||||
assert_not_nil result
|
||||
@@ -300,7 +287,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
|
||||
})
|
||||
|
||||
Stripe::Invoice.expects(:create).returns(mock_invoice)
|
||||
Stripe::InvoiceItem.expects(:create).twice # Once for tickets, once for service fee
|
||||
Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee
|
||||
mock_invoice.expects(:finalize_invoice).returns(mock_finalized_invoice)
|
||||
|
||||
result = @service.create_post_payment_invoice
|
||||
|
||||
@@ -12,6 +12,7 @@ module ActiveSupport
|
||||
class TestCase
|
||||
# Run tests in parallel with specified workers
|
||||
parallelize(workers: :number_of_processors)
|
||||
use_transactional_fixtures = true
|
||||
|
||||
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
|
||||
fixtures :all
|
||||
|
||||
Reference in New Issue
Block a user