Document Version: 1.0 Date: October 3, 2025 Status: Proposal / Design Document
This document proposes an Accounts Payable (AP) system to track vendor billing and payments within the Quarry Rentals application. Currently, the system tracks customer billing (Accounts Receivable) but has no mechanism to track vendor costs (Accounts Payable), creating a blind spot in financial management.
Customer Side - Fully Automated:
Order
├─ OrderBillingPeriod (recurring charges)
│ ├─ Invoices (auto-generated)
│ └─ Payments (tracked via PaymentTransaction)
└─ One-time charges
├─ Base rental fee
├─ Delivery/removal fees
└─ Overage fees (weight, time)
Vendor Side - Not Tracked:
Scenario: Monthly storage container rental
| Customer Side (AR) | Vendor Side (AP) |
|---|---|
| Customer rented: $300/mo | Vendor cost: $200/mo |
| Billing: 1st of month | Vendor bills: 15th of month |
| Cycle: Monthly (calendar) | Vendor cycle: 28-day |
| Auto-invoice generated | Need to track vendor bill |
| Customer payment tracked | Need to track our payment |
Key Issues:
Scenario: 7-day dumpster rental with overages
| Charge Type | Customer Pays | Vendor Charges Us | Current Tracking |
|---|---|---|---|
| Base rental | $350 (7 days) | $250 (7 days) | ✅ Order.base_price / VendorProduct.cost |
| Delivery | $75 | $50 | ✅ Order.delivery_fee / DeliveryZone |
| Removal | $75 | $50 | ✅ Order.removal_fee / DeliveryZone |
| Weight overage | 3 tons @ $65/ton = $195 | 3 tons @ $45/ton = $135 | ❌ Not tracked on vendor side |
| Time overage | 3 days @ $25/day = $75 | 3 days @ $15/day = $45 | ❌ Not tracked on vendor side |
| Total | $770 | $530 | Partial |
| Margin | $240 (31%) | Can't calculate! |
Key Issues:
VendorBill
- vendor_id (FK)
- order_id (FK, optional - may cover multiple orders)
- bill_number (vendor's invoice number)
- bill_date
- due_date
- bill_type (one_time, recurring, overage_adjustment)
- status (pending, approved, paid, disputed)
- subtotal
- tax_amount (if vendor charges tax)
- total_amount
- paid_amount
- balance_due
- notes
- pdf_attachment (ActiveStorage)
- created_at, updated_at
VendorBillLineItem
- vendor_bill_id (FK)
- order_id (FK, optional - ties charge to specific order)
- description (e.g., "Monthly rental - Oct 2025", "Weight overage - 3 tons")
- quantity
- unit_price
- amount
- charge_type (base_rental, delivery, removal, weight_overage, time_overage, fuel_surcharge, etc.)
- taxable (boolean)
VendorBillingPeriod
- vendor_id (FK)
- order_id (FK)
- period_start
- period_end
- amount
- status (pending, billed, paid)
- vendor_bill_id (FK, null until billed)
- billed_at
- notes
VendorPayment
- vendor_id (FK)
- vendor_bill_id (FK, optional - may pay multiple bills)
- payment_date
- payment_method (check, ach, credit_card, wire_transfer)
- payment_reference (check number, transaction ID, etc.)
- amount
- notes
- created_by_user_id (FK)
- created_at, updated_at
VendorBillPayment
- vendor_bill_id (FK)
- vendor_payment_id (FK)
- amount_applied
1. Order Created
↓
2. Order Delivered
├─ Customer billed (base + delivery)
└─ **Create VendorBill (pending)**
├─ Base rental cost
└─ Delivery cost
↓
3. Order Picked Up
├─ Calculate overages (weight, time)
├─ Customer invoiced for overages
└─ **Update VendorBill with overage line items**
↓
4. Vendor sends actual invoice
├─ Admin reviews/approves VendorBill
└─ Mark status: approved
↓
5. Payment to vendor
├─ Create VendorPayment
└─ Mark VendorBill: paid
Auto-generation Rules:
1. Recurring Order Created
↓
2. Order Delivered (Start billing)
├─ Customer: OrderBillingPeriod created
└─ **Vendor: VendorBillingPeriod created**
↓
3. Daily Job Runs (2am)
├─ Generate customer invoices (existing)
└─ **Generate vendor bills (new)**
├─ Check VendorBillingPeriod.due_for_billing
├─ Create VendorBill
└─ Mark VendorBillingPeriod: billed
↓
4. Vendor sends actual invoice
├─ Admin matches to auto-generated VendorBill
└─ Approve or adjust
↓
5. Payment to vendor
├─ Create VendorPayment
└─ Mark VendorBill: paid
Key Considerations:
class Vendor
has_many :vendor_bills
has_many :vendor_payments
has_many :vendor_billing_periods
end
class Order
has_many :vendor_bills
has_one :vendor_billing_period # For recurring orders
end
class VendorBill
belongs_to :vendor
belongs_to :order, optional: true
has_many :line_items, class_name: "VendorBillLineItem"
has_many :vendor_bill_payments
has_many :vendor_payments, through: :vendor_bill_payments
# Statuses: pending, approved, paid, disputed
# Types: one_time, recurring, overage_adjustment
end
class VendorBillingPeriod
belongs_to :vendor
belongs_to :order
belongs_to :vendor_bill, optional: true
# Similar to OrderBillingPeriod but for vendor costs
end
class VendorPayment
belongs_to :vendor
has_many :vendor_bill_payments
has_many :vendor_bills, through: :vendor_bill_payments
end
Location: /admin/vendor_bills
Features:
Screens Needed:
Location: /admin/accounts_payable
Key Metrics:
Reports:
Add Section: "Vendor Costs"
Display:
Location: vendors.quarryrents.com/bills
Allow vendors to:
Problem: Customer billed monthly on 1st, vendor bills us on 28-day cycle
Solution:
# VendorProduct defines vendor's billing terms
VendorProduct
- billing_cycle (weekly, 28_day, 30_day, monthly, anniversary)
- billing_day (for monthly)
# Order tracks BOTH cycles
Order
- billing_frequency (customer's cycle)
- Customer: OrderBillingPeriod
- Vendor: VendorBillingPeriod (separate tracking)
# Result: Independent billing periods
Customer: Oct 1 - Oct 31 ($300)
Vendor: Sep 28 - Oct 25 ($200)
Customer Overages (Existing):
Order
- weight_included: 2 tons
- weight_used: 5 tons
- weight_overage_fee: $195 (3 tons × $65)
# Calculated from:
- Product.price_per_ton or
- VendorProduct.our_price_per_ton
Vendor Overages (Proposed):
VendorBillLineItem
- charge_type: "weight_overage"
- description: "Weight overage - 3 tons"
- quantity: 3
- unit_price: $45 (from VendorProduct.vendor_price_per_ton)
- amount: $135
# Auto-generated when order completed
# Can be adjusted before approval
Per-Order Profit Analysis:
class Order
def customer_revenue
# All customer charges
base_price + delivery_fee + removal_fee +
weight_overage_fee + time_overage_fee
end
def vendor_cost
# All vendor charges
vendor_bills.sum(:total_amount)
end
def gross_profit
customer_revenue - vendor_cost
end
def profit_margin
return 0 if customer_revenue == 0
(gross_profit / customer_revenue) * 100
end
end
Scenario: Pay vendor $500, they have 3 outstanding bills
Options:
Implementation:
# VendorBillPayment join table tracks application
VendorPayment (total: $500)
├─ VendorBillPayment (bill_id: 123, amount_applied: $300)
└─ VendorBillPayment (bill_id: 124, amount_applied: $200)
# Each VendorBill calculates balance:
def balance_due
total_amount - vendor_bill_payments.sum(:amount_applied)
end
Question: How to handle existing orders without vendor bills?
Options:
Ignore history - Only track going forward
Estimate from VendorProduct - Create estimated bills
Manual entry - Admin enters past vendor bills
Recommendation: Option 1 (track going forward) with optional manual entry for recent high-value orders.
| Role | Vendor Bills | Payments | Reports |
|---|---|---|---|
| Viewer | View only | View only | View only |
| Employee | View | No access | View |
| Manager | View, Create, Edit | View | View all |
| Admin | Full access | Record payments | Full access |
| Super Admin | Full access | Full access | Full access |
Integrate Ramp's virtual card API to automate vendor payments with per-order spending controls.
RampVirtualCard
- vendor_id (FK)
- order_id (FK, optional - might be per-location instead)
- ramp_card_id (Ramp's card ID)
- card_last_four
- card_name (e.g., "ABC Containers - Order #12345")
- spending_limit (total amount vendor can charge)
- spent_amount (tracked from Ramp transactions)
- status (active, suspended, cancelled)
- activated_at
- expires_at (for recurring, null; for one-time, order end date)
- auto_disable (boolean - disable when order completes)
- notes
- created_at, updated_at
RampTransaction
- ramp_virtual_card_id (FK)
- vendor_bill_id (FK, linked when reconciled)
- ramp_transaction_id (Ramp's transaction ID)
- transaction_date
- merchant_name
- amount
- description
- status (pending, cleared, declined)
- reconciled (boolean - matched to VendorBill)
- reconciled_at
- created_at, updated_at
Use Case: Dumpster rental
Workflow:
1. Order Created & Confirmed
↓
2. Generate Ramp Virtual Card
- Name: "Vendor ABC - Order #O20251003001"
- Limit: $530 (estimated vendor cost + 10% buffer)
- Memo: "Dumpster rental - 123 Main St"
↓
3. Send Card to Vendor
- Email card details to vendor
- Include order reference
↓
4. Vendor Charges Card
- $250 (delivery)
- $45 (overages - auto-charged by vendor)
↓
5. Transaction Synced
- RampTransaction created
- Auto-match to VendorBill
↓
6. Order Completed
- Auto-disable card
- Verify spending vs. limit
- Close out order
Benefits:
Settings:
spending_limit: order.estimated_vendor_cost * 1.10 # 10% buffer for overages
expires_at: order.pickup_date + 30.days # Allow time for final charges
auto_disable: true
Use Case: Monthly storage container at same location
Workflow:
1. First Order at Location
↓
2. Generate Ramp Virtual Card
- Name: "Vendor ABC - 123 Main St"
- Limit: $250/month (recurring limit)
- Memo: "Storage container - Monthly"
↓
3. Send Card to Vendor (One Time)
- Vendor keeps on file
- Charges monthly automatically
↓
4. Monthly Vendor Charge
- Ramp transaction created
- Auto-match to VendorBillingPeriod
↓
5. Spending Limit Reset
- Monthly: limit resets on billing date
- Or: limit replenished per billing period
↓
6. Order Ends
- Update card limit to $0 or
- Disable card
Benefits:
Settings:
spending_limit: monthly_cost * 1.05 # 5% buffer
limit_reset_frequency: "monthly" # Ramp feature
auto_disable: false # Keep active for recurring
class RampCardService
def create_virtual_card(order:, vendor:, limit:, name:)
response = ramp_client.post('/cards/virtual', {
cardholder_id: vendor.ramp_cardholder_id,
display_name: name,
spending_restrictions: {
amount: limit,
interval: 'TOTAL', # or 'MONTHLY' for recurring
categories: ['5093'], # MCC code for dumpsters/containers
},
fulfillment: {
shipping: nil, # Virtual only
}
})
RampVirtualCard.create!(
vendor: vendor,
order: order,
ramp_card_id: response['id'],
card_last_four: response['last_four'],
card_name: name,
spending_limit: limit,
status: 'active',
activated_at: Time.current
)
end
end
# POST /webhooks/ramp/transactions
class RampWebhooksController < ApplicationController
def transaction_created
transaction_data = params[:data]
card = RampVirtualCard.find_by(ramp_card_id: transaction_data[:card_id])
return head :ok unless card
RampTransaction.create!(
ramp_virtual_card: card,
ramp_transaction_id: transaction_data[:id],
transaction_date: transaction_data[:transaction_date],
merchant_name: transaction_data[:merchant_name],
amount: transaction_data[:amount],
description: transaction_data[:description],
status: transaction_data[:status]
)
# Auto-reconcile if vendor bill exists
AutoReconcileRampTransactionJob.perform_later(card.id)
end
end
# When order completes
class Order
after_update :disable_ramp_card, if: :completed?
def disable_ramp_card
return unless ramp_virtual_card.present?
return unless ramp_virtual_card.auto_disable?
RampCardService.new.disable_card(ramp_virtual_card)
end
end
class RampCardService
def disable_card(ramp_card)
ramp_client.patch("/cards/#{ramp_card.ramp_card_id}", {
status: 'SUSPENDED'
})
ramp_card.update!(
status: 'suspended',
expires_at: Time.current
)
end
end
def calculate_card_limit(order)
base_cost = order.vendor_product.cost || 0
delivery_cost = order.vendor.delivery_fee_for_zipcode(order.delivery_zipcode) || 0
removal_cost = delivery_cost # Usually same
# Estimate potential overages
max_overage = calculate_max_overage(order)
total = base_cost + delivery_cost + removal_cost + max_overage
# Add 10% buffer for unexpected charges
total * 1.10
end
def calculate_max_overage(order)
weight_overage = 0
time_overage = 0
# Assume worst case: 2x standard weight/days
if order.product.dumpster?
weight_overage = order.vendor_product.vendor_price_per_ton * 2
time_overage = order.vendor_product.vendor_daily_rate * 7
end
weight_overage + time_overage
end
def calculate_monthly_limit(order)
monthly_cost = order.vendor_product.cost || 0
# Add 5% buffer (less buffer needed for predictable recurring)
monthly_cost * 1.05
end
Match Ramp Transactions to Vendor Bills:
class AutoReconcileRampTransactionJob < ApplicationJob
def perform(ramp_card_id)
card = RampVirtualCard.find(ramp_card_id)
transactions = card.ramp_transactions.where(reconciled: false)
transactions.each do |transaction|
# Find matching vendor bill
vendor_bill = find_matching_bill(card, transaction)
if vendor_bill
# Link transaction to bill
transaction.update!(
vendor_bill: vendor_bill,
reconciled: true,
reconciled_at: Time.current
)
# Auto-create VendorPayment
create_payment_from_transaction(vendor_bill, transaction)
end
end
end
private
def find_matching_bill(card, transaction)
# Try to match by order
if card.order.present?
vendor_bill = card.order.vendor_bills
.where(status: ['pending', 'approved'])
.where('total_amount <= ?', transaction.amount * 1.05) # 5% tolerance
.first
end
# Try to match by amount and date
vendor_bill ||= VendorBill
.where(vendor: card.vendor)
.where(status: ['pending', 'approved'])
.where('bill_date BETWEEN ? AND ?', transaction.transaction_date - 7.days, transaction.transaction_date + 7.days)
.where('total_amount BETWEEN ? AND ?', transaction.amount * 0.95, transaction.amount * 1.05)
.first
vendor_bill
end
def create_payment_from_transaction(vendor_bill, transaction)
payment = VendorPayment.create!(
vendor: vendor_bill.vendor,
payment_date: transaction.transaction_date,
payment_method: 'ramp_virtual_card',
payment_reference: transaction.ramp_transaction_id,
amount: transaction.amount,
notes: "Auto-payment via Ramp card #{transaction.ramp_virtual_card.card_last_four}"
)
VendorBillPayment.create!(
vendor_bill: vendor_bill,
vendor_payment: payment,
amount_applied: transaction.amount
)
vendor_bill.update!(status: 'paid') if vendor_bill.balance_due <= 0
end
end
┌─────────────────────────────────────────────┐
│ Vendor Payment Card │
├─────────────────────────────────────────────┤
│ Card: **** **** **** 1234 │
│ Limit: $530.00 │
│ Spent: $495.00 (93%) │
│ Status: Active ● │
│ │
│ Transactions: │
│ Oct 1 Delivery $250.00 │
│ Oct 8 Weight Overage $135.00 │
│ Oct 8 Time Overage $110.00 │
│ │
│ [Disable Card] [Adjust Limit] │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ Virtual Cards │
├─────────────────────────────────────────────┤
│ Card Order Limit Spent │
│ **** 1234 #O001 $530 $495 [●] │
│ **** 5678 #O002 $200 $200 [○] │
│ **** 9012 Location $250/mo $250 [●] │
│ │
│ [Generate New Card] │
└─────────────────────────────────────────────┘
Location: /admin/ramp_cards
Stats:
- Active Cards: 45
- Total Spend This Month: $12,450
- Cards Needing Review: 3
- Average Utilization: 87%
Filters: Active | Suspended | All | Near Limit (>90%)
Table:
Card | Vendor | Order/Location | Limit | Spent | Status | Actions
RAMP_API_KEY=your_api_key
RAMP_API_SECRET=your_api_secret
RAMP_WEBHOOK_SECRET=your_webhook_secret
RAMP_ENVIRONMENT=sandbox # or production
# config/initializers/ramp.rb
Ramp.configure do |config|
config.api_key = ENV['RAMP_API_KEY']
config.api_secret = ENV['RAMP_API_SECRET']
config.webhook_secret = ENV['RAMP_WEBHOOK_SECRET']
config.environment = ENV.fetch('RAMP_ENVIRONMENT', 'sandbox')
end
Scenario: Vendor tries to charge $600 on $530 limit
Ramp Behavior: Transaction declined
Our Response:
transaction.declinedScenario: Order extends beyond card expiry
Handling:
# Check expiring cards daily
class CheckExpiringCardsJob < ApplicationJob
def perform
expiring_soon = RampVirtualCard
.active
.where('expires_at BETWEEN ? AND ?', Date.current, 30.days.from_now)
expiring_soon.each do |card|
if card.order.still_active?
# Extend expiry
extend_card_expiry(card, 30.days)
else
# Warn admin
AdminMailer.card_expiring_soon(card).deliver_later
end
end
end
end
Scenario: Vendor only accepts ACH/Check
Handling:
ROI: Positive within 2 months
| Solution | Pros | Cons | Verdict |
|---|---|---|---|
| Ramp Virtual Cards | Auto-generation, per-order limits, API | Monthly fee | ✅ Recommended |
| Manual Credit Cards | Simple, no fees | No spending limits, fraud risk | ❌ Not scalable |
| ACH/Checks | Low cost | Manual, slow, no auto-tracking | ❌ Too manual |
| Divvy/Brex | Similar to Ramp | Slightly less flexible API | ✅ Alternative |
Order Created
↓
Generate Ramp Card (spending limit = estimated cost)
↓
Send Card to Vendor
↓
Auto-Create VendorBill (status: pending)
↓
Vendor Charges Card
↓
Ramp Transaction Synced
↓
Auto-Match Transaction → VendorBill
↓
Auto-Create VendorPayment (payment_method: ramp_virtual_card)
↓
Mark VendorBill: paid
↓
Order Completed → Disable Card
Result: Fully automated vendor payment with zero manual intervention! 🎉
Cost Visibility
Payment Performance
Profitability
Operational Efficiency
Data Entry Burden
Billing Cycle Complexity
Cost Variance
Payment Errors
The proposed AP system will:
✅ Close the financial visibility gap - Track all vendor costs ✅ Enable profit analysis - Know actual margins per order ✅ Automate recurring costs - Match customer recurring billing ✅ Improve cash management - Know exactly what we owe ✅ Support growth - Scale as business grows
Estimated Development Time:
Next Steps:
Last Updated: October 3, 2025 Status: 85% Complete - Core functionality + UI integration complete Next Steps: Order lifecycle integration, recurring billing UI, reporting
| Feature Area | Completion | Status |
|---|---|---|
| Database Schema | 100% | ✅ All 7 migrations created and run |
| Models | 100% | ✅ All 8 models with validations |
| API Integration | 100% | ✅ Full Ramp API client |
| Services | 100% | ✅ 3 core services |
| Background Jobs | 100% | ✅ 4 jobs scheduled |
| Webhooks | 100% | ✅ Ramp webhook handler |
| Controllers | 100% | ✅ 3 admin controllers (VendorBills, RampCards, RampTransactions) |
| Views | 90% | ✅ 7 of 8 views complete |
| Order Integration | 50% | ⏳ Form/show updated, lifecycle pending |
| Reporting | 0% | ⏳ Pending |
| Overall | 85% | ✅ Core + UI integration complete |
Fully Functional:
Requires Manual Setup:
Not Yet Implemented:
Step 1: Create VendorBills Migration
bin/rails generate migration CreateVendorBills
Step 2: Create VendorBillLineItems Migration
bin/rails generate migration CreateVendorBillLineItems
Step 3: Create VendorBillingPeriods Migration
bin/rails generate migration CreateVendorBillingPeriods
Step 4: Create VendorPayments Migration
bin/rails generate migration CreateVendorPayments
Step 5: Create RampVirtualCards Migration
bin/rails generate migration CreateRampVirtualCards
Step 6: Create RampTransactions Migration
bin/rails generate migration CreateRampTransactions
Step 7: Add Vendor Cost Tracking to Orders
bin/rails generate migration AddVendorCostFieldsToOrders
Step 8: Create VendorBill Model ✅
Step 9: Create VendorBillLineItem Model ✅
Step 10: Create VendorBillingPeriod Model ✅
Step 11: Create VendorPayment Model ✅
Step 12: Create RampVirtualCard Model ✅
Step 13: Create RampTransaction Model ✅
Step 14: Update Order Model ✅
Step 15: Create Ramp API Client ✅
Step 16: Create RampCardService ✅
Step 17: Add Ramp Credentials ⏳ PENDING (Manual Setup)
bin/rails credentials:edit
Step 18: Create VendorRecurringBillingService ✅
Step 19: Create GenerateVendorBillsJob ✅
Step 20: Create Rake Tasks ✅
Step 21: Create Ramp Webhook Endpoint ✅
bin/rails generate controller webhooks/ramp create
Step 22: Add Webhook Routes
Step 23: Create SyncRampTransactionsJob
Step 24: Create VendorBillsController ✅
Step 25: Create VendorBills Index View ✅
Step 26: Create VendorBills Show View ✅
Step 27: Create VendorBills Form Partial ✅
Step 28: Create RampCardsController ✅
Step 29: Create RampCards Dashboard ✅
Step 30: Create RampCards Show View ✅
Step 31: Update Orders Form ✅
Step 32: Update Orders Show Page ✅
Step 33: Create VendorRecurringBillingController ⏳ PENDING
Step 34: Create VendorRecurringBilling Index View ⏳ PENDING
Step 35: Add Navigation Links ✅
Step 36: Update OrdersController ⏳ PENDING
Step 37: Add Order Callbacks ⏳ PENDING
Step 38: Create VendorBillGenerationService ⏳ PENDING
Step 39: Add Vendor Cost Reports ⏳ PENDING
Step 40: Create Vendor Cost Report View ⏳ PENDING
Step 41: Add Ramp Reconciliation Report ⏳ PENDING
Step 42: Test Ramp Sandbox Integration ⏳ PENDING
Step 43: Test Recurring Billing ⏳ PENDING
Step 44: Edge Case Testing ⏳ PENDING
Step 45: Add Seed Data ⏳ PENDING
Step 46: Update README ⏳ PENDING
Step 47: Deploy to Staging ⏳ PENDING
Step 48: Production Rollout ⏳ PENDING
Estimated Timeline: 16 days (3-4 weeks)
Key Files to Reference:
Document Owner: Development Team Stakeholders: Finance, Operations, Management Review Date: October 10, 2025