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:
kbe
2025-09-04 02:18:50 +02:00
parent e1edc1afcd
commit 61079c8171
8 changed files with 590 additions and 205 deletions

View 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.