This commit is contained in:
Fabian @ Blax Software 2026-01-05 12:29:55 +01:00
parent a66fd7ccb8
commit 1a8f111110
5 changed files with 341 additions and 6 deletions

View File

@ -234,4 +234,28 @@ class OrderFactory extends Factory
'payment_reference' => $reference ?? 'pi_' . $this->faker->regexify('[A-Za-z0-9]{24}'), '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),
]);
}
} }

View File

@ -410,6 +410,10 @@ return new class extends Migration
$table->string('ip_address')->nullable(); $table->string('ip_address')->nullable();
$table->text('user_agent')->nullable(); $table->text('user_agent')->nullable();
// Booking date range (for booking-related orders)
$table->timestamp('from')->nullable();
$table->timestamp('until')->nullable();
// Important timestamps // Important timestamps
$table->timestamp('completed_at')->nullable(); $table->timestamp('completed_at')->nullable();
$table->timestamp('paid_at')->nullable(); $table->timestamp('paid_at')->nullable();

View File

@ -47,6 +47,8 @@ class Order extends Model
'internal_note', 'internal_note',
'ip_address', 'ip_address',
'user_agent', 'user_agent',
'from',
'until',
'completed_at', 'completed_at',
'paid_at', 'paid_at',
'shipped_at', 'shipped_at',
@ -68,6 +70,8 @@ class Order extends Model
'billing_address' => 'object', 'billing_address' => 'object',
'shipping_address' => 'object', 'shipping_address' => 'object',
'meta' => 'object', 'meta' => 'object',
'from' => 'datetime',
'until' => 'datetime',
'completed_at' => 'datetime', 'completed_at' => 'datetime',
'paid_at' => 'datetime', 'paid_at' => 'datetime',
'shipped_at' => 'datetime', 'shipped_at' => 'datetime',
@ -458,20 +462,27 @@ class Order extends Model
/** /**
* Add a note to the order. * 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( public function addNote(
string $content, string $content,
string $type = 'note', string $type = 'note',
bool $isCustomerNote = false, bool $isCustomerNote = false,
?string $authorType = null, $author = null,
?string $authorId = null $meta = null
): OrderNote { ): OrderNote {
return $this->notes()->create([ return $this->notes()->create([
'content' => $content, 'content' => $content,
'type' => $type, 'type' => $type,
'is_customer_note' => $isCustomerNote, 'is_customer_note' => $isCustomerNote,
'author_type' => $authorType, 'author_type' => $author ? get_class($author) : null,
'author_id' => $authorId, 'author_id' => $author?->getKey(),
'meta' => $meta,
]); ]);
} }
@ -841,6 +852,8 @@ class Order extends Model
'amount_total' => (int) $cart->getTotal(), 'amount_total' => (int) $cart->getTotal(),
'amount_paid' => 0, 'amount_paid' => 0,
'amount_refunded' => 0, 'amount_refunded' => 0,
'from' => $cart->from,
'until' => $cart->until,
'status' => OrderStatus::PENDING, 'status' => OrderStatus::PENDING,
]); ]);

View File

@ -153,8 +153,7 @@ class OrderNoteTest extends TestCase
'Note from user', 'Note from user',
'note', 'note',
false, false,
get_class($user), $user
$user->id
); );
$this->assertEquals(get_class($user), $note->author_type); $this->assertEquals(get_class($user), $note->author_type);

View File

@ -599,4 +599,299 @@ class OrderTest extends TestCase
$this->assertEquals('USD 100.00', Order::formatMoney(10000, 'usd')); $this->assertEquals('USD 100.00', Order::formatMoney(10000, 'usd'));
$this->assertEquals('EUR 50.50', Order::formatMoney(5050, 'eur')); $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);
}
} }