php-commerce/View/Modal/Web/CheckoutPage.php
Snider 8f27fe85c3 refactor: update Tenant module imports after namespace migration
Updates all references from Core\Mod\Tenant to Core\Tenant following
the monorepo separation. The Tenant module now lives in its own package
with the simplified namespace.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:39:12 +00:00

630 lines
18 KiB
PHP

<?php
namespace Core\Mod\Commerce\View\Modal\Web;
use Core\Tenant\Models\Package;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Core\Mod\Commerce\Models\Coupon;
use Core\Mod\Commerce\Models\ExchangeRate;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Services\CheckoutRateLimiter;
use Core\Mod\Commerce\Services\CommerceService;
use Core\Mod\Commerce\Services\CouponService;
use Core\Mod\Commerce\Services\CurrencyService;
use Core\Mod\Commerce\Services\TaxService;
#[Layout('shared::layouts.checkout')]
class CheckoutPage extends Component
{
// URL parameters
#[Url]
public string $plan = '';
#[Url]
public string $cycle = 'monthly';
// Form state
public int $step = 1;
public ?int $selectedPackageId = null;
public string $billingCycle = 'monthly';
// Billing details
public string $billingName = '';
public string $billingEmail = '';
public string $billingAddressLine1 = '';
public string $billingAddressLine2 = '';
public string $billingCity = '';
public string $billingState = '';
public string $billingPostalCode = '';
public string $billingCountry = 'GB';
public string $taxId = '';
// Currency selection
public string $displayCurrency = '';
// Coupon
public string $couponCode = '';
public ?int $appliedCouponId = null;
public string $couponError = '';
public string $couponSuccess = '';
// Processing state
public bool $processing = false;
public string $error = '';
// Idempotency key for preventing duplicate orders
public string $idempotencyKey = '';
protected CommerceService $commerce;
protected CouponService $couponService;
protected TaxService $taxService;
protected CheckoutRateLimiter $rateLimiter;
protected CurrencyService $currencyService;
public function boot(
CommerceService $commerce,
CouponService $couponService,
TaxService $taxService,
CheckoutRateLimiter $rateLimiter,
CurrencyService $currencyService
): void {
$this->commerce = $commerce;
$this->couponService = $couponService;
$this->taxService = $taxService;
$this->rateLimiter = $rateLimiter;
$this->currencyService = $currencyService;
}
public function mount(string $package = ''): void
{
// Generate idempotency key for this checkout session
$this->idempotencyKey = $this->generateIdempotencyKey();
// Detect and set display currency
$this->displayCurrency = $this->currencyService->getCurrentCurrency();
// Pre-select package from URL route parameter or query param
$packageCode = $package ?: $this->plan;
if ($packageCode) {
$pkg = Package::where('code', $packageCode)->active()->public()->first();
if ($pkg) {
$this->selectedPackageId = $pkg->id;
$this->plan = $packageCode;
}
}
// Set billing cycle from URL
if (in_array($this->cycle, ['monthly', 'yearly'])) {
$this->billingCycle = $this->cycle;
}
// Pre-fill billing details if user is logged in
if (Auth::check()) {
$user = Auth::user();
$workspace = $user->defaultHostWorkspace();
if ($workspace) {
$this->billingName = $workspace->billing_name ?? $user->name ?? '';
$this->billingEmail = $workspace->billing_email ?? $user->email ?? '';
$this->billingAddressLine1 = $workspace->billing_address_line1 ?? '';
$this->billingAddressLine2 = $workspace->billing_address_line2 ?? '';
$this->billingCity = $workspace->billing_city ?? '';
$this->billingState = $workspace->billing_state ?? '';
$this->billingPostalCode = $workspace->billing_postal_code ?? '';
$this->billingCountry = $workspace->billing_country ?? 'GB';
$this->taxId = $workspace->tax_id ?? '';
}
}
}
/**
* Handle currency change event from CurrencySelector component.
*/
#[\Livewire\Attributes\On('currency-changed')]
public function onCurrencyChanged(string $currency): void
{
$this->displayCurrency = $currency;
}
/**
* Get the base currency.
*/
#[Computed]
public function baseCurrency(): string
{
return config('commerce.currencies.base', 'GBP');
}
/**
* Get the exchange rate for display currency.
*/
#[Computed]
public function exchangeRate(): float
{
if ($this->displayCurrency === $this->baseCurrency) {
return 1.0;
}
return ExchangeRate::getRate($this->baseCurrency, $this->displayCurrency) ?? 1.0;
}
/**
* Get supported currencies for display.
*/
#[Computed]
public function supportedCurrencies(): array
{
return $this->currencyService->getSupportedCurrencies();
}
#[Computed]
public function packages(): \Illuminate\Database\Eloquent\Collection
{
return Package::active()
->public()
->base()
->purchasable()
->ordered()
->get();
}
#[Computed]
public function selectedPackage(): ?Package
{
if (! $this->selectedPackageId) {
return null;
}
return Package::find($this->selectedPackageId);
}
#[Computed]
public function appliedCoupon(): ?Coupon
{
if (! $this->appliedCouponId) {
return null;
}
return Coupon::find($this->appliedCouponId);
}
/**
* Get subtotal in base currency.
*/
#[Computed]
public function baseSubtotal(): float
{
if (! $this->selectedPackage) {
return 0;
}
return $this->selectedPackage->getPrice($this->billingCycle);
}
/**
* Get subtotal in display currency.
*/
#[Computed]
public function subtotal(): float
{
return $this->convertToDisplayCurrency($this->baseSubtotal);
}
/**
* Get setup fee in base currency.
*/
#[Computed]
public function baseSetupFee(): float
{
if (! $this->selectedPackage) {
return 0;
}
return $this->selectedPackage->setup_fee ?? 0;
}
/**
* Get setup fee in display currency.
*/
#[Computed]
public function setupFee(): float
{
return $this->convertToDisplayCurrency($this->baseSetupFee);
}
/**
* Get discount in base currency.
*/
#[Computed]
public function baseDiscount(): float
{
if (! $this->appliedCoupon || ! $this->selectedPackage) {
return 0;
}
return $this->appliedCoupon->calculateDiscount($this->baseSubtotal);
}
/**
* Get discount in display currency.
*/
#[Computed]
public function discount(): float
{
return $this->convertToDisplayCurrency($this->baseDiscount);
}
/**
* Get taxable amount in base currency.
*/
#[Computed]
public function baseTaxableAmount(): float
{
return $this->baseSubtotal - $this->baseDiscount + $this->baseSetupFee;
}
/**
* Get taxable amount in display currency.
*/
#[Computed]
public function taxableAmount(): float
{
return $this->subtotal - $this->discount + $this->setupFee;
}
/**
* Get tax amount in base currency (tax is calculated on base amounts).
*/
#[Computed]
public function baseTaxAmount(): float
{
// Create a temporary workspace-like object for tax calculation
$workspace = new Workspace([
'billing_country' => $this->billingCountry,
'billing_state' => $this->billingState,
'tax_id' => $this->taxId,
'tax_exempt' => false,
]);
$result = $this->taxService->calculate($workspace, $this->baseTaxableAmount);
return $result->taxAmount;
}
/**
* Get tax amount in display currency.
*/
#[Computed]
public function taxAmount(): float
{
return $this->convertToDisplayCurrency($this->baseTaxAmount);
}
#[Computed]
public function taxRate(): float
{
$workspace = new Workspace([
'billing_country' => $this->billingCountry,
'billing_state' => $this->billingState,
'tax_id' => $this->taxId,
'tax_exempt' => false,
]);
$result = $this->taxService->calculate($workspace, $this->baseTaxableAmount);
return $result->taxRate;
}
/**
* Get total in base currency.
*/
#[Computed]
public function baseTotal(): float
{
return $this->baseTaxableAmount + $this->baseTaxAmount;
}
/**
* Get total in display currency.
*/
#[Computed]
public function total(): float
{
return $this->taxableAmount + $this->taxAmount;
}
/**
* Convert an amount from base currency to display currency.
*/
public function convertToDisplayCurrency(float $amount): float
{
if ($this->displayCurrency === $this->baseCurrency) {
return $amount;
}
return round($amount * $this->exchangeRate, 2);
}
/**
* Format an amount in the display currency.
*/
public function formatAmount(float $amount): string
{
return $this->currencyService->format($amount, $this->displayCurrency);
}
#[Computed]
public function countries(): array
{
return [
'GB' => 'United Kingdom',
'US' => 'United States',
'AU' => 'Australia',
'AT' => 'Austria',
'BE' => 'Belgium',
'BG' => 'Bulgaria',
'CA' => 'Canada',
'HR' => 'Croatia',
'CY' => 'Cyprus',
'CZ' => 'Czech Republic',
'DK' => 'Denmark',
'EE' => 'Estonia',
'FI' => 'Finland',
'FR' => 'France',
'DE' => 'Germany',
'GR' => 'Greece',
'HU' => 'Hungary',
'IE' => 'Ireland',
'IT' => 'Italy',
'LV' => 'Latvia',
'LT' => 'Lithuania',
'LU' => 'Luxembourg',
'MT' => 'Malta',
'NL' => 'Netherlands',
'NZ' => 'New Zealand',
'PL' => 'Poland',
'PT' => 'Portugal',
'RO' => 'Romania',
'SK' => 'Slovakia',
'SI' => 'Slovenia',
'ES' => 'Spain',
'SE' => 'Sweden',
];
}
public function selectPackage(int $packageId): void
{
$this->selectedPackageId = $packageId;
$this->step = 2;
// Revalidate coupon for new package
if ($this->appliedCouponId) {
$this->validateAppliedCoupon();
}
}
public function setBillingCycle(string $cycle): void
{
$this->billingCycle = $cycle;
}
public function applyCoupon(): void
{
$this->couponError = '';
$this->couponSuccess = '';
if (empty($this->couponCode)) {
$this->couponError = 'Please enter a coupon code';
return;
}
// Check rate limit to prevent brute-forcing coupon codes
$userId = Auth::id();
$workspaceId = Auth::check() ? Auth::user()->defaultHostWorkspace()?->id : null;
if ($this->rateLimiter->tooManyCouponAttempts($workspaceId, $userId, request())) {
$availableIn = $this->rateLimiter->couponAvailableIn($workspaceId, $userId, request());
$minutes = ceil($availableIn / 60);
$this->couponError = "Too many attempts. Please try again in {$minutes} minute(s).";
return;
}
// Increment counter before validation
$this->rateLimiter->incrementCoupon($workspaceId, $userId, request());
$workspace = $this->getOrCreateWorkspace();
$result = $this->couponService->validateByCode(
$this->couponCode,
$workspace,
$this->selectedPackage
);
if (! $result->isValid()) {
$this->couponError = $result->error;
return;
}
$this->appliedCouponId = $result->coupon->id;
$this->couponSuccess = "Coupon applied: {$this->commerce->formatMoney($this->discount)} off";
}
public function removeCoupon(): void
{
$this->appliedCouponId = null;
$this->couponCode = '';
$this->couponError = '';
$this->couponSuccess = '';
}
protected function validateAppliedCoupon(): void
{
if (! $this->appliedCouponId) {
return;
}
$coupon = Coupon::find($this->appliedCouponId);
if (! $coupon || ! $coupon->appliesToPackage($this->selectedPackageId)) {
$this->removeCoupon();
$this->couponError = 'Coupon does not apply to the selected plan';
}
}
public function goToStep(int $step): void
{
if ($step === 1 || ($step === 2 && $this->selectedPackageId)) {
$this->step = $step;
}
}
public function proceedToPayment(): void
{
$this->validate([
'billingName' => 'required|string|max:255',
'billingEmail' => 'required|email|max:255',
'billingAddressLine1' => 'required|string|max:255',
'billingCity' => 'required|string|max:255',
'billingPostalCode' => 'required|string|max:20',
'billingCountry' => 'required|string|size:2',
]);
$this->step = 3;
}
public function checkout(string $gateway = 'btcpay'): void
{
$this->error = '';
$this->processing = true;
try {
// Validate required fields
if (! $this->selectedPackageId) {
throw new \Exception('Please select a plan');
}
// Check rate limit before processing
$userId = Auth::id();
$workspaceId = Auth::check() ? Auth::user()->defaultHostWorkspace()?->id : null;
if ($this->rateLimiter->tooManyAttempts($workspaceId, $userId, request())) {
$availableIn = $this->rateLimiter->availableIn($workspaceId, $userId, request());
$minutes = ceil($availableIn / 60);
throw new \Exception("Too many checkout attempts. Please try again in {$minutes} minute(s).");
}
// Increment rate limiter before processing
$this->rateLimiter->increment($workspaceId, $userId, request());
// Get or create workspace
$workspace = $this->getOrCreateWorkspace();
// Update workspace billing details
$workspace->update([
'billing_name' => $this->billingName,
'billing_email' => $this->billingEmail,
'billing_address_line1' => $this->billingAddressLine1,
'billing_address_line2' => $this->billingAddressLine2,
'billing_city' => $this->billingCity,
'billing_state' => $this->billingState,
'billing_postal_code' => $this->billingPostalCode,
'billing_country' => $this->billingCountry,
'tax_id' => $this->taxId,
]);
// Create order with idempotency key to prevent duplicates
$order = $this->commerce->createOrder(
$workspace,
$this->selectedPackage,
$this->billingCycle,
$this->appliedCoupon,
[
'display_currency' => $this->displayCurrency,
'exchange_rate' => $this->exchangeRate,
],
$this->idempotencyKey
);
// Update order with multi-currency fields
$baseCurrency = $this->baseCurrency;
if ($this->displayCurrency !== $baseCurrency) {
$order->update([
'display_currency' => $this->displayCurrency,
'exchange_rate_used' => $this->exchangeRate,
'base_currency_total' => $this->baseTotal,
]);
}
// Create checkout session
$checkout = $this->commerce->createCheckout($order, $gateway);
// Redirect to payment
$this->redirect($checkout['checkout_url']);
} catch (\Exception $e) {
$this->error = $e->getMessage();
$this->processing = false;
}
}
protected function getOrCreateWorkspace(): Workspace
{
if (Auth::check()) {
$workspace = Auth::user()->defaultHostWorkspace();
if ($workspace) {
return $workspace;
}
}
// For guest checkout, create a temporary workspace
// This will be properly assigned when user registers/logs in
return Workspace::create([
'name' => $this->billingName ?: 'New Workspace',
'slug' => 'checkout-'.uniqid(),
'billing_email' => $this->billingEmail,
'is_active' => false, // Activated after payment
]);
}
public function render()
{
return view('commerce::web.checkout.checkout-page');
}
/**
* Generate a unique idempotency key for this checkout session.
*
* Key is based on user/session, package, billing cycle, and timestamp
* to ensure uniqueness while allowing retries within the same session.
*/
protected function generateIdempotencyKey(): string
{
$userId = Auth::id() ?? session()->getId();
$timestamp = now()->format('YmdHi'); // Minute precision
return hash('sha256', "{$userId}:{$timestamp}:".uniqid('', true));
}
}