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
Email Notifications
Subscription-Related Emails
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_dateis 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