OrderStatus::class, 'amount_subtotal' => 'integer', 'amount_discount' => 'integer', 'amount_shipping' => 'integer', 'amount_tax' => 'integer', 'amount_total' => 'integer', 'amount_paid' => 'integer', 'amount_refunded' => 'integer', 'billing_address' => 'object', 'shipping_address' => 'object', 'meta' => 'object', 'completed_at' => 'datetime', 'paid_at' => 'datetime', 'shipped_at' => 'datetime', 'delivered_at' => 'datetime', 'cancelled_at' => 'datetime', 'refunded_at' => 'datetime', ]; protected $appends = [ 'amount_outstanding', 'is_paid', 'is_fully_paid', ]; public function __construct(array $attributes = []) { parent::__construct($attributes); $this->setTable(config('shop.tables.orders', 'orders')); } protected static function booted() { static::creating(function (Order $order) { // Generate order number if not set if (empty($order->order_number)) { $order->order_number = static::generateOrderNumber(); } // Set default status if (empty($order->status)) { $order->status = OrderStatus::PENDING; } // Initialize amounts if not set $order->amount_paid = $order->amount_paid ?? 0; $order->amount_refunded = $order->amount_refunded ?? 0; }); static::updating(function (Order $order) { // Log status changes if ($order->isDirty('status')) { $oldStatus = $order->getOriginal('status'); $newStatus = $order->status; $order->addNote( "Order status changed from {$oldStatus->label()} to {$newStatus->label()}", 'status_change', false ); // Set timestamp fields based on status if ($newStatus === OrderStatus::COMPLETED && !$order->completed_at) { $order->completed_at = now(); } if ($newStatus === OrderStatus::SHIPPED && !$order->shipped_at) { $order->shipped_at = now(); } if ($newStatus === OrderStatus::DELIVERED && !$order->delivered_at) { $order->delivered_at = now(); } if ($newStatus === OrderStatus::CANCELLED && !$order->cancelled_at) { $order->cancelled_at = now(); } if ($newStatus === OrderStatus::REFUNDED && !$order->refunded_at) { $order->refunded_at = now(); } } // Track payment changes if ($order->isDirty('amount_paid')) { $oldPaid = $order->getOriginal('amount_paid') ?? 0; $newPaid = $order->amount_paid; $difference = $newPaid - $oldPaid; if ($difference > 0) { $order->addNote( "Payment received: " . static::formatMoney($difference, $order->currency), 'payment', false ); // Mark as paid if fully paid if (!$order->paid_at && $newPaid >= $order->amount_total) { $order->paid_at = now(); } } } }); } /** * Generate a unique order number. */ public static function generateOrderNumber(): string { $prefix = config('shop.orders.number_prefix', 'ORD-'); $date = now()->format('Ymd'); // Find the last order number for today $lastOrder = static::where('order_number', 'like', "{$prefix}{$date}%") ->orderBy('order_number', 'desc') ->first(); if ($lastOrder) { // Extract the sequence number and increment $lastNumber = (int) substr($lastOrder->order_number, strlen("{$prefix}{$date}")); $sequence = str_pad($lastNumber + 1, 4, '0', STR_PAD_LEFT); } else { $sequence = '0001'; } return "{$prefix}{$date}{$sequence}"; } /** * Format money amount for display. */ public static function formatMoney(int $amount, string $currency = 'USD'): string { $formatted = number_format($amount / 100, 2); return strtoupper($currency) . ' ' . $formatted; } // ========================================================================= // RELATIONSHIPS // ========================================================================= /** * The cart this order was created from. */ public function cart(): BelongsTo { return $this->belongsTo(config('shop.models.cart', Cart::class), 'cart_id'); } /** * The customer who placed this order. */ public function customer(): MorphTo { return $this->morphTo(); } /** * Order notes and activity log. */ public function notes(): HasMany { return $this->hasMany(config('shop.models.order_note', OrderNote::class), 'order_id') ->orderBy('created_at', 'desc'); } /** * Get the purchases associated with this order through the cart. */ public function purchases(): HasManyThrough { return $this->hasManyThrough( config('shop.models.product_purchase', ProductPurchase::class), config('shop.models.cart', Cart::class), 'id', // Foreign key on carts table (Cart.id) 'cart_id', // Foreign key on product_purchases table (ProductPurchase.cart_id) 'cart_id', // Local key on orders table (Order.cart_id) 'id' // Local key on carts table (Cart.id) ); } /** * Direct access to purchases via cart_id. */ public function directPurchases(): HasMany { return $this->hasMany( config('shop.models.product_purchase', ProductPurchase::class), 'cart_id', 'cart_id' ); } // ========================================================================= // COMPUTED ATTRIBUTES // ========================================================================= /** * Get the outstanding amount (amount_total - amount_paid + amount_refunded). */ public function getAmountOutstandingAttribute(): int { return max(0, ($this->amount_total ?? 0) - ($this->amount_paid ?? 0)); } /** * Check if any payment has been received. */ public function getIsPaidAttribute(): bool { return ($this->amount_paid ?? 0) > 0; } /** * Check if the order is fully paid. */ public function getIsFullyPaidAttribute(): bool { return ($this->amount_paid ?? 0) >= ($this->amount_total ?? 0); } // ========================================================================= // STATUS MANAGEMENT // ========================================================================= /** * Update the order status with validation. * * @throws \InvalidArgumentException if transition is not allowed */ public function updateStatus(OrderStatus $newStatus, ?string $note = null): self { if ($this->status && !$this->status->canTransitionTo($newStatus)) { throw new \InvalidArgumentException( "Cannot transition order from '{$this->status->label()}' to '{$newStatus->label()}'" ); } $this->status = $newStatus; $this->save(); if ($note) { $this->addNote($note, 'status_change'); } return $this; } /** * Force update status without transition validation. */ public function forceStatus(OrderStatus $newStatus, ?string $note = null): self { $this->status = $newStatus; $this->save(); if ($note) { $this->addNote($note, 'status_change'); } return $this; } /** * Mark order as processing (payment received). */ public function markAsProcessing(?string $note = null): self { return $this->updateStatus(OrderStatus::PROCESSING, $note ?? 'Payment confirmed, order is being processed'); } /** * Mark order as in preparation. */ public function markAsInPreparation(?string $note = null): self { return $this->updateStatus(OrderStatus::IN_PREPARATION, $note ?? 'Order is being prepared'); } /** * Mark order as shipped. */ public function markAsShipped(?string $trackingNumber = null, ?string $carrier = null): self { $note = 'Order has been shipped'; if ($trackingNumber) { $note .= " with tracking number: {$trackingNumber}"; $this->updateMetaKey('tracking_number', $trackingNumber); } if ($carrier) { $note .= " via {$carrier}"; $this->updateMetaKey('shipping_carrier', $carrier); } return $this->updateStatus(OrderStatus::SHIPPED, $note); } /** * Mark order as delivered. */ public function markAsDelivered(?string $note = null): self { return $this->updateStatus(OrderStatus::DELIVERED, $note ?? 'Order has been delivered'); } /** * Mark order as completed. */ public function markAsCompleted(?string $note = null): self { return $this->updateStatus(OrderStatus::COMPLETED, $note ?? 'Order completed'); } /** * Cancel the order. */ public function cancel(?string $reason = null): self { return $this->updateStatus(OrderStatus::CANCELLED, $reason ?? 'Order cancelled'); } /** * Put order on hold. */ public function hold(?string $reason = null): self { return $this->updateStatus(OrderStatus::ON_HOLD, $reason ?? 'Order placed on hold'); } // ========================================================================= // PAYMENT MANAGEMENT // ========================================================================= /** * Record a payment for this order. */ public function recordPayment( int $amount, ?string $reference = null, ?string $method = null, ?string $provider = null ): self { DB::transaction(function () use ($amount, $reference, $method, $provider) { $this->amount_paid = ($this->amount_paid ?? 0) + $amount; if ($reference) { $this->payment_reference = $reference; } if ($method) { $this->payment_method = $method; } if ($provider) { $this->payment_provider = $provider; } $this->save(); // Update associated purchases to paid status if ($this->is_fully_paid) { $this->directPurchases()->update([ 'status' => PurchaseStatus::COMPLETED, 'amount_paid' => DB::raw('amount'), ]); // Move to processing if still pending if ($this->status === OrderStatus::PENDING) { $this->markAsProcessing(); } } }); return $this; } /** * Record a refund for this order. */ public function recordRefund(int $amount, ?string $reason = null): self { DB::transaction(function () use ($amount, $reason) { $this->amount_refunded = ($this->amount_refunded ?? 0) + $amount; $this->save(); $this->addNote( "Refund processed: " . static::formatMoney($amount, $this->currency) . ($reason ? " - Reason: {$reason}" : ''), 'refund' ); // If fully refunded, update status if ($this->amount_refunded >= $this->amount_paid) { $this->forceStatus(OrderStatus::REFUNDED); } }); return $this; } // ========================================================================= // NOTES MANAGEMENT // ========================================================================= /** * Add a note to the order. */ public function addNote( string $content, string $type = 'note', bool $isCustomerNote = false, ?string $authorType = null, ?string $authorId = null ): OrderNote { return $this->notes()->create([ 'content' => $content, 'type' => $type, 'is_customer_note' => $isCustomerNote, 'author_type' => $authorType, 'author_id' => $authorId, ]); } /** * Get customer-visible notes only. */ public function customerNotes(): HasMany { return $this->notes()->where('is_customer_note', true); } /** * Get internal notes only (not visible to customer). */ public function internalNotes(): HasMany { return $this->notes()->where('is_customer_note', false); } // ========================================================================= // META HELPERS // ========================================================================= /** * Get a value from the meta object. */ public function getMeta(?string $key = null, $default = null) { if ($key === null) { return $this->meta ?? new \stdClass(); } return $this->meta?->{$key} ?? $default; } /** * Update a key in the meta object. */ public function updateMetaKey(string $key, $value): self { $meta = (array) ($this->meta ?? new \stdClass()); $meta[$key] = $value; $this->meta = (object) $meta; $this->save(); return $this; } // ========================================================================= // SCOPES // ========================================================================= /** * Scope to filter by status. */ public function scopeWithStatus($query, OrderStatus $status) { return $query->where('status', $status->value); } /** * Scope to filter by multiple statuses. */ public function scopeWithStatuses($query, array $statuses) { return $query->whereIn('status', array_map(fn($s) => $s->value, $statuses)); } /** * Scope to get active orders (not final). */ public function scopeActive($query) { return $query->whereIn('status', [ OrderStatus::PENDING->value, OrderStatus::PROCESSING->value, OrderStatus::ON_HOLD->value, OrderStatus::IN_PREPARATION->value, OrderStatus::READY_FOR_PICKUP->value, OrderStatus::SHIPPED->value, ]); } /** * Scope to get completed orders. */ public function scopeCompleted($query) { return $query->where('status', OrderStatus::COMPLETED->value); } /** * Scope to get paid orders. */ public function scopePaid($query) { return $query->whereColumn('amount_paid', '>=', 'amount_total'); } /** * Scope to get unpaid orders. */ public function scopeUnpaid($query) { return $query->whereColumn('amount_paid', '<', 'amount_total'); } /** * Scope to filter by customer. */ public function scopeForCustomer($query, Model $customer) { return $query->where('customer_type', get_class($customer)) ->where('customer_id', $customer->getKey()); } /** * Scope to filter by date range. */ public function scopeCreatedBetween($query, $from, $until) { return $query->whereBetween('created_at', [$from, $until]); } // ========================================================================= // FACTORY METHODS // ========================================================================= /** * Create an order from a converted cart. */ public static function createFromCart(Cart $cart): self { if (!$cart->converted_at) { throw new \InvalidArgumentException('Cart must be converted before creating an order'); } $order = static::create([ 'cart_id' => $cart->id, 'customer_type' => $cart->customer_type, 'customer_id' => $cart->customer_id, 'currency' => $cart->currency ?? config('shop.currency', 'USD'), 'amount_subtotal' => (int) $cart->getTotal() * 100, 'amount_discount' => 0, // TODO: Calculate from cart discounts 'amount_shipping' => 0, 'amount_tax' => 0, 'amount_total' => (int) $cart->getTotal() * 100, 'amount_paid' => 0, 'amount_refunded' => 0, 'status' => OrderStatus::PENDING, ]); $order->addNote('Order created from cart checkout', 'system', false); return $order; } }