AM orders
This commit is contained in:
parent
1486424229
commit
9c1fcd6cfd
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue