Goal: Build a payment processor-agnostic architecture and migrate from Authorize.Net to CardPointe Gateway (Fiserv).
Why:
Status: ✅ UAT Complete, Awaiting Production Credentials
Original Plan: Fiserv Commerce Hub with Hosted Fields What We Built: CardPointe Gateway with Hosted iFrame Tokenizer (HIT)
Why the change:
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)
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
# 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
# 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
What was done:
PaymentProcessors::Base interfacePaymentProcessors::AuthorizeNetAdapterPaymentProcessors::FiservAdapter (CardPointe)PaymentProcessorFactoryFiles:
app/services/payment_processors/base.rbapp/services/payment_processors/authorize_net_adapter.rbapp/services/payment_processors/fiserv_adapter.rbapp/services/payment_processor_factory.rbWhat was done:
processor, processor_customer_id, processor_token_id columnscompany_id and contact_id NULLABLE (polymorphic fix)Migrations:
20251006204846_make_payment_profile_references_optional.rbWhat was done:
Files Modified:
app/views/app/quotes/payment.html.erb - CardPointe HIT iframeapp/controllers/app/quotes_controller.rb - Processor routingapp/services/payment_processors/fiserv_adapter.rb - CardPointe API.kamal/secrets - CardPointe credentialsWhat's needed:
Not Started Yet:
Keeping Authorize.Net as backup for now:
// 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
});
# 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
# 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
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
# 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
# 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)
cardpointe_expiry hidden field, captured via postMessageexp_month but database has expiration_monthcustomer_authenticated? but controller didn't have itIf CardPointe has issues in production:
.env: DEFAULT_PAYMENT_PROCESSOR=authorize_netNo code changes needed - just config.
.env with production values# Add to authorize_with_token payload
cof: determine_cof_indicator(@order), # C or M
cofscheduled: determine_cofscheduled(@order) # Y or N
Recommended: Gradual Rollout
Week 1:
DEFAULT_PAYMENT_PROCESSOR=authorize_netWeek 2:
DEFAULT_PAYMENT_PROCESSOR=fiservWeek 3+:
CardPointe:
PCI Compliance:
Internal Docs:
docs/cardpointe_integration_complete.md - Complete implementation guidedocs/cardpointe_credentials.md - Credentials and test dataapp/services/payment_processors/base.rb - Interface documentationLast Updated: 2025-10-06 Status: UAT Complete, Production Pending Implementation Time: ~2 weeks (vs 3-4 weeks estimated)