- 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>
15 KiB
15 KiB
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:
# 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:
# 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:
# 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
# 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
# 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
# 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
# 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
# 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
<!-- 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
# 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
# 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.