AM orders

This commit is contained in:
Fabian @ Blax Software 2025-12-29 09:59:02 +01:00
parent 1486424229
commit 9c1fcd6cfd
18 changed files with 5017 additions and 44 deletions

View File

@ -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,

View File

@ -0,0 +1,237 @@
<?php
namespace Blax\Shop\Database\Factories;
use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Order;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
class OrderFactory extends Factory
{
protected $model = Order::class;
public function definition(): array
{
$subtotal = $this->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}'),
]);
}
}

View File

@ -0,0 +1,151 @@
<?php
namespace Blax\Shop\Database\Factories;
use Blax\Shop\Models\Order;
use Blax\Shop\Models\OrderNote;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
class OrderNoteFactory extends Factory
{
protected $model = OrderNote::class;
public function definition(): array
{
return [
'content' => $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,
]);
}
}

View File

@ -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'));

View File

@ -0,0 +1,284 @@
<?php
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
use Stripe\Stripe;
use Stripe\WebhookEndpoint;
class ShopSetupStripeWebhooksCommand extends Command
{
protected $signature = 'shop:setup-stripe-webhooks
{--url= : The webhook URL (defaults to APP_URL/api/shop/stripe/webhook)}
{--list : List existing webhooks instead of creating}
{--delete= : Delete a webhook by ID}
{--update= : Update an existing webhook by ID}';
protected $description = 'Setup Stripe webhook endpoints for the shop package';
/**
* The webhook events that the shop package needs to receive
*/
protected array $requiredEvents = [
// 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',
];
public function handle(): int
{
if (!config('shop.stripe.enabled')) {
$this->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(" <comment>{$group}:</comment>");
foreach ($events as $event) {
$this->line("{$event}");
}
}
}
}

227
src/Enums/OrderStatus.php Normal file
View File

@ -0,0 +1,227 @@
<?php
namespace Blax\Shop\Enums;
/**
* Order status enum representing the lifecycle of an order.
*
* Inspired by WooCommerce order statuses with additional e-commerce best practices.
*/
enum OrderStatus: string
{
/**
* Order received but awaiting payment confirmation.
*/
case PENDING = 'pending';
/**
* Payment received and order is being processed.
*/
case PROCESSING = 'processing';
/**
* Order is on hold, awaiting further action (manual review, stock, etc.)
*/
case ON_HOLD = 'on_hold';
/**
* Order is being prepared (packing, manufacturing, etc.)
*/
case IN_PREPARATION = 'in_preparation';
/**
* Order is ready for pickup (for local pickup orders).
*/
case READY_FOR_PICKUP = 'ready_for_pickup';
/**
* Order has been shipped and is in transit.
*/
case SHIPPED = 'shipped';
/**
* Order has been delivered to the customer.
*/
case DELIVERED = 'delivered';
/**
* Order is complete - all actions have been fulfilled.
*/
case COMPLETED = 'completed';
/**
* Order has been cancelled.
*/
case CANCELLED = 'cancelled';
/**
* Order has been fully or partially refunded.
*/
case REFUNDED = 'refunded';
/**
* Order payment or processing failed.
*/
case FAILED = 'failed';
/**
* Get human-readable label for the status.
*/
public function label(): string
{
return match ($this) {
self::PENDING => '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<OrderStatus>
*/
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());
}
}

View File

@ -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;
}
/**

View File

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

631
src/Models/Order.php Normal file
View File

@ -0,0 +1,631 @@
<?php
namespace Blax\Shop\Models;
use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Enums\PurchaseStatus;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
/**
* Order model representing a completed/paid cart.
*
* Orders are created when a cart is converted (checked out) and represent
* a customer's purchase transaction with full tracking capabilities.
*/
class Order extends Model
{
use HasUuids, HasFactory, SoftDeletes;
protected $fillable = [
'order_number',
'cart_id',
'customer_type',
'customer_id',
'status',
'currency',
'amount_subtotal',
'amount_discount',
'amount_shipping',
'amount_tax',
'amount_total',
'amount_paid',
'amount_refunded',
'payment_method',
'payment_provider',
'payment_reference',
'billing_address',
'shipping_address',
'customer_note',
'internal_note',
'ip_address',
'user_agent',
'completed_at',
'paid_at',
'shipped_at',
'delivered_at',
'cancelled_at',
'refunded_at',
'meta',
];
protected $casts = [
'status' => 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;
}
}

312
src/Models/OrderNote.php Normal file
View File

@ -0,0 +1,312 @@
<?php
namespace Blax\Shop\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* OrderNote model for tracking order activity and notes.
*
* Similar to WooCommerce order notes, this provides a complete audit trail
* of all activities, status changes, and communications related to an order.
*/
class OrderNote extends Model
{
use HasUuids, HasFactory;
/**
* Note types for categorization.
*/
public const TYPE_NOTE = 'note';
public const TYPE_STATUS_CHANGE = 'status_change';
public const TYPE_PAYMENT = 'payment';
public const TYPE_REFUND = 'refund';
public const TYPE_SHIPPING = 'shipping';
public const TYPE_CUSTOMER = 'customer';
public const TYPE_SYSTEM = 'system';
public const TYPE_EMAIL = 'email';
public const TYPE_WEBHOOK = 'webhook';
protected $fillable = [
'order_id',
'author_type',
'author_id',
'content',
'type',
'is_customer_note',
'meta',
];
protected $casts = [
'is_customer_note' => '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,
]);
}
}

View File

@ -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,
]);
}
}

148
src/Traits/HasOrders.php Normal file
View File

@ -0,0 +1,148 @@
<?php
namespace Blax\Shop\Traits;
use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Models\Order;
use Illuminate\Database\Eloquent\Relations\MorphMany;
trait HasOrders
{
/**
* Get all orders for this customer.
*/
public function orders(): MorphMany
{
return $this->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();
}
}

View File

@ -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

View File

@ -0,0 +1,586 @@
<?php
namespace Blax\Shop\Tests\Feature;
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\Product;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
class OrderCheckoutFlowTest extends TestCase
{
use RefreshDatabase;
// =========================================================================
// CHECKOUT ORDER CREATION TESTS
// =========================================================================
#[Test]
public function checkout_creates_order_from_cart()
{
$user = User::factory()->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());
}
}

View File

@ -0,0 +1,343 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Models\Order;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
class HasOrdersTraitTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function user_can_have_orders_relationship()
{
$user = User::factory()->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());
}
}

View File

@ -0,0 +1,394 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Models\Order;
use Blax\Shop\Models\OrderNote;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
class OrderNoteTest extends TestCase
{
use RefreshDatabase;
// =========================================================================
// ORDER NOTE CREATION TESTS
// =========================================================================
#[Test]
public function order_note_can_be_created()
{
$order = Order::factory()->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);
}
}

602
tests/Unit/OrderTest.php Normal file
View File

@ -0,0 +1,602 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Models\Order;
use Blax\Shop\Models\OrderNote;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
class OrderTest extends TestCase
{
use RefreshDatabase;
// =========================================================================
// ORDER CREATION TESTS
// =========================================================================
#[Test]
public function order_can_be_created_with_factory()
{
$order = Order::factory()->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'));
}
}

View File

@ -0,0 +1,553 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Http\Controllers\StripeWebhookController;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Order;
use Blax\Shop\Models\OrderNote;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use ReflectionClass;
use Workbench\App\Models\User;
class StripeWebhookOrderTest extends TestCase
{
use RefreshDatabase;
protected StripeWebhookController $controller;
protected function setUp(): void
{
parent::setUp();
config(['shop.stripe.enabled' => 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);
}
}