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:
26
website/app/Console/Commands/ProcessDunning.php
Normal file
26
website/app/Console/Commands/ProcessDunning.php
Normal 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;
|
||||
}
|
||||
}
|
||||
23
website/app/Events/PaymentFailed.php
Normal file
23
website/app/Events/PaymentFailed.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
20
website/app/Events/PaymentSucceeded.php
Normal file
20
website/app/Events/PaymentSucceeded.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
20
website/app/Events/SubscriptionCancelled.php
Normal file
20
website/app/Events/SubscriptionCancelled.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
20
website/app/Events/SubscriptionCreated.php
Normal file
20
website/app/Events/SubscriptionCreated.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
129
website/app/Http/Controllers/Account/BillingController.php
Normal file
129
website/app/Http/Controllers/Account/BillingController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
161
website/app/Http/Controllers/Account/CheckoutController.php
Normal file
161
website/app/Http/Controllers/Account/CheckoutController.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
38
website/app/Http/Controllers/Account/PlanController.php
Normal file
38
website/app/Http/Controllers/Account/PlanController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
131
website/app/Http/Controllers/Account/SubscriptionController.php
Normal file
131
website/app/Http/Controllers/Account/SubscriptionController.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
22
website/app/Listeners/HandlePaymentFailed.php
Normal file
22
website/app/Listeners/HandlePaymentFailed.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
website/app/Listeners/HandlePaymentSucceeded.php
Normal file
36
website/app/Listeners/HandlePaymentSucceeded.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Http\Responses\LoginResponse;
|
||||
use App\Services\Billing\BillingServiceFactory;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
@@ -17,6 +18,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(LoginResponseContract::class, LoginResponse::class);
|
||||
$this->app->singleton(BillingServiceFactory::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
|
||||
24
website/app/Services/Billing/BillingServiceFactory.php
Normal file
24
website/app/Services/Billing/BillingServiceFactory.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
62
website/app/Services/Billing/BillingServiceInterface.php
Normal file
62
website/app/Services/Billing/BillingServiceInterface.php
Normal 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;
|
||||
}
|
||||
107
website/app/Services/Billing/DunningService.php
Normal file
107
website/app/Services/Billing/DunningService.php
Normal 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}.");
|
||||
}
|
||||
}
|
||||
155
website/app/Services/Billing/PayPalBillingService.php
Normal file
155
website/app/Services/Billing/PayPalBillingService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
235
website/app/Services/Billing/StripeBillingService.php
Normal file
235
website/app/Services/Billing/StripeBillingService.php
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,19 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
Route::domain(config('app.domains.admin'))
|
||||
->middleware(['web', 'auth', 'verified', 'role:admin'])
|
||||
->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 {
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'webhooks/*',
|
||||
]);
|
||||
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||
]);
|
||||
|
||||
73
website/config/billing.php
Normal file
73
website/config/billing.php
Normal 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),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -24,6 +24,24 @@ const domains = computed(() => page.props.domains);
|
||||
>
|
||||
Dashboard
|
||||
</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
|
||||
href="/profile"
|
||||
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||
|
||||
169
website/resources/js/Pages/Billing/Index.vue
Normal file
169
website/resources/js/Pages/Billing/Index.vue
Normal 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">•••• {{ 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>
|
||||
81
website/resources/js/Pages/Billing/Invoices.vue
Normal file
81
website/resources/js/Pages/Billing/Invoices.vue
Normal 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">← 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>
|
||||
80
website/resources/js/Pages/Billing/Transactions.vue
Normal file
80
website/resources/js/Pages/Billing/Transactions.vue
Normal 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">← 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>
|
||||
191
website/resources/js/Pages/Checkout/Show.vue
Normal file
191
website/resources/js/Pages/Checkout/Show.vue
Normal 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">← 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>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
|
||||
defineOptions({ layout: AppLayout });
|
||||
@@ -27,7 +28,10 @@ defineProps({
|
||||
<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>
|
||||
<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>
|
||||
|
||||
77
website/resources/js/Pages/Plans/Index.vue
Normal file
77
website/resources/js/Pages/Plans/Index.vue
Normal 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>
|
||||
59
website/resources/js/Pages/Plans/Show.vue
Normal file
59
website/resources/js/Pages/Plans/Show.vue
Normal 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">← 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>
|
||||
78
website/resources/js/Pages/Subscriptions/Index.vue
Normal file
78
website/resources/js/Pages/Subscriptions/Index.vue
Normal 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' }} ·
|
||||
<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>
|
||||
163
website/resources/js/Pages/Subscriptions/Show.vue
Normal file
163
website/resources/js/Pages/Subscriptions/Show.vue
Normal 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">← 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>
|
||||
@@ -2,11 +2,43 @@
|
||||
|
||||
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\PlanController;
|
||||
use App\Http\Controllers\Account\ProfileController;
|
||||
use App\Http\Controllers\Account\SubscriptionController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('account.dashboard');
|
||||
|
||||
Route::get('/profile', [ProfileController::class, 'show'])->name('account.profile');
|
||||
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');
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('billing:process-dunning')->daily()->at('06:00');
|
||||
|
||||
10
website/routes/webhooks.php
Normal file
10
website/routes/webhooks.php
Normal 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');
|
||||
75
website/tests/Feature/Billing/BillingPageTest.php
Normal file
75
website/tests/Feature/Billing/BillingPageTest.php
Normal 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();
|
||||
});
|
||||
117
website/tests/Feature/Billing/CheckoutTest.php
Normal file
117
website/tests/Feature/Billing/CheckoutTest.php
Normal 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');
|
||||
});
|
||||
52
website/tests/Feature/Billing/CouponTest.php
Normal file
52
website/tests/Feature/Billing/CouponTest.php
Normal 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();
|
||||
});
|
||||
130
website/tests/Feature/Billing/DunningTest.php
Normal file
130
website/tests/Feature/Billing/DunningTest.php
Normal 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();
|
||||
});
|
||||
62
website/tests/Feature/Billing/PlanBrowsingTest.php
Normal file
62
website/tests/Feature/Billing/PlanBrowsingTest.php
Normal 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();
|
||||
});
|
||||
28
website/tests/Feature/Billing/SubscriptionManagementTest.php
Normal file
28
website/tests/Feature/Billing/SubscriptionManagementTest.php
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user