*/ public const BILLING_ADDRESS_REQUIRED_FIELDS = ['line1', 'city', 'postcode', 'country']; /** * All recognised billing address keys. * * @var array */ public const BILLING_ADDRESS_ALLOWED_FIELDS = ['line1', 'line2', 'city', 'state', 'postcode', 'country']; protected static function newFactory(): OrderFactory { return OrderFactory::new(); } protected static function booted(): void { $validate = function (self $order): void { $order->validateBillingAddress(); }; static::creating($validate); static::updating($validate); } /** * Validate the billing_address structure. * * When `commerce.checkout.require_billing_address` is enabled (default), * the billing address must be present and contain the required fields. * When disabled, null is permitted but any non-null value must still * conform to the expected structure. * * @throws ValidationException */ public function validateBillingAddress(): void { $requireAddress = config('commerce.checkout.require_billing_address', true); $address = $this->billing_address; // Null is acceptable only when billing address is not required if ($address === null) { if ($requireAddress) { throw ValidationException::withMessages([ 'billing_address' => ['Billing address is required.'], ]); } return; } // Non-null value must be an array if (! is_array($address)) { throw ValidationException::withMessages([ 'billing_address' => ['Billing address must be an array.'], ]); } $validator = Validator::make($address, [ 'line1' => ['required', 'string', 'max:255'], 'line2' => ['nullable', 'string', 'max:255'], 'city' => ['required', 'string', 'max:255'], 'state' => ['nullable', 'string', 'max:255'], 'postcode' => ['required', 'string', 'max:20'], 'country' => ['required', 'string', 'size:2'], ]); if ($validator->fails()) { throw ValidationException::withMessages( collect($validator->errors()->toArray()) ->mapWithKeys(fn (array $messages, string $key) => [ "billing_address.{$key}" => $messages, ]) ->all() ); } // Strip any unrecognised keys to prevent data pollution $this->billing_address = array_intersect_key( $address, array_flip(self::BILLING_ADDRESS_ALLOWED_FIELDS) ); } protected $fillable = [ 'orderable_type', 'orderable_id', 'user_id', 'order_number', 'status', 'type', 'billing_cycle', 'currency', 'display_currency', 'exchange_rate_used', 'base_currency_total', 'subtotal', 'tax_amount', 'discount_amount', 'total', 'payment_method', 'payment_gateway', 'gateway_order_id', 'coupon_id', 'billing_name', 'billing_email', 'tax_rate', 'tax_country', 'billing_address', 'metadata', 'idempotency_key', 'paid_at', ]; protected $casts = [ 'subtotal' => 'decimal:2', 'tax_amount' => 'decimal:2', 'discount_amount' => 'decimal:2', 'total' => 'decimal:2', 'tax_rate' => 'decimal:2', 'exchange_rate_used' => 'decimal:8', 'base_currency_total' => 'decimal:2', 'billing_address' => 'array', 'metadata' => 'array', 'paid_at' => 'datetime', ]; // Relationships /** * The orderable entity (User or Workspace). */ public function orderable(): MorphTo { return $this->morphTo(); } public function user(): BelongsTo { return $this->belongsTo(User::class); } public function items(): HasMany { return $this->hasMany(OrderItem::class); } public function coupon(): BelongsTo { return $this->belongsTo(Coupon::class); } public function invoice(): HasOne { return $this->hasOne(Invoice::class); } public function payments(): HasMany { return $this->hasMany(Payment::class, 'invoice_id', 'id') ->whereHas('invoice', fn ($q) => $q->where('order_id', $this->id)); } /** * Credit notes that originated from this order. */ public function creditNotes(): HasMany { return $this->hasMany(CreditNote::class); } /** * Credit notes that were applied to this order. */ public function appliedCreditNotes(): HasMany { return $this->hasMany(CreditNote::class, 'applied_to_order_id'); } // Status helpers public function isPending(): bool { return $this->status === 'pending'; } public function isProcessing(): bool { return $this->status === 'processing'; } public function isPaid(): bool { return $this->status === 'paid'; } public function isFailed(): bool { return $this->status === 'failed'; } public function isRefunded(): bool { return $this->status === 'refunded'; } public function isCancelled(): bool { return $this->status === 'cancelled'; } // Actions public function markAsPaid(): void { $this->update([ 'status' => 'paid', 'paid_at' => now(), ]); } public function markAsFailed(?string $reason = null): void { $this->update([ 'status' => 'failed', 'metadata' => array_merge($this->metadata ?? [], [ 'failure_reason' => $reason, 'failed_at' => now()->toIso8601String(), ]), ]); } public function cancel(): void { $this->update(['status' => 'cancelled']); } // Scopes public function scopePending($query) { return $query->where('status', 'pending'); } public function scopePaid($query) { return $query->where('status', 'paid'); } public function scopeForWorkspace($query, int $workspaceId) { return $query->where('orderable_type', Workspace::class) ->where('orderable_id', $workspaceId); } // Workspace resolution /** * Get the workspace ID for this order. * * Handles polymorphic orderables: if the orderable is a Workspace, * returns its ID directly. If it's a User, returns their default * workspace ID. */ public function getWorkspaceIdAttribute(): ?int { if ($this->orderable_type === Workspace::class) { return $this->orderable_id; } if ($this->orderable_type === User::class) { $user = $this->orderable; return $user?->defaultHostWorkspace()?->id; } return null; } /** * Get the workspace for this order. * * Returns the workspace directly if orderable is Workspace, * or the user's default workspace if orderable is User. */ public function getResolvedWorkspace(): ?Workspace { if ($this->orderable_type === Workspace::class) { return $this->orderable; } if ($this->orderable_type === User::class) { return $this->orderable?->defaultHostWorkspace(); } return null; } // Order number generation public static function generateOrderNumber(): string { $prefix = 'ORD'; $date = now()->format('Ymd'); $random = strtoupper(substr(md5(uniqid()), 0, 6)); return "{$prefix}-{$date}-{$random}"; } // Currency helpers /** * Get the display currency (customer-facing). */ public function getDisplayCurrencyAttribute($value): string { return $value ?? $this->currency ?? config('commerce.currency', 'GBP'); } /** * Get formatted total in display currency. */ public function getFormattedTotalAttribute(): string { $currencyService = app(CurrencyService::class); return $currencyService->format($this->total, $this->display_currency); } /** * Get formatted subtotal in display currency. */ public function getFormattedSubtotalAttribute(): string { $currencyService = app(CurrencyService::class); return $currencyService->format($this->subtotal, $this->display_currency); } /** * Get formatted tax amount in display currency. */ public function getFormattedTaxAmountAttribute(): string { $currencyService = app(CurrencyService::class); return $currencyService->format($this->tax_amount, $this->display_currency); } /** * Get formatted discount amount in display currency. */ public function getFormattedDiscountAmountAttribute(): string { $currencyService = app(CurrencyService::class); return $currencyService->format($this->discount_amount, $this->display_currency); } /** * Convert an amount from display currency to base currency. */ public function toBaseCurrency(float $amount): float { if ($this->exchange_rate_used && $this->exchange_rate_used > 0) { return $amount / $this->exchange_rate_used; } $baseCurrency = config('commerce.currencies.base', 'GBP'); if ($this->display_currency === $baseCurrency) { return $amount; } return ExchangeRate::convert( $amount, $this->display_currency, $baseCurrency ) ?? $amount; } /** * Convert an amount from base currency to display currency. */ public function toDisplayCurrency(float $amount): float { if ($this->exchange_rate_used) { return $amount * $this->exchange_rate_used; } $baseCurrency = config('commerce.currencies.base', 'GBP'); if ($this->display_currency === $baseCurrency) { return $amount; } return ExchangeRate::convert( $amount, $baseCurrency, $this->display_currency ) ?? $amount; } /** * Check if order uses a different display currency than base. */ public function hasMultiCurrency(): bool { $baseCurrency = config('commerce.currencies.base', 'GBP'); return $this->display_currency !== $baseCurrency; } public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() ->logOnly(['status', 'paid_at']) ->logOnlyDirty() ->dontSubmitEmptyLogs() ->setDescriptionForEvent(fn (string $eventName) => "Order {$eventName}"); } }