From 9fd34f900ffe74e347fceb8f2e680e88d28ae2cc Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 20 Aug 2026 17:01:06 -0300 Subject: [PATCH 1/3] fix(purchase): unify expired checkout errors --- .../Controllers/PurchaseController.php | 44 +++++++++------ .../Exceptions/PurchaseExpiredException.php | 13 +++++ .../Checkout/CompleteCheckoutService.php | 5 ++ .../Services/Checkout/EditCheckoutService.php | 6 ++ .../Checkout/ReleaseCheckoutService.php | 7 +++ .../Purchase/Services/PurchaseStateGuard.php | 24 ++++++++ bootstrap/app.php | 11 ++++ lang/en/api.php | 1 + lang/es/api.php | 1 + .../PurchaseExpiredExceptionResponseTest.php | 24 ++++++++ .../Unit/Purchase/PurchaseStateGuardTest.php | 56 +++++++++++++++++++ 11 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 app/Domains/Purchase/Exceptions/PurchaseExpiredException.php create mode 100644 app/Domains/Purchase/Services/PurchaseStateGuard.php create mode 100644 tests/Feature/Purchase/PurchaseExpiredExceptionResponseTest.php create mode 100644 tests/Unit/Purchase/PurchaseStateGuardTest.php diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 930ef48..4d25a08 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -12,6 +12,7 @@ use App\Domains\Purchase\Requests\UpdatePurchaseItemRequest; use App\Domains\Purchase\Resources\PurchaseResource; use App\Domains\Purchase\Services\Checkout\PurchaseResponseLoader; use App\Domains\Purchase\Services\CheckoutService; +use App\Domains\Purchase\Services\PurchaseStateGuard; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; @@ -132,42 +133,48 @@ class PurchaseController extends Controller Tenant $tenant, Purchase $compra, CheckoutService $checkoutService, + PurchaseStateGuard $purchaseState, ): JsonResponse { $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); $method = $request->validated('method'); - $totalAmount = $compra->calculateCurrentTotalAmount(); + $transferPayerDni = $method === 'transfer' + ? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni')) + : null; - $purchaseUpdate = [ - 'payment_method' => $method, - 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'expires_at' => now()->addMinutes( - max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)) - ), - 'total' => $totalAmount, - ]; - - if ($method === 'transfer') { - $purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni')); - } - - $updated = DB::transaction(function () use ($compra, $purchaseUpdate): bool { + $updated = DB::transaction(function () use ($compra, $method, $purchaseState, $transferPayerDni): bool { /** @var Purchase|null $purchase */ $purchase = Purchase::query() ->whereKey($compra->getKey()) ->lockForUpdate() ->first(); + if ($purchase === null) { + return false; + } + + $purchaseState->assertNotExpired($purchase); + if ( - $purchase === null - || ! in_array($purchase->status, [ + ! in_array($purchase->status, [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, ], true) - || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) ) { return false; } + $purchaseUpdate = [ + 'payment_method' => $method, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'expires_at' => now()->addMinutes( + max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)) + ), + 'total' => $purchase->calculateCurrentTotalAmount(), + ]; + + if ($transferPayerDni !== null) { + $purchaseUpdate['transfer_payer_dni'] = $transferPayerDni; + } $purchase->update($purchaseUpdate); return true; @@ -180,6 +187,7 @@ class PurchaseController extends Controller } $compra->refresh(); + $totalAmount = (float) $compra->total; $checkoutService->syncReservationExpiration($compra); if ($method === 'transfer') { diff --git a/app/Domains/Purchase/Exceptions/PurchaseExpiredException.php b/app/Domains/Purchase/Exceptions/PurchaseExpiredException.php new file mode 100644 index 0000000..629148f --- /dev/null +++ b/app/Domains/Purchase/Exceptions/PurchaseExpiredException.php @@ -0,0 +1,13 @@ +lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); if ($purchase->payment_method === null) { throw ValidationException::withMessages([ @@ -45,6 +48,7 @@ class CompleteCheckoutService { return DB::transaction(function () use ($purchase): Purchase { $purchase = $this->lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); if ($purchase->status === Purchase::STATUS_PAID) { return $this->loadPurchase($purchase); @@ -70,6 +74,7 @@ class CompleteCheckoutService { DB::transaction(function () use ($purchase): void { $purchase = $this->lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); if ($purchase->status === Purchase::STATUS_PAID) { return; diff --git a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php index 4b6aa33..58e7ec9 100644 --- a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php @@ -6,6 +6,7 @@ use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; +use App\Domains\Purchase\Services\PurchaseStateGuard; use App\Domains\Purchase\Services\UserPurchaseLimitService; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -20,6 +21,7 @@ class EditCheckoutService private readonly SourceCartService $sourceCart, private readonly PurchaseItemSnapshotFactory $snapshots, private readonly PurchaseResponseLoader $responses, + private readonly PurchaseStateGuard $purchaseState, ) {} /** @param array $customerData */ @@ -27,6 +29,7 @@ class EditCheckoutService { return DB::transaction(function () use ($purchase, $customerData): Purchase { $purchase = $this->lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); $this->assertEditable($purchase); $purchase->update($customerData); @@ -50,6 +53,7 @@ class EditCheckoutService $updateVariant, ): Purchase { $purchase = $this->lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) { throw ValidationException::withMessages([ @@ -182,6 +186,7 @@ class EditCheckoutService { return DB::transaction(function () use ($purchase): Purchase { $purchase = $this->lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); $this->assertEditable($purchase); if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsModification()) { @@ -208,6 +213,7 @@ class EditCheckoutService { return DB::transaction(function () use ($purchase, $purchaseItem): Purchase { $purchase = $this->lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); $this->assertEditable($purchase); if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsRemoval()) { diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index bdf17a4..77870d6 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -5,6 +5,7 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Services\PurchaseStateGuard; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; @@ -14,6 +15,8 @@ class ReleaseCheckoutService { public function __construct( private readonly StockReservationService $reservations, + private readonly SourceCartService $sourceCart, + private readonly PurchaseStateGuard $purchaseState, ) {} public function cancel(Purchase $purchase): Purchase @@ -70,6 +73,10 @@ class ReleaseCheckoutService return DB::transaction(function () use ($purchase, $targetStatus): Purchase { $purchase = $this->lockPurchase($purchase); + if ($targetStatus !== Purchase::STATUS_EXPIRED) { + $this->purchaseState->assertNotExpired($purchase); + } + if ($purchase->status === Purchase::STATUS_PAID) { if ($targetStatus === Purchase::STATUS_EXPIRED) { return $this->loadPurchase($purchase); diff --git a/app/Domains/Purchase/Services/PurchaseStateGuard.php b/app/Domains/Purchase/Services/PurchaseStateGuard.php new file mode 100644 index 0000000..45e8b92 --- /dev/null +++ b/app/Domains/Purchase/Services/PurchaseStateGuard.php @@ -0,0 +1,24 @@ +status === Purchase::STATUS_EXPIRED; + $hasExpiredByTime = in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true) + && $purchase->expires_at !== null + && $purchase->expires_at->isPast(); + + if ($hasExpiredStatus || $hasExpiredByTime) { + throw new PurchaseExpiredException; + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index da41ac0..6159d8d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,6 +2,7 @@ use App\Domains\Auth\Exceptions\AccountLockedException; use App\Domains\Purchase\Exceptions\InsufficientStockException; +use App\Domains\Purchase\Exceptions\PurchaseExpiredException; use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException; use App\Domains\Ticket\Exceptions\TicketNotAvailableException; use App\Http\Middleware\EnsureAdminAppTenant; @@ -102,6 +103,16 @@ return Application::configure(basePath: dirname(__DIR__)) 'maximum_addable_quantity' => $exception->maximumAddableQuantity, ], 422); }); + $exceptions->render(function (PurchaseExpiredException $exception, Request $request) { + if (! $request->is('api/*')) { + return null; + } + + return response()->json([ + 'code' => 'purchase.expired', + 'message' => $exception->getMessage(), + ], 422); + }); $exceptions->render(function (ModelNotFoundException $exception, Request $request) { if (! $request->is('api/*')) { return null; diff --git a/lang/en/api.php b/lang/en/api.php index aa37385..f0356ed 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -36,6 +36,7 @@ return [ 'variant_required' => 'You must select a variant for this item.', ], 'purchase' => [ + 'expired' => 'The purchase has expired. Please start a new purchase.', 'variant_change_disabled' => 'Variant changes are disabled for this purchase.', 'source_required' => 'A cart or direct item is required.', 'payment_method_required' => 'The purchase payment method must be selected before finalizing.', diff --git a/lang/es/api.php b/lang/es/api.php index 1fb1596..bfa17c4 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -36,6 +36,7 @@ return [ 'variant_required' => 'Debe seleccionar una variante para este ítem.', ], 'purchase' => [ + 'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.", 'variant_change_disabled' => 'El cambio de variante está deshabilitado para esta compra.', 'source_required' => 'Se requiere un carrito o un producto directo.', 'payment_method_required' => 'Debes seleccionar el método de pago antes de finalizar la compra.', diff --git a/tests/Feature/Purchase/PurchaseExpiredExceptionResponseTest.php b/tests/Feature/Purchase/PurchaseExpiredExceptionResponseTest.php new file mode 100644 index 0000000..49e4522 --- /dev/null +++ b/tests/Feature/Purchase/PurchaseExpiredExceptionResponseTest.php @@ -0,0 +1,24 @@ +getJson('/api/test/purchase-expired') + ->assertUnprocessable() + ->assertExactJson([ + 'code' => 'purchase.expired', + 'message' => __('api.purchase.expired'), + ]); + } +} diff --git a/tests/Unit/Purchase/PurchaseStateGuardTest.php b/tests/Unit/Purchase/PurchaseStateGuardTest.php new file mode 100644 index 0000000..f083fdd --- /dev/null +++ b/tests/Unit/Purchase/PurchaseStateGuardTest.php @@ -0,0 +1,56 @@ +guard = new PurchaseStateGuard; + } + + public function test_it_rejects_a_purchase_with_expired_status(): void + { + $purchase = (new Purchase)->forceFill([ + 'status' => Purchase::STATUS_EXPIRED, + 'expires_at' => null, + ]); + + $this->expectException(PurchaseExpiredException::class); + + $this->guard->assertNotExpired($purchase); + } + + public function test_it_rejects_an_active_purchase_when_its_deadline_has_passed(): void + { + $purchase = (new Purchase)->forceFill([ + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'expires_at' => now()->subMinute(), + ]); + + $this->expectException(PurchaseExpiredException::class); + + $this->guard->assertNotExpired($purchase); + } + + public function test_it_does_not_treat_a_paid_purchase_as_expired_by_time(): void + { + $purchase = (new Purchase)->forceFill([ + 'status' => Purchase::STATUS_PAID, + 'expires_at' => now()->subMinute(), + ]); + + $this->guard->assertNotExpired($purchase); + + $this->assertTrue(true); + } +} From 318e6559cbba66f696fcf4940d14b8f8e05bee52 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 20 Aug 2026 17:01:21 -0300 Subject: [PATCH 2/3] fix(cart): propagate expired checkout purchase errors --- app/Domains/Cart/Services/CartService.php | 32 +++++++++++------------ app/Domains/Cart/routes/api.php | 4 +-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index 044b11a..c6b1e79 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -7,6 +7,7 @@ use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Services\PurchaseStateGuard; use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -20,6 +21,7 @@ class CartService { public function __construct( private readonly StockReservationService $reservations, + private readonly PurchaseStateGuard $purchaseState, ) {} public function show(Tenant $tenant, Request $request): Cart @@ -116,10 +118,6 @@ class CartService ->where('cart_id', $cart->getKey()) ->where('tenant_codigo', $tenant->codigo) ->where('user_id', $user->getKey()) - ->whereIn('status', [ - Purchase::STATUS_CREATED, - Purchase::STATUS_PENDING_PAYMENT, - ]) ->whereDoesntHave('items') ->lockForUpdate() ->first(); @@ -128,10 +126,13 @@ class CartService throw new NotFoundHttpException('Checkout cart not found.'); } - if ($purchase->expires_at !== null && $purchase->expires_at->isPast()) { - throw ValidationException::withMessages([ - 'cart' => __('api.purchase.not_editable'), - ]); + $this->purchaseState->assertNotExpired($purchase); + + if (! in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true)) { + throw new NotFoundHttpException('Checkout cart not found.'); } /** @var Cart|null $checkoutCart */ @@ -246,10 +247,6 @@ class CartService ->where('cart_id', $cart->getKey()) ->where('tenant_codigo', $tenant->codigo) ->where('user_id', $user->getKey()) - ->whereIn('status', [ - Purchase::STATUS_CREATED, - Purchase::STATUS_PENDING_PAYMENT, - ]) ->whereDoesntHave('items') ->lockForUpdate() ->first(); @@ -258,10 +255,13 @@ class CartService throw new NotFoundHttpException('Checkout cart not found.'); } - if ($purchase->expires_at !== null && $purchase->expires_at->isPast()) { - throw ValidationException::withMessages([ - 'cart' => __('api.purchase.not_editable'), - ]); + $this->purchaseState->assertNotExpired($purchase); + + if (! in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true)) { + throw new NotFoundHttpException('Checkout cart not found.'); } /** @var Cart|null $checkoutCart */ diff --git a/app/Domains/Cart/routes/api.php b/app/Domains/Cart/routes/api.php index 2e03f58..07e73f0 100644 --- a/app/Domains/Cart/routes/api.php +++ b/app/Domains/Cart/routes/api.php @@ -14,6 +14,6 @@ Route::prefix('tenants/{tenant:codigo}') Route::prefix('tenants/{tenant:codigo}') ->middleware('auth:sanctum') ->group(function (): void { - Route::patch('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'updateCheckoutItem']); - Route::delete('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'removeCheckoutItem']); + Route::patch('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'updateCheckoutItem'])->withTrashed(); + Route::delete('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'removeCheckoutItem'])->withTrashed(); }); From 98d502a8effbdc93a450451abcb6581963f61b57 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 20 Aug 2026 17:02:50 -0300 Subject: [PATCH 3/3] fix(purchase): enhance handling of cart items for created and pending payment purchases --- .../Purchase/Resources/PurchaseResource.php | 17 ++-- .../Checkout/PurchaseResponseLoader.php | 11 ++- .../Sale/Services/AdminAppSaleService.php | 13 +++- tests/Feature/Purchase/StorePurchaseTest.php | 36 +++++++++ .../Sale/AdminAppSaleControllerTest.php | 77 +++++++++++++++++++ 5 files changed, 145 insertions(+), 9 deletions(-) diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index 090b52f..5ccfa9b 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -21,15 +21,18 @@ class PurchaseResource extends JsonResource $purchaseItems = $this->resource->relationLoaded('items') ? $this->resource->getRelation('items') : collect(); - $cartItems = $purchaseItems->isEmpty() - && $this->resource->relationLoaded('cart') + $cartItems = $this->resource->relationLoaded('cart') && $this->resource->getRelation('cart')?->relationLoaded('items') ? $this->resource->getRelation('cart')->getRelation('items') - : collect(); - $items = $purchaseItems->isNotEmpty() ? $purchaseItems : $cartItems; - $itemsSource = $purchaseItems->isNotEmpty() - ? 'purchase' - : ($cartItems->isNotEmpty() ? 'cart' : null); + : null; + $usesCartItems = in_array($this->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true) && $cartItems !== null; + $items = $usesCartItems ? $cartItems : $purchaseItems; + $itemsSource = $usesCartItems + ? 'cart' + : ($purchaseItems->isNotEmpty() ? 'purchase' : null); $ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes()) ? (int) $this->resource->getAttribute('tickets_count') : null; diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php b/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php index 7739899..4e549a5 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php @@ -10,7 +10,16 @@ class PurchaseResponseLoader { $purchase->load(['tenant', 'items.imageAttachment']); - if ($purchase->items->isEmpty() && $purchase->cart_id !== null) { + if ( + $purchase->cart_id !== null + && ( + in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true) + || $purchase->items->isEmpty() + ) + ) { $purchase->load([ 'cart.items.catalogItem.inventory', 'cart.items.catalogItem.attachments', diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index 95fec11..e3c4de5 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -151,7 +151,18 @@ class AdminAppSaleService $filters['status'] ?? null, fn (Builder $query, string $status): Builder => $query->where('status', $status) ) - ->withSum('items as quantity', 'cantidad') + ->select('compras.*') + ->selectRaw( + 'CASE WHEN compras.status IN (?, ?) ' + .'THEN (SELECT COALESCE(SUM(cart_items.cantidad), 0) FROM carrito_items AS cart_items ' + .'WHERE cart_items.cart_id = compras.cart_id) ' + .'ELSE (SELECT COALESCE(SUM(purchase_items.cantidad), 0) FROM compra_items AS purchase_items ' + .'WHERE purchase_items.compra_id = compras.id) END AS quantity', + [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], + ) ->withCount('tickets') ->orderBy($sortColumns[$sortBy], $sortDirection) ->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id')); diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index ac61311..1be38f0 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -1244,6 +1244,42 @@ class StorePurchaseTest extends TestCase ->assertJsonPath('data.total', '100.00'); } + public function test_created_and_pending_payment_purchase_details_use_the_current_cart_quantity(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create([ + 'email' => 'buyer@example.com', + ]); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + + foreach ([Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT] as $status) { + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + $purchase->items()->create([ + 'source_catalog_item_id' => $variant->catalog_item_id, + 'source_variant_id' => $variant->id, + 'item_nombre' => $variant->catalogItem->nombre, + 'descripcion' => $variant->catalogItem->descripcion, + 'slug' => $variant->catalogItem->slug, + 'variant_attributes' => [], + 'cantidad' => 1, + 'precio_unitario' => '50.00', + 'total' => '50.00', + ]); + $purchase->cart->items()->update(['cantidad' => 3]); + $purchase->update(['status' => $status]); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/sonder/compras/{$purchase->id}") + ->assertOk() + ->assertJsonPath('data.status', $status) + ->assertJsonPath('data.items_source', 'cart') + ->assertJsonPath('data.items.0.quantity', 3) + ->assertJsonPath('data.items.0.line_total', '150.00') + ->assertJsonPath('data.subtotal', '150.00') + ->assertJsonPath('data.total', '150.00'); + } + } + public function test_purchase_detail_uses_purchase_items_for_paid_purchase_even_without_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index 5235e38..696ac80 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -31,6 +31,83 @@ class AdminAppSaleControllerTest extends TestCase WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); } + public function test_sales_list_uses_cart_quantity_for_created_and_pending_payment_sales(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'entrada-general', + 'nombre' => 'Entrada general', + 'precio' => '10000.00', + ]); + + $createdCart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => 'checkout', + ]); + CartItem::query()->create([ + 'cart_id' => $createdCart->id, + 'catalog_item_id' => $catalogItem->id, + 'cantidad' => 3, + ]); + $createdPurchase = Purchase::query()->create([ + 'cart_id' => $createdCart->id, + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_CREATED, + 'total' => '30000.00', + ]); + PurchaseItem::query()->create([ + 'compra_id' => $createdPurchase->id, + 'source_catalog_item_id' => $catalogItem->id, + 'item_nombre' => $catalogItem->nombre, + 'cantidad' => 1, + 'precio_unitario' => '10000.00', + 'total' => '10000.00', + ]); + + $pendingCart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => 'checkout', + ]); + CartItem::query()->create([ + 'cart_id' => $pendingCart->id, + 'catalog_item_id' => $catalogItem->id, + 'cantidad' => 4, + ]); + $pendingPurchase = Purchase::query()->create([ + 'cart_id' => $pendingCart->id, + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'total' => '40000.00', + ]); + + $paidPurchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PAID, + 'total' => '20000.00', + ]); + PurchaseItem::query()->create([ + 'compra_id' => $paidPurchase->id, + 'source_catalog_item_id' => $catalogItem->id, + 'item_nombre' => $catalogItem->nombre, + 'cantidad' => 2, + 'precio_unitario' => '10000.00', + 'total' => '20000.00', + ]); + + $this->getJson('/api/v1/adminapp/tenant/sales?sort_by=id&sort_direction=asc') + ->assertOk() + ->assertJsonCount(3, 'data') + ->assertJsonPath('data.0.id', $createdPurchase->id) + ->assertJsonPath('data.0.quantity', 3) + ->assertJsonPath('data.1.id', $pendingPurchase->id) + ->assertJsonPath('data.1.quantity', 4) + ->assertJsonPath('data.2.id', $paidPurchase->id) + ->assertJsonPath('data.2.quantity', 2); + } + public function test_authentication_is_required_to_read_a_sale_detail(): void { $this->getJson('/api/v1/adminapp/tenant/sales/1')->assertUnauthorized();