Date: October 7, 2025 Status: Planning Phase
This document outlines options for automating the monthly invoicing process, particularly for consolidating multiple orders into single invoices and streamlining invoice delivery to customers.
Pain Points:
Effort: Small Risk: Very Low Control: Full manual control
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
app/views/admin/dashboard/_ready_to_invoice.html.erbapp/views/admin/invoices/new.html.erb
Effort: Medium Risk: Low Control: Review before sending
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
app/views/admin/billing_runs/index.html.erb - List of billing runsapp/views/admin/billing_runs/new.html.erb - Setup new billing runapp/views/admin/billing_runs/show.html.erb - Billing run resultsapp/views/admin/billing_runs/_company_row.html.erb - Preview rowapp/views/admin/invoices/index.html.erb - Add bulk actions10. 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>
Effort: Large Risk: Medium Control: Automated with monitoring
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
✅ Best balance of automation and control
✅ Low risk
✅ Can upgrade to Level 3 later
Decision needed: Which order statuses are billable?
completed only? (safest)completed + active? (for recurring rentals)delivered + active + completed?Recommendation: Start with completed only, expand as needed.
Scenario: Order cancelled after month-end but before invoice generated.
Solution:
Scenario: Customer wants 2 invoices per month (15th and month-end).
Solution:
last_billing_run_date to prevent duplicatesScenario: Need to credit an order after invoicing.
Solution:
Scenario: Some customers want detail, others want one line.
Options:
by_order - One line per order (default)by_product_type - Group dumpsters, equipment, etc.single_line_item - "Rental services for October 2025 - $X"Recommendation: Start with by_order, add others if requested.
Issue: Billing run at midnight - which timezone?
Solution:
.beginning_of_day / .end_of_day for date ranges# 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
Before implementing, discuss:
Billing day preference?
Grace period?
Minimum threshold?
Order status inclusion?
Preview requirement?
Error handling?
Audit requirements?
Recommended approach: Implement Level 2 (Batch Generation) automation.
Expected benefits:
Timeline: 3-4 weeks for complete implementation and testing
Next steps: