Invoice Automation Proposal

Date: October 7, 2025 Status: Planning Phase

Overview

This document outlines options for automating the monthly invoicing process, particularly for consolidating multiple orders into single invoices and streamlining invoice delivery to customers.

Current Workflow

  1. Customer creates multiple orders throughout the month
  2. Admin manually navigates to "Create Invoice"
  3. Admin selects customer and checks orders to include
  4. Admin creates consolidated invoice
  5. Admin manually sends invoice email
  6. Process repeats for each customer

Pain Points:


Automation Options

Level 1: Smart Defaults (Low Automation)

Effort: Small Risk: Very Low Control: Full manual control

Features

User Experience

Code Changes Required

1. Dashboard Widget

# app/controllers/admin/dashboard_controller.rb
def index
  @ready_to_invoice = Company.joins(:orders)
                             .where(orders: { invoice_id: nil })
                             .where.not(orders: { status: ['quote', 'cancelled'] })
                             .group('companies.id')
                             .select('companies.*, COUNT(orders.id) as uninvoiced_count,
                                      SUM(orders.total_amount) as uninvoiced_total')
                             .having('COUNT(orders.id) > 0')
                             .order('uninvoiced_total DESC')
end

2. Order Model Scope

# app/models/order.rb
scope :uninvoiced, -> {
  where(invoice_id: nil)
    .where.not(status: ['quote', 'cancelled'])
}

scope :billable, -> {
  uninvoiced.where(status: ['completed', 'active', 'delivered'])
}

3. Invoice Controller Enhancement

# app/controllers/admin/invoices_controller.rb
def new
  # ... existing code ...

  if params[:company_id].present?
    @company = Company.find(params[:company_id])
    @available_orders = @company.orders.billable
                                      .order(created_at: :desc)
    @suggested_date_range = {
      start: 1.month.ago.beginning_of_month,
      end: 1.month.ago.end_of_month
    }
  end
end

4. View Enhancements


Level 2: Batch Generation (Medium Automation) ⭐ RECOMMENDED

Effort: Medium Risk: Low Control: Review before sending

Features

User Experience

  1. Navigate to "Monthly Billing Run"
  2. See list of customers ready to invoice
  3. Select which to include (or "Select All")
  4. Click "Generate Drafts"
  5. System creates all invoices as drafts
  6. Review each draft, make adjustments
  7. Select drafts to send
  8. Click "Send Selected"

Code Changes Required

1. Database Migration: Add Billing Cycle to Companies

# db/migrate/[timestamp]_add_billing_settings_to_companies.rb
class AddBillingSettingsToCompanies < ActiveRecord::Migration[8.0]
  def change
    add_column :companies, :billing_cycle, :string, default: 'manual'
    add_column :companies, :billing_day, :integer, default: 1
    add_column :companies, :auto_consolidate, :boolean, default: true
    add_column :companies, :auto_send_invoices, :boolean, default: false
    add_column :companies, :invoice_grouping_preference, :string, default: 'by_order'
    add_column :companies, :last_billing_run_date, :date

    add_index :companies, :billing_cycle
    add_index :companies, :billing_day
  end
end

2. Company Model Updates

# app/models/company.rb
BILLING_CYCLES = %w[
  manual
  weekly
  monthly_1st
  monthly_15th
  monthly_last_day
  on_delivery
  custom
].freeze

INVOICE_GROUPING = %w[
  by_order
  by_product_type
  single_line_item
].freeze

validates :billing_cycle, inclusion: { in: BILLING_CYCLES }
validates :billing_day, numericality: {
  only_integer: true,
  greater_than_or_equal_to: 1,
  less_than_or_equal_to: 31
}, if: -> { billing_cycle == 'custom' }

scope :due_for_billing, -> {
  where(auto_consolidate: true)
    .where('last_billing_run_date IS NULL OR last_billing_run_date < ?', 1.month.ago)
}

def ready_for_billing?(as_of_date = Date.current)
  return false unless auto_consolidate
  return true if last_billing_run_date.nil?

  case billing_cycle
  when 'monthly_1st'
    as_of_date.day == 1 && last_billing_run_date < as_of_date.beginning_of_month
  when 'monthly_15th'
    as_of_date.day == 15 && last_billing_run_date < as_of_date - 15.days
  when 'weekly'
    as_of_date.wday == 1 && last_billing_run_date < as_of_date - 7.days
  when 'custom'
    as_of_date.day == billing_day && last_billing_run_date < as_of_date.beginning_of_month
  else
    false # manual
  end
end

def uninvoiced_orders_for_period(start_date, end_date)
  orders.billable
        .where(created_at: start_date..end_date)
        .order(created_at: :asc)
end

3. New Controller: Billing Runs

# app/controllers/admin/billing_runs_controller.rb
class Admin::BillingRunsController < ApplicationController
  before_action :require_authentication

  def index
    # Show overview of billing runs
    @billing_runs = BillingRun.includes(:invoices, :company)
                               .order(created_at: :desc)
                               .page(params[:page])
  end

  def new
    # Setup for new billing run
    @billing_period_start = params[:start_date]&.to_date || 1.month.ago.beginning_of_month
    @billing_period_end = params[:end_date]&.to_date || 1.month.ago.end_of_month

    @companies_to_bill = Company.includes(:orders)
                                .where(auto_consolidate: true)
                                .select { |c| c.uninvoiced_orders_for_period(@billing_period_start, @billing_period_end).any? }
                                .map do |company|
      orders = company.uninvoiced_orders_for_period(@billing_period_start, @billing_period_end)
      {
        company: company,
        orders: orders,
        order_count: orders.count,
        total_amount: orders.sum(&:total_amount)
      }
    end.sort_by { |c| -c[:total_amount] }
  end

  def create
    @billing_run = BillingRun.new(billing_run_params)
    @billing_run.created_by = current_user.id

    if @billing_run.save
      # Process in background job
      GenerateInvoicesJob.perform_later(@billing_run.id, billing_run_params[:company_ids])

      redirect_to admin_billing_run_path(@billing_run),
                  notice: "Billing run started. Generating #{billing_run_params[:company_ids].count} invoices..."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def show
    @billing_run = BillingRun.includes(invoices: :company).find(params[:id])
  end

  private

  def billing_run_params
    params.require(:billing_run).permit(
      :billing_period_start,
      :billing_period_end,
      :notes,
      company_ids: []
    )
  end
end

4. New Model: Billing Run

# app/models/billing_run.rb
class BillingRun < ApplicationRecord
  belongs_to :user, foreign_key: :created_by
  has_many :invoices, dependent: :nullify

  validates :billing_period_start, :billing_period_end, presence: true
  validate :end_date_after_start_date

  enum status: {
    pending: 0,
    processing: 1,
    completed: 2,
    failed: 3
  }

  def summary
    {
      total_invoices: invoices.count,
      total_amount: invoices.sum(:total),
      draft_count: invoices.where(status: 'draft').count,
      sent_count: invoices.where(status: 'sent').count,
      failed_count: errors_log.present? ? errors_log.count : 0
    }
  end

  private

  def end_date_after_start_date
    if billing_period_end.present? && billing_period_start.present? &&
       billing_period_end < billing_period_start
      errors.add(:billing_period_end, "must be after start date")
    end
  end
end

5. Database Migration: Billing Runs

# db/migrate/[timestamp]_create_billing_runs.rb
class CreateBillingRuns < ActiveRecord::Migration[8.0]
  def change
    create_table :billing_runs do |t|
      t.date :billing_period_start, null: false
      t.date :billing_period_end, null: false
      t.integer :status, default: 0, null: false
      t.integer :created_by, null: false
      t.text :notes
      t.jsonb :errors_log, default: {}
      t.integer :invoices_generated_count, default: 0

      t.timestamps
    end

    add_index :billing_runs, :status
    add_index :billing_runs, :billing_period_start
    add_index :billing_runs, :created_by

    add_column :invoices, :billing_run_id, :integer
    add_index :invoices, :billing_run_id
  end
end

6. Background Job: Generate Invoices

# app/jobs/generate_invoices_job.rb
class GenerateInvoicesJob < ApplicationJob
  queue_as :default

  def perform(billing_run_id, company_ids)
    billing_run = BillingRun.find(billing_run_id)
    billing_run.update!(status: :processing)

    errors = []
    generated_count = 0

    company_ids.each do |company_id|
      begin
        company = Company.find(company_id)
        orders = company.uninvoiced_orders_for_period(
          billing_run.billing_period_start,
          billing_run.billing_period_end
        )

        next if orders.empty?

        invoice = Invoice.create!(
          company: company,
          billing_run: billing_run,
          invoice_date: Date.current,
          status: 'draft',
          payment_terms: company.payment_method == 'purchase_order' ? 'net_30' : 'on_receipt',
          notes: "Billing period: #{billing_run.billing_period_start.strftime('%m/%d/%Y')} - #{billing_run.billing_period_end.strftime('%m/%d/%Y')}"
        )

        # Add orders to invoice
        orders.each do |order|
          invoice.add_order(order)
        end

        generated_count += 1
        company.update!(last_billing_run_date: Date.current)

      rescue => e
        errors << {
          company_id: company_id,
          company_name: company&.name || "Unknown",
          error: e.message,
          backtrace: e.backtrace.first(5)
        }
      end
    end

    billing_run.update!(
      status: errors.any? ? :failed : :completed,
      invoices_generated_count: generated_count,
      errors_log: errors
    )
  end
end

7. Bulk Invoice Actions

# app/controllers/admin/invoices_controller.rb

# Add new actions:

def bulk_send
  invoice_ids = params[:invoice_ids] || []
  invoices = Invoice.where(id: invoice_ids, status: 'draft')

  sent_count = 0
  invoices.each do |invoice|
    InvoiceMailer.invoice_email(invoice).deliver_later
    invoice.update(status: 'sent')
    sent_count += 1
  end

  redirect_to admin_invoices_path, notice: "#{sent_count} invoices sent successfully."
end

def bulk_mark_sent
  invoice_ids = params[:invoice_ids] || []
  Invoice.where(id: invoice_ids, status: 'draft').update_all(status: 'sent')

  redirect_to admin_invoices_path, notice: "#{invoice_ids.count} invoices marked as sent."
end

8. Routes

# config/routes.rb
namespace :admin do
  # ... existing routes ...

  resources :billing_runs, only: [:index, :new, :create, :show]

  resources :invoices do
    collection do
      post :bulk_send
      post :bulk_mark_sent
    end
    # ... existing member routes ...
  end
end

9. Views to Create

10. Company Form Update

# app/views/companies/_form.html.erb
# Add billing settings section:

<div class="border-b border-slate-200 pb-6">
  <h3 class="text-base font-semibold leading-7 text-slate-900">Billing Settings</h3>
  <p class="mt-1 text-sm text-slate-600">Configure automatic invoicing preferences.</p>

  <div class="mt-6 grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2">
    <div>
      <%= form.label :billing_cycle, class: "block text-sm font-medium text-slate-700" %>
      <%= form.select :billing_cycle,
          options_for_select([
            ['Manual - Create invoices manually', 'manual'],
            ['Monthly - 1st of month', 'monthly_1st'],
            ['Monthly - 15th of month', 'monthly_15th'],
            ['Monthly - Last day of month', 'monthly_last_day'],
            ['Weekly - Every Monday', 'weekly'],
            ['Custom day of month', 'custom']
          ], company.billing_cycle),
          {},
          class: "mt-1 block w-full rounded-md border-slate-300" %>
    </div>

    <div>
      <%= form.label :auto_consolidate, class: "block text-sm font-medium text-slate-700" %>
      <div class="mt-2">
        <%= form.check_box :auto_consolidate, class: "rounded border-slate-300" %>
        <span class="ml-2 text-sm text-slate-600">
          Automatically consolidate orders into invoices
        </span>
      </div>
    </div>
  </div>
</div>

Level 3: Full Automation (High Automation)

Effort: Large Risk: Medium Control: Automated with monitoring

Features

User Experience

  1. Set up billing cycles per customer (one-time)
  2. System runs automatically on schedule
  3. Receive email: "10 invoices generated and sent"
  4. Review exception report for issues
  5. Manually handle edge cases

Code Changes Required

All Level 2 changes, plus:

1. Scheduled Job

# app/jobs/automated_billing_run_job.rb
class AutomatedBillingRunJob < ApplicationJob
  queue_as :default

  def perform
    # Find companies due for billing today
    companies_to_bill = Company.where(auto_consolidate: true)
                               .select { |c| c.ready_for_billing? }

    return if companies_to_bill.empty?

    # Determine billing period (last month)
    billing_period_start = 1.month.ago.beginning_of_month
    billing_period_end = 1.month.ago.end_of_month

    # Create billing run
    billing_run = BillingRun.create!(
      billing_period_start: billing_period_start,
      billing_period_end: billing_period_end,
      status: :processing,
      created_by: User.find_by(email: 'system@quarryrentals.com')&.id || 1,
      notes: "Automated billing run for #{billing_period_start.strftime('%B %Y')}"
    )

    company_ids = companies_to_bill.map(&:id)

    # Generate invoices
    GenerateInvoicesJob.perform_now(billing_run.id, company_ids)

    # Auto-send if configured
    if billing_run.completed?
      billing_run.invoices.draft.each do |invoice|
        if invoice.company.auto_send_invoices?
          InvoiceMailer.invoice_email(invoice).deliver_later
          invoice.update(status: 'sent')
        end
      end
    end

    # Send summary email to admin
    AdminMailer.billing_run_summary(billing_run).deliver_later
  end
end

2. Schedule Configuration

# config/schedule.rb (using whenever gem)
# Or use Solid Queue recurring tasks

every 1.day, at: '6:00 am' do
  runner "AutomatedBillingRunJob.perform_later"
end

3. Admin Notification Mailer

# app/mailers/admin_mailer.rb
class AdminMailer < ApplicationMailer
  def billing_run_summary(billing_run)
    @billing_run = billing_run
    @summary = billing_run.summary

    mail(
      to: ENV['ADMIN_EMAILS'],
      subject: "Billing Run Complete: #{@summary[:total_invoices]} invoices generated"
    )
  end
end

4. Exception Monitoring Integration

# In GenerateInvoicesJob
rescue => e
  # Log to monitoring service (Sentry, Bugsnag, etc.)
  Sentry.capture_exception(e, extra: {
    billing_run_id: billing_run_id,
    company_id: company_id
  })

  errors << { ... }
end

Recommendation: Level 2 (Batch Generation)

Why Level 2?

Best balance of automation and control

Low risk

Can upgrade to Level 3 later

Implementation Phases

Phase 1: Foundation (Week 1)

  1. Add billing cycle fields to Company model
  2. Create BillingRun model and migration
  3. Add Order scopes for billable/uninvoiced
  4. Update company form with billing settings

Phase 2: Billing Run Interface (Week 2)

  1. Create BillingRunsController
  2. Build "new billing run" preview page
  3. Create GenerateInvoicesJob
  4. Test with 2-3 test companies

Phase 3: Bulk Actions (Week 3)

  1. Add bulk send/mark sent actions
  2. Add checkboxes to invoice index
  3. Create billing run history view
  4. Testing and refinement

Phase 4: Polish & Documentation (Week 4)

  1. Add dashboard widgets
  2. Create user documentation
  3. Training for staff
  4. Full production rollout

Edge Cases & Considerations

1. What orders should be included?

Decision needed: Which order statuses are billable?

Recommendation: Start with completed only, expand as needed.

2. Mid-month changes

Scenario: Order cancelled after month-end but before invoice generated.

Solution:

3. Partial invoicing

Scenario: Customer wants 2 invoices per month (15th and month-end).

Solution:

4. Credits and adjustments

Scenario: Need to credit an order after invoicing.

Solution:

5. Different grouping preferences

Scenario: Some customers want detail, others want one line.

Options:

Recommendation: Start with by_order, add others if requested.

6. Timezone considerations

Issue: Billing run at midnight - which timezone?

Solution:


Testing Strategy

Manual Testing Checklist

Automated Tests

# test/jobs/generate_invoices_job_test.rb
# test/models/billing_run_test.rb
# test/models/company_test.rb (billing cycle logic)
# test/controllers/admin/billing_runs_controller_test.rb

Rollout Plan

Phase 1: Beta (First Month)

Phase 2: Expanded (Month 2)

Phase 3: Full Rollout (Month 3)


Success Metrics

Time Savings

Accuracy

Customer Satisfaction


Future Enhancements

Near-term (3-6 months)

Long-term (6-12 months)


Questions for Consideration

Before implementing, discuss:

  1. Billing day preference?

  2. Grace period?

  3. Minimum threshold?

  4. Order status inclusion?

  5. Preview requirement?

  6. Error handling?

  7. Audit requirements?


Conclusion

Recommended approach: Implement Level 2 (Batch Generation) automation.

Expected benefits:

Timeline: 3-4 weeks for complete implementation and testing

Next steps:

  1. Review and approve this proposal
  2. Prioritize edge case decisions
  3. Begin Phase 1 implementation
  4. Schedule weekly check-ins during development