Payment Processor Abstraction & CardPointe Migration

Executive Summary

Goal: Build a payment processor-agnostic architecture and migrate from Authorize.Net to CardPointe Gateway (Fiserv).

Why:

Status: ✅ UAT Complete, Awaiting Production Credentials


What We Actually Built

CardPointe Gateway Integration (Not Commerce Hub)

Original Plan: Fiserv Commerce Hub with Hosted Fields What We Built: CardPointe Gateway with Hosted iFrame Tokenizer (HIT)

Why the change:

Architecture Comparison

Current State (Authorize.Net) - STILL ACTIVE

Browser → Accept.js (tokenize) → payment_nonce → Server
Server → Authorize.Net API → Create Customer Profile
Server → Authorize.Net API → Create Payment Profile (vault card)
Server → Authorize.Net API → Authorize Payment (hold funds)
Server → Database → Save PaymentProfile + PaymentTransaction
Later → Authorize.Net API → Capture Authorization (charge card)

New State (CardPointe) - ACTIVE IN UAT

Browser → CardPointe HIT iframe → token + expiry via postMessage → Server
Server → CardPointe API → Authorize Payment (with token)
Server → Database → Save PaymentProfile + PaymentTransaction
Later → CardPointe API → Capture Authorization (charge card)

// Abstraction Layer (IMPLEMENTED)
Controller → PaymentProcessorFactory.for_order(@order)
           ↓
   FiservAdapter (CardPointe) OR AuthorizeNetAdapter
           ↓
   Processor-specific API calls
           ↓
   Returns standardized response

Database Schema (IMPLEMENTED)

PaymentProfile

# Generic processor columns (NEW)
- processor              # 'fiserv' or 'authorize_net'
- processor_customer_id  # Generic customer ID
- processor_token_id     # Generic payment token

# Card details
- card_type, last_four, expiration_month, expiration_year

# Polymorphic relationship (FIXED)
- company_id or contact_id (BOTH NULLABLE)
- is_default

# Legacy Authorize.Net columns (KEPT FOR BACKWARDS COMPATIBILITY)
- authorize_customer_profile_id
- authorize_payment_profile_id

PaymentTransaction

# Generic processor columns (NEW)
- processor                # Which processor handled this
- processor_transaction_id # Generic transaction ID
- transaction_type         # auth_only, capture, refund
- amount, status, response_code
- order_id, payment_profile_id
- metadata (jsonb)         # Processor-specific data

# Legacy Authorize.Net columns (KEPT)
- authorize_transaction_id

Implementation Status

✅ Phase 1: Abstraction Layer - COMPLETE

What was done:

Files:

✅ Phase 2: Database Migration - COMPLETE

What was done:

Migrations:

✅ Phase 3: CardPointe Integration - COMPLETE

What was done:

Files Modified:

🚧 Phase 4: Production Migration - PENDING

What's needed:

Not Started Yet:

⏸️ Phase 5: Cleanup - ON HOLD

Keeping Authorize.Net as backup for now:


Technical Implementation Details

CardPointe HIT Integration

Frontend (JavaScript)

// Load iframe
const iframe = document.createElement('iframe');
iframe.src = 'https://fts-uat.cardconnect.com/itoke/ajax-tokenizer.html'
  + '?useexpiry=true&usecvv=true'
  + '&tokenizewheninactive=true&inactivityto=2000'
  + '&enhancedresponse=true&formatinput=true';

// Listen for token via postMessage
window.addEventListener('message', function(event) {
  if (!/fts-uat\.cardconnect\.com$/.test(new URL(event.origin).host)) {
    return;
  }

  const data = JSON.parse(event.data);
  cardpointeToken = data.token || data.message;  // e.g., '9418594164541111'
  cardpointeExpiry = data.expiry;                 // e.g., '1225' or '20273'

  // Store in hidden fields
  document.getElementById('cardpointe_token').value = cardpointeToken;
  document.getElementById('cardpointe_expiry').value = cardpointeExpiry;
});

// On submit
form.addEventListener('submit', function(e) {
  if (!cardpointeToken) {
    alert('Please complete your card details');
    return false;
  }
  // Form submits with token to server
});

Backend (Ruby)

# app/controllers/app/quotes_controller.rb
def process_credit_card_payment
  processor = PaymentProcessorFactory.for_order(@order)

  if processor.processor_name == "fiserv"
    # CardPointe flow
    cardpointe_token = params[:cardpointe_token]
    cardpointe_expiry = params[:cardpointe_expiry]
    cardholder_name = params[:card_name]

    result = processor.authorize_with_token(
      order: @order,
      token: cardpointe_token,
      expiry: cardpointe_expiry,
      cardholder_name: cardholder_name
    )
  else
    # Authorize.Net flow (unchanged)
    payment_nonce = params[:payment_nonce]
    result = processor.process_payment_with_nonce(...)
  end

  # Save PaymentProfile with parsed expiration
  payment_profile.assign_attributes(
    expiration_month: result[:exp_month],    # Parsed from expiry
    expiration_year: result[:exp_year],      # Parsed from expiry
    card_type: result[:card_type],
    last_four: result[:last_four]
  )
end

CardPointe API Call

# app/services/payment_processors/fiserv_adapter.rb
def authorize_with_token(order:, token:, expiry:, cardholder_name:)
  # Parse expiry (handles MMYY, MMYYY, MMYYYY formats)
  exp_month, exp_year = parse_expiry(expiry)

  # Call CardPointe auth endpoint
  response = HTTP.basic_auth(user: username, pass: password)
    .post("https://fts-uat.cardconnect.com/cardconnect/rest/auth", json: {
      merchid: merchant_id,
      account: token,
      expiry: expiry,
      amount: (order.total_amount * 100).to_i.to_s,  # Cents
      currency: "USD",
      orderid: order.order_number,
      capture: "Y",  # or "N" for auth-only
      ecomind: "E"   # E-commerce indicator
    })

  json = JSON.parse(response.body)

  return {
    transaction_id: json["retref"],           # e.g., '279971160962'
    status: json["respstat"] == "A" ? "approved" : "declined",
    amount: order.total_amount.to_f,
    response_code: json["respcode"],          # e.g., '000'
    response_text: json["resptext"],          # e.g., 'Approval'
    card_type: json["cardtype"] || "Unknown",
    last_four: json["token"]&.last(4),
    exp_month: exp_month,                     # Parsed
    exp_year: exp_year,                       # Parsed
    customer_profile_id: customer_profile_id,
    payment_token_id: json["token"]           # e.g., '9418594164541111'
  }
end

def parse_expiry(expiry)
  clean = expiry.to_s.gsub(/\D/, "")

  case clean.length
  when 4  # MMYY (standard)
    month = clean[0, 2].to_i
    year = 2000 + clean[2, 2].to_i
  when 5  # MMYYY (variable)
    if clean[0] == "0" || clean[0] == "1"
      month = clean[0, 2].to_i        # 01-12
      year = 2000 + clean[2, 2].to_i
    else
      month = clean[0, 1].to_i        # 2-9
      year = 2000 + clean[2, 2].to_i
    end
  when 6  # MMYYYY
    month = clean[0, 2].to_i
    year = clean[2, 4].to_i
  end

  [month, year]
end

Standard Interface (Implemented)

All adapters implement this interface:

module PaymentProcessors
  class Base
    # Must return processor name for routing
    def processor_name
      raise NotImplementedError
    end

    # Create customer profile (or return local ID)
    def create_customer_profile(customer)
      raise NotImplementedError
    end

    # Authorize payment (different per processor)
    # - Authorize.Net: uses payment_nonce
    # - CardPointe: uses token + expiry
    def process_payment_with_nonce(...)  # Authorize.Net
    def authorize_with_token(...)         # CardPointe

    # Standard methods (all adapters)
    def capture_authorization(transaction_id:, amount:)
    def refund_transaction(transaction_id:, amount:)
    def void_authorization(transaction_id:)

    # Error handling
    def errors
      @errors || []
    end
  end
end

Configuration

Environment Variables (Current)

# Processor Selection
DEFAULT_PAYMENT_PROCESSOR=fiserv  # or 'authorize_net'

# CardPointe (UAT)
CARDPOINTE_API_URL=https://fts-uat.cardconnect.com
CARDPOINTE_MERCHANT_ID=490000000069
CARDPOINTE_API_USERNAME=testing
CARDPOINTE_API_PASSWORD=testing123

# Authorize.Net (Keep for backwards compatibility)
AUTHORIZENET_API_LOGIN_ID=4wkfB9825G9
AUTHORIZENET_TRANSACTION_KEY=...
AUTHORIZENET_SIGNATURE_KEY=...
AUTHORIZENET_PUBLIC_CLIENT_KEY=8B4Qt6L8Wu56afsG3RWnXrg8gtZA3p3MWrJ64FfpjnJmyH32W32zjWEy3zAG6Fk3

Per-Customer Processor Selection (Future Enhancement)

# Not yet implemented, but architecture supports it:
class Company
  def payment_processor
    settings[:payment_processor] || ENV['DEFAULT_PAYMENT_PROCESSOR']
  end
end

# Usage:
processor = PaymentProcessorFactory.for(@order.company.payment_processor)

Issues Encountered & Resolved

1. Expiry Field Not Captured

2. Expiry Format Variations

3. PaymentProfile NOT NULL Constraint

4. Column Name Mismatch

5. AccountsController Missing Helpers


Testing Strategy (Implemented)

✅ UAT Testing Completed

🚧 Pending Production Testing


Rollback Plan

If CardPointe has issues in production:

  1. Change .env: DEFAULT_PAYMENT_PROCESSOR=authorize_net
  2. Restart server
  3. New payments route to Authorize.Net
  4. Existing CardPointe authorizations can still be captured

No code changes needed - just config.


Success Metrics


Production Deployment Plan

Pre-Deployment Checklist

Deployment Strategy

Recommended: Gradual Rollout

Week 1:

Week 2:

Week 3+:


Next Steps

Immediate (This Week)

  1. ✅ Complete Fiserv validation testing
  2. ⏳ Await production credentials approval
  3. ⏳ Update documentation with final production values

Short Term (Next 2 Weeks)

  1. Implement Stored Credential Framework in production code
  2. Add capture/refund/void methods to FiservAdapter
  3. Remove debug logging
  4. Production deployment

Long Term (Next 3-6 Months)

  1. Monitor production transactions
  2. Compare costs: CardPointe vs Authorize.Net
  3. Build admin UI for processor management
  4. Consider full Authorize.Net deprecation

Resources

CardPointe:

PCI Compliance:

Internal Docs:


Questions/Decisions

Answered:

  1. Fiserv Account: Yes, we have CardPointe UAT credentials
  2. Integration Type: CardPointe HIT (not Commerce Hub Hosted Fields)
  3. PCI Compliance: SAQ-A achieved
  4. Database Schema: Fixed polymorphic relationship

Pending:

  1. Production Credentials: When will Fiserv approve?
  2. Migration Date: When to switch DEFAULT_PAYMENT_PROCESSOR?
  3. Authorize.Net Retention: Keep as backup or deprecate?
  4. Customer Communication: Notify of payment processor change?

Last Updated: 2025-10-06 Status: UAT Complete, Production Pending Implementation Time: ~2 weeks (vs 3-4 weeks estimated)