Implement Phase 2: Billing & Subscriptions

Add complete billing system with Stripe and PayPal gateway support,
checkout flow with coupon validation, subscription management
(cancel/resume/swap), payment method management, invoice and
transaction history, webhook handlers, dunning/suspension system
with scheduled processing, and 29 new tests (53 total passing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 07:18:48 -05:00
parent 5988c6d064
commit b1e080d70c
40 changed files with 3018 additions and 1 deletions

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Billing\DunningService;
use Illuminate\Console\Command;
class ProcessDunning extends Command
{
protected $signature = 'billing:process-dunning';
protected $description = 'Suspend overdue subscriptions and terminate long-suspended services';
public function handle(DunningService $dunningService): int
{
$suspended = $dunningService->suspendOverdueSubscriptions();
$this->info("Suspended services for {$suspended} overdue subscriptions.");
$terminated = $dunningService->terminateLongSuspendedSubscriptions();
$this->info("Terminated {$terminated} long-suspended services.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PaymentFailed
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
public string $gateway,
public string $gatewayTransactionId,
public float $amount,
public string $currency,
public string $reason,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\PaymentTransaction;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PaymentSucceeded
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
public PaymentTransaction $transaction,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Cashier\Subscription;
class SubscriptionCancelled
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
public Subscription $subscription,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Cashier\Subscription;
class SubscriptionCreated
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
public Subscription $subscription,
) {}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use App\Models\Invoice;
use App\Services\Billing\BillingServiceFactory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class BillingController extends Controller
{
public function __construct(
private BillingServiceFactory $billingFactory,
) {}
public function index(Request $request): Response
{
$user = $request->user();
$stripeService = $this->billingFactory->make('stripe');
return Inertia::render('Billing/Index', [
'paymentMethods' => $stripeService->getPaymentMethods($user),
'invoices' => $user->invoices()
->latest()
->take(20)
->get(),
'transactions' => $user->paymentTransactions()
->latest()
->take(20)
->get(),
'intent' => $user->hasStripeId() ? $user->createSetupIntent() : null,
'stripeKey' => config('cashier.key'),
]);
}
public function addPaymentMethod(Request $request): RedirectResponse
{
$request->validate([
'payment_method_id' => ['required', 'string'],
]);
$service = $this->billingFactory->make('stripe');
$success = $service->addPaymentMethod($request->user(), $request->input('payment_method_id'));
if (! $success) {
return back()->with('error', 'Failed to add payment method.');
}
return back()->with('success', 'Payment method added.');
}
public function removePaymentMethod(Request $request, string $paymentMethodId): RedirectResponse
{
$service = $this->billingFactory->make('stripe');
$service->removePaymentMethod($request->user(), $paymentMethodId);
return back()->with('success', 'Payment method removed.');
}
public function setDefaultPaymentMethod(Request $request): RedirectResponse
{
$request->validate([
'payment_method_id' => ['required', 'string'],
]);
$service = $this->billingFactory->make('stripe');
$service->setDefaultPaymentMethod($request->user(), $request->input('payment_method_id'));
return back()->with('success', 'Default payment method updated.');
}
public function invoices(Request $request): Response
{
$invoices = $request->user()
->invoices()
->latest()
->paginate(20);
return Inertia::render('Billing/Invoices', [
'invoices' => $invoices,
]);
}
public function downloadInvoice(Request $request, Invoice $invoice): \Symfony\Component\HttpFoundation\Response
{
if ($invoice->user_id !== $request->user()->id) {
abort(403);
}
if ($invoice->invoice_pdf) {
return redirect($invoice->invoice_pdf);
}
// Generate a basic invoice download as JSON for now
// PDF generation will be added with a dedicated package
return response()->json($invoice->load('items'));
}
public function transactions(Request $request): Response
{
$transactions = $request->user()
->paymentTransactions()
->latest()
->paginate(20);
return Inertia::render('Billing/Transactions', [
'transactions' => $transactions,
]);
}
public function setupIntent(Request $request): JsonResponse
{
$user = $request->user();
if (! $user->hasStripeId()) {
$user->createAsStripeCustomer();
}
return response()->json([
'client_secret' => $user->createSetupIntent()->client_secret,
]);
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Events\SubscriptionCreated;
use App\Http\Controllers\Controller;
use App\Models\Coupon;
use App\Models\Plan;
use App\Services\Billing\BillingServiceFactory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CheckoutController extends Controller
{
public function __construct(
private BillingServiceFactory $billingFactory,
) {}
public function show(Plan $plan): Response
{
if (! $plan->isAvailable()) {
abort(404, 'This plan is not currently available.');
}
$user = request()->user();
$stripeService = $this->billingFactory->make('stripe');
return Inertia::render('Checkout/Show', [
'plan' => $plan,
'paymentMethods' => $stripeService->getPaymentMethods($user),
'intent' => $user->hasStripeId() ? $user->createSetupIntent() : null,
'stripeKey' => config('cashier.key'),
]);
}
public function store(Request $request, Plan $plan): RedirectResponse|JsonResponse
{
$request->validate([
'gateway' => ['required', 'in:stripe,paypal'],
'payment_method_id' => ['required_if:gateway,stripe', 'nullable', 'string'],
'coupon_code' => ['nullable', 'string', 'max:50'],
]);
if (! $plan->isAvailable()) {
return back()->with('error', 'This plan is not currently available.');
}
$user = $request->user();
$gateway = $request->input('gateway');
$couponCode = $request->input('coupon_code');
$service = $this->billingFactory->make($gateway);
try {
$result = $service->createSubscription(
$user,
$plan,
$request->input('payment_method_id'),
$couponCode,
);
if ($couponCode) {
$this->redeemCoupon($couponCode, $user, $plan);
}
// PayPal requires redirect to approval URL
if ($gateway === 'paypal' && isset($result['approval_url'])) {
return response()->json([
'redirect' => $result['approval_url'],
]);
}
// Stripe incomplete payment needs client-side confirmation
if ($result['status'] === 'incomplete' && isset($result['client_secret'])) {
return response()->json([
'requires_action' => true,
'client_secret' => $result['client_secret'],
]);
}
$subscription = $user->subscriptions()->latest()->first();
if ($subscription) {
SubscriptionCreated::dispatch($user, $subscription);
}
return redirect()->route('account.dashboard')
->with('success', "Successfully subscribed to {$plan->name}!");
} catch (\Exception $e) {
return back()->with('error', 'Checkout failed: '.$e->getMessage());
}
}
public function applyCoupon(Request $request): JsonResponse
{
$request->validate([
'code' => ['required', 'string'],
'plan_id' => ['required', 'exists:plans,id'],
]);
$coupon = Coupon::where('code', $request->input('code'))->first();
if (! $coupon || ! $coupon->isValid()) {
return response()->json(['valid' => false, 'message' => 'Invalid or expired coupon.'], 422);
}
$plan = Plan::findOrFail($request->input('plan_id'));
$discount = $this->calculateDiscount($coupon, $plan);
return response()->json([
'valid' => true,
'discount' => $discount,
'new_total' => max(0, $plan->price - $discount),
'coupon_type' => $coupon->type,
'coupon_value' => $coupon->value,
]);
}
public function paypalCallback(Request $request): RedirectResponse
{
$planId = $request->query('plan');
$plan = Plan::findOrFail($planId);
return redirect()->route('account.dashboard')
->with('success', "Successfully subscribed to {$plan->name}!");
}
public function paypalCancel(): RedirectResponse
{
return redirect()->route('account.plans.index')
->with('error', 'PayPal checkout was cancelled.');
}
private function redeemCoupon(string $code, \App\Models\User $user, Plan $plan): void
{
$coupon = Coupon::where('code', $code)->first();
if ($coupon && $coupon->isValid()) {
$discount = $this->calculateDiscount($coupon, $plan);
$coupon->redemptions()->create([
'user_id' => $user->id,
'discount_amount' => $discount,
]);
$coupon->increment('times_used');
}
}
private function calculateDiscount(Coupon $coupon, Plan $plan): float
{
return match ($coupon->type) {
'percentage' => round($plan->price * ($coupon->value / 100), 2),
'fixed_amount' => min($coupon->value, $plan->price),
default => 0,
};
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use App\Models\Plan;
use Inertia\Inertia;
use Inertia\Response;
class PlanController extends Controller
{
public function index(): Response
{
$plans = Plan::query()
->where('status', 'active')
->orderBy('sort_order')
->orderBy('price')
->get()
->groupBy('service_type');
return Inertia::render('Plans/Index', [
'plansByType' => $plans,
]);
}
public function show(Plan $plan): Response
{
if ($plan->status !== 'active') {
abort(404);
}
return Inertia::render('Plans/Show', [
'plan' => $plan,
]);
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Events\SubscriptionCancelled;
use App\Http\Controllers\Controller;
use App\Models\Plan;
use App\Services\Billing\BillingServiceFactory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class SubscriptionController extends Controller
{
public function __construct(
private BillingServiceFactory $billingFactory,
) {}
public function index(Request $request): Response
{
$subscriptions = $request->user()
->subscriptions()
->with('plan')
->latest()
->get();
return Inertia::render('Subscriptions/Index', [
'subscriptions' => $subscriptions,
]);
}
public function show(Request $request, int $subscriptionId): Response
{
$subscription = $request->user()
->subscriptions()
->with('plan')
->findOrFail($subscriptionId);
$availablePlans = Plan::query()
->where('status', 'active')
->where('service_type', $subscription->plan?->service_type)
->where('id', '!=', $subscription->plan_id)
->orderBy('price')
->get();
return Inertia::render('Subscriptions/Show', [
'subscription' => $subscription,
'availablePlans' => $availablePlans,
]);
}
public function cancel(Request $request, int $subscriptionId): RedirectResponse
{
$subscription = $request->user()
->subscriptions()
->findOrFail($subscriptionId);
$gateway = $subscription->gateway ?? 'stripe';
$service = $this->billingFactory->make($gateway);
$gatewayId = $gateway === 'stripe'
? $subscription->stripe_id
: $subscription->gateway_subscription_id;
$success = $service->cancelSubscription(
$request->user(),
$gatewayId,
$request->boolean('immediately'),
);
if (! $success) {
return back()->with('error', 'Failed to cancel subscription. Please try again.');
}
SubscriptionCancelled::dispatch($request->user(), $subscription);
return back()->with('success', 'Subscription has been cancelled.');
}
public function resume(Request $request, int $subscriptionId): RedirectResponse
{
$subscription = $request->user()
->subscriptions()
->findOrFail($subscriptionId);
$gateway = $subscription->gateway ?? 'stripe';
$service = $this->billingFactory->make($gateway);
$gatewayId = $gateway === 'stripe'
? $subscription->stripe_id
: $subscription->gateway_subscription_id;
$success = $service->resumeSubscription($request->user(), $gatewayId);
if (! $success) {
return back()->with('error', 'Failed to resume subscription. Please try again.');
}
return back()->with('success', 'Subscription has been resumed.');
}
public function swap(Request $request, int $subscriptionId): RedirectResponse
{
$request->validate([
'plan_id' => ['required', 'exists:plans,id'],
]);
$subscription = $request->user()
->subscriptions()
->findOrFail($subscriptionId);
$newPlan = Plan::findOrFail($request->input('plan_id'));
$gateway = $subscription->gateway ?? 'stripe';
$service = $this->billingFactory->make($gateway);
$gatewayId = $gateway === 'stripe'
? $subscription->stripe_id
: $subscription->gateway_subscription_id;
try {
$service->swapSubscription($request->user(), $gatewayId, $newPlan);
return back()->with('success', "Plan changed to {$newPlan->name}.");
} catch (\Exception $e) {
return back()->with('error', 'Failed to change plan: '.$e->getMessage());
}
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Webhooks;
use App\Events\PaymentFailed;
use App\Events\PaymentSucceeded;
use App\Events\SubscriptionCancelled;
use App\Http\Controllers\Controller;
use App\Models\Invoice;
use App\Models\PaymentTransaction;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Subscription;
class PayPalWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
$payload = $request->all();
$eventType = $payload['event_type'] ?? '';
Log::info('PayPal webhook received', ['event_type' => $eventType]);
return match ($eventType) {
'PAYMENT.SALE.COMPLETED' => $this->handlePaymentCompleted($payload),
'PAYMENT.SALE.DENIED', 'PAYMENT.SALE.REFUNDED' => $this->handlePaymentFailed($payload),
'BILLING.SUBSCRIPTION.CANCELLED', 'BILLING.SUBSCRIPTION.EXPIRED' => $this->handleSubscriptionCancelled($payload),
'BILLING.SUBSCRIPTION.SUSPENDED' => $this->handleSubscriptionSuspended($payload),
default => response()->json(['status' => 'ignored']),
};
}
private function handlePaymentCompleted(array $payload): JsonResponse
{
$resource = $payload['resource'] ?? [];
$subscriptionId = $resource['billing_agreement_id'] ?? null;
$subscription = $subscriptionId
? Subscription::where('gateway_subscription_id', $subscriptionId)->first()
: null;
$user = $subscription?->user;
if (! $user) {
Log::warning('PayPal webhook: user not found', ['subscription_id' => $subscriptionId]);
return response()->json(['status' => 'user_not_found']);
}
$transaction = PaymentTransaction::create([
'user_id' => $user->id,
'subscription_id' => $subscription->id,
'gateway' => 'paypal',
'gateway_transaction_id' => $resource['id'] ?? '',
'amount' => (float) ($resource['amount']['total'] ?? 0),
'currency' => strtoupper($resource['amount']['currency'] ?? 'USD'),
'status' => 'succeeded',
'payment_method' => 'paypal',
'description' => 'PayPal subscription payment',
'metadata' => ['paypal_resource' => $resource['id'] ?? null],
]);
$this->createInvoice($user, $subscription, $resource);
PaymentSucceeded::dispatch($user, $transaction);
return response()->json(['status' => 'success']);
}
private function handlePaymentFailed(array $payload): JsonResponse
{
$resource = $payload['resource'] ?? [];
$subscriptionId = $resource['billing_agreement_id'] ?? null;
$subscription = $subscriptionId
? Subscription::where('gateway_subscription_id', $subscriptionId)->first()
: null;
$user = $subscription?->user;
if ($user) {
PaymentTransaction::create([
'user_id' => $user->id,
'subscription_id' => $subscription?->id,
'gateway' => 'paypal',
'gateway_transaction_id' => $resource['id'] ?? '',
'amount' => (float) ($resource['amount']['total'] ?? 0),
'currency' => strtoupper($resource['amount']['currency'] ?? 'USD'),
'status' => 'failed',
'payment_method' => 'paypal',
'description' => 'PayPal payment failed',
]);
PaymentFailed::dispatch(
$user,
'paypal',
$resource['id'] ?? '',
(float) ($resource['amount']['total'] ?? 0),
strtoupper($resource['amount']['currency'] ?? 'USD'),
'Payment denied or refunded',
);
}
return response()->json(['status' => 'success']);
}
private function handleSubscriptionCancelled(array $payload): JsonResponse
{
$resource = $payload['resource'] ?? [];
$paypalSubscriptionId = $resource['id'] ?? null;
$subscription = Subscription::where('gateway_subscription_id', $paypalSubscriptionId)->first();
if ($subscription) {
$subscription->update([
'stripe_status' => 'canceled',
'cancelled_at' => now(),
'ends_at' => $subscription->current_period_end ?? now(),
]);
SubscriptionCancelled::dispatch($subscription->user, $subscription);
}
return response()->json(['status' => 'success']);
}
private function handleSubscriptionSuspended(array $payload): JsonResponse
{
$resource = $payload['resource'] ?? [];
$paypalSubscriptionId = $resource['id'] ?? null;
$subscription = Subscription::where('gateway_subscription_id', $paypalSubscriptionId)->first();
if ($subscription) {
$subscription->update(['stripe_status' => 'past_due']);
}
return response()->json(['status' => 'success']);
}
private function createInvoice(User $user, Subscription $subscription, array $resource): void
{
Invoice::create([
'user_id' => $user->id,
'subscription_id' => $subscription->id,
'gateway' => 'paypal',
'gateway_invoice_id' => $resource['id'] ?? null,
'number' => config('billing.invoice.prefix').'-'.now()->format('Ymd').'-'.rand(1000, 9999),
'total' => (float) ($resource['amount']['total'] ?? 0),
'tax' => 0,
'currency' => strtoupper($resource['amount']['currency'] ?? 'USD'),
'status' => 'paid',
'paid_at' => now(),
]);
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Webhooks;
use App\Events\PaymentFailed;
use App\Events\PaymentSucceeded;
use App\Events\SubscriptionCancelled;
use App\Models\Invoice;
use App\Models\PaymentTransaction;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Http\Controllers\WebhookController;
use Symfony\Component\HttpFoundation\Response;
class StripeWebhookController extends WebhookController
{
public function handleInvoicePaymentSucceeded(array $payload): Response
{
$stripeInvoice = $payload['data']['object'] ?? [];
$stripeCustomerId = $stripeInvoice['customer'] ?? null;
$user = User::where('stripe_id', $stripeCustomerId)->first();
if (! $user) {
Log::warning('Stripe webhook: user not found for customer', ['stripe_customer' => $stripeCustomerId]);
return $this->successMethod();
}
$transaction = PaymentTransaction::create([
'user_id' => $user->id,
'gateway' => 'stripe',
'gateway_transaction_id' => $stripeInvoice['payment_intent'] ?? $stripeInvoice['id'],
'amount' => ($stripeInvoice['amount_paid'] ?? 0) / 100,
'currency' => strtoupper($stripeInvoice['currency'] ?? 'usd'),
'status' => 'succeeded',
'payment_method' => 'card',
'description' => 'Subscription payment',
'metadata' => [
'stripe_invoice_id' => $stripeInvoice['id'] ?? null,
'subscription' => $stripeInvoice['subscription'] ?? null,
],
]);
$this->createOrUpdateInvoice($user, $stripeInvoice);
PaymentSucceeded::dispatch($user, $transaction);
return $this->successMethod();
}
public function handleInvoicePaymentFailed(array $payload): Response
{
$stripeInvoice = $payload['data']['object'] ?? [];
$stripeCustomerId = $stripeInvoice['customer'] ?? null;
$user = User::where('stripe_id', $stripeCustomerId)->first();
if (! $user) {
return $this->successMethod();
}
PaymentTransaction::create([
'user_id' => $user->id,
'gateway' => 'stripe',
'gateway_transaction_id' => $stripeInvoice['payment_intent'] ?? $stripeInvoice['id'],
'amount' => ($stripeInvoice['amount_due'] ?? 0) / 100,
'currency' => strtoupper($stripeInvoice['currency'] ?? 'usd'),
'status' => 'failed',
'payment_method' => 'card',
'description' => 'Payment failed',
'metadata' => [
'stripe_invoice_id' => $stripeInvoice['id'] ?? null,
'attempt_count' => $stripeInvoice['attempt_count'] ?? null,
],
]);
PaymentFailed::dispatch(
$user,
'stripe',
$stripeInvoice['payment_intent'] ?? $stripeInvoice['id'] ?? '',
($stripeInvoice['amount_due'] ?? 0) / 100,
strtoupper($stripeInvoice['currency'] ?? 'usd'),
'Payment failed',
);
return $this->successMethod();
}
public function handleCustomerSubscriptionDeleted(array $payload): Response
{
$stripeSubscription = $payload['data']['object'] ?? [];
$stripeCustomerId = $stripeSubscription['customer'] ?? null;
$user = User::where('stripe_id', $stripeCustomerId)->first();
if ($user) {
$subscription = $user->subscriptions()
->where('stripe_id', $stripeSubscription['id'] ?? null)
->first();
if ($subscription) {
$subscription->update(['cancelled_at' => now()]);
SubscriptionCancelled::dispatch($user, $subscription);
}
}
return parent::handleCustomerSubscriptionDeleted($payload);
}
private function createOrUpdateInvoice(User $user, array $stripeInvoice): void
{
$subscriptionId = $stripeInvoice['subscription'] ?? null;
$subscription = $subscriptionId
? $user->subscriptions()->where('stripe_id', $subscriptionId)->first()
: null;
Invoice::updateOrCreate(
['gateway_invoice_id' => $stripeInvoice['id']],
[
'user_id' => $user->id,
'subscription_id' => $subscription?->id,
'gateway' => 'stripe',
'number' => $stripeInvoice['number'] ?? config('billing.invoice.prefix').'-'.now()->format('Ymd').'-'.rand(1000, 9999),
'total' => ($stripeInvoice['amount_paid'] ?? 0) / 100,
'tax' => ($stripeInvoice['tax'] ?? 0) / 100,
'currency' => strtoupper($stripeInvoice['currency'] ?? 'usd'),
'status' => 'paid',
'invoice_pdf' => $stripeInvoice['invoice_pdf'] ?? null,
'paid_at' => now(),
],
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\PaymentFailed;
use Illuminate\Support\Facades\Log;
class HandlePaymentFailed
{
public function handle(PaymentFailed $event): void
{
Log::warning("Payment failed for user #{$event->user->id}", [
'gateway' => $event->gateway,
'transaction_id' => $event->gatewayTransactionId,
'amount' => $event->amount,
'currency' => $event->currency,
'reason' => $event->reason,
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\PaymentSucceeded;
use App\Services\Billing\DunningService;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Subscription;
class HandlePaymentSucceeded
{
public function __construct(
private DunningService $dunningService,
) {}
public function handle(PaymentSucceeded $event): void
{
Log::info("Payment succeeded for user #{$event->user->id}", [
'transaction_id' => $event->transaction->id,
'amount' => $event->transaction->amount,
]);
// Reactivate any suspended services if the user pays an overdue subscription
$subscriptionId = $event->transaction->subscription_id;
if ($subscriptionId) {
$subscription = Subscription::find($subscriptionId);
if ($subscription && $subscription->stripe_status === 'active') {
$this->dunningService->reactivateServicesForSubscription($subscription);
}
}
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Http\Responses\LoginResponse; use App\Http\Responses\LoginResponse;
use App\Services\Billing\BillingServiceFactory;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@@ -17,6 +18,7 @@ class AppServiceProvider extends ServiceProvider
public function register(): void public function register(): void
{ {
$this->app->singleton(LoginResponseContract::class, LoginResponse::class); $this->app->singleton(LoginResponseContract::class, LoginResponse::class);
$this->app->singleton(BillingServiceFactory::class);
} }
public function boot(): void public function boot(): void

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use InvalidArgumentException;
class BillingServiceFactory
{
public function make(string $gateway): BillingServiceInterface
{
return match ($gateway) {
'stripe' => app(StripeBillingService::class),
'paypal' => app(PayPalBillingService::class),
default => throw new InvalidArgumentException("Unsupported billing gateway: {$gateway}"),
};
}
public function default(): BillingServiceInterface
{
return $this->make(config('billing.default_gateway'));
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\Plan;
use App\Models\User;
interface BillingServiceInterface
{
/**
* Create a new subscription for a user.
*
* @return array{subscription_id: string, status: string, client_secret?: string, approval_url?: string}
*/
public function createSubscription(User $user, Plan $plan, ?string $paymentMethodId = null, ?string $couponCode = null): array;
/**
* Cancel a subscription.
*/
public function cancelSubscription(User $user, string $subscriptionId, bool $immediately = false): bool;
/**
* Swap a subscription to a different plan.
*
* @return array{subscription_id: string, status: string}
*/
public function swapSubscription(User $user, string $subscriptionId, Plan $newPlan): array;
/**
* Resume a cancelled subscription.
*/
public function resumeSubscription(User $user, string $subscriptionId): bool;
/**
* Add a payment method for a user.
*/
public function addPaymentMethod(User $user, string $paymentMethodId): bool;
/**
* Remove a payment method.
*/
public function removePaymentMethod(User $user, string $paymentMethodId): bool;
/**
* Set the default payment method.
*/
public function setDefaultPaymentMethod(User $user, string $paymentMethodId): bool;
/**
* Get all payment methods for a user.
*
* @return array<int, array{id: string, brand: string, last_four: string, exp_month: int, exp_year: int, is_default: bool}>
*/
public function getPaymentMethods(User $user): array;
/**
* Get the gateway name.
*/
public function getGateway(): string;
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\Service;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Subscription;
class DunningService
{
/**
* Suspend services for subscriptions that are past due beyond the grace period.
*/
public function suspendOverdueSubscriptions(): int
{
$graceDays = config('billing.suspension.days_past_due_to_suspend');
$cutoff = now()->subDays($graceDays);
$subscriptions = Subscription::query()
->where('stripe_status', 'past_due')
->where('updated_at', '<=', $cutoff)
->get();
$count = 0;
foreach ($subscriptions as $subscription) {
$this->suspendServicesForSubscription($subscription);
$count++;
}
if ($count > 0) {
Log::info("Dunning: suspended services for {$count} overdue subscriptions.");
}
return $count;
}
/**
* Terminate services for subscriptions that have been suspended too long.
*/
public function terminateLongSuspendedSubscriptions(): int
{
$terminateDays = config('billing.suspension.days_suspended_to_terminate');
$cutoff = now()->subDays($terminateDays);
$services = Service::query()
->where('status', 'suspended')
->where('suspended_at', '<=', $cutoff)
->get();
$count = 0;
foreach ($services as $service) {
$service->update([
'status' => 'terminated',
'terminated_at' => now(),
'auto_renew' => false,
]);
$count++;
Log::info("Dunning: terminated service #{$service->id} for user #{$service->user_id}.");
}
return $count;
}
/**
* Suspend all services tied to a specific subscription.
*/
public function suspendServicesForSubscription(Subscription $subscription): void
{
Service::query()
->where('subscription_id', $subscription->id)
->where('status', 'active')
->update([
'status' => 'suspended',
'suspended_at' => now(),
]);
}
/**
* Reactivate services when a past-due subscription becomes active again.
*/
public function reactivateServicesForSubscription(Subscription $subscription): void
{
Service::query()
->where('subscription_id', $subscription->id)
->where('status', 'suspended')
->update([
'status' => 'active',
'suspended_at' => null,
]);
}
/**
* Suspend a user's account entirely.
*/
public function suspendUser(User $user, string $reason = 'overdue'): void
{
$user->update(['status' => 'suspended']);
Log::info("Dunning: suspended user #{$user->id} for reason: {$reason}.");
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Srmklive\PayPal\Services\PayPal as PayPalClient;
class PayPalBillingService implements BillingServiceInterface
{
private PayPalClient $client;
public function __construct()
{
$this->client = new PayPalClient;
$this->client->setApiCredentials(config('paypal'));
$this->client->getAccessToken();
}
public function createSubscription(User $user, Plan $plan, ?string $paymentMethodId = null, ?string $couponCode = null): array
{
if (! $plan->paypal_plan_id) {
throw new \RuntimeException('Plan does not have a PayPal plan ID configured.');
}
try {
$response = $this->client->createSubscription([
'plan_id' => $plan->paypal_plan_id,
'subscriber' => [
'name' => [
'given_name' => $user->name,
],
'email_address' => $user->email,
],
'application_context' => [
'brand_name' => config('app.name'),
'return_url' => route('account.billing.paypal.callback', ['plan' => $plan->id]),
'cancel_url' => route('account.billing.paypal.cancel'),
'user_action' => 'SUBSCRIBE_NOW',
],
]);
if (isset($response['id'])) {
$approvalUrl = collect($response['links'] ?? [])
->firstWhere('rel', 'approve')['href'] ?? null;
return [
'subscription_id' => $response['id'],
'status' => $response['status'] ?? 'APPROVAL_PENDING',
'approval_url' => $approvalUrl,
];
}
throw new \RuntimeException('Failed to create PayPal subscription: '.json_encode($response));
} catch (\Exception $e) {
Log::error('PayPal subscription creation failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function cancelSubscription(User $user, string $subscriptionId, bool $immediately = false): bool
{
try {
$this->client->cancelSubscription($subscriptionId, 'Customer requested cancellation');
return true;
} catch (\Exception $e) {
Log::error('PayPal subscription cancellation failed', [
'subscription_id' => $subscriptionId,
'error' => $e->getMessage(),
]);
return false;
}
}
public function swapSubscription(User $user, string $subscriptionId, Plan $newPlan): array
{
try {
$response = $this->client->reviseSubscription($subscriptionId, [
'plan_id' => $newPlan->paypal_plan_id,
]);
$approvalUrl = collect($response['links'] ?? [])
->firstWhere('rel', 'approve')['href'] ?? null;
return [
'subscription_id' => $subscriptionId,
'status' => $response['status'] ?? 'APPROVAL_PENDING',
'approval_url' => $approvalUrl,
];
} catch (\Exception $e) {
Log::error('PayPal subscription swap failed', [
'subscription_id' => $subscriptionId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function resumeSubscription(User $user, string $subscriptionId): bool
{
try {
$this->client->activateSubscription($subscriptionId, 'Customer requested reactivation');
return true;
} catch (\Exception $e) {
Log::error('PayPal subscription resume failed', [
'subscription_id' => $subscriptionId,
'error' => $e->getMessage(),
]);
return false;
}
}
public function addPaymentMethod(User $user, string $paymentMethodId): bool
{
// PayPal handles payment methods through its own flow
return true;
}
public function removePaymentMethod(User $user, string $paymentMethodId): bool
{
// PayPal handles payment methods through its own flow
return true;
}
public function setDefaultPaymentMethod(User $user, string $paymentMethodId): bool
{
// PayPal handles payment methods through its own flow
return true;
}
/** @return array<int, array{id: string, brand: string, last_four: string, exp_month: int, exp_year: int, is_default: bool}> */
public function getPaymentMethods(User $user): array
{
// PayPal manages payment methods externally
return [];
}
public function getGateway(): string
{
return 'paypal';
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\Coupon;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Exceptions\IncompletePayment;
class StripeBillingService implements BillingServiceInterface
{
public function createSubscription(User $user, Plan $plan, ?string $paymentMethodId = null, ?string $couponCode = null): array
{
if (! $user->hasStripeId()) {
$user->createAsStripeCustomer();
}
if ($paymentMethodId) {
$user->addPaymentMethod($paymentMethodId);
$user->updateDefaultPaymentMethod($paymentMethodId);
}
$subscription = $user->newSubscription($plan->slug, $plan->stripe_price_id);
if ($couponCode) {
$coupon = Coupon::where('code', $couponCode)->first();
if ($coupon?->isValid()) {
$subscription->withPromotionCode($couponCode);
}
}
try {
$result = DB::transaction(function () use ($subscription, $plan) {
$cashierSubscription = $subscription->create();
$cashierSubscription->update([
'plan_id' => $plan->id,
'gateway' => 'stripe',
'gateway_subscription_id' => $cashierSubscription->stripe_id,
'gateway_customer_id' => $cashierSubscription->user->stripe_id,
'gateway_price_id' => $plan->stripe_price_id,
'current_period_start' => now(),
'current_period_end' => $this->calculatePeriodEnd($plan->billing_cycle),
]);
return [
'subscription_id' => $cashierSubscription->stripe_id,
'status' => $cashierSubscription->stripe_status,
];
});
return $result;
} catch (IncompletePayment $e) {
return [
'subscription_id' => $e->payment->subscription,
'status' => 'incomplete',
'client_secret' => $e->payment->clientSecret(),
];
}
}
public function cancelSubscription(User $user, string $subscriptionId, bool $immediately = false): bool
{
$subscription = $user->subscriptions()->where('stripe_id', $subscriptionId)->first();
if (! $subscription) {
return false;
}
try {
if ($immediately) {
$subscription->cancelNow();
} else {
$subscription->cancel();
}
$subscription->update(['cancelled_at' => now()]);
return true;
} catch (\Exception $e) {
Log::error('Stripe subscription cancellation failed', [
'subscription_id' => $subscriptionId,
'error' => $e->getMessage(),
]);
return false;
}
}
public function swapSubscription(User $user, string $subscriptionId, Plan $newPlan): array
{
$subscription = $user->subscriptions()->where('stripe_id', $subscriptionId)->first();
if (! $subscription) {
throw new \RuntimeException('Subscription not found.');
}
try {
$subscription->swap($newPlan->stripe_price_id);
$subscription->update([
'plan_id' => $newPlan->id,
'gateway_price_id' => $newPlan->stripe_price_id,
]);
return [
'subscription_id' => $subscription->stripe_id,
'status' => $subscription->stripe_status,
];
} catch (IncompletePayment $e) {
return [
'subscription_id' => $subscription->stripe_id,
'status' => 'incomplete',
'client_secret' => $e->payment->clientSecret(),
];
}
}
public function resumeSubscription(User $user, string $subscriptionId): bool
{
$subscription = $user->subscriptions()->where('stripe_id', $subscriptionId)->first();
if (! $subscription) {
return false;
}
try {
$subscription->resume();
$subscription->update(['cancelled_at' => null]);
return true;
} catch (\Exception $e) {
Log::error('Stripe subscription resume failed', [
'subscription_id' => $subscriptionId,
'error' => $e->getMessage(),
]);
return false;
}
}
public function addPaymentMethod(User $user, string $paymentMethodId): bool
{
try {
if (! $user->hasStripeId()) {
$user->createAsStripeCustomer();
}
$user->addPaymentMethod($paymentMethodId);
return true;
} catch (\Exception $e) {
Log::error('Failed to add payment method', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function removePaymentMethod(User $user, string $paymentMethodId): bool
{
try {
$paymentMethod = $user->findPaymentMethod($paymentMethodId);
$paymentMethod?->delete();
return true;
} catch (\Exception $e) {
Log::error('Failed to remove payment method', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function setDefaultPaymentMethod(User $user, string $paymentMethodId): bool
{
try {
$user->updateDefaultPaymentMethod($paymentMethodId);
return true;
} catch (\Exception $e) {
Log::error('Failed to set default payment method', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/** @return array<int, array{id: string, brand: string, last_four: string, exp_month: int, exp_year: int, is_default: bool}> */
public function getPaymentMethods(User $user): array
{
if (! $user->hasStripeId()) {
return [];
}
$defaultMethod = $user->defaultPaymentMethod();
$defaultId = $defaultMethod?->id;
return $user->paymentMethods()->map(fn ($method) => [
'id' => $method->id,
'brand' => $method->card->brand ?? 'unknown',
'last_four' => $method->card->last4 ?? '****',
'exp_month' => $method->card->exp_month ?? 0,
'exp_year' => $method->card->exp_year ?? 0,
'is_default' => $method->id === $defaultId,
])->all();
}
public function getGateway(): string
{
return 'stripe';
}
private function calculatePeriodEnd(string $billingCycle): \Carbon\Carbon
{
return match ($billingCycle) {
'monthly' => now()->addMonth(),
'quarterly' => now()->addMonths(3),
'semi_annual' => now()->addMonths(6),
'annual' => now()->addYear(),
default => now()->addMonth(),
};
}
}

View File

@@ -23,11 +23,19 @@ return Application::configure(basePath: dirname(__DIR__))
Route::domain(config('app.domains.admin')) Route::domain(config('app.domains.admin'))
->middleware(['web', 'auth', 'verified', 'role:admin']) ->middleware(['web', 'auth', 'verified', 'role:admin'])
->group(base_path('routes/admin.php')); ->group(base_path('routes/admin.php'));
Route::domain(config('app.domains.account'))
->middleware('web')
->group(base_path('routes/webhooks.php'));
}, },
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*'); $middleware->trustProxies(at: '*');
$middleware->validateCsrfTokens(except: [
'webhooks/*',
]);
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class, \App\Http\Middleware\HandleInertiaRequests::class,
]); ]);

View File

@@ -0,0 +1,73 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Payment Gateway
|--------------------------------------------------------------------------
*/
'default_gateway' => env('BILLING_DEFAULT_GATEWAY', 'stripe'),
/*
|--------------------------------------------------------------------------
| Supported Currencies
|--------------------------------------------------------------------------
*/
'currencies' => ['USD', 'EUR', 'GBP', 'CAD', 'AUD'],
'default_currency' => env('BILLING_DEFAULT_CURRENCY', 'USD'),
/*
|--------------------------------------------------------------------------
| Invoice Settings
|--------------------------------------------------------------------------
*/
'invoice' => [
'prefix' => env('INVOICE_PREFIX', 'INV'),
'company_name' => env('INVOICE_COMPANY_NAME', 'EZSCALE LLC'),
'company_address' => env('INVOICE_COMPANY_ADDRESS', ''),
'company_email' => env('INVOICE_COMPANY_EMAIL', 'billing@ezscale.cloud'),
'company_phone' => env('INVOICE_COMPANY_PHONE', ''),
'tax_id' => env('INVOICE_TAX_ID', ''),
'footer' => env('INVOICE_FOOTER', 'Thank you for your business!'),
],
/*
|--------------------------------------------------------------------------
| Dunning (Failed Payment Handling)
|--------------------------------------------------------------------------
*/
'dunning' => [
'grace_period_days' => (int) env('DUNNING_GRACE_PERIOD_DAYS', 7),
'suspension_warning_days' => (int) env('DUNNING_SUSPENSION_WARNING_DAYS', 3),
'termination_days' => (int) env('DUNNING_TERMINATION_DAYS', 30),
],
/*
|--------------------------------------------------------------------------
| Suspension Policy
|--------------------------------------------------------------------------
*/
'suspension' => [
'days_past_due_to_suspend' => (int) env('SUSPENSION_DAYS_PAST_DUE', 7),
'days_suspended_to_terminate' => (int) env('SUSPENSION_DAYS_TO_TERMINATE', 30),
],
/*
|--------------------------------------------------------------------------
| Tax
|--------------------------------------------------------------------------
*/
'tax' => [
'enabled' => (bool) env('TAX_ENABLED', false),
'default_rate' => (float) env('TAX_DEFAULT_RATE', 0),
],
];

View File

@@ -24,6 +24,24 @@ const domains = computed(() => page.props.domains);
> >
Dashboard Dashboard
</Link> </Link>
<Link
href="/subscriptions"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
>
Subscriptions
</Link>
<Link
href="/billing"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
>
Billing
</Link>
<Link
href="/plans"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
>
Plans
</Link>
<Link <Link
href="/profile" href="/profile"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100" class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"

View File

@@ -0,0 +1,169 @@
<script setup>
import { ref } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
paymentMethods: Array,
invoices: Array,
transactions: Array,
intent: Object,
stripeKey: String,
});
const addForm = useForm({
payment_method_id: '',
});
const defaultForm = useForm({
payment_method_id: '',
});
const setDefault = (id) => {
defaultForm.payment_method_id = id;
defaultForm.post('/billing/payment-methods/default');
};
const removeMethod = (id) => {
if (confirm('Are you sure you want to remove this payment method?')) {
useForm({}).delete(`/billing/payment-methods/${id}`);
}
};
</script>
<template>
<div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Billing</h1>
<div class="space-y-8">
<!-- Payment Methods -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Payment Methods</h2>
</div>
<div v-if="paymentMethods.length === 0" class="text-sm text-gray-500">
No payment methods on file.
</div>
<div v-else class="space-y-3">
<div
v-for="pm in paymentMethods"
:key="pm.id"
class="flex items-center justify-between p-3 border rounded-md"
:class="pm.is_default ? 'border-blue-300 bg-blue-50' : 'border-gray-200'"
>
<div class="flex items-center gap-3">
<span class="text-sm font-medium capitalize">{{ pm.brand }}</span>
<span class="text-sm text-gray-500">&bull;&bull;&bull;&bull; {{ pm.last_four }}</span>
<span class="text-sm text-gray-400">{{ pm.exp_month }}/{{ pm.exp_year }}</span>
<span v-if="pm.is_default" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">Default</span>
</div>
<div class="flex items-center gap-2">
<button
v-if="!pm.is_default"
@click="setDefault(pm.id)"
:disabled="defaultForm.processing"
class="text-sm text-blue-600 hover:text-blue-500"
>
Make Default
</button>
<button
@click="removeMethod(pm.id)"
class="text-sm text-red-600 hover:text-red-500"
>
Remove
</button>
</div>
</div>
</div>
</div>
<!-- Recent Invoices -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Recent Invoices</h2>
<Link href="/billing/invoices" class="text-sm text-blue-600 hover:text-blue-500">View All</Link>
</div>
<div v-if="invoices.length === 0" class="text-sm text-gray-500">No invoices yet.</div>
<table v-else class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 text-gray-500 font-medium">Number</th>
<th class="text-left py-2 text-gray-500 font-medium">Date</th>
<th class="text-left py-2 text-gray-500 font-medium">Status</th>
<th class="text-right py-2 text-gray-500 font-medium">Amount</th>
<th class="text-right py-2 text-gray-500 font-medium"></th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in invoices" :key="invoice.id" class="border-b border-gray-100">
<td class="py-2 text-gray-900">{{ invoice.number }}</td>
<td class="py-2 text-gray-600">{{ new Date(invoice.created_at).toLocaleDateString() }}</td>
<td class="py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize"
:class="{
'bg-green-100 text-green-800': invoice.status === 'paid',
'bg-yellow-100 text-yellow-800': invoice.status === 'pending',
'bg-red-100 text-red-800': invoice.status === 'overdue',
}"
>
{{ invoice.status }}
</span>
</td>
<td class="py-2 text-right text-gray-900">${{ parseFloat(invoice.total).toFixed(2) }}</td>
<td class="py-2 text-right">
<a :href="`/billing/invoices/${invoice.id}/download`" class="text-blue-600 hover:text-blue-500">Download</a>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Recent Transactions -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Recent Transactions</h2>
<Link href="/billing/transactions" class="text-sm text-blue-600 hover:text-blue-500">View All</Link>
</div>
<div v-if="transactions.length === 0" class="text-sm text-gray-500">No transactions yet.</div>
<table v-else class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 text-gray-500 font-medium">Date</th>
<th class="text-left py-2 text-gray-500 font-medium">Gateway</th>
<th class="text-left py-2 text-gray-500 font-medium">Status</th>
<th class="text-left py-2 text-gray-500 font-medium">Description</th>
<th class="text-right py-2 text-gray-500 font-medium">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions" :key="tx.id" class="border-b border-gray-100">
<td class="py-2 text-gray-600">{{ new Date(tx.created_at).toLocaleDateString() }}</td>
<td class="py-2 text-gray-600 capitalize">{{ tx.gateway }}</td>
<td class="py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize"
:class="{
'bg-green-100 text-green-800': tx.status === 'succeeded',
'bg-red-100 text-red-800': tx.status === 'failed',
'bg-yellow-100 text-yellow-800': tx.status === 'pending',
}"
>
{{ tx.status }}
</span>
</td>
<td class="py-2 text-gray-600">{{ tx.description }}</td>
<td class="py-2 text-right text-gray-900">${{ parseFloat(tx.amount).toFixed(2) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
invoices: Object,
});
</script>
<template>
<div>
<div class="mb-4">
<Link href="/billing" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to Billing</Link>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Invoices</h1>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
<div v-if="!invoices.data || invoices.data.length === 0" class="p-6 text-sm text-gray-500 text-center">
No invoices found.
</div>
<table v-else class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Number</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Date</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Gateway</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Status</th>
<th class="text-right px-6 py-3 text-gray-500 font-medium">Amount</th>
<th class="text-right px-6 py-3 text-gray-500 font-medium"></th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in invoices.data" :key="invoice.id" class="border-t border-gray-100">
<td class="px-6 py-3 text-gray-900">{{ invoice.number }}</td>
<td class="px-6 py-3 text-gray-600">{{ new Date(invoice.created_at).toLocaleDateString() }}</td>
<td class="px-6 py-3 text-gray-600 capitalize">{{ invoice.gateway }}</td>
<td class="px-6 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize"
:class="{
'bg-green-100 text-green-800': invoice.status === 'paid',
'bg-yellow-100 text-yellow-800': invoice.status === 'pending',
'bg-red-100 text-red-800': invoice.status === 'overdue',
}"
>
{{ invoice.status }}
</span>
</td>
<td class="px-6 py-3 text-right text-gray-900">${{ parseFloat(invoice.total).toFixed(2) }}</td>
<td class="px-6 py-3 text-right">
<a :href="`/billing/invoices/${invoice.id}/download`" class="text-blue-600 hover:text-blue-500">Download</a>
</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div v-if="invoices.links && invoices.last_page > 1" class="px-6 py-3 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-500">
Showing {{ invoices.from }} to {{ invoices.to }} of {{ invoices.total }}
</div>
<div class="flex gap-1">
<Link
v-for="link in invoices.links"
:key="link.label"
:href="link.url || '#'"
:class="[
'px-3 py-1 text-sm rounded',
link.active ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100',
!link.url && 'opacity-50 pointer-events-none',
]"
v-html="link.label"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
transactions: Object,
});
</script>
<template>
<div>
<div class="mb-4">
<Link href="/billing" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to Billing</Link>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Transactions</h1>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
<div v-if="!transactions.data || transactions.data.length === 0" class="p-6 text-sm text-gray-500 text-center">
No transactions found.
</div>
<table v-else class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Date</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Gateway</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Method</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Status</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Description</th>
<th class="text-right px-6 py-3 text-gray-500 font-medium">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions.data" :key="tx.id" class="border-t border-gray-100">
<td class="px-6 py-3 text-gray-600">{{ new Date(tx.created_at).toLocaleDateString() }}</td>
<td class="px-6 py-3 text-gray-600 capitalize">{{ tx.gateway }}</td>
<td class="px-6 py-3 text-gray-600 capitalize">{{ tx.payment_method }}</td>
<td class="px-6 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize"
:class="{
'bg-green-100 text-green-800': tx.status === 'succeeded',
'bg-red-100 text-red-800': tx.status === 'failed',
'bg-yellow-100 text-yellow-800': tx.status === 'pending',
'bg-gray-100 text-gray-800': tx.status === 'refunded',
}"
>
{{ tx.status }}
</span>
</td>
<td class="px-6 py-3 text-gray-600">{{ tx.description }}</td>
<td class="px-6 py-3 text-right text-gray-900">${{ parseFloat(tx.amount).toFixed(2) }}</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div v-if="transactions.links && transactions.last_page > 1" class="px-6 py-3 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-500">
Showing {{ transactions.from }} to {{ transactions.to }} of {{ transactions.total }}
</div>
<div class="flex gap-1">
<Link
v-for="link in transactions.links"
:key="link.label"
:href="link.url || '#'"
:class="[
'px-3 py-1 text-sm rounded',
link.active ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100',
!link.url && 'opacity-50 pointer-events-none',
]"
v-html="link.label"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,191 @@
<script setup>
import { ref, computed } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
const props = defineProps({
plan: Object,
paymentMethods: Array,
intent: Object,
stripeKey: String,
});
const selectedGateway = ref('stripe');
const selectedPaymentMethod = ref(props.paymentMethods?.[0]?.id || '');
const couponCode = ref('');
const couponApplied = ref(false);
const couponDiscount = ref(0);
const couponError = ref('');
const total = computed(() => {
const price = parseFloat(props.plan.price);
return Math.max(0, price - couponDiscount.value).toFixed(2);
});
const form = useForm({
gateway: 'stripe',
payment_method_id: props.paymentMethods?.[0]?.id || '',
coupon_code: '',
});
const applyCoupon = async () => {
couponError.value = '';
couponApplied.value = false;
try {
const response = await fetch('/checkout/apply-coupon', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content,
'Accept': 'application/json',
},
body: JSON.stringify({
code: couponCode.value,
plan_id: props.plan.id,
}),
});
const data = await response.json();
if (data.valid) {
couponApplied.value = true;
couponDiscount.value = data.discount;
} else {
couponError.value = data.message || 'Invalid coupon.';
}
} catch {
couponError.value = 'Failed to validate coupon.';
}
};
const submit = () => {
form.gateway = selectedGateway.value;
form.payment_method_id = selectedPaymentMethod.value;
form.coupon_code = couponApplied.value ? couponCode.value : '';
form.post(`/checkout/${props.plan.id}`);
};
</script>
<template>
<div>
<div class="mb-4">
<Link href="/plans" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to Plans</Link>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Checkout</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Order Summary -->
<div class="lg:col-span-1 order-2 lg:order-1">
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Order Summary</h2>
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-gray-600">{{ plan.name }}</span>
<span class="text-gray-900">${{ parseFloat(plan.price).toFixed(2) }}</span>
</div>
<div class="flex justify-between text-sm text-gray-500">
<span>Billing Cycle</span>
<span class="capitalize">{{ plan.billing_cycle }}</span>
</div>
<div v-if="couponApplied" class="flex justify-between text-sm text-green-600">
<span>Discount</span>
<span>-${{ couponDiscount.toFixed(2) }}</span>
</div>
<hr class="border-gray-200">
<div class="flex justify-between font-semibold">
<span>Total</span>
<span>${{ total }}/{{ plan.billing_cycle }}</span>
</div>
</div>
</div>
</div>
<!-- Checkout Form -->
<div class="lg:col-span-2 order-1 lg:order-2">
<form @submit.prevent="submit" class="space-y-6">
<!-- Payment Gateway -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Payment Method</h2>
<div class="space-y-3">
<label class="flex items-center p-3 border rounded-md cursor-pointer" :class="selectedGateway === 'stripe' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'">
<input v-model="selectedGateway" type="radio" value="stripe" class="mr-3">
<span class="text-sm font-medium">Credit / Debit Card (Stripe)</span>
</label>
<label class="flex items-center p-3 border rounded-md cursor-pointer" :class="selectedGateway === 'paypal' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'">
<input v-model="selectedGateway" type="radio" value="paypal" class="mr-3">
<span class="text-sm font-medium">PayPal</span>
</label>
</div>
<!-- Saved Payment Methods (Stripe) -->
<div v-if="selectedGateway === 'stripe' && paymentMethods.length > 0" class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Select Card</label>
<select v-model="selectedPaymentMethod" class="w-full rounded-md border-gray-300 text-sm">
<option v-for="pm in paymentMethods" :key="pm.id" :value="pm.id">
{{ pm.brand }} ending in {{ pm.last_four }} ({{ pm.exp_month }}/{{ pm.exp_year }})
<template v-if="pm.is_default"> - Default</template>
</option>
</select>
</div>
<div v-if="selectedGateway === 'stripe' && paymentMethods.length === 0" class="mt-4">
<p class="text-sm text-gray-500">
You have no saved payment methods.
<Link href="/billing" class="text-blue-600 hover:text-blue-500">Add one first</Link>.
</p>
</div>
</div>
<!-- Coupon -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Coupon Code</h2>
<div class="flex gap-3">
<input
v-model="couponCode"
type="text"
placeholder="Enter coupon code"
class="flex-1 rounded-md border-gray-300 text-sm"
:disabled="couponApplied"
>
<button
type="button"
@click="applyCoupon"
:disabled="!couponCode || couponApplied"
class="px-4 py-2 bg-gray-100 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50"
>
{{ couponApplied ? 'Applied' : 'Apply' }}
</button>
</div>
<p v-if="couponError" class="mt-2 text-sm text-red-600">{{ couponError }}</p>
<p v-if="couponApplied" class="mt-2 text-sm text-green-600">Coupon applied successfully!</p>
</div>
<!-- Errors -->
<div v-if="form.errors && Object.keys(form.errors).length" class="rounded-md bg-red-50 p-4">
<ul class="list-disc list-inside text-sm text-red-600">
<li v-for="(error, field) in form.errors" :key="field">{{ error }}</li>
</ul>
</div>
<!-- Submit -->
<button
type="submit"
:disabled="form.processing || (selectedGateway === 'stripe' && !selectedPaymentMethod)"
class="w-full px-6 py-3 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
<span v-if="form.processing">Processing...</span>
<span v-else>Subscribe for ${{ total }}/{{ plan.billing_cycle }}</span>
</button>
</form>
</div>
</div>
</div>
</template>

View File

@@ -1,4 +1,5 @@
<script setup> <script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout }); defineOptions({ layout: AppLayout });
@@ -27,7 +28,10 @@ defineProps({
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6"> <div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h3 class="text-sm font-medium text-gray-500">Quick Actions</h3> <h3 class="text-sm font-medium text-gray-500">Quick Actions</h3>
<div class="mt-3 space-y-2"> <div class="mt-3 space-y-2">
<a href="/profile" class="block text-sm text-blue-600 hover:text-blue-500">Edit Profile</a> <Link href="/plans" class="block text-sm text-blue-600 hover:text-blue-500">Browse Plans</Link>
<Link href="/subscriptions" class="block text-sm text-blue-600 hover:text-blue-500">My Subscriptions</Link>
<Link href="/billing" class="block text-sm text-blue-600 hover:text-blue-500">Billing & Payments</Link>
<Link href="/profile" class="block text-sm text-blue-600 hover:text-blue-500">Edit Profile</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,77 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
plansByType: Object,
});
const formatPrice = (price, cycle) => {
const amount = parseFloat(price).toFixed(2);
return `$${amount}/${cycle}`;
};
const serviceTypeLabels = {
vps: 'VPS Servers',
dedicated: 'Dedicated Servers',
hosting: 'Web Hosting',
game: 'Game Servers',
};
</script>
<template>
<div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Plans & Pricing</h1>
<div v-for="(plans, type) in plansByType" :key="type" class="mb-10">
<h2 class="text-xl font-semibold text-gray-800 mb-4">
{{ serviceTypeLabels[type] || type }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="plan in plans"
:key="plan.id"
class="bg-white rounded-lg border border-gray-200 shadow-sm p-6 flex flex-col"
>
<h3 class="text-lg font-semibold text-gray-900">{{ plan.name }}</h3>
<p v-if="plan.description" class="mt-1 text-sm text-gray-500">{{ plan.description }}</p>
<div class="mt-4">
<span class="text-3xl font-bold text-gray-900">
{{ formatPrice(plan.price, plan.billing_cycle) }}
</span>
</div>
<ul v-if="plan.features" class="mt-4 space-y-2 flex-1">
<li v-for="(value, feature) in plan.features" :key="feature" class="flex items-start text-sm text-gray-600">
<svg class="h-5 w-5 text-green-500 mr-2 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span><strong>{{ feature }}:</strong> {{ value }}</span>
</li>
</ul>
<div class="mt-6">
<span v-if="plan.stock_quantity !== null && plan.stock_quantity <= 0" class="block text-center text-sm font-medium text-red-600">
Out of Stock
</span>
<Link
v-else
:href="`/checkout/${plan.id}`"
class="block w-full text-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700"
>
Order Now
</Link>
</div>
</div>
</div>
</div>
<div v-if="!plansByType || Object.keys(plansByType).length === 0" class="text-center py-12">
<p class="text-gray-500">No plans are currently available.</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
plan: Object,
});
const formatPrice = (price, cycle) => {
const amount = parseFloat(price).toFixed(2);
return `$${amount}/${cycle}`;
};
</script>
<template>
<div>
<div class="mb-4">
<Link href="/plans" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to Plans</Link>
</div>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-8 max-w-2xl">
<h1 class="text-2xl font-bold text-gray-900">{{ plan.name }}</h1>
<p v-if="plan.description" class="mt-2 text-gray-500">{{ plan.description }}</p>
<div class="mt-6">
<span class="text-4xl font-bold text-gray-900">
{{ formatPrice(plan.price, plan.billing_cycle) }}
</span>
</div>
<div v-if="plan.features" class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-3">Features</h2>
<ul class="space-y-2">
<li v-for="(value, feature) in plan.features" :key="feature" class="flex items-start text-sm text-gray-600">
<svg class="h-5 w-5 text-green-500 mr-2 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span><strong>{{ feature }}:</strong> {{ value }}</span>
</li>
</ul>
</div>
<div class="mt-8">
<span v-if="plan.stock_quantity !== null && plan.stock_quantity <= 0" class="block text-center text-sm font-medium text-red-600">
This plan is currently out of stock.
</span>
<Link
v-else
:href="`/checkout/${plan.id}`"
class="inline-block px-6 py-3 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700"
>
Order Now
</Link>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,78 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
subscriptions: Array,
});
const statusColors = {
active: 'bg-green-100 text-green-800',
canceled: 'bg-red-100 text-red-800',
past_due: 'bg-yellow-100 text-yellow-800',
trialing: 'bg-blue-100 text-blue-800',
incomplete: 'bg-gray-100 text-gray-800',
};
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Subscriptions</h1>
<Link href="/plans" class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700">
Browse Plans
</Link>
</div>
<div v-if="subscriptions.length === 0" class="bg-white rounded-lg border border-gray-200 shadow-sm p-12 text-center">
<p class="text-gray-500 mb-4">You don't have any subscriptions yet.</p>
<Link href="/plans" class="text-blue-600 hover:text-blue-500 text-sm font-medium">Browse Available Plans</Link>
</div>
<div v-else class="space-y-4">
<div
v-for="subscription in subscriptions"
:key="subscription.id"
class="bg-white rounded-lg border border-gray-200 shadow-sm p-6"
>
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">
{{ subscription.plan?.name || subscription.type }}
</h3>
<p class="text-sm text-gray-500 mt-1">
{{ subscription.gateway || 'stripe' }} &middot;
<span v-if="subscription.current_period_end">
Renews {{ new Date(subscription.current_period_end).toLocaleDateString() }}
</span>
</p>
</div>
<div class="flex items-center gap-3">
<span
:class="statusColors[subscription.stripe_status] || 'bg-gray-100 text-gray-800'"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
>
{{ subscription.stripe_status }}
</span>
<Link
:href="`/subscriptions/${subscription.id}`"
class="text-sm text-blue-600 hover:text-blue-500 font-medium"
>
Manage
</Link>
</div>
</div>
<div v-if="subscription.plan" class="mt-3 text-sm text-gray-600">
${{ parseFloat(subscription.plan.price).toFixed(2) }}/{{ subscription.plan.billing_cycle }}
</div>
<div v-if="subscription.ends_at" class="mt-2 text-sm text-red-600">
Cancels on {{ new Date(subscription.ends_at).toLocaleDateString() }}
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,163 @@
<script setup>
import { ref } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
const props = defineProps({
subscription: Object,
availablePlans: Array,
});
const cancelImmediately = ref(false);
const cancelForm = useForm({
immediately: false,
});
const swapForm = useForm({
plan_id: '',
});
const statusColors = {
active: 'bg-green-100 text-green-800',
canceled: 'bg-red-100 text-red-800',
past_due: 'bg-yellow-100 text-yellow-800',
trialing: 'bg-blue-100 text-blue-800',
incomplete: 'bg-gray-100 text-gray-800',
};
const cancelSubscription = () => {
cancelForm.immediately = cancelImmediately.value;
cancelForm.post(`/subscriptions/${props.subscription.id}/cancel`);
};
const resumeSubscription = () => {
useForm({}).post(`/subscriptions/${props.subscription.id}/resume`);
};
const swapPlan = () => {
swapForm.post(`/subscriptions/${props.subscription.id}/swap`);
};
</script>
<template>
<div>
<div class="mb-4">
<Link href="/subscriptions" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to Subscriptions</Link>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Subscription Details</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Subscription Info -->
<div class="lg:col-span-2 space-y-6">
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">
{{ subscription.plan?.name || subscription.type }}
</h2>
<span
:class="statusColors[subscription.stripe_status] || 'bg-gray-100 text-gray-800'"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
>
{{ subscription.stripe_status }}
</span>
</div>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">Gateway</dt>
<dd class="mt-1 text-gray-900 capitalize">{{ subscription.gateway || 'stripe' }}</dd>
</div>
<div v-if="subscription.plan">
<dt class="text-gray-500">Price</dt>
<dd class="mt-1 text-gray-900">${{ parseFloat(subscription.plan.price).toFixed(2) }}/{{ subscription.plan.billing_cycle }}</dd>
</div>
<div v-if="subscription.current_period_start">
<dt class="text-gray-500">Current Period Start</dt>
<dd class="mt-1 text-gray-900">{{ new Date(subscription.current_period_start).toLocaleDateString() }}</dd>
</div>
<div v-if="subscription.current_period_end">
<dt class="text-gray-500">Current Period End</dt>
<dd class="mt-1 text-gray-900">{{ new Date(subscription.current_period_end).toLocaleDateString() }}</dd>
</div>
<div v-if="subscription.ends_at">
<dt class="text-gray-500">Cancels On</dt>
<dd class="mt-1 text-red-600">{{ new Date(subscription.ends_at).toLocaleDateString() }}</dd>
</div>
<div>
<dt class="text-gray-500">Created</dt>
<dd class="mt-1 text-gray-900">{{ new Date(subscription.created_at).toLocaleDateString() }}</dd>
</div>
</dl>
</div>
<!-- Change Plan -->
<div v-if="availablePlans.length > 0 && subscription.stripe_status === 'active'" class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Change Plan</h2>
<form @submit.prevent="swapPlan" class="space-y-4">
<div class="space-y-2">
<label v-for="plan in availablePlans" :key="plan.id"
class="flex items-center justify-between p-3 border rounded-md cursor-pointer"
:class="swapForm.plan_id == plan.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200'"
>
<div class="flex items-center">
<input v-model="swapForm.plan_id" type="radio" :value="plan.id" class="mr-3">
<span class="text-sm font-medium">{{ plan.name }}</span>
</div>
<span class="text-sm text-gray-600">${{ parseFloat(plan.price).toFixed(2) }}/{{ plan.billing_cycle }}</span>
</label>
</div>
<button
type="submit"
:disabled="!swapForm.plan_id || swapForm.processing"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{{ swapForm.processing ? 'Changing...' : 'Change Plan' }}
</button>
</form>
</div>
</div>
<!-- Actions Sidebar -->
<div class="space-y-6">
<!-- Cancel -->
<div v-if="subscription.stripe_status === 'active' && !subscription.ends_at" class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Cancel Subscription</h2>
<div class="space-y-3">
<label class="flex items-center text-sm">
<input v-model="cancelImmediately" type="checkbox" class="mr-2">
Cancel immediately (no grace period)
</label>
<button
@click="cancelSubscription"
:disabled="cancelForm.processing"
class="w-full px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 disabled:opacity-50"
>
{{ cancelForm.processing ? 'Cancelling...' : 'Cancel Subscription' }}
</button>
</div>
</div>
<!-- Resume -->
<div v-if="subscription.ends_at && subscription.stripe_status !== 'canceled'" class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Resume Subscription</h2>
<p class="text-sm text-gray-500 mb-3">Your subscription is set to cancel. You can resume it before it expires.</p>
<button
@click="resumeSubscription"
class="w-full px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700"
>
Resume Subscription
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -2,11 +2,43 @@
declare(strict_types=1); declare(strict_types=1);
use App\Http\Controllers\Account\BillingController;
use App\Http\Controllers\Account\CheckoutController;
use App\Http\Controllers\Account\DashboardController; use App\Http\Controllers\Account\DashboardController;
use App\Http\Controllers\Account\PlanController;
use App\Http\Controllers\Account\ProfileController; use App\Http\Controllers\Account\ProfileController;
use App\Http\Controllers\Account\SubscriptionController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/dashboard', [DashboardController::class, 'index'])->name('account.dashboard'); Route::get('/dashboard', [DashboardController::class, 'index'])->name('account.dashboard');
Route::get('/profile', [ProfileController::class, 'show'])->name('account.profile'); Route::get('/profile', [ProfileController::class, 'show'])->name('account.profile');
Route::put('/profile', [ProfileController::class, 'update'])->name('account.profile.update'); Route::put('/profile', [ProfileController::class, 'update'])->name('account.profile.update');
// Plans
Route::get('/plans', [PlanController::class, 'index'])->name('account.plans.index');
Route::get('/plans/{plan}', [PlanController::class, 'show'])->name('account.plans.show');
// Checkout (static routes first to avoid parameter matching)
Route::post('/checkout/apply-coupon', [CheckoutController::class, 'applyCoupon'])->name('account.checkout.apply-coupon');
Route::get('/checkout/paypal/callback', [CheckoutController::class, 'paypalCallback'])->name('account.checkout.paypal-callback');
Route::get('/checkout/paypal/cancel', [CheckoutController::class, 'paypalCancel'])->name('account.checkout.paypal-cancel');
Route::get('/checkout/{plan}', [CheckoutController::class, 'show'])->name('account.checkout.show');
Route::post('/checkout/{plan}', [CheckoutController::class, 'store'])->name('account.checkout.store');
// Subscriptions
Route::get('/subscriptions', [SubscriptionController::class, 'index'])->name('account.subscriptions.index');
Route::get('/subscriptions/{subscription}', [SubscriptionController::class, 'show'])->name('account.subscriptions.show');
Route::post('/subscriptions/{subscription}/cancel', [SubscriptionController::class, 'cancel'])->name('account.subscriptions.cancel');
Route::post('/subscriptions/{subscription}/resume', [SubscriptionController::class, 'resume'])->name('account.subscriptions.resume');
Route::post('/subscriptions/{subscription}/swap', [SubscriptionController::class, 'swap'])->name('account.subscriptions.swap');
// Billing
Route::get('/billing', [BillingController::class, 'index'])->name('account.billing.index');
Route::post('/billing/payment-methods', [BillingController::class, 'addPaymentMethod'])->name('account.billing.add-payment-method');
Route::delete('/billing/payment-methods/{paymentMethod}', [BillingController::class, 'removePaymentMethod'])->name('account.billing.remove-payment-method');
Route::post('/billing/payment-methods/default', [BillingController::class, 'setDefaultPaymentMethod'])->name('account.billing.set-default-payment-method');
Route::get('/billing/invoices', [BillingController::class, 'invoices'])->name('account.billing.invoices');
Route::get('/billing/invoices/{invoice}/download', [BillingController::class, 'downloadInvoice'])->name('account.billing.invoices.download');
Route::get('/billing/transactions', [BillingController::class, 'transactions'])->name('account.billing.transactions');
Route::post('/billing/setup-intent', [BillingController::class, 'setupIntent'])->name('account.billing.setup-intent');

View File

@@ -2,7 +2,10 @@
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('billing:process-dunning')->daily()->at('06:00');

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
use App\Http\Controllers\Webhooks\PayPalWebhookController;
use App\Http\Controllers\Webhooks\StripeWebhookController;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handleWebhook'])->name('webhooks.stripe');
Route::post('/webhooks/paypal', [PayPalWebhookController::class, 'handle'])->name('webhooks.paypal');

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Models\Invoice;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->accountUrl = 'http://'.config('app.domains.account');
});
it('displays the billing index page', function (): void {
$user = User::factory()->create();
$this->actingAs($user)
->get($this->accountUrl.'/billing')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Billing/Index')
->has('paymentMethods')
->has('invoices')
->has('transactions')
);
});
it('displays the invoices page', function (): void {
$user = User::factory()->create();
Invoice::factory()->count(3)->create(['user_id' => $user->id]);
$this->actingAs($user)
->get($this->accountUrl.'/billing/invoices')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Billing/Invoices')
->has('invoices.data', 3)
);
});
it('displays the transactions page', function (): void {
$user = User::factory()->create();
$this->actingAs($user)
->get($this->accountUrl.'/billing/transactions')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Billing/Transactions')
->has('transactions')
);
});
it('prevents downloading another users invoice', function (): void {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$invoice = Invoice::factory()->create(['user_id' => $otherUser->id]);
$this->actingAs($user)
->get($this->accountUrl.'/billing/invoices/'.$invoice->id.'/download')
->assertForbidden();
});
it('allows downloading own invoice', function (): void {
$user = User::factory()->create();
$invoice = Invoice::factory()->create(['user_id' => $user->id]);
$this->actingAs($user)
->get($this->accountUrl.'/billing/invoices/'.$invoice->id.'/download')
->assertOk();
});
it('requires authentication to view billing', function (): void {
$this->get($this->accountUrl.'/billing')
->assertRedirect();
});

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
use App\Models\Coupon;
use App\Models\Plan;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->accountUrl = 'http://'.config('app.domains.account');
});
it('displays the checkout page for an active plan', function (): void {
$user = User::factory()->create();
$plan = Plan::factory()->create(['status' => 'active']);
$this->actingAs($user)
->get($this->accountUrl.'/checkout/'.$plan->id)
->assertOk()
->assertInertia(fn ($page) => $page
->component('Checkout/Show')
->has('plan')
->where('plan.id', $plan->id)
);
});
it('returns 404 for unavailable plan checkout', function (): void {
$user = User::factory()->create();
$plan = Plan::factory()->create(['status' => 'inactive']);
$this->actingAs($user)
->get($this->accountUrl.'/checkout/'.$plan->id)
->assertNotFound();
});
it('returns 404 for out of stock plan checkout', function (): void {
$user = User::factory()->create();
$plan = Plan::factory()->create(['status' => 'active', 'stock_quantity' => 0]);
$this->actingAs($user)
->get($this->accountUrl.'/checkout/'.$plan->id)
->assertNotFound();
});
it('validates a valid coupon code', function (): void {
$user = User::factory()->create();
$plan = Plan::factory()->create(['status' => 'active', 'price' => 100.00]);
$coupon = Coupon::factory()->create([
'code' => 'SAVE20',
'type' => 'percentage',
'value' => 20.00,
'max_uses' => 100,
'times_used' => 0,
'expires_at' => now()->addMonth(),
]);
$this->actingAs($user)
->postJson($this->accountUrl.'/checkout/apply-coupon', [
'code' => 'SAVE20',
'plan_id' => $plan->id,
])
->assertOk()
->assertJson([
'valid' => true,
'discount' => 20.00,
'new_total' => 80.00,
'coupon_type' => 'percentage',
'coupon_value' => '20.00',
]);
});
it('rejects an expired coupon', function (): void {
$user = User::factory()->create();
$plan = Plan::factory()->create(['status' => 'active']);
Coupon::factory()->create([
'code' => 'EXPIRED',
'expires_at' => now()->subDay(),
]);
$this->actingAs($user)
->postJson($this->accountUrl.'/checkout/apply-coupon', [
'code' => 'EXPIRED',
'plan_id' => $plan->id,
])
->assertUnprocessable()
->assertJson(['valid' => false]);
});
it('rejects a maxed-out coupon', function (): void {
$user = User::factory()->create();
$plan = Plan::factory()->create(['status' => 'active']);
Coupon::factory()->create([
'code' => 'MAXED',
'max_uses' => 5,
'times_used' => 5,
'expires_at' => now()->addMonth(),
]);
$this->actingAs($user)
->postJson($this->accountUrl.'/checkout/apply-coupon', [
'code' => 'MAXED',
'plan_id' => $plan->id,
])
->assertUnprocessable()
->assertJson(['valid' => false]);
});
it('validates checkout form requires gateway', function (): void {
$user = User::factory()->create();
$plan = Plan::factory()->create(['status' => 'active']);
$this->actingAs($user)
->post($this->accountUrl.'/checkout/'.$plan->id, [])
->assertSessionHasErrors('gateway');
});

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use App\Models\Coupon;
it('validates a valid coupon', function (): void {
$coupon = Coupon::factory()->create([
'max_uses' => 100,
'times_used' => 50,
'expires_at' => now()->addMonth(),
]);
expect($coupon->isValid())->toBeTrue();
});
it('rejects an expired coupon', function (): void {
$coupon = Coupon::factory()->create([
'expires_at' => now()->subDay(),
]);
expect($coupon->isValid())->toBeFalse();
});
it('rejects a maxed out coupon', function (): void {
$coupon = Coupon::factory()->create([
'max_uses' => 5,
'times_used' => 5,
]);
expect($coupon->isValid())->toBeFalse();
});
it('accepts a coupon with no usage limit', function (): void {
$coupon = Coupon::factory()->create([
'max_uses' => null,
'times_used' => 999,
'expires_at' => now()->addMonth(),
]);
expect($coupon->isValid())->toBeTrue();
});
it('accepts a coupon with no expiry', function (): void {
$coupon = Coupon::factory()->create([
'max_uses' => 100,
'times_used' => 0,
'expires_at' => null,
]);
expect($coupon->isValid())->toBeTrue();
});

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
use App\Models\Plan;
use App\Models\Service;
use App\Models\User;
use App\Services\Billing\DunningService;
use Database\Seeders\RoleAndPermissionSeeder;
use Laravel\Cashier\Subscription;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
});
it('suspends services for overdue subscriptions past the grace period', function (): void {
$user = User::factory()->create();
$plan = Plan::factory()->create();
$subscription = Subscription::create([
'user_id' => $user->id,
'type' => 'default',
'stripe_id' => 'sub_test_123',
'stripe_status' => 'past_due',
'stripe_price' => 'price_test_123',
'plan_id' => $plan->id,
]);
// Manually set updated_at to past the grace period
$graceDays = config('billing.suspension.days_past_due_to_suspend');
$subscription->update(['updated_at' => now()->subDays($graceDays + 1)]);
$service = Service::factory()->create([
'user_id' => $user->id,
'subscription_id' => $subscription->id,
'plan_id' => $plan->id,
'status' => 'active',
]);
$dunningService = app(DunningService::class);
$count = $dunningService->suspendOverdueSubscriptions();
expect($count)->toBe(1);
expect($service->fresh()->status)->toBe('suspended');
});
it('does not suspend services within the grace period', function (): void {
$user = User::factory()->create();
$plan = Plan::factory()->create();
$subscription = Subscription::create([
'user_id' => $user->id,
'type' => 'default',
'stripe_id' => 'sub_test_456',
'stripe_status' => 'past_due',
'stripe_price' => 'price_test_456',
'plan_id' => $plan->id,
]);
$service = Service::factory()->create([
'user_id' => $user->id,
'subscription_id' => $subscription->id,
'plan_id' => $plan->id,
'status' => 'active',
]);
$dunningService = app(DunningService::class);
$count = $dunningService->suspendOverdueSubscriptions();
expect($count)->toBe(0);
expect($service->fresh()->status)->toBe('active');
});
it('terminates services that have been suspended too long', function (): void {
$user = User::factory()->create();
$terminateDays = config('billing.suspension.days_suspended_to_terminate');
$service = Service::factory()->suspended()->create([
'user_id' => $user->id,
'suspended_at' => now()->subDays($terminateDays + 1),
]);
$dunningService = app(DunningService::class);
$count = $dunningService->terminateLongSuspendedSubscriptions();
expect($count)->toBe(1);
expect($service->fresh()->status)->toBe('terminated');
expect($service->fresh()->auto_renew)->toBeFalse();
});
it('does not terminate recently suspended services', function (): void {
$user = User::factory()->create();
$service = Service::factory()->suspended()->create([
'user_id' => $user->id,
'suspended_at' => now()->subDay(),
]);
$dunningService = app(DunningService::class);
$count = $dunningService->terminateLongSuspendedSubscriptions();
expect($count)->toBe(0);
expect($service->fresh()->status)->toBe('suspended');
});
it('reactivates suspended services when subscription becomes active', function (): void {
$user = User::factory()->create();
$plan = Plan::factory()->create();
$subscription = Subscription::create([
'user_id' => $user->id,
'type' => 'default',
'stripe_id' => 'sub_test_789',
'stripe_status' => 'active',
'stripe_price' => 'price_test_789',
'plan_id' => $plan->id,
]);
$service = Service::factory()->suspended()->create([
'user_id' => $user->id,
'subscription_id' => $subscription->id,
'plan_id' => $plan->id,
]);
$dunningService = app(DunningService::class);
$dunningService->reactivateServicesForSubscription($subscription);
expect($service->fresh()->status)->toBe('active');
expect($service->fresh()->suspended_at)->toBeNull();
});

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Models\Plan;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->accountUrl = 'http://'.config('app.domains.account');
});
it('displays the plans index page', function (): void {
$user = User::factory()->create();
Plan::factory()->count(3)->create(['status' => 'active']);
$this->actingAs($user)
->get($this->accountUrl.'/plans')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Plans/Index')
->has('plansByType')
);
});
it('does not show inactive plans', function (): void {
$user = User::factory()->create();
Plan::factory()->create(['status' => 'active', 'service_type' => 'vps']);
Plan::factory()->create(['status' => 'inactive', 'service_type' => 'vps']);
$this->actingAs($user)
->get($this->accountUrl.'/plans')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Plans/Index')
->where('plansByType.vps', fn ($plans) => count($plans) === 1)
);
});
it('displays a single plan page', function (): void {
$user = User::factory()->create();
$plan = Plan::factory()->create(['status' => 'active']);
$this->actingAs($user)
->get($this->accountUrl.'/plans/'.$plan->id)
->assertOk()
->assertInertia(fn ($page) => $page
->component('Plans/Show')
->has('plan')
->where('plan.id', $plan->id)
);
});
it('returns 404 for inactive plan', function (): void {
$user = User::factory()->create();
$plan = Plan::factory()->create(['status' => 'inactive']);
$this->actingAs($user)
->get($this->accountUrl.'/plans/'.$plan->id)
->assertNotFound();
});

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->accountUrl = 'http://'.config('app.domains.account');
});
it('displays the subscriptions index page', function (): void {
$user = User::factory()->create();
$this->actingAs($user)
->get($this->accountUrl.'/subscriptions')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Subscriptions/Index')
->has('subscriptions')
);
});
it('requires authentication to view subscriptions', function (): void {
$this->get($this->accountUrl.'/subscriptions')
->assertRedirect();
});