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