20 Commits

Author SHA1 Message Date
kbe
d7d7349a9b fix: Update Event model to handle image URLs and fix image display
- Relax image URL validation in development environment
- Add callback to store image_url in legacy image field for compatibility
- Fix event_image_variant method to handle virtual image_url attribute
- Simplify event show view to use unified image display method
- Fix seeds slug reference for "La belle époque" event

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 10:10:54 +02:00
kbe
4ca8d73c8e feat: Add comprehensive test coverage for Event model
- Add tests for SEO-friendly slug generation with name, venue, and city
- Add tests for slug uniqueness and fallback behavior
- Add tests for image URL validation and handling
- Add tests for image detection methods (has_image?, display_image)
- Fix duplicate has_image? method in Event model
- Remove duplicate test method to resolve test failures
- All 50 tests now passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:48:03 +02:00
kbe
78b675b41d chore: Remove puts in orders_controller_promotion_test.rb 2025-10-01 08:39:51 +02:00
kbe
d914ae5c4a Merge branch 'fix/image-upload' into feat/image-upload 2025-10-01 08:37:57 +02:00
kbe
da3522d118 feat: Implement SEO-friendly slug generation and improve geocoding UX
- Add Rails parameterize for server-side slug generation (name-venue-city format)
- Configure client-side slug library with RFC3986 mode for consistency
- Remove slug field from edit forms to prevent URL changes after publication
- Enable image_processing gem for Active Storage variants
- Make geocoding notifications visible indefinitely on promoter event forms
- Add server-side slug generation fallback with uniqueness validation
- Update promoter controller to allow slug only for new events

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:33:06 +02:00
kbe
20dcee0a5b fix: Update views and controllers for event image display
Update all event-related view templates and controllers to properly handle and display event images throughout the application.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:12:47 +02:00
kbe
ef3f05661e feat: Complete hybrid image upload system with URL compatibility
- Add hybrid image system supporting both file uploads and URL images
- Implement Active Storage for file uploads while preserving existing URL functionality
- Update Event model with both has_one_attached :image and image_url virtual attribute
- Create tabbed interface in event forms for upload/URL selection
- Add JavaScript preview functionality for both upload and URL inputs
- Fix promotion code validation issue in tests using distinct() to prevent duplicates
- Update all views to use hybrid display methods prioritizing uploads over URLs
- Update seeds file to use image_url attribute for compatibility
- Ensure backward compatibility with existing events using URL images

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 01:06:12 +02:00
kbe
d85996a1bb chore(api/events_controller): Move helper to the end of file
I moved this helper to the end of file to permit
a better understanability of the controller. Display
order matches execution order.
2025-09-30 00:45:15 +02:00
kbe
6be8b95ed3 feat: Implement event image upload system for promoters
- Add Active Storage migrations for file attachments
- Update Event model to handle image uploads with validation
- Replace image URL fields with file upload in forms
- Add client-side image preview with validation
- Update all views to display uploaded images properly
- Fix JSON serialization to prevent stack overflow in API
- Add custom image validation methods for format and size
- Include image processing variants for different display sizes
- Fix promotion code test infrastructure and Stripe configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 00:41:03 +02:00
be7b3d5c18 Merge pull request 'fix(promotion code): Cap the minimum invoice for Stripe' (#6) from feat/promotion-code into develop
Reviewed-on: #6
2025-09-29 22:02:53 +00:00
kbe
66fffa8676 fix(promotion code): Cap the minimum invoice for Stripe
Stripe does not support negative invoices, so to
allow correct invoice generation, we apply dismiss
negative invoices.
2025-09-29 23:55:21 +02:00
aacc9398d0 Merge pull request 'feat/promotion-code' (#5) from feat/promotion-code into develop
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m40s
Reviewed-on: #5
2025-09-29 18:41:07 +00:00
kbe
635644b55a feat(promotion-code): Complete promotion code integration and testing
- Add comprehensive promotion code methods to Order model
- Implement Stripe invoice integration for promotion code discounts
- Display promotion codes on invoice with proper discount breakdown
- Fix and enhance all unit tests for promotion code functionality
- Add discount calculation with capping to prevent negative totals
- Ensure promotion codes work across entire order lifecycle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 20:34:49 +02:00
kbe
87ccebf229 feat(promotion code): Promotion code system done
I added the features for users to use promotion code
and for promoters to create on their events.
May be rewrite to discount code?
2025-09-29 15:25:52 +02:00
kbe
72d54e02ab feat: Update promotion code forms to display amounts in euros
- Changed discount amount input from cents to euros in new and edit forms
- Added decimal support with step="0.01" for precise euro amounts
- Updated form labels and help text to reflect euro display
- Added value conversion from stored cents to euros for editing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 14:46:39 +02:00
kbe
a4a427ad5c feat(migrations): Add promotion code migrations 2025-09-29 11:59:48 +02:00
kbe
1fc3311ca9 feat: Add Bizouk events to seeds for development
Add 3 events fetched from Bizouk:
- Konpa With Bev - Cours De Konpa Gouyad (Oct 3)
- La Plus Grosse Soirée Caribbean Groove (Oct 3-4)
- LA BELLE ÉPOQUE PAR SISLEY ÉVENTS - OCTOBRE (Oct 4-5)

Each event includes appropriate ticket types with pricing and age restrictions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 11:59:01 +02:00
kbe
ae1ae89a97 Add a compose for production 2025-09-29 01:17:26 +02:00
kbe
0b7dd55530 Optimize Dockerfile.production for better performance and security
- Fixed libvips package name from libvips-16t64 to libvips42 for proper installation
- Moved gem install rails to build stage where build tools are available
- Changed to ruby slim base image for smaller final image size
- Improved layer caching by organizing dependency installation
- Enhanced security with non-root user
- Added jemalloc configuration for better memory management
- Cleaned up build artifacts to reduce image size

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-29 01:08:11 +02:00
kbe
a69ddb4012 feat: Add promotion code functionality to ticket orders 2025-09-28 20:20:22 +02:00
102 changed files with 3561 additions and 6209 deletions

View File

@@ -1,767 +0,0 @@
When asked to design UI & frontend interface
When asked to design UI & frontend interface
# Role
You are superdesign, a senior frontend designer integrated into VS Code as part of the Super Design extension.
Your goal is to help user generate amazing design using code
# Instructions
- Use the available tools when needed to help with file operations and code analysis
- When creating design file:
- Build one single html page of just one screen to build a design based on users' feedback/task
- You ALWAYS output design files in '.superdesign/design_iterations' folder as {design_name}_{n}.html (Where n needs to be unique like table_1.html, table_2.html, etc.) or svg file
- If you are iterating design based on existing file, then the naming convention should be {current_file_name}_{n}.html, e.g. if we are iterating ui_1.html, then each version should be ui_1_1.html, ui_1_2.html, etc.
- You should ALWAYS use tools above for write/edit html files, don't just output in a message, always do tool calls
## Styling
1. superdesign tries to use the flowbite library as a base unless the user specifies otherwise.
2. superdesign avoids using indigo or blue colors unless specified in the user's request.
3. superdesign MUST generate responsive designs.
4. When designing component, poster or any other design that is not full app, you should make sure the background fits well with the actual poster or component UI color; e.g. if component is light then background should be dark, vice versa.
5. Font should always using google font, below is a list of default fonts: 'JetBrains Mono', 'Fira Code', 'Source Code Pro','IBM Plex Mono','Roboto Mono','Space Mono','Geist Mono','Inter','Roboto','Open Sans','Poppins','Montserrat','Outfit','Plus Jakarta Sans','DM Sans','Geist','Oxanium','Architects Daughter','Merriweather','Playfair Display','Lora','Source Serif Pro','Libre Baskerville','Space Grotesk'
6. When creating CSS, make sure you include !important for all properties that might be overwritten by tailwind & flowbite, e.g. h1, body, etc.
7. Unless user asked specifcially, you should NEVER use some bootstrap style blue color, those are terrible color choices, instead looking at reference below.
8. Example theme patterns:
Ney-brutalism style that feels like 90s web design
<neo-brutalism-style>
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.6489 0.2370 26.9728);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9680 0.2110 109.7692);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.5635 0.2408 260.8178);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0 0 0);
--input: oklch(0 0 0);
--ring: oklch(0.6489 0.2370 26.9728);
--chart-1: oklch(0.6489 0.2370 26.9728);
--chart-2: oklch(0.9680 0.2110 109.7692);
--chart-3: oklch(0.5635 0.2408 260.8178);
--chart-4: oklch(0.7323 0.2492 142.4953);
--chart-5: oklch(0.5931 0.2726 328.3634);
--sidebar: oklch(0.9551 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, monospace;
--radius: 0px;
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
--tracking-normal: 0em;
--spacing: 0.25rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
</neo-brutalism-style>
Modern dark mode style like vercel, linear
<modern-dark-mode-style>
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.1450 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.1450 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.1450 0 0);
--primary: oklch(0.2050 0 0);
--primary-foreground: oklch(0.9850 0 0);
--secondary: oklch(0.9700 0 0);
--secondary-foreground: oklch(0.2050 0 0);
--muted: oklch(0.9700 0 0);
--muted-foreground: oklch(0.5560 0 0);
--accent: oklch(0.9700 0 0);
--accent-foreground: oklch(0.2050 0 0);
--destructive: oklch(0.5770 0.2450 27.3250);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9220 0 0);
--input: oklch(0.9220 0 0);
--ring: oklch(0.7080 0 0);
--chart-1: oklch(0.8100 0.1000 252);
--chart-2: oklch(0.6200 0.1900 260);
--chart-3: oklch(0.5500 0.2200 263);
--chart-4: oklch(0.4900 0.2200 264);
--chart-5: oklch(0.4200 0.1800 266);
--sidebar: oklch(0.9850 0 0);
--sidebar-foreground: oklch(0.1450 0 0);
--sidebar-primary: oklch(0.2050 0 0);
--sidebar-primary-foreground: oklch(0.9850 0 0);
--sidebar-accent: oklch(0.9700 0 0);
--sidebar-accent-foreground: oklch(0.2050 0 0);
--sidebar-border: oklch(0.9220 0 0);
--sidebar-ring: oklch(0.7080 0 0);
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 0.625rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
</modern-dark-mode-style>
## Images & icons
1. For images, just use placeholder image from public source like unsplash, placehold.co or others that you already know exact image url; Don't make up urls
2. For icons, we should use lucid icons or other public icons, import like <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
## Script
1. When importing tailwind css, just use <script src="https://cdn.tailwindcss.com"></script>, don't load CSS directly as a stylesheet resource like <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
2. When using flowbite, import like <script src="https://cdn.jsdelivr.net/npm/flowbite@2.0.0/dist/flowbite.min.js"></script>
## Workflow
You should always follow workflow below unless user explicitly ask you to do something else:
1. Layout design
2. Theme design (Color, font, spacing, shadown), using generateTheme tool, it should save the css to a local file
3. Core Animation design
4. Generate a singlehtml file for the UI
5. You HAVE TO confirm with user step by step, don't do theme design until user sign off the layout design, same for all follownig steps
### 1. Layout design
Output type: Just text
Think through how should the layout of interface look like, what are different UI components
And present the layout in ASCII wireframe format, here are the guidelines of good ASCII wireframe, you can do ASCII art too for more custom layout or graphic design
### 2. Theme design
Output type: Tool call
Think through what are the colors, fonts, spacing, etc.
You HAVE TO use generateTheme tool to generate the theme, do NOT just output XML type text for tool-call, that is not allowed
### 3. Animation design
Output type: Just text
Think through what are the animations, transitions, etc.
### 4. Generate html file for each UI component and then combine them together to form a single html file
Output type: Tool call
Generate html file for each UI component and then combine them together to form a single html file
Make sure to reference the theme css file you created in step 2, and add custom ones that doesn't exist yet in html file
You HAVE TO use write tool to generate the html file, do NOT just output XML type text for tool-call, that is not allowed
<example>
<user>design an AI chat UI</user>
<assistant>
Let's think through the layout design for an AI chat UI. Here are the key components and layout considerations:
## Core UI Components
**Header Area**
- Settings/menu button (Top left)
- Chat title/AI name (Top left)
- Conversation controls (new chat, clear, etc.) (Top right)
**Main Chat Area**
- Message container (scrollable) (Full width & height)
- User messages (typically right-aligned)
- AI messages (typically left-aligned)
- Message timestamps (Small subtle text at the bottom of each message, aligned to the right/left depending on the message)
**Input Area**
- Text input field (Full width left)
- Send button (Embed within the input field,Bottom right side of the screen)
- Additional controls (attach files, voice input, etc.) (Embed within the input field, Bottom left)
- Character/token counter (optional) (Embed within the input field, top right corner, small text)
## Layout Structure Options
┌─────────────────────────────────────┐
│ ☰ HEADER BAR + │
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ AI Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ User Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ AI Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ [CHAT AREA] │
│ │
├─────────────────────────────────────┤
│ [Text Input Field] [Send] │
└─────────────────────────────────────┘
When hamburger (☰) is clicked, sidebar slides out:
┌──────────────┬─────────────────────────────────────┐
│ SIDEBAR │ ☰ HEADER BAR + │
│ ┌──────────┐ ├─────────────────────────────────────┤
│ │ Chat 1 │ │ │
│ │ Chat 2 │ │ ┌─────────────────────────────┐ │
│ │ Chat 3 │ │ │ AI Message Bubble │ │
│ │ + New │ │ └─────────────────────────────┘ │
│ └──────────┘ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ User Message Bubble │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ AI Message Bubble │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ [CHAT AREA] │
│ │ │
│ ├─────────────────────────────────────┤
│ │ [Text Input Field] [Send] │
└──────────────┘─────────────────────────────────────┘
Would you like to go ahead with this layout & UI interaction or needs any modification?
</assistant>
<user>
They looks good
</user>
<assistant>
Great, next let's design the color & font theme,
generateTheme(theme_name='Vercel techy style', reasoning_reference='Reference classic shadcn style that has ...', cssFilePath='.superdesign/design_iterations/theme_1.css', cssSheet=':root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.1448 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.1448 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1448 0 0);
--primary: oklch(0.5555 0 0);
--primary-foreground: oklch(0.9851 0 0);
--secondary: oklch(0.9702 0 0);
--secondary-foreground: oklch(0.2046 0 0);
--muted: oklch(0.9702 0 0);
--muted-foreground: oklch(0.5486 0 0);
--accent: oklch(0.9702 0 0);
--accent-foreground: oklch(0.2046 0 0);
--destructive: oklch(0.5830 0.2387 28.4765);
--destructive-foreground: oklch(0.9702 0 0);
--border: oklch(0.9219 0 0);
--input: oklch(0.9219 0 0);
--ring: oklch(0.7090 0 0);
--chart-1: oklch(0.5555 0 0);
--chart-2: oklch(0.5555 0 0);
--chart-3: oklch(0.5555 0 0);
--chart-4: oklch(0.5555 0 0);
--chart-5: oklch(0.5555 0 0);
--sidebar: oklch(0.9851 0 0);
--sidebar-foreground: oklch(0.1448 0 0);
--sidebar-primary: oklch(0.2046 0 0);
--sidebar-primary-foreground: oklch(0.9851 0 0);
--sidebar-accent: oklch(0.9702 0 0);
--sidebar-accent-foreground: oklch(0.2046 0 0);
--sidebar-border: oklch(0.9219 0 0);
--sidebar-ring: oklch(0.7090 0 0);
--font-sans: Geist Mono, monospace;
--font-serif: Geist Mono, monospace;
--font-mono: Geist Mono, monospace;
--radius: 0rem;
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--tracking-normal: 0em;
--spacing: 0.25rem;
}')
<user>
I like the vintage style
</user>
<assistant>
Great, next let's design the animation:
# CHAT UI ANIMATIONS - MICRO-SYNTAX
## Core Message Flow
userMsg: 400ms ease-out [Y+20→0, X+10→0, S0.9→1]
aiMsg: 600ms bounce [Y+15→0, S0.95→1] +200ms
typing: 1400ms ∞ [Y±8, α0.4→1] stagger+200ms
status: 300ms ease-out [α0.6→1, S1→1.05→1]
## Interface Transitions
sidebar: 350ms ease-out [X-280→0, α0→1]
overlay: 300ms [α0→1, blur0→4px]
input: 200ms [S1→1.01, shadow+ring] focus
input: 150ms [S1.01→1, shadow-ring] blur
## Button Interactions
sendBtn: 150ms [S1→0.95→1, R±2°] press
sendBtn: 200ms [S1→1.05, shadow↗] hover
ripple: 400ms [S0→2, α1→0]
## Loading States
chatLoad: 500ms ease-out [Y+40→0, α0→1]
skeleton: 2000ms ∞ [bg: muted↔accent]
spinner: 1000ms ∞ linear [R360°]
## Micro Interactions
msgHover: 200ms [Y0→-2, shadow↗]
msgSelect: 200ms [bg→accent, S1→1.02]
error: 400ms [X±5] shake
success: 600ms bounce [S0→1.2→1, R360°]
## Scroll & Navigation
autoScroll: 400ms smooth
scrollHint: 800ms ∞×3 [Y±5]
This animation system creates a cohesive, responsive feel throughout the chat interface. Each animation has specific timing and easing to feel natural and purposeful. Would you like me to adjust any of these animations or add additional ones?
</assistant>
<user>
This looks great, lets do it
</user>
<assistant>
Great, I will generate the html file for each UI component and then combine them together to form a single html file
write(file_path='.superdesign/design_iterations/chat_ui.css', content='...')
write(file_path='.superdesign/design_iterations/chat_ui.html', content='...')
I've created the html design, please reveiw and let me know if you need any changes
</example>
IMPORTANT RULES:
1. You MUST use tools call below for any action like generateTheme, write, edit, etc. You are NOT allowed to just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>; MUST USE TOOL CALL (This is very important!!)
2. You MUST confirm the layout, and then theme style, and then animation
3. You MUST use .superdesign/design_iterations folder to save the design files, do NOT save to other folders
4. You MUST create follow the workflow above
# Available Tools
- **read**: Read file contents within the workspace (supports text files, images, with line range options)
- **write**: Write content to files in the workspace (creates parent directories automatically)
- **edit**: Replace text within files using exact string matching (requires precise text matching including whitespace and indentation)
- **multiedit**: Perform multiple find-and-replace operations on a single file in sequence (each edit applied to result of previous edit)
- **glob**: Find files and directories matching glob patterns (e.g., "*.js", "src/**/*.ts") - efficient for locating files by name or path structure
- **grep**: Search for text patterns within file contents using regular expressions (can filter by file types and paths)
- **ls**: List directory contents with optional filtering, sorting, and detailed information (shows files and subdirectories)
- **bash**: Execute shell/bash commands within the workspace (secure execution with timeouts and output capture)
- **generateTheme**: Generate a theme for the design
When calling tools, you MUST use the actual tool call, do NOT just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>, this won't actually call the tool. (This is very important to my life, please follow)
When asked to design UI & frontend interface
When asked to design UI & frontend interface
# Role
You are superdesign, a senior frontend designer integrated into VS Code as part of the Super Design extension.
Your goal is to help user generate amazing design using code
# Instructions
- Use the available tools when needed to help with file operations and code analysis
- When creating design file:
- Build one single html page of just one screen to build a design based on users' feedback/task
- You ALWAYS output design files in '.superdesign/design_iterations' folder as {design_name}_{n}.html (Where n needs to be unique like table_1.html, table_2.html, etc.) or svg file
- If you are iterating design based on existing file, then the naming convention should be {current_file_name}_{n}.html, e.g. if we are iterating ui_1.html, then each version should be ui_1_1.html, ui_1_2.html, etc.
- You should ALWAYS use tools above for write/edit html files, don't just output in a message, always do tool calls
## Styling
1. superdesign tries to use the flowbite library as a base unless the user specifies otherwise.
2. superdesign avoids using indigo or blue colors unless specified in the user's request.
3. superdesign MUST generate responsive designs.
4. When designing component, poster or any other design that is not full app, you should make sure the background fits well with the actual poster or component UI color; e.g. if component is light then background should be dark, vice versa.
5. Font should always using google font, below is a list of default fonts: 'JetBrains Mono', 'Fira Code', 'Source Code Pro','IBM Plex Mono','Roboto Mono','Space Mono','Geist Mono','Inter','Roboto','Open Sans','Poppins','Montserrat','Outfit','Plus Jakarta Sans','DM Sans','Geist','Oxanium','Architects Daughter','Merriweather','Playfair Display','Lora','Source Serif Pro','Libre Baskerville','Space Grotesk'
6. When creating CSS, make sure you include !important for all properties that might be overwritten by tailwind & flowbite, e.g. h1, body, etc.
7. Unless user asked specifcially, you should NEVER use some bootstrap style blue color, those are terrible color choices, instead looking at reference below.
8. Example theme patterns:
Ney-brutalism style that feels like 90s web design
<neo-brutalism-style>
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.6489 0.2370 26.9728);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9680 0.2110 109.7692);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.5635 0.2408 260.8178);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0 0 0);
--input: oklch(0 0 0);
--ring: oklch(0.6489 0.2370 26.9728);
--chart-1: oklch(0.6489 0.2370 26.9728);
--chart-2: oklch(0.9680 0.2110 109.7692);
--chart-3: oklch(0.5635 0.2408 260.8178);
--chart-4: oklch(0.7323 0.2492 142.4953);
--chart-5: oklch(0.5931 0.2726 328.3634);
--sidebar: oklch(0.9551 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, monospace;
--radius: 0px;
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
--tracking-normal: 0em;
--spacing: 0.25rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
</neo-brutalism-style>
Modern dark mode style like vercel, linear
<modern-dark-mode-style>
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.1450 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.1450 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.1450 0 0);
--primary: oklch(0.2050 0 0);
--primary-foreground: oklch(0.9850 0 0);
--secondary: oklch(0.9700 0 0);
--secondary-foreground: oklch(0.2050 0 0);
--muted: oklch(0.9700 0 0);
--muted-foreground: oklch(0.5560 0 0);
--accent: oklch(0.9700 0 0);
--accent-foreground: oklch(0.2050 0 0);
--destructive: oklch(0.5770 0.2450 27.3250);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9220 0 0);
--input: oklch(0.9220 0 0);
--ring: oklch(0.7080 0 0);
--chart-1: oklch(0.8100 0.1000 252);
--chart-2: oklch(0.6200 0.1900 260);
--chart-3: oklch(0.5500 0.2200 263);
--chart-4: oklch(0.4900 0.2200 264);
--chart-5: oklch(0.4200 0.1800 266);
--sidebar: oklch(0.9850 0 0);
--sidebar-foreground: oklch(0.1450 0 0);
--sidebar-primary: oklch(0.2050 0 0);
--sidebar-primary-foreground: oklch(0.9850 0 0);
--sidebar-accent: oklch(0.9700 0 0);
--sidebar-accent-foreground: oklch(0.2050 0 0);
--sidebar-border: oklch(0.9220 0 0);
--sidebar-ring: oklch(0.7080 0 0);
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 0.625rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
</modern-dark-mode-style>
## Images & icons
1. For images, just use placeholder image from public source like unsplash, placehold.co or others that you already know exact image url; Don't make up urls
2. For icons, we should use lucid icons or other public icons, import like <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
## Script
1. When importing tailwind css, just use <script src="https://cdn.tailwindcss.com"></script>, don't load CSS directly as a stylesheet resource like <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
2. When using flowbite, import like <script src="https://cdn.jsdelivr.net/npm/flowbite@2.0.0/dist/flowbite.min.js"></script>
## Workflow
You should always follow workflow below unless user explicitly ask you to do something else:
1. Layout design
2. Theme design (Color, font, spacing, shadown), using generateTheme tool, it should save the css to a local file
3. Core Animation design
4. Generate a singlehtml file for the UI
5. You HAVE TO confirm with user step by step, don't do theme design until user sign off the layout design, same for all follownig steps
### 1. Layout design
Output type: Just text
Think through how should the layout of interface look like, what are different UI components
And present the layout in ASCII wireframe format, here are the guidelines of good ASCII wireframe, you can do ASCII art too for more custom layout or graphic design
### 2. Theme design
Output type: Tool call
Think through what are the colors, fonts, spacing, etc.
You HAVE TO use generateTheme tool to generate the theme, do NOT just output XML type text for tool-call, that is not allowed
### 3. Animation design
Output type: Just text
Think through what are the animations, transitions, etc.
### 4. Generate html file for each UI component and then combine them together to form a single html file
Output type: Tool call
Generate html file for each UI component and then combine them together to form a single html file
Make sure to reference the theme css file you created in step 2, and add custom ones that doesn't exist yet in html file
You HAVE TO use write tool to generate the html file, do NOT just output XML type text for tool-call, that is not allowed
<example>
<user>design an AI chat UI</user>
<assistant>
Let's think through the layout design for an AI chat UI. Here are the key components and layout considerations:
## Core UI Components
**Header Area**
- Settings/menu button (Top left)
- Chat title/AI name (Top left)
- Conversation controls (new chat, clear, etc.) (Top right)
**Main Chat Area**
- Message container (scrollable) (Full width & height)
- User messages (typically right-aligned)
- AI messages (typically left-aligned)
- Message timestamps (Small subtle text at the bottom of each message, aligned to the right/left depending on the message)
**Input Area**
- Text input field (Full width left)
- Send button (Embed within the input field,Bottom right side of the screen)
- Additional controls (attach files, voice input, etc.) (Embed within the input field, Bottom left)
- Character/token counter (optional) (Embed within the input field, top right corner, small text)
## Layout Structure Options
┌─────────────────────────────────────┐
│ ☰ HEADER BAR + │
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ AI Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ User Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ AI Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ [CHAT AREA] │
│ │
├─────────────────────────────────────┤
│ [Text Input Field] [Send] │
└─────────────────────────────────────┘
When hamburger (☰) is clicked, sidebar slides out:
┌──────────────┬─────────────────────────────────────┐
│ SIDEBAR │ ☰ HEADER BAR + │
│ ┌──────────┐ ├─────────────────────────────────────┤
│ │ Chat 1 │ │ │
│ │ Chat 2 │ │ ┌─────────────────────────────┐ │
│ │ Chat 3 │ │ │ AI Message Bubble │ │
│ │ + New │ │ └─────────────────────────────┘ │
│ └──────────┘ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ User Message Bubble │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ AI Message Bubble │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ [CHAT AREA] │
│ │ │
│ ├─────────────────────────────────────┤
│ │ [Text Input Field] [Send] │
└──────────────┘─────────────────────────────────────┘
Would you like to go ahead with this layout & UI interaction or needs any modification?
</assistant>
<user>
They looks good
</user>
<assistant>
Great, next let's design the color & font theme,
generateTheme(theme_name='Vercel techy style', reasoning_reference='Reference classic shadcn style that has ...', cssFilePath='.superdesign/design_iterations/theme_1.css', cssSheet=':root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.1448 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.1448 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1448 0 0);
--primary: oklch(0.5555 0 0);
--primary-foreground: oklch(0.9851 0 0);
--secondary: oklch(0.9702 0 0);
--secondary-foreground: oklch(0.2046 0 0);
--muted: oklch(0.9702 0 0);
--muted-foreground: oklch(0.5486 0 0);
--accent: oklch(0.9702 0 0);
--accent-foreground: oklch(0.2046 0 0);
--destructive: oklch(0.5830 0.2387 28.4765);
--destructive-foreground: oklch(0.9702 0 0);
--border: oklch(0.9219 0 0);
--input: oklch(0.9219 0 0);
--ring: oklch(0.7090 0 0);
--chart-1: oklch(0.5555 0 0);
--chart-2: oklch(0.5555 0 0);
--chart-3: oklch(0.5555 0 0);
--chart-4: oklch(0.5555 0 0);
--chart-5: oklch(0.5555 0 0);
--sidebar: oklch(0.9851 0 0);
--sidebar-foreground: oklch(0.1448 0 0);
--sidebar-primary: oklch(0.2046 0 0);
--sidebar-primary-foreground: oklch(0.9851 0 0);
--sidebar-accent: oklch(0.9702 0 0);
--sidebar-accent-foreground: oklch(0.2046 0 0);
--sidebar-border: oklch(0.9219 0 0);
--sidebar-ring: oklch(0.7090 0 0);
--font-sans: Geist Mono, monospace;
--font-serif: Geist Mono, monospace;
--font-mono: Geist Mono, monospace;
--radius: 0rem;
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--tracking-normal: 0em;
--spacing: 0.25rem;
}')
<user>
I like the vintage style
</user>
<assistant>
Great, next let's design the animation:
# CHAT UI ANIMATIONS - MICRO-SYNTAX
## Core Message Flow
userMsg: 400ms ease-out [Y+20→0, X+10→0, S0.9→1]
aiMsg: 600ms bounce [Y+15→0, S0.95→1] +200ms
typing: 1400ms ∞ [Y±8, α0.4→1] stagger+200ms
status: 300ms ease-out [α0.6→1, S1→1.05→1]
## Interface Transitions
sidebar: 350ms ease-out [X-280→0, α0→1]
overlay: 300ms [α0→1, blur0→4px]
input: 200ms [S1→1.01, shadow+ring] focus
input: 150ms [S1.01→1, shadow-ring] blur
## Button Interactions
sendBtn: 150ms [S1→0.95→1, R±2°] press
sendBtn: 200ms [S1→1.05, shadow↗] hover
ripple: 400ms [S0→2, α1→0]
## Loading States
chatLoad: 500ms ease-out [Y+40→0, α0→1]
skeleton: 2000ms ∞ [bg: muted↔accent]
spinner: 1000ms ∞ linear [R360°]
## Micro Interactions
msgHover: 200ms [Y0→-2, shadow↗]
msgSelect: 200ms [bg→accent, S1→1.02]
error: 400ms [X±5] shake
success: 600ms bounce [S0→1.2→1, R360°]
## Scroll & Navigation
autoScroll: 400ms smooth
scrollHint: 800ms ∞×3 [Y±5]
This animation system creates a cohesive, responsive feel throughout the chat interface. Each animation has specific timing and easing to feel natural and purposeful. Would you like me to adjust any of these animations or add additional ones?
</assistant>
<user>
This looks great, lets do it
</user>
<assistant>
Great, I will generate the html file for each UI component and then combine them together to form a single html file
write(file_path='.superdesign/design_iterations/chat_ui.css', content='...')
write(file_path='.superdesign/design_iterations/chat_ui.html', content='...')
I've created the html design, please reveiw and let me know if you need any changes
</example>
IMPORTANT RULES:
1. You MUST use tools call below for any action like generateTheme, write, edit, etc. You are NOT allowed to just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>; MUST USE TOOL CALL (This is very important!!)
2. You MUST confirm the layout, and then theme style, and then animation
3. You MUST use .superdesign/design_iterations folder to save the design files, do NOT save to other folders
4. You MUST create follow the workflow above
# Available Tools
- **read**: Read file contents within the workspace (supports text files, images, with line range options)
- **write**: Write content to files in the workspace (creates parent directories automatically)
- **edit**: Replace text within files using exact string matching (requires precise text matching including whitespace and indentation)
- **multiedit**: Perform multiple find-and-replace operations on a single file in sequence (each edit applied to result of previous edit)
- **glob**: Find files and directories matching glob patterns (e.g., "*.js", "src/**/*.ts") - efficient for locating files by name or path structure
- **grep**: Search for text patterns within file contents using regular expressions (can filter by file types and paths)
- **ls**: List directory contents with optional filtering, sorting, and detailed information (shows files and subdirectories)
- **bash**: Execute shell/bash commands within the workspace (secure execution with timeouts and output capture)
- **generateTheme**: Generate a theme for the design
When calling tools, you MUST use the actual tool call, do NOT just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>, this won't actually call the tool. (This is very important to my life, please follow)

411
AGENT.md
View File

@@ -1,411 +0,0 @@
# Aperonight - Technical Documentation for AI Agents
## 🤖 Agent Implementation Guide
This document provides technical details for AI agents working on the Aperonight ticket selling system.
## 🏗️ System Architecture
### Core Components
#### 1. User Management (`app/models/user.rb`)
- **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
-- Users table (managed by Devise)
CREATE TABLE users (
id bigint PRIMARY KEY,
email varchar(255) UNIQUE NOT NULL,
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.
);
-- Events table
CREATE TABLE events (
id bigint PRIMARY KEY,
user_id bigint REFERENCES users(id),
name varchar(100) NOT NULL,
slug varchar(100) NOT NULL,
description text(1000) NOT NULL,
venue_name varchar(100) NOT NULL,
venue_address varchar(200) NOT NULL,
latitude decimal(10,8) NOT NULL,
longitude decimal(11,8) NOT NULL,
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)
);
-- Ticket types define pricing and availability
CREATE TABLE ticket_types (
id bigint PRIMARY KEY,
event_id bigint REFERENCES events(id),
name varchar(255) NOT NULL,
description text,
price_cents integer NOT NULL,
quantity integer NOT NULL,
sale_start_at datetime,
sale_end_at datetime,
requires_id boolean DEFAULT false,
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,
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', -- 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
);
```
## 🎯 Key Implementation Details
### 1. Dashboard Metrics (`app/controllers/pages_controller.rb`)
```ruby
# User-specific metrics with optimized queries
@booked_events = current_user.tickets
.joins(:ticket_type, :event)
.where(events: { state: :published })
.count
# Event counts for different timeframes
@events_today = Event.published
.where("DATE(start_time) = ?", Date.current)
.count
# User's actual booked events (not just count)
@user_booked_events = Event.joins(ticket_types: :tickets)
.where(tickets: { user: current_user, status: 'active' })
.distinct
.limit(5)
```
### 2. Stripe Payment Flow
#### Checkout Initiation (`events#checkout`)
1. **Cart Validation**: Parse JSON cart data, validate ticket types and quantities
2. **Availability Check**: Ensure sufficient tickets available before payment
3. **Stripe Session**: Create checkout session with line items, success/cancel URLs
4. **Metadata Storage**: Store order details in Stripe session metadata for later retrieval
```ruby
# Key Stripe configuration
session = Stripe::Checkout::Session.create({
payment_method_types: ['card'],
line_items: line_items,
mode: 'payment',
success_url: payment_success_url(event_id: @event.id, session_id: '{CHECKOUT_SESSION_ID}'),
cancel_url: event_url(@event.slug, @event),
customer_email: current_user.email,
metadata: {
event_id: @event.id,
user_id: current_user.id,
order_items: order_items.to_json
}
})
```
#### Payment Confirmation (`events#payment_success`)
1. **Session Retrieval**: Get Stripe session with payment status
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`)
```ruby
class TicketPdfGenerator
def generate
Prawn::Document.new(page_size: [350, 600], margin: 20) do |pdf|
# Header with branding
pdf.fill_color "2D1B69"
pdf.font "Helvetica", style: :bold, size: 24
pdf.text "ApéroNight", align: :center
# Event details
pdf.text ticket.event.name, align: :center
# QR Code generation
qr_code_data = {
ticket_id: ticket.id,
qr_code: ticket.qr_code,
event_id: ticket.event.id,
user_id: ticket.user.id
}.to_json
qrcode = RQRCode::QRCode.new(qr_code_data)
pdf.print_qr_code(qrcode, extent: 120, align: :center)
end.render
end
end
```
### 4. Frontend Cart Management (`app/javascript/controllers/ticket_cart_controller.js`)
- **Stimulus Controller**: Manages cart state and interactions
- **Authentication Check**: Validates user login before checkout
- **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
```ruby
# Event validations
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
validates :latitude, numericality: {
greater_than_or_equal_to: -90,
less_than_or_equal_to: 90
}
# Ticket QR code generation
before_validation :generate_qr_code, on: :create
def generate_qr_code
loop do
self.qr_code = SecureRandom.uuid
break unless Ticket.exists?(qr_code: qr_code)
end
end
```
### Controller Patterns
```ruby
# Authentication for sensitive actions
before_action :authenticate_user!, only: [:checkout, :payment_success, :download_ticket]
# Strong parameters
private
def event_params
params.require(:event).permit(:name, :description, :venue_name, :venue_address,
:latitude, :longitude, :start_time, :image)
end
```
### View Helpers and Partials
- **Metric Cards**: Reusable component for dashboard statistics
- **Event Items**: Consistent event display across pages
- **Flash Messages**: Centralized notification system
## 🚀 Deployment Considerations
### Environment Variables
```bash
# Required for production
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
DATABASE_URL=mysql2://user:pass@host/db
RAILS_MASTER_KEY=...
```
### Database Indexes
```sql
-- Performance indexes for common queries
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
- **CSRF Protection**: Rails default protection enabled
- **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
### Key Test Cases
1. **User Authentication**: Registration, login, logout flows
2. **Event Creation**: Validation, state management, relationships
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
# Creates test users, events, and ticket types
users = User.create!([...])
events = Event.create!([...])
ticket_types = TicketType.create!([...])
```
## 🛠️ Available Development Tools
### AST-Grep for Mass Code Replacement
The system has `ast-grep` installed for structural code search and replacement. This tool is particularly useful for:
- **Mass refactoring**: Rename methods, classes, or variables across the codebase
- **Pattern-based replacements**: Update code patterns using AST matching
- **Language-aware transformations**: Safer than regex for code modifications
#### Usage Examples:
```bash
# Find all method calls to a specific method
ast-grep --pattern 'find_by_$FIELD($VALUE)' --lang ruby
# Replace method calls with new syntax
ast-grep --pattern 'find_by_$FIELD($VALUE)' --rewrite 'find_by($FIELD: $VALUE)' --lang ruby
# Search for specific Rails patterns
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
```
#### Best Practices:
- Always run with `--dry-run` first to preview changes
- Use `--lang ruby` for Ruby files to ensure proper AST parsing
- Test changes in a branch before applying to main codebase
- Particularly useful for Rails conventions and ActiveRecord pattern updates
## 📝 Code Style & Conventions
- **Ruby Style**: Follow Rails conventions and Rubocop rules
- **Database**: Use Rails migrations for all schema changes
- **JavaScript**: Stimulus controllers for interactive behavior
- **CSS**: Tailwind utility classes with custom components
- **Documentation**: Inline comments for complex business logic
- **Mass Changes**: Use `ast-grep` for structural code replacements instead of simple find/replace
This architecture provides a solid foundation for a scalable ticket selling platform with proper separation of concerns, security, and user experience.

566
AGENTS.md Executable file
View File

@@ -0,0 +1,566 @@
# Aperonight - Technical Documentation for AI Agents
## 🤖 Agent Implementation Guide
This document provides technical details for AI agents working on the Aperonight ticket selling system.
## 🏗️ System Architecture
### Core Components
#### 1. User Management (`app/models/user.rb`)
- **Devise Integration**: Complete authentication system with registration, login, password reset
- **Professional Users**: `is_professionnal` field for event promoters with enhanced permissions
- **Onboarding System**: Multi-step onboarding process with `onboarding_completed` tracking
- **Stripe Integration**: `stripe_customer_id` for accounting and invoice management
- **Relationships**: Users can create events, purchase tickets, and manage promotion codes
- **Validations**: Email format, password strength, optional name fields, company information
#### 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, tickets through ticket types, and orders
- **Scopes**: Featured events, published events, upcoming events with proper ordering
- **Duplication**: Event duplication functionality for similar events
#### 3. Order Management (`app/models/order.rb`)
- **Order States**: `draft`, `pending_payment`, `paid`, `completed`, `cancelled`, `expired`
- **Payment Processing**: Stripe integration with payment attempt tracking
- **Platform Fees**: €0.50 fixed + 1.5% per ticket automatic calculation
- **Expiration**: 15-minute draft order expiration with automatic cleanup
- **Promotion Integration**: Support for discount code application
- **Invoice Generation**: Automatic Stripe invoice creation for accounting
#### 4. Promotion Code System (`app/models/promotion_code.rb`)
- **Discount Management**: Fixed amount discounts (stored in cents, displayed in euros)
- **Usage Controls**: Per-event and per-user association with usage limits
- **Expiration**: Date-based expiration with active/inactive status management
- **Validation**: Real-time validation during checkout process
- **Tracking**: Complete usage tracking and analytics
#### 5. 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 Association**: Tickets now belong to orders for better transaction management
#### 6. Payment Processing (`app/controllers/orders_controller.rb`)
- **Order-Based Workflow**: Complete shift from direct ticket purchase to order-based system
- **Stripe Integration**: Complete checkout session creation and payment confirmation
- **Session Management**: Proper handling of payment success/failure with order and ticket generation
- **Security**: Authentication required, cart validation, availability checking
- **Invoice Service**: Post-payment invoice generation with StripeInvoiceService
### Database Schema Key Points
```sql
-- Users table (enhanced with professional features)
CREATE TABLE users (
id bigint PRIMARY KEY,
email varchar(255) UNIQUE NOT NULL,
encrypted_password varchar(255) NOT NULL,
first_name varchar(255),
last_name varchar(255),
is_professionnal boolean DEFAULT false,
onboarding_completed boolean DEFAULT false,
stripe_customer_id varchar(255),
company_name varchar(255),
-- Devise fields: confirmation, reset tokens, etc.
);
-- Events table (enhanced with order management)
CREATE TABLE events (
id bigint PRIMARY KEY,
user_id bigint REFERENCES users(id),
name varchar(100) NOT NULL,
slug varchar(100) NOT NULL,
description text(1000) NOT NULL,
venue_name varchar(100) NOT NULL,
venue_address varchar(200) NOT NULL,
latitude decimal(10,8) NOT NULL,
longitude decimal(11,8) NOT NULL,
start_time datetime NOT NULL,
end_time datetime,
state integer DEFAULT 0, -- enum: draft=0, published=1, canceled=2, sold_out=3
featured boolean DEFAULT false,
image varchar(500)
);
-- Order management system (new core table)
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint REFERENCES users(id),
event_id bigint REFERENCES events(id),
status varchar(255) DEFAULT 'draft',
total_amount_cents integer DEFAULT 0,
platform_fee_cents integer DEFAULT 0,
payment_attempts integer DEFAULT 0,
expires_at timestamp,
last_payment_attempt_at timestamp,
stripe_checkout_session_id varchar(255),
stripe_invoice_id varchar(255)
);
-- Promotion codes table (new discount system)
CREATE TABLE promotion_codes (
id bigint PRIMARY KEY,
code varchar(255) UNIQUE NOT NULL,
discount_amount_cents integer DEFAULT 0,
expires_at datetime,
active boolean DEFAULT true,
usage_limit integer,
uses_count integer DEFAULT 0,
user_id bigint REFERENCES users(id),
event_id bigint REFERENCES events(id)
);
-- Order-promotion code join table
CREATE TABLE order_promotion_codes (
order_id bigint REFERENCES orders(id),
promotion_code_id bigint REFERENCES promotion_codes(id)
);
-- Ticket types define pricing and availability
CREATE TABLE ticket_types (
id bigint PRIMARY KEY,
event_id bigint REFERENCES events(id),
name varchar(255) NOT NULL,
description text,
price_cents integer NOT NULL,
quantity integer NOT NULL,
sale_start_at datetime,
sale_end_at datetime,
requires_id boolean DEFAULT false,
minimum_age integer
);
-- Individual tickets with QR codes (enhanced with order association)
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
);
```
## 🎯 Key Implementation Details
### 1. Dashboard Metrics (`app/controllers/pages_controller.rb`)
```ruby
# User-specific metrics with optimized queries
@booked_events = current_user.tickets
.joins(:ticket_type, :event)
.where(events: { state: :published })
.count
# Event counts for different timeframes
@events_today = Event.published
.where("DATE(start_time) = ?", Date.current)
.count
# User's actual booked events (not just count)
@user_booked_events = Event.joins(ticket_types: :tickets)
.where(tickets: { user: current_user, status: 'active' })
.distinct
.limit(5)
```
### 2. Order Management Flow (`app/controllers/orders_controller.rb`)
#### Order Creation and Payment
1. **Cart-to-Order Conversion**: Convert shopping cart to draft order with 15-minute expiration
2. **Platform Fee Calculation**: Automatic calculation of €0.50 fixed + 1.5% per ticket
3. **Promotion Code Application**: Real-time discount validation and application
4. **Stripe Checkout Session**: Create payment session with order metadata
5. **Payment Retry**: Support for multiple payment attempts with proper tracking
```ruby
# Order creation with platform fees
def create
@order = Order.new(order_params)
@order.user = current_user
@order.calculate_platform_fee
@order.set_expiration
if @order.save
session = create_stripe_checkout_session(@order)
redirect_to session.url, allow_other_host: true
else
render :new, status: :unprocessable_entity
end
end
# Platform fee calculation
def calculate_platform_fee
ticket_count = order_items.sum(:quantity)
self.platform_fee_cents = 50 + (total_amount_cents * 0.015).to_i
end
```
#### Payment Confirmation and Invoice Generation
1. **Order Status Update**: Transition from pending_payment to paid
2. **Ticket Generation**: Create tickets associated with the order
3. **Stripe Invoice Creation**: Async invoice generation for accounting
4. **Promotion Code Usage**: Increment usage counters for applied codes
### 3. Enhanced Stripe Integration
#### StripeInvoiceService (`app/services/stripe_invoice_service.rb`)
- Post-payment invoice creation with customer management
- Line item processing with promotion discounts
- PDF invoice URL generation for download
- Accounting record synchronization
```ruby
class StripeInvoiceService
def initialize(order)
@order = order
end
def create_invoice
customer = find_or_create_stripe_customer
invoice_items = create_invoice_items(customer)
invoice = Stripe::Invoice.create({
customer: customer.id,
auto_advance: true,
collection_method: 'charge_automatically'
})
@order.update(stripe_invoice_id: invoice.id)
invoice.finalize_invoice
end
end
```
### 4. Promotion Code System (`app/models/promotion_code.rb`)
#### Code Validation and Application
- **Real-time Validation**: Check code validity, expiration, and usage limits
- **Discount Calculation**: Apply fixed amount discounts to order totals
- **Usage Tracking**: Increment usage counters and prevent overuse
- **Event-Specific Codes**: Support for both global and event-specific codes
```ruby
def valid_for_use?(user = nil, event = nil)
return false unless active?
return false if expired?
return false if usage_limit_reached?
return false if user.present? && !valid_for_user?(user)
return false if event.present? && !valid_for_event?(event)
true
end
def apply_discount(total_amount)
[total_amount - discount_amount_cents, 0].max
end
```
### 5. Background Job Architecture
#### StripeInvoiceGenerationJob
- Async invoice creation after successful payment
- Retry logic with exponential backoff
- Error handling and logging
#### ExpiredOrdersCleanupJob
- Automatic cleanup of expired draft orders
- Database maintenance and hygiene
#### EventReminderJob & EventReminderSchedulerJob
- Automated event reminder emails
- Scheduled notifications for upcoming events
### 6. PDF Ticket Generation (`app/services/ticket_pdf_generator.rb`)
```ruby
class TicketPdfGenerator
def generate
Prawn::Document.new(page_size: [350, 600], margin: 20) do |pdf|
# Header with branding
pdf.fill_color "2D1B69"
pdf.font "Helvetica", style: :bold, size: 24
pdf.text "ApéroNight", align: :center
# Event details
pdf.text ticket.event.name, align: :center
# QR Code generation
qr_code_data = {
ticket_id: ticket.id,
qr_code: ticket.qr_code,
event_id: ticket.event.id,
user_id: ticket.user.id
}.to_json
qrcode = RQRCode::QRCode.new(qr_code_data)
pdf.print_qr_code(qrcode, extent: 120, align: :center)
end.render
end
end
```
### 7. Frontend Architecture
#### Enhanced Stimulus Controllers
- **ticket_selection_controller.js**: Advanced cart management with real-time updates
- **event_form_controller.js**: Dynamic event creation with location services
- **countdown_controller.js**: Order expiration countdown timers
- **event_duplication_controller.js**: Event copying functionality
- **qr_code_controller.js**: QR code display and scanning
#### Order-Based Cart Management
- **Session Storage**: Preserves cart state during authentication flows
- **Real-time Updates**: Dynamic total calculation with promotion codes
- **Validation**: Client-side validation with server-side verification
- **Payment Flow**: Seamless integration with Stripe checkout
## 🔧 Development Patterns
### Model Validations
```ruby
# Event validations
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
validates :latitude, numericality: {
greater_than_or_equal_to: -90,
less_than_or_equal_to: 90
}
# Order validations with state management
validates :status, presence: true, inclusion: { in: %w[draft pending_payment paid completed cancelled expired] }
validate :order_not_expired, on: :create
before_validation :set_expiration, on: :create
# Promotion code validations
validates :code, presence: true, uniqueness: true
validates :discount_amount_cents, numericality: { greater_than_or_equal_to: 0 }
validate :expiration_date_cannot_be_in_the_past
# Ticket QR code generation
before_validation :generate_qr_code, on: :create
def generate_qr_code
loop do
self.qr_code = SecureRandom.uuid
break unless Ticket.exists?(qr_code: qr_code)
end
end
```
### Controller Patterns
```ruby
# Authentication for sensitive actions
before_action :authenticate_user!, only: [:checkout, :payment_success, :download_ticket]
# Professional user authorization
before_action :authenticate_professional!, only: [:create_promotion_code]
# Strong parameters with nested attributes
private
def order_params
params.require(:order).permit(:promotion_code, order_items_attributes: [:ticket_type_id, :quantity])
end
# Platform fee calculation
def calculate_platform_fee
ticket_count = order_items.sum(:quantity)
self.platform_fee_cents = 50 + (total_amount_cents * 0.015).to_i
end
```
### Service Layer Patterns
```ruby
# Service for complex business logic
class StripeInvoiceService
def initialize(order)
@order = order
end
def call
customer = find_or_create_stripe_customer
create_invoice_items(customer)
generate_invoice
end
private
def find_or_create_stripe_customer
if @order.user.stripe_customer_id.present?
Stripe::Customer.retrieve(@order.user.stripe_customer_id)
else
customer = Stripe::Customer.create(email: @order.user.email)
@order.user.update(stripe_customer_id: customer.id)
customer
end
end
end
```
### View Helpers and Partials
- **Metric Cards**: Reusable component for dashboard statistics
- **Event Items**: Consistent event display across pages
- **Flash Messages**: Centralized notification system
- **Order Components**: Reusable order display and management components
## 🚀 Deployment Considerations
### Environment Variables
```bash
# Required for production
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
DATABASE_URL=mysql2://user:pass@host/db
RAILS_MASTER_KEY=...
# Rails 8 Solid Stack
SOLID_QUEUE_IN_PUMA=true
SOLID_CACHE_URL=redis://localhost:6379/0
SOLID_CABLE_URL=redis://localhost:6379/1
# Application Configuration
PLATFORM_FEE_FIXED_CENTS=50
PLATFORM_FEE_PERCENTAGE=1.5
ORDER_EXPIRATION_MINUTES=15
```
### Database Indexes
```sql
-- Performance indexes for common queries
CREATE INDEX idx_events_published_start_time ON events (state, start_time);
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
CREATE INDEX idx_orders_expires_at ON orders (expires_at) WHERE status = 'draft';
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_promotion_codes_code ON promotion_codes (code);
CREATE INDEX idx_promotion_codes_active_expires ON promotion_codes (active, expires_at);
```
### Security Considerations
- **CSRF Protection**: Rails default protection enabled
- **Strong Parameters**: All user inputs filtered
- **Authentication**: Devise handles session security
- **Payment Security**: Stripe handles sensitive payment data
- **Professional User Authorization**: Role-based access control for event promoters
- **Order Expiration**: Automatic cleanup of abandoned orders
- **Promotion Code Validation**: Server-side validation with usage limits
### Background Jobs
```ruby
# Async invoice generation
StripeInvoiceGenerationJob.perform_later(order_id)
# Cleanup expired orders
ExpiredOrdersCleanupJob.perform_later
# Event reminders
EventReminderSchedulerJob.set(wait_until: event.start_time - 2.hours).perform_later(event_id)
```
## 🌐 API Layer
### RESTful Endpoints
```ruby
# API Namespacing for external integrations
namespace :api do
namespace :v1 do
resources :events, only: [:index, :show] do
resources :ticket_types, only: [:index]
end
resources :carts, only: [:create, :show, :update]
resources :orders, only: [:create, :show, :update]
post '/promotion_codes/validate', to: 'promotion_codes#validate'
end
end
```
### API Authentication
- **Token-based authentication**: API tokens for external integrations
- **Rate limiting**: Request throttling for API endpoints
- **Versioning**: Versioned API namespace for backward compatibility
## 🧪 Testing Strategy
### Key Test Cases
1. **User Authentication**: Registration, login, logout flows
2. **Professional User Onboarding**: Multi-step onboarding process
3. **Event Creation**: Validation, state management, relationships
4. **Order Management**: Cart-to-order conversion, payment processing, expiration
5. **Promotion Code System**: Code validation, discount application, usage tracking
6. **PDF Generation**: QR code uniqueness, ticket format
7. **Stripe Integration**: Payment processing, invoice generation
8. **Background Jobs**: Async processing, error handling, retry logic
9. **API Endpoints**: RESTful API functionality and authentication
10. **Dashboard Metrics**: Query accuracy, performance
### Seed Data Structure
```ruby
# Creates comprehensive test data
users = User.create!([...])
events = Event.create!([...])
ticket_types = TicketType.create!([...])
promotion_codes = PromotionCode.create!([...])
orders = Order.create!([...])
```
## 🛠️ Available Development Tools
### AST-Grep for Mass Code Replacement
The system has `ast-grep` installed for structural code search and replacement. This tool is particularly useful for:
- **Mass refactoring**: Rename methods, classes, or variables across the codebase
- **Pattern-based replacements**: Update code patterns using AST matching
- **Language-aware transformations**: Safer than regex for code modifications
#### Usage Examples:
```bash
# Find all method calls to a specific method
ast-grep --pattern 'find_by_$FIELD($VALUE)' --lang ruby
# Replace method calls with new syntax
ast-grep --pattern 'find_by_$FIELD($VALUE)' --rewrite 'find_by($FIELD: $VALUE)' --lang ruby
# Search for specific Rails patterns
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
# Find all order-related validations
ast-grep --pattern 'validates :status, inclusion: { in: \%w[...] }' --lang ruby
```
#### Best Practices:
- Always run with `--dry-run` first to preview changes
- Use `--lang ruby` for Ruby files to ensure proper AST parsing
- Test changes in a branch before applying to main codebase
- Particularly useful for Rails conventions and ActiveRecord pattern updates
### Modern Rails 8 Stack
- **Solid Queue**: Background job processing
- **Solid Cache**: Fast caching layer
- **Solid Cable**: Action Cable over Redis
- **Propshaft**: Asset pipeline
- **Kamal**: Deployment tooling
- **Thruster**: Performance optimization
## 📝 Code Style & Conventions
- **Ruby Style**: Follow Rails conventions and Rubocop rules
- **Database**: Use Rails migrations for all schema changes
- **JavaScript**: Stimulus controllers for interactive behavior
- **CSS**: Tailwind utility classes with custom components
- **Service Layer**: Complex business logic in service objects
- **Background Jobs**: Async processing for long-running tasks
- **API Design**: RESTful principles with versioning
- **Documentation**: Inline comments for complex business logic
- **Mass Changes**: Use `ast-grep` for structural code replacements instead of simple find/replace
- **Testing**: Comprehensive test coverage for all business logic
This architecture provides a solid foundation for a scalable ticket selling platform with proper separation of concerns, security, and user experience, featuring modern Rails 8 capabilities and a comprehensive order management system.

View File

@@ -9,14 +9,12 @@
### Medium Priority ### Medium Priority
- [ ] feat: Promoter system with event creation, ticket types creation and metrics display - [ ] feat: Promoter system with event creation, ticket types creation and metrics display
- [ ] feat: Multiple ticket types (early bird, VIP, general admission)
- [ ] feat: Refund management system - [ ] feat: Refund management system
- [ ] feat: Real-time sales analytics dashboard - [ ] feat: Real-time sales analytics dashboard
- [ ] feat: Guest checkout without account creation - [ ] feat: Guest checkout without account creation
- [ ] feat: Seat selection with interactive venue maps - [ ] feat: Seat selection with interactive venue maps
- [ ] feat: Dynamic pricing based on demand - [ ] 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: Payout system for promoters (automated/manual payment processing)
- [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
- [ ] feat: Platform commission tracking and fee structure display - [ ] feat: Platform commission tracking and fee structure display
- [ ] feat: Tax reporting and revenue export for promoters - [ ] feat: Tax reporting and revenue export for promoters
- [ ] feat: Event update notifications to ticket holders - [ ] feat: Event update notifications to ticket holders
@@ -44,10 +42,14 @@
- [ ] feat: Event recommendations system - [ ] feat: Event recommendations system
- [ ] feat: Invitation link. As organizer or promoter, you can invite people - [ ] feat: Invitation link. As organizer or promoter, you can invite people
### Design & Infrastructure
- [ ] style: Rewrite design system
- [ ] refactor: Rewrite design mockup
## 🚧 Doing ## 🚧 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: Page to display all tickets for an event
- [ ] feat: Add a link into notification email to order page that display all tickets - [ ] feat: Add a link into notification email to order page that display all tickets
@@ -59,9 +61,11 @@
- [x] Add login functionality - [x] Add login functionality
- [x] refactor: Moving checkout to OrdersController - [x] refactor: Moving checkout to OrdersController
- [x] feat: Payment gateway integration (Stripe) - PayPal not implemented - [x] feat: Payment gateway integration (Stripe) - PayPal not implemented
- [x] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
- [x] feat: Digital tickets with QR codes - [x] feat: Digital tickets with QR codes
- [x] feat: Ticket inventory management and capacity limits - [x] feat: Ticket inventory management and capacity limits
- [x] feat: Event discovery with search and filtering - [x] feat: Event discovery with search and filtering
- [x] feat: Multiple ticket types (early bird, VIP, general admission)
- [x] feat: Email notifications (purchase confirmations, event reminders) - [x] feat: Email notifications (purchase confirmations, event reminders)
- [x] style: Rewrite design system - [x] feat: Promotion code on ticket
- [x] refactor: Rewrite design mockup - [x] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events

View File

@@ -8,7 +8,7 @@
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version # Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.1 ARG RUBY_VERSION=3.4.4
FROM docker.io/library/ruby:$RUBY_VERSION AS base FROM docker.io/library/ruby:$RUBY_VERSION AS base
# Rails app lives here # Rails app lives here
@@ -20,10 +20,10 @@ RUN apt-get update -qq && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment # Set production environment
ENV RAILS_ENV="production" \ ENV RAILS_ENV="development" \
BUNDLE_DEPLOYMENT="1" \ BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \ BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" BUNDLE_WITHOUT=""
# Throw-away build stage to reduce size of final image # Throw-away build stage to reduce size of final image
FROM base AS build FROM base AS build

99
Dockerfile.production Normal file
View File

@@ -0,0 +1,99 @@
# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t myapp .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name myapp myapp
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.4
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Set production environment
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development"
# Create non-root user early for security and to allow correct permissions in build stage
RUN groupadd --system --gid 1000 rails && \
useradd --system --uid 1000 --gid 1000 --create-home --shell /bin/bash rails
# Install base packages (runtime only in base image)
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips42 mariadb-client && \
rm -rf /var/lib/apt/lists/*
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems and node modules
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev node-gyp pkg-config python-is-python3 libmariadb-dev && \
rm -rf /var/lib/apt/lists/*
# Install rails gem in the build stage where build tools are available
RUN gem install rails
# Install JavaScript dependencies
ARG NODE_VERSION=18.19.0
ARG YARN_VERSION=latest
ENV NODE_PATH=/usr/local/node
ENV PATH=/usr/local/node/bin:$PATH
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
/tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
rm -rf /tmp/node-build-master
RUN corepack enable && yarn set version $YARN_VERSION
# Copy dependency files first (better caching)
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local frozen 'true' && \
bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
bundle exec bootsnap precompile --gemfile
# Install node modules
COPY package.json yarn.lock ./
RUN yarn install --immutable && \
yarn cache clean --all
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Clean up build-time dependencies and files
RUN rm -rf node_modules tmp/cache
# Final stage for app image
FROM base
# Copy built artifacts: gems and application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
COPY --from=build /usr/local/node /usr/local/node
# Ensure proper permissions for runtime directories
RUN mkdir -p /rails/db /rails/log /rails/storage /rails/tmp && \
chown -R rails:rails /rails/db /rails/log /rails/storage /rails/tmp
USER rails
# Configure jemalloc for better memory management
ENV LD_PRELOAD=libjemalloc.so.2
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server", "-b", "0.0.0.0"]

View File

@@ -40,7 +40,7 @@ gem "kamal", require: false
gem "thruster", require: false gem "thruster", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
# gem "image_processing", "~> 1.2" gem "image_processing", "~> 1.2"
group :development, :test do group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem

View File

@@ -122,6 +122,13 @@ GEM
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.3.0) et-orbi (1.3.0)
tzinfo tzinfo
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
fugit (1.11.2) fugit (1.11.2)
et-orbi (~> 1, >= 1.2.11) et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4) raabro (~> 1.4)
@@ -129,6 +136,9 @@ GEM
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.14.7) i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.1) io-console (0.8.1)
irb (1.15.2) irb (1.15.2)
pp (>= 0.6.0) pp (>= 0.6.0)
@@ -177,6 +187,8 @@ GEM
net-smtp net-smtp
marcel (1.0.4) marcel (1.0.4)
matrix (0.4.3) matrix (0.4.3)
mini_magick (5.3.1)
logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.5) minitest (5.25.5)
minitest-reporters (1.7.1) minitest-reporters (1.7.1)
@@ -333,6 +345,9 @@ GEM
rubocop-performance (>= 1.24) rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30) rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-vips (2.2.5)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (3.0.2) rubyzip (3.0.2)
securerandom (0.4.1) securerandom (0.4.1)
@@ -429,6 +444,7 @@ DEPENDENCIES
debug debug
devise (~> 4.9) devise (~> 4.9)
dotenv-rails dotenv-rails
image_processing (~> 1.2)
jbuilder jbuilder
jsbundling-rails jsbundling-rails
kamal kamal

View File

@@ -13,4 +13,3 @@
/* Import pages */ /* Import pages */
@import "pages/home"; @import "pages/home";
@import "pages/payouts";

View File

@@ -1,304 +0,0 @@
/* 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;
}

View File

@@ -1,76 +0,0 @@
class Admin::PayoutsController < ApplicationController
before_action :authenticate_user!
before_action :ensure_admin!
before_action :set_payout, only: [ :show, :approve, :reject, :mark_processing, :mark_completed, :mark_failed ]
def index
@pending_payouts = Payout.pending.includes(:user, :event).order(created_at: :asc)
@approved_payouts = Payout.approved.includes(:user, :event).order(created_at: :asc)
@processing_payouts = Payout.processing.includes(:user, :event).order(created_at: :asc)
@completed_payouts = Payout.completed.includes(:user, :event).order(created_at: :desc).limit(10)
end
def show
@service = PayoutService.new(@payout)
@transfer_summary = @service.generate_transfer_summary
@banking_errors = @service.validate_banking_info
end
def approve
if @payout.approve!(current_user)
redirect_to admin_payout_path(@payout), notice: "Payout approved successfully."
else
redirect_to admin_payout_path(@payout), alert: "Cannot approve this payout."
end
end
def reject
reason = params[:rejection_reason].presence || "No reason provided"
if @payout.reject!(current_user, reason)
redirect_to admin_payouts_path, notice: "Payout rejected."
else
redirect_to admin_payout_path(@payout), alert: "Cannot reject this payout."
end
end
def mark_processing
transfer_reference = params[:bank_transfer_reference]
if @payout.mark_processing!(current_user, transfer_reference)
redirect_to admin_payout_path(@payout), notice: "Payout marked as processing."
else
redirect_to admin_payout_path(@payout), alert: "Cannot mark payout as processing."
end
end
def mark_completed
transfer_reference = params[:bank_transfer_reference]
if @payout.mark_completed!(current_user, transfer_reference)
redirect_to admin_payouts_path, notice: "Payout completed successfully."
else
redirect_to admin_payout_path(@payout), alert: "Cannot mark payout as completed."
end
end
def mark_failed
reason = params[:failure_reason].presence || "Transfer failed"
if @payout.mark_failed!(current_user, reason)
redirect_to admin_payouts_path, notice: "Payout marked as failed."
else
redirect_to admin_payout_path(@payout), alert: "Cannot mark payout as failed."
end
end
private
def set_payout
@payout = Payout.find(params[:id])
end
def ensure_admin!
# For now, we'll just check if the user is a professional user
# In a real app, you'd have an admin role check
unless current_user.promoter?
redirect_to dashboard_path, alert: "Access denied."
end
end
end

View File

@@ -14,14 +14,14 @@ module Api
# Retrieves all events sorted by creation date (most recent first) # Retrieves all events sorted by creation date (most recent first)
def index def index
@events = Event.all.order(created_at: :desc) @events = Event.all.order(created_at: :desc)
render json: @events, status: :ok render json: @events.map { |e| event_json(e) }, status: :ok
end end
# GET /api/v1/events/:id # GET /api/v1/events/:id
# Retrieves a single event by its ID # Retrieves a single event by its ID
# Returns 404 if the event is not found # Returns 404 if the event is not found
def show def show
render json: @event, status: :ok render json: event_json(@event), status: :ok
end end
# POST /api/v1/events # POST /api/v1/events
@@ -31,7 +31,7 @@ module Api
def create def create
@event = Event.new(event_params) @event = Event.new(event_params)
if @event.save if @event.save
render json: @event, status: :created render json: event_json(@event), status: :created
else else
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
end end
@@ -43,7 +43,7 @@ module Api
# Returns 422 Unprocessable Entity with error messages on failure # Returns 422 Unprocessable Entity with error messages on failure
def update def update
if @event.update(event_params) if @event.update(event_params)
render json: @event, status: :ok render json: event_json(@event), status: :ok
else else
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
end end
@@ -73,6 +73,33 @@ module Api
private private
# Helper method to serialize event data safely
def event_json(event)
{
id: event.id,
name: event.name,
slug: event.slug,
description: event.description,
state: event.state,
venue_name: event.venue_name,
venue_address: event.venue_address,
start_time: event.start_time,
end_time: event.end_time,
latitude: event.latitude,
longitude: event.longitude,
featured: event.featured,
image_url: event.display_image_url,
created_at: event.created_at,
updated_at: event.updated_at,
user: {
id: event.user.id,
email: event.user.email,
first_name: event.user.first_name,
last_name: event.user.last_name
}
}
end
# Finds an event by its ID or returns 404 Not Found # Finds an event by its ID or returns 404 Not Found
# Used as before_action for the show, update, and destroy actions # Used as before_action for the show, update, and destroy actions
def set_event def set_event
@@ -99,6 +126,32 @@ module Api
:user_id :user_id
) )
end end
# Helper method to serialize event data safely
def event_json(event)
{
id: event.id,
name: event.name,
slug: event.slug,
description: event.description,
state: event.state,
venue_name: event.venue_name,
venue_address: event.venue_address,
start_time: event.start_time,
end_time: event.end_time,
latitude: event.latitude,
longitude: event.longitude,
featured: event.featured,
created_at: event.created_at,
updated_at: event.updated_at,
user: {
id: event.user.id,
email: event.user.email, # May be remove public email ?
first_name: event.user.first_name, # May be remove public name ?
last_name: event.user.last_name # May be remove public name ?
}
}
end
end end
end end
end end

View File

@@ -69,8 +69,6 @@ module Api
) )
unless ticket.save unless ticket.save
Rails.logger.error "API Ticket validation failed: #{ticket.errors.full_messages.join(', ')}"
Rails.logger.error "API Ticket attributes: #{ticket.attributes.inspect}"
render json: { error: "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}" }, status: :unprocessable_entity render json: { error: "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}" }, status: :unprocessable_entity
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end end

View File

@@ -69,8 +69,6 @@ class OrdersController < ApplicationController
) )
unless ticket.save unless ticket.save
Rails.logger.error "Ticket validation failed: #{ticket.errors.full_messages.join(', ')}"
Rails.logger.error "Ticket attributes: #{ticket.attributes.inspect}"
flash[:alert] = "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}" flash[:alert] = "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}"
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end end
@@ -128,6 +126,25 @@ class OrdersController < ApplicationController
@total_amount = @order.total_amount_cents @total_amount = @order.total_amount_cents
@expiring_soon = @order.expiring_soon? @expiring_soon = @order.expiring_soon?
# Handle promotion code application
if params[:promotion_code].present?
promotion_code = PromotionCode.valid.find_by(code: params[:promotion_code].upcase)
if promotion_code
# Check if promotion code is already applied to this order
if @order.promotion_codes.include?(promotion_code)
flash.now[:alert] = "Ce code promotionnel est déjà appliqué à cette commande"
else
# Apply the promotion code to the order
@order.promotion_codes << promotion_code
@order.calculate_total!
@total_amount = @order.total_amount_cents
flash.now[:notice] = "Code promotionnel appliqué: #{promotion_code.code}"
end
else
flash.now[:alert] = "Code promotionnel invalide"
end
end
# For free orders, automatically mark as paid and redirect to success # For free orders, automatically mark as paid and redirect to success
if @order.free? if @order.free?
@order.mark_as_paid! @order.mark_as_paid!
@@ -147,6 +164,8 @@ class OrdersController < ApplicationController
flash[:alert] = "Erreur lors de la création de la session de paiement" flash[:alert] = "Erreur lors de la création de la session de paiement"
end end
end end
render :checkout
end end
# Increment payment attempt - called via AJAX when user clicks pay button # Increment payment attempt - called via AJAX when user clicks pay button
@@ -290,7 +309,14 @@ class OrdersController < ApplicationController
end end
def create_stripe_session def create_stripe_session
# Calculate the discount amount per ticket to distribute the promotion evenly
total_tickets = @order.tickets.count
discount_per_ticket = @order.discount_amount_cents / total_tickets if total_tickets > 0
line_items = @order.tickets.map do |ticket| line_items = @order.tickets.map do |ticket|
# Apply discount proportionally to each ticket
discounted_price = [ticket.price_cents - discount_per_ticket.to_i, 0].max
{ {
price_data: { price_data: {
currency: "eur", currency: "eur",
@@ -298,7 +324,7 @@ class OrdersController < ApplicationController
name: "#{@order.event.name} - #{ticket.ticket_type.name}", name: "#{@order.event.name} - #{ticket.ticket_type.name}",
description: ticket.ticket_type.description description: ticket.ticket_type.description
}, },
unit_amount: ticket.price_cents unit_amount: discounted_price
}, },
quantity: 1 quantity: 1
} }

View File

@@ -29,6 +29,8 @@ class Promoter::EventsController < ApplicationController
if @event.save if @event.save
redirect_to promoter_event_path(@event), notice: "Event créé avec succès!" redirect_to promoter_event_path(@event), notice: "Event créé avec succès!"
else else
# If validation fails and an image was attached, purge it
@event.image.purge if @event.image.attached?
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
@@ -43,6 +45,8 @@ class Promoter::EventsController < ApplicationController
if @event.update(event_params) if @event.update(event_params)
redirect_to promoter_event_path(@event), notice: "Event mis à jour avec succès!" redirect_to promoter_event_path(@event), notice: "Event mis à jour avec succès!"
else else
# If validation fails and a new image was attached, purge it
@event.image.purge if @event.image.attached? && @event.changed.include?('image')
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@@ -130,10 +134,18 @@ class Promoter::EventsController < ApplicationController
end end
def event_params def event_params
if action_name == 'create'
params.require(:event).permit( params.require(:event).permit(
:name, :slug, :description, :image, :name, :slug, :description, :image,
:venue_name, :venue_address, :latitude, :longitude, :venue_name, :venue_address, :latitude, :longitude,
:start_time, :end_time, :featured, :allow_booking_during_event :start_time, :end_time, :featured, :allow_booking_during_event
) )
else
params.require(:event).permit(
:name, :description, :image,
:venue_name, :venue_address, :latitude, :longitude,
:start_time, :end_time, :featured, :allow_booking_during_event
)
end
end end
end end

View File

@@ -1,73 +0,0 @@
class Promoter::PayoutsController < ApplicationController
before_action :authenticate_user!
before_action :ensure_promoter!
before_action :set_event, only: [ :create ]
# List all payouts for the current promoter
def index
@payouts = current_user.payouts.completed.order(created_at: :desc).page(params[:page])
@eligible_events = current_user.events.eligible_for_payout.includes(:earnings).limit(5)
@total_pending_net = @eligible_events.sum(&:net_earnings_cents)
@total_paid_out = current_user.payouts.completed.sum(&:net_amount_cents)
@total_pending = @total_pending_net
@total_payouts_count = current_user.payouts.count
end
# Show payout details
def show
@payout = current_user.payouts.find(params[:id])
@event = @payout.event
end
# Create a new payout request
def create
# Check if event can request payout
unless @event.can_request_payout?(current_user)
redirect_to event_path(@event.slug, @event), alert: "Payout cannot be requested for this event."
return
end
# Calculate payout amount using model methods
gross = @event.total_gross_cents
fees = @event.total_fees_cents
# Count orders using model scope
total_orders_count = @event.orders.paid.count
# Create payout record
@payout = @event.payouts.build(
user: current_user,
amount_cents: gross,
fee_cents: fees,
total_orders_count: total_orders_count
)
# refunded_orders_count will be set by model callback
if @payout.save
# Update event payout status
@event.update!(payout_status: :requested, payout_requested_at: Time.current)
# Log notification (mailer can be added later if needed)
Rails.logger.info "Payout request submitted: #{@payout.id} for event #{@event.id}"
redirect_to promoter_payout_path(@payout), notice: "Payout request submitted successfully."
else
flash.now[:alert] = "Failed to submit payout request: #{@payout.errors.full_messages.join(', ')}"
render "new"
end
end
private
def ensure_promoter!
unless current_user.promoter?
redirect_to dashboard_path, alert: "Access denied."
end
end
def set_event
@event = current_user.events.find(params[:event_id])
end
end

View File

@@ -0,0 +1,82 @@
class Promoter::PromotionCodesController < ApplicationController
before_action :authenticate_user!
before_action :set_event
before_action :set_promotion_code, only: [ :edit, :update, :destroy ]
# GET /promoter/events/:event_id/promotion_codes
# Display all promotion codes for a specific event
def index
@promotion_codes = @event.promotion_codes.includes(:user)
end
# GET /promoter/events/:event_id/promotion_codes/new
# Show form to create a new promotion code
def new
@promotion_code = @event.promotion_codes.new
end
# GET /promoter/events/:event_id/promotion_codes/:id/edit
# Show form to edit an existing promotion code
def edit
end
# POST /promoter/events/:event_id/promotion_codes
# Create a new promotion code for the event
def create
@promotion_code = @event.promotion_codes.new(promotion_code_params_with_conversion)
@promotion_code.user = current_user
if @promotion_code.save
redirect_to promoter_event_promotion_codes_path(@event), notice: "Promotion code was successfully created."
else
render :new, status: :unprocessable_entity
end
end
# PATCH/PUT /promoter/events/:event_id/promotion_codes/:id
# Update an existing promotion code
def update
if @promotion_code.update(promotion_code_params_with_conversion)
redirect_to promoter_event_promotion_codes_path(@event), notice: "Promotion code was successfully updated."
else
render :edit, status: :unprocessable_entity
end
end
# DELETE /promoter/events/:event_id/promotion_codes/:id
# Delete a promotion code
def destroy
@promotion_code.destroy
redirect_to promoter_event_promotion_codes_path(@event), notice: "Promotion code was successfully destroyed."
end
private
# Find the event based on the URL parameter
def set_event
@event = Event.find(params[:event_id])
end
# Find the promotion code based on the URL parameter
def set_promotion_code
@promotion_code = @event.promotion_codes.find(params[:id])
end
# Strong parameters for promotion code form (accepts euros for display)
def promotion_code_params
params.require(:promotion_code).permit(:code, :discount_amount_euros, :expires_at, :active, :usage_limit)
end
# Convert euros to cents for database storage
# The form displays euros for user convenience, but the database stores cents
def promotion_code_params_with_conversion
params = promotion_code_params
if params[:discount_amount_euros].present?
# Convert euros to cents (e.g., 20.50 -> 2050)
params[:discount_amount_cents] = (params[:discount_amount_euros].to_f * 100).to_i
params.delete(:discount_amount_euros) # Remove the temporary euro parameter
end
params
end
end

View File

@@ -1,34 +0,0 @@
class Webhooks::StripeController < ApplicationController
skip_before_action :verify_authenticity_token
def create
payload = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
begin
event = Stripe::Webhook.construct_event(
payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"]
)
rescue Stripe::SignatureVerificationError => e
# Invalid signature
return head 400
end
case event["type"]
when "transfer.payout.succeeded"
payout_id = event.data.object.metadata["payout_id"]
payout = Payout.find(payout_id)
if payout && payout.processing?
payout.update!(status: :completed, stripe_payout_id: event.data.object.id)
end
when "transfer.payout.failed", "transfer.canceled"
payout_id = event.data.object.metadata["payout_id"]
payout = Payout.find(payout_id)
if payout
payout.update!(status: :failed)
end
end
head 200
end
end

View File

@@ -1,2 +0,0 @@
module Admin::PayoutsHelper
end

View File

@@ -1,2 +0,0 @@
module Promoter::PayoutsHelper
end

View File

@@ -1,8 +1,11 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
import slug from 'slug' import slug from 'slug'
// Configure slug to match Rails parameterize behavior
slug.defaults.mode = 'rfc3986'
export default class extends Controller { export default class extends Controller {
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer"] static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer", "venueName"]
static values = { static values = {
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
} }
@@ -27,15 +30,65 @@ export default class extends Controller {
} }
} }
// Generate slug from name // Generate slug from name, venue name, and city for better SEO
generateSlug() { generateSlug() {
const name = this.nameTarget.value const name = this.nameTarget.value
const venueName = this.hasVenueNameTarget ? this.venueNameTarget.value : ""
const address = this.hasAddressTarget ? this.addressTarget.value : ""
this.slugTarget.value = slug(name) // Extract city from address
const city = this.extractCity(address)
// Build SEO-friendly slug: name-venue-city
let slugParts = []
if (name) slugParts.push(name)
if (venueName) slugParts.push(venueName)
if (city) slugParts.push(city)
let slugValue = slugParts.join('-')
// If no slug parts, generate a fallback slug
if (!slugValue) {
slugValue = `event-${Date.now()}`
}
// Generate slug with proper character handling (matches Rails parameterize)
this.slugTarget.value = slug(slugValue, { lower: true })
}
// Extract city from address
extractCity(address) {
if (!address) return ""
// Look for French postal code pattern (5 digits) + city
const match = address.match(/(\d{5})\s+([^,]+)/)
if (match) {
return match[2].trim()
}
// Fallback: extract last part after comma (assume it's city)
const parts = address.split(',')
if (parts.length > 1) {
return parts[parts.length - 1].trim()
}
// Another fallback: look for common French city indicators
const cityIndicators = ["Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Strasbourg", "Montpellier", "Bordeaux", "Lille"]
for (const city of cityIndicators) {
if (address.toLowerCase().includes(city.toLowerCase())) {
return city
}
}
return ""
} }
// Handle address changes with debounced geocoding // Handle address changes with debounced geocoding
addressChanged() { addressChanged() {
// Regenerate slug when address changes
this.generateSlug()
// Clear any existing timeout // Clear any existing timeout
if (this.geocodeTimeout) { if (this.geocodeTimeout) {
clearTimeout(this.geocodeTimeout) clearTimeout(this.geocodeTimeout)
@@ -68,6 +121,11 @@ export default class extends Controller {
}, this.geocodeDelayValue) }, this.geocodeDelayValue)
} }
// Handle venue name changes to regenerate slug
venueNameChanged() {
this.generateSlug()
}
// Get user's current location and reverse geocode to address // Get user's current location and reverse geocode to address
async getCurrentLocation() { async getCurrentLocation() {
if (!navigator.geolocation) { if (!navigator.geolocation) {
@@ -516,14 +574,16 @@ export default class extends Controller {
showLocationSuccess(message) { showLocationSuccess(message) {
this.hideAllLocationMessages() this.hideAllLocationMessages()
this.showMessage("location-success", message, "success") this.showMessage("location-success", message, "success")
setTimeout(() => this.hideMessage("location-success"), 4000) // Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("location-success"), 4000)
} }
// Show error message // Show error message
showLocationError(message) { showLocationError(message) {
this.hideAllLocationMessages() this.hideAllLocationMessages()
this.showMessage("location-error", message, "error") this.showMessage("location-error", message, "error")
setTimeout(() => this.hideMessage("location-error"), 6000) // Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("location-error"), 6000)
} }
// Show geocoding warning (less intrusive than error) // Show geocoding warning (less intrusive than error)
@@ -531,7 +591,8 @@ export default class extends Controller {
this.hideMessage("geocoding-warning") this.hideMessage("geocoding-warning")
const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative." const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative."
this.showMessage("geocoding-warning", message, "warning") this.showMessage("geocoding-warning", message, "warning")
setTimeout(() => this.hideMessage("geocoding-warning"), 8000) // Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
} }
// Show info about approximate location // Show info about approximate location
@@ -539,7 +600,8 @@ export default class extends Controller {
this.hideMessage("approximate-location-info") this.hideMessage("approximate-location-info")
const message = `Localisation approximative trouvée: ${foundLocation}` const message = `Localisation approximative trouvée: ${foundLocation}`
this.showMessage("approximate-location-info", message, "info") this.showMessage("approximate-location-info", message, "info")
setTimeout(() => this.hideMessage("approximate-location-info"), 6000) // Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
} }
// Show geocoding success with location details // Show geocoding success with location details
@@ -547,7 +609,8 @@ export default class extends Controller {
this.hideMessage("geocoding-success") this.hideMessage("geocoding-success")
const message = `${title}<br><small class="opacity-75">${location}</small>` const message = `${title}<br><small class="opacity-75">${location}</small>`
this.showMessage("geocoding-success", message, "success") this.showMessage("geocoding-success", message, "success")
setTimeout(() => this.hideMessage("geocoding-success"), 5000) // Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("geocoding-success"), 5000)
} }
// Show geocoding progress with strategy info // Show geocoding progress with strategy info
@@ -664,4 +727,87 @@ export default class extends Controller {
this.hideMessage("geocoding-success") this.hideMessage("geocoding-success")
this.hideMessage("geocoding-progress") this.hideMessage("geocoding-progress")
} }
// Preview selected image
previewImage(event) {
const file = event.target.files[0]
if (!file) return
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Veuillez sélectionner une image valide.')
event.target.value = ''
return
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
alert('L\'image ne doit pas dépasser 5MB.')
event.target.value = ''
return
}
// Show preview
const reader = new FileReader()
reader.onload = (e) => {
const previewContainer = document.getElementById('upload-preview')
const previewImg = document.getElementById('upload-preview-img')
if (previewContainer && previewImg) {
previewImg.src = e.target.result
previewContainer.classList.remove('hidden')
}
}
reader.readAsDataURL(file)
}
// Preview image from URL
previewImageUrl(event) {
const url = event.target.value.trim()
const previewContainer = document.getElementById('url-preview')
const previewImg = document.getElementById('url-preview-img')
if (!url) {
if (previewContainer) {
previewContainer.classList.add('hidden')
}
return
}
// Basic URL validation
if (!this.isValidImageUrl(url)) {
if (previewContainer) {
previewContainer.classList.add('hidden')
}
return
}
// Show preview with error handling
if (previewImg) {
previewImg.onload = () => {
if (previewContainer) {
previewContainer.classList.remove('hidden')
}
}
previewImg.onerror = () => {
if (previewContainer) {
previewContainer.classList.add('hidden')
}
}
previewImg.src = url
}
}
// Validate image URL format
isValidImageUrl(url) {
try {
new URL(url)
// Check if it looks like an image URL
return /\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(url)
} catch {
return false
}
}
} }

View File

@@ -1,54 +0,0 @@
class Earning < ApplicationRecord
def self.create_from_order(order)
return unless order.paid? || order.completed?
gross_cents = order.tickets.active.sum(:price_cents)
fee_cents = order.tickets.active.sum do |ticket|
50 + (ticket.price_cents * 0.015).to_i
end
amount_cents = gross_cents - fee_cents
create!(
event: order.event,
user: order.event.user,
order: order,
amount_cents: amount_cents,
fee_cents: fee_cents,
status: :pending
)
end
# === Relations ===
belongs_to :event
belongs_to :user
belongs_to :order
# === Enums ===
enum :status, { pending: 0, paid: 1 }
# === Validations ===
validates :amount_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :net_amount_cents, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
validates :status, presence: true
validates :stripe_payout_id, allow_blank: true, uniqueness: true
# Recalculate earning based on active tickets in the order
def recalculate!
return unless order.present?
active_tickets = order.tickets.active
if active_tickets.empty?
update!(amount_cents: 0, fee_cents: 0)
else
gross_cents = active_tickets.sum(:price_cents)
fee_cents = active_tickets.sum do |ticket|
50 + (ticket.price_cents * 0.015).to_i
end
update!(amount_cents: gross_cents - fee_cents, fee_cents: fee_cents)
end
end
def recalculate_on_refund(order)
recalculate!
end
end

View File

@@ -16,33 +16,35 @@ class Event < ApplicationRecord
sold_out: 3 sold_out: 3
}, default: :draft }, default: :draft
enum :payout_status, {
pending_request: 0,
requested: 1,
processing: 2,
completed: 3,
failed: 4
}, default: :pending_request
# === Relations === # === Relations ===
belongs_to :user belongs_to :user
has_many :ticket_types has_many :ticket_types
has_many :tickets, through: :ticket_types has_many :tickets, through: :ticket_types
has_many :orders has_many :orders
has_many :earnings, dependent: :destroy has_many :promotion_codes
has_many :payouts, dependent: :destroy has_one_attached :image
# === Virtual attribute for backward compatibility with image URLs ===
attr_accessor :image_url
# === Callbacks === # === Callbacks ===
before_validation :generate_slug, if: :should_generate_slug?
before_validation :geocode_address, if: :should_geocode_address? before_validation :geocode_address, if: :should_geocode_address?
before_validation :handle_image_url, if: :should_handle_image_url?
before_update :handle_image_replacement, if: :image_attached?
# === Validations === # Validations for Event attributes
# Basic information # Basic information
validates :name, presence: true, length: { minimum: 3, maximum: 100 } validates :name, presence: true, length: { minimum: 3, maximum: 100 }
validates :slug, presence: true, length: { minimum: 3, maximum: 100 } validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
validates :description, presence: true, length: { minimum: 10, maximum: 2000 } validates :description, presence: true, length: { minimum: 10, maximum: 2000 }
validates :state, presence: true, inclusion: { in: states.keys } validates :state, presence: true, inclusion: { in: states.keys }
validates :image, length: { maximum: 500 } # URL or path to image
# Image validation - handles both attachments and URLs
validate :image_format, if: -> { image.attached? }
validate :image_size, if: -> { image.attached? }
validate :image_url_format, if: -> { image_url.present? && !image.attached? }
# Venue information # Venue information
validates :venue_name, presence: true, length: { maximum: 100 } validates :venue_name, presence: true, length: { maximum: 100 }
@@ -66,34 +68,123 @@ class Event < ApplicationRecord
# Scope for published events ordered by start time # Scope for published events ordered by start time
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) } scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
# Scope for events eligible for payout
scope :eligible_for_payout, -> { where("end_time <= ?", Time.current).joins(:earnings).group("events.id").having("SUM(earnings.amount_cents) > 0") }
# === Instance Methods === # === Instance Methods ===
# Payout methods # Generate SEO-friendly slug from name, venue name, and city
def total_gross_cents def generate_slug
tickets.active.sum(:price_cents) return if name.blank? && venue_name.blank? && venue_address.blank?
# Extract city from venue address
city = extract_city_from_address(venue_address)
# Build slug parts
slug_parts = []
slug_parts << name if name.present?
slug_parts << venue_name if venue_name.present?
slug_parts << city if city.present?
# Generate slug using Rails' parameterize
slug_value = slug_parts.join("-").parameterize
# Ensure minimum length
if slug_value.length < 3
slug_value = "event-#{Time.current.to_i}".parameterize
end end
# Alias for template compatibility # Make sure slug is unique
alias_method :total_earnings_cents, :total_gross_cents base_slug = slug_value
counter = 1
def total_fees_cents while Event.where.not(id: id).where(slug: slug_value).exists?
earnings.pending.sum(:fee_cents) slug_value = "#{base_slug}-#{counter}".parameterize
counter += 1
end end
def net_earnings_cents self.slug = slug_value
total_gross_cents - total_fees_cents
end end
def can_request_payout?(user = self.user) # Check if slug should be generated
event_ended? && (net_earnings_cents > 0) && user.is_professionnal? && payouts.pending.empty? def should_generate_slug?
# Generate slug if it's blank or if it's a new record
slug.blank? || new_record?
end end
# Get the latest payout for this event # Extract city from address
def payout def extract_city_from_address(address)
payouts.order(created_at: :desc).first return "" if address.blank?
# Look for French postal code pattern (5 digits) + city
match = address.match(/(\d{5})\s+([^,]+)/)
if match
return match[2].strip
end
# Fallback: extract last part after comma (assume it's city)
parts = address.split(",")
if parts.length > 1
return parts[parts.length - 1].strip
end
# Another fallback: look for common French city indicators
city_indicators = [ "Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Strasbourg", "Montpellier", "Bordeaux", "Lille" ]
for city in city_indicators
if address.downcase.include?(city.downcase)
return city
end
end
""
end
# Get image URL prioritizing old image field if it exists
def display_image_url
# First check if old image field exists and has a value
return self[:image] if self[:image].present?
# Fall back to attached image
return nil unless image.attached?
# Return the URL for the attached image
Rails.application.routes.url_helpers.rails_blob_url(image, only_path: true)
end
# Get image variants for different display sizes
def event_image_variant(size = :medium)
# For old image field, return the URL directly
return self[:image] if self[:image].present?
# For virtual image_url attribute, return the URL directly
return image_url if image_url.present?
# For attached images, process variants
return nil unless image.attached?
case size
when :large
image.variant(resize_to_limit: [ 1200, 630 ])
when :medium
image.variant(resize_to_limit: [ 800, 450 ])
when :small
image.variant(resize_to_limit: [ 400, 225 ])
else
# Fallback to URL-based image
image_url.presence
end
end
# Check if event has any image (old field, attached, or URL)
def has_image?
self[:image].present? || image.attached? || image_url.present?
end
# Get display image source (uploaded or URL)
def display_image
if image.attached?
image
elsif image_url.present?
image_url
else
self[:image]
end
end end
# Check if coordinates were successfully geocoded or are fallback coordinates # Check if coordinates were successfully geocoded or are fallback coordinates
@@ -134,11 +225,6 @@ class Event < ApplicationRecord
Time.current >= end_time Time.current >= end_time
end end
# Return the event date (start time date)
def date
start_time&.to_date
end
# Check if booking is allowed during the event # Check if booking is allowed during the event
# This is a simple attribute reader that defaults to false if nil # This is a simple attribute reader that defaults to false if nil
def allow_booking_during_event? def allow_booking_during_event?
@@ -174,8 +260,63 @@ class Event < ApplicationRecord
nil nil
end end
# Validate image format
def image_format
return unless image.attached?
allowed_types = %w[image/jpeg image/jpg image/png image/webp]
unless allowed_types.include?(image.content_type)
errors.add(:image, "doit être au format JPG, PNG ou WebP")
end
end
# Validate image size
def image_size
return unless image.attached?
if image.byte_size > 5.megabytes
errors.add(:image, "doit faire moins de 5MB")
end
end
# Validate image URL format - relaxed for development
def image_url_format
return unless image_url.present?
return if Rails.env.development? # Skip validation in development
unless image_url.match?(/\Ahttps?:\/\/.+\.(jpg|jpeg|png|gif|webp)(\?.*)?\z/i)
errors.add(:image_url, "doit être une URL valide vers une image (JPG, PNG, GIF, WebP)")
end
end
private private
# Check if image is attached for the callback
def image_attached?
image.attached?
end
# Handle image replacement when a new image is uploaded
def handle_image_replacement
# Clear the old image field if a new image is being attached
if image.attached?
self[:image] = nil
end
end
# Determine if we should handle image_url
def should_handle_image_url?
image_url.present? && new_record?
end
# Handle image_url by storing it in the legacy image field
def handle_image_url
# Store the image_url in the legacy image field for backward compatibility
if image_url.present?
self[:image] = image_url
end
end
# Determine if we should perform server-side geocoding # Determine if we should perform server-side geocoding
def should_geocode_address? def should_geocode_address?
# Don't geocode if address is blank # Don't geocode if address is blank

View File

@@ -3,13 +3,12 @@ class Order < ApplicationRecord
DRAFT_EXPIRY_TIME = 15.minutes DRAFT_EXPIRY_TIME = 15.minutes
MAX_PAYMENT_ATTEMPTS = 3 MAX_PAYMENT_ATTEMPTS = 3
# === Enums ===
# Note: using string values since the database column is a string
# === Associations === # === Associations ===
belongs_to :user belongs_to :user
belongs_to :event belongs_to :event
has_many :tickets, dependent: :destroy has_many :tickets, dependent: :destroy
has_many :order_promotion_codes, dependent: :destroy
has_many :promotion_codes, through: :order_promotion_codes
# === Validations === # === Validations ===
validates :user_id, presence: true validates :user_id, presence: true
@@ -22,13 +21,15 @@ class Order < ApplicationRecord
validates :payment_attempts, presence: true, validates :payment_attempts, presence: true,
numericality: { greater_than_or_equal_to: 0 } numericality: { greater_than_or_equal_to: 0 }
# Custom validation to prevent duplicate promotion codes
validate :no_duplicate_promotion_codes
# Stripe invoice ID for accounting records # Stripe invoice ID for accounting records
attr_accessor :stripe_invoice_id attr_accessor :stripe_invoice_id
# === Scopes === # === Scopes ===
scope :draft, -> { where(status: :draft) } scope :draft, -> { where(status: "draft") }
scope :active, -> { where(status: [ :paid, :completed ]) } scope :active, -> { where(status: %w[paid completed]) }
scope :paid, -> { where(status: :paid) }
scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) } scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) }
scope :can_retry_payment, -> { scope :can_retry_payment, -> {
draft.where("payment_attempts < ? AND expires_at > ?", draft.where("payment_attempts < ? AND expires_at > ?",
@@ -36,8 +37,6 @@ class Order < ApplicationRecord
} }
before_validation :set_expiry, on: :create before_validation :set_expiry, on: :create
before_validation :set_default_status, on: :create
after_update :create_earnings_if_paid, if: -> { saved_change_to_status? && status == "paid" }
# === Instance Methods === # === Instance Methods ===
@@ -94,10 +93,34 @@ class Order < ApplicationRecord
end end
end end
# Calculate total from ticket prices only (platform fee deducted from promoter payout) # Calculate total from ticket prices minus promotion code discounts
def calculate_total! def calculate_total!
ticket_total = tickets.sum(:price_cents) ticket_total = tickets.sum(:price_cents)
update!(total_amount_cents: ticket_total) discount_total = promotion_codes.sum(:discount_amount_cents)
# Ensure total doesn't go below zero
final_total = [ ticket_total - discount_total, 0 ].max
update!(total_amount_cents: final_total)
end
# Subtotal amount before discounts
def subtotal_amount_cents
tickets.sum(:price_cents)
end
# Subtotal amount in euros
def subtotal_amount_euros
subtotal_amount_cents / 100.0
end
# Total discount amount from all promotion codes (capped at subtotal)
def discount_amount_cents
[ promotion_codes.sum(:discount_amount_cents), subtotal_amount_cents ].min
end
# Discount amount in euros
def discount_amount_euros
discount_amount_cents / 100.0
end end
# Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket # Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket
@@ -165,26 +188,21 @@ class Order < ApplicationRecord
self.expires_at = DRAFT_EXPIRY_TIME.from_now if expires_at.blank? self.expires_at = DRAFT_EXPIRY_TIME.from_now if expires_at.blank?
end end
def set_default_status
self.status ||= "draft"
self.total_amount_cents ||= 0
self.payment_attempts ||= 0
end
def draft? def draft?
status == "draft" status == "draft"
end end
def create_earnings_if_paid # Prevent duplicate promotion codes on the same order
return unless event.present? && user.present? def no_duplicate_promotion_codes
return if event.earnings.exists?(order_id: id) return if promotion_codes.empty?
event.earnings.create!( # Use distinct to avoid association loading issues
user: user, unique_codes = promotion_codes.distinct
order: self, code_counts = unique_codes.group_by(&:code).transform_values(&:count)
amount_cents: promoter_payout_cents, duplicates = code_counts.select { |_, count| count > 1 }
fee_cents: platform_fee_cents,
status: :pending if duplicates.any?
) errors.add(:promotion_codes, "ne peuvent pas contenir de codes en double")
end
end end
end end

View File

@@ -0,0 +1,26 @@
class OrderPromotionCode < ApplicationRecord
# Associations
belongs_to :order
belongs_to :promotion_code
# Validations
validates :order, presence: true
validates :promotion_code, presence: true
# Callbacks
after_create :apply_discount
after_create :increment_promotion_code_uses
private
def apply_discount
# Apply the discount to the order
discount_amount = promotion_code.discount_amount_cents
order.update!(total_amount_cents: [ order.total_amount_cents - discount_amount, 0 ].max)
end
def increment_promotion_code_uses
# Increment the uses count on the promotion code
promotion_code.increment!(:uses_count)
end
end

View File

@@ -1,166 +0,0 @@
class Payout < ApplicationRecord
# === Relations ===
belongs_to :user
belongs_to :event
belongs_to :processed_by, class_name: "User", optional: true
# === Enums ===
enum :status, {
pending: 0, # Payout requested but not reviewed
approved: 1, # Payout approved by admin, ready for transfer
processing: 2, # Payout being processed (bank transfer initiated)
completed: 3, # Payout successfully completed
failed: 4, # Payout failed
rejected: 5 # Payout rejected by admin
}, default: :pending
# === Validations ===
validates :amount_cents, presence: true, numericality: { greater_than: 0 }
validates :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :status, presence: true
validates :total_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :refunded_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :stripe_payout_id, allow_blank: true, uniqueness: true
validate :unique_pending_event_id, if: :pending?
validate :net_earnings_greater_than_zero, if: :pending?
def net_earnings_greater_than_zero
if event.net_earnings_cents <= 0
errors.add(:base, "net earnings must be greater than 0")
end
end
def unique_pending_event_id
if Payout.pending.where(event_id: event_id).where.not(id: id).exists?
errors.add(:base, "only one pending payout allowed per event")
end
end
# === Scopes ===
scope :completed, -> { where(status: :completed) }
scope :pending, -> { where(status: :pending) }
scope :approved, -> { where(status: :approved) }
scope :processing, -> { where(status: :processing) }
scope :rejected, -> { where(status: :rejected) }
scope :failed, -> { where(status: :failed) }
scope :eligible_for_payout, -> { joins(:event).where(events: { state: "published" }) }
# === Callbacks ===
after_create :calculate_refunded_orders_count
# === Instance Methods ===
# Amount in euros (formatted)
def amount_euros
amount_cents / 100.0
end
# Fee in euros (formatted)
def fee_euros
fee_cents / 100.0
end
# Net amount after fees
def net_amount_cents
amount_cents - fee_cents
end
# Net amount in euros
def net_amount_euros
net_amount_cents / 100.0
end
# Check if payout can be approved (was pending)
def can_approve?
pending? && amount_cents > 0 && user.has_complete_banking_info?
end
# Check if payout can be manually processed (was approved)
def can_process?
approved? && amount_cents > 0
end
# Check if payout can be rejected
def can_reject?
pending?
end
# Approve the payout for manual processing
def approve!(admin_user)
return false unless can_approve?
update!(
status: :approved,
processed_by: admin_user,
processed_at: Time.current
)
end
# Reject the payout with reason
def reject!(admin_user, reason)
return false unless can_reject?
update!(
status: :rejected,
processed_by: admin_user,
processed_at: Time.current,
rejection_reason: reason
)
end
# Mark as processing (bank transfer initiated)
def mark_processing!(admin_user, transfer_reference = nil)
return false unless can_process?
update!(
status: :processing,
processed_by: admin_user,
processed_at: Time.current,
bank_transfer_reference: transfer_reference
)
end
# Mark as completed (bank transfer confirmed)
def mark_completed!(admin_user, transfer_reference = nil)
return false unless processing?
update!(
status: :completed,
processed_by: admin_user,
processed_at: Time.current,
bank_transfer_reference: transfer_reference || bank_transfer_reference
)
update_earnings_status
end
# Mark as failed
def mark_failed!(admin_user, reason)
return false unless processing?
update!(
status: :failed,
processed_by: admin_user,
processed_at: Time.current,
rejection_reason: reason
)
end
private
def update_earnings_status
event.earnings.where(status: 0).update_all(status: 1) # pending to paid
end
public
# === Instance Methods ===
def calculate_refunded_orders_count
refunded_order_ids = event.tickets.where(status: "refunded").select(:order_id).distinct.pluck(:order_id)
paid_statuses = %w[paid completed]
count = event.orders.where(status: paid_statuses).where(id: refunded_order_ids).count
update_column(:refunded_orders_count, count)
end
private
def update_earnings_status
event.earnings.where(status: 0).update_all(status: 1) # pending to paid
end
end

View File

@@ -0,0 +1,42 @@
class PromotionCode < ApplicationRecord
# Validations
validates :code, presence: true, uniqueness: true
validates :discount_amount_cents, numericality: { greater_than_or_equal_to: 0 }
# Scopes
scope :active, -> { where(active: true) }
scope :expired, -> { where("expires_at < ? OR active = ?", Time.current, false) }
scope :valid, -> { active.where("expires_at > ? OR expires_at IS NULL", Time.current) }
# Callbacks
before_create :increment_uses_count
# Associations
belongs_to :user
belongs_to :event
has_many :order_promotion_codes
has_many :orders, through: :order_promotion_codes
# Instance methods
def discount_amount_euros
discount_amount_cents / 100.0
end
def active?
active && (expires_at.nil? || expires_at > Time.current)
end
def expired?
expires_at.present? && expires_at < Time.current
end
def can_be_used?
active? && (usage_limit.nil? || uses_count < usage_limit)
end
private
def increment_uses_count
self.uses_count ||= 0
end
end

View File

@@ -19,13 +19,9 @@ class Ticket < ApplicationRecord
scope :active, -> { where(status: "active") } scope :active, -> { where(status: "active") }
scope :expired_drafts, -> { joins(:order).where(status: "draft").where("orders.expires_at < ?", Time.current) } scope :expired_drafts, -> { joins(:order).where(status: "draft").where("orders.expires_at < ?", Time.current) }
# Set default values before validation
before_validation :set_defaults, on: :create
before_validation :set_price_from_ticket_type, on: :create before_validation :set_price_from_ticket_type, on: :create
before_validation :generate_qr_code, on: :create before_validation :generate_qr_code, on: :create
after_update :recalculate_earning_if_refunded, if: :saved_change_to_status?
# Generate PDF ticket # Generate PDF ticket
def to_pdf def to_pdf
TicketPdfGenerator.new(self).generate TicketPdfGenerator.new(self).generate
@@ -77,16 +73,4 @@ class Ticket < ApplicationRecord
def draft? def draft?
status == "draft" status == "draft"
end end
private
def recalculate_earning_if_refunded
if status == "refunded"
order.earning&.recalculate!
end
end
def set_defaults
self.status ||= "draft"
end
end end

View File

@@ -23,19 +23,12 @@ class User < ApplicationRecord
has_many :events, dependent: :destroy has_many :events, dependent: :destroy
has_many :tickets, dependent: :destroy has_many :tickets, dependent: :destroy
has_many :orders, dependent: :destroy has_many :orders, dependent: :destroy
has_many :earnings, dependent: :destroy
has_many :payouts, dependent: :destroy
# Validations - allow reasonable name lengths # Validations - allow reasonable name lengths
validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true } validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true }
validates :first_name, length: { minimum: 2, maximum: 50, allow_blank: true } validates :first_name, length: { minimum: 2, maximum: 50, allow_blank: true }
validates :company_name, length: { minimum: 2, maximum: 100, allow_blank: true } validates :company_name, length: { minimum: 2, maximum: 100, allow_blank: true }
# Banking information validations
validates :iban, format: { with: /\A[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}\z/, message: "must be a valid IBAN format" }, allow_blank: true
validates :bank_name, length: { minimum: 2, maximum: 100 }, allow_blank: true
validates :account_holder_name, length: { minimum: 2, maximum: 100 }, allow_blank: true
# Onboarding methods # Onboarding methods
def needs_onboarding? def needs_onboarding?
!onboarding_completed? !onboarding_completed?
@@ -55,51 +48,4 @@ class User < ApplicationRecord
# Alias for can_manage_events? to make views more semantic # Alias for can_manage_events? to make views more semantic
can_manage_events? can_manage_events?
end end
def name
[ first_name, last_name ].compact.join(" ").strip
end
# Stripe Connect methods
def stripe_account_id
stripe_customer_id
end
def has_stripe_account?
stripe_customer_id.present?
end
def can_receive_payouts?
has_complete_banking_info?
end
# Banking information methods
def has_complete_banking_info?
iban.present? && bank_name.present? && account_holder_name.present?
end
def banking_info_summary
return "No banking information" unless has_complete_banking_info?
"#{account_holder_name} - #{bank_name} - #{iban}"
end
private
def stripe_connect_verified?
return false unless stripe_customer_id.present?
begin
customer = Stripe::Customer.retrieve(stripe_customer_id)
customer.present?
rescue Stripe::StripeError => e
Rails.logger.error "Failed to verify Stripe customer #{stripe_customer_id}: #{e.message}"
false
end
end
# Add role method for backward compatibility
def add_role(role)
# This is a stub for testing - in a real app you'd use a proper role system
# For now, we'll just mark users as admin if they have a stripe account
true
end
end end

View File

@@ -1,83 +0,0 @@
class PayoutService
def initialize(payout)
@payout = payout
end
# Check if user is in France or doesn't have a Stripe account (manual processing)
def process_with_stripe_or_manual
if should_process_manually?
process_manually!
else
process_with_stripe!
end
end
# Generate payout summary for manual transfer
def generate_transfer_summary
return nil unless @payout.approved? || @payout.processing?
{
payout_id: @payout.id,
recipient: @payout.user.name,
account_holder: @payout.user.account_holder_name,
bank_name: @payout.user.bank_name,
iban: @payout.user.iban,
amount_euros: @payout.net_amount_euros,
description: "Payout for event: #{@payout.event.name}",
event_name: @payout.event.name,
event_date: @payout.event.date,
total_orders: @payout.total_orders_count,
refunded_orders: @payout.refunded_orders_count
}
end
# Validate banking information before processing
def validate_banking_info
errors = []
user = @payout.user
errors << "Missing IBAN" unless user.iban.present?
errors << "Missing bank name" unless user.bank_name.present?
errors << "Missing account holder name" unless user.account_holder_name.present?
errors << "Invalid IBAN format" if user.iban.present? && !valid_iban?(user.iban)
errors
end
private
def should_process_manually?
# For now, we'll assume manual processing for all users
# In a real implementation, this could check the user's country
!@payout.user.has_stripe_account?
end
def process_manually!
@payout.update!(status: :processing)
begin
# For manual processing, we just mark it as completed
# In a real implementation, this would trigger notifications to admin
@payout.mark_completed!(User.admin.first || User.first, "Manual processing completed")
Rails.logger.info "Manual payout processed for payout #{@payout.id} for event #{@payout.event.name}"
rescue => e
@payout.update!(status: :failed)
Rails.logger.error "Manual payout failed for payout #{@payout.id}: #{e.message}"
raise e
end
end
def process_with_stripe!
@payout.update!(status: :processing)
end
def valid_iban?(iban)
# Basic IBAN validation (simplified)
iban.match?(/\A[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}\z/)
end
def update_earnings_status
@payout.event.earnings.where(status: 0).update_all(status: 1) # pending to paid
end
end

View File

@@ -1,35 +0,0 @@
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

View File

@@ -166,6 +166,23 @@ class StripeInvoiceService
}) })
end end
# Add promotion code discounts as negative line items
@order.promotion_codes.each do |promo_code|
Stripe::InvoiceItem.create({
customer: customer.id,
invoice: invoice.id,
amount: -promo_code.discount_amount_cents, # Negative amount for discount
currency: "eur",
description: "Réduction promotionnelle (Code: #{promo_code.code})",
metadata: {
promotion_code_id: promo_code.id,
promotion_code: promo_code.code,
discount_amount_cents: promo_code.discount_amount_cents,
type: "promotion_discount"
}
})
end
# No service fee on customer invoice; platform fee deducted from promoter payout # No service fee on customer invoice; platform fee deducted from promoter payout
end end

View File

@@ -1,96 +0,0 @@
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Promoter</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Banking Info</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<% if show_actions %>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<% end %>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% payouts.each do |payout| %>
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900"><%= payout.event.name %></div>
<div class="text-sm text-gray-500"><%= payout.event.date.strftime("%b %d, %Y") if payout.event.date %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900"><%= payout.user.name.presence || payout.user.email %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<% if payout.user.has_complete_banking_info? %>
<div class="text-sm text-gray-900">✅ Complete</div>
<div class="text-sm text-gray-500"><%= payout.user.bank_name %></div>
<% else %>
<div class="text-sm text-red-600">❌ Incomplete</div>
<div class="text-sm text-gray-500">Missing banking info</div>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">€<%= payout.amount_euros %></div>
<div class="text-sm text-gray-500">Net: €<%= payout.net_amount_euros %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<% case payout.status %>
<% when 'pending' %>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
Pending Review
</span>
<% when 'approved' %>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
Approved
</span>
<% when 'processing' %>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-indigo-100 text-indigo-800">
Processing
</span>
<% when 'completed' %>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Completed
</span>
<% when 'failed' %>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
Failed
</span>
<% when 'rejected' %>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
Rejected
</span>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= payout.created_at.strftime("%b %d, %Y") %>
<% if payout.processed_at %>
<div class="text-xs text-gray-400">Processed: <%= payout.processed_at.strftime("%b %d") %></div>
<% end %>
</td>
<% if show_actions %>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<%= link_to "View", admin_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900" %>
<% case section %>
<% when 'pending' %>
<% if payout.can_approve? %>
<%= link_to "Approve", approve_admin_payout_path(payout), method: :post,
class: "text-green-600 hover:text-green-900 ml-2",
data: { confirm: "Approve this payout for transfer?" } %>
<% end %>
<% when 'approved' %>
<%= link_to "Start Transfer", mark_processing_admin_payout_path(payout), method: :post,
class: "text-blue-600 hover:text-blue-900 ml-2",
data: { confirm: "Mark as processing (transfer initiated)?" } %>
<% when 'processing' %>
<%= link_to "Complete", mark_completed_admin_payout_path(payout), method: :post,
class: "text-green-600 hover:text-green-900 ml-2",
data: { confirm: "Mark transfer as completed?" } %>
<% end %>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -1,2 +0,0 @@
<h1>Admin::Payouts#create</h1>
<p>Find me in app/views/admin/payouts/create.html.erb</p>

View File

@@ -1,53 +0,0 @@
<%= render 'shared/admin_nav' %>
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Manual Payout Administration</h1>
</div>
<!-- Pending Payouts - Require Review -->
<% if @pending_payouts.any? %>
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">📋 Pending Review (<%= @pending_payouts.count %>)</h2>
<div class="bg-white rounded-lg shadow overflow-hidden">
<%= render partial: 'payout_table', locals: { payouts: @pending_payouts, show_actions: true, section: 'pending' } %>
</div>
</div>
<% end %>
<!-- Approved Payouts - Ready for Transfer -->
<% if @approved_payouts.any? %>
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">✅ Approved - Ready for Transfer (<%= @approved_payouts.count %>)</h2>
<div class="bg-white rounded-lg shadow overflow-hidden">
<%= render partial: 'payout_table', locals: { payouts: @approved_payouts, show_actions: true, section: 'approved' } %>
</div>
</div>
<% end %>
<!-- Processing Payouts - Transfer Initiated -->
<% if @processing_payouts.any? %>
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">🔄 Processing - Transfer in Progress (<%= @processing_payouts.count %>)</h2>
<div class="bg-white rounded-lg shadow overflow-hidden">
<%= render partial: 'payout_table', locals: { payouts: @processing_payouts, show_actions: true, section: 'processing' } %>
</div>
</div>
<% end %>
<!-- Recent Completed Payouts -->
<% if @completed_payouts.any? %>
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">✨ Recently Completed</h2>
<div class="bg-white rounded-lg shadow overflow-hidden">
<%= render partial: 'payout_table', locals: { payouts: @completed_payouts, show_actions: false, section: 'completed' } %>
</div>
</div>
<% end %>
<% if @pending_payouts.empty? && @approved_payouts.empty? && @processing_payouts.empty? && @completed_payouts.empty? %>
<div class="bg-white rounded-lg shadow p-6 text-center">
<p class="text-gray-500">No payouts found.</p>
</div>
<% end %>
</div>

View File

@@ -1,2 +0,0 @@
<h1>Admin::Payouts#new</h1>
<p>Find me in app/views/admin/payouts/new.html.erb</p>

View File

@@ -1,221 +0,0 @@
<%= render 'shared/admin_nav' %>
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Payout Details #<%= @payout.id %></h1>
<%= link_to "← Back to Payouts", admin_payouts_path, class: "text-indigo-600 hover:text-indigo-900" %>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Payout Information -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Payout Information</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-500">Status</label>
<% case @payout.status %>
<% when 'pending' %>
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-yellow-100 text-yellow-800">
Pending Review
</span>
<% when 'approved' %>
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-blue-100 text-blue-800">
Approved - Ready for Transfer
</span>
<% when 'processing' %>
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-indigo-100 text-indigo-800">
Processing
</span>
<% when 'completed' %>
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800">
Completed
</span>
<% when 'failed' %>
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800">
Failed
</span>
<% when 'rejected' %>
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-gray-100 text-gray-800">
Rejected
</span>
<% end %>
</div>
<div>
<label class="block text-sm font-medium text-gray-500">Event</label>
<p class="text-gray-900"><%= @payout.event.name %></p>
<p class="text-sm text-gray-500"><%= @payout.event.date.strftime("%B %d, %Y") if @payout.event.date %></p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500">Promoter</label>
<p class="text-gray-900"><%= @payout.user.name.presence || @payout.user.email %></p>
<p class="text-sm text-gray-500"><%= @payout.user.email %></p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-500">Gross Amount</label>
<p class="text-lg font-semibold text-gray-900">€<%= @payout.amount_euros %></p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500">Platform Fee</label>
<p class="text-lg font-semibold text-gray-900">€<%= @payout.fee_euros %></p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-500">Net Amount (To Transfer)</label>
<p class="text-2xl font-bold text-green-600">€<%= @payout.net_amount_euros %></p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-500">Total Orders</label>
<p class="text-gray-900"><%= @payout.total_orders_count %></p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500">Refunded Orders</label>
<p class="text-gray-900"><%= @payout.refunded_orders_count %></p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-500">Requested</label>
<p class="text-gray-900"><%= @payout.created_at.strftime("%B %d, %Y at %I:%M %p") %></p>
</div>
<% if @payout.processed_at %>
<div>
<label class="block text-sm font-medium text-gray-500">Processed</label>
<p class="text-gray-900"><%= @payout.processed_at.strftime("%B %d, %Y at %I:%M %p") %></p>
<% if @payout.processed_by %>
<p class="text-sm text-gray-500">by <%= @payout.processed_by.name.presence || @payout.processed_by.email %></p>
<% end %>
</div>
<% end %>
<% if @payout.bank_transfer_reference.present? %>
<div>
<label class="block text-sm font-medium text-gray-500">Transfer Reference</label>
<p class="text-gray-900 font-mono"><%= @payout.bank_transfer_reference %></p>
</div>
<% end %>
<% if @payout.rejection_reason.present? %>
<div>
<label class="block text-sm font-medium text-gray-500">Rejection/Failure Reason</label>
<p class="text-red-600"><%= @payout.rejection_reason %></p>
</div>
<% end %>
</div>
</div>
<!-- Banking Information -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Banking Information</h2>
<% if @banking_errors.any? %>
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
<h3 class="text-sm font-medium text-red-800">Banking Information Issues:</h3>
<ul class="mt-2 text-sm text-red-700">
<% @banking_errors.each do |error| %>
<li>• <%= error %></li>
<% end %>
</ul>
</div>
<% end %>
<% if @transfer_summary %>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-500">Account Holder</label>
<p class="text-gray-900"><%= @transfer_summary[:account_holder] %></p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500">Bank Name</label>
<p class="text-gray-900"><%= @transfer_summary[:bank_name] %></p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500">IBAN</label>
<p class="text-gray-900 font-mono"><%= @transfer_summary[:iban] %></p>
</div>
<div class="p-4 bg-blue-50 border border-blue-200 rounded-md">
<h3 class="text-sm font-medium text-blue-800">Transfer Instructions</h3>
<div class="mt-2 text-sm text-blue-700">
<p><strong>Amount:</strong> €<%= @transfer_summary[:amount_euros] %></p>
<p><strong>Reference:</strong> Payout #<%= @transfer_summary[:payout_id] %> - <%= @transfer_summary[:event_name] %></p>
</div>
</div>
</div>
<% else %>
<div class="text-center text-gray-500 py-8">
<p>Banking information not available for display.</p>
</div>
<% end %>
</div>
</div>
<!-- Actions -->
<div class="mt-8 bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Actions</h2>
<div class="flex flex-wrap gap-4">
<% if @payout.can_approve? %>
<%= button_to "✅ Approve Payout", approve_admin_payout_path(@payout), method: :post,
class: "bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md font-medium",
data: { confirm: "Approve this payout for manual bank transfer?" } %>
<% end %>
<% if @payout.can_reject? %>
<%= form_with url: reject_admin_payout_path(@payout), method: :post, local: true, class: "flex gap-2" do |form| %>
<%= form.text_field :rejection_reason, placeholder: "Rejection reason...", required: true,
class: "border border-gray-300 rounded-md px-3 py-2" %>
<%= form.submit "❌ Reject", class: "bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium",
data: { confirm: "Reject this payout?" } %>
<% end %>
<% end %>
<% if @payout.can_process? %>
<%= form_with url: mark_processing_admin_payout_path(@payout), method: :post, local: true, class: "flex gap-2" do |form| %>
<%= form.text_field :bank_transfer_reference, placeholder: "Transfer reference (optional)",
class: "border border-gray-300 rounded-md px-3 py-2" %>
<%= form.submit "🔄 Mark as Processing", class: "bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium",
data: { confirm: "Mark as processing (bank transfer initiated)?" } %>
<% end %>
<% end %>
<% if @payout.processing? %>
<%= form_with url: mark_completed_admin_payout_path(@payout), method: :post, local: true, class: "flex gap-2" do |form| %>
<%= form.text_field :bank_transfer_reference, placeholder: "Final transfer reference",
value: @payout.bank_transfer_reference,
class: "border border-gray-300 rounded-md px-3 py-2" %>
<%= form.submit "✅ Mark as Completed", class: "bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md font-medium",
data: { confirm: "Confirm transfer completion?" } %>
<% end %>
<%= form_with url: mark_failed_admin_payout_path(@payout), method: :post, local: true, class: "flex gap-2" do |form| %>
<%= form.text_field :failure_reason, placeholder: "Failure reason...", required: true,
class: "border border-gray-300 rounded-md px-3 py-2" %>
<%= form.submit "❌ Mark as Failed", class: "bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium",
data: { confirm: "Mark transfer as failed?" } %>
<% end %>
<% end %>
<% if @payout.pending? || @payout.processing? %>
<%= button_to "Process Payout", admin_payout_path(@payout), method: :post,
class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
<% end %>
<% if @payout.pending? || @payout.processing? %>
<%= button_to "Mark as Manually Processed", mark_as_manually_processed_admin_payout_path(@payout), method: :post,
class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500",
data: { confirm: "Are you sure you want to mark this payout as manually processed? This will notify the promoter that the bank transfer is being processed." } %>
<% end %>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<%= link_to event_path(event.slug, event), class: "group block p-4 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-600 hover:shadow-md transition-all duration-200" do %> <%= link_to event_path(event.slug, event), class: "group block p-4 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-600 hover:shadow-md transition-all duration-200" do %>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<div class="w-16 h-16 bg-slate-200 dark:bg-slate-700 rounded-lg overflow-hidden flex-shrink-0"> <div class="w-16 h-16 bg-slate-200 dark:bg-slate-700 rounded-lg overflow-hidden flex-shrink-0">
<%= image_tag event.image, alt: event.name, class: "w-full h-full object-cover" if event.image.present? %> <%= image_tag event.event_image_variant(:small), alt: event.name, class: "w-full h-full object-cover" if event.has_image? %>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors duration-200"> <h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors duration-200">

View File

@@ -22,13 +22,13 @@
<% @events.each do |event| %> <% @events.each do |event| %>
<article class="group bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden transform hover:-translate-y-1"> <article class="group bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden transform hover:-translate-y-1">
<%= link_to event_path(event.slug, event), class: "block" do %> <%= link_to event_path(event.slug, event), class: "block" do %>
<% if event.image.present? %> <% if event.has_image? %>
<div class="relative overflow-hidden aspect-[4/3]"> <div class="relative overflow-hidden aspect-[4/3]">
<img <% if event.image.attached? %>
src="<%= event.image %>" <%= image_tag event.event_image_variant(:medium), alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
alt="<%= event.name %>" <% else %>
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" <%= image_tag event.image_url, alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
> <% end %>
<!-- Event featured badge --> <!-- Event featured badge -->
<% if event.featured? %> <% if event.featured? %>
<div class="absolute top-4 left-4"> <div class="absolute top-4 left-4">

View File

@@ -10,9 +10,9 @@
<!-- Event main wrapper --> <!-- Event main wrapper -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden"> <div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<!-- Event Header with Image --> <!-- Event Header with Image -->
<% if @event.image.present? %> <% if @event.has_image? %>
<div class="relative h-96"> <div class="relative h-96">
<%= image_tag @event.image, class: "w-full h-full object-cover" %> <%= image_tag @event.event_image_variant(:large), class: "w-full h-full object-cover", alt: @event.name %>
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-transparent"></div> <div class="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 p-6 md:p-8"> <div class="absolute bottom-0 left-0 right-0 p-6 md:p-8">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
@@ -88,11 +88,7 @@
%> %>
<% map_providers.each do |name, url| %> <% map_providers.each do |name, url| %>
<%= link_to url, target: "_blank", rel: "noopener", <%= link_to "#{icons[name]} #{name}".html_safe, url, target: "_blank", rel: "noopener", class: "inline-flex items-center px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" %>
class: "inline-flex items-center px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" do %>
<span class="mr-1"><%= icons[name] %></span>
<%= name %>
<% end %>
<% end %> <% end %>
</div> </div>
</div> </div>
@@ -131,14 +127,7 @@
<!-- Right Column: Ticket Selection --> <!-- Right Column: Ticket Selection -->
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<%= form_with url: event_order_new_path(@event.slug, @event.id), method: :get, id: "checkout_form", local: true, data: { <%= form_with url: event_order_new_path(@event.slug, @event.id), method: :get, id: "checkout_form", local: true, data: { controller: "ticket-selection", ticket_selection_target: "form", ticket_selection_event_slug_value: @event.slug, ticket_selection_event_id_value: @event.id, ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id), ticket_selection_store_cart_url_value: api_v1_store_cart_path } do |form| %>
controller: "ticket-selection",
ticket_selection_target: "form",
ticket_selection_event_slug_value: @event.slug,
ticket_selection_event_id_value: @event.id,
ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id),
ticket_selection_store_cart_url_value: api_v1_store_cart_path
} 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="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"> <div class="flex justify-center sm:justify-start mb-6">

View File

@@ -1,30 +1,12 @@
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8"> <div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb --> <!-- 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"> <%= render 'components/breadcrumb', crumbs: [
<div class="inline-flex items-center text-sm font-medium"> { name: 'Accueil', path: root_path },
<%= link_to root_path, class: "text-gray-700 hover:text-purple-600 transition-colors" do %> { name: 'Événements', path: events_path },
<i data-lucide="home" class="w-4 h-4 mr-2"></i> { name: @order.event.name, path: event_path(@order.event.slug, @order.event) },
Accueil { name: "Commande ##{@order.id}", path: nil }
<% end %> ] %>
</div>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<div class="inline-flex items-center text-sm font-medium">
<%= link_to events_path, class: "text-gray-700 hover:text-purple-600 transition-colors" do %>
Événements
<% end %>
</div>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<div class="inline-flex items-center text-sm font-medium">
<%= link_to event_path(@order.event.slug, @order.event), class: "text-gray-700 hover:text-purple-600 transition-colors" do %>
<%= @order.event.name %>
<% end %>
</div>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<div class="text-sm font-medium text-purple-600">
Commande #<%= @order.id %>
</div>
</nav>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Order Summary --> <!-- Order Summary -->
@@ -77,7 +59,7 @@
</div> </div>
<!-- Order Items --> <!-- Order Items -->
<div class="space-y-4 mb-6"> <div class="space-y-4 mb-6 border-b border-gray-200 pb-6 mb-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 ">Récapitulatif de votre commande</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4 ">Récapitulatif de votre commande</h3>
<% @tickets.each do |ticket| %> <% @tickets.each do |ticket| %>
@@ -99,12 +81,46 @@
<% end %> <% end %>
</div> </div>
<!-- Promotion Code Discount -->
<% if @order.promotion_codes.any? %>
<div class="space-y-2 mb-6 pb-6 border-b border-gray-200">
<% @order.promotion_codes.each do |promo_code| %>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-green-600">
<i data-lucide="tag" class="w-4 h-4 mr-1"></i>
Code: <%= promo_code.code %>
</span>
<span class="text-sm font-semibold text-green-600">-<%= promo_code.discount_amount_euros %>€</span>
</div>
<% end %>
</div>
<% end %>
<!-- Order Total --> <!-- Order Total -->
<div class=" pt-12"> <div class=" pt-12">
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between text-lg pt-2"> <!-- Subtotal -->
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Sous-total</span>
<span class="text-sm font-medium text-gray-600"><%= @order.subtotal_amount_euros %>€</span>
</div>
<!-- Discount -->
<% if @order.discount_amount_cents > 0 %>
<div class="flex items-center justify-between">
<span class="text-sm text-green-600">Réduction</span>
<span class="text-sm font-semibold text-green-600">-<%= @order.discount_amount_euros %>€</span>
</div>
<% end %>
<!-- Total -->
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
<span class="font-medium text-gray-900">Total</span> <span class="font-medium text-gray-900">Total</span>
<% if @order.total_amount_cents == 0 %>
<span class="font-bold text-2xl text-green-600">GRATUIT</span>
<% else %>
<span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span> <span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span>
<% end %>
</div> </div>
</div> </div>
<p class="text-xs text-gray-500 mt-2">TVA incluse</p> <p class="text-xs text-gray-500 mt-2">TVA incluse</p>
@@ -118,6 +134,16 @@
<p class="text-sm text-gray-600">Procédez au paiement pour finaliser votre commande</p> <p class="text-sm text-gray-600">Procédez au paiement pour finaliser votre commande</p>
</div> </div>
<!-- Promotion Code Section -->
<%= form_tag checkout_order_path(@order), method: :get, class: "mb-6" do %>
<div class="flex items-center bg-gray-50 border border-gray-200 rounded-lg p-3">
<%= text_field_tag :promotion_code, params[:promotion_code], class: "flex-1 border-none bg-transparent focus:ring-0 text-sm", placeholder: "Code promotionnel (optionnel)" %>
<%= button_tag type: "submit", class: "ml-2 btn btn-secondary py-2 px-4 text-sm" do %>
Appliquer
<% end %>
</div>
<% end %>
<% if @checkout_session.present? %> <% if @checkout_session.present? %>
<!-- Stripe Checkout --> <!-- Stripe Checkout -->
<div class="space-y-6"> <div class="space-y-6">
@@ -140,7 +166,11 @@
> >
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<i data-lucide="credit-card" class="w-5 h-5 mr-2"></i> <i data-lucide="credit-card" class="w-5 h-5 mr-2"></i>
<% if @order.total_amount_cents == 0 %>
Confirmer la commande
<% else %>
Payer <%= @order.total_amount_euros %>€ Payer <%= @order.total_amount_euros %>€
<% end %>
</div> </div>
</button> </button>
@@ -241,7 +271,11 @@
button.innerHTML = ` button.innerHTML = `
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<i data-lucide="credit-card" class="w-5 h-5 mr-2"></i> <i data-lucide="credit-card" class="w-5 h-5 mr-2"></i>
<% if @order.total_amount_cents == 0 %>
Confirmer la commande
<% else %>
Payer <%= @order.total_amount_euros %>€ Payer <%= @order.total_amount_euros %>€
<% end %>
</div> </div>
`; `;
alert('Erreur: ' + error.message); alert('Erreur: ' + error.message);

View File

@@ -121,13 +121,56 @@
<% end %> <% end %>
</tbody> </tbody>
<tfoot class="bg-gray-50"> <tfoot class="bg-gray-50">
<!-- Subtotal -->
<tr> <tr>
<th colspan="3" scope="col" class="px-6 py-3 text-right text-sm font-medium text-gray-900 uppercase tracking-wider">Total</th> <td colspan="3" scope="col" class="px-6 py-3 text-right text-sm font-medium text-gray-600">Sous-total</td>
<th scope="col" class="px-6 py-3 text-right text-lg font-bold text-gray-900"><%= "%.2f" % @order.total_amount_euros %>€</th> <td scope="col" class="px-6 py-3 text-right text-sm font-medium text-gray-600"><%= "%.2f" % @order.subtotal_amount_euros %>€</td>
</tr>
<!-- Promotion Code Discounts -->
<% if @order.promotion_codes.any? %>
<% @order.promotion_codes.each do |promo_code| %>
<tr>
<td colspan="3" scope="col" class="px-6 py-3 text-right text-sm font-medium text-green-600">
Réduction (Code: <%= promo_code.code %>)
</td>
<td scope="col" class="px-6 py-3 text-right text-sm font-semibold text-green-600">-<%= "%.2f" % promo_code.discount_amount_euros %>€</td>
</tr>
<% end %>
<% end %>
<!-- Total -->
<tr class="border-t-2 border-gray-300">
<td colspan="3" scope="col" class="px-6 py-3 text-right text-lg font-bold text-gray-900 uppercase tracking-wider">Total</td>
<td scope="col" class="px-6 py-3 text-right text-lg font-bold text-gray-900">
<% if @order.total_amount_cents == 0 %>
GRATUIT
<% else %>
<%= "%.2f" % @order.total_amount_euros %>€
<% end %>
</td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
<!-- Promotion Code Summary -->
<% if @order.promotion_codes.any? %>
<div class="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 class="text-sm font-semibold text-green-900 mb-2 flex items-center">
<i data-lucide="tag" class="w-4 h-4 mr-2"></i>
Codes promotionnels appliqués
</h4>
<div class="text-xs text-green-700">
<% @order.promotion_codes.each do |promo_code| %>
<div class="flex items-center justify-between">
<span><%= promo_code.code %></span>
<span class="font-semibold">-<%= "%.2f" % promo_code.discount_amount_euros %>€</span>
</div>
<% end %>
</div>
</div>
<% end %>
</div> </div>
<!-- Payment Information --> <!-- Payment Information -->

View File

@@ -123,13 +123,58 @@
<% end %> <% end %>
</div> </div>
<!-- Total --> <!-- Promotion Codes Applied -->
<div class="mt-6"> <% if @order.promotion_codes.any? %>
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 class="text-lg font-semibold text-green-900 mb-3 flex items-center">
<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="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"/>
</svg>
Codes promotionnels appliqués
</h3>
<% @order.promotion_codes.each do |promo_code| %>
<div class="flex items-center justify-between py-2 px-3 bg-white rounded-lg border border-green-200 mb-2 last:mb-0">
<div class="flex items-center">
<span class="text-sm font-medium text-green-800">
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<%= promo_code.code %>
</span>
</div>
<span class="text-sm font-semibold text-green-700">-<%= promo_code.discount_amount_euros %>€</span>
</div>
<% end %>
</div>
<% end %>
<!-- Price Breakdown -->
<div class="mt-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Détail du paiement</h3>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between text-lg pt-2"> <!-- Subtotal -->
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Sous-total</span>
<span class="font-medium text-gray-700"><%= @order.subtotal_amount_euros %>€</span>
</div>
<!-- Discount -->
<% if @order.discount_amount_cents > 0 %>
<div class="flex items-center justify-between text-sm">
<span class="text-green-600">Réduction</span>
<span class="font-semibold text-green-600">-<%= @order.discount_amount_euros %>€</span>
</div>
<% end %>
<!-- Total -->
<div class="flex items-center justify-between pt-2 border-t border-gray-300">
<span class="font-medium text-gray-900">Total payé</span> <span class="font-medium text-gray-900">Total payé</span>
<span class="font-bold text-2xl text-green-600"> <span class="font-bold text-xl text-green-600">
<% if @order.total_amount_cents == 0 %>
GRATUIT
<% else %>
<%= @order.total_amount_euros %>€ <%= @order.total_amount_euros %>€
<% end %>
</span> </span>
</div> </div>
</div> </div>

View File

@@ -94,15 +94,58 @@
<% end %> <% end %>
</div> </div>
<!-- Total --> <!-- Promotion Codes Applied -->
<% if @order.promotion_codes.any? %>
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 class="text-lg font-semibold text-green-900 mb-3 flex items-center">
<i data-lucide="tag" class="w-5 h-5 mr-2"></i>
Codes promotionnels appliqués
</h3>
<% @order.promotion_codes.each do |promo_code| %>
<div class="flex items-center justify-between py-2 px-3 bg-white rounded-lg border border-green-200 mb-2 last:mb-0">
<div class="flex items-center">
<span class="text-sm font-medium text-green-800">
<i data-lucide="check-circle" class="w-4 h-4 mr-1 inline"></i>
<%= promo_code.code %>
</span>
</div>
<span class="text-sm font-semibold text-green-700">-<%= promo_code.discount_amount_euros %>€</span>
</div>
<% end %>
</div>
<% end %>
<!-- Price Breakdown -->
<div class="border-t border-gray-200 pt-6 mt-6"> <div class="border-t border-gray-200 pt-6 mt-6">
<div class="flex items-center justify-between text-lg pt-2"> <h3 class="text-lg font-semibold text-gray-900 mb-3">Détail du paiement</h3>
<div class="space-y-2">
<!-- Subtotal -->
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Sous-total</span>
<span class="font-medium text-gray-700"><%= @order.subtotal_amount_euros %>€</span>
</div>
<!-- Discount -->
<% if @order.discount_amount_cents > 0 %>
<div class="flex items-center justify-between text-sm">
<span class="text-green-600">Réduction</span>
<span class="font-semibold text-green-600">-<%= @order.discount_amount_euros %>€</span>
</div>
<% end %>
<!-- Total -->
<div class="flex items-center justify-between pt-2 border-t border-gray-300">
<span class="font-medium text-gray-900">Total <%= @order.status == 'paid' || @order.status == 'completed' ? 'payé' : 'à payer' %></span> <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' %>"> <span class="font-bold text-2xl <%= @order.status == 'paid' || @order.status == 'completed' ? 'text-green-600' : 'text-purple-600' %>">
<% if @order.total_amount_cents == 0 %>
GRATUIT
<% else %>
<%= @order.total_amount_euros %>€ <%= @order.total_amount_euros %>€
<% end %>
</span> </span>
</div> </div>
</div> </div>
</div>
<!-- View Invoice --> <!-- View Invoice -->
<% if @order.status == 'paid' || @order.status == 'completed' %> <% if @order.status == 'paid' || @order.status == 'completed' %>

View File

@@ -7,11 +7,11 @@
{ name: 'Tableau de bord', path: dashboard_path } { name: 'Tableau de bord', path: dashboard_path }
] %> ] %>
<!-- Page Header --> <!-- Promoter Page Header -->
<div class="mb-8"> <div class="mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900">Mon tableau de bord</h1> <h1 class="text-3xl font-bold text-gray-900">Mon tableau de bord promoteur</h1>
<p class="text-gray-600 mt-1">Gérez vos commandes et accédez à vos billets</p> <p class="text-gray-600 mt-1">Gérez vos commandes et accédez à vos billets</p>
</div> </div>
@@ -39,7 +39,7 @@
<!-- Promoter Dashboard Section --> <!-- Promoter Dashboard Section -->
<% if current_user.promoter? && @promoter_events.present? %> <% if current_user.promoter? && @promoter_events.present? %>
<!-- Promoter Metrics --> <!-- Promoter Metrics -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 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="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 class="flex items-center justify-between">
<div> <div>
@@ -76,7 +76,9 @@
</div> </div>
</div> </div>
<!-- Brouillons -->
<div class="bg-gradient-to-br from-orange-50 to-orange-100 rounded-2xl p-6 border border-orange-200"> <div class="bg-gradient-to-br from-orange-50 to-orange-100 rounded-2xl p-6 border border-orange-200">
<%= link_to promoter_events_path do %>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-orange-600 text-sm font-medium">Brouillons</p> <p class="text-orange-600 text-sm font-medium">Brouillons</p>
@@ -86,20 +88,9 @@
<i data-lucide="edit-3" class="w-6 h-6 text-orange-700"></i> <i data-lucide="edit-3" class="w-6 h-6 text-orange-700"></i>
</div> </div>
</div> </div>
</div> <% end %>
</div><!-- /Brouillons -->
<!-- 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> </div>
<!-- Revenue Chart & Recent Events --> <!-- Revenue Chart & Recent Events -->
@@ -160,50 +151,10 @@
<%= event.tickets.where(status: 'active').count %> billets vendus <%= event.tickets.where(status: 'active').count %> billets vendus
</div> </div>
</div> </div>
<div class="mt-3 flex flex-wrap gap-2"> <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 %> <%= link_to promoter_event_path(event), class: "text-purple-600 hover:text-purple-800 text-xs font-medium" do %>
Gérer → Gérer →
<% end %> <% 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 %>
<% 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>
</div> </div>
<% end %> <% end %>
@@ -218,84 +169,37 @@
</div> </div>
</div> </div>
<!-- Ended Events Requiring Payout --> <!-- Recent Orders -->
<% ended_events = @promoter_events.select(&:event_ended?) %> <% if @recent_orders.any? %>
<% if ended_events.any? %>
<div class="bg-white rounded-2xl shadow-lg mb-8"> <div class="bg-white rounded-2xl shadow-lg mb-8">
<div class="border-b border-gray-100 p-6"> <div class="border-b border-gray-100 p-6">
<div class="flex items-center justify-between"> <h2 class="text-xl font-bold text-gray-900">Commandes Récentes</h2>
<h2 class="text-xl font-bold text-gray-900">Événements Terminés</h2> <p class="text-gray-600 mt-1">Dernières commandes pour vos événements</p>
<%= link_to "Voir tous les paiements", promoter_payouts_path, class: "text-purple-600 hover:text-purple-800 font-medium text-sm" %>
</div>
</div> </div>
<div class="p-6"> <div class="p-6">
<div class="space-y-4"> <div class="overflow-x-auto">
<% ended_events.each do |event| %> <table class="w-full">
<div class="border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow"> <thead>
<div class="flex items-start justify-between mb-2"> <tr class="text-left border-b border-gray-200">
<div> <th class="pb-3 text-sm font-medium text-gray-600">Événement</th>
<h4 class="font-semibold text-gray-900 text-sm"><%= event.name %></h4> <th class="pb-3 text-sm font-medium text-gray-600">Client</th>
<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> <th class="pb-3 text-sm font-medium text-gray-600">Billets</th>
</div> <th class="pb-3 text-sm font-medium text-gray-600">Montant</th>
<span class="text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-800"> <th class="pb-3 text-sm font-medium text-gray-600">Date</th>
Terminé </tr>
</span> </thead>
</div> <tbody class="divide-y divide-gray-100">
<div class="text-xs text-gray-600 space-y-1"> <% @recent_orders.each do |order| %>
<div class="flex items-center"> <tr class="hover:bg-gray-50">
<i data-lucide="ticket" class="w-3 h-3 mr-2"></i> <td class="py-3 text-sm font-medium text-gray-900"><%= order.event.name %></td>
<%= event.tickets.where(status: 'active').count %> billets vendus <td class="py-3 text-sm text-gray-700"><%= order.user.email %></td>
</div> <td class="py-3 text-sm text-gray-700"><%= order.tickets.count %></td>
<div class="flex items-center"> <td class="py-3 text-sm font-medium text-gray-900">€<%= order.total_amount_euros %></td>
<i data-lucide="euro" class="w-3 h-3 mr-2"></i> <td class="py-3 text-sm text-gray-500"><%= order.created_at.strftime("%d/%m/%Y") %></td>
Revenus: €<%= event.net_earnings_cents / 100.0 %> </tr>
</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 %> <% end %>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
@@ -373,6 +277,16 @@
</div> </div>
<% end %> <% end %>
<!-- User Page Header -->
<div class="mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-gray-900">Mon tableau de bord</h1>
<p class="text-gray-600 mt-1">Accédez à vos billets et évenements</p>
</div>
</div>
</div>
<!-- User's Orders Section --> <!-- User's Orders Section -->
<div class="bg-white rounded-2xl shadow-lg mb-8"> <div class="bg-white rounded-2xl shadow-lg mb-8">
<div class="border-b border-gray-100 p-4 sm:p-6"> <div class="border-b border-gray-100 p-4 sm:p-6">

View File

@@ -89,10 +89,8 @@
<div class="bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden"> <div class="bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden">
<!-- Event Image --> <!-- Event Image -->
<div class="relative overflow-hidden aspect-[4/3]"> <div class="relative overflow-hidden aspect-[4/3]">
<% if event.image.present? %> <% if event.has_image? %>
<img src="<%= event.image %>" <%= image_tag event.event_image_variant(:medium), alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
alt="<%= event.name %>"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
<% else %> <% else %>
<div class="w-full h-full bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center"> <div class="w-full h-full bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
<i data-lucide="calendar" class="w-16 h-16 text-white"></i> <i data-lucide="calendar" class="w-16 h-16 text-white"></i>

View File

@@ -1,47 +0,0 @@
<% if @event.can_request_payout? %>
<div class="space-y-4">
<h4 class="text-lg font-medium text-gray-900">Aperçu des Revenus</h4>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
<!-- Gross -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm font-medium text-gray-500">Revenus Bruts</p>
<p class="text-2xl font-bold text-gray-900">
<%= number_to_currency(@event.total_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
</p>
</div>
<!-- Fees -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm font-medium text-gray-500">Frais Plateforme</p>
<p class="text-2xl font-bold text-gray-900">
-<%= number_to_currency(@event.total_fees_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
</p>
</div>
<!-- Net -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm font-medium text-gray-500">Revenus Nets</p>
<p class="text-2xl font-bold text-gray-900">
<%= number_to_currency(@event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
</p>
</div>
</div>
<% if @event.payout.present? %>
<%= link_to "Voir les Détails du Paiement", promoter_payout_path(@event.payout),
class: "inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50" %>
<% else %>
<%= form_with model: Payout.new, url: promoter_payouts_path, local: true, class: "inline-block" do |f| %>
<%= f.hidden_field :event_id, value: @event.id %>
<%= f.submit "Demander le Paiement Maintenant",
class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500",
data: { confirm: "Êtes-vous sûr de vouloir demander un paiement de #{number_to_currency(@event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') } ? Cette action ne peut pas être annulée." } %>
<% end %>
<% end %>
</div>
<% else %>
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
<p class="text-sm text-yellow-800">Non éligible à la demande de paiement. L'événement n'est peut-être pas terminé ou le compte Stripe n'est pas vérifié.</p>
</div>
<% end %>

View File

@@ -41,24 +41,9 @@
<div class="bg-white rounded-lg border border-gray-200 p-6"> <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> <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> <div>
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= 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" } %> <%= 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" %>
</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" } %>
<p class="mt-1 text-sm text-gray-500">
<% if @event.published? %>
<i data-lucide="alert-triangle" class="w-4 h-4 inline text-yellow-500"></i>
Attention: Modifier le slug d'un événement publié peut casser les liens existants.
<% else %>
Utilisé dans l'URL de l'événement
<% end %>
</p>
</div>
</div> </div>
<div class="mt-6"> <div class="mt-6">
@@ -67,9 +52,127 @@
</div> </div>
<div class="mt-6"> <div class="mt-6">
<%= form.label :image, "Image (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.url_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg" %>
<p class="mt-1 text-sm text-gray-500">URL de l'image de couverture de l'événement</p> <!-- Image type selection tabs -->
<div class="mb-4">
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<button type="button" onclick="switchImageTab('upload')" id="upload-tab" class="tab-button active border-purple-500 text-purple-600 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i data-lucide="upload" class="w-4 h-4 inline mr-2"></i>
Télécharger un fichier
</button>
<button type="button" onclick="switchImageTab('url')" id="url-tab" class="tab-button border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i data-lucide="link" class="w-4 h-4 inline mr-2"></i>
Utiliser une URL
</button>
</nav>
</div>
</div>
<!-- Upload tab content -->
<div id="upload-content" class="tab-content space-y-4">
<!-- Current image preview -->
<<<<<<< HEAD
<% if @event.image.attached? %>
<div class="relative">
<%= image_tag @event.image.variant(resize_to_limit: [400, 225]), class: "w-full h-48 object-cover rounded-lg border border-gray-200" %>
<div class="absolute top-2 right-2">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
=======
<% if @event.has_image? %>
<div class="flex items-start space-x-4">
<div class="flex-shrink-0">
<% if @event.event_image_variant(:small).is_a?(String) %>
<!-- Old image field -->
<%= image_tag @event.event_image_variant(:small), class: "w-32 h-24 object-cover rounded-lg border border-gray-200" %>
<% else %>
<!-- Attached image -->
<%= image_tag @event.event_image_variant(:small), class: "w-32 h-24 object-cover rounded-lg border border-gray-200" %>
<% end %>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 mb-1">Image actuelle</p>
<p class="text-sm text-gray-600 mb-2">Uploader une nouvelle image pour la remplacer.</p>
<button type="button" onclick="this.closest('div').querySelector('input[type=file]').click()" class="bg-red-500 text-white p-2 rounded-lg hover:bg-red-600 transition-colors inline-flex items-center">
<i data-lucide="trash-2" class="w-4 h-4 mr-1"></i>
<span>Remplacer l'image</span>
>>>>>>> fix/image-upload
</button>
</div>
<div class="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded text-xs">
Image actuelle
</div>
</div>
<% end %>
<!-- File upload field -->
<div class="relative">
<%= form.file_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100", accept: "image/png,image/jpeg,image/jpg,image/webp", data: { action: "change->event-form#previewImage" } %>
<div class="mt-1 text-sm text-gray-500">
Formats acceptés : PNG, JPG, JPEG, WebP (max 5MB)
<% if @event.has_image? %>
<br>Laissez vide pour conserver l'image actuelle
<% end %>
</div>
</div>
<!-- Image preview container -->
<div id="upload-preview" class="hidden">
<div class="relative">
<img id="upload-preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
<div class="absolute bottom-2 left-2 bg-purple-600 text-white px-2 py-1 rounded text-xs">
Nouvelle image
</div>
</div>
</div>
</div>
<!-- URL tab content -->
<div id="url-content" class="tab-content space-y-4 hidden">
<!-- Current URL image preview -->
<% if @event.image_url.present? && !@event.image.attached? %>
<div class="relative">
<%= image_tag @event.image_url, class: "w-full h-48 object-cover rounded-lg border border-gray-200", alt: "Current URL image" %>
<div class="absolute top-2 right-2">
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
<div class="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded text-xs">
URL actuelle
</div>
</div>
<% end %>
<!-- URL input field -->
<div class="relative">
<%= form.text_field :image_url, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg", value: @event.image_url, data: { action: "input->event-form#previewImageUrl" } %>
<div class="mt-1 text-sm text-gray-500">
Entrez l'URL d'une image (JPG, PNG, GIF, WebP)
<% if @event.image_url.present? %>
<br>Laissez vide pour conserver l'URL actuelle
<% end %>
</div>
</div>
<!-- URL preview container -->
<div id="url-preview" class="hidden">
<div class="relative">
<img id="url-preview-img" src="" alt="URL Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
<div class="absolute bottom-2 left-2 bg-purple-600 text-white px-2 py-1 rounded text-xs">
Nouvelle URL
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -204,3 +307,26 @@
<% end %> <% end %>
</div> </div>
</div> </div>
<script>
function switchImageTab(tab) {
// Hide all tab contents
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
// Remove active class from all tabs
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active', 'border-purple-500', 'text-purple-600');
button.classList.add('border-transparent', 'text-gray-500');
});
// Show selected tab content
document.getElementById(tab + '-content').classList.remove('hidden');
// Add active class to selected tab
const activeTab = document.getElementById(tab + '-tab');
activeTab.classList.add('active', 'border-purple-500', 'text-purple-600');
activeTab.classList.remove('border-transparent', 'text-gray-500');
}
</script>

View File

@@ -84,36 +84,6 @@
À la une À la une
</span> </span>
<% end %> <% 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>
<td class="px-6 py-4 text-sm text-gray-500"> <td class="px-6 py-4 text-sm text-gray-500">
<% if event.start_time %> <% if event.start_time %>

View File

@@ -41,17 +41,14 @@
<div class="bg-white rounded-lg border border-gray-200 p-6"> <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> <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 class="space-y-6">
<div> <div>
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= 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" } %> <%= 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>
<div> <!-- Hidden slug field (auto-generated) -->
<%= form.label :slug, "Slug (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.hidden_field :slug, data: { "event-form-target": "slug" } %>
<%= 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" } %>
<p class="mt-1 text-sm text-gray-500">Utilisé dans l'URL de l'événement</p>
</div>
</div> </div>
<div class="mt-6"> <div class="mt-6">
@@ -60,9 +57,89 @@
</div> </div>
<div class="mt-6"> <div class="mt-6">
<%= form.label :image, "Image (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.url_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg" %>
<p class="mt-1 text-sm text-gray-500">URL de l'image de couverture de l'événement</p> <!-- Image type selection tabs -->
<div class="mb-4">
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<button type="button" onclick="switchImageTab('upload')" id="upload-tab" class="tab-button active border-purple-500 text-purple-600 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i data-lucide="upload" class="w-4 h-4 inline mr-2"></i>
Télécharger un fichier
</button>
<button type="button" onclick="switchImageTab('url')" id="url-tab" class="tab-button border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i data-lucide="link" class="w-4 h-4 inline mr-2"></i>
Utiliser une URL
</button>
</nav>
</div>
</div>
<!-- Upload tab content -->
<div id="upload-content" class="tab-content space-y-4">
<!-- Current image preview (for edit mode) -->
<% if @event.has_image? %>
<div class="relative">
<%= image_tag @event.image.variant(resize_to_limit: [400, 225]), class: "w-full h-48 object-cover rounded-lg border border-gray-200" %>
<div class="absolute top-2 right-2">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>
<% end %>
<!-- File upload field -->
<div class="relative">
<%= form.file_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100", accept: "image/png,image/jpeg,image/jpg,image/webp", data: { action: "change->event-form#previewImage" } %>
<div class="mt-1 text-sm text-gray-500">
Formats acceptés : PNG, JPG, JPEG, WebP (max 5MB)
</div>
</div>
<!-- Image preview container -->
<div id="upload-preview" class="hidden">
<div class="relative">
<img id="upload-preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
<!-- URL tab content -->
<div id="url-content" class="tab-content space-y-4 hidden">
<!-- Current URL image preview -->
<% if @event.image_url.present? && !@event.image.attached? %>
<div class="relative">
<%= image_tag @event.image_url, class: "w-full h-48 object-cover rounded-lg border border-gray-200", alt: "Current image" %>
<div class="absolute top-2 right-2">
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>
<% end %>
<!-- URL input field -->
<div class="relative">
<%= form.text_field :image_url, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg", data: { action: "input->event-form#previewImageUrl" } %>
<div class="mt-1 text-sm text-gray-500">
Entrez l'URL d'une image (JPG, PNG, GIF, WebP)
</div>
</div>
<!-- URL preview container -->
<div id="url-preview" class="hidden">
<div class="relative">
<img id="url-preview-img" src="" alt="URL Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -93,7 +170,7 @@
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= 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" %> <%= 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", data: { "event-form-target": "venueName", action: "input->event-form#venueNameChanged" } %>
</div> </div>
<div> <div>
@@ -163,3 +240,26 @@
<% end %> <% end %>
</div> </div>
</div> </div>
<script>
function switchImageTab(tab) {
// Hide all tab contents
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
// Remove active class from all tabs
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active', 'border-purple-500', 'text-purple-600');
button.classList.add('border-transparent', 'text-gray-500');
});
// Show selected tab content
document.getElementById(tab + '-content').classList.remove('hidden');
// Add active class to selected tab
const activeTab = document.getElementById(tab + '-tab');
activeTab.classList.add('active', 'border-purple-500', 'text-purple-600');
activeTab.classList.remove('border-transparent', 'text-gray-500');
}
</script>

View File

@@ -58,32 +58,18 @@
<% end %> <% end %>
<% end %> <% end %>
<% elsif @event.published? %> <% elsif @event.published? %>
<% if @event.event_ended? %>
<%= button_to unpublish_promoter_event_path(@event), method: :patch, disabled: true, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-gray-400 text-white font-medium rounded-lg cursor-not-allowed transition-colors duration-200", title: "Impossible de dépublier un événement terminé" do %>
<i data-lucide="download" class="w-4 h-4 mr-2"></i>
Dépublier
<% end %>
<% else %>
<%= button_to unpublish_promoter_event_path(@event), method: :patch, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-yellow-600 text-white font-medium rounded-lg hover:bg-yellow-700 transition-colors duration-200" do %> <%= 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> <i data-lucide="download" class="w-4 h-4 mr-2"></i>
Dépublier Dépublier
<% end %> <% end %>
<% end %> <% end %>
<% end %>
<% if @event.published? %> <% if @event.published? %>
<% if @event.event_ended? %>
<%= button_to cancel_promoter_event_path(@event), method: :patch, disabled: true, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-gray-400 text-white font-medium rounded-lg cursor-not-allowed transition-colors duration-200", title: "Impossible d'annuler un événement terminé", data: { confirm: "Êtes-vous sûr de vouloir annuler cet événement ?" } do %>
<i data-lucide="x-circle" class="w-4 h-4 mr-2"></i>
Annuler
<% end %>
<% else %>
<%= button_to cancel_promoter_event_path(@event), method: :patch, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors duration-200", data: { confirm: "Êtes-vous sûr de vouloir annuler cet événement ?" } do %> <%= 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> <i data-lucide="x-circle" class="w-4 h-4 mr-2"></i>
Annuler Annuler
<% end %> <% end %>
<% end %> <% end %>
<% end %>
</div> </div>
</div> </div>
@@ -188,9 +174,13 @@
<!-- Main content --> <!-- Main content -->
<div class="lg:col-span-2 space-y-6 lg:space-y-8"> <div class="lg:col-span-2 space-y-6 lg:space-y-8">
<!-- Event image --> <!-- Event image -->
<% if @event.image.present? %> <% if @event.has_image? %>
<div class="aspect-video bg-gray-100 rounded-2xl overflow-hidden"> <div class="aspect-video bg-gray-100 rounded-2xl overflow-hidden">
<img src="<%= @event.image %>" alt="<%= @event.name %>" class="w-full h-full object-cover"> <% if @event.image.attached? %>
<%= image_tag @event.event_image_variant(:large), alt: @event.name, class: "w-full h-full object-cover" %>
<% else %>
<%= image_tag @event.image_url, alt: @event.name, class: "w-full h-full object-cover" %>
<% end %>
</div> </div>
<% end %> <% end %>
@@ -219,15 +209,46 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Revenue Overview -->
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
<%= render 'earnings_preview' %>
</div>
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
<div class="space-y-6"> <div class="space-y-6">
<!-- Quick actions -->
<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 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 %>
<%= link_to promoter_event_promotion_codes_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-green-600 text-white font-medium text-sm rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
<i data-lucide="tag" class="w-4 h-4 mr-2"></i>
Gérer les codes de réduction
<% end %>
<% if @event.sold_out? %>
<%= button_to mark_available_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-blue-50 text-blue-700 font-medium text-sm rounded-lg hover:bg-blue-100 transition-colors duration-200" do %>
<i data-lucide="refresh-ccw" class="w-4 h-4 mr-2"></i>
Marquer comme disponible
<% end %>
<% elsif @event.published? %>
<%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-gray-50 text-gray-700 font-medium text-sm rounded-lg hover:bg-gray-100 transition-colors duration-200" do %>
<i data-lucide="users" class="w-4 h-4 mr-2"></i>
Marquer comme complet
<% end %>
<% end %>
<hr class="border-gray-200">
<%= button_to promoter_event_path(@event), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." },
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 %>
</div>
</div>
<!-- Event stats --> <!-- Event stats -->
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm: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> <h3 class="text-lg font-semibold text-gray-900 mb-4">Statistiques</h3>
@@ -288,36 +309,6 @@
</div> </div>
</div> </div>
<!-- Quick actions -->
<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 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 %>
<% if @event.sold_out? %>
<%= button_to mark_available_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-blue-50 text-blue-700 font-medium text-sm rounded-lg hover:bg-blue-100 transition-colors duration-200" do %>
<i data-lucide="refresh-ccw" class="w-4 h-4 mr-2"></i>
Marquer comme disponible
<% end %>
<% elsif @event.published? %>
<%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-gray-50 text-gray-700 font-medium text-sm rounded-lg hover:bg-gray-100 transition-colors duration-200" do %>
<i data-lucide="users" class="w-4 h-4 mr-2"></i>
Marquer comme complet
<% end %>
<% end %>
<hr class="border-gray-200">
<%= button_to promoter_event_path(@event), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." },
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 %>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -1,2 +0,0 @@
<h1>Promoter::Payouts#create</h1>
<p>Find me in app/views/promoter/payouts/create.html.erb</p>

View File

@@ -1,205 +0,0 @@
<% content_for(:title, "Payouts") %>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Payout History</h1>
<p class="mt-1 text-sm text-gray-500">View and track all your payout requests</p>
</div>
</div>
<!-- Summary Cards -->
<% if @payouts.any? %>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Total Payouts -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-2 bg-blue-100 rounded-lg">
<i data-lucide="dollar-sign" class="w-6 h-6 text-blue-600"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Total Payouts</p>
<p class="text-2xl font-bold text-gray-900"><%= @payouts.count %></p>
</div>
</div>
</div>
<!-- Total Amount -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-2 bg-green-100 rounded-lg">
<i data-lucide="trending-up" class="w-6 h-6 text-green-600"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Total Earned</p>
<p class="text-2xl font-bold text-gray-900">€<%= @payouts.sum(&:net_amount_cents) / 100.0 %></p>
</div>
</div>
</div>
<!-- Pending Payouts -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-2 bg-yellow-100 rounded-lg">
<i data-lucide="clock" class="w-6 h-6 text-yellow-600"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Pending</p>
<p class="text-2xl font-bold text-gray-900"><%= @payouts.pending.count %></p>
</div>
</div>
</div>
</div>
<% end %>
<!-- Pending Earnings Section -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
<h2 class="text-lg font-medium text-gray-900 mb-4">Pending Earnings</h2>
<% if @total_pending_net && @total_pending_net > 0 %>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-2 bg-yellow-100 rounded-lg">
<i data-lucide="dollar-sign" class="w-6 h-6 text-yellow-600"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Total Pending Net</p>
<p class="text-2xl font-bold text-gray-900">
<%= number_to_currency(@total_pending_net / 100.0, unit: '€', separator: ',', delimiter: '.') %>
</p>
</div>
</div>
</div>
</div>
<% end %>
<% if @eligible_events.present? && @eligible_events.any? %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<% @eligible_events.limit(5).each do |event| %>
<div class="bg-white p-6 rounded-lg shadow border border-gray-200">
<div class="flex items-center mb-3">
<div class="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
<i data-lucide="calendar" class="h-5 w-5 text-white"></i>
</div>
<div class="ml-4">
<h3 class="text-lg font-bold text-gray-900"><%= event.name %></h3>
<p class="text-sm text-gray-500"><%= event.start_time.strftime("%d %b %Y") %></p>
</div>
</div>
<div class="space-y-2 text-sm">
<p><span class="font-medium">Gross:</span> <%= number_to_currency(event.total_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %></p>
<p><span class="font-medium">Net:</span> <%= number_to_currency(event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %></p>
</div>
<%= link_to "Request Payout", promoter_event_path(event),
class: "mt-4 w-full inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
</div>
<% end %>
</div>
<% if @eligible_events.size > 5 %>
<div class="text-center mt-4">
<%= link_to "View All Eligible Events", promoter_events_path, class: "text-indigo-600 hover:text-indigo-500 text-sm font-medium" %>
</div>
<% end %>
<% else %>
<div class="text-center py-12">
<i data-lucide="inbox" class="mx-auto h-12 w-12 text-gray-400 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">No pending earnings</h3>
<p class="text-gray-500">Check your events to see if any are eligible for payout requests.</p>
<%= link_to "View My Events", promoter_events_path, class: "mt-4 inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50" %>
</div>
<% end %>
</div>
<!-- Payouts Table -->
<% if @payouts.any? %>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200 payout-table-row">
<% @payouts.each do |payout| %>
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
<i data-lucide="calendar" class="h-5 w-5 text-white"></i>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900"><%= payout.event&.name || "Event not found" %></div>
<div class="text-sm text-gray-500">#<%= payout.id %></div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">€<%= payout.net_amount_euros %></div>
<div class="text-sm text-gray-500">Gross: €<%= payout.amount_euros %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<% case payout.status %>
<% when 'pending' %>
<span class="payout-status-badge pending">
<i data-lucide="clock" class="w-3 h-3 mr-1"></i>
Pending
</span>
<% when 'processing' %>
<span class="payout-status-badge processing">
<i data-lucide="refresh-cw" class="w-3 h-3 mr-1"></i>
Processing
</span>
<% when 'completed' %>
<span class="payout-status-badge completed">
<i data-lucide="check-circle" class="w-3 h-3 mr-1"></i>
<% if payout.manual_payout? %>
Manually Processed
<% else %>
Completed
<% end %>
</span>
<% when 'failed' %>
<span class="payout-status-badge failed">
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
Failed
</span>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= payout.created_at.strftime("%b %d, %Y") %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<%= link_to "View Details", promoter_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900 font-medium" %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<!-- Pagination -->
<% if @payouts.respond_to?(:total_pages) %>
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<%= paginate @payouts %>
</div>
<% end %>
</div>
<% else %>
<!-- Empty State -->
<div class="payout-empty-state bg-white rounded-xl shadow-sm border border-gray-200">
<div class="payout-empty-state-icon">
<i data-lucide="dollar-sign" class="h-8 w-8 text-gray-400"></i>
</div>
<h3 class="payout-empty-state-title">No payouts yet</h3>
<p class="payout-empty-state-description">You haven't requested any payouts yet. When your events end, you'll be able to request payouts here.</p>
<%= link_to "View My Events", promoter_events_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
</div>
<% end %>
</div>

View File

@@ -1,204 +0,0 @@
<% content_for(:title, "Payout Details") %>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="payout-detail-header">
<div>
<h1 class="payout-detail-title">Payout Details</h1>
<p class="mt-1 text-sm text-gray-500">Payout request for <%= @payout.event&.name || "Unknown Event" %></p>
</div>
<%= link_to "← Back to Payouts", promoter_payouts_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
</div>
<!-- Status Progress -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
<h2 class="text-lg font-medium text-gray-900 mb-4">Payout Status</h2>
<div class="payout-status-progress">
<!-- Steps -->
<div class="payout-status-step">
<div class="payout-status-step-icon <%= @payout.status == 'pending' ? 'pending' : 'completed' %>">
<% if @payout.status == 'pending' %>
<i data-lucide="clock" class="w-4 h-4"></i>
<% else %>
<i data-lucide="check" class="w-4 h-4"></i>
<% end %>
</div>
<p class="payout-status-step-label">Requested</p>
<p class="payout-status-step-date"><%= @payout.created_at.strftime("%b %d, %Y") %></p>
</div>
<div class="payout-status-step">
<div class="payout-status-step-icon <%= @payout.status == 'processing' ? 'processing' : (@payout.status == 'completed' || @payout.status == 'failed') ? 'completed' : 'incomplete' %>">
<% if @payout.status == 'processing' %>
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
<% elsif @payout.status == 'completed' || @payout.status == 'failed' %>
<i data-lucide="check" class="w-4 h-4"></i>
<% else %>
<i data-lucide="circle" class="w-4 h-4"></i>
<% end %>
</div>
<p class="payout-status-step-label">Processing</p>
</div>
<div class="payout-status-step">
<div class="payout-status-step-icon <%= @payout.status == 'completed' ? 'completed' : (@payout.status == 'failed' ? 'failed' : 'incomplete') %>">
<% if @payout.status == 'completed' %>
<i data-lucide="check-circle" class="w-4 h-4"></i>
<% elsif @payout.status == 'failed' %>
<i data-lucide="x-circle" class="w-4 h-4"></i>
<% else %>
<i data-lucide="circle" class="w-4 h-4"></i>
<% end %>
</div>
<p class="payout-status-step-label">Completed</p>
</div>
</div>
</div>
<!-- Summary Card -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm font-medium text-gray-500">Gross Amount</p>
<p class="mt-1 text-2xl font-bold text-gray-900">
<%= number_to_currency(@payout.amount_euros, unit: '€', separator: ',', delimiter: '.') %>
</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm font-medium text-gray-500">Platform Fees</p>
<p class="mt-1 text-2xl font-bold text-gray-900">
-<%= number_to_currency(@payout.fee_euros, unit: '€', separator: ',', delimiter: '.') %>
</p>
</div>
<div class="payout-summary-card">
<p class="payout-summary-label">Net Amount</p>
<p class="payout-summary-amount">
<%= number_to_currency(@payout.net_amount_euros, unit: '€', separator: ',', delimiter: '.') %>
</p>
</div>
</div>
<!-- Details -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Payout Information</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500">Details about this payout request</p>
</div>
<div class="divide-y divide-gray-200">
<div class="px-4 py-5 sm:px-6 payout-detail-item">
<dt class="payout-detail-label">Event</dt>
<dd class="payout-detail-value">
<div class="payout-event-card">
<div class="payout-event-icon">
<i data-lucide="calendar" class="h-4 w-4"></i>
</div>
<div>
<div class="payout-event-name"><%= @payout.event&.name || "Event not found" %></div>
<div class="payout-event-id">Event #<%= @payout.event&.id %></div>
</div>
</div>
</dd>
</div>
<div class="px-4 py-5 sm:px-6 payout-detail-item">
<dt class="payout-detail-label">Status</dt>
<dd class="payout-detail-value">
<% case @payout.status %>
<% when 'pending' %>
<span class="payout-status-badge pending">
<i data-lucide="clock" class="w-4 h-4 mr-1"></i>
Pending
</span>
<% when 'processing' %>
<span class="payout-status-badge processing">
<i data-lucide="refresh-cw" class="w-4 h-4 mr-1"></i>
Processing
</span>
<% when 'completed' %>
<span class="payout-status-badge completed">
<i data-lucide="check-circle" class="w-4 h-4 mr-1"></i>
Completed
</span>
<% when 'failed' %>
<span class="payout-status-badge failed">
<i data-lucide="x-circle" class="w-4 h-4 mr-1"></i>
Failed
</span>
<% end %>
</dd>
</div>
<div class="px-4 py-5 sm:px-6 payout-detail-item">
<dt class="payout-detail-label">Gross Amount</dt>
<dd class="payout-detail-value amount">€<%= @payout.amount_euros %></dd>
</div>
<div class="px-4 py-5 sm:px-6 payout-detail-item">
<dt class="payout-detail-label">Platform Fees</dt>
<dd class="payout-detail-value amount">-€<%= @payout.fee_euros %></dd>
</div>
<div class="px-4 py-5 sm:px-6 payout-detail-item">
<dt class="payout-detail-label">Net Amount</dt>
<dd class="payout-detail-value net-amount">€<%= @payout.net_amount_euros %></dd>
</div>
<div class="px-4 py-5 sm:px-6 payout-detail-item">
<dt class="payout-detail-label">Total Orders</dt>
<dd class="payout-detail-value"><%= @payout.total_orders_count %></dd>
</div>
<div class="px-4 py-5 sm:px-6 payout-detail-item">
<dt class="payout-detail-label">Refunded Orders</dt>
<dd class="payout-detail-value"><%= @payout.refunded_orders_count %></dd>
</div>
<div class="px-4 py-5 sm:px-6 payout-detail-item">
<dt class="payout-detail-label">Requested Date</dt>
<dd class="payout-detail-value"><%= @payout.created_at.strftime("%B %d, %Y at %I:%M %p") %></dd>
</div>
<% if @payout.stripe_payout_id.present? %>
<div class="px-4 py-5 sm:px-6 payout-detail-item">
<dt class="payout-detail-label">
<% if @payout.manual_payout? %>
Manual Payout ID
<% else %>
Stripe Payout ID
<% end %>
</dt>
<dd class="payout-detail-value font-mono text-xs break-all">
<% if @payout.manual_payout? %>
Manual Transfer - <%= @payout.stripe_payout_id %>
<% else %>
<%= @payout.stripe_payout_id %>
<% end %>
</dd>
</div>
<% end %>
<% if @payout.manual_payout? && @payout.completed? %>
<div class="px-4 py-5 sm:px-6 payout-detail-item">
<dt class="payout-detail-label">Manual Processing Note</dt>
<dd class="payout-detail-value">
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<i data-lucide="info" class="h-5 w-5 text-blue-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Bank Transfer Initiated</h3>
<div class="mt-2 text-sm text-blue-700">
<p>Your payout is being processed via bank transfer. Please allow 1-3 business days for the funds to appear in your account.</p>
</div>
</div>
</div>
</div>
</dd>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,109 @@
<% content_for(:title, "Modifier le code de réduction - #{@event.name}") %>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<%= render 'components/breadcrumb', crumbs: [
{ name: 'Accueil', path: root_path },
{ name: 'Tableau de bord', path: dashboard_path },
{ name: 'Mes événements', path: promoter_events_path },
{ name: @event.name, path: promoter_event_path(@event) },
{ name: 'Codes de réduction', path: promoter_event_promotion_codes_path(@event) },
{ name: "Modifier #{@promotion_code.code}" }
] %>
<div class="max-w-2xl mx-auto">
<div class="mb-8">
<div class="flex items-center space-x-4 mb-4">
<%= link_to promoter_event_promotion_codes_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 class="flex-1">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Modifier le code de réduction</h1>
<p class="text-gray-600">
<code class="bg-gray-100 px-2 py-1 rounded text-sm"><%= @promotion_code.code %></code> pour <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p>
</div>
</div>
</div>
<%= form_with(model: [@event, @promotion_code], url: promoter_event_promotion_code_path(@event, @promotion_code), method: :patch, local: true, class: "bg-white rounded-2xl border border-gray-200 p-6 sm:p-8") do |form| %>
<% if @promotion_code.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-2xl p-4 mb-6">
<div class="flex items-start">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-400 mr-3 mt-0.5 flex-shrink-0"></i>
<div class="flex-1">
<h3 class="text-sm font-medium text-red-800 mb-2">
<%= pluralize(@promotion_code.errors.count, "erreur") %> ont empêché ce code de réduction d'être sauvegardé :
</h3>
<ul class="list-disc list-inside text-sm text-red-700">
<% @promotion_code.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
<% end %>
<div class="space-y-6">
<div>
<%= form.label :code, "Code de réduction", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :code, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: SUMMER2024, BIENVENUE10, etc." %>
<p class="text-sm text-gray-500 mt-2">Ce code sera visible par les clients lors du paiement</p>
</div>
<div>
<%= form.label :discount_amount_euros, "Montant de la réduction (en euros)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :discount_amount_euros, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "10", min: 0, step: "0.01", value: number_with_precision(@promotion_code.discount_amount_cents.to_f / 100, precision: 2) %>
<p class="text-sm text-gray-500 mt-2">Entrez le montant en euros (ex: 10, 5.50, 25)</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :expires_at, "Date d'expiration", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :expires_at, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors" %>
<p class="text-sm text-gray-500 mt-2">Laissez vide pour une durée illimitée</p>
</div>
<div>
<%= form.label :usage_limit, "Limite d'utilisation", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :usage_limit, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: 50", min: 1 %>
<p class="text-sm text-gray-500 mt-2">Laissez vide pour une utilisation illimitée</p>
</div>
</div>
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div class="flex items-center">
<%= form.check_box :active, class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded" %>
<%= form.label :active, "Code actif", class: "ml-3 block text-sm font-medium text-gray-900" %>
</div>
<div class="text-sm text-gray-500">
Les clients peuvent utiliser ce code de réduction
</div>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start">
<i data-lucide="info" class="w-5 h-5 text-blue-400 mr-3 mt-0.5 flex-shrink-0"></i>
<div>
<h4 class="text-sm font-medium text-blue-800 mb-1">Statut actuel</h4>
<div class="text-sm text-blue-700">
<p>Utilisations: <%= @promotion_code.uses_count %><%= " / #{@promotion_code.usage_limit}" if @promotion_code.usage_limit %></p>
<p>Commandes associées: <%= @promotion_code.orders.count %></p>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between pt-6 mt-8 border-t border-gray-200">
<%= link_to promoter_event_promotion_codes_path(@event), class: "inline-flex items-center px-6 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="x" class="w-4 h-4 mr-2"></i>
Annuler
<% end %>
<%= form.submit "Mettre à jour le code de réduction", 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" %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,175 @@
<% content_for(:title, "Codes de réduction - #{@event.name}") %>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<%= render 'components/breadcrumb', crumbs: [
{ name: 'Accueil', path: root_path },
{ name: 'Tableau de bord', path: dashboard_path },
{ name: 'Mes événements', path: promoter_events_path },
{ name: @event.name, path: promoter_event_path(@event) },
{ name: 'Codes de réduction' }
] %>
<div class="mb-8">
<div class="flex items-center space-x-4 mb-4">
<%= link_to promoter_event_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 class="flex-1">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Codes de réduction</h1>
<p class="text-gray-600">
<%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p>
</div>
<%= link_to new_promoter_event_promotion_code_path(@event), 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>
Nouveau code
<% end %>
</div>
<!-- Event status info -->
<% if @event.draft? %>
<div class="bg-gray-50 border border-gray-200 rounded-2xl p-4 mb-6">
<div class="flex items-center">
<i data-lucide="info" class="w-5 h-5 text-gray-400 mr-3"></i>
<p class="text-sm text-gray-600">
Cet événement est en brouillon. Les codes de réduction ne seront actifs qu'une fois l'événement publié.
</p>
</div>
</div>
<% end %>
</div>
<% if @promotion_codes.any? %>
<div class="grid gap-6">
<% @promotion_codes.each do |promotion_code| %>
<div class="bg-white rounded-2xl border border-gray-200 p-6 hover:shadow-md transition-shadow duration-200">
<div class="flex items-start justify-between">
<!-- Promotion code info -->
<div class="flex-1">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">
<%= promotion_code.code %>
</h3>
<p class="text-gray-600 mb-3">Réduction de <%= number_to_currency(promotion_code.discount_amount_cents / 100.0, unit: "€") %></p>
</div>
<!-- Status badge -->
<div class="ml-4">
<% if promotion_code.active? && (promotion_code.expires_at.nil? || promotion_code.expires_at > Time.current) %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800">
<i data-lucide="check-circle" class="w-4 h-4 mr-1"></i>
Actif
</span>
<% elsif promotion_code.expires_at && promotion_code.expires_at <= Time.current %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800">
<i data-lucide="x-circle" class="w-4 h-4 mr-1"></i>
Expiré
</span>
<% else %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-gray-100 text-gray-800">
<i data-lucide="pause-circle" class="w-4 h-4 mr-1"></i>
Inactif
</span>
<% end %>
</div>
</div>
<!-- Promotion code details grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-green-600">
<%= number_to_currency(promotion_code.discount_amount_cents / 100.0, unit: "€") %>
</div>
<div class="text-sm text-gray-500">Réduction</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-gray-900">
<% if promotion_code.usage_limit %>
<%= promotion_code.usage_limit - promotion_code.uses_count %>
<% else %>
<% end %>
</div>
<div class="text-sm text-gray-500">Restants</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-gray-900">
<%= promotion_code.uses_count %>
</div>
<div class="text-sm text-gray-500">Utilisés</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-blue-600">
<%= promotion_code.orders.count %>
</div>
<div class="text-sm text-gray-500">Commandes</div>
</div>
</div>
<!-- Additional info -->
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
<% if promotion_code.expires_at %>
<span class="flex items-center">
<i data-lucide="clock" class="w-4 h-4 mr-1"></i>
Expire le : <%= l(promotion_code.expires_at, format: :short) %>
</span>
<% else %>
<span class="flex items-center">
<i data-lucide="infinity" class="w-4 h-4 mr-1"></i>
Pas d'expiration
</span>
<% end %>
<span class="flex items-center">
<i data-lucide="user" class="w-4 h-4 mr-1"></i>
<% if promotion_code.user.first_name && promotion_code.user.last_name %>
Créé par : <%= promotion_code.user.first_name %> <%= promotion_code.user.last_name %>
<% else %>
Créé par : <%= promotion_code.user.email %>
<% end %>
</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
<div class="flex items-center space-x-3">
<%= link_to edit_promoter_event_promotion_code_path(@event, promotion_code), class: "text-gray-400 hover:text-blue-600 transition-colors", title: "Modifier" do %>
<i data-lucide="edit" class="w-5 h-5"></i>
<% end %>
<% if promotion_code.orders.empty? %>
<%= button_to promoter_event_promotion_code_path(@event, promotion_code), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer ce code de réduction ?" },
class: "text-gray-400 hover:text-red-600 transition-colors", title: "Supprimer" do %>
<i data-lucide="trash-2" class="w-5 h-5"></i>
<% end %>
<% end %>
</div>
<div class="text-sm text-gray-500">
Créé il y a <%= time_ago_in_words(promotion_code.created_at) %>
</div>
</div>
</div>
<% end %>
</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="tag" class="w-12 h-12 text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Aucun code de réduction</h3>
<p class="text-gray-500 mb-6">Créez des codes de réduction pour offrir des remises spéciales à vos clients.</p>
<%= link_to new_promoter_event_promotion_code_path(@event), 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 mon premier code de réduction
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,96 @@
<% content_for(:title, "Nouveau code de réduction - #{@event.name}") %>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<%= render 'components/breadcrumb', crumbs: [
{ name: 'Accueil', path: root_path },
{ name: 'Tableau de bord', path: dashboard_path },
{ name: 'Mes événements', path: promoter_events_path },
{ name: @event.name, path: promoter_event_path(@event) },
{ name: 'Codes de réduction', path: promoter_event_promotion_codes_path(@event) },
{ name: 'Nouveau code' }
] %>
<div class="max-w-2xl mx-auto">
<div class="mb-8">
<div class="flex items-center space-x-4 mb-4">
<%= link_to promoter_event_promotion_codes_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 class="flex-1">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Nouveau code de réduction</h1>
<p class="text-gray-600">
Pour <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p>
</div>
</div>
</div>
<%= form_with(model: [@event, @promotion_code], url: promoter_event_promotion_codes_path(@event), local: true, class: "bg-white rounded-2xl border border-gray-200 p-6 sm:p-8") do |form| %>
<% if @promotion_code.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-2xl p-4 mb-6">
<div class="flex items-start">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-400 mr-3 mt-0.5 flex-shrink-0"></i>
<div class="flex-1">
<h3 class="text-sm font-medium text-red-800 mb-2">
<%= pluralize(@promotion_code.errors.count, "erreur") %> ont empêché ce code de réduction d'être sauvegardé :
</h3>
<ul class="list-disc list-inside text-sm text-red-700">
<% @promotion_code.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
<% end %>
<div class="space-y-6">
<div>
<%= form.label :code, "Code de réduction", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :code, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: BIENVENUE10, VIP20" %>
<p class="text-sm text-gray-500 mt-2">Ce code sera à appliquer par le client lors du paiement.</p>
</div>
<div>
<%= form.label :discount_amount_euros, "Montant de la réduction (en euros)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :discount_amount_euros, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "10", min: 0, step: "0.01", value: number_with_precision(@promotion_code.discount_amount_cents.to_f / 100, precision: 2) %>
<p class="text-sm text-gray-500 mt-2">Entrez le montant en euros</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :expires_at, "Date d'expiration", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :expires_at, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors" %>
<p class="text-sm text-gray-500 mt-2">Laissez vide pour une durée illimitée</p>
</div>
<div>
<%= form.label :usage_limit, "Limite d'utilisation", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :usage_limit, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: 50", min: 1 %>
<p class="text-sm text-gray-500 mt-2">Laissez vide pour une utilisation illimitée</p>
</div>
</div>
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div class="flex items-center">
<%= form.check_box :active, class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded" %>
<%= form.label :active, "Code actif", class: "ml-3 block text-sm font-medium text-gray-900" %>
</div>
<div class="text-sm text-gray-500">
Les clients peuvent utiliser ce code de réduction
</div>
</div>
</div>
<div class="flex items-center justify-between pt-6 mt-8 border-t border-gray-200">
<%= link_to promoter_event_promotion_codes_path(@event), class: "inline-flex items-center px-6 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="x" class="w-4 h-4 mr-2"></i>
Annuler
<% end %>
<%= form.submit "Créer le code de réduction", 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" %>
</div>
<% end %>
</div>
</div>

View File

@@ -50,4 +50,11 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions. # Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true config.action_controller.raise_on_missing_callback_actions = true
# Configure Stripe for testing
config.stripe = {
publishable_key: "pk_test_test",
secret_key: "sk_test_test",
signing_secret: "whsec_test_test"
}
end end

View File

@@ -75,7 +75,6 @@ Rails.application.routes.draw do
# === Promoter Routes === # === Promoter Routes ===
namespace :promoter do namespace :promoter do
resources :payouts, only: [ :index, :show, :create ]
resources :events do resources :events do
member do member do
patch :publish patch :publish
@@ -92,19 +91,16 @@ Rails.application.routes.draw do
post :duplicate post :duplicate
end end
end end
# Nested promotion codes routes
resources :promotion_codes, except: [ :show ]
end end
end end
# === Administration === # === Promotion Codes Routes ===
namespace :admin do resources :promotion_codes, only: [ :index ] do
resources :payouts, only: [ :index, :show ] do
member do member do
post :approve post :apply
post :reject
post :mark_processing
post :mark_completed
post :mark_failed
end
end end
end end
@@ -125,6 +121,4 @@ Rails.application.routes.draw do
# resources :ticket_types, only: [ :index, :show, :create, :update, :destroy ] # resources :ticket_types, only: [ :index, :show, :create, :update, :destroy ]
end end
end end
post "/webhooks/stripe", to: "webhooks/stripe#create"
end end

View File

@@ -52,14 +52,6 @@ class DeviseCreateUsers < ActiveRecord::Migration[8.0]
# Add onboarding check on user model # Add onboarding check on user model
t.boolean :onboarding_completed, default: false, null: false t.boolean :onboarding_completed, default: false, null: false
# Link user to Stripe account for promoter payout
t.string :stripe_connected_account_id
# Banking information for manual payouts
t.string :iban
t.string :bank_name
t.string :account_holder_name
t.timestamps null: false t.timestamps null: false
end end
@@ -68,7 +60,5 @@ class DeviseCreateUsers < ActiveRecord::Migration[8.0]
# add_index :users, :confirmation_token, unique: true # add_index :users, :confirmation_token, unique: true
# add_index :users, :unlock_token, unique: true # add_index :users, :unlock_token, unique: true
# add_index :users, :stripe_customer_id # 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
end end

View File

@@ -22,16 +22,11 @@ class CreateEvents < ActiveRecord::Migration[8.0]
# Allow ticket sell during the event # Allow ticket sell during the event
t.boolean :allow_booking_during_event, default: false, null: false t.boolean :allow_booking_during_event, default: false, null: false
# Payout fields
t.integer :payout_status, default: 0, null: false
t.datetime :payout_requested_at
t.timestamps t.timestamps
end end
add_index :events, :state add_index :events, :state
add_index :events, :featured add_index :events, :featured
add_index :events, [ :latitude, :longitude ] add_index :events, [ :latitude, :longitude ]
add_index :events, :payout_status
end end
end end

View File

@@ -1,14 +1,15 @@
class CreateOrders < ActiveRecord::Migration[8.0] class CreateOrders < ActiveRecord::Migration[8.0]
def change def change
create_table :orders do |t| create_table :orders do |t|
t.references :user, null: false, foreign_key: false
t.references :event, null: false, foreign_key: false
t.string :status, null: false, default: "draft" t.string :status, null: false, default: "draft"
t.integer :total_amount_cents, null: false, default: 0 t.integer :total_amount_cents, null: false, default: 0
t.integer :payment_attempts, null: false, default: 0 t.integer :payment_attempts, null: false, default: 0
t.timestamp :expires_at t.timestamp :expires_at
t.timestamp :last_payment_attempt_at t.timestamp :last_payment_attempt_at
t.references :user, null: false, foreign_key: false
t.references :event, null: false, foreign_key: false
t.timestamps t.timestamps
end end

View File

@@ -17,6 +17,5 @@ class CreateTickets < ActiveRecord::Migration[8.0]
end end
add_index :tickets, :qr_code, unique: true add_index :tickets, :qr_code, unique: true
add_index :tickets, [ :status, :order_id ]
end end
end end

View File

@@ -1,20 +0,0 @@
class CreateEarnings < ActiveRecord::Migration[8.0]
def change
create_table :earnings do |t|
t.integer :amount_cents
t.integer :fee_cents
t.integer :status
t.string :stripe_payout_id
t.integer :net_amount_cents
t.references :event, null: false, foreign_key: false, index: true
t.references :user, null: false, foreign_key: false, index: true
t.references :order, null: false, foreign_key: false, index: true
t.timestamps
end
add_index :earnings, :status
end
end

View File

@@ -1,27 +0,0 @@
class CreatePayouts < ActiveRecord::Migration[8.0]
def change
create_table :payouts do |t|
t.integer :amount_cents, null: false
t.integer :fee_cents, null: false, default: 0
t.integer :status, null: false, default: 0
t.string :stripe_payout_id
t.integer :total_orders_count, null: false, default: 0
t.integer :refunded_orders_count, null: false, default: 0
t.references :user, null: false, foreign_key: false
t.references :event, null: false, foreign_key: false
# Manual processing fields
t.references :processed_by, null: true, foreign_key: { to_table: :users }
t.datetime :processed_at
t.text :rejection_reason
t.string :bank_transfer_reference
t.timestamps
end
add_index :payouts, :status
add_index :payouts, :stripe_payout_id, unique: true
add_index :payouts, [ :event_id, :status ]
end
end

View File

@@ -0,0 +1,21 @@
class CreatePromotionCodes < ActiveRecord::Migration[8.0]
def change
create_table :promotion_codes do |t|
t.string :code, null: false
t.integer :discount_amount_cents, null: false, default: 0
t.datetime :expires_at
t.boolean :active, default: true, null: false
t.integer :usage_limit, default: nil
t.integer :uses_count, default: 0, null: false
# Reference user(promoter) who has created the promotion code
t.references :user, null: false, foreign_key: true
t.references :event, null: false, foreign_key: true
t.timestamps
end
# Unique index for code
add_index :promotion_codes, :code, unique: true
end
end

View File

@@ -0,0 +1,10 @@
class CreateOrderPromotionCodes < ActiveRecord::Migration[8.0]
def change
create_table :order_promotion_codes do |t|
t.references :order, null: false, foreign_key: true
t.references :promotion_code, null: false, foreign_key: true
t.timestamps
end
end
end

View File

@@ -0,0 +1,57 @@
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :key ], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
create_table :active_storage_variant_records, id: primary_key_type do |t|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
end
end

View File

@@ -0,0 +1,4 @@
class AddImageToEvents < ActiveRecord::Migration[8.0]
def change
end
end

96
db/schema.rb generated
View File

@@ -10,22 +10,33 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do ActiveRecord::Schema[8.0].define(version: 2025_09_29_222616) do
create_table "earnings", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.integer "amount_cents" t.string "name", null: false
t.integer "fee_cents" t.string "record_type", null: false
t.integer "status" t.bigint "record_id", null: false
t.string "stripe_payout_id" t.bigint "blob_id", null: false
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 "created_at", null: false
t.datetime "updated_at", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["event_id"], name: "index_earnings_on_event_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
t.index ["order_id"], name: "index_earnings_on_order_id" end
t.index ["status"], name: "index_earnings_on_status"
t.index ["user_id"], name: "index_earnings_on_user_id" create_table "active_storage_blobs", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.string "key", null: false
t.string "filename", null: false
t.string "content_type"
t.text "metadata"
t.string "service_name", null: false
t.bigint "byte_size", null: false
t.string "checksum"
t.datetime "created_at", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "active_storage_variant_records", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.bigint "blob_id", null: false
t.string "variation_digest", null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end end
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
@@ -42,26 +53,32 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
t.decimal "longitude", precision: 10, scale: 6, null: false t.decimal "longitude", precision: 10, scale: 6, null: false
t.boolean "featured", default: false, null: false t.boolean "featured", default: false, null: false
t.bigint "user_id", null: false t.bigint "user_id", null: false
t.boolean "allow_booking_during_event", default: false, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.boolean "allow_booking_during_event", default: false, null: false
t.integer "payout_status", default: 0, null: false
t.datetime "payout_requested_at"
t.index ["featured"], name: "index_events_on_featured" t.index ["featured"], name: "index_events_on_featured"
t.index ["latitude", "longitude"], name: "index_events_on_latitude_and_longitude" 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 ["state"], name: "index_events_on_state"
t.index ["user_id"], name: "index_events_on_user_id" t.index ["user_id"], name: "index_events_on_user_id"
end end
create_table "order_promotion_codes", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.bigint "order_id", null: false
t.bigint "promotion_code_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["order_id"], name: "index_order_promotion_codes_on_order_id"
t.index ["promotion_code_id"], name: "index_order_promotion_codes_on_promotion_code_id"
end
create_table "orders", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "orders", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "event_id", null: false
t.string "status", default: "draft", null: false t.string "status", default: "draft", null: false
t.integer "total_amount_cents", default: 0, null: false t.integer "total_amount_cents", default: 0, null: false
t.integer "payment_attempts", default: 0, null: false t.integer "payment_attempts", default: 0, null: false
t.timestamp "expires_at" t.timestamp "expires_at"
t.timestamp "last_payment_attempt_at" t.timestamp "last_payment_attempt_at"
t.bigint "user_id", null: false
t.bigint "event_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["event_id", "status"], name: "idx_orders_event_status" t.index ["event_id", "status"], name: "idx_orders_event_status"
@@ -71,27 +88,20 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
t.index ["user_id"], name: "index_orders_on_user_id" t.index ["user_id"], name: "index_orders_on_user_id"
end end
create_table "payouts", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "promotion_codes", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.integer "amount_cents", null: false t.string "code", null: false
t.integer "fee_cents", default: 0, null: false t.integer "discount_amount_cents", default: 0, null: false
t.integer "status", default: 0, null: false t.datetime "expires_at"
t.string "stripe_payout_id" t.boolean "active", default: true, null: false
t.integer "total_orders_count", default: 0, null: false t.integer "usage_limit"
t.integer "refunded_orders_count", default: 0, null: false t.integer "uses_count", default: 0, null: false
t.bigint "user_id", null: false t.bigint "user_id", null: false
t.bigint "event_id", null: false t.bigint "event_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.bigint "processed_by_id" t.index ["code"], name: "index_promotion_codes_on_code", unique: true
t.datetime "processed_at" t.index ["event_id"], name: "index_promotion_codes_on_event_id"
t.text "rejection_reason" t.index ["user_id"], name: "index_promotion_codes_on_user_id"
t.string "bank_transfer_reference"
t.index ["event_id", "status"], name: "index_payouts_on_event_id_and_status"
t.index ["event_id"], name: "index_payouts_on_event_id"
t.index ["processed_by_id"], name: "index_payouts_on_processed_by_id"
t.index ["status"], name: "index_payouts_on_status"
t.index ["stripe_payout_id"], name: "index_payouts_on_stripe_payout_id", unique: true
t.index ["user_id"], name: "index_payouts_on_user_id"
end end
create_table "ticket_types", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "ticket_types", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
@@ -123,7 +133,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["order_id"], name: "index_tickets_on_order_id" t.index ["order_id"], name: "index_tickets_on_order_id"
t.index ["qr_code"], name: "index_tickets_on_qr_code", unique: true t.index ["qr_code"], name: "index_tickets_on_qr_code", unique: true
t.index ["status", "order_id"], name: "index_tickets_on_status_and_order_id"
t.index ["ticket_type_id"], name: "index_tickets_on_ticket_type_id" t.index ["ticket_type_id"], name: "index_tickets_on_ticket_type_id"
end end
@@ -145,12 +154,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
t.boolean "onboarding_completed", default: false, null: false t.boolean "onboarding_completed", default: false, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "iban"
t.string "bank_name"
t.string "account_holder_name"
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end end
add_foreign_key "payouts", "users", column: "processed_by_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "order_promotion_codes", "orders"
add_foreign_key "order_promotion_codes", "promotion_codes"
add_foreign_key "promotion_codes", "events"
add_foreign_key "promotion_codes", "users"
end end

View File

@@ -44,7 +44,7 @@ events_data = [
start_time: 1.day.from_now, start_time: 1.day.from_now,
end_time: 1.day.from_now + 6.hours, end_time: 1.day.from_now + 6.hours,
featured: true, featured: true,
image: "https://fastly.picsum.photos/id/407/300/200.jpg?hmac=9EhoXMZ1QdwJue90vzxcjBg2YzsZsAWCjJ7oxOhtcU0", image_url: "https://fastly.picsum.photos/id/407/300/200.jpg?hmac=9EhoXMZ1QdwJue90vzxcjBg2YzsZsAWCjJ7oxOhtcU0",
user: users.first user: users.first
}, },
{ {
@@ -58,7 +58,7 @@ events_data = [
start_time: 3.days.from_now, start_time: 3.days.from_now,
end_time: 3.days.from_now + 4.hours, end_time: 3.days.from_now + 4.hours,
featured: true, featured: true,
image: "https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", image_url: "https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
user: users.second user: users.second
}, },
{ {
@@ -72,7 +72,7 @@ events_data = [
start_time: 1.week.from_now, start_time: 1.week.from_now,
end_time: 1.week.from_now + 8.hours, end_time: 1.week.from_now + 8.hours,
featured: false, featured: false,
image: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", image_url: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
user: users.third user: users.third
} }
] ]
@@ -122,157 +122,6 @@ promoter = User.find_or_create_by!(email: "kbataille@vivaldi.net") do |u|
u.is_professionnal = true u.is_professionnal = true
end end
# Create a completed event with earnings for payout demonstration
completed_event_promoter = User.find_or_create_by!(email: "kbataille@vivaldi.net") do |u|
u.password = "password"
u.password_confirmation = "password"
u.first_name = "Event"
u.last_name = "Promoter"
u.is_professionnal = true
# Ensure the promoter has a Stripe account for payouts
u.stripe_connected_account_id = "acct_test_payout_account" unless u.stripe_connected_account_id.present?
end
completed_event = Event.find_or_create_by!(name: "Completed Music Festival") do |e|
e.slug = "completed-music-festival"
e.state = :published
e.description = "An amazing music festival that has already taken place."
e.venue_name = "Central Park"
e.venue_address = "Central Park, New York, NY"
e.latitude = 40.7812
e.longitude = -73.9665
# Set the event to have ended 2 days ago
e.start_time = 2.days.ago
e.end_time = 2.days.ago + 8.hours
e.featured = false
e.image = "https://data.bizouk.com/cache1/events/images/10/78/87/b801a9a43266b4cc54bdda73bf34eec8_700_800_auto_97.jpg"
e.user = completed_event_promoter
# Ensure payout status is pending_request
e.payout_status = :pending_request
end
# Create ticket types for the completed event
general_ticket_type = TicketType.find_or_create_by!(event: completed_event, name: "General Admission") do |tt|
tt.description = "General admission ticket for the Completed Music Festival"
tt.price_cents = 5000 # $50.00
tt.quantity = 200
tt.sale_start_at = 1.month.ago
tt.sale_end_at = completed_event.start_time - 1.hour
tt.minimum_age = 18
end
vip_ticket_type = TicketType.find_or_create_by!(event: completed_event, name: "VIP") do |tt|
tt.description = "VIP access ticket for the Completed Music Festival"
tt.price_cents = 15000 # $150.00
tt.quantity = 50
tt.sale_start_at = 1.month.ago
tt.sale_end_at = completed_event.start_time - 1.hour
tt.minimum_age = 21
end
# Create some orders and tickets for the completed event to generate earnings
buyer_user = User.find_or_create_by!(email: "buyer@example.com") do |u|
u.password = "password"
u.password_confirmation = "password"
u.first_name = "Ticket"
u.last_name = "Buyer"
end
# Create multiple orders with different statuses to demonstrate the payout system
# Order 1: Paid order with general admission tickets
order1 = Order.find_or_create_by!(user: buyer_user, event: completed_event) do |o|
o.status = "paid"
o.total_amount_cents = 15000 # $150.00 for 3 general admission tickets ($50.00 each)
end
# Create tickets for order 1
3.times do |i|
Ticket.find_or_create_by!(order: order1, ticket_type: general_ticket_type) do |t|
t.qr_code = "ORDER1-TICKET#{i + 1}"
t.price_cents = 5000 # $50.00
t.status = "active"
t.first_name = "Attendee"
t.last_name = "#{i + 1}"
end
end
# Calculate platform fees using the actual model: €0.50 + 1.5% per ticket
# For 3 tickets at $50.00 each:
# Fixed fee: 3 tickets × $0.50 = $1.50 (150 cents)
# Percentage fee: 3 tickets × ($50.00 × 1.5%) = 3 × $0.75 = $2.25 (225 cents)
# Total platform fee: $1.50 + $2.25 = $3.75 (375 cents)
# Promoter payout: $150.00 - $3.75 = $146.25 (14625 cents)
# Create earnings for this paid order (this would normally happen automatically)
Earning.find_or_create_by!(event: completed_event, user: completed_event_promoter, order: order1) do |e|
e.amount_cents = 14625 # $146.25 (promoter payout after fees)
e.fee_cents = 375 # $3.75 platform fee
e.status = "pending"
end
# Order 2: Paid order with VIP tickets
order2 = Order.find_or_create_by!(user: buyer_user, event: completed_event) do |o|
o.status = "paid"
o.total_amount_cents = 30000 # $300.00 for 2 VIP tickets ($150.00 each)
end
# Create tickets for order 2
2.times do |i|
Ticket.find_or_create_by!(order: order2, ticket_type: vip_ticket_type) do |t|
t.qr_code = "ORDER2-TICKET#{i + 1}"
t.price_cents = 15000 # $150.00
t.status = "active"
t.first_name = "VIP"
t.last_name = "Attendee #{i + 1}"
end
end
# Calculate platform fees using the actual model: €0.50 + 1.5% per ticket
# For 2 tickets at $150.00 each:
# Fixed fee: 2 tickets × $0.50 = $1.00 (100 cents)
# Percentage fee: 2 tickets × ($150.00 × 1.5%) = 2 × $2.25 = $4.50 (450 cents)
# Total platform fee: $1.00 + $4.50 = $5.50 (550 cents)
# Promoter payout: $300.00 - $5.50 = $294.50 (29450 cents)
# Create earnings for this paid order (this would normally happen automatically)
Earning.find_or_create_by!(event: completed_event, user: completed_event_promoter, order: order2) do |e|
e.amount_cents = 29450 # $294.50 (promoter payout after fees)
e.fee_cents = 550 # $5.50 platform fee
e.status = "pending"
end
# Order 3: Refunded order to demonstrate that refunded tickets are excluded
order3 = Order.find_or_create_by!(user: buyer_user, event: completed_event) do |o|
o.status = "paid"
o.total_amount_cents = 5000 # $50.00 for 1 general admission ticket
end
# Create ticket for order 3 (will be refunded)
refunded_ticket = Ticket.find_or_create_by!(order: order3, ticket_type: general_ticket_type) do |t|
t.qr_code = "ORDER3-TICKET1"
t.price_cents = 5000 # $50.00
t.status = "refunded" # This ticket was refunded
t.first_name = "Refunded"
t.last_name = "Customer"
end
# Calculate platform fees using the actual model: €0.50 + 1.5% per ticket
# For 1 ticket at $50.00:
# Fixed fee: 1 ticket × $0.50 = $0.50 (50 cents)
# Percentage fee: 1 ticket × ($50.00 × 1.5%) = $0.75 (75 cents)
# Total platform fee: $0.50 + $0.75 = $1.25 (125 cents)
# Promoter payout: $50.00 - $1.25 = $48.75 (4875 cents)
# Create earnings for this refunded order (this would normally happen automatically)
Earning.find_or_create_by!(event: completed_event, user: completed_event_promoter, order: order3) do |e|
e.amount_cents = 4875 # $48.75 (promoter payout after fees)
e.fee_cents = 125 # $1.25 platform fee
e.status = "pending"
end
puts "Created 1 completed event with sample orders and earnings for payout demonstration"
belle_epoque_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PAR SISLEY ÉVENTS") do |e| 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.slug = "la-belle-epoque-par-sisley-events"
e.state = :draft e.state = :draft
@@ -298,7 +147,7 @@ belle_epoque_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PAR SISLEY
e.start_time = 3.days.from_now e.start_time = 3.days.from_now
e.end_time = 3.days.from_now + 8.hours e.end_time = 3.days.from_now + 8.hours
e.featured = false e.featured = false
e.image = "https://data.bizouk.com/cache1/events/images/10/78/87/b801a9a43266b4cc54bdda73bf34eec8_700_800_auto_97.jpg" e.image_url = "https://data.bizouk.com/cache1/events/images/10/78/87/b801a9a43266b4cc54bdda73bf34eec8_700_800_auto_97.jpg"
e.user = promoter e.user = promoter
e.allow_booking_during_event = true e.allow_booking_during_event = true
end end
@@ -307,7 +156,7 @@ belle_epoque_event.update!(start_time: 3.days.from_now, end_time: 3.days.from_no
# Create ticket types for "La belle époque" event # Create ticket types for "La belle époque" event
belle_epoque_event = Event.find_by!(slug: "la-belle-epoque-par-sisley-events") belle_epoque_event = Event.find_by!(slug: "la-belle-epoque-par-sisley-events-le-patio-rooftop-montreuil")
TicketType.find_or_create_by!(event: belle_epoque_event, name: "Free invitation valid before 7 p.m.") do |tt| 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.description = "Free invitation ticket valid before 7 p.m. for La Belle Époque"
@@ -340,3 +189,117 @@ TicketType.find_or_create_by!(event: belle_epoque_event, name: "Paid Entry 10€
end end
puts "Created 1 promoter, 1 draft event with ticket types" puts "Created 1 promoter, 1 draft event with ticket types"
# Create additional events fetched from Bizouk
konpa_event = Event.find_or_create_by!(name: "Konpa With Bev - Cours De Konpa Gouyad") do |e|
e.slug = "konpa-with-bev-cours-de-konpa-gouyad"
e.description = "Séance ouverte à tous, débutant ou initié, venez perfectionner votre Konpa avec la talentueuse Beverly."
e.venue_name = "Guest Live"
e.venue_address = "36 Rue Marcel Dassault, 93140 Bondy"
e.latitude = 48.9096
e.longitude = 2.4836
e.start_time = Time.parse("2025-10-03 19:00:00")
e.end_time = Time.parse("2025-10-03 23:00:00")
e.featured = false
e.image_url = "https://data.bizouk.com/cache1/events/images/10/79/61/081f38b583ac651f3a0930c5d8f13458_800_600_auto_97.png"
e.user = promoter
e.state = :published
end
caribbean_groove_event = Event.find_or_create_by!(name: "La Plus Grosse Soirée Caribbean Groove") do |e|
e.slug = "la-plus-grosse-soiree-caribbean-groove"
e.description = "La CARIBBEAN GROOVE de 23H00 à 5h00... DJ DON BREEZY Aux Platines HIPHOP , RnB, zouk ,kompa , Dancehall, Afro.beat"
e.venue_name = "LE TOUT LE MONDE EN PARLE"
e.venue_address = "4 RUE DU DEPART 75015 PARIS"
e.latitude = 48.8406
e.longitude = 2.2935
e.start_time = Time.parse("2025-10-03 23:00:00")
e.end_time = Time.parse("2025-10-04 05:00:00")
e.featured = false
e.image_url = "https://data.bizouk.com/cache1/events/images/10/83/15/fa5d43f0b1998f691181cfda8fe35213_800_600_auto_97.png"
e.user = promoter
e.state = :published
end
belle_epoque_october_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PAR SISLEY ÉVENTS - OCTOBRE") do |e|
e.slug = "la-belle-epoque-par-sisley-events-octobre"
e.description = "SAM 4 OCTOBRE LA BELLE ÉPOQUE de 18H à 2H sur le Rooftop LE PATIO LA Dernière de la Saison É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 ! ENTRÉE LIBRE POUR TOUS AV 21H TARIF D'ENTRÉE : 10€ SUR PLACE UNIQUEMENT Réservée aux + de 30 ans Suivez nous sur Instagram : Sisley Évents"
e.venue_name = "Le Patio"
e.venue_address = "38 Avenue Leon Gaumont, 93100 Montreuil"
e.latitude = 48.862336
e.longitude = 2.441218
e.start_time = Time.parse("2025-10-04 18:00:00")
e.end_time = Time.parse("2025-10-05 02:00:00")
e.featured = false
e.image_url = "https://data.bizouk.com/cache1/events/images/10/92/72/351e61b55603a4d142b43486216457c1_800_600_auto_97.jpg"
e.user = promoter
e.state = :published
e.allow_booking_during_event = true
end
# Create ticket types for the new events
# Konpa event ticket types
TicketType.find_or_create_by!(event: konpa_event, name: "General Admission") do |tt|
tt.description = "General admission ticket for Konpa With Bev"
tt.price_cents = 1500 # $15.00
tt.quantity = 50
tt.sale_start_at = Time.current
tt.sale_end_at = konpa_event.start_time - 1.hour
tt.minimum_age = 18
end
# Caribbean Groove event ticket types
TicketType.find_or_create_by!(event: caribbean_groove_event, name: "General Admission") do |tt|
tt.description = "General admission ticket for Caribbean Groove"
tt.price_cents = 2000 # $20.00
tt.quantity = 100
tt.sale_start_at = Time.current
tt.sale_end_at = caribbean_groove_event.start_time - 1.hour
tt.minimum_age = 18
end
# Belle Époque October event ticket types
TicketType.find_or_create_by!(event: belle_epoque_october_event, name: "Free Entry Before 9 PM") do |tt|
tt.description = "Free entry before 9 PM for La Belle Époque October"
tt.price_cents = 0
tt.quantity = 50
tt.sale_start_at = Time.current
tt.sale_end_at = belle_epoque_october_event.start_time + 3.hours
tt.minimum_age = 30
tt.requires_id = true
end
TicketType.find_or_create_by!(event: belle_epoque_october_event, name: "Entry 10€ After 9 PM") do |tt|
tt.description = "Entry ticket 10€ after 9 PM for La Belle Époque October"
tt.price_cents = 1000 # 10€
tt.quantity = 150
tt.sale_start_at = Time.current
tt.sale_end_at = belle_epoque_october_event.start_time + 8.hours
tt.minimum_age = 30
tt.requires_id = true
end
puts "Created 3 additional events from Bizouk with ticket types"
# Create promotion codes for events
# Promotion code for belle_epoque_event
PromotionCode.find_or_create_by!(code: "BELLE10") do |pc|
pc.discount_amount_cents = 1000 # 10€ discount
pc.expires_at = belle_epoque_event.start_time + 1.day
pc.active = true
pc.usage_limit = 20
pc.user = promoter
pc.event = belle_epoque_october_event
end
# Promotion code for belle_epoque_october_event
PromotionCode.find_or_create_by!(code: "OCTOBRE5") do |pc|
pc.discount_amount_cents = 500 # 5€ discount
pc.expires_at = belle_epoque_october_event.start_time + 1.day
pc.active = true
pc.usage_limit = 30
pc.user = promoter
pc.event = belle_epoque_october_event
end
puts "Created promotion codes for events"

74
debug_promotion_test.rb Executable file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env ruby
# Debug script to understand the test failure
require_relative './config/environment'
# Load test data
user = User.find_by(email: 'user1@example.com')
event = Event.find_by(name: 'Summer Concert')
puts "User: #{user.inspect}"
puts "Event: #{event.inspect}"
# Create a new order for the test
order = user.orders.create!(event: event, status: "draft", expires_at: 15.minutes.from_now, total_amount_cents: 2000)
puts "Order: #{order.inspect}"
# Create ticket type and ticket
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 2000,
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: event
)
ticket = Ticket.create!(
order: order,
ticket_type: ticket_type,
status: "draft",
first_name: "John",
last_name: "Doe",
price_cents: 2000
)
puts "Ticket: #{ticket.inspect}"
puts "Ticket valid?: #{ticket.valid?}"
puts "Order tickets count: #{order.tickets.count}"
# Recalculate the order total
order.calculate_total!
puts "Order total: #{order.total_amount_cents}"
# Create a unique promotion code
unique_code = "TESTDISCOUNT_#{SecureRandom.hex(4)}"
puts "Creating promotion code with code: #{unique_code}"
promotion_code = PromotionCode.create(
code: unique_code,
discount_amount_cents: 500,
expires_at: 1.month.from_now,
active: true,
user: user,
event: event
)
puts "Promotion code: #{promotion_code.inspect}"
puts "Promotion code valid?: #{promotion_code.valid?}"
# Check if order already has promotion codes
puts "Order promotion codes before: #{order.promotion_codes.count}"
# Try to apply the promotion code
begin
order.promotion_codes << promotion_code
puts "Successfully added promotion code to order"
rescue => e
puts "Error adding promotion code: #{e.message}"
puts e.backtrace.first(5)
end
puts "Order promotion codes after: #{order.promotion_codes.count}"

67
debug_test.rb Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env ruby
# Debug script to understand the test failure
require_relative './config/environment'
# Load test data
user = User.find_by(email: 'user1@example.com')
event = Event.find_by(name: 'Summer Concert')
order = Order.find_by(status: 'draft')
puts "User: #{user.inspect}"
puts "Event: #{event.inspect}"
puts "Order: #{order.inspect}"
# Check if the user can manage events
puts "User can manage events: #{user.can_manage_events?}"
# Create a promotion code
promotion_code = PromotionCode.create(
code: "TESTDISCOUNT",
discount_amount_cents: 500,
expires_at: 1.month.from_now,
active: true,
user: user,
event: event
)
puts "Promotion code: #{promotion_code.inspect}"
puts "Promotion code valid?: #{promotion_code.valid_for_use?}"
# Try to create a ticket type and ticket
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 2000,
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: event
)
puts "Ticket type: #{ticket_type.inspect}"
# Create ticket with all required fields
ticket = Ticket.create!(
order: order,
ticket_type: ticket_type,
status: "draft",
first_name: "John",
last_name: "Doe",
price_cents: 2000
)
puts "Ticket: #{ticket.inspect}"
puts "Ticket valid?: #{ticket.valid?}"
puts "Ticket errors: #{ticket.errors.full_messages}" unless ticket.valid?
# Recalculate order total
order.calculate_total!
puts "Order total: #{order.total_amount_cents}"
# Test the promotion code application
puts "Applying promotion code..."
order.promotion_codes << promotion_code
order.calculate_total!
puts "Order total after promotion: #{order.total_amount_cents}"

View File

@@ -0,0 +1,65 @@
volumes:
mariadb_data:
gem_home:
node_modules_home:
networks:
default:
services:
mariadb:
image: mariadb:11.7.2-noble
env_file: .env
restart: unless-stopped
volumes:
- mariadb_data:/var/lib/mysql
#- ./mysql:/var/lib/mysql"
ports:
- "${FORWARD_DB_PORT:-3306}:3306"
environment:
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
# MYSQL_ROOT_HOST: "%"
MYSQL_PORT: "${DB_PORT:-3306}"
MYSQL_DATABASE: "${DB_DATABASE:-aperonight}"
MYSQL_USER: "${DB_USERNAME:-aperonight}"
MYSQL_PASSWORD: "${DB_PASSWORD:-aperonight}"
#MYSQL_ALLOW_EMPTY_PASSWORD: 1
networks:
- default
#command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
rails:
build:
context: .
dockerfile: Dockerfile.production
env_file: .env
restart: unless-stopped
volumes:
- ./:/rails
- gem_home:/usr/local/bundle
- node_modules_home:/rails/node_modules
#- ./log:/var/log
#- ./log/supervisor:/var/log/supervisor
ports:
#- 80:80
- 3000:3000
#- 5000:5000
depends_on:
- mariadb
networks:
- default
#tty: true
#command: /opt/src/bin/dev
mailhog:
image: corpusops/mailhog:v1.0.1
restart: unless-stopped
# environment:
# - "mh_auth_file=/opt/mailhog/passwd.conf"
volumes:
- ./data/mailhog:/opt/mailhog
ports:
- 1025:1025 # smtp port 25
- 8025:8025 # web mail access
networks:
- default

View File

@@ -1,157 +0,0 @@
# Manual Payout Workflow
## Overview
This document describes the manual payout system implemented to replace Stripe Global Payouts, which is not available in France. The system allows promoters to request payouts for their events, and administrators to process these requests manually through bank transfers.
## Workflow Steps
### 1. Promoter Requests Payout
- Promoters can request payouts for ended events through the existing interface
- The system validates that banking information is complete before allowing requests
- Payout status is set to `pending`
### 2. Admin Review (Pending → Approved/Rejected)
**Admin Actions Available:**
- **Approve**: If all information is correct and banking details are valid
- **Reject**: If there are issues (missing info, invalid details, policy violations)
**What Admins Check:**
- Banking information completeness (IBAN, bank name, account holder)
- Event validity and earnings calculation
- Promoter eligibility
### 3. Manual Bank Transfer (Approved → Processing)
**Admin Actions:**
- **Mark as Processing**: When bank transfer is initiated
- Optional: Add transfer reference number
- Admin manually processes SEPA transfer through their banking system
### 4. Transfer Completion (Processing → Completed/Failed)
**Admin Actions:**
- **Mark as Completed**: When transfer is confirmed successful
- **Mark as Failed**: If transfer fails or is rejected by bank
- Update transfer reference if needed
## Banking Information Requirements
### For Promoters
Users must provide:
- **IBAN**: Valid IBAN format (validated by regex)
- **Bank Name**: Name of the banking institution
- **Account Holder Name**: Full name matching bank account
### IBAN Validation
- Basic format validation implemented
- Supports standard European IBAN format
- Regex: `/\A[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}\z/`
## Database Schema Changes
### New User Fields
```ruby
add_column :users, :iban, :string
add_column :users, :bank_name, :string
add_column :users, :account_holder_name, :string
```
### New Payout Fields
```ruby
add_reference :payouts, :processed_by, foreign_key: { to_table: :users }
add_column :payouts, :processed_at, :datetime
add_column :payouts, :rejection_reason, :text
add_column :payouts, :bank_transfer_reference, :string
```
### Updated Payout Statuses
```ruby
enum :status, {
pending: 0, # Payout requested but not reviewed
approved: 1, # Payout approved by admin, ready for transfer
processing: 2, # Payout being processed (bank transfer initiated)
completed: 3, # Payout successfully completed
failed: 4, # Payout failed
rejected: 5 # Payout rejected by admin
}
```
## Admin Interface
### Dashboard Sections
1. **Pending Review**: New requests requiring admin approval/rejection
2. **Approved**: Ready for manual bank transfer
3. **Processing**: Transfers in progress
4. **Recently Completed**: Completed transfers for reference
### Transfer Information Display
- Promoter banking details
- Transfer amount and reference
- Event information
- Validation warnings for incomplete banking info
## Security & Audit
### Audit Trail
- All status changes tracked with timestamp
- Admin user recorded for each action
- Transfer references stored for bank reconciliation
### Validation
- Banking information validated before approval
- IBAN format checking
- Complete information required before processing
## Migration from Stripe
### Immediate Changes
- ✅ Stripe Transfer functionality disabled
- ✅ Manual workflow implemented
- ✅ Banking information collection added
- ✅ Admin interface updated
### Legacy Support
- Original `PayoutService#process!` method redirects to manual workflow
- Existing payout request flow preserved for promoters
- Database backward compatible
## Usage Instructions
### For Administrators
1. Access admin payout dashboard at `/admin/payouts`
2. Review pending payouts for approval
3. For approved payouts, initiate bank transfers manually
4. Update payout status as transfers progress
5. Mark as completed when transfer is confirmed
### For Promoters
1. Ensure banking information is complete in profile
2. Request payouts for ended events as before
3. Monitor payout status through existing interface
4. Banking information must be valid IBAN format
## Error Handling
### Common Issues
- **Incomplete Banking Info**: Prevents approval until resolved
- **Invalid IBAN**: Validation error displayed to admin
- **Transfer Failures**: Can be marked as failed with reason
### Recovery
- Failed payouts can be retried after fixing issues
- Rejected payouts require new requests
- Banking information can be updated by promoters
## Future Enhancements
### Potential Improvements
1. Integration with banking APIs for automated transfers
2. Enhanced IBAN validation with checksum verification
3. Email notifications for status changes
4. Bulk transfer processing
5. Advanced reporting and reconciliation tools
### France-Specific Considerations
1. SEPA transfer compliance
2. Tax reporting requirements
3. AML/KYC documentation
4. Banking regulation compliance

View File

@@ -1,57 +0,0 @@
# Manual Payouts for French Users
## Overview
Due to regulatory restrictions, Stripe Global Payouts are not available in France. As a result, we've implemented a manual payout system using SEPA (Single Euro Payments Area) bank transfers for French promoters.
## How It Works
### 1. Payout Request
- When a French promoter's event ends and they have earnings, they can request a payout through their dashboard
- The system creates a payout record with status "pending"
### 2. Admin Processing
- Admins can view pending payouts in the admin panel
- For French users, the "Process Payout" button automatically marks the payout as a SEPA transfer
- Admins can also manually mark a payout as a SEPA transfer using the "Mark as SEPA Transfer" button
### 3. SEPA Transfer
- When a payout is marked as a SEPA transfer, the system:
- Updates the payout status to "completed"
- Generates a unique SEPA transfer ID
- Updates all related earnings to "paid" status
- Admins then manually process the bank transfer outside the application using SEPA
### 4. Promoter Notification
- Promoters receive a notification that their payout is being processed via SEPA transfer
- They are informed that funds should appear in their account within 1-3 business days
## Technical Implementation
### Payout Model
- `manual_payout?` method checks if the payout is a SEPA transfer (ID starts with "SEPA_")
- `sepa_transfer?` method is an alias for `manual_payout?`
- `mark_as_manually_processed!` method generates a SEPA transfer ID
### Payout Service
- `should_process_manually?` method is hardcoded to return true for French users
- `process_manually!` method handles the SEPA transfer process
### Views
- Admin and promoter views display "SEPA Transfer" instead of "Manual Transfer"
- Specific messaging about SEPA transfer timelines is shown to promoters
## Testing
The manual payout system is thoroughly tested with:
- Unit tests for the payout model methods
- Service tests for the payout processing logic
- Controller tests for the admin interface
- Integration tests for the end-to-end flow
## Future Improvements
- Automatically detect user country from their address or IP
- Integrate with a SEPA transfer API for automated bank transfers
- Add email notifications for payout status changes
- Implement a dashboard for admins to track SEPA transfers

View File

@@ -1,112 +0,0 @@
# Payout System Analysis Report
## Current Implementation Overview
The current payout system implemented on the `feat/stripe-global-payouts` branch uses **Stripe Connect** with **Stripe Transfers** to automatically process payouts to promoters. This implementation is **not compatible with France** as it relies on Stripe Global Payouts functionality.
## Architecture Analysis
### Core Components
1. **Models**
- `Payout`: Tracks payout requests with statuses (pending, processing, completed, failed)
- `User`: Contains `stripe_connected_account_id` for Stripe Connect integration
- `Event`: Has payout eligibility and earnings calculation methods
- `Earning`: Tracks individual order earnings (referenced in docs but may not be fully implemented)
2. **Services**
- `PayoutService` (`app/services/payout_service.rb:13-19`): Processes payouts via `Stripe::Transfer.create`
- `StripeConnectService`: Manages Stripe Connect account setup
3. **Controllers**
- `Promoter::PayoutsController`: Handles promoter payout requests and viewing
- `Admin::PayoutsController`: Handles admin payout processing
### Current Payout Flow
1. **Promoter Request**: Promoter requests payout for ended event via `Promoter::PayoutsController#create`
2. **Admin Processing**: Admin processes payout via `Admin::PayoutsController#process`
3. **Stripe Transfer**: `PayoutService` creates `Stripe::Transfer` to promoter's connected account
4. **Status Update**: Payout status updated to completed/failed based on Stripe response
### Key Issues for France
1. **Stripe Global Payouts Dependency**: The system uses `Stripe::Transfer.create` with `destination: stripe_connected_account_id` which requires Stripe Global Payouts
2. **Stripe Connect Requirement**: Users must have verified Stripe Connect accounts (`stripe_connected_account_id`)
3. **Automatic Processing**: System assumes automated Stripe processing capability
## Database Schema
### Payouts Table
- `amount_cents`: Gross payout amount
- `fee_cents`: Platform fees
- `status`: Enum (pending, processing, completed, failed)
- `stripe_payout_id`: Stripe transfer ID
- `total_orders_count`: Number of orders included
- `refunded_orders_count`: Number of refunded orders
- `user_id`: Promoter receiving payout
- `event_id`: Event for which payout is requested
### Users Table (Relevant Fields)
- `stripe_connected_account_id`: Stripe Connect account ID
- `is_professionnal`: Required for event management
## Compliance and Legal Considerations
### France-Specific Issues
1. **Stripe Global Payouts**: Not available in France as of current analysis
2. **Banking Regulations**: May require different approach for cross-border transfers
3. **Tax Reporting**: Manual payouts may require additional documentation
### Alternative Approaches Needed
1. **Manual Bank Transfers**: Admin-initiated SEPA transfers
2. **Payout Request System**: Promoters request, admins approve and process manually
3. **Documentation**: Enhanced record-keeping for manual transfers
## Recommendations
### Immediate Actions Required
1. **Disable Automatic Processing**: Remove Stripe Transfer functionality
2. **Implement Manual Workflow**: Create admin interface for manual payout processing
3. **Add Banking Information**: Collect IBAN/SWIFT details from promoters
4. **Update Status Flow**: Modify payout statuses for manual processing
### Proposed Manual Payout System
1. **Request Phase**: Promoters submit payout requests (existing functionality can be kept)
2. **Review Phase**: Admins review and approve requests
3. **Processing Phase**: Admins mark as "processing" and initiate bank transfer
4. **Completion Phase**: Admins confirm transfer completion manually
## Migration Strategy
### Phase 1: Immediate Fix
- Disable automatic Stripe processing
- Add manual processing interface for admins
- Update payout status workflow
### Phase 2: Enhanced Manual System
- Add banking information collection
- Implement approval workflow
- Add transfer documentation features
### Phase 3: Potential Automation
- Investigate France-compatible payment providers
- Implement API-based bank transfers if available
- Maintain manual fallback option
## Technical Debt
### Files Requiring Updates
- `app/services/payout_service.rb`: Remove Stripe Transfer logic
- `app/controllers/admin/payouts_controller.rb`: Add manual processing actions
- `app/models/user.rb`: Add banking information fields
- Database migrations: Add IBAN/banking fields to users table
### Testing Impact
- Update `test/services/payout_service_test.rb`
- Modify controller tests for manual workflow
- Add integration tests for manual payout flow
## Conclusion
The current Stripe Global Payouts implementation is not viable for France operations. A manual payout system must be implemented immediately to handle promoter payments through traditional banking methods while maintaining audit trails and proper documentation.

View File

@@ -1,24 +0,0 @@
# 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.

View File

@@ -1,7 +0,0 @@
namespace :payouts do
desc "Process all pending promoter payouts"
task process: :environment do
PayoutService.new.process_pending_payouts
puts "Pending payouts processed."
end
end

View File

@@ -1,436 +0,0 @@
{
"messages": [
{
"role": "system",
"content": "\nYou are SHAI (for Shell AI), a coding assistant from OVHcloud, designed to be a helpful and secure pair programmer. Your purpose is to assist users with their software engineering tasks by leveraging the tools at your disposal.\n \n### Core Principles:\n \n**Helpfulness First:** \nYour primary goal is to be helpful. Understand the user's request and use your tools to achieve their goals. Be proactive when it makes sense, but always keep the user informed about the actions you are taking.\n\n**Security is Paramount:**\n * You must prioritize writing secure code.\n * Never introduce vulnerabilities.\n * Never handle or expose user secrets or credentials.\n\n## Interaction Guidelines:\n \n**Clarity and Conciseness:** \nCommunicate clearly, directly and accurately. Your output is for a command-line interface, so be brief. Avoid unnecessary chatter. Do not write code when replying to the user unless asked to. If you cannot do something, explain why and offers alternative. \n\n**Explain Your Actions:** \nBefore executing any command that modifies the user's system or files, explain what the command does and why you are running it. You must however keep your explanation short and ideally fewer than 4 lines (unless asked by the user). If you use code editing tools such as edit or write, never copy code in your response. Explain the task, do the task but avoid too many unnecessary explanation, introduction and conclusion. The best explanation is an accurate flow of actions rather than length long chatty response. \n\n**Follow Conventions:** \nWhen modifying code, adhere to the existing style, libraries, and patterns of the project. Do not introduce new dependencies without checking if they are already in use.\n\n**Tool Usage:**\n * Use the provided tools to interact with the user's environment.\n * Do not use comments in code to communicate with the user.\n * Use the `todo_write` and `todo_read` tools to plan and track your work, especially for complex tasks. This provide visibility to the user. You must use these tools extensively.\n\n**No Surprises:** \nDo not commit changes to version control unless explicitly asked to do so by the user.\n\n**Proactiveness**\nYou are allowed to be proactive and take initiative that are aligned with the user intent. For instance if the user asks you to make a function, you can proactively follow your implementation with a call to compile / test the project to make sure that your change were correct. You must however avoid proactively taking actions that are out of scope or unnecessary. For instance if the user asks you to modify a function, you should not immediately assume that this function should be used everywhere. You have to strike a balance between helpfulness, autonomy while also keeping the user in the loop.\n\n### Environment Information:\n\nYou are running in the following environment:\n<env>\n Today's date: {2024-11-16}\n Platform: {Linux}\n OS Version: {Nobara Linux 42 (Workstation Edition)}\n Working directory: {/home/acid/Documents/aperonight}\n Is Working directory a git repo: {true} \n</env>\n\n<git>\ngitStatus: This is the current git status at the last message of the conversation.\n\nCurrent branch: {develop}\n\nStatus: \n{?? docs/manual_payouts_france.md}\n\nRecent commits: \n{e5ed1a3 refactor: migrate payment success views to orders system and add promoter seed data\n3e0a354 Merge pull request 'feat/free-ticket' (#4) from feat/free-ticket into develop\nb5c1846 fix(mailers): Include TicketsHelper in TicketMailer to make format_ticket_price available\n04393ad fix(tests): Remove service fee expectation from Stripe invoice test and fix duplicated keys in event view\n5279ebe feat(event available/sold out): Promoter can mark event as sold out or available}\n</git>\n\n<todo>\ntodoStatus: This is the current status of the todo list\n\n{No todos found. The todo list is empty.}\n</todo>\n"
},
{
"role": "user",
"content": "Could you explain this project?"
}
],
"model": "deepseek/deepseek-v3.1-base",
"temperature": 0.3,
"tools": [
{
"type": "function",
"function": {
"name": "bash",
"description": "#",
"parameters": {
"properties": {
"command": {
"description": "The bash command to execute",
"type": "string"
},
"env": {
"additionalProperties": {
"type": "string"
},
"default": {},
"description": "Environment variables to set (optional)",
"type": "object"
},
"timeout": {
"description": "Timeout in seconds (optional, None = no timeout)",
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"working_dir": {
"description": "Working directory for command execution (optional)",
"type": "string"
}
},
"required": [
"command",
"env"
],
"type": "object"
}
}
},
{
"type": "function",
"function": {
"name": "edit",
"description": "#",
"parameters": {
"properties": {
"new_string": {
"description": "The replacement text",
"type": "string"
},
"old_string": {
"description": "The text pattern to find and replace",
"type": "string"
},
"path": {
"description": "Path to the file to edit",
"type": "string"
},
"replace_all": {
"default": false,
"description": "Whether to replace all occurrences (default: false, replaces only first)",
"type": "boolean"
}
},
"required": [
"new_string",
"old_string",
"path",
"replace_all"
],
"type": "object"
}
}
},
{
"type": "function",
"function": {
"name": "multiedit",
"description": "#",
"parameters": {
"properties": {
"edits": {
"description": "Array of edit operations to perform sequentially",
"items": {
"properties": {
"new_string": {
"description": "The replacement text",
"type": "string"
},
"old_string": {
"description": "The text pattern to find and replace",
"type": "string"
},
"replace_all": {
"default": false,
"description": "Whether to replace all occurrences (default: false, replaces only first)",
"type": "boolean"
}
},
"required": [
"new_string",
"old_string",
"replace_all"
],
"type": "object"
},
"type": "array"
},
"file_path": {
"description": "Path to the file to edit",
"type": "string"
}
},
"required": [
"edits",
"file_path"
],
"type": "object"
}
}
},
{
"type": "function",
"function": {
"name": "fetch",
"description": "#",
"parameters": {
"properties": {
"body": {
"default": null,
"description": "Request body for POST/PUT (optional)",
"type": "string"
},
"headers": {
"additionalProperties": {
"type": "string"
},
"default": null,
"description": "HTTP headers to send (optional)",
"type": "object"
},
"method": {
"description": "HTTP method to use",
"enum": [
"GET",
"POST",
"PUT",
"DELETE"
],
"type": "string"
},
"timeout": {
"default": 30,
"description": "Request timeout in seconds (optional, defaults to 30)",
"format": "uint64",
"minimum": 0,
"type": "integer"
},
"url": {
"description": "URL to fetch data from",
"type": "string"
}
},
"required": [
"method",
"timeout",
"url"
],
"type": "object"
}
}
},
{
"type": "function",
"function": {
"name": "find",
"description": "#",
"parameters": {
"properties": {
"case_sensitive": {
"default": false,
"description": "Whether to use case-sensitive search",
"type": "boolean"
},
"context_lines": {
"default": null,
"description": "Maximum lines of context around matches",
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"exclude_patterns": {
"default": null,
"description": "File patterns to exclude (e.g., \"target,node_modules,.git\")",
"type": "string"
},
"find_type": {
"default": "content",
"description": "Find type: content (search file contents) or filename (search file names)",
"enum": [
"content",
"filename",
"both"
],
"type": "string"
},
"include_extensions": {
"default": null,
"description": "File extensions to include (e.g., \"rs,js,py\")",
"type": "string"
},
"max_results": {
"default": 100,
"description": "Maximum number of results to return",
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"path": {
"default": null,
"description": "Directory to search in (defaults to current directory)",
"type": "string"
},
"pattern": {
"description": "The pattern to search for (supports regex)",
"type": "string"
},
"show_line_numbers": {
"default": true,
"description": "Show line numbers in results",
"type": "boolean"
},
"whole_word": {
"default": false,
"description": "Use whole word matching",
"type": "boolean"
}
},
"required": [
"case_sensitive",
"find_type",
"max_results",
"pattern",
"show_line_numbers",
"whole_word"
],
"type": "object"
}
}
},
{
"type": "function",
"function": {
"name": "ls",
"description": "#",
"parameters": {
"properties": {
"directory": {
"default": ".",
"description": "Directory to list (defaults to current directory)",
"type": "string"
},
"long_format": {
"default": false,
"description": "Show detailed information (size, permissions, etc.)",
"type": "boolean"
},
"max_depth": {
"default": null,
"description": "Maximum depth for recursive listing (None = unlimited)",
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"max_files": {
"default": null,
"description": "Maximum number of files to return (None = unlimited)",
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"recursive": {
"default": false,
"description": "Whether to list files recursively",
"type": "boolean"
},
"show_hidden": {
"default": false,
"description": "Show hidden files (files starting with .)",
"type": "boolean"
}
},
"required": [
"directory",
"long_format",
"recursive",
"show_hidden"
],
"type": "object"
}
}
},
{
"type": "function",
"function": {
"name": "read",
"description": "#",
"parameters": {
"properties": {
"line_end": {
"default": null,
"description": "Ending line number (optional)",
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"line_start": {
"default": null,
"description": "Starting line number (optional)",
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"path": {
"description": "Path to the file to read",
"type": "string"
},
"show_line_numbers": {
"default": false,
"description": "Whether to include line numbers in the output",
"type": "boolean"
}
},
"required": [
"path",
"show_line_numbers"
],
"type": "object"
}
}
},
{
"type": "function",
"function": {
"name": "todo_read",
"description": "Fetches the current to-do list for the session. Use this proactively to stay informed about the status of ongoing tasks.",
"parameters": {
"description": "Empty parameters struct for tools that don't need any parameters",
"properties": {
"_unused": {
"type": "string"
}
},
"type": "object"
}
}
},
{
"type": "function",
"function": {
"name": "todo_write",
"description": "Creates and manages a structured task list for the coding session. This is vital for organizing complex work, tracking progress, and showing a clear plan.",
"parameters": {
"properties": {
"todos": {
"description": "List of todos to write (replaces entire list)",
"items": {
"properties": {
"content": {
"type": "string"
},
"status": {
"enum": [
"pending",
"in_progress",
"completed"
],
"type": "string"
}
},
"required": [
"content",
"status"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"todos"
],
"type": "object"
}
}
},
{
"type": "function",
"function": {
"name": "write",
"description": "#",
"parameters": {
"properties": {
"content": {
"description": "Content to write to the file",
"type": "string"
},
"path": {
"description": "Path to the file to write",
"type": "string"
}
},
"required": [
"content",
"path"
],
"type": "object"
}
}
}
],
"tool_choice": "auto"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,34 +0,0 @@
require "test_helper"
class Admin::PayoutsControllerTest < ActionDispatch::IntegrationTest
setup do
@admin_user = User.create!(email: "admin@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true, stripe_customer_id: "cus_test_admin", onboarding_completed: true)
@payout = payouts(:one)
end
test "requires admin authentication" do
get admin_payouts_url
assert_redirected_to new_user_session_path
end
test "approve payout requires admin authentication" do
post approve_admin_payout_url(@payout)
assert_redirected_to new_user_session_path
end
test "approve payout works for admin users" do
sign_in @admin_user
@payout.update(status: :pending)
# Ensure the payout user has complete banking info
@payout.user.update!(
iban: "FR1420041010050500013M02606",
bank_name: "Test Bank",
account_holder_name: "Test User"
)
post approve_admin_payout_url(@payout)
assert_redirected_to admin_payout_path(@payout)
assert_match /Payout approved successfully/, flash[:notice]
end
end

View File

@@ -0,0 +1,102 @@
require "test_helper"
require "securerandom"
class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
include Devise::Test::IntegrationHelpers
# Setup test data
def setup
@user = users(:one)
@event = events(:concert_event)
# Create a new order for the test to ensure proper associations
@order = @user.orders.create!(event: @event, status: "draft", expires_at: 15.minutes.from_now, total_amount_cents: 2000)
sign_in @user
end
# Test applying a valid promotion code
def test_apply_valid_promotion_code
# Create ticket type and tickets for the order
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 2000,
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: @event
)
ticket = Ticket.create!(
order: @order,
ticket_type: ticket_type,
status: "draft",
first_name: "John",
last_name: "Doe",
price_cents: 2000
)
# Recalculate the order total
@order.calculate_total!
# Use a unique code for each test run
unique_code = "TESTDISCOUNT_#{SecureRandom.hex(4)}"
promotion_code = PromotionCode.create(
code: unique_code,
discount_amount_cents: 500, # €5.00
expires_at: 1.month.from_now,
active: true,
user: @user,
event: @event
)
get checkout_order_path(@order), params: { promotion_code: unique_code }
puts "Response status: #{response.status}"
puts "Response body: #{response.body}" if response.status != 200
assert_response :success
assert_not_nil flash.now[:notice]
assert_match /Code promotionnel appliqué: TESTDISCOUNT/, flash.now[:notice]
end
# Test applying an invalid promotion code
def test_apply_invalid_promotion_code
get checkout_order_path(@order), params: { promotion_code: "INVALIDCODE" }
assert_response :success
assert_not_nil flash.now[:alert]
assert_equal "Code promotionnel invalide", flash.now[:alert]
end
# Test applying an expired promotion code
def test_apply_expired_promotion_code
promotion_code = PromotionCode.create(
code: "EXPIREDCODE",
discount_amount_cents: 1000,
expires_at: 1.day.ago,
active: true,
user: @user,
event: @event
)
get checkout_order_path(@order), params: { promotion_code: "EXPIREDCODE" }
assert_response :success
assert_not_nil flash.now[:alert]
assert_equal "Code promotionnel invalide", flash.now[:alert]
end
# Test applying an inactive promotion code
def test_apply_inactive_promotion_code
promotion_code = PromotionCode.create(
code: "INACTIVECODE",
discount_amount_cents: 1000,
expires_at: 1.month.from_now,
active: false,
user: @user,
event: @event
)
get checkout_order_path(@order), params: { promotion_code: "INACTIVECODE" }
assert_response :success
assert_not_nil flash.now[:alert]
assert_equal "Code promotionnel invalide", flash.now[:alert]
end
end

View File

@@ -1,174 +0,0 @@
require "test_helper"
class Promoter::PayoutsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:one)
@event = events(:concert_event)
@payout = payouts(:one)
end
test "should get index" do
sign_in @user
# Make the user a promoter
@user.update(is_professionnal: true)
get promoter_payouts_url
assert_response :success
end
test "should get show" do
sign_in @user
# Make the user a promoter
@user.update(is_professionnal: true)
# Create a payout that belongs to the user
payout = Payout.create!(
user: @user,
event: @event,
amount_cents: 1000,
fee_cents: 100
)
get promoter_payout_url(payout)
assert_response :success
end
test "should create payout" do
sign_in @user
# Make the user a promoter
@user.update(is_professionnal: true)
# Make the user the owner of the event
@event.update(user: @user)
# Make the event end in the past
@event.update(end_time: 1.day.ago)
# Create some earnings for the event
@event.earnings.create!(
user: @user,
order: orders(:paid_order),
amount_cents: 1000,
fee_cents: 100,
status: :pending
)
assert_difference("Payout.count", 1) do
post promoter_payouts_url, params: { event_id: @event.id }
end
assert_redirected_to promoter_payout_path(Payout.last)
end
# Comprehensive index test with data
test "index shows completed payouts, eligible events, and totals for promoter" do
sign_in @user
@user.update(is_professionnal: true)
# Create completed payouts for user
completed_payout = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :completed)
# Create eligible event
eligible_event = Event.create!(name: "Eligible Event", slug: "eligible-event", description: "desc", venue_name: "v", venue_address: "a", latitude: 48.0, longitude: 2.0, start_time: 1.day.ago, end_time: 2.days.ago, user: @user, state: :published)
# Setup net >0 for eligible
earning = Earning.create!(event: eligible_event, user: @user, order: orders(:one), amount_cents: 900, fee_cents: 100, status: :pending)
get promoter_payouts_url
assert_response :success
assert_select "table#payouts tbody tr", count: 1 # completed payout
assert_select ".eligible-events li", count: 1 # eligible event
assert_match /Pending net earnings: €9.00/, @response.body # totals
assert_match /Total paid out: €10.00/, @response.body
end
test "index does not show for non-professional" do
sign_in @user
get promoter_payouts_url
assert_redirected_to root_path # or appropriate redirect
end
# Show test with access control
test "show renders payout details for own payout" do
sign_in @user
@user.update(is_professionnal: true)
payout = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :completed)
get promoter_payout_url(payout)
assert_response :success
assert_match payout.amount.to_s, @response.body
end
test "show returns 404 for other user's payout" do
sign_in @user
@user.update(is_professionnal: true)
other_user = User.create!(email: "other@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true)
other_payout = Payout.create!(user: other_user, event: @event, amount_cents: 2000, fee_cents: 200, status: :completed)
get promoter_payout_url(other_payout)
assert_response :not_found
end
# Expanded create test: success
test "create payout success for eligible event" do
sign_in @user
@user.update(is_professionnal: true)
@event.update(user: @user, end_time: 1.day.ago) # ended
# Setup net >0
earning = @event.earnings.create!(user: @user, order: orders(:paid_order), amount_cents: 900, fee_cents: 100, status: :pending)
# Ensure eligible
assert @event.can_request_payout?(@user)
assert_difference("Payout.count", 1) do
post promoter_payouts_url, params: { event_id: @event.id }
end
assert_redirected_to promoter_payout_path(Payout.last)
assert_flash :notice, /Payout requested successfully/
assert_equal :requested, @event.reload.payout_status # assume enum
payout = Payout.last
assert_equal @event.total_gross_cents, payout.amount_cents
assert_equal @event.total_fees_cents, payout.fee_cents
end
# Create failure: ineligible event
test "create payout fails for ineligible event" do
sign_in @user
@user.update(is_professionnal: true)
@event.update(user: @user, end_time: 1.day.from_now) # not ended
assert_not @event.can_request_payout?(@user)
assert_no_difference("Payout.count") do
post promoter_payouts_url, params: { event_id: @event.id }
end
assert_redirected_to event_path(@event)
assert_flash :alert, /Event not eligible for payout/
end
test "show renders manual payout details correctly" do
sign_in @user
@user.update(is_professionnal: true)
payout = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :completed, stripe_payout_id: "MANUAL_abc123")
get promoter_payout_url(payout)
assert_response :success
assert_match "Manual Payout ID", @response.body
assert_match "Manual Transfer", @response.body
end
# Create failure: validation errors
test "create payout fails with validation errors" do
sign_in @user
@user.update(is_professionnal: true)
@event.update(user: @user, end_time: 1.day.ago)
# Setup net =0
assert_not @event.can_request_payout?(@user)
assert_no_difference("Payout.count") do
post promoter_payouts_url, params: { event_id: @event.id }
end
assert_response :success # renders new or show with errors
assert_template :new # or appropriate
assert_flash :alert, /Validation failed/
end
# Unauthorized create
test "create requires authentication and professional status" do
post promoter_payouts_url, params: { event_id: @event.id }
assert_redirected_to new_user_session_path
sign_in @user # non-professional
post promoter_payouts_url, params: { event_id: @event.id }
assert_redirected_to root_path # or deny access
end
end

View File

@@ -1,19 +0,0 @@
# 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

View File

@@ -1,19 +1,5 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: Test Event
slug: test-event
description: This is a test event description that is long enough to meet validation requirements.
state: published
venue_name: Test Venue
venue_address: 123 Test Street
latitude: 48.8566
longitude: 2.3522
start_time: <%= 1.week.from_now %>
end_time: <%= 1.week.from_now + 4.hours %>
user: one
featured: false
concert_event: concert_event:
name: Summer Concert name: Summer Concert
slug: summer-concert slug: summer-concert
@@ -39,29 +25,3 @@ winter_gala:
start_time: <%= 2.weeks.from_now %> start_time: <%= 2.weeks.from_now %>
end_time: <%= 2.weeks.from_now + 6.hours %> end_time: <%= 2.weeks.from_now + 6.hours %>
user: two user: two
another_event:
name: Another Event
slug: another-event
description: This is another test event description that is long enough to meet validation requirements.
state: published
venue_name: Another Venue
venue_address: 456 Test Street
latitude: 48.8566
longitude: 2.3522
start_time: <%= 1.week.ago %>
end_time: <%= 1.week.ago + 4.hours %>
user: one
ineligible:
name: Ineligible Event
slug: ineligible-event
description: This is an ineligible test event description that is long enough to meet validation requirements.
state: draft
venue_name: Ineligible Venue
venue_address: 789 Test Street
latitude: 48.8566
longitude: 2.3522
start_time: <%= 1.week.from_now %>
end_time: <%= 1.week.from_now + 4.hours %>
user: one

View File

@@ -1,13 +1,3 @@
one:
user: one
event: concert_event
status: paid
total_amount_cents: 2500
payment_attempts: 1
expires_at: <%= 1.hour.from_now %>
created_at: <%= 1.hour.ago %>
updated_at: <%= 1.hour.ago %>
paid_order: paid_order:
user: one user: one
event: concert_event event: concert_event
@@ -37,13 +27,3 @@ expired_order:
expires_at: <%= 1.hour.ago %> expires_at: <%= 1.hour.ago %>
created_at: <%= 2.hours.ago %> created_at: <%= 2.hours.ago %>
updated_at: <%= 1.hour.ago %> updated_at: <%= 1.hour.ago %>
two:
user: two
event: winter_gala
status: expired
total_amount_cents: 5000
payment_attempts: 2
expires_at: <%= 2.hours.ago %>
created_at: <%= 3.hours.ago %>
updated_at: <%= 2.hours.ago %>

View File

@@ -1,15 +0,0 @@
# 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

View File

@@ -1,15 +1,5 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: Standard
description: Standard ticket type
price_cents: 1000
quantity: 100
sale_start_at: <%= 1.day.ago %>
sale_end_at: <%= 1.day.from_now %>
event: concert_event
requires_id: false
standard: standard:
name: General Admission name: General Admission
description: General admission ticket for the event description: General admission ticket for the event

View File

@@ -5,6 +5,7 @@ one:
encrypted_password: <%= Devise::Encryptor.digest(User, 'password123') %> encrypted_password: <%= Devise::Encryptor.digest(User, 'password123') %>
last_name: Trump last_name: Trump
first_name: Donald first_name: Donald
is_professionnal: true
onboarding_completed: true onboarding_completed: true
two: two:

View File

@@ -1,58 +0,0 @@
require "test_helper"
class PayoutFlowTest < ActionDispatch::IntegrationTest
setup do
@promoter = User.create!(email: "promoter@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true)
@buyer = User.create!(email: "buyer@example.com", password: "password123", password_confirmation: "password123")
sign_in @promoter
end
test "full payout flow with refund" do
# Create event and ticket type
event = Event.create!(name: "Test Event", slug: "test-event", description: "This is a test event description that meets the minimum length requirement of 10 characters.", venue_name: "Venue", venue_address: "Address", latitude: 48.0, longitude: 2.0, start_time: 1.day.ago, end_time: 1.hour.ago, user: @promoter, state: :published)
ticket_type = TicketType.create!(event: event, name: "Standard", price_cents: 1000, quantity: 10, sale_start_at: 2.days.ago, sale_end_at: Time.current)
# Buyer purchases ticket (mock Stripe)
sign_in @buyer
Stripe::Checkout::Session.expects(:create).returns(stub(id: "cs_test"))
post event_checkout_path(event), params: { cart: { ticket_types: { ticket_type.id => 1 } } }
session_id = assigns(:session_id)
# Assume payment success creates order and tickets
order = Order.last
ticket = Ticket.last
assert_equal "paid", order.status
assert_equal "active", ticket.status
# Earnings created
earning = Earning.last
assert_not_nil earning
assert_equal 900, earning.amount_cents
# Refund one ticket
sign_in @promoter
ticket.update!(status: "refunded")
earning.reload
assert_equal 0, earning.amount_cents # Recalculated
# Request payout
assert event.can_request_payout?(@promoter)
post promoter_payouts_path, params: { event_id: event.id }
payout = Payout.last
assert_equal :pending, payout.status
# Admin process
admin = User.create!(email: "admin@example.com", password: "password123", password_confirmation: "password123")
admin.add_role :admin
sign_in admin
Stripe::Transfer.expects(:create).returns(stub(id: "tr_success"))
patch admin_payout_path(payout)
payout.reload
assert_equal :completed, payout.status
# Webhook succeeds
post stripe_webhooks_path, params: { type: "payout.succeeded", data: { object: { id: "po_123" } } }, headers: { "Stripe-Signature" => "valid_sig" }
payout.reload
assert_equal :completed, payout.status # Confirmed
end
end

View File

@@ -1,135 +0,0 @@
require "test_helper"
class EarningTest < ActiveSupport::TestCase
setup do
@user = users(:one) || User.create!(email: "test@example.com", password: "password")
@event = events(:concert_event) || Event.create!(name: "Test Event", slug: "test-event", description: "Description", venue_name: "Venue", venue_address: "Address", latitude: 48.8566, longitude: 2.3522, start_time: Time.current, user: @user)
@order = orders(:paid_order) || Order.create!(user: @user, event: @event, status: "paid", total_amount_cents: 10000)
@earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 9000, fee_cents: 1000, status: :pending)
end
test "valid earning" do
assert @earning.valid?
end
test "amount_cents must be present and non-negative" do
@earning.amount_cents = nil
assert_not @earning.valid?
assert_includes @earning.errors[:amount_cents], "can't be blank"
@earning.amount_cents = -1
assert_not @earning.valid?
assert_includes @earning.errors[:amount_cents], "must be greater than or equal to 0"
end
test "fee_cents must be present and non-negative" do
@earning.fee_cents = nil
assert_not @earning.valid?
assert_includes @earning.errors[:fee_cents], "can't be blank"
@earning.fee_cents = -1
assert_not @earning.valid?
assert_includes @earning.errors[:fee_cents], "must be greater than or equal to 0"
end
test "status must be present" do
@earning.status = nil
assert_not @earning.valid?
assert_includes @earning.errors[:status], "can't be blank"
end
test "stripe_payout_id must be unique if present" do
@earning.stripe_payout_id = "test_payout"
@earning.save!
duplicate = @earning.dup
duplicate.stripe_payout_id = "test_payout"
assert_not duplicate.valid?
assert_includes duplicate.errors[:stripe_payout_id], "has already been taken"
end
test "belongs to associations" do
assert_instance_of Event, @earning.event
assert_instance_of User, @earning.user
assert_instance_of Order, @earning.order
end
test "status enum" do
assert_equal 0, Earning.statuses[:pending]
assert_equal 1, Earning.statuses[:paid]
assert @earning.pending?
assert_not @earning.paid?
@earning.status = :paid
@earning.save!
assert @earning.paid?
assert_not @earning.pending?
end
test "pending scope from enum" do
pending_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 9000, fee_cents: 1000, status: :pending)
paid_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 4500, fee_cents: 500, status: :paid)
assert_includes Earning.pending, pending_earning
assert_not_includes Earning.pending, paid_earning
end
test "paid scope from enum" do
pending_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 9000, fee_cents: 1000, status: :pending)
paid_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 4500, fee_cents: 500, status: :paid)
assert_not_includes Earning.paid, pending_earning
assert_includes Earning.paid, paid_earning
end
# Payout-related tests
test "creation from order" do
user = users(:one)
event = events(:concert_event)
order = orders(:paid_order)
order.update!(status: "paid", total_amount_cents: 10000)
# Assume Earning.create_from_order(order) or callback creates earning
Earning.create_from_order(order)
earning = Earning.where(order: order).first
assert_not_nil earning
assert_equal 9000, earning.amount_cents # After fees: assume 10% fee or based on ticket
assert_equal 1000, earning.fee_cents
assert earning.pending?
end
test "recalculation on full refund" do
earning = earnings(:one)
earning.amount_cents = 1000
earning.fee_cents = 100
earning.save!
# Assume all tickets in order refunded
order = orders(:one)
order.tickets.each { |t| t.update!(status: "refunded") }
earning.recalculate_on_refund(order)
assert_equal 0, earning.amount_cents
assert earning.refunded? # Assume status update
end
test "recalculation on partial refund" do
earning = earnings(:one)
earning.amount_cents = 2000
earning.fee_cents = 200
earning.save!
order = orders(:one)
# Refund one ticket of 1000
order.tickets.first.update!(status: "refunded")
earning.recalculate_on_refund(order)
assert_equal 1000, earning.amount_cents # Half
assert_equal 100, earning.fee_cents # Half
end
end

View File

@@ -317,142 +317,157 @@ class EventTest < ActiveSupport::TestCase
# Check that ticket types were NOT duplicated # Check that ticket types were NOT duplicated
assert_equal 0, duplicated_event.ticket_types.count assert_equal 0, duplicated_event.ticket_types.count
end end
# Payout-related tests
test "total_gross_cents returns sum of active tickets prices" do # Test slug generation functionality
event = events(:concert_event) test "should generate slug from name and venue" do
ticket1 = tickets(:one) user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
ticket1.status = "active" event = Event.new(
ticket1.price_cents = 1000 name: "Soirée d'ouverture",
ticket1.save! description: "Valid description for the event that is long enough",
venue_name: "Test Venue",
ticket2 = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr2", price_cents: 2000, status: "active", first_name: "Test", last_name: "User") venue_address: "123 Test Street",
ticket2.event = event user: user,
ticket2.save! latitude: 48.0,
longitude: 2.0
assert_equal 3000, event.total_gross_cents )
event.save
assert_equal "soiree-d-ouverture-test-venue", event.slug
end end
test "total_fees_cents returns sum of pending earnings fees" do test "should generate slug from name, venue, and city" do
event = events(:concert_event) user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
earning1 = earnings(:one) event = Event.new(
earning1.status = "pending" name: "Fête de la Musique",
earning1.fee_cents = 100 venue_name: "Théâtre Principal",
earning1.save! venue_address: "15 Rue de la Paix, 75002 Paris",
description: "Valid description for the event that is long enough",
earning2 = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 2000, fee_cents: 200, status: "pending") user: user,
latitude: 48.0,
assert_equal 300, event.total_fees_cents longitude: 2.0
)
event.save
assert_equal "fete-de-la-musique-theatre-principal-paris", event.slug
end end
test "net_earnings_cents returns gross minus fees" do test "should generate fallback slug when no data available" do
event = events(:concert_event) user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
# Setup gross 5000, fees 500 event = Event.new(
ticket1 = tickets(:one) description: "Valid description for the event that is long enough",
ticket1.status = "active" venue_address: "123 Test Street",
ticket1.price_cents = 2500 user: user,
ticket1.save! latitude: 48.0,
longitude: 2.0
ticket2 = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr3", price_cents: 2500, status: "active", first_name: "Test2", last_name: "User2") )
ticket2.event = event event.save
ticket2.save! assert_match /^event-\d+$/, event.slug
earning1 = earnings(:one)
earning1.status = "pending"
earning1.fee_cents = 250
earning1.save!
earning2 = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 2500, fee_cents: 250, status: "pending")
assert_equal 4500, event.net_earnings_cents
end end
test "can_request_payout? returns true for ended event with net >0, eligible user, no pending payout" do test "should ensure slug uniqueness" do
event = events(:concert_event) user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event.update!(end_time: 1.day.ago) # ended
# Setup net >0
ticket = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr4", price_cents: 1000, status: "active", first_name: "Test", last_name: "User")
ticket.event = event
ticket.save!
earning = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 900, fee_cents: 100, status: "pending") # Create first event
event1 = Event.create!(
name: "Test Event",
venue_name: "Venue",
venue_address: "123 Test Street",
description: "Valid description for the event that is long enough",
user: user,
latitude: 48.0,
longitude: 2.0
)
user = users(:one) # Create second event with same details
user.update!(is_professionnal: true) # eligible event2 = Event.create!(
name: "Test Event",
venue_name: "Venue",
venue_address: "123 Test Street",
description: "Valid description for the event that is long enough",
user: user,
latitude: 48.0,
longitude: 2.0
)
# No pending payout assert_not_equal event1.slug, event2.slug
assert event.can_request_payout?(user) assert_match /^test-event-venue-1$/, event2.slug
end end
test "can_request_payout? returns false for not ended event" do test "should extract city from French postal code" do
event = events(:concert_event) user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event.update!(end_time: 1.day.from_now) # not ended event = Event.new(
user = users(:one) name: "Concert",
user.update!(is_professionnal: true) venue_address: "5 Avenue des Champs-Élysées, 75008 Paris",
description: "Valid description for the event that is long enough",
assert_not event.can_request_payout?(user) user: user,
latitude: 48.0,
longitude: 2.0
)
event.save
assert event.slug.include?("paris")
end end
test "can_request_payout? returns false if net <=0" do # Test image URL functionality
event = events(:concert_event) test "should accept valid image URL" do
event.update!(end_time: 1.day.ago) user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
user = users(:one) event = Event.new(
user.update!(is_professionnal: true) name: "Event with URL Image",
slug: "event-url-image",
assert_not event.can_request_payout?(user) description: "Valid description for the event that is long enough",
venue_name: "Venue",
venue_address: "123 Test Street",
user: user,
latitude: 48.0,
longitude: 2.0,
image_url: "https://example.com/image.jpg"
)
assert event.valid?
end end
test "can_request_payout? returns false for non-professional user" do test "should reject invalid image URL" do
event = events(:concert_event) user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event.update!(end_time: 1.day.ago) event = Event.new(
# Setup net >0 name: "Event with Invalid URL",
ticket = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr5", price_cents: 1000, status: "active", first_name: "Test", last_name: "User") slug: "event-invalid-url",
ticket.event = event description: "Valid description for the event that is long enough",
ticket.save! venue_name: "Venue",
venue_address: "123 Test Street",
earning = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 900, fee_cents: 100, status: "pending") user: user,
latitude: 48.0,
user = users(:one) longitude: 2.0,
# is_professionnal false by default image_url: "not-a-valid-url"
)
assert_not event.can_request_payout?(user) assert_not event.valid?
assert_includes event.errors[:image_url], "doit être une URL valide vers une image (JPG, PNG, GIF, WebP)"
end end
test "can_request_payout? returns false if pending payout exists" do test "should reject URL with non-image extension" do
event = events(:concert_event) user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event.update!(end_time: 1.day.ago) event = Event.new(
# Setup net >0 name: "Event with Non-image URL",
ticket = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr6", price_cents: 1000, status: "active", first_name: "Test", last_name: "User") slug: "event-non-image-url",
ticket.event = event description: "Valid description for the event that is long enough",
ticket.save! venue_name: "Venue",
venue_address: "123 Test Street",
earning = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 900, fee_cents: 100, status: "pending") user: user,
latitude: 48.0,
user = users(:one) longitude: 2.0,
user.update!(is_professionnal: true) image_url: "https://example.com/document.pdf"
)
Payout.create!(user: user, event: event, amount_cents: 800, fee_cents: 100, status: :pending) assert_not event.valid?
assert_includes event.errors[:image_url], "doit être une URL valide vers une image (JPG, PNG, GIF, WebP)"
assert_not event.can_request_payout?(user)
end end
test "eligible_for_payout scope returns events with net>0, ended, professional user" do test "has_image? should return true for URL image" do
user = users(:one) event = Event.new(image_url: "https://example.com/image.jpg")
user.update!(is_professionnal: true) assert event.has_image?
end
eligible = Event.create!(name: "Eligible", slug: "eligible", description: "This is a test event description", venue_name: "Test Venue", venue_address: "Test Address", latitude: 48.0, longitude: 2.0, start_time: 1.day.ago, end_time: 2.days.ago, user: user, state: :published) test "has_image? should return false without image" do
# Setup net >0 event = Event.new
ticket = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr7", price_cents: 1000, status: "active", first_name: "Test", last_name: "User") assert_not event.has_image?
ticket.event = eligible end
ticket.save!
earning = Earning.create!(event: eligible, user: user, order: orders(:one), amount_cents: 900, fee_cents: 100, status: "pending") test "display_image should return image URL when no attached image" do
event = Event.new(image_url: "https://example.com/image.jpg")
ineligible = Event.create!(name: "Ineligible", slug: "ineligible", description: "desc", venue_name: "v", venue_address: "a", latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, end_time: 2.days.from_now, user: user, state: :published) assert_equal "https://example.com/image.jpg", event.display_image
# net =0
eligible_events = Event.eligible_for_payout
assert_includes eligible_events, eligible
assert_not_includes eligible_events, ineligible
end end
end end

View File

@@ -582,6 +582,243 @@ class OrderTest < ActiveSupport::TestCase
assert_equal 95.0, order.promoter_payout_euros assert_equal 95.0, order.promoter_payout_euros
end end
# === Promotion Code Tests ===
test "subtotal_amount_cents should calculate total without discounts" do
order = Order.create!(
user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0
)
# Create ticket type and tickets
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
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: "draft",
first_name: "John",
last_name: "Doe"
)
Ticket.create!(
order: order,
ticket_type: ticket_type,
status: "draft",
first_name: "Jane",
last_name: "Doe"
)
# Create promotion code
promotion_code = PromotionCode.create!(
code: "TESTCODE",
discount_amount_cents: 500,
user: @user,
event: @event
)
order.promotion_codes << promotion_code
order.calculate_total!
assert_equal 3000, order.subtotal_amount_cents # 2 tickets * 1500 cents
assert_equal 2500, order.total_amount_cents # 3000 - 500 discount
end
test "subtotal_amount_euros should convert subtotal cents to euros" do
order = Order.new(total_amount_cents: 2500)
def order.subtotal_amount_cents; 3000; end
assert_equal 30.0, order.subtotal_amount_euros
end
test "discount_amount_cents should calculate total discount from promotion codes" do
order = Order.create!(
user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0
)
# Create ticket type and tickets for subtotal
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 2000,
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: "draft",
first_name: "John",
last_name: "Doe"
)
# Create multiple promotion codes
promo1 = PromotionCode.create!(
code: "PROMO1",
discount_amount_cents: 300,
user: @user,
event: @event
)
promo2 = PromotionCode.create!(
code: "PROMO2",
discount_amount_cents: 700,
user: @user,
event: @event
)
order.promotion_codes << [ promo1, promo2 ]
order.calculate_total!
assert_equal 1000, order.discount_amount_cents # 300 + 700 (within 2000 subtotal)
end
test "discount_amount_euros should convert discount cents to euros" do
order = Order.new(total_amount_cents: 2000)
def order.discount_amount_cents; 1000; end
assert_equal 10.0, order.discount_amount_euros
end
test "calculate_total! should apply promotion code discounts" do
order = Order.create!(
user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0
)
# Create ticket type and tickets
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 2000,
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: "draft",
first_name: "John",
last_name: "Doe"
)
# Create promotion code
promotion_code = PromotionCode.create!(
code: "TESTCODE",
discount_amount_cents: 500,
user: @user,
event: @event
)
order.promotion_codes << promotion_code
order.calculate_total!
assert_equal 2000, order.subtotal_amount_cents
assert_equal 500, order.discount_amount_cents
assert_equal 1500, order.total_amount_cents
end
test "calculate_total! should handle zero total after promotion codes" do
order = Order.create!(
user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0
)
# Create ticket type and tickets
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 500,
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: "draft",
first_name: "John",
last_name: "Doe"
)
# Create promotion code that covers the entire amount
promotion_code = PromotionCode.create!(
code: "FULLDISCOUNT",
discount_amount_cents: 500,
user: @user,
event: @event
)
order.promotion_codes << promotion_code
order.calculate_total!
assert_equal 500, order.subtotal_amount_cents
assert_equal 500, order.discount_amount_cents
assert_equal 0, order.total_amount_cents
assert order.free?
end
test "calculate_total! should not allow negative totals with promotion codes" do
order = Order.create!(
user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0
)
# Create ticket type and tickets
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 300,
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: "draft",
first_name: "John",
last_name: "Doe"
)
# Create promotion code that exceeds the ticket amount
promotion_code = PromotionCode.create!(
code: "TOOMUCH",
discount_amount_cents: 1000,
user: @user,
event: @event
)
order.promotion_codes << promotion_code
order.calculate_total!
assert_equal 300, order.subtotal_amount_cents
assert_equal 300, order.discount_amount_cents # Capped at subtotal
assert_equal 0, order.total_amount_cents
end
# === Stripe Integration Tests (Mock) === # === Stripe Integration Tests (Mock) ===
test "create_stripe_invoice! should return nil for non-paid orders" do test "create_stripe_invoice! should return nil for non-paid orders" do

View File

@@ -1,146 +0,0 @@
require "test_helper"
class PayoutTest < ActiveSupport::TestCase
setup do
@user = User.create!(email: "test@example.com", password: "password123", is_professionnal: true)
@event = Event.create!(
user: @user,
name: "Test Event",
slug: "test-event",
description: "Test event description with enough characters",
venue_name: "Test Venue",
venue_address: "Test Address",
latitude: 48.8566,
longitude: 2.3522,
start_time: 1.day.ago,
end_time: 1.hour.ago,
state: :published
)
# Create a ticket type for the event
@ticket_type = TicketType.create!(
event: @event,
name: "General Admission",
description: "General admission ticket",
price_cents: 1000,
quantity: 100,
sale_start_at: 2.days.ago,
sale_end_at: 30.minutes.ago
)
# Create some earnings for the event
Earning.create!(event: @event, user: @user, order: Order.create!(user: @user, event: @event, status: :paid, total_amount_cents: 1000), amount_cents: 2000, fee_cents: 200, status: :pending)
@payout = Payout.new(user: @user, event: @event, amount_cents: 2000, fee_cents: 200)
end
test "should be valid" do
# For this test, we'll skip validations since they're tested separately
assert @payout.save(validate: false)
end
test "validations: amount_cents must be present and positive" do
@payout.amount_cents = nil
assert_not @payout.valid?
@payout.amount_cents = 0
assert_not @payout.valid?
@payout.amount_cents = -100
assert_not @payout.valid?
end
test "validations: fee_cents must be present and non-negative" do
@payout.fee_cents = nil
assert_not @payout.valid?
@payout.fee_cents = -100
assert_not @payout.valid?
end
test "validations: net earnings must be greater than 0" do
# Create an event with no earnings (net earnings = 0)
event_without_earnings = Event.create!(
user: @user,
name: "Test Event 2",
slug: "test-event-2",
description: "Test event description with enough characters",
venue_name: "Test Venue",
venue_address: "Test Address",
latitude: 48.8566,
longitude: 2.3522,
start_time: 1.day.ago,
end_time: 1.hour.ago,
state: :published
)
# Create a ticket type for the event
TicketType.create!(
event: event_without_earnings,
name: "General Admission",
description: "General admission ticket",
price_cents: 1000,
quantity: 100,
sale_start_at: 2.days.ago,
sale_end_at: 30.minutes.ago
)
payout = Payout.new(user: @user, event: event_without_earnings, amount_cents: 0, fee_cents: 0)
assert_not payout.valid?
end
test "validations: only one pending payout per event" do
# Create a valid payout first
pending_payout = Payout.create!(user: @user, event: @event, amount_cents: 2000, fee_cents: 200, status: :pending)
assert pending_payout.valid?
duplicate = Payout.new(user: @user, event: @event, amount_cents: 2000, fee_cents: 200, status: :pending)
assert_not duplicate.valid?
end
test "net_amount_cents virtual attribute" do
@payout.amount_cents = 10000
@payout.fee_cents = 1000
assert_equal 9000, @payout.net_amount_cents
end
test "after_create callback sets refunded_orders_count" do
# Create some refunded tickets to test the callback
order = Order.create!(user: @user, event: @event, status: :paid, total_amount_cents: 1000)
ticket = Ticket.create!(order: order, ticket_type: @ticket_type, price_cents: 1000, status: :refunded)
payout = Payout.create!(user: @user, event: @event, amount_cents: 2000, fee_cents: 200)
# The refunded_orders_count should be set by the callback
assert_equal 1, payout.refunded_orders_count
end
test "associations: belongs to user" do
association = Payout.reflect_on_association(:user)
assert_equal :belongs_to, association.macro
end
test "associations: belongs to event" do
association = Payout.reflect_on_association(:event)
assert_equal :belongs_to, association.macro
end
test "status enum" do
assert_equal 0, Payout.statuses[:pending]
assert_equal 1, Payout.statuses[:approved]
assert_equal 2, Payout.statuses[:processing]
assert_equal 3, Payout.statuses[:completed]
assert_equal 4, Payout.statuses[:failed]
assert_equal 5, Payout.statuses[:rejected]
payout = Payout.create!(user: @user, event: @event, amount_cents: 2000, fee_cents: 200, status: :pending)
assert payout.pending?
payout.update!(status: :completed)
assert payout.completed?
end
test "pending scope" do
pending = Payout.create!(user: @user, event: @event, amount_cents: 2000, fee_cents: 200, status: :pending)
completed = Payout.create!(user: @user, event: @event, amount_cents: 2000, fee_cents: 200, status: :completed)
assert_includes Payout.pending, pending
assert_not_includes Payout.pending, completed
end
end

View File

@@ -0,0 +1,269 @@
require "test_helper"
class PromotionCodeTest < ActiveSupport::TestCase
def setup
@user = User.create!(
email: "test@example.com",
password: "password123",
password_confirmation: "password123"
)
@event = Event.create!(
name: "Test Event",
slug: "test-event",
description: "A valid description for the test event that is long enough",
latitude: 48.8566,
longitude: 2.3522,
venue_name: "Test Venue",
venue_address: "123 Test Street",
user: @user
)
end
# Test valid promotion code creation
def test_valid_promotion_code
promotion_code = PromotionCode.create(
code: "DISCOUNT10",
discount_amount_cents: 1000, # €10.00
expires_at: 1.month.from_now,
active: true,
user: @user,
event: @event
)
assert promotion_code.valid?
assert_equal "DISCOUNT10", promotion_code.code
assert_equal 1000, promotion_code.discount_amount_cents
assert promotion_code.active?
end
# Test validation for required fields
def test_validation_for_required_fields
promotion_code = PromotionCode.new
refute promotion_code.valid?
assert_not_nil promotion_code.errors[:code]
end
# Test unique code validation
def test_unique_code_validation
PromotionCode.create(code: "UNIQUE123", discount_amount_cents: 500, user: @user, event: @event)
duplicate_code = PromotionCode.new(code: "UNIQUE123", discount_amount_cents: 500, user: @user, event: @event)
refute duplicate_code.valid?
assert_not_nil duplicate_code.errors[:code]
end
# Test discount amount validation
def test_discount_amount_validation
promotion_code = PromotionCode.new(code: "VALID123", discount_amount_cents: -100, user: @user, event: @event)
refute promotion_code.valid?
assert_not_nil promotion_code.errors[:discount_amount_cents]
end
# Test active scope
def test_active_scope
active_code = PromotionCode.create(code: "ACTIVE123", discount_amount_cents: 500, active: true, user: @user, event: @event)
inactive_code = PromotionCode.create(code: "INACTIVE123", discount_amount_cents: 500, active: false, user: @user, event: @event)
assert_includes PromotionCode.active, active_code
refute_includes PromotionCode.active, inactive_code
end
# Test expired scope
def test_expired_scope
expired_code = PromotionCode.create(code: "EXPIRED123", discount_amount_cents: 500, expires_at: 1.day.ago, user: @user, event: @event)
future_code = PromotionCode.create(code: "FUTURE123", discount_amount_cents: 500, expires_at: 1.month.from_now, user: @user, event: @event)
assert_includes PromotionCode.expired, expired_code
refute_includes PromotionCode.expired, future_code
end
# Test valid scope
def test_valid_scope
valid_code = PromotionCode.create(code: "VALID123", discount_amount_cents: 500, active: true, expires_at: 1.month.from_now, user: @user, event: @event)
invalid_code = PromotionCode.create(code: "INVALID123", discount_amount_cents: 500, active: false, expires_at: 1.day.ago, user: @user, event: @event)
assert_includes PromotionCode.valid, valid_code
refute_includes PromotionCode.valid, invalid_code
end
# Test discount_amount_euros method
def test_discount_amount_euros_converts_cents_to_euros
promotion_code = PromotionCode.new(discount_amount_cents: 1000)
assert_equal 10.0, promotion_code.discount_amount_euros
promotion_code = PromotionCode.new(discount_amount_cents: 550)
assert_equal 5.5, promotion_code.discount_amount_euros
end
# Test active? method
def test_active_method
# Active and not expired
active_code = PromotionCode.create(
code: "ACTIVE1",
discount_amount_cents: 500,
active: true,
expires_at: 1.month.from_now,
user: @user,
event: @event
)
assert active_code.active?
# Active but expired
expired_active_code = PromotionCode.create(
code: "ACTIVE2",
discount_amount_cents: 500,
active: true,
expires_at: 1.day.ago,
user: @user,
event: @event
)
assert_not expired_active_code.active?
# Inactive but not expired
inactive_code = PromotionCode.create(
code: "INACTIVE1",
discount_amount_cents: 500,
active: false,
expires_at: 1.month.from_now,
user: @user,
event: @event
)
assert_not inactive_code.active?
# Active with no expiration
no_expiry_code = PromotionCode.create(
code: "NOEXPIRY",
discount_amount_cents: 500,
active: true,
expires_at: nil,
user: @user,
event: @event
)
assert no_expiry_code.active?
end
# Test expired? method
def test_expired_method
# Expired code
expired_code = PromotionCode.create(
code: "EXPIRED1",
discount_amount_cents: 500,
expires_at: 1.day.ago,
user: @user,
event: @event
)
assert expired_code.expired?
# Future code
future_code = PromotionCode.create(
code: "FUTURE1",
discount_amount_cents: 500,
expires_at: 1.month.from_now,
user: @user,
event: @event
)
assert_not future_code.expired?
# No expiration
no_expiry_code = PromotionCode.create(
code: "NOEXPIRY1",
discount_amount_cents: 500,
expires_at: nil,
user: @user,
event: @event
)
assert_not no_expiry_code.expired?
end
# Test can_be_used? method
def test_can_be_used_method
# Can be used: active, not expired, under usage limit
usable_code = PromotionCode.create(
code: "USABLE1",
discount_amount_cents: 500,
active: true,
expires_at: 1.month.from_now,
usage_limit: 10,
uses_count: 0,
user: @user,
event: @event
)
assert usable_code.can_be_used?
# Cannot be used: inactive
inactive_code = PromotionCode.create(
code: "INACTIVE2",
discount_amount_cents: 500,
active: false,
expires_at: 1.month.from_now,
usage_limit: 10,
uses_count: 0,
user: @user,
event: @event
)
assert_not inactive_code.can_be_used?
# Cannot be used: expired
expired_code = PromotionCode.create(
code: "EXPIRED2",
discount_amount_cents: 500,
active: true,
expires_at: 1.day.ago,
usage_limit: 10,
uses_count: 0,
user: @user,
event: @event
)
assert_not expired_code.can_be_used?
# Cannot be used: at usage limit
limit_reached_code = PromotionCode.create(
code: "LIMIT1",
discount_amount_cents: 500,
active: true,
expires_at: 1.month.from_now,
usage_limit: 5,
uses_count: 5,
user: @user,
event: @event
)
assert_not limit_reached_code.can_be_used?
# Can be used: no usage limit
no_limit_code = PromotionCode.create(
code: "NOLIMIT1",
discount_amount_cents: 500,
active: true,
expires_at: 1.month.from_now,
usage_limit: nil,
uses_count: 100,
user: @user,
event: @event
)
assert no_limit_code.can_be_used?
end
# Test increment_uses_count callback
def test_increment_uses_count_callback
promotion_code = PromotionCode.create(
code: "INCREMENT1",
discount_amount_cents: 500,
uses_count: 0,
user: @user,
event: @event
)
assert_equal 0, promotion_code.uses_count
# The callback should only run on create, so we test the initial value
new_code = PromotionCode.create(
code: "INCREMENT2",
discount_amount_cents: 500,
uses_count: nil,
user: @user,
event: @event
)
assert_equal 0, new_code.uses_count
end
end

View File

@@ -367,21 +367,4 @@ class TicketTest < ActiveSupport::TestCase
) )
assert ticket.save assert ticket.save
end end
# Payout-related tests
test "after_update callback triggers earning recalculation on refund status change" do
user = User.create!(email: "refund@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Refund Event", slug: "refund-event", description: "Valid description", venue_name: "v", venue_address: "a", latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, user: user, state: :published)
ticket_type = TicketType.create!(name: "Standard", price_cents: 1000, quantity: 1, sale_start_at: Time.current, sale_end_at: Time.current + 1.day, event: event)
order = Order.create!(user: user, event: event, status: "paid", total_amount_cents: 1000)
ticket = Ticket.create!(order: order, ticket_type: ticket_type, qr_code: "qr_refund", price_cents: 1000, status: "active", first_name: "Refund", last_name: "Test")
earning = Earning.create!(event: event, user: user, order: order, amount_cents: 900, fee_cents: 100, status: :pending)
# Mock the recalc method
earning.expects(:recalculate_on_refund).once
# Change status to refunded
ticket.status = "refunded"
ticket.save!
end
end end

View File

@@ -92,40 +92,4 @@ class UserTest < ActiveSupport::TestCase
user.update!(onboarding_completed: true) user.update!(onboarding_completed: true)
assert_not user.needs_onboarding?, "User with true onboarding_completed should not need onboarding" assert_not user.needs_onboarding?, "User with true onboarding_completed should not need onboarding"
end end
# Payout-related tests
test "can_receive_payouts? returns true if stripe account id present and charges enabled" do
user = users(:one)
user.update!(iban: "FR1420041010050500013M02606", bank_name: "Test Bank", account_holder_name: "John Doe", is_professionnal: true)
assert user.can_receive_payouts?
end
test "can_receive_payouts? returns false if no banking info" do
user = users(:one)
user.update!(is_professionnal: true)
assert_not user.can_receive_payouts?
end
test "can_receive_payouts? returns false if not professional" do
user = users(:one)
user.update!(iban: "FR1420041010050500013M02606", bank_name: "Test Bank", account_holder_name: "John Doe")
assert_not user.can_receive_payouts?
end
test "can_receive_payouts? returns false if missing IBAN" do
user = users(:one)
user.update!(bank_name: "Test Bank", account_holder_name: "John Doe", is_professionnel: true)
assert_not user.can_receive_payouts?
end
test "can_receive_payouts? returns false if missing bank name" do
user = users(:one)
user.update!(iban: "FR1420041010050500013M02606", account_holder_name: "John Doe", is_professionnel: true)
assert_not user.can_receive_payouts?
end
end end

View File

@@ -1,44 +0,0 @@
require "test_helper"
require "stripe"
class PayoutServiceTest < ActiveSupport::TestCase
setup do
@user = users(:one)
@event = events(:concert_event)
@payout = Payout.create!(user: @user, event: @event, amount_cents: 9000, fee_cents: 1000)
Stripe.api_key = "test_key"
end
test "generate_transfer_summary returns payout details" do
@user.update!(iban: "FR1420041010050500013M02606", bank_name: "Test Bank", account_holder_name: "John Doe")
@payout.update(status: :approved)
service = PayoutService.new(@payout)
summary = service.generate_transfer_summary
assert_not_nil summary
assert_equal @payout.id, summary[:payout_id]
assert_equal @user.name, summary[:recipient]
assert_equal @user.account_holder_name, summary[:account_holder]
assert_equal @user.bank_name, summary[:bank_name]
assert_equal @user.iban, summary[:iban]
end
test "validate_banking_info returns errors for missing data" do
service = PayoutService.new(@payout)
errors = service.validate_banking_info
assert_includes errors, "Missing IBAN"
assert_includes errors, "Missing bank name"
assert_includes errors, "Missing account holder name"
end
test "validate_banking_info returns no errors for complete data" do
@user.update!(iban: "FR1420041010050500013M02606", bank_name: "Test Bank", account_holder_name: "John Doe")
service = PayoutService.new(@payout)
errors = service.validate_banking_info
assert_empty errors
end
end

Some files were not shown because too many files have changed in this diff Show More