diff --git a/website/app/Console/Commands/ProcessDunning.php b/website/app/Console/Commands/ProcessDunning.php new file mode 100644 index 0000000..f91a391 --- /dev/null +++ b/website/app/Console/Commands/ProcessDunning.php @@ -0,0 +1,26 @@ +suspendOverdueSubscriptions(); + $this->info("Suspended services for {$suspended} overdue subscriptions."); + + $terminated = $dunningService->terminateLongSuspendedSubscriptions(); + $this->info("Terminated {$terminated} long-suspended services."); + + return self::SUCCESS; + } +} diff --git a/website/app/Events/PaymentFailed.php b/website/app/Events/PaymentFailed.php new file mode 100644 index 0000000..008b075 --- /dev/null +++ b/website/app/Events/PaymentFailed.php @@ -0,0 +1,23 @@ +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, + ]); + } +} diff --git a/website/app/Http/Controllers/Account/CheckoutController.php b/website/app/Http/Controllers/Account/CheckoutController.php new file mode 100644 index 0000000..397e471 --- /dev/null +++ b/website/app/Http/Controllers/Account/CheckoutController.php @@ -0,0 +1,161 @@ +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, + }; + } +} diff --git a/website/app/Http/Controllers/Account/PlanController.php b/website/app/Http/Controllers/Account/PlanController.php new file mode 100644 index 0000000..1937965 --- /dev/null +++ b/website/app/Http/Controllers/Account/PlanController.php @@ -0,0 +1,38 @@ +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, + ]); + } +} diff --git a/website/app/Http/Controllers/Account/SubscriptionController.php b/website/app/Http/Controllers/Account/SubscriptionController.php new file mode 100644 index 0000000..b058962 --- /dev/null +++ b/website/app/Http/Controllers/Account/SubscriptionController.php @@ -0,0 +1,131 @@ +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()); + } + } +} diff --git a/website/app/Http/Controllers/Webhooks/PayPalWebhookController.php b/website/app/Http/Controllers/Webhooks/PayPalWebhookController.php new file mode 100644 index 0000000..ddde1cd --- /dev/null +++ b/website/app/Http/Controllers/Webhooks/PayPalWebhookController.php @@ -0,0 +1,160 @@ +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(), + ]); + } +} diff --git a/website/app/Http/Controllers/Webhooks/StripeWebhookController.php b/website/app/Http/Controllers/Webhooks/StripeWebhookController.php new file mode 100644 index 0000000..995c5c3 --- /dev/null +++ b/website/app/Http/Controllers/Webhooks/StripeWebhookController.php @@ -0,0 +1,136 @@ +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(), + ], + ); + } +} diff --git a/website/app/Listeners/HandlePaymentFailed.php b/website/app/Listeners/HandlePaymentFailed.php new file mode 100644 index 0000000..bf8fa52 --- /dev/null +++ b/website/app/Listeners/HandlePaymentFailed.php @@ -0,0 +1,22 @@ +user->id}", [ + 'gateway' => $event->gateway, + 'transaction_id' => $event->gatewayTransactionId, + 'amount' => $event->amount, + 'currency' => $event->currency, + 'reason' => $event->reason, + ]); + } +} diff --git a/website/app/Listeners/HandlePaymentSucceeded.php b/website/app/Listeners/HandlePaymentSucceeded.php new file mode 100644 index 0000000..b9ddee6 --- /dev/null +++ b/website/app/Listeners/HandlePaymentSucceeded.php @@ -0,0 +1,36 @@ +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); + } + } + } +} diff --git a/website/app/Providers/AppServiceProvider.php b/website/app/Providers/AppServiceProvider.php index 0281f47..52397a5 100644 --- a/website/app/Providers/AppServiceProvider.php +++ b/website/app/Providers/AppServiceProvider.php @@ -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 diff --git a/website/app/Services/Billing/BillingServiceFactory.php b/website/app/Services/Billing/BillingServiceFactory.php new file mode 100644 index 0000000..7cec297 --- /dev/null +++ b/website/app/Services/Billing/BillingServiceFactory.php @@ -0,0 +1,24 @@ + 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')); + } +} diff --git a/website/app/Services/Billing/BillingServiceInterface.php b/website/app/Services/Billing/BillingServiceInterface.php new file mode 100644 index 0000000..2c58c19 --- /dev/null +++ b/website/app/Services/Billing/BillingServiceInterface.php @@ -0,0 +1,62 @@ + + */ + public function getPaymentMethods(User $user): array; + + /** + * Get the gateway name. + */ + public function getGateway(): string; +} diff --git a/website/app/Services/Billing/DunningService.php b/website/app/Services/Billing/DunningService.php new file mode 100644 index 0000000..828902d --- /dev/null +++ b/website/app/Services/Billing/DunningService.php @@ -0,0 +1,107 @@ +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}."); + } +} diff --git a/website/app/Services/Billing/PayPalBillingService.php b/website/app/Services/Billing/PayPalBillingService.php new file mode 100644 index 0000000..968a101 --- /dev/null +++ b/website/app/Services/Billing/PayPalBillingService.php @@ -0,0 +1,155 @@ +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 */ + public function getPaymentMethods(User $user): array + { + // PayPal manages payment methods externally + return []; + } + + public function getGateway(): string + { + return 'paypal'; + } +} diff --git a/website/app/Services/Billing/StripeBillingService.php b/website/app/Services/Billing/StripeBillingService.php new file mode 100644 index 0000000..d7ea684 --- /dev/null +++ b/website/app/Services/Billing/StripeBillingService.php @@ -0,0 +1,235 @@ +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 */ + 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(), + }; + } +} diff --git a/website/bootstrap/app.php b/website/bootstrap/app.php index 09c4e38..14de121 100644 --- a/website/bootstrap/app.php +++ b/website/bootstrap/app.php @@ -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, ]); diff --git a/website/config/billing.php b/website/config/billing.php new file mode 100644 index 0000000..3b39768 --- /dev/null +++ b/website/config/billing.php @@ -0,0 +1,73 @@ + 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), + ], + +]; diff --git a/website/resources/js/Layouts/AppLayout.vue b/website/resources/js/Layouts/AppLayout.vue index 6fe963f..bd69318 100644 --- a/website/resources/js/Layouts/AppLayout.vue +++ b/website/resources/js/Layouts/AppLayout.vue @@ -24,6 +24,24 @@ const domains = computed(() => page.props.domains); > Dashboard + + Subscriptions + + + Billing + + + Plans + +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}`); + } +}; + + + diff --git a/website/resources/js/Pages/Billing/Invoices.vue b/website/resources/js/Pages/Billing/Invoices.vue new file mode 100644 index 0000000..546eac0 --- /dev/null +++ b/website/resources/js/Pages/Billing/Invoices.vue @@ -0,0 +1,81 @@ + + + diff --git a/website/resources/js/Pages/Billing/Transactions.vue b/website/resources/js/Pages/Billing/Transactions.vue new file mode 100644 index 0000000..88c0376 --- /dev/null +++ b/website/resources/js/Pages/Billing/Transactions.vue @@ -0,0 +1,80 @@ + + + diff --git a/website/resources/js/Pages/Checkout/Show.vue b/website/resources/js/Pages/Checkout/Show.vue new file mode 100644 index 0000000..3cec32b --- /dev/null +++ b/website/resources/js/Pages/Checkout/Show.vue @@ -0,0 +1,191 @@ + + + diff --git a/website/resources/js/Pages/Dashboard.vue b/website/resources/js/Pages/Dashboard.vue index 452397e..223ef35 100644 --- a/website/resources/js/Pages/Dashboard.vue +++ b/website/resources/js/Pages/Dashboard.vue @@ -1,4 +1,5 @@ + + diff --git a/website/resources/js/Pages/Plans/Show.vue b/website/resources/js/Pages/Plans/Show.vue new file mode 100644 index 0000000..87ecabc --- /dev/null +++ b/website/resources/js/Pages/Plans/Show.vue @@ -0,0 +1,59 @@ + + + diff --git a/website/resources/js/Pages/Subscriptions/Index.vue b/website/resources/js/Pages/Subscriptions/Index.vue new file mode 100644 index 0000000..261cbda --- /dev/null +++ b/website/resources/js/Pages/Subscriptions/Index.vue @@ -0,0 +1,78 @@ + + + diff --git a/website/resources/js/Pages/Subscriptions/Show.vue b/website/resources/js/Pages/Subscriptions/Show.vue new file mode 100644 index 0000000..b632cd6 --- /dev/null +++ b/website/resources/js/Pages/Subscriptions/Show.vue @@ -0,0 +1,163 @@ + + + diff --git a/website/routes/account.php b/website/routes/account.php index 87d54e4..4bec321 100644 --- a/website/routes/account.php +++ b/website/routes/account.php @@ -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'); diff --git a/website/routes/console.php b/website/routes/console.php index cf940fe..6e9a69a 100644 --- a/website/routes/console.php +++ b/website/routes/console.php @@ -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'); diff --git a/website/routes/webhooks.php b/website/routes/webhooks.php new file mode 100644 index 0000000..f6f9de2 --- /dev/null +++ b/website/routes/webhooks.php @@ -0,0 +1,10 @@ +name('webhooks.stripe'); +Route::post('/webhooks/paypal', [PayPalWebhookController::class, 'handle'])->name('webhooks.paypal'); diff --git a/website/tests/Feature/Billing/BillingPageTest.php b/website/tests/Feature/Billing/BillingPageTest.php new file mode 100644 index 0000000..5f29516 --- /dev/null +++ b/website/tests/Feature/Billing/BillingPageTest.php @@ -0,0 +1,75 @@ +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(); +}); diff --git a/website/tests/Feature/Billing/CheckoutTest.php b/website/tests/Feature/Billing/CheckoutTest.php new file mode 100644 index 0000000..ddc9df5 --- /dev/null +++ b/website/tests/Feature/Billing/CheckoutTest.php @@ -0,0 +1,117 @@ +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'); +}); diff --git a/website/tests/Feature/Billing/CouponTest.php b/website/tests/Feature/Billing/CouponTest.php new file mode 100644 index 0000000..0ad972e --- /dev/null +++ b/website/tests/Feature/Billing/CouponTest.php @@ -0,0 +1,52 @@ +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(); +}); diff --git a/website/tests/Feature/Billing/DunningTest.php b/website/tests/Feature/Billing/DunningTest.php new file mode 100644 index 0000000..dbdcf3c --- /dev/null +++ b/website/tests/Feature/Billing/DunningTest.php @@ -0,0 +1,130 @@ +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(); +}); diff --git a/website/tests/Feature/Billing/PlanBrowsingTest.php b/website/tests/Feature/Billing/PlanBrowsingTest.php new file mode 100644 index 0000000..760400b --- /dev/null +++ b/website/tests/Feature/Billing/PlanBrowsingTest.php @@ -0,0 +1,62 @@ +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(); +}); diff --git a/website/tests/Feature/Billing/SubscriptionManagementTest.php b/website/tests/Feature/Billing/SubscriptionManagementTest.php new file mode 100644 index 0000000..ff41125 --- /dev/null +++ b/website/tests/Feature/Billing/SubscriptionManagementTest.php @@ -0,0 +1,28 @@ +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(); +});