Skip to main content

Subscription System

Complete guide to recurring subscriptions with automated billing and reservation generation.

Overview

Lancaster Archery Academy's subscription system enables recurring monthly enrollments for ongoing programs with automatic:

  • Monthly billing via Authorize.Net CIM
  • Session reservation generation based on schedule preferences
  • Volume discounts for multi-month commitments
  • Flexible cancellation options (immediate or end-of-period)

Subscription Lifecycle

1. Enrollment

Prerequisites:

  • Event configured as subscription type (event_types.has_subscriptions = 1)
  • Timeslots defined for the event
  • Account has saved payment method (Authorize.Net CIM)

Enrollment Flow:

// User selects subscription event
// Chooses preferred timeslot (day/time)
// Sets session start date (when to begin attending)
// Pays enrollment fee (if required)
// Creates Subscription record

Subscription::create([
'account_id' => $account->id,
'event_id' => $event->id,
'timeslot_id' => $selectedTimeslot->id,
'session_start_date' => $startDate,
'order_id' => $enrollmentOrder->id,
'status' => 1, // active
'enrollment_fee_paid' => $event->enrollment_fee_amount > 0,
'enrollment_fee_date_paid' => now(),
]);

2. Reservation Generation

Automated Process via app/Console/Commands/GenerateSubscriptionReservations.php

Schedule: Runs daily at midnight

Logic (app/Models/Subscriptions/SubscriptionRepository.php):

public static function generateReservations(\Carbon\Carbon $date = null): array
{
$date = $date ?? now();
$lookAheadDays = 30; // Generate reservations 30 days in advance

$activeSubscriptions = Subscription::where('status', 1)
->where('session_start_date', '<=', $date->copy()->addDays($lookAheadDays))
->with(['timeslot', 'account'])
->get();

$generated = [];

foreach ($activeSubscriptions as $subscription) {
$timeslot = $subscription->timeslot;

// Find sessions matching this subscription's timeslot
$matchingSessions = Session::where('event_id', $subscription->event_id)
->where('timeslot_id', $subscription->timeslot_id)
->where('start_date', '>=', $subscription->session_start_date)
->where('start_date', '>=', $date)
->where('start_date', '<=', $date->copy()->addDays($lookAheadDays))
->orderBy('start_date')
->get();

foreach ($matchingSessions as $session) {
// Check if reservation already exists
$existingReservation = Reservation::where('subscription_id', $subscription->id)
->where('session_id', $session->id)
->first();

if (!$existingReservation) {
// Check session has available seats
if ($session->availableSeats() > 0) {
$reservation = Reservation::create([
'account_id' => $subscription->account_id,
'session_id' => $session->id,
'subscription_id' => $subscription->id,
'seats' => 1,
'base_cost' => 0, // Billed monthly, not per session
'status' => 1, // confirmed
]);

$generated[] = $reservation;
}
}
}
}

return $generated;
}

Timeslot Matching:

timeslots (
id, event_id, location_id,
day_of_week (0-6, Sunday=0),
start_time, end_time,
seat_quantity, status
)

// Subscription selects timeslot_id
// System generates sessions with matching timeslot_id
// Reservations auto-created for those sessions

3. Invoice Generation

Command: app/Console/Commands/GenerateSubscriptionInvoices.php

Schedule: Runs on the 1st of each month at 1:00 AM

Logic:

public function handle()
{
$activeSubscriptions = Subscription::where('status', 1)->get();
$currentPeriod = now()->format('Y-m');

foreach ($activeSubscriptions as $subscription) {
// Check if invoice already exists for this billing period
$exists = SubscriptionInvoice::where('subscription_id', $subscription->id)
->where('billing_period', $currentPeriod)
->exists();

if (!$exists) {
// Calculate amount with volume discounts
$amount = $this->calculateInvoiceAmount($subscription);

SubscriptionInvoice::create([
'subscription_id' => $subscription->id,
'account_id' => $subscription->account_id,
'total' => $amount,
'status' => 0, // unpaid
'billing_period' => $currentPeriod,
'discounts' => $this->getApplicableDiscounts($subscription),
'retry_count' => 0,
]);
}
}

$this->info('Generated ' . $count . ' subscription invoices');
}

private function calculateInvoiceAmount(Subscription $subscription): float
{
$basePrice = $subscription->event->price;

// Apply volume discounts if configured
$volumePricing = $subscription->event->volume_pricing;
if ($volumePricing) {
$monthsActive = $subscription->created_at->diffInMonths(now());
$discount = $this->calculateVolumeDiscount($monthsActive, $volumePricing);
return max(0, $basePrice - $discount);
}

return $basePrice;
}

Invoice Structure:

subscription_invoices (
id, subscription_id, account_id,
total, status (0=unpaid, 1=paid, 2=refunded),
billing_period (YYYY-MM),
discounts (JSON),
retry_count,
created_at, updated_at
)

4. Billing

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 = DB::table('subscriptions')
->join('subscription_invoices', 'subscriptions.id', '=', 'subscription_invoices.subscription_id')
->join('accounts', 'subscriptions.account_id', '=', 'accounts.id')
->where('subscriptions.status', 1)
->where('subscription_invoices.status', 0)
->where('accounts.billing_profile_id', '!=', null)
->select('subscriptions.id as Subscription_Id', 'subscription_invoices.id as Invoice_Id')
->get();

$successful = 0;
$failed = 0;

foreach ($results as $result) {
$subscription = Subscription::find($result->Subscription_Id);
$invoice = SubscriptionInvoice::find($result->Invoice_Id);
$account = $subscription->account;

// Charge payment method
$charge = $gateway->chargeCardForSubscription(
$account,
$subscription,
$invoice->total
);

// Create transaction record
$transaction = SubscriptionTransaction::create([
'subscription_invoice_id' => $invoice->id,
'subscription_id' => $subscription->id,
'account_id' => $account->id,
'amount' => $invoice->total,
'response_code' => $charge->getResponseCode(),
'response_text' => $charge->getMessage(),
'trans_id' => $charge->getTransId(),
'auth_code' => $charge->getAuthCode(),
'avs_code' => $charge->getAvsCode(),
'cvv_code' => $charge->getCvvCode(),
]);

if ($charge->isSuccessful()) {
// Mark invoice as paid
$invoice->update([
'status' => 1,
'paid_at' => now(),
]);

// Send confirmation email
Mail::to($account->email)->send(
new SubscriptionPayment($transaction, $account)
);

$successful++;
} else {
// Increment retry count
$invoice->increment('retry_count');

// Send decline notification
Mail::to($account->email)->send(
new SubscriptionTransactionDeclined($transaction, $account)
);

// Check if max retries exceeded
if ($invoice->retry_count >= 3) {
// Cancel subscription
$subscription->update([
'status' => 0,
'canceled_at' => now(),
]);

// Send cancellation notice
Mail::to($account->email)->send(
new SubscriptionCanceled($subscription, 'Maximum payment retries exceeded')
);
}

$failed++;
}
}

Log::info("Subscription billing complete: {$successful} successful, {$failed} failed");
}

Retry Schedule:

  • Initial charge attempt on invoice creation
  • Retry after 1 day if declined
  • Retry after 3 days if still declined
  • Retry after 7 days if still declined
  • Cancel subscription after 3 failed attempts

5. Cancellation

Cancellation Options:

Immediate Cancellation:

use App\Models\Subscriptions\SubscriptionCancelOption;

$subscription->cancel(SubscriptionCancelOption::IMMEDIATELY);

// Effects:
// - Set status = 0
// - Set canceled_at = now()
// - Cancel all future reservations
// - Stop billing immediately
// - No refund for current month

End-of-Period Cancellation:

$subscription->cancel(SubscriptionCancelOption::PERIOD_END);

// Effects:
// - Set status = 0
// - Set canceled_at = end of current billing period
// - Allow completion of current month's sessions
// - No future billing after current period
// - Future reservations beyond current period canceled

Nova Action Implementation (app/Nova/Actions/CancelSubscription.php):

public function handle(ActionFields $fields, Collection $models)
{
foreach ($models as $subscription) {
$cancelOption = $fields->cancel_option === 'immediate'
? SubscriptionCancelOption::IMMEDIATELY
: SubscriptionCancelOption::PERIOD_END;

$subscription->cancel($cancelOption);

// Send cancellation confirmation
Mail::to($subscription->account->email)->send(
new SubscriptionCanceled($subscription, $fields->reason)
);
}

return Action::message('Subscription(s) canceled successfully');
}

public function fields(NovaRequest $request)
{
return [
Select::make('Cancel Option')
->options([
'immediate' => 'Cancel Immediately',
'period_end' => 'Cancel at End of Billing Period',
])
->required(),

Textarea::make('Reason')
->help('Optional reason for cancellation'),
];
}

Subscription Invoices

Invoice Display

Account Dashboard (resources/js/Pages/Account/Transactions.vue):

  • List of all subscription invoices
  • Payment status (paid/unpaid/refunded)
  • Billing period
  • Amount
  • Payment date
  • Download receipt link

Nova Resource (app/Nova/SubscriptionInvoice.php):

  • Admin view of all invoices
  • Filter by status, subscription, account
  • Actions: Manual payment retry, refund, void

Invoice Transactions

Tracking Payment Attempts:

subscription_transactions (
id, subscription_invoice_id, subscription_id, account_id,
amount,
response_code, response_text,
trans_id, auth_code, avs_code, cvv_code,
created_at
)

// Multiple transaction records per invoice (if retries)
// Latest successful transaction = payment record
// Failed transactions = decline history

Manual Payment Retry

Nova Action on SubscriptionInvoice resource:

public function handle(ActionFields $fields, Collection $models)
{
$gateway = new AuthNetGateway();

foreach ($models as $invoice) {
if ($invoice->status == 1) {
return Action::danger('Invoice already paid');
}

$subscription = $invoice->subscription;
$account = $invoice->account;

if (!$account->hasPaymentMethod()) {
return Action::danger('No payment method on file');
}

// Attempt charge
$charge = $gateway->chargeCardForSubscription(
$account, $subscription, $invoice->total
);

// Create transaction
SubscriptionTransaction::create([...]);

if ($charge->isSuccessful()) {
$invoice->update(['status' => 1, 'paid_at' => now()]);
return Action::message('Payment processed successfully');
} else {
$invoice->increment('retry_count');
return Action::danger('Payment declined: ' . $charge->getMessage());
}
}
}

Volume Discounts

Configuration

Event Setup:

// events.volume_pricing JSON field
[
{
"months": 3,
"discount_type": "percent",
"amount": 10
},
{
"months": 6,
"discount_type": "percent",
"amount": 15
},
{
"months": 12,
"discount_type": "fixed",
"amount": 50
}
]

Application Logic

public function calculateMonthlyPrice(Subscription $subscription): float
{
$basePrice = $subscription->event->price;
$monthsActive = $subscription->created_at->diffInMonths(now());

$volumePricing = $subscription->event->volume_pricing;
if (!$volumePricing) {
return $basePrice;
}

// Find applicable discount tier
$applicableDiscount = collect($volumePricing)
->where('months', '<=', $monthsActive)
->sortByDesc('months')
->first();

if (!$applicableDiscount) {
return $basePrice;
}

// Calculate discount
if ($applicableDiscount['discount_type'] === 'percent') {
$discount = $basePrice * ($applicableDiscount['amount'] / 100);
} else {
$discount = $applicableDiscount['amount'];
}

return max(0, $basePrice - $discount);
}

Display to User

Account Dashboard:

  • Show current monthly price
  • Show discount applied
  • Show next tier and months until eligible
  • Encourage continued enrollment

Subscription Migration

Assign New Event

Use Case: Program changes, level progression, schedule update

Nova Action: AssignNewEventToSubscription

public function handle(ActionFields $fields, Collection $models)
{
foreach ($models as $subscription) {
$newEvent = Event::find($fields->event_id);
$newTimeslot = Timeslot::find($fields->timeslot_id);

// Validate new event is subscription type
if (!$newEvent->eventType->has_subscriptions) {
return Action::danger('New event must be subscription type');
}

// Update subscription
$subscription->update([
'event_id' => $newEvent->id,
'timeslot_id' => $newTimeslot->id,
]);

// Cancel future reservations for old event
Reservation::where('subscription_id', $subscription->id)
->whereHas('session', function($q) {
$q->where('start_date', '>', now());
})
->delete();

// Generate new reservations for new event
SubscriptionRepository::generateReservationsForSubscription($subscription);

// Send notification
Mail::to($subscription->account->email)->send(
new SubscriptionMigrated($subscription, $newEvent)
);
}

return Action::message('Subscription(s) migrated successfully');
}

Subscription Reports

Nova Reports Component

Suma Package: nova-components/Reports

Available Reports:

  • Active subscriptions by event
  • Churn rate (cancellations per month)
  • Revenue projections (MRR)
  • Payment success/failure rates
  • Average subscription lifetime
  • Discount tier distribution

Export Formats:

  • CSV
  • Excel
  • PDF

Email Notifications

Enrollment Confirmation:

  • Sent on successful enrollment
  • Includes timeslot, start date, monthly price
  • Links to account dashboard

Monthly Invoice:

  • Sent when invoice generated
  • Shows amount due, billing period
  • Payment method on file

Payment Confirmation:

  • Sent on successful payment
  • Receipt with transaction details
  • Download PDF receipt option

Payment Declined:

  • Sent on failed payment
  • Instructions to update payment method
  • Warning about cancellation if not resolved

Subscription Canceled:

  • Sent on cancellation
  • Reason (if provided)
  • Final billing date
  • Option to re-enroll

Migration Notice:

  • Sent when moved to new event
  • New schedule details
  • Price changes if applicable

Troubleshooting

Common Issues

Reservations Not Generating:

  • Check subscription status is active (status = 1)
  • Verify session_start_date is less than or equal to today
  • Confirm matching sessions exist with timeslot_id
  • Check sessions have available seats
  • Review cron job: php artisan schedule:list

Billing Not Processing:

  • Verify account has valid payment method
  • Check subscription_invoices exist with unpaid status (status=0)
  • Confirm cron job running: php artisan schedule:run
  • Review logs: storage/logs/laravel.log
  • Test manually: php artisan subscriptions:charge

Subscription Won't Cancel:

  • Check for database locks
  • Verify subscription status
  • Review future reservations
  • Check for pending invoices

Testing Subscriptions

# Generate test subscriptions
php artisan subscriptions:seed

# Generate invoices manually
php artisan subscriptions:generate-invoices

# Test billing (sandbox mode)
php artisan subscriptions:charge

# Generate reservations
php artisan subscriptions:generate-reservations

# View upcoming charges
php artisan subscriptions:upcoming