Squashed commit of the following:

commit 1dc4e29c69
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 13:58:03 2026 -0300

    refactor(reservations): unify expiration command

commit 093e894cc3
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 13:48:09 2026 -0300

    feat(cart): expire abandoned stock reservations

commit fdf0f3328f
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:53:12 2026 -0300

    refactor(stock): implement expiration for stock reservations and add configuration

commit 8d6bcdcc43
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:38:21 2026 -0300

    refactor(cart): invalidate payment on actual changes

commit 3206e293eb
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:24:48 2026 -0300

    refactor(cart): own checkout item editing

commit aed99bd05e
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:14:57 2026 -0300

    refactor(checkout): remove legacy purchase item reservations

commit f1649e0e4b
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:06:29 2026 -0300

    refactor(checkout): materialize purchase items on confirmation

commit e6c4b40a37
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:06:19 2026 -0300

    feat(inventory): add traceable cart stock reservations
This commit is contained in:
2026-08-19 13:59:25 -03:00
parent 15878cd9ba
commit 58cbeae9d3
45 changed files with 1391 additions and 544 deletions

View File

@@ -30,7 +30,7 @@ class StorePurchaseTest extends TestCase
Queue::fake();
}
public function test_it_creates_an_independent_purchase_snapshot_from_cart(): void
public function test_it_starts_checkout_from_cart_without_materializing_purchase_items(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$tenant->update([
@@ -90,7 +90,7 @@ class StorePurchaseTest extends TestCase
$response->assertJsonPath('data.email', null);
$response->assertJsonPath('data.tenant_codigo', 'sonder');
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
$response->assertJsonPath('data.items_source', 'purchase');
$response->assertJsonPath('data.items_source', 'cart');
$response->assertJsonCount(1, 'data.items');
$response->assertJsonPath('data.subtotal', '100.00');
$response->assertJsonPath('data.total', '100.00');
@@ -109,12 +109,12 @@ class StorePurchaseTest extends TestCase
'status' => Purchase::STATUS_CREATED,
'total' => 100,
]);
$this->assertDatabaseHas('compra_items', [
'compra_id' => $purchaseId,
'source_catalog_item_id' => $catalogItem->id,
'source_variant_id' => $variant->id,
'cantidad' => 2,
'reservation_status' => 'active',
$this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $inventory->id,
'purchase_id' => $purchaseId,
'quantity' => 2,
'status' => 'active',
]);
$this->assertDatabaseHas('carritos', [
'id' => $cartId,
@@ -133,9 +133,22 @@ class StorePurchaseTest extends TestCase
'real_stock' => 10,
'reserved_stock' => 2,
]);
$catalogItem->update([
'nombre' => 'Updated Product',
'precio' => '75.00',
]);
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/sonder/compras/{$purchaseId}")
->assertOk()
->assertJsonPath('data.items_source', 'cart')
->assertJsonPath('data.items.0.item_details.nombre', 'Updated Product')
->assertJsonPath('data.items.0.unit_price', '75.00')
->assertJsonPath('data.total', '150.00');
}
public function test_it_creates_a_direct_purchase_without_creating_or_changing_a_cart(): void
public function test_it_creates_a_direct_purchase_with_a_technical_checkout_cart(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
@@ -152,18 +165,23 @@ class StorePurchaseTest extends TestCase
],
])
->assertCreated()
->assertJsonPath('data.cart_id', null)
->assertJsonPath('data.items_source', 'purchase')
->assertJsonPath('data.items_source', 'cart')
->assertJsonPath('data.items.0.quantity', 3)
->assertJsonPath('data.total', '150.00');
$this->assertDatabaseCount('carritos', 0);
$this->assertDatabaseHas('compra_items', [
$this->assertDatabaseHas('carritos', [
'id' => $response->json('data.cart_id'),
'status' => 'checkout',
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
]);
$this->assertDatabaseMissing('compra_items', [
'compra_id' => $response->json('data.id'),
'source_catalog_item_id' => $variant->catalog_item_id,
'source_variant_id' => $variant->id,
'cantidad' => 3,
'reservation_status' => 'active',
]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $variant->inventory_id,
'purchase_id' => $response->json('data.id'),
'quantity' => 3,
'status' => 'active',
]);
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
@@ -176,9 +194,9 @@ class StorePurchaseTest extends TestCase
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
$this->assertDatabaseHas('compra_items', [
'compra_id' => $response->json('data.id'),
'reservation_status' => 'released',
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => $response->json('data.id'),
'status' => 'released',
]);
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
@@ -215,7 +233,7 @@ class StorePurchaseTest extends TestCase
],
])
->assertCreated()
->assertJsonPath('data.cart_id', null)
->assertJsonPath('data.items_source', 'cart')
->assertJsonCount(2, 'data.items')
->assertJsonPath('data.items.0.source_variant_id', $firstVariant->id)
->assertJsonPath('data.items.1.source_variant_id', $secondVariant->id)
@@ -223,18 +241,22 @@ class StorePurchaseTest extends TestCase
$purchaseId = $response->json('data.id');
$this->assertDatabaseCount('carritos', 0);
$this->assertDatabaseHas('compra_items', [
'compra_id' => $purchaseId,
'source_variant_id' => $firstVariant->id,
'cantidad' => 1,
'reservation_status' => 'active',
$this->assertDatabaseHas('carritos', [
'id' => $response->json('data.cart_id'),
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
]);
$this->assertDatabaseHas('compra_items', [
'compra_id' => $purchaseId,
'source_variant_id' => $secondVariant->id,
'cantidad' => 1,
'reservation_status' => 'active',
$this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $firstVariant->inventory_id,
'purchase_id' => $purchaseId,
'quantity' => 1,
'status' => 'active',
]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $secondVariant->inventory_id,
'purchase_id' => $purchaseId,
'quantity' => 1,
'status' => 'active',
]);
$this->assertDatabaseHas('inventories', [
'id' => $firstVariant->inventory_id,
@@ -462,7 +484,7 @@ class StorePurchaseTest extends TestCase
]);
}
public function test_it_creates_purchase_items_before_checkout_and_updates_customer_data(): void
public function test_it_keeps_cart_items_during_checkout_and_updates_customer_data(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
@@ -479,7 +501,7 @@ class StorePurchaseTest extends TestCase
],
])
->assertCreated()
->assertJsonPath('data.items_source', 'purchase')
->assertJsonPath('data.items_source', 'cart')
->assertJsonCount(1, 'data.items')
->assertJsonPath('data.items.0.quantity', 2)
->assertJsonPath('data.dni', null)
@@ -537,23 +559,21 @@ class StorePurchaseTest extends TestCase
]);
}
public function test_it_updates_a_created_purchase_item_quantity_and_its_stock_reservation(): void
public function test_it_updates_a_checkout_cart_item_quantity_and_its_stock_reservation(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$itemId = $purchase->items->firstOrFail()->id;
$itemId = $purchase->cart->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
'quantity' => 4,
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
'cantidad' => 4,
])
->assertOk()
->assertJsonPath('data.items.0.quantity', 4)
->assertJsonPath('data.items.0.line_total', '200.00')
->assertJsonPath('data.subtotal', '200.00')
->assertJsonPath('data.total', '200.00');
->assertJsonPath('data.items.0.cantidad', 4)
->assertJsonPath('data.subtotal', '200.00');
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
@@ -567,12 +587,17 @@ class StorePurchaseTest extends TestCase
]);
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
'quantity' => 1,
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
'cantidad' => 1,
])
->assertOk()
->assertJsonPath('data.items.0.quantity', 1)
->assertJsonPath('data.total', '50.00');
->assertJsonPath('data.items.0.cantidad', 1)
->assertJsonPath('data.subtotal', '50.00');
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'total' => '50.00',
]);
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
@@ -587,16 +612,16 @@ class StorePurchaseTest extends TestCase
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$variant->catalogItem->update(['max_units_per_user' => 3]);
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$itemId = $purchase->items->firstOrFail()->id;
$itemId = $purchase->cart->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
'quantity' => 4,
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
'cantidad' => 4,
])
->assertUnprocessable()
->assertJsonValidationErrors('quantity');
->assertJsonValidationErrors('cantidad');
$this->assertDatabaseHas('compra_items', [
$this->assertDatabaseHas('carrito_items', [
'id' => $itemId,
'cantidad' => 2,
]);
@@ -606,7 +631,7 @@ class StorePurchaseTest extends TestCase
]);
}
public function test_it_reopens_a_pending_purchase_before_editing_items(): void
public function test_it_invalidates_a_pending_payment_when_the_checkout_cart_changes(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
@@ -620,17 +645,20 @@ class StorePurchaseTest extends TestCase
'qr_order_id' => 'stale-order',
'qr_code' => 'stale-qr',
]);
$itemId = $purchase->cart->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->postJson("/api/tenants/sonder/compras/{$purchase->id}/edit-items")
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
'cantidad' => 3,
])
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_CREATED)
->assertJsonPath('data.payment_method', null);
->assertJsonPath('data.items.0.cantidad', 3);
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'total' => '150.00',
]);
$this->assertDatabaseMissing('telepagos_qr', [
'compra_id' => $purchase->id,
@@ -638,6 +666,39 @@ class StorePurchaseTest extends TestCase
]);
}
public function test_a_no_op_checkout_cart_update_keeps_the_pending_payment_intact(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$purchase->update([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'payment_method' => 'qr',
]);
$purchase->telepagosQr()->create([
'qr_order_id' => 'current-order',
'qr_code' => 'current-qr',
]);
$itemId = $purchase->cart->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
'cantidad' => 2,
])
->assertOk();
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'payment_method' => 'qr',
]);
$this->assertDatabaseHas('telepagos_qr', [
'compra_id' => $purchase->id,
'qr_order_id' => 'current-order',
]);
}
public function test_start_checkout_rejects_customer_data(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
@@ -789,17 +850,20 @@ class StorePurchaseTest extends TestCase
$this->travel(31)->minutes();
$this->artisan('purchases:expire')
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 1')
->expectsOutput('Expired cart items: 0')
->assertSuccessful();
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'status' => Purchase::STATUS_EXPIRED,
]);
$this->assertDatabaseHas('compra_items', [
'compra_id' => $purchase->id,
'reservation_status' => 'released',
$this->assertDatabaseMissing('compra_items', ['compra_id' => $purchase->id]);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => null,
'quantity' => 3,
'status' => 'active',
]);
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
@@ -814,12 +878,13 @@ class StorePurchaseTest extends TestCase
'deleted_at' => null,
]);
$this->artisan('purchases:expire')
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart items: 0')
->assertSuccessful();
}
public function test_purchase_detail_uses_purchase_items_for_created_purchase(): void
public function test_purchase_detail_uses_cart_items_for_created_purchase(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create([
@@ -832,7 +897,7 @@ class StorePurchaseTest extends TestCase
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_CREATED)
->assertJsonPath('data.items_source', 'purchase')
->assertJsonPath('data.items_source', 'cart')
->assertJsonCount(1, 'data.items')
->assertJsonPath('data.items.0.quantity', 2)
->assertJsonPath('data.items.0.unit_price', '50.00')
@@ -863,7 +928,7 @@ class StorePurchaseTest extends TestCase
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$purchase->items()->firstOrFail()->update(['image_attachment_id' => $image->id]);
$variant->catalogItem->attachments()->attach($image->id, ['orden' => 0]);
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
@@ -871,7 +936,7 @@ class StorePurchaseTest extends TestCase
->assertJsonPath('data.items.0.item_details.imagen', null);
}
public function test_purchase_detail_uses_purchase_items_for_pending_payment_purchase(): void
public function test_purchase_detail_uses_cart_items_for_pending_payment_purchase(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create([
@@ -890,7 +955,7 @@ class StorePurchaseTest extends TestCase
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
->assertJsonPath('data.items_source', 'purchase')
->assertJsonPath('data.items_source', 'cart')
->assertJsonCount(1, 'data.items')
->assertJsonPath('data.items.0.quantity', 2)
->assertJsonPath('data.items.0.unit_price', '50.00')
@@ -925,6 +990,12 @@ class StorePurchaseTest extends TestCase
'reserved_stock' => 0,
'sold_units' => 2,
]);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => $purchase->id,
'inventory_id' => $variant->inventory_id,
'quantity' => 2,
'status' => 'committed',
]);
$this->assertSoftDeleted('carritos', [
'id' => $purchase->cart_id,
@@ -956,7 +1027,9 @@ class StorePurchaseTest extends TestCase
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$purchase->update(['cart_id' => null]);
$purchase->update(['payment_method' => 'transfer']);
app(CheckoutService::class)->confirmPurchase($purchase);
$purchase->refresh()->update(['cart_id' => null]);
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/sonder/compras/{$purchase->id}")