Ramp Virtual Cards Implementation Guide

Status: Phase 1 Complete ✅ | Phase 2 Complete ✅ | Security Fixes Applied ✅ Created: November 10, 2025 Last Updated: November 12, 2025

Overview

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.

Key Challenges Addressed

  1. PCI Compliance Workaround: Store cards manually using Rails ActiveRecord Encryption until we get PCI approval
  2. Misaligned Billing Cycles: Vendor billing periods don't match our customer billing periods
  3. Payment-Based Limit Increases: Only increase card limits when customer actually pays (not on schedule)
  4. Vendor Transparency: Vendors need to see card balance, billing history, and charges

Phase 1 Implementation Summary (November 11, 2025) ✅

What Was Completed

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.

1. Manual Card Entry with Atomic Creation

Status: ✅ Deployed

Key Files:

2. Billing Period Tracking

Status: ✅ 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:

3. Vendor Invoice Metadata

Status: ✅ Deployed

Track vendor invoice details for reconciliation:

Database Fields Added:

Features:

4. AI-Powered Invoice Extraction with Gemini

Status: ✅ Deployed

Automatic extraction of invoice data from PDF uploads using Google Gemini 2.5 Flash:

Key Features:

Implementation Details:

Files:

Extracted 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"
}

5. Enhanced Security & Encryption

Status: ✅ Deployed (from previous implementation)

6. UI/UX Improvements

Status: ✅ Deployed

Admin Card Entry Form:

Order Page Integration:

Database Schema Changes

# 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

Configuration Changes

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

Testing Performed

✅ 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

Known Limitations

  1. Billing period doesn't auto-reload funds - This is Phase 1 only. Phase 2 will implement automatic limit adjustments based on customer payments.
  2. Manual Gemini API calls - Each extraction requires user clicking "Extract Data" button (intentional for user control)
  3. Single PDF upload per card - Can only attach one vendor invoice PDF
  4. No automatic card creation - Admin must manually click button to start flow

Files Modified

Controllers:

Models:

Views:

Migrations:

Config:


Security & UX Improvements (November 12, 2025) ✅

What Was Completed

Critical security fixes and user experience improvements were deployed to production on November 12, 2025.

1. Fixed PCI Compliance Vulnerability in One-Time Card View

Status: ✅ Deployed Priority: CRITICAL SECURITY FIX

Problem Identified:

Solution Implemented:

Files Modified:

Security Impact:

2. Improved Card Display Names

Status: ✅ Deployed

Problem:

Solution:

Files Modified:

User Impact:

3. Fixed Billing Period Status Confusion

Status: ✅ Deployed

Problem:

Solution:

Files Modified:

Status 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:

4. Added Rake Tasks for Card Management

Status: ✅ Deployed

Created convenient rake tasks for managing card view status during testing and support:

Files Added:

Available 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:

5. Minor Admin UI Improvements

Status: ✅ Deployed

Files Modified:

Testing Performed

✅ 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

Known Issues Fixed

  1. SECURITY: Card details exposed in HTML - FIXED
  2. SECURITY: Unlimited card views possible - FIXED
  3. UX: Confusing billing period status labels - FIXED
  4. UX: Generic card names not helpful - FIXED

Phase 2 Implementation Summary (November 11, 2025) ✅

What Was Completed

Phase 2 successfully implemented automatic card limit adjustments based on customer payment success. All features are deployed to production and working automatically.

1. Automatic Limit Adjustments

Status: ✅ Deployed & Active

How It Works:

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:

2. RampLimitAdjuster Service

Status: ✅ Deployed

File: app/services/ramp_limit_adjuster.rb

Key Methods:

Features:

3. Automatic Payment Trigger

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:

4. Vendor Dashboard - Limit History

Status: ✅ 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

5. RampCardLimitAdjustment Model

Status: ✅ Deployed

File: app/models/ramp_card_limit_adjustment.rb

Features:

6. Order Configuration

Status: ✅ Deployed

Fields on Order model:

Migration: 20251111190143_add_vendor_auto_increase_limit_to_orders.rb

Testing Performed

✅ 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

How It Works End-to-End

  1. Setup: Admin creates Ramp card for order, sets vendor_auto_increase_limit: true
  2. Customer Payment: Customer pays for billing period (e.g., $281)
  3. Automatic Trigger: PaymentTransaction after_commit callback fires
  4. Calculation: RampLimitAdjuster determines which vendor period we're in
  5. Check: Verifies we haven't already increased for this vendor period
  6. Increase: Calls Ramp API to increase limit by vendor billing amount
  7. Record: Creates RampCardLimitAdjustment record with full details
  8. Update: Updates local card record with new limit and spent amount
  9. Vendor View: Vendor sees new limit and adjustment history on their dashboard

Real-World Example

Order #250:

Timeline:

  1. Nov 1: Customer period starts
  2. Nov 1: Customer auto-charged $281 → Payment succeeds
  3. Nov 1: System calculates vendor is in period 1 (Oct 14 - Nov 10)
  4. Nov 1: Limit increased by $230.48 ($500 → $730.48)
  5. Nov 1: Adjustment recorded linking customer period 1 to vendor period 1
  6. Nov 29: Customer period 2 starts → Customer charged $281
  7. Nov 29: System calculates vendor is in period 2 (Nov 11 - Dec 8)
  8. Nov 29: Limit increased by $230.48 ($730.48 → $960.96)
  9. Vendor charges card throughout each period, limit grows as customer pays

Known Limitations

  1. Requires manual Ramp card creation - Admin must create card in Ramp UI first
  2. Depends on vendor_cost_frequency - Must be set correctly on order
  3. No automatic decrease - Limit only goes up, never down (by design)
  4. Silent failures - If limit adjustment fails, payment still succeeds

Files Modified/Added

Models:

Services:

Controllers:

Views:

Migrations:


Architecture

Two-Part Implementation

Part 1: Manual Card Entry & Secure Storage

Part 2: Automatic Limit Adjustments


Part 1: Manual Card Entry & Encryption

Database Changes

Migration 1: Add Encrypted Card Fields

# 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:

Model Changes

Update RampVirtualCard Model

# 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

Service Updates

Update CardViewService

# 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

Controller Changes

Add Admin Card Entry Actions

# 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

Update Routes

# config/routes.rb
namespace :admin do
  resources :ramp_cards do
    member do
      get :enter_card_details
      post :save_card_details
    end
  end
end

Views

Admin Card Entry Form

File: app/views/admin/ramp_cards/enter_card_details.html.erb

Features:

Vendor Card Display

File: app/views/vendors/cards/show.html.erb

Features:


Part 2: Automatic Limit Adjustments

Database Changes

Migration 2: Create Limit Adjustments Table

# 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

Migration 3: Add Vendor Billing Configuration

# 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:

Model Changes

New RampCardLimitAdjustment Model

# 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

Update Order Model

# 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

Service Changes

New RampLimitAdjuster Service

# 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

Callback Integration

Update PaymentTransaction Model

# 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

Admin UI

Vendor Billing Configuration Form

File: app/views/admin/orders/_vendor_billing_config.html.erb

Features:

Card Limit Management Interface

File: app/views/admin/ramp_cards/show.html.erb

Features:

Manual Adjustment Controller

# 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

Vendor UI

Enhanced Order Show Page

File: app/views/vendors/orders/show.html.erb

New section: "Payment Card Status"

Features:


Implementation Steps

Phase 1: Encrypted Card Storage (Week 1) ✅ COMPLETE

Phase 2: Limit Adjustments (Week 2)

Phase 3: Testing & Refinement (Week 3)

Phase 4: Migration & Monitoring (Ongoing)


Testing Plan

Unit Tests

RampVirtualCard Model

# 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

RampLimitAdjuster Service

# 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

Integration Tests

Card Entry Flow

# 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

Automatic Limit Increase

# 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

Manual Testing Checklist


Security Considerations

Encrypted Data

One-Time View

Access Control

PCI Compliance


Migration to PCI/Vault API

When we receive PCI approval from Ramp:

Step 1: Update RampCardService

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

Step 2: Update CardViewService

Step 3: Backfill Existing Cards


Monitoring & Alerts

Key Metrics to Track

Alerts

Logs

Rails.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}"

Future Enhancements

Automatic Card Creation

Vendor Self-Service

Reconciliation Dashboard

Smart Limit Prediction


Questions & Decisions

Resolved

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.

Open

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


Resources


Change Log

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