VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
243 lines
8.6 KiB
PHP
243 lines
8.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Hosting;
|
|
|
|
use App\Models\CustomerHostingOrder;
|
|
use App\Models\HostingAccount;
|
|
use App\Models\HostingBillingInvoice;
|
|
use App\Models\User;
|
|
use App\Services\Billing\PaystackService;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class HostingRenewalCheckoutService
|
|
{
|
|
public function __construct(
|
|
private PaystackService $paystack,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @return array{months:int, amount:float, currency:string, billing_cycle:string, amount_minor:int}
|
|
*/
|
|
public function renewalQuote(HostingAccount $account, ?int $durationMonths = null): array
|
|
{
|
|
$account->loadMissing(['product', 'latestOrder']);
|
|
|
|
if (! $account->canBeRenewed()) {
|
|
throw ValidationException::withMessages([
|
|
'hosting_account_id' => 'This hosting account does not have a configured renewal duration.',
|
|
]);
|
|
}
|
|
|
|
$months = $durationMonths ?? $account->assignedDurationMonths();
|
|
$billingCycle = $this->billingCycleForMonths($months);
|
|
$amount = $this->renewalAmount($account, $months, $billingCycle);
|
|
|
|
return [
|
|
'months' => $months,
|
|
'amount' => round($amount, 2),
|
|
'currency' => $account->product?->display_currency ?? 'GHS',
|
|
'billing_cycle' => $billingCycle,
|
|
'amount_minor' => (int) round($amount * 100),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{months:int, amount:float, currency:string, billing_cycle:string, label:string, current:bool}>
|
|
*/
|
|
public function availableRenewalOptions(HostingAccount $account): array
|
|
{
|
|
$account->loadMissing(['product']);
|
|
|
|
if (! $account->canBeRenewed() || ! $account->product) {
|
|
return [];
|
|
}
|
|
|
|
$currency = $account->product->display_currency ?? 'GHS';
|
|
$currentMonths = $account->assignedDurationMonths();
|
|
|
|
$cycles = [
|
|
['months' => 1, 'cycle' => CustomerHostingOrder::CYCLE_MONTHLY, 'label' => '1 Month'],
|
|
['months' => 3, 'cycle' => CustomerHostingOrder::CYCLE_QUARTERLY, 'label' => '3 Months'],
|
|
['months' => 12, 'cycle' => CustomerHostingOrder::CYCLE_YEARLY, 'label' => '1 Year'],
|
|
['months' => 24, 'cycle' => CustomerHostingOrder::CYCLE_BIENNIAL, 'label' => '2 Years'],
|
|
];
|
|
|
|
$options = [];
|
|
|
|
foreach ($cycles as $cycle) {
|
|
$amount = $this->renewalAmount($account, $cycle['months'], $cycle['cycle']);
|
|
|
|
if ($amount <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$options[] = [
|
|
'months' => $cycle['months'],
|
|
'amount' => round($amount, 2),
|
|
'currency' => $currency,
|
|
'billing_cycle' => $cycle['cycle'],
|
|
'label' => $cycle['label'],
|
|
'current' => $cycle['months'] === $currentMonths,
|
|
];
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* @return array{authorization_url:string, invoice:HostingBillingInvoice, quote:array{months:int, amount:float, currency:string, billing_cycle:string, amount_minor:int}}
|
|
*/
|
|
public function beginCheckout(HostingAccount $account, User $user, string $callbackUrl, ?int $durationMonths = null): array
|
|
{
|
|
$quote = $this->renewalQuote($account, $durationMonths);
|
|
$reference = 'HOST-REN-' . strtoupper(Str::random(16));
|
|
|
|
$invoice = HostingBillingInvoice::query()->create([
|
|
'user_id' => $user->id,
|
|
'hosting_account_id' => $account->id,
|
|
'invoice_type' => HostingBillingInvoice::TYPE_RENEWAL,
|
|
'status' => HostingBillingInvoice::STATUS_PENDING,
|
|
'currency' => $quote['currency'],
|
|
'subtotal_amount' => $quote['amount'],
|
|
'credit_amount' => 0,
|
|
'total_amount' => $quote['amount'],
|
|
'description' => sprintf(
|
|
'Hosting renewal for %s (%d month%s)',
|
|
$account->primary_domain ?: $account->username,
|
|
$quote['months'],
|
|
$quote['months'] === 1 ? '' : 's'
|
|
),
|
|
'provider_reference' => $reference,
|
|
'line_items' => [[
|
|
'code' => 'renewal',
|
|
'label' => 'Hosting Renewal',
|
|
'quantity' => 1,
|
|
'amount' => $quote['amount'],
|
|
'description' => sprintf('%d month renewal for %s', $quote['months'], $account->product?->name ?? 'Hosting plan'),
|
|
]],
|
|
'metadata' => [
|
|
'months' => $quote['months'],
|
|
'billing_cycle' => $quote['billing_cycle'],
|
|
],
|
|
]);
|
|
|
|
$transaction = $this->paystack->initializeTransaction([
|
|
'email' => (string) $user->email,
|
|
'amount' => $quote['amount_minor'],
|
|
'currency' => $quote['currency'],
|
|
'reference' => $reference,
|
|
'callback_url' => $callbackUrl,
|
|
'metadata' => [
|
|
'context' => 'hosting_renewal',
|
|
'hosting_account_id' => $account->id,
|
|
'invoice_id' => $invoice->id,
|
|
'user_id' => $user->id,
|
|
'months' => $quote['months'],
|
|
],
|
|
]);
|
|
|
|
$authorizationUrl = (string) ($transaction['authorization_url'] ?? '');
|
|
|
|
if ($authorizationUrl === '') {
|
|
throw ValidationException::withMessages([
|
|
'payment' => 'Paystack did not return a checkout URL for the renewal.',
|
|
]);
|
|
}
|
|
|
|
return [
|
|
'authorization_url' => $authorizationUrl,
|
|
'invoice' => $invoice,
|
|
'quote' => $quote,
|
|
];
|
|
}
|
|
|
|
public function completeCheckout(HostingAccount $account, User $user, string $reference): HostingBillingInvoice
|
|
{
|
|
$account->loadMissing(['latestOrder']);
|
|
|
|
$invoice = HostingBillingInvoice::query()
|
|
->where('hosting_account_id', $account->id)
|
|
->where('user_id', $user->id)
|
|
->where('invoice_type', HostingBillingInvoice::TYPE_RENEWAL)
|
|
->where('provider_reference', $reference)
|
|
->latest('id')
|
|
->first();
|
|
|
|
if (! $invoice) {
|
|
throw ValidationException::withMessages([
|
|
'reference' => 'Renewal invoice could not be found for this payment reference.',
|
|
]);
|
|
}
|
|
|
|
if ($invoice->status === HostingBillingInvoice::STATUS_PAID) {
|
|
return $invoice;
|
|
}
|
|
|
|
$transaction = $this->paystack->verifyTransaction($reference);
|
|
$status = strtolower((string) ($transaction['status'] ?? ''));
|
|
|
|
if ($status !== 'success') {
|
|
throw ValidationException::withMessages([
|
|
'payment' => 'Renewal payment was not successful.',
|
|
]);
|
|
}
|
|
|
|
$months = (int) data_get($invoice->metadata, 'months', $account->assignedDurationMonths() ?? 0);
|
|
if ($months < 1) {
|
|
throw ValidationException::withMessages([
|
|
'payment' => 'Renewal duration is invalid for this hosting account.',
|
|
]);
|
|
}
|
|
|
|
$paidAt = now();
|
|
$account->renew($months, [
|
|
'renewed_by_user' => $user->id,
|
|
'renewed_at' => $paidAt->toIso8601String(),
|
|
'renewal_invoice_id' => $invoice->id,
|
|
]);
|
|
|
|
if ($account->latestOrder) {
|
|
$account->latestOrder->update([
|
|
'expires_at' => $account->fresh()->expires_at,
|
|
'meta' => array_merge((array) ($account->latestOrder->meta ?? []), [
|
|
'renewal_invoice_id' => $invoice->id,
|
|
'renewed_at' => $paidAt->toIso8601String(),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
$invoice->update([
|
|
'status' => HostingBillingInvoice::STATUS_PAID,
|
|
'paid_at' => $paidAt,
|
|
]);
|
|
|
|
return $invoice->fresh();
|
|
}
|
|
|
|
private function billingCycleForMonths(int $months): string
|
|
{
|
|
return match ($months) {
|
|
1 => CustomerHostingOrder::CYCLE_MONTHLY,
|
|
3 => CustomerHostingOrder::CYCLE_QUARTERLY,
|
|
12 => CustomerHostingOrder::CYCLE_YEARLY,
|
|
24 => CustomerHostingOrder::CYCLE_BIENNIAL,
|
|
default => CustomerHostingOrder::CYCLE_MONTHLY,
|
|
};
|
|
}
|
|
|
|
private function renewalAmount(HostingAccount $account, int $months, string $billingCycle): float
|
|
{
|
|
$price = $account->product?->getPriceForCycle($billingCycle);
|
|
|
|
if ($price !== null && in_array($months, [1, 3, 12, 24], true)) {
|
|
return (float) $price;
|
|
}
|
|
|
|
$monthly = (float) ($account->product?->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY) ?? 0);
|
|
|
|
return $monthly * $months;
|
|
}
|
|
}
|