develop #3
@@ -1,82 +0,0 @@
|
||||
|
||||
.footer {
|
||||
background: var(--color-neutral-800);
|
||||
color: var(--color-neutral-300);
|
||||
padding: var(--space-8) 0 var(--space-4);
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.footer-content {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.footer-content {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.footer-section h3 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-3);
|
||||
color: white;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.footer-links li {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--color-neutral-400);
|
||||
text-decoration: none;
|
||||
transition: color var(--duration-normal);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: var(--color-accent-400);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
border-top: 1px solid var(--color-neutral-700);
|
||||
padding-top: var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--color-neutral-400);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero {
|
||||
padding: var(--space-8) 0 var(--space-6);
|
||||
}
|
||||
|
||||
.cta-group {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
<header class="bg-neutral-800 border-b border-neutral-700">
|
||||
<nav data-controller="header" class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center h-16 justify-between">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<%= link_to Rails.application.config.app_name, current_user ? "/dashboard" : "/",
|
||||
class: "text-xl font-bold text-white" %>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden sm:flex items-center space-x-6">
|
||||
<div class="hidden sm:flex items-center space-x-6 w-full justify-start">
|
||||
<%= link_to t("header.parties"), events_path,
|
||||
class: "text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
|
||||
class: "mx-4 text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
|
||||
<%= link_to t("header.concerts"), "#",
|
||||
class: "text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
|
||||
class: "mx-4 text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
|
||||
</div>
|
||||
|
||||
<!-- Authentication -->
|
||||
@@ -26,17 +26,6 @@
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div data-header-target="userMenu" class="absolute right-0 mt-2 w-48 rounded-md shadow-lg z-50 hidden">
|
||||
<%= link_to t("header.profile"), edit_user_registration_path,
|
||||
class: "block px-4 py-2 text-sm text-gray-100 hover:bg-purple-700 first:rounded-t-md" %>
|
||||
<%= link_to t("header.reservations"), "#",
|
||||
class: "block px-4 py-2 text-sm text-gray-100 hover:bg-purple-700" %>
|
||||
<%= link_to t("header.logout"), destroy_user_session_path,
|
||||
data: { controller: "logout", action: "click->logout#signOut",
|
||||
logout_url_value: destroy_user_session_path, redirect_url_value: "/", turbo: false },
|
||||
class: "block px-4 py-2 text-sm text-gray-100 hover:bg-purple-700 last:rounded-b-md" %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= link_to t("header.login"), new_user_session_path,
|
||||
@@ -47,7 +36,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<div class="flex items-center sm:hidden">
|
||||
<div class="flex-shrink-0 sm:hidden">
|
||||
<button data-action="click->header#toggleMobileMenu" data-header-target="mobileMenuButton" class="p-2 rounded-md text-neutral-300 hover:text-white hover:bg-purple-700">
|
||||
<svg data-menu-icon="open" class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
événements trouvés
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2 text-sm">
|
||||
|
||||
@@ -5,3 +5,6 @@ Rails.application.config.assets.version = "1.0"
|
||||
|
||||
# Add additional assets to the asset load path.
|
||||
# Rails.application.config.assets.paths << Emoji.images_path
|
||||
|
||||
# Add build output directory to asset load path for Propshaft
|
||||
# Rails.application.config.assets.paths << Rails.root.join("app/assets/builds")
|
||||
|
||||
572
docs/invoice-system-implementation.md
Normal file
572
docs/invoice-system-implementation.md
Normal file
@@ -0,0 +1,572 @@
|
||||
# Invoice System Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines various approaches for adding invoice generation to the ApéroNight event ticketing system, based on the current Rails architecture with Stripe integration.
|
||||
|
||||
## Current System Analysis
|
||||
|
||||
### Existing Infrastructure
|
||||
- **Rails 8.0.2** with MySQL database
|
||||
- **Stripe** payment processing via Checkout Sessions
|
||||
- **Prawn** gem for PDF generation (tickets)
|
||||
- **Order/Ticket** models with pricing logic
|
||||
- **Devise** for user authentication
|
||||
|
||||
### Current Payment Flow
|
||||
```
|
||||
User selects tickets → Order created (draft) → Stripe Checkout → Payment → Order marked as paid → Tickets activated → PDF tickets emailed
|
||||
```
|
||||
|
||||
## Invoice Generation Approaches
|
||||
|
||||
### 1. Extend Existing PDF Infrastructure (Recommended)
|
||||
|
||||
**Pros**:
|
||||
- Leverages existing Prawn setup
|
||||
- Consistent styling with tickets
|
||||
- No additional dependencies
|
||||
|
||||
**Implementation**:
|
||||
```ruby
|
||||
# app/services/invoice_pdf_generator.rb
|
||||
class InvoicePdfGenerator
|
||||
attr_reader :order
|
||||
|
||||
def initialize(order)
|
||||
@order = order
|
||||
end
|
||||
|
||||
def generate
|
||||
Prawn::Document.new(page_size: "A4", margin: 40) do |pdf|
|
||||
# Header
|
||||
pdf.fill_color "2D1B69"
|
||||
pdf.font "Helvetica", style: :bold, size: 24
|
||||
pdf.text "ApéroNight Invoice", align: :center
|
||||
pdf.move_down 20
|
||||
|
||||
# Invoice details
|
||||
pdf.fill_color "000000"
|
||||
pdf.font "Helvetica", size: 12
|
||||
|
||||
# Invoice number and date
|
||||
pdf.text "Invoice #: #{@order.invoice_number}"
|
||||
pdf.text "Date: #{@order.created_at.strftime('%B %d, %Y')}"
|
||||
pdf.text "Due Date: #{@order.created_at.strftime('%B %d, %Y')}" # Same day for events
|
||||
pdf.move_down 20
|
||||
|
||||
# Customer details
|
||||
pdf.text "Bill To:", style: :bold
|
||||
pdf.text @order.user.email
|
||||
pdf.move_down 20
|
||||
|
||||
# Order details table
|
||||
pdf.text "Event: #{@order.event.name}", style: :bold, size: 14
|
||||
pdf.move_down 10
|
||||
|
||||
# Line items
|
||||
items = [["Description", "Quantity", "Unit Price", "Total"]]
|
||||
|
||||
@order.tickets.group_by(&:ticket_type).each do |ticket_type, tickets|
|
||||
items << [
|
||||
"#{ticket_type.name} - #{@order.event.name}",
|
||||
tickets.count.to_s,
|
||||
"€#{ticket_type.price_cents / 100.0}",
|
||||
"€#{(tickets.count * ticket_type.price_cents) / 100.0}"
|
||||
]
|
||||
end
|
||||
|
||||
pdf.table(items, header: true, width: pdf.bounds.width) do
|
||||
row(0).font_style = :bold
|
||||
columns(1..3).align = :right
|
||||
end
|
||||
|
||||
pdf.move_down 20
|
||||
|
||||
# Total
|
||||
pdf.text "Total: €#{@order.total_amount_cents / 100.0}",
|
||||
style: :bold, size: 16, align: :right
|
||||
|
||||
# Footer
|
||||
pdf.move_down 40
|
||||
pdf.text "Thank you for your purchase!", align: :center
|
||||
pdf.text "Generated on #{Time.current.strftime('%B %d, %Y at %I:%M %p')}",
|
||||
align: :center, size: 8
|
||||
end.render
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 2. HTML-to-PDF Solutions
|
||||
|
||||
**Using WickedPdf**:
|
||||
```ruby
|
||||
# Gemfile
|
||||
gem 'wicked_pdf'
|
||||
|
||||
# app/controllers/invoices_controller.rb
|
||||
def show
|
||||
@order = current_user.orders.find(params[:order_id])
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.pdf do
|
||||
render pdf: "invoice_#{@order.id}",
|
||||
template: 'invoices/show.html.erb',
|
||||
layout: 'pdf'
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 3. Third-Party Services
|
||||
|
||||
**Stripe Invoicing Integration**:
|
||||
```ruby
|
||||
# app/services/stripe_invoice_service.rb
|
||||
class StripeInvoiceService
|
||||
def initialize(order)
|
||||
@order = order
|
||||
end
|
||||
|
||||
def create_post_payment_invoice
|
||||
customer = find_or_create_stripe_customer
|
||||
|
||||
invoice = Stripe::Invoice.create({
|
||||
customer: customer.id,
|
||||
collection_method: 'charge_automatically',
|
||||
paid: true, # Already paid via checkout
|
||||
metadata: {
|
||||
order_id: @order.id,
|
||||
user_id: @order.user.id
|
||||
}
|
||||
})
|
||||
|
||||
# Add line items
|
||||
@order.tickets.group_by(&:ticket_type).each do |ticket_type, tickets|
|
||||
Stripe::InvoiceItem.create({
|
||||
customer: customer.id,
|
||||
invoice: invoice.id,
|
||||
amount: ticket_type.price_cents * tickets.count,
|
||||
currency: 'eur',
|
||||
description: "#{@order.event.name} - #{ticket_type.name} (#{tickets.count}x)"
|
||||
})
|
||||
end
|
||||
|
||||
invoice.finalize_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,
|
||||
metadata: { user_id: @order.user.id }
|
||||
})
|
||||
|
||||
@order.user.update(stripe_customer_id: customer.id)
|
||||
customer
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Stripe Payment Methods Comparison
|
||||
|
||||
### Payment Intents vs Invoicing vs Checkout Sessions
|
||||
|
||||
| Feature | Payment Intents | Stripe Invoicing | Checkout Sessions (Current) |
|
||||
|---------|----------------|------------------|---------------------------|
|
||||
| **Timing** | Immediate | Deferred (days/weeks) | Immediate |
|
||||
| **User Experience** | Custom UI on your site | Stripe-hosted invoice page | Stripe-hosted checkout |
|
||||
| **Payment Methods** | Cards, wallets, BNPL | Cards, bank transfers, checks | Cards, wallets, BNPL |
|
||||
| **Documentation** | Custom receipts | Formal invoices | Stripe receipts |
|
||||
| **Integration Complexity** | Medium | Low | Low |
|
||||
| **Best For** | Custom checkout flows | B2B billing | Quick implementation |
|
||||
|
||||
### For Event Ticketing Use Case
|
||||
|
||||
**Current Checkout Sessions are ideal** because:
|
||||
- Events require immediate payment confirmation
|
||||
- Time-sensitive inventory management
|
||||
- Users expect instant ticket delivery
|
||||
- Built-in fraud protection
|
||||
|
||||
**Recommended**: Keep Checkout Sessions, add invoice generation for accounting records
|
||||
|
||||
## Recommended Implementation
|
||||
|
||||
### Phase 1: Database Schema
|
||||
|
||||
```ruby
|
||||
# Migration
|
||||
class CreateInvoices < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :invoices do |t|
|
||||
t.references :order, null: false, foreign_key: true
|
||||
t.string :invoice_number, null: false
|
||||
t.integer :total_amount_cents, null: false
|
||||
t.string :currency, default: 'eur'
|
||||
t.string :status, default: 'issued' # issued, paid, cancelled
|
||||
t.datetime :issued_at
|
||||
t.datetime :paid_at
|
||||
t.string :stripe_invoice_id # Optional: if using Stripe
|
||||
t.text :notes
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :invoices, :invoice_number, unique: true
|
||||
add_index :invoices, :status
|
||||
end
|
||||
end
|
||||
|
||||
# Add to User model for Stripe integration
|
||||
class AddStripeFieldsToUsers < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :users, :stripe_customer_id, :string
|
||||
add_index :users, :stripe_customer_id
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Phase 2: Models
|
||||
|
||||
```ruby
|
||||
# app/models/invoice.rb
|
||||
class Invoice < ApplicationRecord
|
||||
belongs_to :order
|
||||
has_one :user, through: :order
|
||||
has_one :event, through: :order
|
||||
|
||||
validates :invoice_number, presence: true, uniqueness: true
|
||||
validates :total_amount_cents, presence: true, numericality: { greater_than: 0 }
|
||||
validates :status, inclusion: { in: %w[issued paid cancelled] }
|
||||
|
||||
before_validation :generate_invoice_number, on: :create
|
||||
before_validation :set_defaults, on: :create
|
||||
|
||||
scope :paid, -> { where(status: 'paid') }
|
||||
scope :unpaid, -> { where(status: 'issued') }
|
||||
|
||||
def total_amount_euros
|
||||
total_amount_cents / 100.0
|
||||
end
|
||||
|
||||
def generate_pdf
|
||||
InvoicePdfGenerator.new(self).generate
|
||||
end
|
||||
|
||||
def mark_as_paid!
|
||||
update!(status: 'paid', paid_at: Time.current)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_invoice_number
|
||||
return if invoice_number.present?
|
||||
|
||||
year = Time.current.year
|
||||
month = Time.current.strftime('%m')
|
||||
|
||||
# Find highest invoice number for current month
|
||||
last_invoice = Invoice.where(
|
||||
'invoice_number LIKE ?',
|
||||
"INV-#{year}#{month}-%"
|
||||
).order(:invoice_number).last
|
||||
|
||||
if last_invoice
|
||||
sequence = last_invoice.invoice_number.split('-').last.to_i + 1
|
||||
else
|
||||
sequence = 1
|
||||
end
|
||||
|
||||
self.invoice_number = "INV-#{year}#{month}-#{sequence.to_s.rjust(4, '0')}"
|
||||
end
|
||||
|
||||
def set_defaults
|
||||
self.total_amount_cents = order.total_amount_cents if order
|
||||
self.issued_at = Time.current
|
||||
self.status = 'paid' if order&.status == 'paid' # Auto-mark as paid for completed orders
|
||||
end
|
||||
end
|
||||
|
||||
# app/models/order.rb - Add invoice association
|
||||
class Order < ApplicationRecord
|
||||
# ... existing code ...
|
||||
has_one :invoice, dependent: :destroy
|
||||
|
||||
# Add method to create invoice after payment
|
||||
def create_invoice!
|
||||
return invoice if invoice.present?
|
||||
|
||||
Invoice.create!(
|
||||
order: self,
|
||||
total_amount_cents: self.total_amount_cents
|
||||
)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Phase 3: Controllers
|
||||
|
||||
```ruby
|
||||
# app/controllers/invoices_controller.rb
|
||||
class InvoicesController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :set_invoice
|
||||
|
||||
def show
|
||||
# HTML view of invoice
|
||||
end
|
||||
|
||||
def download_pdf
|
||||
pdf = @invoice.generate_pdf
|
||||
|
||||
send_data pdf,
|
||||
filename: "invoice_#{@invoice.invoice_number}.pdf",
|
||||
type: 'application/pdf',
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_invoice
|
||||
@invoice = current_user.invoices.joins(:order).find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to dashboard_path, alert: "Invoice not found"
|
||||
end
|
||||
end
|
||||
|
||||
# Update app/controllers/orders_controller.rb
|
||||
class OrdersController < ApplicationController
|
||||
# ... existing code ...
|
||||
|
||||
def payment_success
|
||||
# ... existing payment success logic ...
|
||||
|
||||
if stripe_session.payment_status == "paid"
|
||||
@order.mark_as_paid!
|
||||
|
||||
# Generate invoice
|
||||
@order.create_invoice!
|
||||
|
||||
# Send confirmation emails with invoice attached
|
||||
@order.tickets.each do |ticket|
|
||||
begin
|
||||
TicketMailer.purchase_confirmation(ticket).deliver_now
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to send confirmation: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
# ... rest of existing code ...
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Phase 4: Mailer Updates
|
||||
|
||||
```ruby
|
||||
# app/mailers/ticket_mailer.rb - Update to include invoice
|
||||
class TicketMailer < ApplicationMailer
|
||||
def purchase_confirmation(ticket)
|
||||
@ticket = ticket
|
||||
@order = ticket.order
|
||||
@user = ticket.user
|
||||
@event = ticket.event
|
||||
|
||||
# Attach ticket PDF
|
||||
ticket_pdf = ticket.to_pdf
|
||||
attachments["ticket_#{ticket.id}.pdf"] = ticket_pdf
|
||||
|
||||
# Attach invoice PDF
|
||||
if @order.invoice.present?
|
||||
invoice_pdf = @order.invoice.generate_pdf
|
||||
attachments["invoice_#{@order.invoice.invoice_number}.pdf"] = invoice_pdf
|
||||
end
|
||||
|
||||
mail(
|
||||
to: @user.email,
|
||||
subject: "Your tickets for #{@event.name}"
|
||||
)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Phase 5: Routes
|
||||
|
||||
```ruby
|
||||
# config/routes.rb
|
||||
Rails.application.routes.draw do
|
||||
# ... existing routes ...
|
||||
|
||||
resources :invoices, only: [:show] do
|
||||
member do
|
||||
get :download_pdf
|
||||
end
|
||||
end
|
||||
|
||||
resources :orders do
|
||||
member do
|
||||
get :invoice # Shortcut to order's invoice
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Phase 6: Views
|
||||
|
||||
```erb
|
||||
<!-- app/views/invoices/show.html.erb -->
|
||||
<div class="max-w-4xl mx-auto p-6">
|
||||
<div class="bg-white shadow-lg rounded-lg p-8">
|
||||
<div class="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Invoice</h1>
|
||||
<p class="text-gray-600">Invoice #<%= @invoice.invoice_number %></p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-600">Date: <%= @invoice.issued_at.strftime('%B %d, %Y') %></p>
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full
|
||||
<%= @invoice.status == 'paid' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800' %>">
|
||||
<%= @invoice.status.capitalize %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice details -->
|
||||
<div class="grid grid-cols-2 gap-8 mb-8">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Bill To:</h3>
|
||||
<p class="text-gray-600"><%= @invoice.user.email %></p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Event:</h3>
|
||||
<p class="text-gray-600"><%= @invoice.event.name %></p>
|
||||
<p class="text-sm text-gray-500"><%= @invoice.event.start_time.strftime('%B %d, %Y at %I:%M %p') %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line items -->
|
||||
<div class="border-t border-b border-gray-200 py-4 mb-8">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="text-left text-gray-500 text-sm">
|
||||
<th class="pb-2">Description</th>
|
||||
<th class="pb-2 text-right">Qty</th>
|
||||
<th class="pb-2 text-right">Unit Price</th>
|
||||
<th class="pb-2 text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @invoice.order.tickets.group_by(&:ticket_type).each do |ticket_type, tickets| %>
|
||||
<tr>
|
||||
<td class="py-2"><%= ticket_type.name %></td>
|
||||
<td class="py-2 text-right"><%= tickets.count %></td>
|
||||
<td class="py-2 text-right">€<%= ticket_type.price_cents / 100.0 %></td>
|
||||
<td class="py-2 text-right">€<%= (tickets.count * ticket_type.price_cents) / 100.0 %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="text-right mb-8">
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
Total: €<%= @invoice.total_amount_euros %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end space-x-4">
|
||||
<%= link_to "Download PDF", download_pdf_invoice_path(@invoice),
|
||||
class: "bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Integration Guidelines
|
||||
|
||||
### 1. Testing Strategy
|
||||
|
||||
```ruby
|
||||
# test/services/invoice_pdf_generator_test.rb
|
||||
require 'test_helper'
|
||||
|
||||
class InvoicePdfGeneratorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@order = orders(:paid_order)
|
||||
@generator = InvoicePdfGenerator.new(@order.invoice)
|
||||
end
|
||||
|
||||
test "generates PDF successfully" do
|
||||
pdf = @generator.generate
|
||||
assert pdf.present?
|
||||
assert pdf.is_a?(String)
|
||||
end
|
||||
|
||||
test "includes order details in PDF" do
|
||||
# Test PDF content includes expected information
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 2. Performance Considerations
|
||||
|
||||
```ruby
|
||||
# app/jobs/invoice_generation_job.rb
|
||||
class InvoiceGenerationJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(order_id)
|
||||
order = Order.find(order_id)
|
||||
invoice = order.create_invoice!
|
||||
|
||||
# Generate PDF in background
|
||||
pdf = invoice.generate_pdf
|
||||
|
||||
# Store in cloud storage if needed
|
||||
# S3Service.store_invoice_pdf(invoice, pdf)
|
||||
|
||||
# Send email notification
|
||||
InvoiceMailer.invoice_ready(invoice).deliver_now
|
||||
end
|
||||
end
|
||||
|
||||
# Call from OrdersController#payment_success
|
||||
InvoiceGenerationJob.perform_later(@order.id)
|
||||
```
|
||||
|
||||
### 3. Security Considerations
|
||||
|
||||
- Ensure users can only access their own invoices
|
||||
- Validate invoice numbers are unique
|
||||
- Sanitize user input in PDF generation
|
||||
- Use HTTPS for invoice downloads
|
||||
- Consider adding invoice access tokens for sharing
|
||||
|
||||
### 4. Deployment Checklist
|
||||
|
||||
- [ ] Run database migrations
|
||||
- [ ] Update mailer templates
|
||||
- [ ] Test PDF generation in production environment
|
||||
- [ ] Verify email attachments work correctly
|
||||
- [ ] Set up monitoring for PDF generation failures
|
||||
- [ ] Add invoice generation to payment success flow
|
||||
|
||||
## Conclusion
|
||||
|
||||
The recommended approach combines the best of both worlds:
|
||||
- Keep the existing immediate payment flow for better user experience
|
||||
- Generate professional invoices for accounting and user records
|
||||
- Use existing Prawn infrastructure for consistent PDF styling
|
||||
- Optionally integrate with Stripe invoicing for advanced features
|
||||
|
||||
This implementation provides a solid foundation that can be extended with additional features like tax calculations, discounts, or integration with accounting systems.
|
||||
@@ -1,112 +1,14 @@
|
||||
const { fontFamily } = require("tailwindcss/defaultTheme")
|
||||
const { fontFamily } = require("tailwindcss/defaultTheme");
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./public/*.html',
|
||||
'./app/helpers/**/*.rb',
|
||||
'./app/javascript/**/*.js',
|
||||
'./app/views/**/*.{erb,haml,html,slim}',
|
||||
'./app/components/**/*.{erb,haml,html,slim,js}',
|
||||
"./public/*.html",
|
||||
"./app/helpers/**/*.rb",
|
||||
"./app/javascript/**/*.js",
|
||||
"./app/views/**/*.{erb,haml,html,slim}",
|
||||
"./app/components/**/*.{erb,haml,html,slim,js}",
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
// Custom theme colors from theme-rules.md
|
||||
purple: {
|
||||
50: '#faf5ff',
|
||||
100: '#f3e8ff',
|
||||
200: '#e9d5ff',
|
||||
300: '#d8b4fe',
|
||||
400: '#c084fc',
|
||||
500: '#a855f7',
|
||||
600: '#9333ea',
|
||||
700: '#7e22ce',
|
||||
800: '#6b21a8',
|
||||
900: '#581c87',
|
||||
},
|
||||
pink: {
|
||||
400: '#f472b6',
|
||||
500: '#ec4899',
|
||||
600: '#db2777',
|
||||
},
|
||||
slate: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
},
|
||||
// Existing shadcn colors
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
theme: {},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
};
|
||||
|
||||
0
tmp/pids/.keep
Executable file → Normal file
0
tmp/pids/.keep
Executable file → Normal file
0
tmp/storage/.keep
Executable file → Normal file
0
tmp/storage/.keep
Executable file → Normal file
Reference in New Issue
Block a user