Files
ladill-servers/app/Services/Hosting/HostingRenewalCheckoutService.php
T
isaaccladandCursor b6c8ac343f Extract Ladill Servers as standalone app at servers.ladill.com.
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>
2026-06-06 19:18:30 +00:00

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;
}
}