2026-01-27 00:24:22 +00:00
|
|
|
<?php
|
|
|
|
|
|
2026-03-17 09:08:03 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-27 16:23:12 +00:00
|
|
|
namespace Core\Mod\Commerce\Models;
|
2026-01-27 00:24:22 +00:00
|
|
|
|
2026-03-17 09:08:03 +00:00
|
|
|
use Carbon\Carbon;
|
2026-02-23 03:50:05 +00:00
|
|
|
use Core\Mod\Commerce\Contracts\Orderable;
|
2026-03-17 09:08:03 +00:00
|
|
|
use Core\Mod\Commerce\Database\Factories\OrderFactory;
|
|
|
|
|
use Core\Mod\Commerce\Services\CurrencyService;
|
2026-01-27 17:39:12 +00:00
|
|
|
use Core\Tenant\Models\User;
|
|
|
|
|
use Core\Tenant\Models\Workspace;
|
2026-01-27 00:24:22 +00:00
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
2026-03-24 16:20:14 +00:00
|
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
|
use Illuminate\Validation\ValidationException;
|
2026-01-27 00:24:22 +00:00
|
|
|
use Spatie\Activitylog\LogOptions;
|
|
|
|
|
use Spatie\Activitylog\Traits\LogsActivity;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Order model representing a checkout transaction.
|
|
|
|
|
*
|
|
|
|
|
* @property int $id
|
|
|
|
|
* @property string|null $orderable_type
|
|
|
|
|
* @property int|null $orderable_id
|
|
|
|
|
* @property int $user_id
|
|
|
|
|
* @property string $order_number
|
|
|
|
|
* @property string $status
|
|
|
|
|
* @property string $type
|
|
|
|
|
* @property string $currency
|
|
|
|
|
* @property string|null $display_currency Customer-facing currency
|
|
|
|
|
* @property float|null $exchange_rate_used Exchange rate at time of order
|
|
|
|
|
* @property float|null $base_currency_total Total in base currency for reporting
|
|
|
|
|
* @property float $subtotal
|
|
|
|
|
* @property float $tax_amount
|
|
|
|
|
* @property float $discount_amount
|
|
|
|
|
* @property float $total
|
|
|
|
|
* @property string|null $payment_method
|
|
|
|
|
* @property string|null $payment_gateway
|
|
|
|
|
* @property string|null $gateway_order_id
|
|
|
|
|
* @property int|null $coupon_id
|
|
|
|
|
* @property array|null $billing_address
|
|
|
|
|
* @property array|null $metadata
|
2026-03-17 09:08:03 +00:00
|
|
|
* @property Carbon|null $paid_at
|
2026-01-27 00:24:22 +00:00
|
|
|
* @property-read Orderable|null $orderable
|
|
|
|
|
*/
|
|
|
|
|
class Order extends Model
|
|
|
|
|
{
|
|
|
|
|
use HasFactory;
|
|
|
|
|
use LogsActivity;
|
|
|
|
|
|
2026-03-24 16:20:14 +00:00
|
|
|
/**
|
|
|
|
|
* Required keys for a valid billing address.
|
|
|
|
|
*
|
|
|
|
|
* @var array<int, string>
|
|
|
|
|
*/
|
|
|
|
|
public const BILLING_ADDRESS_REQUIRED_FIELDS = ['line1', 'city', 'postcode', 'country'];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* All recognised billing address keys.
|
|
|
|
|
*
|
|
|
|
|
* @var array<int, string>
|
|
|
|
|
*/
|
|
|
|
|
public const BILLING_ADDRESS_ALLOWED_FIELDS = ['line1', 'line2', 'city', 'state', 'postcode', 'country'];
|
|
|
|
|
|
2026-03-17 09:08:03 +00:00
|
|
|
protected static function newFactory(): OrderFactory
|
2026-01-27 00:24:22 +00:00
|
|
|
{
|
2026-03-17 09:08:03 +00:00
|
|
|
return OrderFactory::new();
|
2026-01-27 00:24:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-24 16:20:14 +00:00
|
|
|
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)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 00:24:22 +00:00
|
|
|
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
|
|
|
|
|
{
|
2026-03-17 09:08:03 +00:00
|
|
|
$currencyService = app(CurrencyService::class);
|
2026-01-27 00:24:22 +00:00
|
|
|
|
|
|
|
|
return $currencyService->format($this->total, $this->display_currency);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get formatted subtotal in display currency.
|
|
|
|
|
*/
|
|
|
|
|
public function getFormattedSubtotalAttribute(): string
|
|
|
|
|
{
|
2026-03-17 09:08:03 +00:00
|
|
|
$currencyService = app(CurrencyService::class);
|
2026-01-27 00:24:22 +00:00
|
|
|
|
|
|
|
|
return $currencyService->format($this->subtotal, $this->display_currency);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get formatted tax amount in display currency.
|
|
|
|
|
*/
|
|
|
|
|
public function getFormattedTaxAmountAttribute(): string
|
|
|
|
|
{
|
2026-03-17 09:08:03 +00:00
|
|
|
$currencyService = app(CurrencyService::class);
|
2026-01-27 00:24:22 +00:00
|
|
|
|
|
|
|
|
return $currencyService->format($this->tax_amount, $this->display_currency);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get formatted discount amount in display currency.
|
|
|
|
|
*/
|
|
|
|
|
public function getFormattedDiscountAmountAttribute(): string
|
|
|
|
|
{
|
2026-03-17 09:08:03 +00:00
|
|
|
$currencyService = app(CurrencyService::class);
|
2026-01-27 00:24:22 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 09:08:03 +00:00
|
|
|
return ExchangeRate::convert(
|
2026-01-27 00:24:22 +00:00
|
|
|
$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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 09:08:03 +00:00
|
|
|
return ExchangeRate::convert(
|
2026-01-27 00:24:22 +00:00
|
|
|
$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}");
|
|
|
|
|
}
|
|
|
|
|
}
|