- 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>
572 lines
15 KiB
Markdown
572 lines
15 KiB
Markdown
# 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. |