Skip to main content

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 - Declined
  • 3 - Error
  • 4 - Held for Review

AVS Codes:

  • A - Address matches, ZIP does not
  • B - Address not provided
  • Y - Address and ZIP match
  • N - No match

CVV Codes:

  • M - Match
  • N - No match
  • P - Not processed
  • U - 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

  1. Never store raw card data - Use Accept.js for tokenization
  2. Use HTTPS - All payment pages must use SSL
  3. Validate CVV - Require CVV for all transactions
  4. Enable AVS - Check address verification
  5. Monitor fraud - Review held transactions daily
  6. Audit trail - Log all payment activity
  7. Secure credentials - Store API keys in .env, never commit to git
  8. 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_id in 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