diff --git a/config/shop.php b/config/shop.php
index 045db0f..6414e04 100644
--- a/config/shop.php
+++ b/config/shop.php
@@ -5,6 +5,8 @@ return [
'tables' => [
'cart_items' => 'cart_items',
'carts' => 'carts',
+ 'orders' => 'orders',
+ 'order_notes' => 'order_notes',
'payment_methods' => 'payment_methods',
'payment_provider_identities' => 'payment_provider_identities',
'product_action_runs' => 'product_action_runs',
@@ -29,6 +31,8 @@ return [
'product_purchase' => \Blax\Shop\Models\ProductPurchase::class,
'cart' => \Blax\Shop\Models\Cart::class,
'cart_item' => \Blax\Shop\Models\CartItem::class,
+ 'order' => \Blax\Shop\Models\Order::class,
+ 'order_note' => \Blax\Shop\Models\OrderNote::class,
'payment_provider_identity' => \Blax\Shop\Models\PaymentProviderIdentity::class,
'payment_method' => \Blax\Shop\Models\PaymentMethod::class,
],
@@ -62,6 +66,36 @@ return [
'enabled' => env('SHOP_STRIPE_ENABLED', false),
'sync_prices' => true,
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
+
+ // Webhook events that the shop package listens for
+ // You can customize this list to add/remove events as needed
+ 'webhook_events' => [
+ // Checkout Session Events
+ 'checkout.session.completed',
+ 'checkout.session.async_payment_succeeded',
+ 'checkout.session.async_payment_failed',
+ 'checkout.session.expired',
+
+ // Charge Events
+ 'charge.succeeded',
+ 'charge.failed',
+ 'charge.refunded',
+ 'charge.dispute.created',
+ 'charge.dispute.closed',
+
+ // Payment Intent Events
+ 'payment_intent.succeeded',
+ 'payment_intent.payment_failed',
+ 'payment_intent.canceled',
+
+ // Refund Events
+ 'refund.created',
+ 'refund.updated',
+
+ // Invoice Events (for subscriptions)
+ 'invoice.payment_succeeded',
+ 'invoice.payment_failed',
+ ],
],
// Currency configuration
@@ -81,6 +115,13 @@ return [
'merge_on_login' => true,
],
+ // Order configuration
+ 'orders' => [
+ 'number_prefix' => env('SHOP_ORDER_PREFIX', 'ORD-'),
+ 'auto_complete_virtual' => true, // Auto-complete orders with only virtual products
+ 'auto_complete_paid' => false, // Auto-complete orders when fully paid
+ ],
+
// API Response format
'api' => [
'include_meta' => true,
diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php
new file mode 100644
index 0000000..af3799a
--- /dev/null
+++ b/database/factories/OrderFactory.php
@@ -0,0 +1,237 @@
+faker->numberBetween(1000, 50000); // 10.00 to 500.00
+ $discount = $this->faker->optional(0.3)->numberBetween(100, min(1000, $subtotal));
+ $shipping = $this->faker->optional(0.5)->numberBetween(500, 1500);
+ $tax = (int) (($subtotal - ($discount ?? 0)) * 0.1); // 10% tax
+ $total = $subtotal - ($discount ?? 0) + ($shipping ?? 0) + $tax;
+
+ return [
+ 'order_number' => 'ORD-' . now()->format('Ymd') . str_pad($this->faker->unique()->numberBetween(1, 9999), 4, '0', STR_PAD_LEFT),
+ 'status' => OrderStatus::PENDING,
+ 'currency' => 'EUR',
+ 'amount_subtotal' => $subtotal,
+ 'amount_discount' => $discount ?? 0,
+ 'amount_shipping' => $shipping ?? 0,
+ 'amount_tax' => $tax,
+ 'amount_total' => $total,
+ 'amount_paid' => 0,
+ 'amount_refunded' => 0,
+ ];
+ }
+
+ /**
+ * Associate order with a customer.
+ */
+ public function forCustomer(Model $customer): static
+ {
+ return $this->state([
+ 'customer_type' => get_class($customer),
+ 'customer_id' => $customer->getKey(),
+ ]);
+ }
+
+ /**
+ * Associate order with a cart.
+ */
+ public function forCart(Cart $cart): static
+ {
+ return $this->state([
+ 'cart_id' => $cart->id,
+ 'customer_type' => $cart->customer_type,
+ 'customer_id' => $cart->customer_id,
+ 'currency' => $cart->currency ?? 'EUR',
+ ]);
+ }
+
+ /**
+ * Set order status to pending.
+ */
+ public function pending(): static
+ {
+ return $this->state([
+ 'status' => OrderStatus::PENDING,
+ ]);
+ }
+
+ /**
+ * Set order status to processing.
+ */
+ public function processing(): static
+ {
+ return $this->state([
+ 'status' => OrderStatus::PROCESSING,
+ ]);
+ }
+
+ /**
+ * Set order status to completed.
+ */
+ public function completed(): static
+ {
+ return $this->state([
+ 'status' => OrderStatus::COMPLETED,
+ 'completed_at' => now(),
+ ]);
+ }
+
+ /**
+ * Set order status to shipped.
+ */
+ public function shipped(): static
+ {
+ return $this->state([
+ 'status' => OrderStatus::SHIPPED,
+ 'shipped_at' => now(),
+ ]);
+ }
+
+ /**
+ * Set order status to cancelled.
+ */
+ public function cancelled(): static
+ {
+ return $this->state([
+ 'status' => OrderStatus::CANCELLED,
+ 'cancelled_at' => now(),
+ ]);
+ }
+
+ /**
+ * Set order as fully paid.
+ */
+ public function paid(): static
+ {
+ return $this->state(function (array $attributes) {
+ return [
+ 'amount_paid' => $attributes['amount_total'] ?? 10000,
+ 'paid_at' => now(),
+ 'status' => OrderStatus::PROCESSING,
+ ];
+ });
+ }
+
+ /**
+ * Set order as partially paid.
+ */
+ public function partiallyPaid(int $amount = null): static
+ {
+ return $this->state(function (array $attributes) use ($amount) {
+ $total = $attributes['amount_total'] ?? 10000;
+ $paid = $amount ?? (int) ($total * 0.5);
+
+ return [
+ 'amount_paid' => min($paid, $total - 1),
+ ];
+ });
+ }
+
+ /**
+ * Set order as refunded.
+ */
+ public function refunded(): static
+ {
+ return $this->state(function (array $attributes) {
+ $total = $attributes['amount_total'] ?? 10000;
+
+ return [
+ 'status' => OrderStatus::REFUNDED,
+ 'amount_paid' => $total,
+ 'amount_refunded' => $total,
+ 'paid_at' => now()->subHour(),
+ 'refunded_at' => now(),
+ ];
+ });
+ }
+
+ /**
+ * Add billing address.
+ */
+ public function withBillingAddress(): static
+ {
+ return $this->state([
+ 'billing_address' => (object) [
+ 'first_name' => $this->faker->firstName(),
+ 'last_name' => $this->faker->lastName(),
+ 'company' => $this->faker->optional()->company(),
+ 'address_1' => $this->faker->streetAddress(),
+ 'address_2' => $this->faker->optional()->secondaryAddress(),
+ 'city' => $this->faker->city(),
+ 'state' => $this->faker->stateAbbr(),
+ 'postcode' => $this->faker->postcode(),
+ 'country' => $this->faker->countryCode(),
+ 'email' => $this->faker->email(),
+ 'phone' => $this->faker->phoneNumber(),
+ ],
+ ]);
+ }
+
+ /**
+ * Add shipping address.
+ */
+ public function withShippingAddress(): static
+ {
+ return $this->state([
+ 'shipping_address' => (object) [
+ 'first_name' => $this->faker->firstName(),
+ 'last_name' => $this->faker->lastName(),
+ 'company' => $this->faker->optional()->company(),
+ 'address_1' => $this->faker->streetAddress(),
+ 'address_2' => $this->faker->optional()->secondaryAddress(),
+ 'city' => $this->faker->city(),
+ 'state' => $this->faker->stateAbbr(),
+ 'postcode' => $this->faker->postcode(),
+ 'country' => $this->faker->countryCode(),
+ ],
+ ]);
+ }
+
+ /**
+ * Set specific amounts.
+ */
+ public function withAmounts(
+ int $subtotal,
+ int $discount = 0,
+ int $shipping = 0,
+ int $tax = 0
+ ): static {
+ $total = $subtotal - $discount + $shipping + $tax;
+
+ return $this->state([
+ 'amount_subtotal' => $subtotal,
+ 'amount_discount' => $discount,
+ 'amount_shipping' => $shipping,
+ 'amount_tax' => $tax,
+ 'amount_total' => $total,
+ ]);
+ }
+
+ /**
+ * Add payment information.
+ */
+ public function withPayment(
+ string $method = 'card',
+ string $provider = 'stripe',
+ ?string $reference = null
+ ): static {
+ return $this->state([
+ 'payment_method' => $method,
+ 'payment_provider' => $provider,
+ 'payment_reference' => $reference ?? 'pi_' . $this->faker->regexify('[A-Za-z0-9]{24}'),
+ ]);
+ }
+}
diff --git a/database/factories/OrderNoteFactory.php b/database/factories/OrderNoteFactory.php
new file mode 100644
index 0000000..66a2995
--- /dev/null
+++ b/database/factories/OrderNoteFactory.php
@@ -0,0 +1,151 @@
+ $this->faker->sentence(),
+ 'type' => OrderNote::TYPE_NOTE,
+ 'is_customer_note' => false,
+ ];
+ }
+
+ /**
+ * Associate note with an order.
+ */
+ public function forOrder(Order $order): static
+ {
+ return $this->state([
+ 'order_id' => $order->id,
+ ]);
+ }
+
+ /**
+ * Set the author of the note.
+ */
+ public function byAuthor(Model $author): static
+ {
+ return $this->state([
+ 'author_type' => get_class($author),
+ 'author_id' => $author->getKey(),
+ ]);
+ }
+
+ /**
+ * Make it a customer-visible note.
+ */
+ public function forCustomer(): static
+ {
+ return $this->state([
+ 'is_customer_note' => true,
+ ]);
+ }
+
+ /**
+ * Make it an internal note.
+ */
+ public function internal(): static
+ {
+ return $this->state([
+ 'is_customer_note' => false,
+ ]);
+ }
+
+ /**
+ * Set as status change note.
+ */
+ public function statusChange(): static
+ {
+ return $this->state([
+ 'type' => OrderNote::TYPE_STATUS_CHANGE,
+ 'content' => 'Order status changed',
+ ]);
+ }
+
+ /**
+ * Set as payment note.
+ */
+ public function payment(int $amount = null): static
+ {
+ return $this->state([
+ 'type' => OrderNote::TYPE_PAYMENT,
+ 'content' => 'Payment received: ' . ($amount ? number_format($amount / 100, 2) : '100.00'),
+ 'meta' => $amount ? (object) ['amount' => $amount] : null,
+ ]);
+ }
+
+ /**
+ * Set as refund note.
+ */
+ public function refund(int $amount = null, string $reason = null): static
+ {
+ $content = 'Refund processed: ' . ($amount ? number_format($amount / 100, 2) : '50.00');
+ if ($reason) {
+ $content .= " - Reason: {$reason}";
+ }
+
+ return $this->state([
+ 'type' => OrderNote::TYPE_REFUND,
+ 'content' => $content,
+ 'meta' => (object) array_filter([
+ 'amount' => $amount,
+ 'reason' => $reason,
+ ]),
+ ]);
+ }
+
+ /**
+ * Set as shipping note.
+ */
+ public function shipping(string $trackingNumber = null, string $carrier = null): static
+ {
+ $content = 'Order shipped';
+ if ($trackingNumber) {
+ $content .= " with tracking: {$trackingNumber}";
+ }
+ if ($carrier) {
+ $content .= " via {$carrier}";
+ }
+
+ return $this->state([
+ 'type' => OrderNote::TYPE_SHIPPING,
+ 'content' => $content,
+ 'is_customer_note' => true,
+ 'meta' => (object) array_filter([
+ 'tracking_number' => $trackingNumber,
+ 'carrier' => $carrier,
+ ]),
+ ]);
+ }
+
+ /**
+ * Set as system note.
+ */
+ public function system(): static
+ {
+ return $this->state([
+ 'type' => OrderNote::TYPE_SYSTEM,
+ ]);
+ }
+
+ /**
+ * Set as customer message.
+ */
+ public function customerMessage(): static
+ {
+ return $this->state([
+ 'type' => OrderNote::TYPE_CUSTOMER,
+ 'is_customer_note' => true,
+ ]);
+ }
+}
diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub
index 67d6283..59be724 100644
--- a/database/migrations/create_blax_shop_tables.php.stub
+++ b/database/migrations/create_blax_shop_tables.php.stub
@@ -371,6 +371,95 @@ return new class extends Migration
$table->foreign('product_purchase_id')->references('id')->on(config('shop.tables.product_purchases', 'product_purchases'))->onDelete('set null');
});
}
+
+ // Orders table - represents converted/purchased carts
+ if (!Schema::hasTable(config('shop.tables.orders', 'orders'))) {
+ Schema::create(config('shop.tables.orders', 'orders'), function (Blueprint $table) {
+ $table->uuid('id')->primary();
+ $table->string('order_number')->unique();
+ $table->uuid('cart_id')->nullable();
+ $table->nullableUuidMorphs('customer');
+ $table->string('status')->default('pending');
+ $table->string('currency', 3)->default('USD');
+
+ // Financial fields (all stored in smallest currency unit - cents)
+ $table->unsignedBigInteger('amount_subtotal')->default(0);
+ $table->unsignedBigInteger('amount_discount')->default(0);
+ $table->unsignedBigInteger('amount_shipping')->default(0);
+ $table->unsignedBigInteger('amount_tax')->default(0);
+ $table->unsignedBigInteger('amount_total')->default(0);
+ $table->unsignedBigInteger('amount_paid')->default(0);
+ $table->unsignedBigInteger('amount_refunded')->default(0);
+
+ // Payment information
+ $table->string('payment_method')->nullable();
+ $table->string('payment_provider')->nullable();
+ $table->string('payment_reference')->nullable();
+
+ // Addresses (stored as JSON)
+ $table->json('billing_address')->nullable();
+ $table->json('shipping_address')->nullable();
+
+ // Notes
+ $table->text('customer_note')->nullable();
+ $table->text('internal_note')->nullable();
+
+ // Tracking metadata
+ $table->string('ip_address')->nullable();
+ $table->text('user_agent')->nullable();
+
+ // Important timestamps
+ $table->timestamp('completed_at')->nullable();
+ $table->timestamp('paid_at')->nullable();
+ $table->timestamp('shipped_at')->nullable();
+ $table->timestamp('delivered_at')->nullable();
+ $table->timestamp('cancelled_at')->nullable();
+ $table->timestamp('refunded_at')->nullable();
+
+ // Extensible metadata
+ $table->json('meta')->nullable();
+
+ $table->timestamps();
+ $table->softDeletes();
+
+ // Indexes
+ $table->index(['customer_type', 'customer_id', 'status'], 'orders_customer_status_idx');
+ $table->index(['status', 'created_at']);
+ $table->index(['order_number']);
+ $table->index('cart_id');
+
+ // Foreign key to carts
+ $table->foreign('cart_id')
+ ->references('id')
+ ->on(config('shop.tables.carts', 'carts'))
+ ->nullOnDelete();
+ });
+ }
+
+ // Order notes table - activity log for orders (like WooCommerce order notes)
+ if (!Schema::hasTable(config('shop.tables.order_notes', 'order_notes'))) {
+ Schema::create(config('shop.tables.order_notes', 'order_notes'), function (Blueprint $table) {
+ $table->uuid('id')->primary();
+ $table->uuid('order_id');
+ $table->nullableUuidMorphs('author'); // Who created the note
+ $table->text('content');
+ $table->string('type')->default('note'); // note, status_change, payment, refund, shipping, customer, system
+ $table->boolean('is_customer_note')->default(false); // Whether visible to customer
+ $table->json('meta')->nullable();
+ $table->timestamps();
+
+ // Indexes
+ $table->index(['order_id', 'type']);
+ $table->index(['order_id', 'is_customer_note']);
+ $table->index(['order_id', 'created_at']);
+
+ // Foreign key to orders
+ $table->foreign('order_id')
+ ->references('id')
+ ->on(config('shop.tables.orders', 'orders'))
+ ->onDelete('cascade');
+ });
+ }
}
/**
@@ -378,6 +467,8 @@ return new class extends Migration
*/
public function down(): void
{
+ Schema::dropIfExists(config('shop.tables.order_notes', 'order_notes'));
+ Schema::dropIfExists(config('shop.tables.orders', 'orders'));
Schema::dropIfExists(config('shop.tables.payment_methods', 'payment_methods'));
Schema::dropIfExists(config('shop.tables.payment_provider_identities', 'payment_provider_identities'));
Schema::dropIfExists(config('shop.tables.cart_discounts', 'cart_discounts'));
diff --git a/src/Console/Commands/ShopSetupStripeWebhooksCommand.php b/src/Console/Commands/ShopSetupStripeWebhooksCommand.php
new file mode 100644
index 0000000..7e27c38
--- /dev/null
+++ b/src/Console/Commands/ShopSetupStripeWebhooksCommand.php
@@ -0,0 +1,284 @@
+error('Stripe is not enabled. Please set STRIPE_ENABLED=true in your .env file.');
+ return Command::FAILURE;
+ }
+
+ $stripeSecret = config('services.stripe.secret');
+ if (!$stripeSecret) {
+ $this->error('Stripe secret key is not configured. Please set STRIPE_SECRET in your .env file.');
+ return Command::FAILURE;
+ }
+
+ Stripe::setApiKey($stripeSecret);
+
+ // Handle different operations
+ if ($this->option('list')) {
+ return $this->listWebhooks();
+ }
+
+ if ($webhookId = $this->option('delete')) {
+ return $this->deleteWebhook($webhookId);
+ }
+
+ if ($webhookId = $this->option('update')) {
+ return $this->updateWebhook($webhookId);
+ }
+
+ return $this->createWebhook();
+ }
+
+ /**
+ * List all existing webhook endpoints
+ */
+ protected function listWebhooks(): int
+ {
+ $this->info('Fetching existing Stripe webhooks...');
+
+ try {
+ $webhooks = WebhookEndpoint::all(['limit' => 100]);
+
+ if (empty($webhooks->data)) {
+ $this->warn('No webhook endpoints found.');
+ return Command::SUCCESS;
+ }
+
+ $rows = [];
+ foreach ($webhooks->data as $webhook) {
+ $rows[] = [
+ $webhook->id,
+ $webhook->url,
+ $webhook->status,
+ count($webhook->enabled_events) . ' events',
+ $webhook->livemode ? 'Live' : 'Test',
+ ];
+ }
+
+ $this->table(
+ ['ID', 'URL', 'Status', 'Events', 'Mode'],
+ $rows
+ );
+
+ return Command::SUCCESS;
+ } catch (\Exception $e) {
+ $this->error('Failed to list webhooks: ' . $e->getMessage());
+ return Command::FAILURE;
+ }
+ }
+
+ /**
+ * Delete a webhook endpoint
+ */
+ protected function deleteWebhook(string $webhookId): int
+ {
+ $this->info("Deleting webhook: {$webhookId}");
+
+ try {
+ $webhook = WebhookEndpoint::retrieve($webhookId);
+ $webhook->delete();
+
+ $this->info('✓ Webhook deleted successfully.');
+ return Command::SUCCESS;
+ } catch (\Exception $e) {
+ $this->error('Failed to delete webhook: ' . $e->getMessage());
+ return Command::FAILURE;
+ }
+ }
+
+ /**
+ * Update an existing webhook endpoint with the required events
+ */
+ protected function updateWebhook(string $webhookId): int
+ {
+ $this->info("Updating webhook: {$webhookId}");
+
+ try {
+ $webhook = WebhookEndpoint::retrieve($webhookId);
+ $webhook->update($webhookId, [
+ 'enabled_events' => $this->requiredEvents,
+ ]);
+
+ $this->info('✓ Webhook updated successfully with ' . count($this->requiredEvents) . ' events.');
+ $this->displayEvents();
+
+ return Command::SUCCESS;
+ } catch (\Exception $e) {
+ $this->error('Failed to update webhook: ' . $e->getMessage());
+ return Command::FAILURE;
+ }
+ }
+
+ /**
+ * Create a new webhook endpoint
+ */
+ protected function createWebhook(): int
+ {
+ $url = $this->option('url') ?? $this->getDefaultWebhookUrl();
+
+ $this->info('Creating Stripe webhook endpoint...');
+ $this->line("URL: {$url}");
+ $this->newLine();
+
+ // Validate URL
+ if (!filter_var($url, FILTER_VALIDATE_URL)) {
+ $this->error('Invalid webhook URL. Please provide a valid URL.');
+ return Command::FAILURE;
+ }
+
+ if (str_starts_with($url, 'http://') && !str_contains($url, 'localhost')) {
+ $this->warn('Warning: Stripe recommends using HTTPS for webhook endpoints in production.');
+ if (!$this->confirm('Do you want to continue with HTTP?')) {
+ return Command::FAILURE;
+ }
+ }
+
+ // Check for existing webhooks with same URL
+ try {
+ $existingWebhooks = WebhookEndpoint::all(['limit' => 100]);
+ foreach ($existingWebhooks->data as $webhook) {
+ if ($webhook->url === $url) {
+ $this->warn("A webhook with this URL already exists (ID: {$webhook->id})");
+ if ($this->confirm('Do you want to update it instead?')) {
+ return $this->updateWebhook($webhook->id);
+ }
+ if (!$this->confirm('Do you want to create a new one anyway?')) {
+ return Command::SUCCESS;
+ }
+ }
+ }
+ } catch (\Exception $e) {
+ // Continue if we can't check existing webhooks
+ }
+
+ // Display events to be registered
+ $this->info('Events to be registered:');
+ $this->displayEvents();
+ $this->newLine();
+
+ if (!$this->confirm('Do you want to create this webhook endpoint?')) {
+ $this->info('Aborted.');
+ return Command::SUCCESS;
+ }
+
+ try {
+ $webhook = WebhookEndpoint::create([
+ 'url' => $url,
+ 'enabled_events' => $this->requiredEvents,
+ 'description' => 'Laravel Shop package webhook endpoint',
+ ]);
+
+ $this->newLine();
+ $this->info('✓ Webhook endpoint created successfully!');
+ $this->newLine();
+
+ $this->table(
+ ['Property', 'Value'],
+ [
+ ['Webhook ID', $webhook->id],
+ ['URL', $webhook->url],
+ ['Status', $webhook->status],
+ ['Mode', $webhook->livemode ? 'Live' : 'Test'],
+ ['Events', count($webhook->enabled_events)],
+ ]
+ );
+
+ $this->newLine();
+ $this->warn('⚠ IMPORTANT: Copy the webhook signing secret below and add it to your .env file:');
+ $this->newLine();
+ $this->line("STRIPE_WEBHOOK_SECRET={$webhook->secret}");
+ $this->newLine();
+
+ $this->info('Add this to your config/shop.php or config/services.php:');
+ $this->line("'stripe' => [");
+ $this->line(" 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),");
+ $this->line("],");
+
+ return Command::SUCCESS;
+ } catch (\Exception $e) {
+ $this->error('Failed to create webhook: ' . $e->getMessage());
+ return Command::FAILURE;
+ }
+ }
+
+ /**
+ * Get the default webhook URL based on APP_URL
+ */
+ protected function getDefaultWebhookUrl(): string
+ {
+ $appUrl = rtrim(config('app.url'), '/');
+ $routePrefix = config('shop.routes.prefix', 'api/shop');
+
+ return "{$appUrl}/{$routePrefix}/stripe/webhook";
+ }
+
+ /**
+ * Display the list of events in a formatted way
+ */
+ protected function displayEvents(): void
+ {
+ $groups = [
+ 'Checkout Session' => array_filter($this->requiredEvents, fn($e) => str_starts_with($e, 'checkout.session.')),
+ 'Charge' => array_filter($this->requiredEvents, fn($e) => str_starts_with($e, 'charge.')),
+ 'Payment Intent' => array_filter($this->requiredEvents, fn($e) => str_starts_with($e, 'payment_intent.')),
+ 'Refund' => array_filter($this->requiredEvents, fn($e) => str_starts_with($e, 'refund.')),
+ 'Invoice' => array_filter($this->requiredEvents, fn($e) => str_starts_with($e, 'invoice.')),
+ ];
+
+ foreach ($groups as $group => $events) {
+ $this->line(" {$group}:");
+ foreach ($events as $event) {
+ $this->line(" • {$event}");
+ }
+ }
+ }
+}
diff --git a/src/Enums/OrderStatus.php b/src/Enums/OrderStatus.php
new file mode 100644
index 0000000..ee545b0
--- /dev/null
+++ b/src/Enums/OrderStatus.php
@@ -0,0 +1,227 @@
+ 'Pending Payment',
+ self::PROCESSING => 'Processing',
+ self::ON_HOLD => 'On Hold',
+ self::IN_PREPARATION => 'In Preparation',
+ self::READY_FOR_PICKUP => 'Ready for Pickup',
+ self::SHIPPED => 'Shipped',
+ self::DELIVERED => 'Delivered',
+ self::COMPLETED => 'Completed',
+ self::CANCELLED => 'Cancelled',
+ self::REFUNDED => 'Refunded',
+ self::FAILED => 'Failed',
+ };
+ }
+
+ /**
+ * Get a color code for the status (for UI purposes).
+ */
+ public function color(): string
+ {
+ return match ($this) {
+ self::PENDING => 'yellow',
+ self::PROCESSING => 'blue',
+ self::ON_HOLD => 'orange',
+ self::IN_PREPARATION => 'indigo',
+ self::READY_FOR_PICKUP => 'teal',
+ self::SHIPPED => 'purple',
+ self::DELIVERED => 'green',
+ self::COMPLETED => 'green',
+ self::CANCELLED => 'gray',
+ self::REFUNDED => 'red',
+ self::FAILED => 'red',
+ };
+ }
+
+ /**
+ * Check if this status indicates the order is still active/pending.
+ */
+ public function isActive(): bool
+ {
+ return in_array($this, [
+ self::PENDING,
+ self::PROCESSING,
+ self::ON_HOLD,
+ self::IN_PREPARATION,
+ self::READY_FOR_PICKUP,
+ self::SHIPPED,
+ ]);
+ }
+
+ /**
+ * Check if this status indicates the order is finalized.
+ */
+ public function isFinal(): bool
+ {
+ return in_array($this, [
+ self::COMPLETED,
+ self::CANCELLED,
+ self::REFUNDED,
+ self::FAILED,
+ self::DELIVERED,
+ ]);
+ }
+
+ /**
+ * Check if this status requires payment.
+ */
+ public function requiresPayment(): bool
+ {
+ return $this === self::PENDING;
+ }
+
+ /**
+ * Check if this status indicates successful payment.
+ */
+ public function isPaid(): bool
+ {
+ return in_array($this, [
+ self::PROCESSING,
+ self::IN_PREPARATION,
+ self::READY_FOR_PICKUP,
+ self::SHIPPED,
+ self::DELIVERED,
+ self::COMPLETED,
+ ]);
+ }
+
+ /**
+ * Get valid transitions from this status.
+ *
+ * @return array
+ */
+ public function allowedTransitions(): array
+ {
+ return match ($this) {
+ self::PENDING => [
+ self::PROCESSING,
+ self::ON_HOLD,
+ self::CANCELLED,
+ self::FAILED,
+ ],
+ self::PROCESSING => [
+ self::IN_PREPARATION,
+ self::READY_FOR_PICKUP,
+ self::SHIPPED,
+ self::COMPLETED,
+ self::ON_HOLD,
+ self::CANCELLED,
+ self::REFUNDED,
+ ],
+ self::ON_HOLD => [
+ self::PENDING,
+ self::PROCESSING,
+ self::CANCELLED,
+ ],
+ self::IN_PREPARATION => [
+ self::READY_FOR_PICKUP,
+ self::SHIPPED,
+ self::COMPLETED,
+ self::ON_HOLD,
+ self::CANCELLED,
+ ],
+ self::READY_FOR_PICKUP => [
+ self::COMPLETED,
+ self::DELIVERED,
+ self::ON_HOLD,
+ self::CANCELLED,
+ ],
+ self::SHIPPED => [
+ self::DELIVERED,
+ self::COMPLETED,
+ self::REFUNDED,
+ ],
+ self::DELIVERED => [
+ self::COMPLETED,
+ self::REFUNDED,
+ ],
+ self::COMPLETED => [
+ self::REFUNDED,
+ ],
+ self::CANCELLED => [],
+ self::REFUNDED => [],
+ self::FAILED => [
+ self::PENDING,
+ ],
+ };
+ }
+
+ /**
+ * Check if a transition to the given status is allowed.
+ */
+ public function canTransitionTo(OrderStatus $status): bool
+ {
+ return in_array($status, $this->allowedTransitions());
+ }
+}
diff --git a/src/Http/Controllers/StripeWebhookController.php b/src/Http/Controllers/StripeWebhookController.php
index d47ba44..d0d29eb 100644
--- a/src/Http/Controllers/StripeWebhookController.php
+++ b/src/Http/Controllers/StripeWebhookController.php
@@ -2,15 +2,18 @@
namespace Blax\Shop\Http\Controllers;
-use Blax\Shop\Models\Cart;
-use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Enums\CartStatus;
+use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Enums\PurchaseStatus;
+use Blax\Shop\Models\Cart;
+use Blax\Shop\Models\Order;
+use Blax\Shop\Models\OrderNote;
+use Blax\Shop\Models\ProductPurchase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
+use Stripe\Exception\SignatureVerificationException;
use Stripe\Stripe;
use Stripe\Webhook;
-use Stripe\Exception\SignatureVerificationException;
class StripeWebhookController
{
@@ -32,7 +35,7 @@ class StripeWebhookController
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
- $webhookSecret = config('services.stripe.webhook_secret');
+ $webhookSecret = config('shop.stripe.webhook_secret') ?? config('services.stripe.webhook_secret');
try {
// Verify webhook signature
@@ -41,6 +44,7 @@ class StripeWebhookController
} else {
// If no webhook secret, parse the event directly (not recommended for production)
$event = json_decode($payload);
+ Log::warning('Stripe webhook received without signature verification - not recommended for production');
}
} catch (\UnexpectedValueException $e) {
Log::error('Stripe webhook invalid payload', ['error' => $e->getMessage()]);
@@ -52,40 +56,37 @@ class StripeWebhookController
// Handle the event
try {
- switch ($event->type) {
- case 'checkout.session.completed':
- $this->handleCheckoutSessionCompleted($event->data->object);
- break;
+ $handled = match ($event->type) {
+ // Checkout Session Events
+ 'checkout.session.completed' => $this->handleCheckoutSessionCompleted($event->data->object),
+ 'checkout.session.async_payment_succeeded' => $this->handleCheckoutSessionCompleted($event->data->object),
+ 'checkout.session.async_payment_failed' => $this->handleCheckoutSessionFailed($event->data->object),
+ 'checkout.session.expired' => $this->handleCheckoutSessionExpired($event->data->object),
- case 'checkout.session.async_payment_succeeded':
- $this->handleCheckoutSessionCompleted($event->data->object);
- break;
+ // Charge Events
+ 'charge.succeeded' => $this->handleChargeSucceeded($event->data->object),
+ 'charge.failed' => $this->handleChargeFailed($event->data->object),
+ 'charge.refunded' => $this->handleChargeRefunded($event->data->object),
+ 'charge.dispute.created' => $this->handleChargeDisputeCreated($event->data->object),
+ 'charge.dispute.closed' => $this->handleChargeDisputeClosed($event->data->object),
- case 'checkout.session.async_payment_failed':
- $this->handleCheckoutSessionFailed($event->data->object);
- break;
+ // Payment Intent Events
+ 'payment_intent.succeeded' => $this->handlePaymentIntentSucceeded($event->data->object),
+ 'payment_intent.payment_failed' => $this->handlePaymentIntentFailed($event->data->object),
+ 'payment_intent.canceled' => $this->handlePaymentIntentCanceled($event->data->object),
- case 'charge.succeeded':
- $this->handleChargeSucceeded($event->data->object);
- break;
+ // Refund Events
+ 'refund.created' => $this->handleRefundCreated($event->data->object),
+ 'refund.updated' => $this->handleRefundUpdated($event->data->object),
- case 'charge.failed':
- $this->handleChargeFailed($event->data->object);
- break;
+ // Invoice Events (for subscriptions)
+ 'invoice.payment_succeeded' => $this->handleInvoicePaymentSucceeded($event->data->object),
+ 'invoice.payment_failed' => $this->handleInvoicePaymentFailed($event->data->object),
- case 'payment_intent.succeeded':
- $this->handlePaymentIntentSucceeded($event->data->object);
- break;
+ default => $this->handleUnknownEvent($event->type),
+ };
- case 'payment_intent.payment_failed':
- $this->handlePaymentIntentFailed($event->data->object);
- break;
-
- default:
- Log::info('Stripe webhook unhandled event type', ['type' => $event->type]);
- }
-
- return response()->json(['success' => true]);
+ return response()->json(['success' => true, 'handled' => $handled]);
} catch (\Exception $e) {
Log::error('Stripe webhook handler failed', [
'type' => $event->type,
@@ -97,22 +98,31 @@ class StripeWebhookController
}
}
+ /**
+ * Handle unknown/unhandled event types
+ */
+ protected function handleUnknownEvent(string $type): bool
+ {
+ Log::info('Stripe webhook unhandled event type', ['type' => $type]);
+ return false;
+ }
+
/**
* Handle checkout.session.completed event
*/
- protected function handleCheckoutSessionCompleted($session)
+ protected function handleCheckoutSessionCompleted($session): bool
{
$cartId = $session->metadata->cart_id ?? $session->client_reference_id;
if (!$cartId) {
Log::warning('Stripe checkout session completed without cart ID', ['session_id' => $session->id]);
- return;
+ return false;
}
$cart = Cart::find($cartId);
if (!$cart) {
Log::warning('Stripe checkout session for non-existent cart', ['cart_id' => $cartId]);
- return;
+ return false;
}
// Only update if not already converted
@@ -130,18 +140,62 @@ class StripeWebhookController
'session_id' => $session->id,
]);
}
+
+ // Record payment on the associated order
+ $order = $cart->order;
+ if ($order) {
+ $amountPaid = (int) (($session->amount_total ?? 0) / 100);
+ $currency = strtoupper($session->currency ?? $order->currency ?? 'USD');
+
+ // recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider)
+ $order->recordPayment($amountPaid, $session->payment_intent, 'stripe', 'stripe');
+
+ // Add a detailed note
+ $order->addNote(
+ "Payment of " . Order::formatMoney($amountPaid, $currency) . " received via Stripe checkout (Session: {$session->id})",
+ OrderNote::TYPE_PAYMENT
+ );
+
+ // Mark order as processing if payment is successful
+ if ($session->payment_status === 'paid' && $order->status === OrderStatus::PENDING) {
+ $order->markAsProcessing('Payment received via Stripe checkout');
+ }
+
+ Log::info('Order payment recorded via Stripe checkout', [
+ 'order_id' => $order->id,
+ 'order_number' => $order->order_number,
+ 'amount' => $amountPaid,
+ 'currency' => $currency,
+ ]);
+ }
+
+ return true;
}
/**
* Handle checkout.session failed event
*/
- protected function handleCheckoutSessionFailed($session)
+ protected function handleCheckoutSessionFailed($session): bool
{
$cartId = $session->metadata->cart_id ?? $session->client_reference_id;
if (!$cartId) {
Log::warning('Stripe checkout session failed without cart ID', ['session_id' => $session->id]);
- return;
+ return false;
+ }
+
+ $cart = Cart::find($cartId);
+ if ($cart) {
+ // Mark order as failed if it exists
+ $order = $cart->order;
+ if ($order && $order->status->canTransitionTo(OrderStatus::FAILED)) {
+ $order->update(['status' => OrderStatus::FAILED]);
+ // addNote(string $content, string $type, bool $isCustomerNote, ?string $authorType, ?string $authorId)
+ $order->addNote(
+ "Payment failed via Stripe checkout (Session: {$session->id})",
+ OrderNote::TYPE_PAYMENT
+ );
+ }
}
Log::info('Stripe checkout session failed', [
@@ -149,13 +203,42 @@ class StripeWebhookController
'session_id' => $session->id,
]);
- // Cart remains in active state for retry
+ return true;
+ }
+
+ /**
+ * Handle checkout.session.expired event
+ */
+ protected function handleCheckoutSessionExpired($session): bool
+ {
+ $cartId = $session->metadata->cart_id ?? $session->client_reference_id;
+
+ if ($cartId) {
+ $cart = Cart::find($cartId);
+ if ($cart) {
+ // Add note to order if it exists
+ $order = $cart->order;
+ if ($order) {
+ $order->addNote(
+ "Stripe checkout session expired (Session: {$session->id})",
+ OrderNote::TYPE_SYSTEM
+ );
+ }
+ }
+ }
+
+ Log::info('Stripe checkout session expired', [
+ 'cart_id' => $cartId,
+ 'session_id' => $session->id,
+ ]);
+
+ return true;
}
/**
* Handle charge.succeeded event
*/
- protected function handleChargeSucceeded($charge)
+ protected function handleChargeSucceeded($charge): bool
{
Log::info('Stripe charge succeeded', [
'charge_id' => $charge->id,
@@ -180,12 +263,22 @@ class StripeWebhookController
$this->claimStockForPurchase($purchase);
}
}
+
+ // Try to find related order via payment_intent
+ $order = $this->findOrderByPaymentIntent($charge->payment_intent);
+ if ($order && !$order->is_fully_paid) {
+ $amountPaid = (int) ($charge->amount / 100);
+ // recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider)
+ $order->recordPayment($amountPaid, $charge->id, 'stripe', 'stripe');
+ }
+
+ return true;
}
/**
* Handle charge.failed event
*/
- protected function handleChargeFailed($charge)
+ protected function handleChargeFailed($charge): bool
{
Log::warning('Stripe charge failed', [
'charge_id' => $charge->id,
@@ -199,12 +292,107 @@ class StripeWebhookController
'status' => PurchaseStatus::FAILED,
]);
}
+
+ // Try to find related order and add note
+ $order = $this->findOrderByPaymentIntent($charge->payment_intent);
+ if ($order) {
+ $order->addNote(
+ 'Stripe charge failed: ' . ($charge->failure_message ?? 'Unknown error') .
+ ' (Charge: ' . $charge->id . ', Code: ' . ($charge->failure_code ?? 'none') . ')',
+ OrderNote::TYPE_PAYMENT
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle charge.refunded event
+ */
+ protected function handleChargeRefunded($charge): bool
+ {
+ Log::info('Stripe charge refunded', [
+ 'charge_id' => $charge->id,
+ 'amount_refunded' => $charge->amount_refunded,
+ ]);
+
+ // Find order and record refund
+ $order = $this->findOrderByPaymentIntent($charge->payment_intent);
+ if ($order) {
+ $refundAmount = (int) ($charge->amount_refunded / 100);
+
+ // Only record refund if the amount changed
+ if ($refundAmount > 0 && $order->amount_refunded < $refundAmount) {
+ $newRefundAmount = $refundAmount - $order->amount_refunded;
+ // recordRefund(int $amount, ?string $reason)
+ $order->recordRefund($newRefundAmount, "Refund processed via Stripe (Charge: {$charge->id})");
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle charge.dispute.created event
+ */
+ protected function handleChargeDisputeCreated($dispute): bool
+ {
+ Log::warning('Stripe dispute created', [
+ 'dispute_id' => $dispute->id,
+ 'charge_id' => $dispute->charge,
+ 'amount' => $dispute->amount,
+ 'reason' => $dispute->reason,
+ ]);
+
+ // Try to find order via the charge
+ $order = $this->findOrderByChargeId($dispute->charge);
+ if ($order) {
+ $order->update(['status' => OrderStatus::ON_HOLD]);
+ $disputeAmount = ($dispute->amount ?? 0) / 100;
+ $order->addNote(
+ 'Payment dispute opened: ' . ($dispute->reason ?? 'Unknown reason') .
+ " (Dispute: {$dispute->id}, Amount: " . Order::formatMoney($disputeAmount, $order->currency) . ')',
+ OrderNote::TYPE_PAYMENT
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle charge.dispute.closed event
+ */
+ protected function handleChargeDisputeClosed($dispute): bool
+ {
+ Log::info('Stripe dispute closed', [
+ 'dispute_id' => $dispute->id,
+ 'status' => $dispute->status,
+ ]);
+
+ $order = $this->findOrderByChargeId($dispute->charge);
+ if ($order) {
+ $outcome = $dispute->status === 'won' ? 'in your favor' : 'against you';
+ $order->addNote(
+ "Payment dispute closed {$outcome} (Dispute: {$dispute->id})",
+ OrderNote::TYPE_PAYMENT
+ );
+
+ // If dispute was lost, mark as refunded
+ if ($dispute->status === 'lost' && $order->status === OrderStatus::ON_HOLD) {
+ $order->update(['status' => OrderStatus::REFUNDED]);
+ } elseif ($dispute->status === 'won' && $order->status === OrderStatus::ON_HOLD) {
+ // Restore to processing if dispute was won
+ $order->update(['status' => OrderStatus::PROCESSING]);
+ }
+ }
+
+ return true;
}
/**
* Handle payment_intent.succeeded event
*/
- protected function handlePaymentIntentSucceeded($paymentIntent)
+ protected function handlePaymentIntentSucceeded($paymentIntent): bool
{
Log::info('Stripe payment intent succeeded', [
'payment_intent_id' => $paymentIntent->id,
@@ -229,12 +417,14 @@ class StripeWebhookController
$this->claimStockForPurchase($purchase);
}
}
+
+ return true;
}
/**
* Handle payment_intent.payment_failed event
*/
- protected function handlePaymentIntentFailed($paymentIntent)
+ protected function handlePaymentIntentFailed($paymentIntent): bool
{
Log::warning('Stripe payment intent failed', [
'payment_intent_id' => $paymentIntent->id,
@@ -247,6 +437,174 @@ class StripeWebhookController
'status' => PurchaseStatus::FAILED,
]);
}
+
+ return true;
+ }
+
+ /**
+ * Handle payment_intent.canceled event
+ */
+ protected function handlePaymentIntentCanceled($paymentIntent): bool
+ {
+ Log::info('Stripe payment intent canceled', [
+ 'payment_intent_id' => $paymentIntent->id,
+ ]);
+
+ $order = $this->findOrderByPaymentIntent($paymentIntent->id);
+ if ($order) {
+ $order->addNote(
+ "Payment intent was canceled (Intent: {$paymentIntent->id})",
+ OrderNote::TYPE_PAYMENT
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle refund.created event
+ */
+ protected function handleRefundCreated($refund): bool
+ {
+ Log::info('Stripe refund created', [
+ 'refund_id' => $refund->id,
+ 'charge_id' => $refund->charge,
+ 'amount' => $refund->amount,
+ ]);
+
+ $order = $this->findOrderByChargeId($refund->charge);
+ if ($order) {
+ $refundAmount = (int) ($refund->amount / 100);
+ // recordRefund(int $amount, ?string $reason)
+ $order->recordRefund($refundAmount, ($refund->reason ?? 'Refund created') . " (Refund: {$refund->id})");
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle refund.updated event
+ */
+ protected function handleRefundUpdated($refund): bool
+ {
+ Log::info('Stripe refund updated', [
+ 'refund_id' => $refund->id,
+ 'status' => $refund->status,
+ ]);
+
+ $order = $this->findOrderByChargeId($refund->charge);
+ if ($order) {
+ $order->addNote(
+ "Refund status updated to: {$refund->status} (Refund: {$refund->id})",
+ OrderNote::TYPE_REFUND
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle invoice.payment_succeeded event (for subscriptions)
+ */
+ protected function handleInvoicePaymentSucceeded($invoice): bool
+ {
+ Log::info('Stripe invoice payment succeeded', [
+ 'invoice_id' => $invoice->id,
+ 'subscription_id' => $invoice->subscription ?? null,
+ 'amount_paid' => $invoice->amount_paid,
+ ]);
+
+ // Invoice events are typically for subscriptions
+ // Add order note if we can find the related order
+ if ($invoice->metadata->order_id ?? null) {
+ $order = Order::find($invoice->metadata->order_id);
+ if ($order) {
+ $amountPaid = ($invoice->amount_paid ?? 0) / 100;
+ $order->addNote(
+ "Subscription invoice paid: " . Order::formatMoney($amountPaid, $order->currency) . " (Invoice: {$invoice->id})",
+ OrderNote::TYPE_PAYMENT
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle invoice.payment_failed event (for subscriptions)
+ */
+ protected function handleInvoicePaymentFailed($invoice): bool
+ {
+ Log::warning('Stripe invoice payment failed', [
+ 'invoice_id' => $invoice->id,
+ 'subscription_id' => $invoice->subscription ?? null,
+ ]);
+
+ if ($invoice->metadata->order_id ?? null) {
+ $order = Order::find($invoice->metadata->order_id);
+ if ($order) {
+ $order->addNote(
+ "Subscription invoice payment failed (Invoice: {$invoice->id})",
+ OrderNote::TYPE_PAYMENT
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Find an order by payment intent ID
+ */
+ protected function findOrderByPaymentIntent(?string $paymentIntentId): ?Order
+ {
+ if (!$paymentIntentId) {
+ return null;
+ }
+
+ // First try to find via order's payment_reference
+ $order = Order::where('payment_reference', $paymentIntentId)->first();
+ if ($order) {
+ return $order;
+ }
+
+ // Try to find via cart's stripe session meta
+ $cart = Cart::whereJsonContains('meta->stripe_payment_intent', $paymentIntentId)->first();
+ if ($cart) {
+ return $cart->order;
+ }
+
+ // Try to find via purchase charge_id
+ $purchase = ProductPurchase::where('charge_id', $paymentIntentId)->first();
+ if ($purchase && $purchase->cart) {
+ return $purchase->cart->order;
+ }
+
+ return null;
+ }
+
+ /**
+ * Find an order by charge ID
+ */
+ protected function findOrderByChargeId(?string $chargeId): ?Order
+ {
+ if (!$chargeId) {
+ return null;
+ }
+
+ // Try to find order where payment_reference contains the charge
+ $order = Order::where('payment_reference', $chargeId)->first();
+ if ($order) {
+ return $order;
+ }
+
+ // Try to find via purchase charge_id
+ $purchase = ProductPurchase::where('charge_id', $chargeId)->first();
+ if ($purchase && $purchase->cart) {
+ return $purchase->cart->order;
+ }
+
+ return null;
}
/**
diff --git a/src/Models/Cart.php b/src/Models/Cart.php
index 9426a2d..a384a21 100644
--- a/src/Models/Cart.php
+++ b/src/Models/Cart.php
@@ -95,6 +95,14 @@ class Cart extends Model
return $this->hasMany(config('shop.models.product_purchase', \Blax\Shop\Models\ProductPurchase::class), 'cart_id');
}
+ /**
+ * Get the order created from this cart (if converted).
+ */
+ public function order()
+ {
+ return $this->hasOne(config('shop.models.order', \Blax\Shop\Models\Order::class), 'cart_id');
+ }
+
public function getTotal(): float
{
return $this->items()->sum('subtotal');
@@ -1805,8 +1813,12 @@ class Cart extends Model
$this->update([
'converted_at' => now(),
+ 'status' => CartStatus::CONVERTED,
]);
+ // Create an Order from this converted cart
+ $order = Order::createFromCart($this);
+
return $this;
});
}
diff --git a/src/Models/Order.php b/src/Models/Order.php
new file mode 100644
index 0000000..b6f8a47
--- /dev/null
+++ b/src/Models/Order.php
@@ -0,0 +1,631 @@
+ 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;
+ }
+}
diff --git a/src/Models/OrderNote.php b/src/Models/OrderNote.php
new file mode 100644
index 0000000..7622f31
--- /dev/null
+++ b/src/Models/OrderNote.php
@@ -0,0 +1,312 @@
+ 'boolean',
+ 'meta' => 'object',
+ ];
+
+ public function __construct(array $attributes = [])
+ {
+ parent::__construct($attributes);
+ $this->setTable(config('shop.tables.order_notes', 'order_notes'));
+ }
+
+ protected static function booted()
+ {
+ static::creating(function (OrderNote $note) {
+ // Set default type
+ if (empty($note->type)) {
+ $note->type = self::TYPE_NOTE;
+ }
+
+ // Default to internal note
+ if (!isset($note->is_customer_note)) {
+ $note->is_customer_note = false;
+ }
+ });
+ }
+
+ // =========================================================================
+ // RELATIONSHIPS
+ // =========================================================================
+
+ /**
+ * The order this note belongs to.
+ */
+ public function order(): BelongsTo
+ {
+ return $this->belongsTo(config('shop.models.order', Order::class), 'order_id');
+ }
+
+ /**
+ * The author of this note (user, admin, system).
+ */
+ public function author(): MorphTo
+ {
+ return $this->morphTo();
+ }
+
+ // =========================================================================
+ // COMPUTED ATTRIBUTES
+ // =========================================================================
+
+ /**
+ * Get human-readable type label.
+ */
+ public function getTypeLabelAttribute(): string
+ {
+ return match ($this->type) {
+ self::TYPE_NOTE => 'Note',
+ self::TYPE_STATUS_CHANGE => 'Status Change',
+ self::TYPE_PAYMENT => 'Payment',
+ self::TYPE_REFUND => 'Refund',
+ self::TYPE_SHIPPING => 'Shipping',
+ self::TYPE_CUSTOMER => 'Customer Message',
+ self::TYPE_SYSTEM => 'System',
+ self::TYPE_EMAIL => 'Email',
+ self::TYPE_WEBHOOK => 'Webhook',
+ default => ucfirst($this->type),
+ };
+ }
+
+ /**
+ * Get icon for the note type (for UI purposes).
+ */
+ public function getTypeIconAttribute(): string
+ {
+ return match ($this->type) {
+ self::TYPE_NOTE => 'pencil',
+ self::TYPE_STATUS_CHANGE => 'arrow-path',
+ self::TYPE_PAYMENT => 'credit-card',
+ self::TYPE_REFUND => 'arrow-uturn-left',
+ self::TYPE_SHIPPING => 'truck',
+ self::TYPE_CUSTOMER => 'user',
+ self::TYPE_SYSTEM => 'cog',
+ self::TYPE_EMAIL => 'envelope',
+ self::TYPE_WEBHOOK => 'bolt',
+ default => 'information-circle',
+ };
+ }
+
+ /**
+ * Get color for the note type (for UI purposes).
+ */
+ public function getTypeColorAttribute(): string
+ {
+ return match ($this->type) {
+ self::TYPE_NOTE => 'gray',
+ self::TYPE_STATUS_CHANGE => 'blue',
+ self::TYPE_PAYMENT => 'green',
+ self::TYPE_REFUND => 'red',
+ self::TYPE_SHIPPING => 'purple',
+ self::TYPE_CUSTOMER => 'yellow',
+ self::TYPE_SYSTEM => 'indigo',
+ self::TYPE_EMAIL => 'teal',
+ self::TYPE_WEBHOOK => 'orange',
+ default => 'gray',
+ };
+ }
+
+ // =========================================================================
+ // SCOPES
+ // =========================================================================
+
+ /**
+ * Scope to get customer-visible notes.
+ */
+ public function scopeForCustomer($query)
+ {
+ return $query->where('is_customer_note', true);
+ }
+
+ /**
+ * Scope to get internal notes only.
+ */
+ public function scopeInternal($query)
+ {
+ return $query->where('is_customer_note', false);
+ }
+
+ /**
+ * Scope to filter by type.
+ */
+ public function scopeOfType($query, string $type)
+ {
+ return $query->where('type', $type);
+ }
+
+ /**
+ * Scope to filter by multiple types.
+ */
+ public function scopeOfTypes($query, array $types)
+ {
+ return $query->whereIn('type', $types);
+ }
+
+ /**
+ * Scope to get system notes.
+ */
+ public function scopeSystem($query)
+ {
+ return $query->where('type', self::TYPE_SYSTEM);
+ }
+
+ /**
+ * Scope to get payment-related notes.
+ */
+ public function scopePaymentRelated($query)
+ {
+ return $query->whereIn('type', [self::TYPE_PAYMENT, self::TYPE_REFUND]);
+ }
+
+ // =========================================================================
+ // 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;
+ }
+
+ // =========================================================================
+ // FACTORY METHODS
+ // =========================================================================
+
+ /**
+ * Create a system note.
+ */
+ public static function createSystemNote(Order $order, string $content, ?array $meta = null): self
+ {
+ return $order->notes()->create([
+ 'content' => $content,
+ 'type' => self::TYPE_SYSTEM,
+ 'is_customer_note' => false,
+ 'meta' => $meta ? (object) $meta : null,
+ ]);
+ }
+
+ /**
+ * Create a customer note (visible to customer).
+ */
+ public static function createCustomerNote(Order $order, string $content, $author = null): self
+ {
+ return $order->notes()->create([
+ 'content' => $content,
+ 'type' => self::TYPE_CUSTOMER,
+ 'is_customer_note' => true,
+ 'author_type' => $author ? get_class($author) : null,
+ 'author_id' => $author?->getKey(),
+ ]);
+ }
+
+ /**
+ * Create a payment note.
+ */
+ public static function createPaymentNote(
+ Order $order,
+ string $content,
+ ?string $reference = null,
+ ?int $amount = null
+ ): self {
+ $meta = [];
+ if ($reference) {
+ $meta['payment_reference'] = $reference;
+ }
+ if ($amount !== null) {
+ $meta['amount'] = $amount;
+ }
+
+ return $order->notes()->create([
+ 'content' => $content,
+ 'type' => self::TYPE_PAYMENT,
+ 'is_customer_note' => false,
+ 'meta' => !empty($meta) ? (object) $meta : null,
+ ]);
+ }
+
+ /**
+ * Create a shipping note.
+ */
+ public static function createShippingNote(
+ Order $order,
+ string $content,
+ ?string $trackingNumber = null,
+ ?string $carrier = null
+ ): self {
+ $meta = [];
+ if ($trackingNumber) {
+ $meta['tracking_number'] = $trackingNumber;
+ }
+ if ($carrier) {
+ $meta['carrier'] = $carrier;
+ }
+
+ return $order->notes()->create([
+ 'content' => $content,
+ 'type' => self::TYPE_SHIPPING,
+ 'is_customer_note' => true, // Shipping info should be visible to customer
+ 'meta' => !empty($meta) ? (object) $meta : null,
+ ]);
+ }
+}
diff --git a/src/ShopServiceProvider.php b/src/ShopServiceProvider.php
index 01bc672..94385a3 100644
--- a/src/ShopServiceProvider.php
+++ b/src/ShopServiceProvider.php
@@ -60,6 +60,7 @@ class ShopServiceProvider extends ServiceProvider
\Blax\Shop\Console\Commands\ShopListPurchasesCommand::class,
\Blax\Shop\Console\Commands\ShopStatsCommand::class,
\Blax\Shop\Console\Commands\ShopAddExampleProducts::class,
+ \Blax\Shop\Console\Commands\ShopSetupStripeWebhooksCommand::class,
]);
}
}
diff --git a/src/Traits/HasOrders.php b/src/Traits/HasOrders.php
new file mode 100644
index 0000000..860a312
--- /dev/null
+++ b/src/Traits/HasOrders.php
@@ -0,0 +1,148 @@
+morphMany(
+ config('shop.models.order', Order::class),
+ 'customer'
+ );
+ }
+
+ /**
+ * Get orders with a specific status.
+ */
+ public function ordersWithStatus(OrderStatus $status): MorphMany
+ {
+ return $this->orders()->where('status', $status->value);
+ }
+
+ /**
+ * Get pending orders.
+ */
+ public function pendingOrders(): MorphMany
+ {
+ return $this->ordersWithStatus(OrderStatus::PENDING);
+ }
+
+ /**
+ * Get processing orders.
+ */
+ public function processingOrders(): MorphMany
+ {
+ return $this->ordersWithStatus(OrderStatus::PROCESSING);
+ }
+
+ /**
+ * Get completed orders.
+ */
+ public function completedOrders(): MorphMany
+ {
+ return $this->ordersWithStatus(OrderStatus::COMPLETED);
+ }
+
+ /**
+ * Get active orders (not in a final state).
+ */
+ public function activeOrders(): MorphMany
+ {
+ return $this->orders()->whereIn('status', [
+ OrderStatus::PENDING->value,
+ OrderStatus::PROCESSING->value,
+ OrderStatus::ON_HOLD->value,
+ OrderStatus::IN_PREPARATION->value,
+ OrderStatus::READY_FOR_PICKUP->value,
+ OrderStatus::SHIPPED->value,
+ ]);
+ }
+
+ /**
+ * Get orders that have been paid.
+ */
+ public function paidOrders(): MorphMany
+ {
+ return $this->orders()->where('amount_paid', '>', 0);
+ }
+
+ /**
+ * Get fully paid orders.
+ */
+ public function fullyPaidOrders(): MorphMany
+ {
+ return $this->orders()->whereColumn('amount_paid', '>=', 'amount_total');
+ }
+
+ /**
+ * Get orders within a date range.
+ */
+ public function ordersBetween(\DateTimeInterface $from, \DateTimeInterface $to): MorphMany
+ {
+ return $this->orders()->whereBetween('created_at', [$from, $to]);
+ }
+
+ /**
+ * Get the most recent order.
+ */
+ public function latestOrder(): ?Order
+ {
+ return $this->orders()->latest('created_at')->latest('id')->first();
+ }
+
+ /**
+ * Get total amount spent by this customer (sum of amount_paid across all orders).
+ */
+ public function getTotalSpentAttribute(): int
+ {
+ return $this->orders()->sum('amount_paid') ?? 0;
+ }
+
+ /**
+ * Get total number of completed orders.
+ */
+ public function getOrderCountAttribute(): int
+ {
+ return $this->orders()->count();
+ }
+
+ /**
+ * Get total number of completed orders.
+ */
+ public function getCompletedOrderCountAttribute(): int
+ {
+ return $this->completedOrders()->count();
+ }
+
+ /**
+ * Check if the customer has any orders.
+ */
+ public function hasOrders(): bool
+ {
+ return $this->orders()->exists();
+ }
+
+ /**
+ * Check if the customer has any active orders.
+ */
+ public function hasActiveOrders(): bool
+ {
+ return $this->activeOrders()->exists();
+ }
+
+ /**
+ * Find an order by order number.
+ */
+ public function findOrderByNumber(string $orderNumber): ?Order
+ {
+ return $this->orders()->where('order_number', $orderNumber)->first();
+ }
+}
diff --git a/src/Traits/HasShoppingCapabilities.php b/src/Traits/HasShoppingCapabilities.php
index 6b506c6..8768727 100644
--- a/src/Traits/HasShoppingCapabilities.php
+++ b/src/Traits/HasShoppingCapabilities.php
@@ -9,15 +9,17 @@ use Blax\Shop\Exceptions\MultiplePurchaseOptions;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Exceptions\NotPurchasable;
use Blax\Shop\Models\Cart;
-use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
+use Blax\Shop\Models\ProductPurchase;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Collection;
trait HasShoppingCapabilities
{
- use HasChargingOptions, HasCart;
+ use HasCart;
+ use HasChargingOptions;
+ use HasOrders;
/**
* Get all purchases made by this entity
diff --git a/tests/Feature/OrderCheckoutFlowTest.php b/tests/Feature/OrderCheckoutFlowTest.php
new file mode 100644
index 0000000..d304c7e
--- /dev/null
+++ b/tests/Feature/OrderCheckoutFlowTest.php
@@ -0,0 +1,586 @@
+create();
+ $product = Product::factory()->withPrices(unit_amount: 50.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 2);
+
+ $cart = $user->checkoutCart();
+
+ $this->assertNotNull($cart->converted_at);
+ $this->assertNotNull($cart->order);
+ $this->assertInstanceOf(Order::class, $cart->order);
+ }
+
+ #[Test]
+ public function order_has_correct_cart_id()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+
+ $this->assertEquals($cart->id, $order->cart_id);
+ }
+
+ #[Test]
+ public function order_has_correct_customer_info()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+
+ $this->assertEquals(get_class($user), $order->customer_type);
+ $this->assertEquals($user->id, $order->customer_id);
+ $this->assertTrue($order->customer->is($user));
+ }
+
+ #[Test]
+ public function order_has_correct_currency()
+ {
+ $user = User::factory()->create();
+ $cart = Cart::factory()->forCustomer($user)->create([
+ 'currency' => 'EUR',
+ ]);
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $cart->addToCart($product, quantity: 1);
+ $cart->checkout();
+
+ $order = $cart->fresh()->order;
+
+ $this->assertEquals('EUR', $order->currency);
+ }
+
+ #[Test]
+ public function order_has_correct_total_amount()
+ {
+ $user = User::factory()->create();
+ $product1 = Product::factory()->withPrices(unit_amount: 50.00)->create([
+ 'manage_stock' => false,
+ ]);
+ $product2 = Product::factory()->withPrices(unit_amount: 30.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product1, quantity: 2); // 100.00
+ $user->addToCart($product2, quantity: 3); // 90.00
+
+ $cart = $user->checkoutCart();
+ $order = $cart->order;
+
+ // Total should be 190.00 (19000 cents)
+ $this->assertEquals(19000, $order->amount_total);
+ $this->assertEquals(19000, $order->amount_subtotal);
+ }
+
+ #[Test]
+ public function order_starts_with_pending_status()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+
+ $this->assertEquals(OrderStatus::PENDING, $order->status);
+ }
+
+ #[Test]
+ public function order_starts_with_zero_paid_amount()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+
+ $this->assertEquals(0, $order->amount_paid);
+ $this->assertFalse($order->is_paid);
+ $this->assertFalse($order->is_fully_paid);
+ }
+
+ #[Test]
+ public function order_has_unique_order_number()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart1 = $user->checkoutCart();
+ $order1 = $cart1->order;
+
+ // Create another cart and checkout
+ $user->addToCart($product, quantity: 1);
+ $cart2 = $user->checkoutCart();
+ $order2 = $cart2->order;
+
+ $this->assertNotEquals($order1->order_number, $order2->order_number);
+ }
+
+ #[Test]
+ public function order_creation_adds_system_note()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+
+ $this->assertTrue(
+ $order->notes()
+ ->where('type', OrderNote::TYPE_SYSTEM)
+ ->where('content', 'like', '%created from cart%')
+ ->exists()
+ );
+ }
+
+ // =========================================================================
+ // ORDER PURCHASES RELATIONSHIP TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_has_purchases_through_cart()
+ {
+ $user = User::factory()->create();
+ $product1 = Product::factory()->withPrices(unit_amount: 50.00)->create([
+ 'manage_stock' => false,
+ ]);
+ $product2 = Product::factory()->withPrices(unit_amount: 30.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product1, quantity: 2);
+ $user->addToCart($product2, quantity: 1);
+
+ $cart = $user->checkoutCart();
+ $order = $cart->order;
+
+ $this->assertCount(2, $order->directPurchases);
+ }
+
+ #[Test]
+ public function order_purchases_have_correct_status()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 50.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $purchase = $cart->purchases()->first();
+
+ $this->assertEquals(PurchaseStatus::UNPAID, $purchase->status);
+ }
+
+ // =========================================================================
+ // ORDER PAYMENT FLOW TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_payment_updates_status()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 100.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+ $order->recordPayment(10000, 'pi_test123', 'card', 'stripe');
+
+ $order->refresh();
+
+ $this->assertEquals(10000, $order->amount_paid);
+ $this->assertEquals(OrderStatus::PROCESSING, $order->status);
+ $this->assertTrue($order->is_fully_paid);
+ $this->assertNotNull($order->paid_at);
+ }
+
+ #[Test]
+ public function order_payment_updates_purchase_status()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 100.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+ $order->recordPayment(10000);
+
+ $purchase = $cart->purchases()->first();
+
+ $this->assertEquals(PurchaseStatus::COMPLETED, $purchase->status);
+ $this->assertEquals($purchase->amount, $purchase->amount_paid);
+ }
+
+ #[Test]
+ public function order_partial_payment_does_not_complete_order()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 100.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+ $order->recordPayment(5000); // 50%
+
+ $order->refresh();
+
+ $this->assertEquals(5000, $order->amount_paid);
+ $this->assertEquals(5000, $order->amount_outstanding);
+ $this->assertFalse($order->is_fully_paid);
+ $this->assertNull($order->paid_at);
+ }
+
+ // =========================================================================
+ // ORDER STATUS WORKFLOW TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_can_be_processed_after_payment()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 100.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+ $order->recordPayment(10000);
+ $order->markAsInPreparation();
+
+ $this->assertEquals(OrderStatus::IN_PREPARATION, $order->fresh()->status);
+ }
+
+ #[Test]
+ public function order_can_be_shipped_with_tracking()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 100.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+ $order->recordPayment(10000);
+ $order->markAsShipped('TRACK123', 'FedEx');
+
+ $order->refresh();
+
+ $this->assertEquals(OrderStatus::SHIPPED, $order->status);
+ $this->assertEquals('TRACK123', $order->getMeta('tracking_number'));
+ $this->assertNotNull($order->shipped_at);
+ }
+
+ #[Test]
+ public function order_can_be_completed()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 100.00)->create([
+ 'manage_stock' => false,
+ 'virtual' => true, // Virtual product
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+ $order->recordPayment(10000);
+ $order->markAsCompleted();
+
+ $order->refresh();
+
+ $this->assertEquals(OrderStatus::COMPLETED, $order->status);
+ $this->assertNotNull($order->completed_at);
+ }
+
+ // =========================================================================
+ // ORDER CANCELLATION TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_can_be_cancelled()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 100.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+ $order->cancel('Customer changed their mind');
+
+ $order->refresh();
+
+ $this->assertEquals(OrderStatus::CANCELLED, $order->status);
+ $this->assertNotNull($order->cancelled_at);
+
+ // Verify the reason is logged
+ $this->assertTrue(
+ $order->notes()
+ ->where('content', 'Customer changed their mind')
+ ->exists()
+ );
+ }
+
+ // =========================================================================
+ // ORDER REFUND TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_can_be_refunded()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 100.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+ $order->recordPayment(10000);
+ $order->recordRefund(10000, 'Full refund');
+
+ $order->refresh();
+
+ $this->assertEquals(OrderStatus::REFUNDED, $order->status);
+ $this->assertEquals(10000, $order->amount_refunded);
+ $this->assertNotNull($order->refunded_at);
+ }
+
+ #[Test]
+ public function order_partial_refund_does_not_change_status()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 100.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+ $order->recordPayment(10000);
+ $order->recordRefund(3000, 'Partial refund');
+
+ $order->refresh();
+
+ $this->assertEquals(OrderStatus::PROCESSING, $order->status);
+ $this->assertEquals(3000, $order->amount_refunded);
+ }
+
+ // =========================================================================
+ // ORDER NOTES DURING LIFECYCLE TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_logs_status_changes()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 100.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+ $order->recordPayment(10000);
+ $order->markAsShipped('TRACK123');
+ $order->markAsCompleted();
+
+ $statusNotes = $order->notes()
+ ->where('type', OrderNote::TYPE_STATUS_CHANGE)
+ ->get();
+
+ $this->assertGreaterThanOrEqual(3, $statusNotes->count());
+ }
+
+ #[Test]
+ public function order_logs_payment_notes()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 100.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $order = $cart->order;
+ $order->recordPayment(5000);
+ $order->recordPayment(5000);
+
+ $paymentNotes = $order->notes()
+ ->where('type', OrderNote::TYPE_PAYMENT)
+ ->get();
+
+ $this->assertCount(2, $paymentNotes);
+ }
+
+ // =========================================================================
+ // CART STATUS UPDATE TESTS
+ // =========================================================================
+
+ #[Test]
+ public function cart_status_is_converted_after_checkout()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 100.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product, quantity: 1);
+ $cart = $user->checkoutCart();
+
+ $this->assertEquals(CartStatus::CONVERTED, $cart->status);
+ }
+
+ // =========================================================================
+ // MULTIPLE PRODUCTS CHECKOUT TESTS
+ // =========================================================================
+
+ #[Test]
+ public function checkout_handles_multiple_products_correctly()
+ {
+ $user = User::factory()->create();
+
+ $products = Product::factory()
+ ->withPrices(unit_amount: 25.00)
+ ->count(5)
+ ->create(['manage_stock' => false]);
+
+ foreach ($products as $product) {
+ $user->addToCart($product, quantity: 2);
+ }
+
+ $cart = $user->checkoutCart();
+ $order = $cart->order;
+
+ $this->assertCount(5, $order->directPurchases);
+ $this->assertEquals(25000, $order->amount_total); // 5 products * 2 qty * 25.00 = 250.00
+ }
+
+ // =========================================================================
+ // ORDER QUERY TESTS
+ // =========================================================================
+
+ #[Test]
+ public function can_find_orders_for_customer()
+ {
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ // Create 3 orders for user1
+ for ($i = 0; $i < 3; $i++) {
+ $user1->addToCart($product, quantity: 1);
+ $user1->checkoutCart();
+ }
+
+ // Create 2 orders for user2
+ for ($i = 0; $i < 2; $i++) {
+ $user2->addToCart($product, quantity: 1);
+ $user2->checkoutCart();
+ }
+
+ $this->assertCount(3, Order::forCustomer($user1)->get());
+ $this->assertCount(2, Order::forCustomer($user2)->get());
+ }
+
+ #[Test]
+ public function can_find_paid_orders()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 50.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ // Create and pay one order
+ $user->addToCart($product, quantity: 1);
+ $cart1 = $user->checkoutCart();
+ $cart1->order->recordPayment(5000);
+
+ // Create unpaid order
+ $user->addToCart($product, quantity: 1);
+ $user->checkoutCart();
+
+ $this->assertCount(1, Order::paid()->get());
+ $this->assertCount(1, Order::unpaid()->get());
+ }
+}
diff --git a/tests/Unit/HasOrdersTraitTest.php b/tests/Unit/HasOrdersTraitTest.php
new file mode 100644
index 0000000..ad6957c
--- /dev/null
+++ b/tests/Unit/HasOrdersTraitTest.php
@@ -0,0 +1,343 @@
+create();
+
+ $this->assertTrue(method_exists($user, 'orders'));
+ $this->assertCount(0, $user->orders);
+ }
+
+ #[Test]
+ public function user_orders_returns_morph_many()
+ {
+ $user = User::factory()->create();
+
+ $this->assertInstanceOf(
+ \Illuminate\Database\Eloquent\Relations\MorphMany::class,
+ $user->orders()
+ );
+ }
+
+ #[Test]
+ public function user_can_have_multiple_orders()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 50.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ // Create multiple orders via cart checkout
+ for ($i = 0; $i < 3; $i++) {
+ $user->addToCart($product);
+ $user->checkoutCart();
+ }
+
+ $this->assertCount(3, $user->orders);
+ }
+
+ #[Test]
+ public function user_can_get_pending_orders()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product);
+ $user->checkoutCart();
+
+ $this->assertCount(1, $user->pendingOrders);
+ $this->assertEquals(OrderStatus::PENDING, $user->pendingOrders->first()->status);
+ }
+
+ #[Test]
+ public function user_can_get_processing_orders()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product);
+ $cart = $user->checkoutCart();
+ $order = $cart->order;
+ $order->markAsProcessing();
+
+ $this->assertCount(1, $user->fresh()->processingOrders);
+ }
+
+ #[Test]
+ public function user_can_get_completed_orders()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product);
+ $cart = $user->checkoutCart();
+ $order = $cart->order;
+ $order->forceStatus(OrderStatus::COMPLETED);
+
+ $this->assertCount(1, $user->fresh()->completedOrders);
+ }
+
+ #[Test]
+ public function user_can_get_active_orders()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ // Create one active order
+ $user->addToCart($product);
+ $cart1 = $user->checkoutCart();
+
+ // Create one completed order
+ $user->addToCart($product);
+ $cart2 = $user->checkoutCart();
+ $cart2->order->forceStatus(OrderStatus::COMPLETED);
+
+ $this->assertCount(1, $user->fresh()->activeOrders);
+ }
+
+ #[Test]
+ public function user_can_get_paid_orders()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ // Create unpaid order
+ $user->addToCart($product);
+ $cart1 = $user->checkoutCart();
+
+ // Create paid order
+ $user->addToCart($product);
+ $cart2 = $user->checkoutCart();
+ $cart2->order->recordPayment(2500, 'ref123', 'stripe', 'stripe');
+
+ $this->assertCount(1, $user->fresh()->paidOrders);
+ }
+
+ #[Test]
+ public function user_can_get_fully_paid_orders()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ // Create partially paid order
+ $user->addToCart($product);
+ $cart1 = $user->checkoutCart();
+ $cart1->order->recordPayment(1000, 'ref123', 'stripe', 'stripe'); // Only 10.00
+
+ // Create fully paid order
+ $user->addToCart($product);
+ $cart2 = $user->checkoutCart();
+ $cart2->order->recordPayment(2500, 'ref456', 'stripe', 'stripe'); // Full 25.00
+
+ $this->assertCount(1, $user->fresh()->fullyPaidOrders);
+ }
+
+ #[Test]
+ public function user_can_get_latest_order()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product);
+ $cart1 = $user->checkoutCart();
+
+ $user->addToCart($product);
+ $cart2 = $user->checkoutCart();
+
+ $latestOrder = $user->latestOrder();
+
+ $this->assertNotNull($latestOrder);
+ $this->assertEquals($cart2->order->id, $latestOrder->id);
+ }
+
+ #[Test]
+ public function user_can_get_total_spent()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 50.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product);
+ $cart1 = $user->checkoutCart();
+ $cart1->order->recordPayment(5000, 'ref1', 'stripe', 'stripe');
+
+ $user->addToCart($product);
+ $cart2 = $user->checkoutCart();
+ $cart2->order->recordPayment(3000, 'ref2', 'stripe', 'stripe');
+
+ $this->assertEquals(8000, $user->fresh()->total_spent); // 80.00 in cents
+ }
+
+ #[Test]
+ public function user_can_get_order_count()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product);
+ $user->checkoutCart();
+
+ $user->addToCart($product);
+ $user->checkoutCart();
+
+ $this->assertEquals(2, $user->order_count);
+ }
+
+ #[Test]
+ public function user_can_get_completed_order_count()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product);
+ $cart1 = $user->checkoutCart();
+ $cart1->order->forceStatus(OrderStatus::COMPLETED);
+
+ $user->addToCart($product);
+ $user->checkoutCart(); // This stays pending
+
+ $this->assertEquals(1, $user->fresh()->completed_order_count);
+ }
+
+ #[Test]
+ public function user_can_check_has_orders()
+ {
+ $user = User::factory()->create();
+
+ $this->assertFalse($user->hasOrders());
+
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+ $user->addToCart($product);
+ $user->checkoutCart();
+
+ $this->assertTrue($user->fresh()->hasOrders());
+ }
+
+ #[Test]
+ public function user_can_check_has_active_orders()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product);
+ $cart = $user->checkoutCart();
+
+ $this->assertTrue($user->fresh()->hasActiveOrders());
+
+ // Complete the order
+ $cart->order->forceStatus(OrderStatus::COMPLETED);
+
+ $this->assertFalse($user->fresh()->hasActiveOrders());
+ }
+
+ #[Test]
+ public function user_can_find_order_by_number()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product);
+ $cart = $user->checkoutCart();
+ $order = $cart->order;
+
+ $foundOrder = $user->findOrderByNumber($order->order_number);
+
+ $this->assertNotNull($foundOrder);
+ $this->assertEquals($order->id, $foundOrder->id);
+ }
+
+ #[Test]
+ public function user_cannot_find_other_users_order_by_number()
+ {
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user1->addToCart($product);
+ $cart = $user1->checkoutCart();
+ $order = $cart->order;
+
+ // User2 should not find user1's order
+ $foundOrder = $user2->findOrderByNumber($order->order_number);
+
+ $this->assertNull($foundOrder);
+ }
+
+ #[Test]
+ public function user_can_get_orders_between_dates()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product);
+ $user->checkoutCart();
+
+ $orders = $user->ordersBetween(
+ now()->subDay(),
+ now()->addDay()
+ );
+
+ $this->assertCount(1, $orders->get());
+ }
+
+ #[Test]
+ public function user_can_get_orders_with_specific_status()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices(unit_amount: 25.00)->create([
+ 'manage_stock' => false,
+ ]);
+
+ $user->addToCart($product);
+ $cart = $user->checkoutCart();
+ $cart->order->update(['status' => OrderStatus::SHIPPED]);
+
+ $shippedOrders = $user->ordersWithStatus(OrderStatus::SHIPPED);
+
+ $this->assertCount(1, $shippedOrders->get());
+ }
+}
diff --git a/tests/Unit/OrderNoteTest.php b/tests/Unit/OrderNoteTest.php
new file mode 100644
index 0000000..a774d30
--- /dev/null
+++ b/tests/Unit/OrderNoteTest.php
@@ -0,0 +1,394 @@
+create();
+ $note = OrderNote::factory()->forOrder($order)->create([
+ 'content' => 'Test note content',
+ ]);
+
+ $this->assertInstanceOf(OrderNote::class, $note);
+ $this->assertEquals('Test note content', $note->content);
+ $this->assertEquals($order->id, $note->order_id);
+ }
+
+ #[Test]
+ public function order_note_default_type_is_note()
+ {
+ $order = Order::factory()->create();
+ $note = $order->addNote('Test note');
+
+ $this->assertEquals(OrderNote::TYPE_NOTE, $note->type);
+ }
+
+ #[Test]
+ public function order_note_default_is_not_customer_note()
+ {
+ $order = Order::factory()->create();
+ $note = $order->addNote('Test note');
+
+ $this->assertFalse($note->is_customer_note);
+ }
+
+ // =========================================================================
+ // ORDER NOTE TYPES TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_note_can_have_different_types()
+ {
+ $order = Order::factory()->create();
+
+ $noteTypes = [
+ OrderNote::TYPE_NOTE => 'Note',
+ OrderNote::TYPE_STATUS_CHANGE => 'Status Change',
+ OrderNote::TYPE_PAYMENT => 'Payment',
+ OrderNote::TYPE_REFUND => 'Refund',
+ OrderNote::TYPE_SHIPPING => 'Shipping',
+ OrderNote::TYPE_CUSTOMER => 'Customer Message',
+ OrderNote::TYPE_SYSTEM => 'System',
+ OrderNote::TYPE_EMAIL => 'Email',
+ OrderNote::TYPE_WEBHOOK => 'Webhook',
+ ];
+
+ foreach ($noteTypes as $type => $expectedLabel) {
+ $note = $order->addNote("Test {$type}", $type);
+ $this->assertEquals($type, $note->type);
+ $this->assertEquals($expectedLabel, $note->type_label);
+ }
+ }
+
+ #[Test]
+ public function order_note_has_type_icon()
+ {
+ $order = Order::factory()->create();
+
+ $note = $order->addNote('Test', OrderNote::TYPE_PAYMENT);
+ $this->assertEquals('credit-card', $note->type_icon);
+
+ $note2 = $order->addNote('Test', OrderNote::TYPE_SHIPPING);
+ $this->assertEquals('truck', $note2->type_icon);
+
+ $note3 = $order->addNote('Test', OrderNote::TYPE_SYSTEM);
+ $this->assertEquals('cog', $note3->type_icon);
+ }
+
+ #[Test]
+ public function order_note_has_type_color()
+ {
+ $order = Order::factory()->create();
+
+ $note = $order->addNote('Test', OrderNote::TYPE_PAYMENT);
+ $this->assertEquals('green', $note->type_color);
+
+ $note2 = $order->addNote('Test', OrderNote::TYPE_REFUND);
+ $this->assertEquals('red', $note2->type_color);
+
+ $note3 = $order->addNote('Test', OrderNote::TYPE_STATUS_CHANGE);
+ $this->assertEquals('blue', $note3->type_color);
+ }
+
+ // =========================================================================
+ // ORDER NOTE CUSTOMER VISIBILITY TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_note_can_be_customer_visible()
+ {
+ $order = Order::factory()->create();
+
+ $note = $order->addNote('Customer visible note', 'customer', true);
+
+ $this->assertTrue($note->is_customer_note);
+ }
+
+ #[Test]
+ public function order_note_can_be_internal_only()
+ {
+ $order = Order::factory()->create();
+
+ $note = $order->addNote('Internal only note', 'note', false);
+
+ $this->assertFalse($note->is_customer_note);
+ }
+
+ // =========================================================================
+ // ORDER NOTE RELATIONSHIPS TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_note_belongs_to_order()
+ {
+ $order = Order::factory()->create();
+ $note = $order->addNote('Test note');
+
+ $this->assertTrue($note->order->is($order));
+ }
+
+ #[Test]
+ public function order_note_can_have_author()
+ {
+ $order = Order::factory()->create();
+ $user = User::factory()->create();
+
+ $note = $order->addNote(
+ 'Note from user',
+ 'note',
+ false,
+ get_class($user),
+ $user->id
+ );
+
+ $this->assertEquals(get_class($user), $note->author_type);
+ $this->assertEquals($user->id, $note->author_id);
+ $this->assertTrue($note->author->is($user));
+ }
+
+ // =========================================================================
+ // ORDER NOTE SCOPES TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_note_can_be_scoped_to_customer_notes()
+ {
+ $order = Order::factory()->create();
+
+ $order->addNote('Internal 1', 'note', false);
+ $order->addNote('Customer 1', 'customer', true);
+ $order->addNote('Internal 2', 'note', false);
+ $order->addNote('Customer 2', 'customer', true);
+
+ $customerNotes = $order->notes()->forCustomer()->get();
+ $internalNotes = $order->notes()->internal()->get();
+
+ $this->assertCount(2, $customerNotes);
+ $this->assertCount(2, $internalNotes);
+ }
+
+ #[Test]
+ public function order_note_can_be_scoped_by_type()
+ {
+ $order = Order::factory()->create();
+
+ $order->addNote('Note 1', OrderNote::TYPE_NOTE);
+ $order->addNote('Payment 1', OrderNote::TYPE_PAYMENT);
+ $order->addNote('Payment 2', OrderNote::TYPE_PAYMENT);
+ $order->addNote('Shipping 1', OrderNote::TYPE_SHIPPING);
+
+ $this->assertCount(2, $order->notes()->ofType(OrderNote::TYPE_PAYMENT)->get());
+ $this->assertCount(1, $order->notes()->ofType(OrderNote::TYPE_SHIPPING)->get());
+ $this->assertCount(1, $order->notes()->ofType(OrderNote::TYPE_NOTE)->get());
+ }
+
+ #[Test]
+ public function order_note_can_be_scoped_by_multiple_types()
+ {
+ $order = Order::factory()->create();
+
+ $order->addNote('Note', OrderNote::TYPE_NOTE);
+ $order->addNote('Payment', OrderNote::TYPE_PAYMENT);
+ $order->addNote('Refund', OrderNote::TYPE_REFUND);
+ $order->addNote('Shipping', OrderNote::TYPE_SHIPPING);
+
+ $paymentRelated = $order->notes()->paymentRelated()->get();
+
+ $this->assertCount(2, $paymentRelated);
+ }
+
+ #[Test]
+ public function order_note_can_be_scoped_to_system_notes()
+ {
+ $order = Order::factory()->create();
+
+ $order->addNote('System note', OrderNote::TYPE_SYSTEM);
+ $order->addNote('Regular note', OrderNote::TYPE_NOTE);
+
+ $this->assertCount(1, $order->notes()->system()->get());
+ }
+
+ // =========================================================================
+ // ORDER NOTE META TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_note_can_store_meta()
+ {
+ $order = Order::factory()->create();
+
+ $note = OrderNote::factory()->forOrder($order)->create([
+ 'meta' => (object) ['key' => 'value'],
+ ]);
+
+ $this->assertEquals('value', $note->getMeta('key'));
+ }
+
+ #[Test]
+ public function order_note_can_update_meta_key()
+ {
+ $order = Order::factory()->create();
+ $note = OrderNote::factory()->forOrder($order)->create();
+
+ $note->updateMetaKey('custom', 'data');
+
+ $this->assertEquals('data', $note->fresh()->getMeta('custom'));
+ }
+
+ #[Test]
+ public function order_note_returns_default_for_missing_meta()
+ {
+ $order = Order::factory()->create();
+ $note = OrderNote::factory()->forOrder($order)->create();
+
+ $this->assertNull($note->getMeta('nonexistent'));
+ $this->assertEquals('default', $note->getMeta('nonexistent', 'default'));
+ }
+
+ // =========================================================================
+ // ORDER NOTE FACTORY METHODS TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_note_can_create_system_note()
+ {
+ $order = Order::factory()->create();
+
+ $note = OrderNote::createSystemNote($order, 'System action occurred');
+
+ $this->assertEquals(OrderNote::TYPE_SYSTEM, $note->type);
+ $this->assertFalse($note->is_customer_note);
+ $this->assertEquals('System action occurred', $note->content);
+ }
+
+ #[Test]
+ public function order_note_can_create_customer_note()
+ {
+ $order = Order::factory()->create();
+ $user = User::factory()->create();
+
+ $note = OrderNote::createCustomerNote($order, 'Hello, I have a question', $user);
+
+ $this->assertEquals(OrderNote::TYPE_CUSTOMER, $note->type);
+ $this->assertTrue($note->is_customer_note);
+ $this->assertEquals($user->id, $note->author_id);
+ }
+
+ #[Test]
+ public function order_note_can_create_payment_note()
+ {
+ $order = Order::factory()->create();
+
+ $note = OrderNote::createPaymentNote($order, 'Payment received', 'pi_123', 10000);
+
+ $this->assertEquals(OrderNote::TYPE_PAYMENT, $note->type);
+ $this->assertEquals('pi_123', $note->getMeta('payment_reference'));
+ $this->assertEquals(10000, $note->getMeta('amount'));
+ }
+
+ #[Test]
+ public function order_note_can_create_shipping_note()
+ {
+ $order = Order::factory()->create();
+
+ $note = OrderNote::createShippingNote($order, 'Order shipped', 'TRACK123', 'FedEx');
+
+ $this->assertEquals(OrderNote::TYPE_SHIPPING, $note->type);
+ $this->assertTrue($note->is_customer_note);
+ $this->assertEquals('TRACK123', $note->getMeta('tracking_number'));
+ $this->assertEquals('FedEx', $note->getMeta('carrier'));
+ }
+
+ // =========================================================================
+ // ORDER NOTE FACTORY STATES TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_note_factory_creates_status_change()
+ {
+ $order = Order::factory()->create();
+ $note = OrderNote::factory()->forOrder($order)->statusChange()->create();
+
+ $this->assertEquals(OrderNote::TYPE_STATUS_CHANGE, $note->type);
+ }
+
+ #[Test]
+ public function order_note_factory_creates_payment_note()
+ {
+ $order = Order::factory()->create();
+ $note = OrderNote::factory()->forOrder($order)->payment(5000)->create();
+
+ $this->assertEquals(OrderNote::TYPE_PAYMENT, $note->type);
+ $this->assertEquals(5000, $note->getMeta('amount'));
+ }
+
+ #[Test]
+ public function order_note_factory_creates_refund_note()
+ {
+ $order = Order::factory()->create();
+ $note = OrderNote::factory()->forOrder($order)->refund(3000, 'Damaged item')->create();
+
+ $this->assertEquals(OrderNote::TYPE_REFUND, $note->type);
+ $this->assertStringContainsString('30.00', $note->content);
+ $this->assertStringContainsString('Damaged item', $note->content);
+ }
+
+ #[Test]
+ public function order_note_factory_creates_shipping_note()
+ {
+ $order = Order::factory()->create();
+ $note = OrderNote::factory()->forOrder($order)->shipping('ABC123', 'UPS')->create();
+
+ $this->assertEquals(OrderNote::TYPE_SHIPPING, $note->type);
+ $this->assertTrue($note->is_customer_note);
+ $this->assertStringContainsString('ABC123', $note->content);
+ $this->assertStringContainsString('UPS', $note->content);
+ }
+
+ #[Test]
+ public function order_note_factory_creates_customer_message()
+ {
+ $order = Order::factory()->create();
+ $note = OrderNote::factory()->forOrder($order)->customerMessage()->create([
+ 'content' => 'Customer inquiry',
+ ]);
+
+ $this->assertEquals(OrderNote::TYPE_CUSTOMER, $note->type);
+ $this->assertTrue($note->is_customer_note);
+ }
+
+ // =========================================================================
+ // ORDER NOTES ORDERING TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_notes_are_ordered_by_created_at_desc()
+ {
+ $order = Order::factory()->create();
+
+ $note1 = OrderNote::factory()->forOrder($order)->create(['created_at' => now()->subHours(2)]);
+ $note2 = OrderNote::factory()->forOrder($order)->create(['created_at' => now()->subHour()]);
+ $note3 = OrderNote::factory()->forOrder($order)->create(['created_at' => now()]);
+
+ $notes = $order->notes;
+
+ $this->assertEquals($note3->id, $notes->first()->id);
+ $this->assertEquals($note1->id, $notes->last()->id);
+ }
+}
diff --git a/tests/Unit/OrderTest.php b/tests/Unit/OrderTest.php
new file mode 100644
index 0000000..97a396c
--- /dev/null
+++ b/tests/Unit/OrderTest.php
@@ -0,0 +1,602 @@
+create();
+
+ $this->assertInstanceOf(Order::class, $order);
+ $this->assertNotNull($order->id);
+ $this->assertNotNull($order->order_number);
+ $this->assertEquals(OrderStatus::PENDING, $order->status);
+ }
+
+ #[Test]
+ public function order_generates_unique_order_number_automatically()
+ {
+ $order1 = Order::factory()->create();
+ $order2 = Order::factory()->create();
+ $order3 = Order::factory()->create();
+
+ $this->assertNotEquals($order1->order_number, $order2->order_number);
+ $this->assertNotEquals($order2->order_number, $order3->order_number);
+ $this->assertStringStartsWith('ORD-', $order1->order_number);
+ }
+
+ #[Test]
+ public function order_number_format_includes_date_and_sequence()
+ {
+ $order = Order::factory()->create();
+
+ // Format: ORD-YYYYMMDD0001
+ $expectedPrefix = 'ORD-' . now()->format('Ymd');
+ $this->assertStringStartsWith($expectedPrefix, $order->order_number);
+ $this->assertMatchesRegularExpression('/^ORD-\d{8}\d{4}$/', $order->order_number);
+ }
+
+ #[Test]
+ public function order_can_be_created_for_customer()
+ {
+ $user = User::factory()->create();
+ $order = Order::factory()->forCustomer($user)->create();
+
+ $this->assertEquals(get_class($user), $order->customer_type);
+ $this->assertEquals($user->id, $order->customer_id);
+ $this->assertTrue($order->customer->is($user));
+ }
+
+ #[Test]
+ public function order_can_be_created_for_cart()
+ {
+ $user = User::factory()->create();
+ $cart = Cart::factory()->forCustomer($user)->create([
+ 'currency' => 'EUR',
+ 'converted_at' => now(),
+ ]);
+
+ $order = Order::factory()->forCart($cart)->create();
+
+ $this->assertEquals($cart->id, $order->cart_id);
+ $this->assertEquals($user->id, $order->customer_id);
+ $this->assertEquals('EUR', $order->currency);
+ }
+
+ // =========================================================================
+ // ORDER STATUS TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_default_status_is_pending()
+ {
+ $order = Order::factory()->create();
+
+ $this->assertEquals(OrderStatus::PENDING, $order->status);
+ }
+
+ #[Test]
+ public function order_can_transition_to_processing_from_pending()
+ {
+ $order = Order::factory()->pending()->create();
+
+ $order->updateStatus(OrderStatus::PROCESSING);
+
+ $this->assertEquals(OrderStatus::PROCESSING, $order->fresh()->status);
+ }
+
+ #[Test]
+ public function order_cannot_transition_to_invalid_status()
+ {
+ $order = Order::factory()->pending()->create();
+
+ $this->expectException(\InvalidArgumentException::class);
+ $order->updateStatus(OrderStatus::SHIPPED);
+ }
+
+ #[Test]
+ public function order_can_force_status_without_validation()
+ {
+ $order = Order::factory()->pending()->create();
+
+ $order->forceStatus(OrderStatus::SHIPPED);
+
+ $this->assertEquals(OrderStatus::SHIPPED, $order->fresh()->status);
+ }
+
+ #[Test]
+ public function order_status_change_is_logged()
+ {
+ $order = Order::factory()->pending()->create();
+
+ $order->updateStatus(OrderStatus::PROCESSING);
+
+ $this->assertTrue(
+ $order->notes()
+ ->where('type', 'status_change')
+ ->exists()
+ );
+ }
+
+ #[Test]
+ public function order_status_enum_has_label()
+ {
+ $this->assertEquals('Pending Payment', OrderStatus::PENDING->label());
+ $this->assertEquals('Processing', OrderStatus::PROCESSING->label());
+ $this->assertEquals('Completed', OrderStatus::COMPLETED->label());
+ $this->assertEquals('Shipped', OrderStatus::SHIPPED->label());
+ }
+
+ #[Test]
+ public function order_status_has_allowed_transitions()
+ {
+ $this->assertTrue(OrderStatus::PENDING->canTransitionTo(OrderStatus::PROCESSING));
+ $this->assertTrue(OrderStatus::PENDING->canTransitionTo(OrderStatus::CANCELLED));
+ $this->assertFalse(OrderStatus::PENDING->canTransitionTo(OrderStatus::SHIPPED));
+ $this->assertFalse(OrderStatus::CANCELLED->canTransitionTo(OrderStatus::PROCESSING));
+ }
+
+ #[Test]
+ public function order_status_is_active_check()
+ {
+ $this->assertTrue(OrderStatus::PENDING->isActive());
+ $this->assertTrue(OrderStatus::PROCESSING->isActive());
+ $this->assertTrue(OrderStatus::SHIPPED->isActive());
+ $this->assertFalse(OrderStatus::COMPLETED->isActive());
+ $this->assertFalse(OrderStatus::CANCELLED->isActive());
+ }
+
+ #[Test]
+ public function order_status_is_final_check()
+ {
+ $this->assertFalse(OrderStatus::PENDING->isFinal());
+ $this->assertFalse(OrderStatus::PROCESSING->isFinal());
+ $this->assertTrue(OrderStatus::COMPLETED->isFinal());
+ $this->assertTrue(OrderStatus::CANCELLED->isFinal());
+ $this->assertTrue(OrderStatus::REFUNDED->isFinal());
+ }
+
+ // =========================================================================
+ // ORDER CONVENIENCE STATUS METHODS
+ // =========================================================================
+
+ #[Test]
+ public function order_can_mark_as_processing()
+ {
+ $order = Order::factory()->pending()->create();
+
+ $order->markAsProcessing();
+
+ $this->assertEquals(OrderStatus::PROCESSING, $order->fresh()->status);
+ }
+
+ #[Test]
+ public function order_can_mark_as_shipped_with_tracking()
+ {
+ $order = Order::factory()->processing()->create();
+
+ $order->markAsShipped('TRACK123', 'FedEx');
+
+ $order->refresh();
+ $this->assertEquals(OrderStatus::SHIPPED, $order->status);
+ $this->assertNotNull($order->shipped_at);
+ $this->assertEquals('TRACK123', $order->getMeta('tracking_number'));
+ $this->assertEquals('FedEx', $order->getMeta('shipping_carrier'));
+ }
+
+ #[Test]
+ public function order_can_mark_as_completed()
+ {
+ $order = Order::factory()->processing()->create();
+
+ $order->markAsCompleted();
+
+ $order->refresh();
+ $this->assertEquals(OrderStatus::COMPLETED, $order->status);
+ $this->assertNotNull($order->completed_at);
+ }
+
+ #[Test]
+ public function order_can_be_cancelled()
+ {
+ $order = Order::factory()->pending()->create();
+
+ $order->cancel('Customer request');
+
+ $order->refresh();
+ $this->assertEquals(OrderStatus::CANCELLED, $order->status);
+ $this->assertNotNull($order->cancelled_at);
+ }
+
+ #[Test]
+ public function order_can_be_put_on_hold()
+ {
+ $order = Order::factory()->processing()->create();
+
+ $order->hold('Waiting for stock');
+
+ $order->refresh();
+ $this->assertEquals(OrderStatus::ON_HOLD, $order->status);
+ }
+
+ // =========================================================================
+ // ORDER AMOUNTS TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_calculates_amount_outstanding()
+ {
+ $order = Order::factory()->withAmounts(
+ subtotal: 10000,
+ discount: 0,
+ shipping: 500,
+ tax: 1050
+ )->create([
+ 'amount_paid' => 5000,
+ ]);
+
+ $this->assertEquals(11550, $order->amount_total);
+ $this->assertEquals(6550, $order->amount_outstanding);
+ }
+
+ #[Test]
+ public function order_is_paid_when_any_payment_received()
+ {
+ $order = Order::factory()->create([
+ 'amount_total' => 10000,
+ 'amount_paid' => 0,
+ ]);
+
+ $this->assertFalse($order->is_paid);
+
+ $order->update(['amount_paid' => 1000]);
+
+ $this->assertTrue($order->fresh()->is_paid);
+ }
+
+ #[Test]
+ public function order_is_fully_paid_when_amount_paid_equals_total()
+ {
+ $order = Order::factory()->create([
+ 'amount_total' => 10000,
+ 'amount_paid' => 5000,
+ ]);
+
+ $this->assertFalse($order->is_fully_paid);
+
+ $order->update(['amount_paid' => 10000]);
+
+ $this->assertTrue($order->fresh()->is_fully_paid);
+ }
+
+ // =========================================================================
+ // ORDER PAYMENT TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_can_record_payment()
+ {
+ $order = Order::factory()->pending()->create([
+ 'amount_total' => 10000,
+ 'amount_paid' => 0,
+ ]);
+
+ $order->recordPayment(10000, 'pi_123', 'card', 'stripe');
+
+ $order->refresh();
+ $this->assertEquals(10000, $order->amount_paid);
+ $this->assertEquals('pi_123', $order->payment_reference);
+ $this->assertEquals('card', $order->payment_method);
+ $this->assertEquals('stripe', $order->payment_provider);
+ $this->assertNotNull($order->paid_at);
+ $this->assertEquals(OrderStatus::PROCESSING, $order->status);
+ }
+
+ #[Test]
+ public function order_can_record_partial_payment()
+ {
+ $order = Order::factory()->pending()->create([
+ 'amount_total' => 10000,
+ 'amount_paid' => 0,
+ ]);
+
+ $order->recordPayment(5000);
+
+ $order->refresh();
+ $this->assertEquals(5000, $order->amount_paid);
+ $this->assertNull($order->paid_at); // Not fully paid yet
+ $this->assertEquals(5000, $order->amount_outstanding);
+ }
+
+ #[Test]
+ public function order_payment_creates_note()
+ {
+ $order = Order::factory()->pending()->create([
+ 'amount_total' => 10000,
+ ]);
+
+ $order->recordPayment(10000);
+
+ $this->assertTrue(
+ $order->notes()
+ ->where('type', 'payment')
+ ->exists()
+ );
+ }
+
+ #[Test]
+ public function order_can_record_refund()
+ {
+ $order = Order::factory()->paid()->create([
+ 'amount_total' => 10000,
+ 'amount_paid' => 10000,
+ ]);
+
+ $order->recordRefund(5000, 'Partial refund for damaged item');
+
+ $order->refresh();
+ $this->assertEquals(5000, $order->amount_refunded);
+ }
+
+ #[Test]
+ public function order_becomes_refunded_when_fully_refunded()
+ {
+ $order = Order::factory()->paid()->create([
+ 'amount_total' => 10000,
+ 'amount_paid' => 10000,
+ ]);
+
+ $order->recordRefund(10000);
+
+ $order->refresh();
+ $this->assertEquals(OrderStatus::REFUNDED, $order->status);
+ $this->assertNotNull($order->refunded_at);
+ }
+
+ // =========================================================================
+ // ORDER NOTES TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_can_add_note()
+ {
+ $order = Order::factory()->create();
+
+ $note = $order->addNote('Test note', 'note', false);
+
+ $this->assertInstanceOf(OrderNote::class, $note);
+ $this->assertEquals('Test note', $note->content);
+ $this->assertEquals('note', $note->type);
+ $this->assertFalse($note->is_customer_note);
+ }
+
+ #[Test]
+ public function order_can_add_customer_note()
+ {
+ $order = Order::factory()->create();
+
+ $note = $order->addNote('Customer visible note', 'customer', true);
+
+ $this->assertTrue($note->is_customer_note);
+ $this->assertCount(1, $order->customerNotes);
+ }
+
+ #[Test]
+ public function order_can_filter_customer_notes()
+ {
+ $order = Order::factory()->create();
+
+ $order->addNote('Internal note', 'note', false);
+ $order->addNote('Customer note', 'customer', true);
+ $order->addNote('Another internal', 'note', false);
+
+ $this->assertCount(1, $order->customerNotes);
+ $this->assertCount(2, $order->internalNotes);
+ }
+
+ #[Test]
+ public function order_note_has_type_label()
+ {
+ $order = Order::factory()->create();
+ $note = $order->addNote('Test', OrderNote::TYPE_PAYMENT);
+
+ $this->assertEquals('Payment', $note->type_label);
+ }
+
+ #[Test]
+ public function order_creation_logs_system_note()
+ {
+ $user = User::factory()->create();
+ $cart = Cart::factory()->forCustomer($user)->create([
+ 'converted_at' => now(),
+ ]);
+ $cart->update(['converted_at' => now()]);
+
+ $order = Order::createFromCart($cart);
+
+ $this->assertTrue(
+ $order->notes()
+ ->where('type', 'system')
+ ->where('content', 'Order created from cart checkout')
+ ->exists()
+ );
+ }
+
+ // =========================================================================
+ // ORDER RELATIONSHIPS TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_has_cart_relationship()
+ {
+ $user = User::factory()->create();
+ $cart = Cart::factory()->forCustomer($user)->create([
+ 'converted_at' => now(),
+ ]);
+ $order = Order::factory()->forCart($cart)->create();
+
+ $this->assertTrue($order->cart->is($cart));
+ }
+
+ #[Test]
+ public function order_has_customer_relationship()
+ {
+ $user = User::factory()->create();
+ $order = Order::factory()->forCustomer($user)->create();
+
+ $this->assertTrue($order->customer->is($user));
+ }
+
+ #[Test]
+ public function order_has_notes_relationship()
+ {
+ $order = Order::factory()->create();
+ $order->addNote('Note 1');
+ $order->addNote('Note 2');
+ $order->addNote('Note 3');
+
+ $this->assertCount(3, $order->notes);
+ }
+
+ // =========================================================================
+ // ORDER SCOPES TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_can_be_scoped_by_status()
+ {
+ Order::factory()->pending()->count(2)->create();
+ Order::factory()->processing()->count(3)->create();
+ Order::factory()->completed()->count(1)->create();
+
+ $this->assertCount(2, Order::withStatus(OrderStatus::PENDING)->get());
+ $this->assertCount(3, Order::withStatus(OrderStatus::PROCESSING)->get());
+ $this->assertCount(1, Order::completed()->get());
+ }
+
+ #[Test]
+ public function order_can_be_scoped_by_active_status()
+ {
+ Order::factory()->pending()->create();
+ Order::factory()->processing()->create();
+ Order::factory()->shipped()->create();
+ Order::factory()->completed()->create();
+ Order::factory()->cancelled()->create();
+
+ $this->assertCount(3, Order::active()->get());
+ }
+
+ #[Test]
+ public function order_can_be_scoped_by_paid_status()
+ {
+ Order::factory()->create(['amount_total' => 10000, 'amount_paid' => 10000]);
+ Order::factory()->create(['amount_total' => 10000, 'amount_paid' => 5000]);
+ Order::factory()->create(['amount_total' => 10000, 'amount_paid' => 0]);
+
+ $this->assertCount(1, Order::paid()->get());
+ $this->assertCount(2, Order::unpaid()->get());
+ }
+
+ #[Test]
+ public function order_can_be_scoped_by_customer()
+ {
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+
+ Order::factory()->forCustomer($user1)->count(3)->create();
+ Order::factory()->forCustomer($user2)->count(2)->create();
+
+ $this->assertCount(3, Order::forCustomer($user1)->get());
+ $this->assertCount(2, Order::forCustomer($user2)->get());
+ }
+
+ #[Test]
+ public function order_can_be_scoped_by_date_range()
+ {
+ Order::factory()->create(['created_at' => now()->subDays(10)]);
+ Order::factory()->create(['created_at' => now()->subDays(5)]);
+ Order::factory()->create(['created_at' => now()]);
+
+ $result = Order::createdBetween(now()->subDays(7), now())->get();
+
+ $this->assertCount(2, $result);
+ }
+
+ // =========================================================================
+ // ORDER META TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_can_store_and_retrieve_meta()
+ {
+ $order = Order::factory()->create();
+
+ $order->updateMetaKey('custom_field', 'custom_value');
+
+ $this->assertEquals('custom_value', $order->getMeta('custom_field'));
+ }
+
+ #[Test]
+ public function order_can_store_addresses()
+ {
+ $order = Order::factory()->withBillingAddress()->withShippingAddress()->create();
+
+ $this->assertNotNull($order->billing_address);
+ $this->assertNotNull($order->shipping_address);
+ $this->assertNotNull($order->billing_address->first_name);
+ $this->assertNotNull($order->shipping_address->city);
+ }
+
+ // =========================================================================
+ // ORDER FACTORY STATES TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_factory_creates_paid_state_correctly()
+ {
+ $order = Order::factory()->paid()->create();
+
+ $this->assertTrue($order->is_fully_paid);
+ $this->assertEquals(OrderStatus::PROCESSING, $order->status);
+ $this->assertNotNull($order->paid_at);
+ }
+
+ #[Test]
+ public function order_factory_creates_refunded_state_correctly()
+ {
+ $order = Order::factory()->refunded()->create();
+
+ $this->assertEquals(OrderStatus::REFUNDED, $order->status);
+ $this->assertEquals($order->amount_paid, $order->amount_refunded);
+ $this->assertNotNull($order->refunded_at);
+ }
+
+ // =========================================================================
+ // ORDER MONEY FORMATTING TESTS
+ // =========================================================================
+
+ #[Test]
+ public function order_formats_money_correctly()
+ {
+ $this->assertEquals('USD 100.00', Order::formatMoney(10000, 'usd'));
+ $this->assertEquals('EUR 50.50', Order::formatMoney(5050, 'eur'));
+ }
+}
diff --git a/tests/Unit/StripeWebhookOrderTest.php b/tests/Unit/StripeWebhookOrderTest.php
new file mode 100644
index 0000000..494decf
--- /dev/null
+++ b/tests/Unit/StripeWebhookOrderTest.php
@@ -0,0 +1,553 @@
+ true]);
+ $this->controller = new StripeWebhookController();
+ }
+
+ /**
+ * Call a protected method on the controller
+ */
+ protected function invokeMethod(string $method, array $args = [])
+ {
+ $reflection = new ReflectionClass($this->controller);
+ $method = $reflection->getMethod($method);
+ $method->setAccessible(true);
+ return $method->invokeArgs($this->controller, $args);
+ }
+
+ /**
+ * Create a mock session object
+ */
+ protected function createMockSession(array $overrides = []): object
+ {
+ return (object) array_merge([
+ 'id' => 'cs_test_' . uniqid(),
+ 'payment_intent' => 'pi_test_' . uniqid(),
+ 'metadata' => (object) ['cart_id' => null],
+ 'client_reference_id' => null,
+ 'amount_total' => 10000, // 100.00
+ 'currency' => 'usd',
+ 'payment_status' => 'paid',
+ 'customer' => 'cus_test_123',
+ ], $overrides);
+ }
+
+ /**
+ * Create a mock charge object
+ */
+ protected function createMockCharge(array $overrides = []): object
+ {
+ return (object) array_merge([
+ 'id' => 'ch_test_' . uniqid(),
+ 'payment_intent' => 'pi_test_' . uniqid(),
+ 'amount' => 10000, // 100.00
+ 'amount_refunded' => 0,
+ 'currency' => 'usd',
+ 'failure_message' => null,
+ 'failure_code' => null,
+ 'receipt_url' => 'https://receipt.stripe.com/test',
+ ], $overrides);
+ }
+
+ /**
+ * Create a product for testing
+ */
+ protected function createProduct(float $price = 100.00): Product
+ {
+ return Product::factory()->withPrices(unit_amount: $price)->create([
+ 'manage_stock' => false,
+ ]);
+ }
+
+ #[Test]
+ public function checkout_session_completed_creates_order_payment()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+ $this->assertNotNull($order);
+ $this->assertEquals(0, $order->amount_paid);
+
+ // Simulate checkout session completed
+ $session = $this->createMockSession([
+ 'metadata' => (object) ['cart_id' => $cart->id],
+ 'amount_total' => 10000, // 100.00
+ ]);
+
+ $this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
+
+ $order->refresh();
+ $this->assertEquals(100.00, $order->amount_paid);
+ $this->assertEquals(OrderStatus::PROCESSING, $order->status);
+ }
+
+ #[Test]
+ public function checkout_session_completed_logs_payment_note()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(50.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+ $initialNoteCount = $order->notes()->count();
+
+ $session = $this->createMockSession([
+ 'metadata' => (object) ['cart_id' => $cart->id],
+ 'amount_total' => 5000,
+ 'payment_intent' => 'pi_test_12345',
+ ]);
+
+ $this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
+
+ $order->refresh();
+ $this->assertGreaterThan($initialNoteCount, $order->notes()->count());
+
+ $paymentNote = $order->notes()->where('type', OrderNote::TYPE_PAYMENT)->first();
+ $this->assertNotNull($paymentNote);
+ }
+
+ #[Test]
+ public function checkout_session_failed_updates_order_status()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+
+ $session = $this->createMockSession([
+ 'metadata' => (object) ['cart_id' => $cart->id],
+ ]);
+
+ $this->invokeMethod('handleCheckoutSessionFailed', [$session]);
+
+ $order->refresh();
+ $this->assertEquals(OrderStatus::FAILED, $order->status);
+ }
+
+ #[Test]
+ public function checkout_session_failed_adds_payment_note()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(50.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+
+ $session = $this->createMockSession([
+ 'metadata' => (object) ['cart_id' => $cart->id],
+ ]);
+
+ $this->invokeMethod('handleCheckoutSessionFailed', [$session]);
+
+ $failedNote = $order->notes()
+ ->where('type', OrderNote::TYPE_PAYMENT)
+ ->where('content', 'like', '%failed%')
+ ->first();
+
+ $this->assertNotNull($failedNote);
+ }
+
+ #[Test]
+ public function checkout_session_expired_adds_system_note()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(50.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+
+ $session = $this->createMockSession([
+ 'metadata' => (object) ['cart_id' => $cart->id],
+ ]);
+
+ $this->invokeMethod('handleCheckoutSessionExpired', [$session]);
+
+ $expiredNote = $order->notes()
+ ->where('type', OrderNote::TYPE_SYSTEM)
+ ->where('content', 'like', '%expired%')
+ ->first();
+
+ $this->assertNotNull($expiredNote);
+ }
+
+ #[Test]
+ public function charge_refunded_records_refund_on_order()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+
+ // First record a payment (amount in cents: 100.00 * 100 = 10000)
+ $order->recordPayment(10000, 'pi_test_123', 'stripe', 'stripe');
+ $this->assertTrue($order->is_fully_paid);
+
+ // Update the order's payment_reference so we can find it
+ $order->update(['payment_reference' => 'pi_test_123']);
+
+ // Now simulate a refund
+ $charge = $this->createMockCharge([
+ 'payment_intent' => 'pi_test_123',
+ 'amount_refunded' => 5000, // 50.00 in cents
+ ]);
+
+ $this->invokeMethod('handleChargeRefunded', [$charge]);
+
+ $order->refresh();
+ $this->assertEquals(50, $order->amount_refunded);
+ }
+
+ #[Test]
+ public function charge_dispute_created_puts_order_on_hold()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+ $order->recordPayment(100, 'ch_test_dispute', 'stripe', 'stripe');
+ $order->update([
+ 'status' => OrderStatus::PROCESSING,
+ 'payment_reference' => 'ch_test_dispute',
+ ]);
+
+ $dispute = (object) [
+ 'id' => 'dp_test_123',
+ 'charge' => 'ch_test_dispute',
+ 'amount' => 10000,
+ 'reason' => 'fraudulent',
+ ];
+
+ $this->invokeMethod('handleChargeDisputeCreated', [$dispute]);
+
+ $order->refresh();
+ $this->assertEquals(OrderStatus::ON_HOLD, $order->status);
+ }
+
+ #[Test]
+ public function charge_dispute_created_adds_payment_note()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+ $order->update(['payment_reference' => 'ch_test_dispute2']);
+
+ $dispute = (object) [
+ 'id' => 'dp_test_456',
+ 'charge' => 'ch_test_dispute2',
+ 'amount' => 10000,
+ 'reason' => 'product_not_received',
+ ];
+
+ $this->invokeMethod('handleChargeDisputeCreated', [$dispute]);
+
+ $disputeNote = $order->notes()
+ ->where('type', OrderNote::TYPE_PAYMENT)
+ ->where('content', 'like', '%dispute%')
+ ->first();
+
+ $this->assertNotNull($disputeNote);
+ }
+
+ #[Test]
+ public function charge_dispute_closed_restores_order_if_won()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+ $order->update([
+ 'status' => OrderStatus::ON_HOLD,
+ 'payment_reference' => 'ch_test_won',
+ ]);
+
+ $dispute = (object) [
+ 'id' => 'dp_test_789',
+ 'charge' => 'ch_test_won',
+ 'status' => 'won',
+ ];
+
+ $this->invokeMethod('handleChargeDisputeClosed', [$dispute]);
+
+ $order->refresh();
+ $this->assertEquals(OrderStatus::PROCESSING, $order->status);
+ }
+
+ #[Test]
+ public function charge_dispute_closed_refunds_order_if_lost()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+ $order->update([
+ 'status' => OrderStatus::ON_HOLD,
+ 'payment_reference' => 'ch_test_lost',
+ ]);
+
+ $dispute = (object) [
+ 'id' => 'dp_test_000',
+ 'charge' => 'ch_test_lost',
+ 'status' => 'lost',
+ ];
+
+ $this->invokeMethod('handleChargeDisputeClosed', [$dispute]);
+
+ $order->refresh();
+ $this->assertEquals(OrderStatus::REFUNDED, $order->status);
+ }
+
+ #[Test]
+ public function refund_created_records_refund_on_order()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+ $order->recordPayment(100, 'ch_refund_test', 'stripe', 'stripe');
+ $order->update(['payment_reference' => 'ch_refund_test']);
+
+ $refund = (object) [
+ 'id' => 're_test_123',
+ 'charge' => 'ch_refund_test',
+ 'amount' => 2500, // 25.00
+ 'reason' => 'requested_by_customer',
+ 'status' => 'succeeded',
+ ];
+
+ $this->invokeMethod('handleRefundCreated', [$refund]);
+
+ $order->refresh();
+ $this->assertEquals(25, $order->amount_refunded);
+ }
+
+ #[Test]
+ public function refund_updated_adds_note_to_order()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+ $order->update(['payment_reference' => 'ch_refund_update']);
+
+ $refund = (object) [
+ 'id' => 're_test_456',
+ 'charge' => 'ch_refund_update',
+ 'status' => 'succeeded',
+ ];
+
+ $this->invokeMethod('handleRefundUpdated', [$refund]);
+
+ $refundNote = $order->notes()
+ ->where('type', OrderNote::TYPE_REFUND)
+ ->first();
+
+ $this->assertNotNull($refundNote);
+ }
+
+ #[Test]
+ public function find_order_by_payment_intent_works()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+ $order->update(['payment_reference' => 'pi_find_test']);
+
+ $foundOrder = $this->invokeMethod('findOrderByPaymentIntent', ['pi_find_test']);
+
+ $this->assertNotNull($foundOrder);
+ $this->assertEquals($order->id, $foundOrder->id);
+ }
+
+ #[Test]
+ public function find_order_by_charge_id_works()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+ $order->update(['payment_reference' => 'ch_find_test']);
+
+ $foundOrder = $this->invokeMethod('findOrderByChargeId', ['ch_find_test']);
+
+ $this->assertNotNull($foundOrder);
+ $this->assertEquals($order->id, $foundOrder->id);
+ }
+
+ #[Test]
+ public function find_order_returns_null_for_unknown_payment_intent()
+ {
+ $foundOrder = $this->invokeMethod('findOrderByPaymentIntent', ['pi_unknown_123']);
+ $this->assertNull($foundOrder);
+ }
+
+ #[Test]
+ public function handlers_return_false_for_missing_cart()
+ {
+ $session = $this->createMockSession([
+ 'metadata' => (object) ['cart_id' => 'nonexistent-cart-id'],
+ ]);
+
+ $result = $this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
+ $this->assertFalse($result);
+ }
+
+ #[Test]
+ public function handlers_return_false_for_missing_cart_id()
+ {
+ $session = $this->createMockSession([
+ 'metadata' => (object) ['cart_id' => null],
+ 'client_reference_id' => null,
+ ]);
+
+ $result = $this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
+ $this->assertFalse($result);
+ }
+
+ #[Test]
+ public function handler_uses_client_reference_id_as_fallback()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(50.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $session = $this->createMockSession([
+ 'metadata' => (object) [], // No cart_id in metadata
+ 'client_reference_id' => $cart->id, // But it's in client_reference_id
+ 'amount_total' => 5000,
+ ]);
+
+ $result = $this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
+
+ $this->assertTrue($result);
+
+ $order = $cart->fresh()->order;
+ $this->assertEquals(50.00, $order->amount_paid);
+ }
+
+ #[Test]
+ public function payment_intent_canceled_adds_note()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+ $order->update(['payment_reference' => 'pi_canceled_test']);
+
+ $paymentIntent = (object) [
+ 'id' => 'pi_canceled_test',
+ ];
+
+ $this->invokeMethod('handlePaymentIntentCanceled', [$paymentIntent]);
+
+ $cancelNote = $order->notes()
+ ->where('type', OrderNote::TYPE_PAYMENT)
+ ->where('content', 'like', '%canceled%')
+ ->first();
+
+ $this->assertNotNull($cancelNote);
+ }
+
+ #[Test]
+ public function charge_failed_adds_failure_note_to_order()
+ {
+ $customer = User::factory()->create();
+ $product = $this->createProduct(100.00);
+
+ $customer->addToCart($product);
+ $cart = $customer->checkoutCart();
+
+ $order = $cart->fresh()->order;
+ $order->update(['payment_reference' => 'pi_charge_fail']);
+
+ $charge = $this->createMockCharge([
+ 'id' => 'ch_failed_test',
+ 'payment_intent' => 'pi_charge_fail',
+ 'failure_message' => 'Your card was declined.',
+ 'failure_code' => 'card_declined',
+ ]);
+
+ $this->invokeMethod('handleChargeFailed', [$charge]);
+
+ $failNote = $order->notes()
+ ->where('type', OrderNote::TYPE_PAYMENT)
+ ->where('content', 'like', '%failed%')
+ ->first();
+
+ $this->assertNotNull($failNote);
+ $this->assertStringContainsString('declined', $failNote->content);
+ }
+}