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); + } +}