Payment Processing (Authorize.Net CIM)
Complete guide to Authorize.Net Customer Information Manager integration for secure payment processing and subscription billing.
Overview
Lancaster Archery uses Authorize.Net Customer Information Manager (CIM) for PCI-compliant payment processing. CIM allows secure storage of customer payment methods as tokens, enabling:
- One-time payments without storing card data
- Recurring subscription billing
- Multiple payment methods per customer
- AVS and CVV validation
- Transaction management (void, refund, capture)
Configuration
Environment Variables (.env):
AUTHNET_LOGIN_ID=your_api_login_id
AUTHNET_TRANSACTION_KEY=your_transaction_key
AUTHNET_CLIENT_KEY=your_public_client_key
AUTHNET_MODE=sandbox # or production
API Credentials: Obtained from Authorize.Net merchant dashboard
Customer Information Manager (CIM) Flow
1. Creating Customer Profile with Payment Method
Model: App\Models\Gateways\AuthNetGateway.php
public function createCustomerWithToken($customer, $tokenData): CreateCardResponse
{
// Create payment profile from Accept.js token
$paymentProfile = $this->createPaymentProfile($customer, $tokenData);
// Create customer profile
$customerProfile = new AnetAPI\CustomerProfileType();
$customerProfile->setDescription("Website Customer");
$customerProfile->setMerchantCustomerId($customer['id']);
$customerProfile->setEmail($customer['email']);
$customerProfile->setPaymentProfiles([$paymentProfile]);
// Send to Authorize.Net
$request = new AnetAPI\CreateCustomerProfileRequest();
$request->setMerchantAuthentication($this->getAuth());
$request->setProfile($customerProfile);
$controller = new AnetController\CreateCustomerProfileController($request);
$response = $controller->executeWithApiResponse($this->getEndpoint());
// Returns:
// - billing_profile_id (stored in accounts.billing_profile_id)
// - payment_method_id (stored in accounts.payment_method_id)
return new CreateCardResponse($response);
}
Database Fields:
accounts table:
- billing_profile_id (varchar) — Authorize.Net customer profile ID
- payment_method_id (varchar) — Authorize.Net payment profile ID
2. Accept.js Integration (Frontend)
Purpose: Tokenize card data in browser without touching server
Implementation (resources/js/Pages/Account/Billing.vue):
<script>
import Accept from '@/Vendor/Accept'; // Authorize.Net Accept.js wrapper
export default {
methods: {
async submitPayment() {
const secureData = {
authData: {
clientKey: this.$page.props.authnet_client_key,
apiLoginID: this.$page.props.authnet_login_id
},
cardData: {
cardNumber: this.form.card_number,
month: this.form.exp_month,
year: this.form.exp_year,
cardCode: this.form.cvv
}
};
// Get token from Authorize.Net
Accept.dispatchData(secureData, (response) => {
if (response.messages.resultCode === 'Ok') {
// Send token to server (not card data)
this.form.post(route('billing.store'), {
data_descriptor: response.opaqueData.dataDescriptor,
data_value: response.opaqueData.dataValue
});
} else {
this.handleError(response.messages.message[0].text);
}
});
}
}
};
</script>
Server-Side Processing (app/Http/Controllers/BillingController.php):
public function store(Request $request)
{
$validated = $request->validate([
'data_descriptor' => 'required|string',
'data_value' => 'required|string',
]);
$account = auth()->user()->primaryAccount();
$gateway = new AuthNetGateway();
// Create customer profile with tokenized payment method
$result = $gateway->createCustomerWithToken($account, [
'dataDescriptor' => $validated['data_descriptor'],
'dataValue' => $validated['data_value'],
]);
if ($result->isSuccessful()) {
// Save CIM IDs to account
$account->update([
'billing_profile_id' => $result->getBillingProfileId(),
'payment_method_id' => $result->getPaymentMethodId(),
]);
return redirect()->route('account.billing')
->with('success', 'Payment method added successfully');
}
return back()->withErrors(['error' => $result->getMessage()]);
}
3. Charging Saved Payment Method
One-Time Charge:
public function chargeCard($account, $amount, $description): ChargeCardResponse
{
// Reference saved payment profile
$profileToCharge = new AnetAPI\PaymentProfileType();
$profileToCharge->setPaymentProfileId($account->payment_method_id);
$profileToCharge->setCustomerProfileId($account->billing_profile_id);
// Create transaction request
$transactionRequest = new AnetAPI\TransactionRequestType();
$transactionRequest->setTransactionType("authCaptureTransaction");
$transactionRequest->setAmount($amount);
$transactionRequest->setProfile($profileToCharge);
// Optional: set order information
$order = new AnetAPI\OrderType();
$order->setInvoiceNumber($invoiceNumber);
$order->setDescription($description);
$transactionRequest->setOrder($order);
$request = new AnetAPI\CreateTransactionRequest();
$request->setMerchantAuthentication($this->getAuth());
$request->setTransactionRequest($transactionRequest);
$controller = new AnetController\CreateTransactionController($request);
$response = $controller->executeWithApiResponse($this->getEndpoint());
return new ChargeCardResponse($response);
}
Transaction Record:
// Store transaction details
Transaction::create([
'order_id' => $order->id,
'amount' => $amount,
'payment_method' => 'card',
'response_code' => $response->getResponseCode(),
'response_text' => $response->getResponseText(),
'auth_code' => $response->getAuthCode(),
'trans_id' => $response->getTransId(),
'avs_code' => $response->getAvsCode(),
'cvv_code' => $response->getCvvCode(),
]);
Subscription Billing
Subscription Charge Process
Cron Command: app/Console/Commands/ChargeSubscriptions.php
Schedule: Runs daily at 2:00 AM
Logic (app/Models/Subscriptions/SubscriptionRepository.php):
public static function chargeActiveUnpaidSubscriptions($gateway)
{
// Get all active subscriptions with unpaid invoices
$results = self::getActiveUnpaidSubscriptions();
foreach($results as $result) {
$subscription = Subscription::find($result->Subscription_Id);
$account = $subscription->account;
// Skip if no payment method
if (!$account->hasPaymentMethod()) {
continue;
}
// Calculate amount (with volume discounts if applicable)
$amount = self::calculateInvoiceAmount($subscription);
// Charge payment method
$charge = $gateway->chargeCardForSubscription(
$account,
$subscription,
$amount
);
// Create transaction record
$transaction = self::createSubscriptionTransaction(
$subscription,
$charge->getDetails(),
$charge->getMessage(),
$subscription_invoice->id,
$amount
);
// Update invoice status
if ($charge->isSuccessful()) {
$subscription_invoice->update([
'status' => 1, // paid
'paid_at' => now(),
]);
// Send confirmation email
Mail::to($account->email)->send(
new SubscriptionPayment($transaction, $account)
);
} else {
// Increment retry count
$subscription_invoice->increment('retry_count');
// Send decline email
Mail::to($account->email)->send(
new SubscriptionTransactionDeclined($transaction, $account)
);
// Cancel if max retries exceeded
if ($subscription_invoice->retry_count >= 3) {
$subscription->cancel(SubscriptionCancelOption::IMMEDIATELY);
Mail::to($account->email)->send(
new SubscriptionCanceled($subscription, 'Payment declined')
);
}
}
}
}
Invoice Generation
Cron Command: app/Console/Commands/GenerateSubscriptionInvoices.php
Schedule: Runs first day of each month
Logic:
public function handle()
{
$activeSubscriptions = Subscription::where('status', 1)->get();
foreach ($activeSubscriptions as $subscription) {
// Check if invoice already created for this period
$exists = SubscriptionInvoice::where('subscription_id', $subscription->id)
->where('billing_period', now()->format('Y-m'))
->exists();
if (!$exists) {
SubscriptionInvoice::create([
'subscription_id' => $subscription->id,
'account_id' => $subscription->account_id,
'total' => $subscription->event->price,
'status' => 0, // unpaid
'billing_period' => now()->format('Y-m'),
'discounts' => $this->calculateDiscounts($subscription),
]);
}
}
}
Transaction Management
Void Transaction
Use Case: Cancel same-day transaction before settlement
public function voidTransaction($transactionId): VoidTransactionResponse
{
$request = new AnetAPI\TransactionRequestType();
$request->setTransactionType('voidTransaction');
$request->setRefTransId($transactionId);
$transactionRequest = new AnetAPI\CreateTransactionRequest();
$transactionRequest->setMerchantAuthentication($this->getAuth());
$transactionRequest->setTransactionRequest($request);
$controller = new AnetController\CreateTransactionController($transactionRequest);
$response = $controller->executeWithApiResponse($this->getEndpoint());
return new VoidTransactionResponse($response);
}
Refund Transaction
Use Case: Refund settled transaction
public function refundTransaction($transaction, $amount): RefundTransactionResponse
{
// Must reference original payment method
$creditCard = new AnetAPI\CreditCardType();
$creditCard->setCardNumber($transaction->card_last_four); // Last 4 digits
$creditCard->setExpirationDate('XXXX'); // Required but ignored
$payment = new AnetAPI\PaymentType();
$payment->setCreditCard($creditCard);
$request = new AnetAPI\TransactionRequestType();
$request->setTransactionType('refundTransaction');
$request->setAmount($amount);
$request->setPayment($payment);
$request->setRefTransId($transaction->trans_id); // Original transaction ID
$transactionRequest = new AnetAPI\CreateTransactionRequest();
$transactionRequest->setMerchantAuthentication($this->getAuth());
$transactionRequest->setTransactionRequest($request);
$controller = new AnetController\CreateTransactionController($transactionRequest);
$response = $controller->executeWithApiResponse($this->getEndpoint());
return new RefundTransactionResponse($response);
}
Nova Action (app/Nova/Actions/OrderRefund.php):
- Supports partial or full refunds
- Option to issue credit instead of card refund
- Updates order and reservation status
- Sends refund confirmation email
Error Handling
Response Codes
Success:
1- Approved
Declined:
2- Declined3- Error4- Held for Review
AVS Codes:
A- Address matches, ZIP does notB- Address not providedY- Address and ZIP matchN- No match
CVV Codes:
M- MatchN- No matchP- Not processedU- Issuer unable to process
Retry Logic
Subscription Billing:
- Initial charge attempt
- Retry after 3 days if declined
- Retry after 7 days if still declined
- Cancel subscription after 3 failed attempts
User Notification:
- Immediate email on decline
- Reminder emails before retry
- Cancellation notice after max retries
Testing
Sandbox Test Cards
Visa (Success):
Card: 4111111111111111
Exp: Any future date
CVV: 123
Mastercard (Declined):
Card: 5424000000000015
Exp: Any future date
CVV: 123
Testing Subscription Billing
# Generate test invoices
php artisan subscriptions:generate-invoices
# Charge subscriptions
php artisan subscriptions:charge
# Check results in subscription_transactions table
Security Best Practices
- Never store raw card data - Use Accept.js for tokenization
- Use HTTPS - All payment pages must use SSL
- Validate CVV - Require CVV for all transactions
- Enable AVS - Check address verification
- Monitor fraud - Review held transactions daily
- Audit trail - Log all payment activity
- Secure credentials - Store API keys in
.env, never commit to git - PCI compliance - Follow PCI DSS requirements (simplified with CIM)
Troubleshooting
"Customer profile already exists":
- Account already has
billing_profile_id - Use
createCardWithToken()instead to add additional payment method
"Unable to find customer profile":
billing_profile_idin database doesn't match Authorize.Net- Create new customer profile
"Duplicate transaction":
- Same amount charged within 2 minutes
- Add unique invoice number or wait 2 minutes
Subscription not charging:
- Check
subscription_invoices.status= 0 (unpaid) - Verify account has valid payment method
- Check cron job is running:
php artisan schedule:list - Review logs:
storage/logs/laravel.log