Status: Phase 1 Complete ✅ | Phase 2 Complete ✅ | Security Fixes Applied ✅ Created: November 10, 2025 Last Updated: November 12, 2025
This document outlines the implementation plan for Ramp virtual card management for recurring vendor payments (storage containers, mobile offices). The system will handle card issuance, secure storage, one-time vendor viewing, and automatic limit adjustments based on customer payment success.
Phase 1 successfully implemented manual card entry, secure storage, billing period tracking, and AI-powered invoice extraction. All features are deployed to production and ready for use.
Status: ✅ Deployed
Key Files:
app/controllers/orders_controller.rb - create_manual_card redirects to form (doesn't create card)app/controllers/admin/ramp_cards_controller.rb - new_manual_card (shows form), create_manual_card (creates card on save)app/views/admin/ramp_cards/new_manual_card.html.erb - Card entry form with order contextconfig/routes.rb - Collection routes for new_manual_card and create_manual_cardStatus: ✅ Deployed
Added ability to associate cards with specific billing periods:
Database Changes:
# Migration: 20251111181807_add_billing_info_to_ramp_virtual_cards.rb
add_reference :ramp_virtual_cards, :first_billing_period, foreign_key: { to_table: :order_billing_periods }
Features:
Files:
app/models/ramp_virtual_card.rb - Added belongs_to :first_billing_periodapp/views/admin/ramp_cards/new_manual_card.html.erb - Billing period dropdown (lines 179-196)Status: ✅ Deployed
Track vendor invoice details for reconciliation:
Database Fields Added:
vendor_invoice_number (string) - Invoice number from vendor bill (e.g., "RI1390062")vendor_invoice_date (date) - Date on vendor invoicevendor_invoice_amount (decimal 10,2) - Total amount from billvendor_invoice_tax (decimal 10,2) - Tax amount from billFeatures:
Status: ✅ Deployed
Automatic extraction of invoice data from PDF uploads using Google Gemini 2.5 Flash:
Key Features:
Implementation Details:
gemini-2.5-flash (best price-performance)https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash:generateContentGEMINI_API_KEY (in kamal secrets)has_one_attached :vendor_invoice_pdfFiles:
app/controllers/admin/ramp_cards_controller.rb - extract_invoice_data action (lines 270-356)app/views/admin/ramp_cards/new_manual_card.html.erb - Upload UI and JavaScript (lines 90-122, 236-342)app/models/ramp_virtual_card.rb - has_one_attached :vendor_invoice_pdfconfig/deploy.yml - Added GEMINI_API_KEY to env secretsExtracted Fields:
{
"invoice_number": "RI1390062",
"invoice_date": "2025-11-03",
"total_amount": 230.48,
"tax_amount": 20.48,
"billing_start_date": "2025-11-02",
"billing_end_date": "2025-12-01"
}
Status: ✅ Deployed (from previous implementation)
card_entered_by and card_entered_atStatus: ✅ Deployed
Admin Card Entry Form:
Order Page Integration:
# ramp_virtual_cards table additions
t.references :first_billing_period, foreign_key: { to_table: :order_billing_periods }
t.string :vendor_invoice_number
t.date :vendor_invoice_date
t.decimal :vendor_invoice_amount, precision: 10, scale: 2
t.decimal :vendor_invoice_tax, precision: 10, scale: 2
# Active Storage attachment: vendor_invoice_pdf
Kamal Deployment (config/deploy.yml):
env:
secret:
- GEMINI_API_KEY # Added for AI invoice extraction
Routes (config/routes.rb):
namespace :admin do
resources :ramp_cards do
collection do
get :new_manual_card
post :create_manual_card
post :extract_invoice_data
end
end
end
✅ Manual card entry flow from order 218 ✅ PDF upload and Gemini extraction with real vendor bill (Southwest Mobile Storage invoice) ✅ Billing period selection and association ✅ Card creation on final save (not on button click) ✅ Error handling for missing files, invalid PDFs, API errors ✅ Extract button visibility after file upload ✅ Form validation and user feedback
Controllers:
app/controllers/orders_controller.rb - Refactored create_manual_card to redirect to formapp/controllers/admin/ramp_cards_controller.rb - Added new_manual_card, create_manual_card, extract_invoice_dataModels:
app/models/ramp_virtual_card.rb - Added billing period association, invoice fields, Active Storage attachmentViews:
app/views/admin/ramp_cards/new_manual_card.html.erb - New card entry form (replaces placeholder approach)app/views/orders/show.html.erb - Updated card creation UI with two optionsMigrations:
db/migrate/20251111181807_add_billing_info_to_ramp_virtual_cards.rbConfig:
config/routes.rb - Added collection routes for manual card flowconfig/deploy.yml - Added GEMINI_API_KEY environment variableCritical security fixes and user experience improvements were deployed to production on November 12, 2025.
Status: ✅ Deployed Priority: CRITICAL SECURITY FIX
Problem Identified:
Solution Implemented:
Files Modified:
app/controllers/vendors/cards_controller.rb - Updated view_card action to return JSON with card details only after marking as viewedapp/views/vendors/cards/show.html.erb - Removed hardcoded card details, added AJAX fetch logicSecurity Impact:
Status: ✅ Deployed
Problem:
Solution:
card_brand() method to detect card type from first digit (Visa=4, Mastercard=5, Amex=3, Discover=6)card_display_name() to return "Visa - 2881" format (brand + last 4 digits)card_name if set, or "Card #id" if no details availableFiles Modified:
app/models/ramp_virtual_card.rb - Added card_brand() and updated card_display_name() methodsapp/views/vendors/cards/show.html.erb - Uses card_display_name in page headerapp/views/vendors/cards/index.html.erb - Uses card_display_name in card list tableUser Impact:
Status: ✅ Deployed
Problem:
prior_auth_capture: approved)Solution:
customer_payment_status() method to OrderBillingPeriod modelauth_capture, prior_auth_capture, or sale)Files Modified:
app/models/order_billing_period.rb - Added customer_payment_status() and customer_paid?() methodsapp/views/orders/show.html.erb - Billing history table now shows customer payment statusapp/views/billing_periods/show.html.erb - Period detail page shows customer payment statusStatus Labels (Admin Side):
| Database Status | Has Payment Transaction? | Badge Shown | Color |
|---|---|---|---|
| Any | Has approved auth_capture/prior_auth_capture | Paid | Green ✅ |
| authorized | No payment yet | Awaiting Payment | Orange 🟠 |
| pending | - | Pending | Yellow 🟡 |
| invoiced | - | Invoiced | Blue 🔵 |
| skipped | - | Skipped | Gray ⚫ |
User Impact:
Status: ✅ Deployed
Created convenient rake tasks for managing card view status during testing and support:
Files Added:
lib/tasks/ramp_cards.rake - Card management tasksAvailable Commands:
# Check card view status
kamal app exec "bin/rails ramp_cards:show_status[CARD_ID]"
# Reset card view (allows viewing again)
kamal app exec "bin/rails ramp_cards:reset_view[CARD_ID]"
# Inspect full card details
kamal app exec "bin/rails ramp_cards:inspect[CARD_ID]"
Use Cases:
Status: ✅ Deployed
Files Modified:
app/views/orders/index.html.erb - Customer column now shows name + street address✅ Tested one-time card view security fix with card #10 ✅ Verified card details not in HTML before viewing ✅ Confirmed AJAX fetch works correctly ✅ Tested page reload after viewing shows "Already Viewed" ✅ Verified billing period status shows "Paid" for completed payments ✅ Tested rake tasks for card reset functionality ✅ Confirmed card display names show "Visa - 2881" format
Phase 2 successfully implemented automatic card limit adjustments based on customer payment success. All features are deployed to production and working automatically.
Status: ✅ Deployed & Active
How It Works:
auth_capture or prior_auth_capture with status approved)Database Table: ramp_card_limit_adjustments
# Fields:
- ramp_virtual_card_id (which card)
- order_billing_period_id (customer billing period)
- payment_transaction_id (which payment triggered it)
- created_by_id (for manual adjustments)
- adjustment_type (initial, period_payment, manual, correction)
- amount (dollars increase/decrease)
- previous_limit (before adjustment)
- new_limit (after adjustment)
- vendor_period_number (which vendor period)
- vendor_period_start, vendor_period_end (vendor period dates)
- notes (explanation)
- applied_at (timestamp)
Migrations:
20251111185957_create_ramp_card_limit_adjustments.rb - Creates adjustments table20251111194500_add_vendor_period_to_ramp_card_limit_adjustments.rb - Adds vendor period trackingStatus: ✅ Deployed
File: app/services/ramp_limit_adjuster.rb
Key Methods:
increase_limit_for_successful_payment(billing_period, payment_transaction) - Automatic increasesmanual_adjustment(amount:, notes:, created_by:) - Admin manual adjustmentscalculate_vendor_period(reference_date) - Determines which vendor period we're inFeatures:
Status: ✅ Deployed & Active
File: app/models/payment_transaction.rb (lines 87-104)
Implementation:
after_commit :increase_card_limit_on_success, on: [:create, :update]
private
def increase_card_limit_on_success
# Only for successful payments
return unless status == "approved"
return unless %w[auth_capture prior_auth_capture].include?(transaction_type)
# Only if order uses Ramp payment
return unless order_billing_period.order.use_ramp_payment?
return unless order_billing_period.order.ramp_virtual_card.present?
# Automatically increase the limit
RampLimitAdjuster.new(order).increase_limit_for_successful_payment(
order_billing_period,
self
)
end
Behavior:
use_ramp_payment: trueStatus: ✅ Deployed
File: app/views/vendors/cards/show.html.erb (lines 203-265)
Features:
Controller: app/controllers/vendors/cards_controller.rb
def show
@limit_adjustments = @card.ramp_card_limit_adjustments.recent.limit(20)
end
Status: ✅ Deployed
File: app/models/ramp_card_limit_adjustment.rb
Features:
ramp_virtual_card, order_billing_period, payment_transaction, created_byautomatic, manual, for_period, recentautomatic?, increase?, decrease?Status: ✅ Deployed
Fields on Order model:
vendor_auto_increase_limit (boolean) - Enable/disable automatic increasesvendor_cost_frequency (string) - "monthly", "weekly", "28_day"vendor_billing_amount (decimal) - Amount to increase per periodvendor_base_cost (decimal) - Fallback if vendor_billing_amount not setMigration: 20251111190143_add_vendor_auto_increase_limit_to_orders.rb
✅ Created test order with Ramp card ✅ Made successful customer payment ✅ Verified automatic limit increase triggered ✅ Confirmed adjustment record created ✅ Checked vendor dashboard shows adjustment ✅ Tested manual adjustment by admin ✅ Verified no duplicate increases for same vendor period ✅ Confirmed vendor period calculation works for monthly, weekly, 28-day
vendor_auto_increase_limit: trueOrder #250:
Timeline:
Models:
app/models/ramp_card_limit_adjustment.rb - NEWapp/models/payment_transaction.rb - Added after_commit callbackapp/models/ramp_virtual_card.rb - Added has_many :ramp_card_limit_adjustmentsServices:
app/services/ramp_limit_adjuster.rb - NEWControllers:
app/controllers/vendors/cards_controller.rb - Loads @limit_adjustmentsViews:
app/views/vendors/cards/show.html.erb - Added "Card Limit History" sectionMigrations:
db/migrate/20251111185957_create_ramp_card_limit_adjustments.rbdb/migrate/20251111194500_add_vendor_period_to_ramp_card_limit_adjustments.rbdb/migrate/20251111190143_add_vendor_auto_increase_limit_to_orders.rbPart 1: Manual Card Entry & Secure Storage
Part 2: Automatic Limit Adjustments
auth_capture or prior_auth_capture transactions# db/migrate/YYYYMMDDHHMMSS_add_encrypted_card_details_to_ramp_virtual_cards.rb
class AddEncryptedCardDetailsToRampVirtualCards < ActiveRecord::Migration[8.0]
def change
add_column :ramp_virtual_cards, :encrypted_card_number, :string
add_column :ramp_virtual_cards, :encrypted_cvv, :string
add_column :ramp_virtual_cards, :encrypted_expiration, :string
add_column :ramp_virtual_cards, :card_manually_entered, :boolean, default: false
add_column :ramp_virtual_cards, :card_entered_by_id, :integer
add_column :ramp_virtual_cards, :card_entered_at, :datetime
add_index :ramp_virtual_cards, :card_entered_by_id
add_foreign_key :ramp_virtual_cards, :users, column: :card_entered_by_id
end
end
Columns:
encrypted_card_number: Full 16-digit card number (encrypted)encrypted_cvv: 3-4 digit CVV (encrypted)encrypted_expiration: MM/YY format (encrypted)card_manually_entered: Flag to distinguish from future PCI cardscard_entered_by_id: User who entered the card detailscard_entered_at: Timestamp of entry# app/models/ramp_virtual_card.rb
class RampVirtualCard < ApplicationRecord
# Encrypt sensitive card data
encrypts :encrypted_card_number, deterministic: false
encrypts :encrypted_cvv, deterministic: false
encrypts :encrypted_expiration, deterministic: false
belongs_to :card_entered_by, class_name: "User", optional: true
# Validations
validates :encrypted_card_number, length: { is: 16 }, if: :card_manually_entered?
validates :encrypted_cvv, length: { in: 3..4 }, if: :card_manually_entered?
validates :encrypted_expiration, format: { with: /\A\d{2}\/\d{2}\z/ }, if: :card_manually_entered?
# Methods
def can_view_card_details?
card_manually_entered? &&
card_view_token.present? &&
card_viewed_at.nil? &&
has_card_details?
end
def has_card_details?
encrypted_card_number.present? &&
encrypted_cvv.present? &&
encrypted_expiration.present?
end
def card_last_four
return super if super.present?
encrypted_card_number&.last(4) if has_card_details?
end
end
# app/services/card_view_service.rb
class CardViewService
# Add to get_card_info method
def get_card_info(card)
if card.card_manually_entered? && card.has_card_details?
# Return decrypted card details
{
type: "manual_card",
card_number: card.encrypted_card_number,
cvv: card.encrypted_cvv,
expiration: card.encrypted_expiration,
amount: card.spending_limit,
order_number: card.order&.order_number,
instructions: "Use this card for your recurring charges. This is a one-time view - save these details securely."
}
elsif card.card_type == "bill_pay"
# Existing bill pay logic
{
type: "bill_pay",
message: "Card details have been sent to your email by Ramp. Check your inbox.",
# ... rest of bill_pay response
}
else
# Future: PCI-approved vault cards
fetch_card_from_vault(card)
end
end
end
# app/controllers/admin/ramp_cards_controller.rb
# GET /admin/ramp_cards/:id/enter_card_details
def enter_card_details
@card = RampVirtualCard.find(params[:id])
if @card.has_card_details?
flash[:alert] = "Card details already entered"
redirect_to admin_ramp_card_path(@card)
end
end
# POST /admin/ramp_cards/:id/save_card_details
def save_card_details
@card = RampVirtualCard.find(params[:id])
if @card.has_card_details?
flash[:alert] = "Card details already entered. Cannot modify existing card."
redirect_to admin_ramp_card_path(@card) and return
end
# Validate format
card_number = params[:card_number].gsub(/\s/, '')
unless card_number.match?(/\A\d{16}\z/)
flash[:alert] = "Invalid card number format"
render :enter_card_details and return
end
@card.update!(
encrypted_card_number: card_number,
encrypted_cvv: params[:cvv],
encrypted_expiration: params[:expiration],
card_manually_entered: true,
card_entered_by: current_user,
card_entered_at: Time.current
)
# Generate one-time view token for vendor
CardViewService.new(@card).generate_view_token
flash[:notice] = "Card details saved securely. Vendor can now view the card once."
redirect_to admin_ramp_card_path(@card)
end
# config/routes.rb
namespace :admin do
resources :ramp_cards do
member do
get :enter_card_details
post :save_card_details
end
end
end
File: app/views/admin/ramp_cards/enter_card_details.html.erb
Features:
File: app/views/vendors/cards/show.html.erb
Features:
# db/migrate/YYYYMMDDHHMMSS_create_ramp_card_limit_adjustments.rb
class CreateRampCardLimitAdjustments < ActiveRecord::Migration[8.0]
def change
create_table :ramp_card_limit_adjustments do |t|
t.references :ramp_virtual_card, null: false, foreign_key: true
t.references :order_billing_period, foreign_key: true
t.references :payment_transaction, foreign_key: true
t.references :created_by, foreign_key: { to_table: :users }
t.string :adjustment_type, null: false # initial, period_payment, manual, correction
t.decimal :amount, precision: 10, scale: 2, null: false
t.decimal :previous_limit, precision: 10, scale: 2, null: false
t.decimal :new_limit, precision: 10, scale: 2, null: false
t.text :notes
t.datetime :applied_at, null: false
t.timestamps
end
add_index :ramp_card_limit_adjustments, [:ramp_virtual_card_id, :created_at]
add_index :ramp_card_limit_adjustments, [:ramp_virtual_card_id, :order_billing_period_id],
name: 'index_ramp_adjustments_on_card_and_period'
end
end
# db/migrate/YYYYMMDDHHMMSS_add_vendor_billing_config_to_orders.rb
class AddVendorBillingConfigToOrders < ActiveRecord::Migration[8.0]
def change
add_column :orders, :vendor_billing_cadence_days, :integer
add_column :orders, :vendor_billing_amount, :decimal, precision: 10, scale: 2
add_column :orders, :vendor_billing_start_date, :date
add_column :orders, :vendor_auto_increase_limit, :boolean, default: true
add_index :orders, :vendor_billing_start_date
end
end
Columns:
vendor_billing_cadence_days: How often vendor bills (e.g., 28, 30 days)vendor_billing_amount: Amount vendor charges per periodvendor_billing_start_date: When vendor's billing cycle startsvendor_auto_increase_limit: Enable/disable automatic increases# app/models/ramp_card_limit_adjustment.rb
class RampCardLimitAdjustment < ApplicationRecord
belongs_to :ramp_virtual_card
belongs_to :order_billing_period, optional: true
belongs_to :payment_transaction, optional: true
belongs_to :created_by, class_name: "User", optional: true
# Validations
validates :adjustment_type, presence: true,
inclusion: { in: %w[initial period_payment manual correction] }
validates :amount, presence: true, numericality: true
validates :new_limit, presence: true, numericality: { greater_than: 0 }
validates :previous_limit, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :applied_at, presence: true
# Scopes
scope :automatic, -> { where(adjustment_type: %w[initial period_payment]) }
scope :manual, -> { where(adjustment_type: %w[manual correction]) }
scope :for_period, ->(period_id) { where(order_billing_period_id: period_id) }
scope :recent, -> { order(applied_at: :desc) }
# Methods
def automatic?
%w[initial period_payment].include?(adjustment_type)
end
def increase?
amount > 0
end
def decrease?
amount < 0
end
end
# app/models/order.rb
class Order < ApplicationRecord
# Add validation for vendor billing config
validates :vendor_billing_cadence_days,
numericality: { only_integer: true, greater_than: 0 },
allow_nil: true
validates :vendor_billing_amount,
numericality: { greater_than: 0 },
allow_nil: true
# Calculate expected vendor billing dates
def vendor_billing_dates
return [] unless vendor_billing_start_date && vendor_billing_cadence_days
dates = []
current_date = vendor_billing_start_date
end_date = rental_end_date || Date.current + 1.year
while current_date <= end_date
dates << current_date
current_date += vendor_billing_cadence_days.days
end
dates
end
end
# app/services/ramp_limit_adjuster.rb
class RampLimitAdjuster
class Error < StandardError; end
def initialize(order)
@order = order
@card = order.ramp_virtual_card
@ramp_client = RampApiClient.new
end
# Called when a customer payment succeeds
def increase_limit_for_successful_payment(billing_period, payment_transaction)
return nil unless payment_successful?(payment_transaction)
return nil unless @order.vendor_auto_increase_limit?
return nil if already_adjusted_for_period?(billing_period)
vendor_amount = calculate_vendor_amount_for_period(billing_period)
return nil if vendor_amount <= 0
adjust_limit(
amount: vendor_amount,
adjustment_type: "period_payment",
billing_period: billing_period,
payment_transaction: payment_transaction,
notes: "Auto-increase for period #{billing_period.period_number} " \
"(#{billing_period.period_start} to #{billing_period.period_end})"
)
end
# Manual adjustment by admin
def manual_adjustment(amount:, notes:, created_by:)
adjust_limit(
amount: amount,
adjustment_type: "manual",
created_by: created_by,
notes: notes
)
end
private
def payment_successful?(transaction)
%w[auth_capture prior_auth_capture].include?(transaction.transaction_type) &&
transaction.status == "approved"
end
def calculate_vendor_amount_for_period(billing_period)
@order.vendor_billing_amount || @order.vendor_base_cost || 0
end
def already_adjusted_for_period?(billing_period)
@card.ramp_card_limit_adjustments
.where(order_billing_period: billing_period)
.exists?
end
def adjust_limit(amount:, adjustment_type:, billing_period: nil,
payment_transaction: nil, created_by: nil, notes: nil)
return nil unless @card&.ramp_limit_id.present?
# Get current state from Ramp
limit_details = @ramp_client.get_limit(limit_id: @card.ramp_limit_id)
spent_cents = limit_details.dig("balance", "total", "amount") || 0
spent_dollars = spent_cents / 100.0
current_limit_cents = limit_details.dig("spending_restrictions", "limit", "amount") || 0
current_limit_dollars = current_limit_cents / 100.0
# Calculate new limit
new_limit = current_limit_dollars + amount
# Update in Ramp
@ramp_client.update_limit(
limit_id: @card.ramp_limit_id,
amount: new_limit
)
# Record adjustment
adjustment = RampCardLimitAdjustment.create!(
ramp_virtual_card: @card,
order_billing_period: billing_period,
payment_transaction: payment_transaction,
created_by: created_by,
adjustment_type: adjustment_type,
amount: amount,
previous_limit: current_limit_dollars,
new_limit: new_limit,
notes: notes,
applied_at: Time.current
)
# Update local card record
@card.update!(
spending_limit: new_limit,
spent_amount: spent_dollars
)
Rails.logger.info "Increased card limit #{@card.id} by $#{amount}: " \
"$#{current_limit_dollars} → $#{new_limit}"
adjustment
rescue RampApiClient::Error => e
raise Error, "Failed to adjust Ramp card limit: #{e.message}"
end
end
# app/models/payment_transaction.rb
class PaymentTransaction < ApplicationRecord
after_commit :increase_card_limit_on_success, on: [:create, :update]
private
def increase_card_limit_on_success
# Only for successful payments
return unless status == "approved"
return unless %w[auth_capture prior_auth_capture].include?(transaction_type)
# Find associated order and billing period
return unless order_billing_period.present?
return unless order_billing_period.order.present?
order = order_billing_period.order
# Only if order uses Ramp payment
return unless order.use_ramp_payment?
return unless order.ramp_virtual_card.present?
# Increase the limit
RampLimitAdjuster.new(order).increase_limit_for_successful_payment(
order_billing_period,
self
)
rescue => e
Rails.logger.error "Failed to increase card limit after payment: #{e.message}"
# Don't raise - payment already succeeded
end
end
File: app/views/admin/orders/_vendor_billing_config.html.erb
Features:
File: app/views/admin/ramp_cards/show.html.erb
Features:
# app/controllers/admin/ramp_cards_controller.rb
# POST /admin/ramp_cards/:id/manual_adjustment
def manual_adjustment
@card = RampVirtualCard.find(params[:id])
amount = params[:amount].to_f
notes = params[:notes]
if notes.blank?
flash[:alert] = "Please provide a reason for the adjustment"
redirect_to admin_ramp_card_path(@card) and return
end
order = @card.order || @card.vendor.orders.where(use_ramp_payment: true).first
adjuster = RampLimitAdjuster.new(order)
adjustment = adjuster.manual_adjustment(
amount: amount,
notes: notes,
created_by: current_user
)
flash[:notice] = "Card limit adjusted by $#{amount}. New limit: $#{adjustment.new_limit}"
redirect_to admin_ramp_card_path(@card)
rescue RampLimitAdjuster::Error => e
flash[:alert] = "Failed to adjust limit: #{e.message}"
redirect_to admin_ramp_card_path(@card)
end
File: app/views/vendors/orders/show.html.erb
New section: "Payment Card Status"
Features:
bin/rails db:encryption:init# test/models/ramp_virtual_card_test.rb
test "encrypts card number" do
card = create(:ramp_virtual_card, encrypted_card_number: "1234567890123456")
assert_not_equal "1234567890123456", card.read_attribute_before_type_cast(:encrypted_card_number)
assert_equal "1234567890123456", card.encrypted_card_number
end
test "validates card number length" do
card = build(:ramp_virtual_card, card_manually_entered: true, encrypted_card_number: "123")
assert_not card.valid?
assert_includes card.errors[:encrypted_card_number], "is the wrong length"
end
test "can_view_card_details? returns true when all conditions met" do
card = create(:ramp_virtual_card,
card_manually_entered: true,
encrypted_card_number: "1234567890123456",
encrypted_cvv: "123",
encrypted_expiration: "12/25",
card_view_token: "abc123",
card_viewed_at: nil
)
assert card.can_view_card_details?
end
# test/services/ramp_limit_adjuster_test.rb
test "increases limit when payment succeeds" do
order = create(:order, vendor_billing_amount: 230.48)
card = create(:ramp_virtual_card, order: order, spending_limit: 500)
period = create(:order_billing_period, order: order)
payment = create(:payment_transaction,
transaction_type: "auth_capture",
status: "approved",
order_billing_period: period
)
adjuster = RampLimitAdjuster.new(order)
adjustment = adjuster.increase_limit_for_successful_payment(period, payment)
assert_not_nil adjustment
assert_equal 230.48, adjustment.amount
assert_equal 730.48, adjustment.new_limit
end
test "does not increase limit for failed payment" do
order = create(:order)
period = create(:order_billing_period, order: order)
payment = create(:payment_transaction, status: "declined")
adjuster = RampLimitAdjuster.new(order)
adjustment = adjuster.increase_limit_for_successful_payment(period, payment)
assert_nil adjustment
end
# test/integration/admin/card_entry_test.rb
test "admin can enter card details once" do
sign_in_as_admin
card = create(:ramp_virtual_card)
get enter_card_details_admin_ramp_card_path(card)
assert_response :success
post save_card_details_admin_ramp_card_path(card), params: {
card_number: "1234 5678 9012 3456",
cvv: "123",
expiration: "12/25"
}
card.reload
assert card.has_card_details?
assert card.card_manually_entered?
assert_not_nil card.card_view_token
end
test "cannot enter card details twice" do
sign_in_as_admin
card = create(:ramp_virtual_card, :with_card_details)
get enter_card_details_admin_ramp_card_path(card)
assert_redirected_to admin_ramp_card_path(card)
end
# test/integration/payment_limit_increase_test.rb
test "limit increases when customer payment succeeds" do
order = create(:order, :with_ramp_card, vendor_billing_amount: 230.48)
period = create(:order_billing_period, order: order)
initial_limit = order.ramp_virtual_card.spending_limit
# Simulate successful payment
payment = create(:payment_transaction,
order_billing_period: period,
transaction_type: "auth_capture",
status: "approved",
amount: 280
)
order.ramp_virtual_card.reload
assert_equal initial_limit + 230.48, order.ramp_virtual_card.spending_limit
adjustment = order.ramp_virtual_card.ramp_card_limit_adjustments.last
assert_equal "period_payment", adjustment.adjustment_type
assert_equal period, adjustment.order_billing_period
assert_equal payment, adjustment.payment_transaction
end
When we receive PCI approval from Ramp:
def create_card_for_order(order)
# Use Vault API instead of Limits API
response = @ramp_client.create_virtual_card_with_vault(
user_id: service_user_id,
display_name: generate_card_name(order),
spending_limit: calculate_spending_limit(order)
)
# Store card details (now from Vault API)
RampVirtualCard.create!(
vendor: order.vendor,
order: order,
ramp_limit_id: response["spend_limit_id"],
ramp_card_id: response["card"]["id"],
card_last_four: response["card"]["last_four"],
encrypted_card_number: response["card"]["number"], # Now from API
encrypted_cvv: response["card"]["cvv"],
encrypted_expiration: response["card"]["expiration"],
card_type: "virtual_card", # Not "manual_card"
card_manually_entered: false, # Automated!
# ... rest of fields
)
end
fetch_card_from_vault for all cardsRails.logger.info "RAMP_CARD: Limit increased for card #{card.id}, " \
"period #{period.id}, payment #{payment.id}, " \
"amount $#{amount}, new_limit $#{new_limit}"
Rails.logger.error "RAMP_CARD_ERROR: Failed to increase limit for card #{card.id}: #{error}"
✅ Q: Should we store CVV permanently? A: Yes for now (recurring use). Consider deleting after first vendor view for extra security.
✅ Q: What if vendor billing doesn't match our billing? A: Increase limit based on OUR customer payments, vendor charges when ready.
✅ Q: How to handle older orders with no billing periods? A: Admin manually configures vendor billing cadence, we create virtual periods.
❓ Q: Should we alert vendor when limit increases? A: TBD - probably yes via email
❓ Q: What if customer payment is refunded after limit increased? A: TBD - manual correction or automatic decrease?
❓ Q: Should we sync vendor charges from Ramp automatically? A: Yes - use webhook + polling job
| Date | Change | Author |
|---|---|---|
| 2025-11-10 | Initial document created with Phase 1 & 2 architecture | Claude |
| 2025-11-11 | ✅ Phase 1 implementation complete and deployed to production | Claude |
| 2025-11-11 | Added billing period tracking to ramp_virtual_cards | Claude |
| 2025-11-11 | Added vendor invoice metadata fields (number, date, amount, tax) | Claude |
| 2025-11-11 | Integrated Gemini 2.5 Flash for AI invoice extraction from PDFs | Claude |
| 2025-11-11 | Refactored card creation to atomic save (no placeholder cards) | Claude |
| 2025-11-11 | Added Active Storage for vendor invoice PDF attachments | Claude |
| 2025-11-11 | Created new_manual_card view and flow for card entry | Claude |
| 2025-11-11 | Updated order show page with two-option card creation UI | Claude |
| 2025-11-11 | Deployed all Phase 1 features to production successfully | Claude |
| 2025-11-11 | ✅ Phase 2: Created ramp_card_limit_adjustments table with full audit trail | Claude |
| 2025-11-11 | Built RampLimitAdjuster service for automatic and manual limit increases | Claude |
| 2025-11-11 | Added automatic trigger on PaymentTransaction for successful payments | Claude |
| 2025-11-11 | Implemented vendor period calculation (monthly, weekly, 28-day support) | Claude |
| 2025-11-11 | Added vendor_auto_increase_limit flag to orders table | Claude |
| 2025-11-11 | Created vendor dashboard limit history UI with visual indicators | Claude |
| 2025-11-11 | Deployed Phase 2 to production - automatic limit adjustments now active | Claude |
| 2025-11-12 | 🔒 CRITICAL: Fixed PCI compliance vulnerability in one-time card view | Claude |
| 2025-11-12 | Removed card details from HTML, implemented AJAX-based secure viewing | Claude |
| 2025-11-12 | Added card brand detection (Visa/Mastercard/Amex/Discover) | Claude |
| 2025-11-12 | Updated card_display_name to show "Visa - 2881" format | Claude |
| 2025-11-12 | Fixed billing period status confusion (authorized vs customer paid) | Claude |
| 2025-11-12 | Added customer_payment_status() method to OrderBillingPeriod | Claude |
| 2025-11-12 | Created rake tasks for card management (show_status, reset_view, inspect) | Claude |
| 2025-11-12 | Updated orders table to show delivery address instead of duplicate name | Claude |
| 2025-11-12 | Deployed all security fixes and UX improvements to production | Claude |