docs: Add comprehensive invoice system implementation guide
- Document various invoice generation approaches (PDF, HTML-to-PDF, Stripe) - Compare Stripe Payment Intents vs Invoicing vs Checkout Sessions - Provide complete code implementation with models, controllers, services - Include phase-by-phase implementation strategy for current use case - Add testing, security, and deployment guidelines - Recommend hybrid approach: keep current checkout + post-payment invoices 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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">
|
<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">
|
<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 -->
|
<!-- Logo -->
|
||||||
<div class="flex items-center">
|
<div class="flex-shrink-0 flex items-center">
|
||||||
<%= link_to Rails.application.config.app_name, current_user ? "/dashboard" : "/",
|
<%= link_to Rails.application.config.app_name, current_user ? "/dashboard" : "/",
|
||||||
class: "text-xl font-bold text-white" %>
|
class: "text-xl font-bold text-white" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop Navigation -->
|
<!-- 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,
|
<%= 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"), "#",
|
<%= 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>
|
</div>
|
||||||
|
|
||||||
<!-- Authentication -->
|
<!-- 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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to t("header.login"), new_user_session_path,
|
<%= link_to t("header.login"), new_user_session_path,
|
||||||
@@ -47,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- 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">
|
<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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
événements trouvés
|
événements trouvés
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<nav class="mb-6" aria-label="Breadcrumb">
|
<nav class="mb-6" aria-label="Breadcrumb">
|
||||||
<ol class="flex items-center space-x-2 text-sm">
|
<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.
|
# Add additional assets to the asset load path.
|
||||||
# Rails.application.config.assets.paths << Emoji.images_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} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: [
|
||||||
'./public/*.html',
|
"./public/*.html",
|
||||||
'./app/helpers/**/*.rb',
|
"./app/helpers/**/*.rb",
|
||||||
'./app/javascript/**/*.js',
|
"./app/javascript/**/*.js",
|
||||||
'./app/views/**/*.{erb,haml,html,slim}',
|
"./app/views/**/*.{erb,haml,html,slim}",
|
||||||
'./app/components/**/*.{erb,haml,html,slim,js}',
|
"./app/components/**/*.{erb,haml,html,slim,js}",
|
||||||
],
|
],
|
||||||
theme: {
|
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [require("tailwindcss-animate")],
|
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