diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php index af3799a..9ff9282 100644 --- a/database/factories/OrderFactory.php +++ b/database/factories/OrderFactory.php @@ -234,4 +234,28 @@ class OrderFactory extends Factory 'payment_reference' => $reference ?? 'pi_' . $this->faker->regexify('[A-Za-z0-9]{24}'), ]); } + + /** + * Set booking date range. + */ + public function withDateRange( + \DateTimeInterface $from, + \DateTimeInterface $until + ): static { + return $this->state([ + 'from' => $from, + 'until' => $until, + ]); + } + + /** + * Set as a booking order with default date range. + */ + public function booking(): static + { + return $this->state([ + 'from' => now()->addDay(), + 'until' => now()->addDays(3), + ]); + } } diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub index 8807784..e6c5c4d 100644 --- a/database/migrations/create_blax_shop_tables.php.stub +++ b/database/migrations/create_blax_shop_tables.php.stub @@ -410,6 +410,10 @@ return new class extends Migration $table->string('ip_address')->nullable(); $table->text('user_agent')->nullable(); + // Booking date range (for booking-related orders) + $table->timestamp('from')->nullable(); + $table->timestamp('until')->nullable(); + // Important timestamps $table->timestamp('completed_at')->nullable(); $table->timestamp('paid_at')->nullable(); diff --git a/src/Models/Order.php b/src/Models/Order.php index c2c68df..b7285cb 100644 --- a/src/Models/Order.php +++ b/src/Models/Order.php @@ -47,6 +47,8 @@ class Order extends Model 'internal_note', 'ip_address', 'user_agent', + 'from', + 'until', 'completed_at', 'paid_at', 'shipped_at', @@ -68,6 +70,8 @@ class Order extends Model 'billing_address' => 'object', 'shipping_address' => 'object', 'meta' => 'object', + 'from' => 'datetime', + 'until' => 'datetime', 'completed_at' => 'datetime', 'paid_at' => 'datetime', 'shipped_at' => 'datetime', @@ -458,20 +462,27 @@ class Order extends Model /** * Add a note to the order. + * + * @param string $content The note content + * @param string $type The note type (note, status_change, payment, etc.) + * @param bool $isCustomerNote Whether the note is visible to the customer + * @param \Illuminate\Database\Eloquent\Model|null $author The author model (User, Admin, etc.) + * @param array|object|null $meta Additional metadata */ public function addNote( string $content, string $type = 'note', bool $isCustomerNote = false, - ?string $authorType = null, - ?string $authorId = null + $author = null, + $meta = null ): OrderNote { return $this->notes()->create([ 'content' => $content, 'type' => $type, 'is_customer_note' => $isCustomerNote, - 'author_type' => $authorType, - 'author_id' => $authorId, + 'author_type' => $author ? get_class($author) : null, + 'author_id' => $author?->getKey(), + 'meta' => $meta, ]); } @@ -841,6 +852,8 @@ class Order extends Model 'amount_total' => (int) $cart->getTotal(), 'amount_paid' => 0, 'amount_refunded' => 0, + 'from' => $cart->from, + 'until' => $cart->until, 'status' => OrderStatus::PENDING, ]); diff --git a/tests/Unit/Order/OrderNoteTest.php b/tests/Unit/Order/OrderNoteTest.php index a774d30..718bd7e 100644 --- a/tests/Unit/Order/OrderNoteTest.php +++ b/tests/Unit/Order/OrderNoteTest.php @@ -153,8 +153,7 @@ class OrderNoteTest extends TestCase 'Note from user', 'note', false, - get_class($user), - $user->id + $user ); $this->assertEquals(get_class($user), $note->author_type); diff --git a/tests/Unit/Order/OrderTest.php b/tests/Unit/Order/OrderTest.php index 97a396c..125ad40 100644 --- a/tests/Unit/Order/OrderTest.php +++ b/tests/Unit/Order/OrderTest.php @@ -599,4 +599,299 @@ class OrderTest extends TestCase $this->assertEquals('USD 100.00', Order::formatMoney(10000, 'usd')); $this->assertEquals('EUR 50.50', Order::formatMoney(5050, 'eur')); } + + // ========================================================================= + // ORDER BOOKING DATE RANGE TESTS + // ========================================================================= + + #[Test] + public function order_can_have_from_and_until_dates() + { + $from = now()->addDay(); + $until = now()->addDays(5); + + $order = Order::factory()->withDateRange($from, $until)->create(); + + $this->assertEquals($from->format('Y-m-d H:i:s'), $order->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $order->until->format('Y-m-d H:i:s')); + } + + #[Test] + public function order_factory_booking_state_sets_default_dates() + { + $order = Order::factory()->booking()->create(); + + $this->assertNotNull($order->from); + $this->assertNotNull($order->until); + $this->assertTrue($order->from->lt($order->until)); + } + + #[Test] + public function order_created_from_cart_inherits_booking_dates() + { + $user = User::factory()->create(); + $from = now()->addDay(); + $until = now()->addDays(3); + + $cart = Cart::factory()->forCustomer($user)->create([ + 'converted_at' => now(), + 'from' => $from, + 'until' => $until, + ]); + + $order = Order::createFromCart($cart); + + $this->assertEquals($from->format('Y-m-d H:i:s'), $order->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $order->until->format('Y-m-d H:i:s')); + } + + #[Test] + public function order_without_booking_dates_has_null_from_until() + { + $user = User::factory()->create(); + $cart = Cart::factory()->forCustomer($user)->create([ + 'converted_at' => now(), + ]); + + $order = Order::createFromCart($cart); + + $this->assertNull($order->from); + $this->assertNull($order->until); + } + + // ========================================================================= + // ORDER AUTOMATIC LOG CREATION TESTS + // ========================================================================= + + #[Test] + public function order_status_change_automatically_creates_log() + { + $order = Order::factory()->pending()->create(); + + $order->updateStatus(OrderStatus::PROCESSING); + + $statusNote = $order->notes() + ->where('type', OrderNote::TYPE_STATUS_CHANGE) + ->first(); + + $this->assertNotNull($statusNote); + $this->assertStringContainsString('Pending Payment', $statusNote->content); + $this->assertStringContainsString('Processing', $statusNote->content); + } + + #[Test] + public function order_payment_automatically_creates_log() + { + $order = Order::factory()->pending()->create([ + 'amount_total' => 10000, + 'amount_paid' => 0, + ]); + + $order->recordPayment(5000, 'pi_test123', 'card', 'stripe'); + + $paymentNote = $order->notes() + ->where('type', OrderNote::TYPE_PAYMENT) + ->first(); + + $this->assertNotNull($paymentNote); + $this->assertStringContainsString('50.00', $paymentNote->content); + } + + #[Test] + public function order_refund_automatically_creates_log() + { + $order = Order::factory()->paid()->create([ + 'amount_total' => 10000, + 'amount_paid' => 10000, + ]); + + $order->recordRefund(3000, 'Partial refund'); + + $refundNote = $order->notes() + ->where('type', OrderNote::TYPE_REFUND) + ->first(); + + $this->assertNotNull($refundNote); + $this->assertStringContainsString('30.00', $refundNote->content); + } + + #[Test] + public function order_shipping_creates_log_with_tracking() + { + $order = Order::factory()->processing()->create(); + + $order->markAsShipped('TRACK123456', 'FedEx'); + + $shippingNote = $order->notes() + ->where('type', OrderNote::TYPE_STATUS_CHANGE) + ->orderBy('created_at', 'desc') + ->first(); + + $this->assertNotNull($shippingNote); + $this->assertStringContainsString('TRACK123456', $shippingNote->content); + $this->assertStringContainsString('FedEx', $shippingNote->content); + } + + #[Test] + public function order_cancellation_creates_log_with_reason() + { + $order = Order::factory()->pending()->create(); + + $order->cancel('Customer changed their mind'); + + $cancelNote = $order->notes() + ->where('type', OrderNote::TYPE_STATUS_CHANGE) + ->orderBy('created_at', 'desc') + ->first(); + + $this->assertNotNull($cancelNote); + $this->assertStringContainsString('Customer changed their mind', $cancelNote->content); + } + + // ========================================================================= + // ORDER MANUAL LOG CREATION TESTS + // ========================================================================= + + #[Test] + public function order_can_add_manual_internal_note() + { + $order = Order::factory()->create(); + + $note = $order->addNote('This is a manual internal note', OrderNote::TYPE_NOTE, false); + + $this->assertEquals('This is a manual internal note', $note->content); + $this->assertEquals(OrderNote::TYPE_NOTE, $note->type); + $this->assertFalse($note->is_customer_note); + } + + #[Test] + public function order_can_add_manual_customer_visible_note() + { + $order = Order::factory()->create(); + + $note = $order->addNote('Thank you for your order!', OrderNote::TYPE_CUSTOMER, true); + + $this->assertEquals('Thank you for your order!', $note->content); + $this->assertTrue($note->is_customer_note); + $this->assertCount(1, $order->customerNotes); + } + + #[Test] + public function order_can_add_note_with_author() + { + $order = Order::factory()->create(); + $admin = User::factory()->create(); + + $note = $order->addNote( + 'Admin reviewed this order', + OrderNote::TYPE_NOTE, + false, + $admin + ); + + $this->assertEquals($admin->id, $note->author_id); + $this->assertEquals(get_class($admin), $note->author_type); + $this->assertTrue($note->author->is($admin)); + } + + #[Test] + public function order_can_add_note_with_meta() + { + $order = Order::factory()->create(); + + $note = $order->addNote('Note with metadata', OrderNote::TYPE_SYSTEM, false, null, [ + 'source' => 'api', + 'request_id' => 'req_12345', + ]); + + $this->assertEquals('api', $note->meta->source); + $this->assertEquals('req_12345', $note->meta->request_id); + } + + #[Test] + public function order_notes_are_ordered_by_newest_first() + { + $order = Order::factory()->create(); + + $note1 = $order->addNote('First note'); + sleep(1); // Ensure different timestamps + $note2 = $order->addNote('Second note'); + + $notes = $order->notes()->get(); + + $this->assertEquals($note2->id, $notes->first()->id); + $this->assertEquals($note1->id, $notes->last()->id); + } + + #[Test] + public function order_logs_multiple_status_changes() + { + $order = Order::factory()->pending()->create(); + + $order->updateStatus(OrderStatus::PROCESSING); + $order->updateStatus(OrderStatus::IN_PREPARATION); + $order->updateStatus(OrderStatus::SHIPPED); + + $statusNotes = $order->notes() + ->where('type', OrderNote::TYPE_STATUS_CHANGE) + ->get(); + + // Should have 3 status change notes + $this->assertCount(3, $statusNotes); + } + + #[Test] + public function order_logs_multiple_partial_payments() + { + $order = Order::factory()->pending()->create([ + 'amount_total' => 10000, + 'amount_paid' => 0, + ]); + + $order->recordPayment(3000); + $order->recordPayment(3000); + $order->recordPayment(4000); + + $paymentNotes = $order->notes() + ->where('type', OrderNote::TYPE_PAYMENT) + ->get(); + + $this->assertCount(3, $paymentNotes); + } + + // ========================================================================= + // ORDER LOG FILTERING TESTS + // ========================================================================= + + #[Test] + public function order_can_filter_internal_notes() + { + $order = Order::factory()->create(); + + $order->addNote('Internal 1', OrderNote::TYPE_NOTE, false); + $order->addNote('Internal 2', OrderNote::TYPE_NOTE, false); + $order->addNote('Customer visible', OrderNote::TYPE_CUSTOMER, true); + + $this->assertCount(2, $order->internalNotes); + } + + #[Test] + public function order_can_get_notes_by_type() + { + $order = Order::factory()->pending()->create([ + 'amount_total' => 10000, + ]); + + $order->addNote('Manual note', OrderNote::TYPE_NOTE); + $order->recordPayment(5000); + $order->updateStatus(OrderStatus::PROCESSING); + + $notesByType = $order->notes()->where('type', OrderNote::TYPE_NOTE)->get(); + $paymentsByType = $order->notes()->where('type', OrderNote::TYPE_PAYMENT)->get(); + $statusChangesByType = $order->notes()->where('type', OrderNote::TYPE_STATUS_CHANGE)->get(); + + $this->assertCount(1, $notesByType); + $this->assertCount(1, $paymentsByType); + $this->assertCount(1, $statusChangesByType); + } }