Files
aperonight/docs/invoice-system-implementation.md
kbe 61079c8171 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>
2025-09-04 02:18:50 +02:00

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

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

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.