Squashed commit of the following:
commit1dc4e29c69Author: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 13:58:03 2026 -0300 refactor(reservations): unify expiration command commit093e894cc3Author: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 13:48:09 2026 -0300 feat(cart): expire abandoned stock reservations commitfdf0f3328fAuthor: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 12:53:12 2026 -0300 refactor(stock): implement expiration for stock reservations and add configuration commit8d6bcdcc43Author: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 12:38:21 2026 -0300 refactor(cart): invalidate payment on actual changes commit3206e293ebAuthor: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 12:24:48 2026 -0300 refactor(cart): own checkout item editing commitaed99bd05eAuthor: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 12:14:57 2026 -0300 refactor(checkout): remove legacy purchase item reservations commitf1649e0e4bAuthor: ncoronel <ncoronel@quo.ar> Date: Wed Aug 19 12:06:29 2026 -0300 refactor(checkout): materialize purchase items on confirmation commite6c4b40a37Author: 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:
@@ -8,6 +8,7 @@ PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
|||||||
PURCHASE_QR_EXPIRATION_MINUTES=15
|
PURCHASE_QR_EXPIRATION_MINUTES=15
|
||||||
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
||||||
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
||||||
|
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
||||||
FRONTEND_URLS=http://localhost:4200
|
FRONTEND_URLS=http://localhost:4200
|
||||||
|
|
||||||
APP_LOCALE=es
|
APP_LOCALE=es
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"_postman_id": "76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2",
|
"_postman_id": "76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2",
|
||||||
"name": "ShopIt API — Complete",
|
"name": "ShopIt API — Complete",
|
||||||
"description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 123 operaciones HTTP, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.",
|
"description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 122 operaciones HTTP, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.",
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
},
|
},
|
||||||
"item": [
|
"item": [
|
||||||
@@ -572,6 +572,60 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Update Checkout Cart Item Cart",
|
||||||
|
"request": {
|
||||||
|
"method": "PATCH",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Ruta Laravel: `PATCH /api/tenants/{tenant:codigo}/checkout-carts/{cart}/items/{cartItem}`\n\nControlador: `App\\Domains\\Cart\\Controllers\\CartController@updateCheckoutItem`\n\nRequiere autenticación Sanctum.",
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/tenants/{{tenant_code}}/checkout-carts/{{cart}}/items/{{cart_item_id}}",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"tenants",
|
||||||
|
"{{tenant_code}}",
|
||||||
|
"checkout-carts",
|
||||||
|
"{{cart}}",
|
||||||
|
"items",
|
||||||
|
"{{cart_item_id}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"cantidad\": 2\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"type": "bearer",
|
||||||
|
"bearer": [
|
||||||
|
{
|
||||||
|
"key": "token",
|
||||||
|
"value": "{{token}}",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1115,99 +1169,6 @@
|
|||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Prepare Item Editing Purchase",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "Accept",
|
|
||||||
"value": "application/json",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Ruta Laravel: `POST /api/tenants/{tenant:codigo}/compras/{compra}/edit-items`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@prepareItemEditing`\n\nRequiere autenticación Sanctum.",
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/tenants/{{tenant_code}}/compras/{{purchase_id}}/edit-items",
|
|
||||||
"host": [
|
|
||||||
"{{base_url}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"tenants",
|
|
||||||
"{{tenant_code}}",
|
|
||||||
"compras",
|
|
||||||
"{{purchase_id}}",
|
|
||||||
"edit-items"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"type": "bearer",
|
|
||||||
"bearer": [
|
|
||||||
{
|
|
||||||
"key": "token",
|
|
||||||
"value": "{{token}}",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Update Item Quantity Purchase",
|
|
||||||
"request": {
|
|
||||||
"method": "PATCH",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "Accept",
|
|
||||||
"value": "application/json",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/json",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Ruta Laravel: `PATCH /api/tenants/{tenant:codigo}/compras/{compra}/items/{item}`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@updateItemQuantity`\n\nRequiere autenticación Sanctum.",
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/tenants/{{tenant_code}}/compras/{{purchase_id}}/items/{{purchase_item_id}}",
|
|
||||||
"host": [
|
|
||||||
"{{base_url}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"tenants",
|
|
||||||
"{{tenant_code}}",
|
|
||||||
"compras",
|
|
||||||
"{{purchase_id}}",
|
|
||||||
"items",
|
|
||||||
"{{purchase_item_id}}"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"mode": "raw",
|
|
||||||
"raw": "{\n \"quantity\": 2\n}",
|
|
||||||
"options": {
|
|
||||||
"raw": {
|
|
||||||
"language": "json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"type": "bearer",
|
|
||||||
"bearer": [
|
|
||||||
{
|
|
||||||
"key": "token",
|
|
||||||
"value": "{{token}}",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Create Payment Intent Purchase",
|
"name": "Create Payment Intent Purchase",
|
||||||
"request": {
|
"request": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Cart\Controllers;
|
namespace App\Domains\Cart\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Cart\Requests\AddCartItemRequest;
|
use App\Domains\Cart\Requests\AddCartItemRequest;
|
||||||
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
|
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
|
||||||
@@ -77,6 +78,36 @@ class CartController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateCheckoutItem(
|
||||||
|
UpdateCartItemQuantityRequest $request,
|
||||||
|
Tenant $tenant,
|
||||||
|
Cart $cart,
|
||||||
|
CartItem $cartItem,
|
||||||
|
): CartResource {
|
||||||
|
$updatesVariant = $request->exists('variant_id');
|
||||||
|
|
||||||
|
return CartResource::make(
|
||||||
|
$this->cartService->updateCheckoutItem(
|
||||||
|
$tenant,
|
||||||
|
$request,
|
||||||
|
$cart,
|
||||||
|
$cartItem->getKey(),
|
||||||
|
(int) $request->validated('cantidad'),
|
||||||
|
$updatesVariant
|
||||||
|
? ($request->validated('variant_id') !== null
|
||||||
|
? (int) $request->validated('variant_id')
|
||||||
|
: null)
|
||||||
|
: $cartItem->variant_id,
|
||||||
|
$updatesVariant,
|
||||||
|
),
|
||||||
|
)->additional([
|
||||||
|
'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated',
|
||||||
|
'message' => $updatesVariant
|
||||||
|
? __('api.cart.item_updated')
|
||||||
|
: __('api.cart.quantity_updated'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
|
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
|
||||||
{
|
{
|
||||||
return CartResource::make(
|
return CartResource::make(
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use App\Domains\Catalog\Models\CatalogItem;
|
|||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
@@ -24,6 +26,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|||||||
'user_id',
|
'user_id',
|
||||||
'guest_token',
|
'guest_token',
|
||||||
'status',
|
'status',
|
||||||
|
'origin',
|
||||||
])]
|
])]
|
||||||
class Cart extends Model
|
class Cart extends Model
|
||||||
{
|
{
|
||||||
@@ -32,6 +35,10 @@ class Cart extends Model
|
|||||||
|
|
||||||
protected $table = 'carritos';
|
protected $table = 'carritos';
|
||||||
|
|
||||||
|
public const ORIGIN_USER = 'user';
|
||||||
|
|
||||||
|
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -63,6 +70,12 @@ class Cart extends Model
|
|||||||
return $this->hasMany(CartItem::class, 'cart_id');
|
return $this->hasMany(CartItem::class, 'cart_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Purchase, $this> */
|
||||||
|
public function purchases(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Purchase::class, 'cart_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function getTotalAmount(): float
|
public function getTotalAmount(): float
|
||||||
{
|
{
|
||||||
$items = $this->relationLoaded('items')
|
$items = $this->relationLoaded('items')
|
||||||
@@ -114,11 +127,12 @@ class Cart extends Model
|
|||||||
'cantidad' => $quantity,
|
'cantidad' => $quantity,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
|
app(StockReservationService::class)->ensure($item, $selectedItem);
|
||||||
$item->cantidad += $quantity;
|
$item->cantidad += $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
$inventoryService->reserve($selectedItem, $quantity);
|
app(StockReservationService::class)->reserve($item, $selectedItem, $quantity);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
@@ -129,6 +143,7 @@ class Cart extends Model
|
|||||||
int $quantity,
|
int $quantity,
|
||||||
?int $variantId = null,
|
?int $variantId = null,
|
||||||
bool $updateVariant = false,
|
bool $updateVariant = false,
|
||||||
|
?int $excludedPurchaseId = null,
|
||||||
): CartItem {
|
): CartItem {
|
||||||
if ($quantity <= 0) {
|
if ($quantity <= 0) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -141,6 +156,7 @@ class Cart extends Model
|
|||||||
$quantity,
|
$quantity,
|
||||||
$variantId,
|
$variantId,
|
||||||
$updateVariant,
|
$updateVariant,
|
||||||
|
$excludedPurchaseId,
|
||||||
): CartItem {
|
): CartItem {
|
||||||
/** @var CartItem $item */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
@@ -168,9 +184,10 @@ class Cart extends Model
|
|||||||
$this->assertUserPurchaseLimit(
|
$this->assertUserPurchaseLimit(
|
||||||
$nextSelection,
|
$nextSelection,
|
||||||
$otherVariantsQuantity + $quantity,
|
$otherVariantsQuantity + $quantity,
|
||||||
|
$excludedPurchaseId,
|
||||||
);
|
);
|
||||||
|
|
||||||
$inventoryService->release($currentSelection, $item->cantidad);
|
app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad);
|
||||||
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
||||||
|
|
||||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||||
@@ -186,11 +203,11 @@ class Cart extends Model
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$inventoryService->reserve($nextSelection, $quantity);
|
|
||||||
|
|
||||||
if ($targetItem !== null) {
|
if ($targetItem !== null) {
|
||||||
|
app(StockReservationService::class)->ensure($targetItem, $nextSelection);
|
||||||
$targetItem->cantidad += $quantity;
|
$targetItem->cantidad += $quantity;
|
||||||
$targetItem->save();
|
$targetItem->save();
|
||||||
|
app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity);
|
||||||
$item->delete();
|
$item->delete();
|
||||||
|
|
||||||
return $targetItem->fresh();
|
return $targetItem->fresh();
|
||||||
@@ -199,6 +216,7 @@ class Cart extends Model
|
|||||||
$item->variant_id = $variantId;
|
$item->variant_id = $variantId;
|
||||||
$item->cantidad = $quantity;
|
$item->cantidad = $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
|
app(StockReservationService::class)->reserve($item, $nextSelection, $quantity);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
}
|
}
|
||||||
@@ -213,6 +231,7 @@ class Cart extends Model
|
|||||||
$this->assertUserPurchaseLimit(
|
$this->assertUserPurchaseLimit(
|
||||||
$currentSelection,
|
$currentSelection,
|
||||||
$otherVariantsQuantity + $quantity,
|
$otherVariantsQuantity + $quantity,
|
||||||
|
$excludedPurchaseId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,17 +244,17 @@ class Cart extends Model
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$item->cantidad = $quantity;
|
|
||||||
$item->save();
|
|
||||||
|
|
||||||
if ($delta > 0) {
|
if ($delta > 0) {
|
||||||
$inventoryService->reserve($currentSelection, $delta);
|
app(StockReservationService::class)->reserve($item, $currentSelection, $delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($delta < 0) {
|
if ($delta < 0) {
|
||||||
$inventoryService->release($currentSelection, abs($delta));
|
app(StockReservationService::class)->release($item, $currentSelection, abs($delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$item->cantidad = $quantity;
|
||||||
|
$item->save();
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -254,7 +273,8 @@ class Cart extends Model
|
|||||||
$item->variant_id,
|
$item->variant_id,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
app(CatalogInventoryService::class)->release(
|
app(StockReservationService::class)->release(
|
||||||
|
$item,
|
||||||
$selectedItem,
|
$selectedItem,
|
||||||
$item->cantidad,
|
$item->cantidad,
|
||||||
);
|
);
|
||||||
@@ -334,6 +354,7 @@ class Cart extends Model
|
|||||||
private function assertUserPurchaseLimit(
|
private function assertUserPurchaseLimit(
|
||||||
CatalogItem|Variant $selectedItem,
|
CatalogItem|Variant $selectedItem,
|
||||||
int $cartQuantity,
|
int $cartQuantity,
|
||||||
|
?int $excludedPurchaseId = null,
|
||||||
): void {
|
): void {
|
||||||
if ($this->user_id === null) {
|
if ($this->user_id === null) {
|
||||||
return;
|
return;
|
||||||
@@ -347,6 +368,7 @@ class Cart extends Model
|
|||||||
$catalogItem,
|
$catalogItem,
|
||||||
$this->user_id,
|
$this->user_id,
|
||||||
$cartQuantity,
|
$cartQuantity,
|
||||||
|
$excludedPurchaseId,
|
||||||
field: 'cantidad',
|
field: 'cantidad',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
namespace App\Domains\Cart\Models;
|
namespace App\Domains\Cart\Models;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'cart_id',
|
'cart_id',
|
||||||
@@ -55,4 +57,10 @@ class CartItem extends Model
|
|||||||
{
|
{
|
||||||
return $this->variant ?? $this->catalogItem;
|
return $this->variant ?? $this->catalogItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<StockReservation, $this> */
|
||||||
|
public function stockReservations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(StockReservation::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,24 @@ namespace App\Domains\Cart\Services;
|
|||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Cart\Models\Cart;
|
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\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpFoundation\Cookie;
|
use Symfony\Component\HttpFoundation\Cookie;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
class CartService
|
class CartService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly StockReservationService $reservations,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function show(Tenant $tenant, Request $request): Cart
|
public function show(Tenant $tenant, Request $request): Cart
|
||||||
{
|
{
|
||||||
$resolvedIdentity = $this->resolveIdentity($request);
|
$resolvedIdentity = $this->resolveIdentity($request);
|
||||||
@@ -66,6 +75,107 @@ class CartService
|
|||||||
return $this->loadCart($cart);
|
return $this->loadCart($cart);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateCheckoutItem(
|
||||||
|
Tenant $tenant,
|
||||||
|
Request $request,
|
||||||
|
Cart $cart,
|
||||||
|
int $cartItemId,
|
||||||
|
int $quantity,
|
||||||
|
?int $variantId,
|
||||||
|
bool $updateVariant,
|
||||||
|
): Cart {
|
||||||
|
$user = $request->user() ?? Auth::guard('sanctum')->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new NotFoundHttpException('Checkout cart not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use (
|
||||||
|
$tenant,
|
||||||
|
$user,
|
||||||
|
$cart,
|
||||||
|
$cartItemId,
|
||||||
|
$quantity,
|
||||||
|
$variantId,
|
||||||
|
$updateVariant,
|
||||||
|
): Cart {
|
||||||
|
/** @var Purchase|null $purchase */
|
||||||
|
$purchase = Purchase::query()
|
||||||
|
->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();
|
||||||
|
|
||||||
|
if ($purchase === null) {
|
||||||
|
throw new NotFoundHttpException('Checkout cart not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($purchase->expires_at !== null && $purchase->expires_at->isPast()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'cart' => __('api.purchase.not_editable'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Cart|null $checkoutCart */
|
||||||
|
$checkoutCart = Cart::query()
|
||||||
|
->whereKey($cart->getKey())
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->where('status', 'checkout')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($checkoutCart === null) {
|
||||||
|
throw new NotFoundHttpException('Checkout cart not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CartItem|null $cartItem */
|
||||||
|
$cartItem = $checkoutCart->items()
|
||||||
|
->whereKey($cartItemId)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($cartItem === null) {
|
||||||
|
throw new NotFoundHttpException('Checkout item not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasChanges = (int) $cartItem->cantidad !== $quantity
|
||||||
|
|| ($updateVariant && $cartItem->variant_id !== $variantId);
|
||||||
|
|
||||||
|
if (! $hasChanges) {
|
||||||
|
return $this->loadCart($checkoutCart);
|
||||||
|
}
|
||||||
|
|
||||||
|
$checkoutCart->updateItem(
|
||||||
|
$cartItemId,
|
||||||
|
$quantity,
|
||||||
|
$variantId,
|
||||||
|
$updateVariant,
|
||||||
|
$purchase->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$purchase->telepagosQr()->delete();
|
||||||
|
$purchase->update([
|
||||||
|
'status' => Purchase::STATUS_CREATED,
|
||||||
|
'payment_method' => null,
|
||||||
|
'transfer_payer_dni' => null,
|
||||||
|
'total' => $checkoutCart->getTotalAmount(),
|
||||||
|
'expires_at' => now()->addMinutes(
|
||||||
|
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
$this->reservations->syncPurchaseExpiration($purchase);
|
||||||
|
|
||||||
|
return $this->loadCart($checkoutCart);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
|
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
|
||||||
{
|
{
|
||||||
$identity = $this->requireIdentity($request);
|
$identity = $this->requireIdentity($request);
|
||||||
|
|||||||
123
app/Domains/Cart/Services/ExpireCartReservationsService.php
Normal file
123
app/Domains/Cart/Services/ExpireCartReservationsService.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Cart\Services;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ExpireCartReservationsService
|
||||||
|
{
|
||||||
|
public function expireOverdue(): int
|
||||||
|
{
|
||||||
|
$expiredItems = 0;
|
||||||
|
$lastCartItemId = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$cartItemIds = StockReservation::query()
|
||||||
|
->where('status', StockReservation::STATUS_ACTIVE)
|
||||||
|
->whereNull('purchase_id')
|
||||||
|
->whereNotNull('cart_item_id')
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<=', now())
|
||||||
|
->where('cart_item_id', '>', $lastCartItemId)
|
||||||
|
->whereHas('cartItem.cart', fn ($query) => $query->where('status', 'active'))
|
||||||
|
->select('cart_item_id')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('cart_item_id')
|
||||||
|
->limit(500)
|
||||||
|
->pluck('cart_item_id');
|
||||||
|
|
||||||
|
foreach ($cartItemIds as $cartItemId) {
|
||||||
|
$lastCartItemId = (int) $cartItemId;
|
||||||
|
|
||||||
|
if ($this->expireCartItem($lastCartItemId)) {
|
||||||
|
$expiredItems++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while ($cartItemIds->count() === 500);
|
||||||
|
|
||||||
|
return $expiredItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expireCartItem(int $cartItemId): bool
|
||||||
|
{
|
||||||
|
/** @var CartItem|null $candidate */
|
||||||
|
$candidate = CartItem::query()->select(['id', 'cart_id'])->find($cartItemId);
|
||||||
|
if ($candidate === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($candidate, $cartItemId): bool {
|
||||||
|
/** @var Cart|null $cart */
|
||||||
|
$cart = Cart::query()
|
||||||
|
->whereKey($candidate->cart_id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($cart === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CartItem|null $cartItem */
|
||||||
|
$cartItem = $cart->items()
|
||||||
|
->whereKey($cartItemId)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($cartItem === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservations = StockReservation::query()
|
||||||
|
->where('cart_item_id', $cartItem->getKey())
|
||||||
|
->where('status', StockReservation::STATUS_ACTIVE)
|
||||||
|
->orderBy('inventory_id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if (
|
||||||
|
$reservations->isEmpty()
|
||||||
|
|| $reservations->contains(
|
||||||
|
fn (StockReservation $reservation): bool => $reservation->purchase_id !== null
|
||||||
|
|| $reservation->expires_at === null
|
||||||
|
|| $reservation->expires_at->isFuture(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventories = Inventory::query()
|
||||||
|
->whereKey($reservations->pluck('inventory_id'))
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
foreach ($reservations as $reservation) {
|
||||||
|
$inventory = $inventories->get($reservation->inventory_id)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontro el inventario reservado.');
|
||||||
|
|
||||||
|
$inventory->release((int) $reservation->quantity);
|
||||||
|
$reservation->update([
|
||||||
|
'quantity' => 0,
|
||||||
|
'status' => StockReservation::STATUS_EXPIRED,
|
||||||
|
'expires_at' => null,
|
||||||
|
'released_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cartItem->delete();
|
||||||
|
|
||||||
|
if (! $cart->items()->exists()) {
|
||||||
|
$cart->update(['status' => 'expired']);
|
||||||
|
$cart->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,10 @@ class GuestCartMergeService
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($userCart !== null) {
|
if ($userCart !== null) {
|
||||||
|
$userCart->items()
|
||||||
|
->orderBy('id')
|
||||||
|
->pluck('id')
|
||||||
|
->each(fn (int $itemId) => $userCart->removeItem($itemId));
|
||||||
$userCart->update([
|
$userCart->update([
|
||||||
'status' => 'converted',
|
'status' => 'converted',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios
|
|||||||
## Modelo
|
## Modelo
|
||||||
|
|
||||||
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
|
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
|
||||||
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; expone la selección efectiva.
|
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
|
||||||
|
|
||||||
## Servicios
|
## Servicios
|
||||||
|
|
||||||
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
|
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
|
||||||
|
- `ExpireCartReservationsService`: libera las reservas vencidas de carritos activos y elimina los carritos que quedan vacíos.
|
||||||
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
|
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
@@ -30,3 +31,7 @@ Bajo `/tenants/{tenant:codigo}`:
|
|||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|
||||||
Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual.
|
Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual.
|
||||||
|
|
||||||
|
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
|
||||||
|
|
||||||
|
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas sin compra cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la fila de reserva con estado `expired`, elimina el ítem abandonado y elimina lógicamente el carrito cuando queda vacío.
|
||||||
|
|||||||
@@ -10,3 +10,9 @@ Route::prefix('tenants/{tenant:codigo}')
|
|||||||
Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']);
|
Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']);
|
||||||
Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']);
|
Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::prefix('tenants/{tenant:codigo}')
|
||||||
|
->middleware('auth:sanctum')
|
||||||
|
->group(function (): void {
|
||||||
|
Route::patch('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'updateCheckoutItem']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Models;
|
|||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
@@ -47,6 +48,12 @@ class Inventory extends Model
|
|||||||
return $this->hasOne(Variant::class);
|
return $this->hasOne(Variant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<StockReservation, $this> */
|
||||||
|
public function stockReservations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(StockReservation::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function availableStock(): int
|
public function availableStock(): int
|
||||||
{
|
{
|
||||||
return max(0, $this->real_stock - $this->reserved_stock);
|
return max(0, $this->real_stock - $this->reserved_stock);
|
||||||
|
|||||||
61
app/Domains/Catalog/Models/StockReservation.php
Normal file
61
app/Domains/Catalog/Models/StockReservation.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'inventory_id',
|
||||||
|
'cart_item_id',
|
||||||
|
'purchase_id',
|
||||||
|
'quantity',
|
||||||
|
'status',
|
||||||
|
'expires_at',
|
||||||
|
'committed_at',
|
||||||
|
'released_at',
|
||||||
|
])]
|
||||||
|
class StockReservation extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const STATUS_COMMITTED = 'committed';
|
||||||
|
|
||||||
|
public const STATUS_RELEASED = 'released';
|
||||||
|
|
||||||
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'inventory_id' => 'integer',
|
||||||
|
'cart_item_id' => 'integer',
|
||||||
|
'purchase_id' => 'integer',
|
||||||
|
'quantity' => 'integer',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'committed_at' => 'datetime',
|
||||||
|
'released_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Inventory, $this> */
|
||||||
|
public function inventory(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Inventory::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<CartItem, $this> */
|
||||||
|
public function cartItem(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CartItem::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Purchase, $this> */
|
||||||
|
public function purchase(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Purchase::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,19 @@ use Illuminate\Support\Facades\DB;
|
|||||||
|
|
||||||
class CatalogInventoryService
|
class CatalogInventoryService
|
||||||
{
|
{
|
||||||
|
/** @return array<int, int> */
|
||||||
|
public function requirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array
|
||||||
|
{
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn (array $requirement): int => $requirement['quantity'] * $quantity,
|
||||||
|
$this->inventoryRequirements($selection),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
||||||
{
|
{
|
||||||
if ($selection instanceof CatalogItem
|
if ($selection instanceof CatalogItem
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Services\ExpireCartReservationsService;
|
||||||
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
|
|
||||||
|
class ExpireStockReservationsService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CheckoutService $checkout,
|
||||||
|
private readonly ExpireCartReservationsService $carts,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{purchases: int, cart_items: int}
|
||||||
|
*/
|
||||||
|
public function expireOverdue(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'purchases' => $this->checkout->expireOverduePurchases(),
|
||||||
|
'cart_items' => $this->carts->expireOverdue(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
230
app/Domains/Catalog/Services/StockReservationService.php
Normal file
230
app/Domains/Catalog/Services/StockReservationService.php
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class StockReservationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CatalogInventoryService $inventory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function reserve(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($cartItem, $selection, $quantity): void {
|
||||||
|
$this->inventory->reserve($selection, $quantity);
|
||||||
|
$this->recordIncrease($cartItem, $selection, $quantity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function release(
|
||||||
|
CartItem $cartItem,
|
||||||
|
CatalogItem|Variant $selection,
|
||||||
|
int $quantity,
|
||||||
|
string $releasedStatus = StockReservation::STATUS_RELEASED,
|
||||||
|
): void {
|
||||||
|
DB::transaction(function () use ($cartItem, $selection, $quantity, $releasedStatus): void {
|
||||||
|
$this->ensure($cartItem, $selection);
|
||||||
|
$this->inventory->release($selection, $quantity);
|
||||||
|
$this->recordDecrease($cartItem, $selection, $quantity, $releasedStatus);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function commit(CartItem $cartItem, CatalogItem|Variant $selection): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($cartItem, $selection): void {
|
||||||
|
$this->ensure($cartItem, $selection);
|
||||||
|
$this->inventory->commit($selection, (int) $cartItem->cantidad);
|
||||||
|
|
||||||
|
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
|
||||||
|
foreach ($requirements as $inventoryId => $quantity) {
|
||||||
|
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
||||||
|
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update([
|
||||||
|
'status' => StockReservation::STATUS_COMMITTED,
|
||||||
|
'committed_at' => now(),
|
||||||
|
'expires_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensure(CartItem $cartItem, CatalogItem|Variant $selection): void
|
||||||
|
{
|
||||||
|
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
|
||||||
|
|
||||||
|
foreach ($requirements as $inventoryId => $quantity) {
|
||||||
|
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
||||||
|
|
||||||
|
if ($reservation === null) {
|
||||||
|
StockReservation::query()->create([
|
||||||
|
'inventory_id' => $inventoryId,
|
||||||
|
'cart_item_id' => $cartItem->getKey(),
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
|
||||||
|
$reservation->update([
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'committed_at' => null,
|
||||||
|
'released_at' => null,
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachToPurchase(
|
||||||
|
CartItem $cartItem,
|
||||||
|
CatalogItem|Variant $selection,
|
||||||
|
Purchase $purchase,
|
||||||
|
): void {
|
||||||
|
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
|
||||||
|
$this->ensure($cartItem, $selection);
|
||||||
|
StockReservation::query()
|
||||||
|
->where('cart_item_id', $cartItem->getKey())
|
||||||
|
->where('status', StockReservation::STATUS_ACTIVE)
|
||||||
|
->update([
|
||||||
|
'purchase_id' => $purchase->getKey(),
|
||||||
|
'expires_at' => $purchase->expires_at,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detachFromPurchase(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
StockReservation::query()
|
||||||
|
->where('purchase_id', $purchase->getKey())
|
||||||
|
->where('status', StockReservation::STATUS_ACTIVE)
|
||||||
|
->update([
|
||||||
|
'purchase_id' => null,
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncPurchaseExpiration(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
StockReservation::query()
|
||||||
|
->where('purchase_id', $purchase->getKey())
|
||||||
|
->where('status', StockReservation::STATUS_ACTIVE)
|
||||||
|
->update(['expires_at' => $purchase->expires_at]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transfer(CartItem $source, CartItem $target): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($source, $target): void {
|
||||||
|
$sourceReservations = StockReservation::query()
|
||||||
|
->where('cart_item_id', $source->getKey())
|
||||||
|
->where('status', StockReservation::STATUS_ACTIVE)
|
||||||
|
->orderBy('inventory_id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($sourceReservations as $sourceReservation) {
|
||||||
|
$targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_id);
|
||||||
|
|
||||||
|
if ($targetReservation === null) {
|
||||||
|
$sourceItemQuantity = (int) $source->cantidad;
|
||||||
|
$targetItemQuantity = (int) $target->fresh()->cantidad;
|
||||||
|
$perItemQuantity = intdiv((int) $sourceReservation->quantity, $sourceItemQuantity);
|
||||||
|
$sourceReservation->update([
|
||||||
|
'cart_item_id' => $target->getKey(),
|
||||||
|
'purchase_id' => null,
|
||||||
|
'quantity' => $perItemQuantity * $targetItemQuantity,
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetReservation->update([
|
||||||
|
'quantity' => $targetReservation->quantity + $sourceReservation->quantity,
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
]);
|
||||||
|
$sourceReservation->delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
|
||||||
|
{
|
||||||
|
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
|
||||||
|
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
||||||
|
|
||||||
|
if ($reservation === null) {
|
||||||
|
StockReservation::query()->create([
|
||||||
|
'inventory_id' => $inventoryId,
|
||||||
|
'cart_item_id' => $cartItem->getKey(),
|
||||||
|
'quantity' => $requiredQuantity,
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update([
|
||||||
|
'quantity' => ($reservation->status === StockReservation::STATUS_ACTIVE ? $reservation->quantity : 0) + $requiredQuantity,
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'committed_at' => null,
|
||||||
|
'released_at' => null,
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordDecrease(
|
||||||
|
CartItem $cartItem,
|
||||||
|
CatalogItem|Variant $selection,
|
||||||
|
int $quantity,
|
||||||
|
string $releasedStatus,
|
||||||
|
): void {
|
||||||
|
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
|
||||||
|
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
||||||
|
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity < $requiredQuantity) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no alcanza para liberar la cantidad solicitada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining = $reservation->quantity - $requiredQuantity;
|
||||||
|
$reservation->update([
|
||||||
|
'quantity' => $remaining,
|
||||||
|
'status' => $remaining === 0 ? $releasedStatus : StockReservation::STATUS_ACTIVE,
|
||||||
|
'released_at' => $remaining === 0 ? now() : null,
|
||||||
|
'expires_at' => $remaining === 0 ? null : $reservation->expires_at,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation
|
||||||
|
{
|
||||||
|
return StockReservation::query()
|
||||||
|
->where('cart_item_id', $cartItem->getKey())
|
||||||
|
->where('inventory_id', $inventoryId)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expiration(): Carbon
|
||||||
|
{
|
||||||
|
return now()->addMinutes(
|
||||||
|
max(1, (int) config('catalog.stock_reservation_expiration_minutes', 30)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
|||||||
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
|
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
|
||||||
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
|
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
|
||||||
- `Inventory` administra stock disponible, reservado y comprado.
|
- `Inventory` administra stock disponible, reservado y comprado.
|
||||||
|
- `StockReservation` atribuye cada unidad reservada a un ítem de carrito y, durante checkout, a una compra, con estados `active`, `committed`, `released` y `expired`.
|
||||||
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
||||||
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
||||||
- `BundleComponent` representa los componentes de un paquete.
|
- `BundleComponent` representa los componentes de un paquete.
|
||||||
@@ -17,6 +18,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
|||||||
|
|
||||||
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
||||||
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
|
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
|
||||||
|
- `StockReservationService`: mantiene el ledger de reservas sincronizado con `Inventory.reserved_stock`.
|
||||||
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
||||||
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ namespace App\Domains\Purchase\Controllers;
|
|||||||
|
|
||||||
use App\Domains\Integration\Services\TelepagosIntegrationService;
|
use App\Domains\Integration\Services\TelepagosIntegrationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
|
||||||
use App\Domains\Purchase\Requests\PaymentIntentRequest;
|
use App\Domains\Purchase\Requests\PaymentIntentRequest;
|
||||||
use App\Domains\Purchase\Requests\StartCheckoutRequest;
|
use App\Domains\Purchase\Requests\StartCheckoutRequest;
|
||||||
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
|
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
|
||||||
use App\Domains\Purchase\Requests\UpdatePurchaseItemQuantityRequest;
|
|
||||||
use App\Domains\Purchase\Resources\PurchaseResource;
|
use App\Domains\Purchase\Resources\PurchaseResource;
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
@@ -56,7 +54,16 @@ class PurchaseController extends Controller
|
|||||||
{
|
{
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
|
|
||||||
$compra->loadMissing('items')->loadCount('tickets');
|
$compra->loadMissing([
|
||||||
|
'items',
|
||||||
|
'cart.items.catalogItem.inventory',
|
||||||
|
'cart.items.catalogItem.attachments',
|
||||||
|
'cart.items.variant.inventory',
|
||||||
|
'cart.items.variant.attachments',
|
||||||
|
'cart.items.variant.definitions.itemAttribute.attribute',
|
||||||
|
'cart.items.variant.eventDates',
|
||||||
|
'cart.items.variant.eventDate',
|
||||||
|
])->loadCount('tickets');
|
||||||
$compra->items->load('imageAttachment');
|
$compra->items->load('imageAttachment');
|
||||||
|
|
||||||
return PurchaseResource::make($compra);
|
return PurchaseResource::make($compra);
|
||||||
@@ -75,39 +82,12 @@ class PurchaseController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateItemQuantity(
|
public function paymentIntent(
|
||||||
UpdatePurchaseItemQuantityRequest $request,
|
PaymentIntentRequest $request,
|
||||||
Tenant $tenant,
|
|
||||||
Purchase $compra,
|
|
||||||
PurchaseItem $item,
|
|
||||||
CheckoutService $checkoutService,
|
|
||||||
): PurchaseResource {
|
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
|
||||||
|
|
||||||
return PurchaseResource::make(
|
|
||||||
$checkoutService->updateItemQuantity(
|
|
||||||
$compra,
|
|
||||||
$item,
|
|
||||||
(int) $request->validated('quantity'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function prepareItemEditing(
|
|
||||||
Request $request,
|
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
Purchase $compra,
|
Purchase $compra,
|
||||||
CheckoutService $checkoutService,
|
CheckoutService $checkoutService,
|
||||||
): PurchaseResource {
|
): JsonResponse {
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
|
||||||
|
|
||||||
return PurchaseResource::make(
|
|
||||||
$checkoutService->prepareItemEditing($compra),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function paymentIntent(PaymentIntentRequest $request, Tenant $tenant, Purchase $compra): JsonResponse
|
|
||||||
{
|
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
$method = $request->validated('method');
|
$method = $request->validated('method');
|
||||||
$totalAmount = $compra->calculateCurrentTotalAmount();
|
$totalAmount = $compra->calculateCurrentTotalAmount();
|
||||||
@@ -148,13 +128,14 @@ class PurchaseController extends Controller
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($updated === 0) {
|
if (! $updated) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'purchase' => __('api.purchase.not_available_for_payment'),
|
'purchase' => __('api.purchase.not_available_for_payment'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$compra->refresh();
|
$compra->refresh();
|
||||||
|
$checkoutService->syncReservationExpiration($compra);
|
||||||
|
|
||||||
if ($method === 'transfer') {
|
if ($method === 'transfer') {
|
||||||
$telepagosService = new TelepagosIntegrationService;
|
$telepagosService = new TelepagosIntegrationService;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Models;
|
|||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
|
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
|
||||||
use App\Domains\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
@@ -116,6 +117,12 @@ class Purchase extends Model
|
|||||||
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<StockReservation, $this> */
|
||||||
|
public function stockReservations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(StockReservation::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasOne<TelepagosQr, $this>
|
* @return HasOne<TelepagosQr, $this>
|
||||||
*/
|
*/
|
||||||
@@ -143,11 +150,16 @@ class Purchase extends Model
|
|||||||
|
|
||||||
public function calculateCurrentTotalAmount(): float
|
public function calculateCurrentTotalAmount(): float
|
||||||
{
|
{
|
||||||
if ($this->relationLoaded('items')) {
|
if ($this->relationLoaded('items') && $this->getRelation('items')->isNotEmpty()) {
|
||||||
return (float) $this->getRelation('items')->sum('total');
|
return (float) $this->getRelation('items')->sum('total');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (float) $this->items()->sum('total');
|
$itemsTotal = (float) $this->items()->sum('total');
|
||||||
|
if ($itemsTotal > 0 || $this->items()->exists()) {
|
||||||
|
return $itemsTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (float) ($this->cart?->getTotalAmount() ?? $this->total ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function valueChangeTenantCode(): string
|
protected function valueChangeTenantCode(): string
|
||||||
|
|||||||
@@ -23,18 +23,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
'discount_total',
|
'discount_total',
|
||||||
'tax_total',
|
'tax_total',
|
||||||
'total',
|
'total',
|
||||||
'reservation_status',
|
|
||||||
])]
|
])]
|
||||||
class PurchaseItem extends Model
|
class PurchaseItem extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
public const RESERVATION_ACTIVE = 'active';
|
|
||||||
|
|
||||||
public const RESERVATION_COMMITTED = 'committed';
|
|
||||||
|
|
||||||
public const RESERVATION_RELEASED = 'released';
|
|
||||||
|
|
||||||
protected $table = 'compra_items';
|
protected $table = 'compra_items';
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class UpdatePurchaseItemQuantityRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return $this->user() !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'quantity' => ['required', 'integer', 'min:1', 'max:100'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -57,22 +57,12 @@ class PurchaseItemResource extends JsonResource
|
|||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'unit_price' => $this->formatMoney($unitPrice),
|
'unit_price' => $this->formatMoney($unitPrice),
|
||||||
'line_total' => $this->formatMoney($lineTotal),
|
'line_total' => $this->formatMoney($lineTotal),
|
||||||
'catalog_item_id' => $this->catalog_item_id,
|
'source_catalog_item_id' => $this->catalog_item_id,
|
||||||
'variant_id' => $this->variant_id,
|
'source_variant_id' => $this->variant_id,
|
||||||
'product' => $catalogItem === null ? null : [
|
|
||||||
'id' => $catalogItem->id,
|
|
||||||
'nombre' => $catalogItem->nombre,
|
|
||||||
'descripcion' => $catalogItem->descripcion,
|
|
||||||
'slug' => $catalogItem->slug,
|
|
||||||
'imagen' => $imageUrl,
|
|
||||||
],
|
|
||||||
'variant' => $variant === null ? null : [
|
|
||||||
'id' => $variant->id,
|
|
||||||
'attributes' => $this->resolveAttributes($variant),
|
|
||||||
],
|
|
||||||
'item_details' => $selectedItem === null ? null : [
|
'item_details' => $selectedItem === null ? null : [
|
||||||
'nombre' => $selectedItem->getName(),
|
'nombre' => $selectedItem->getName(),
|
||||||
'descripcion' => $selectedItem->getDescription(),
|
'descripcion' => $selectedItem->getDescription(),
|
||||||
|
'slug' => $catalogItem?->slug,
|
||||||
'imagen' => $imageUrl,
|
'imagen' => $imageUrl,
|
||||||
'attributes' => $variant === null ? [] : $this->resolveAttributes($variant),
|
'attributes' => $variant === null ? [] : $this->resolveAttributes($variant),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Resources;
|
namespace App\Domains\Purchase\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -17,26 +18,37 @@ class PurchaseResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$items = $this->resource->relationLoaded('items')
|
$purchaseItems = $this->resource->relationLoaded('items')
|
||||||
? $this->resource->getRelation('items')
|
? $this->resource->getRelation('items')
|
||||||
: collect();
|
: collect();
|
||||||
|
$cartItems = $purchaseItems->isEmpty()
|
||||||
|
&& $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);
|
||||||
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
||||||
? (int) $this->resource->getAttribute('tickets_count')
|
? (int) $this->resource->getAttribute('tickets_count')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
$subtotal = $items->isNotEmpty()
|
$subtotal = $items->isNotEmpty()
|
||||||
? $items->reduce(
|
? $items->reduce(
|
||||||
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemSubtotal($item),
|
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemSubtotal($item),
|
||||||
0.0,
|
0.0,
|
||||||
)
|
)
|
||||||
: (float) ($this->total ?? 0);
|
: (float) ($this->total ?? 0);
|
||||||
|
|
||||||
$total = $items->isNotEmpty()
|
$total = $this->status === Purchase::STATUS_PAID && $this->total !== null
|
||||||
? $items->reduce(
|
? (float) $this->total
|
||||||
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemTotal($item),
|
: ($items->isNotEmpty()
|
||||||
0.0,
|
? $items->reduce(
|
||||||
)
|
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemTotal($item),
|
||||||
: (float) ($this->total ?? 0);
|
0.0,
|
||||||
|
)
|
||||||
|
: (float) ($this->total ?? 0));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
@@ -52,7 +64,7 @@ class PurchaseResource extends JsonResource
|
|||||||
'telefono' => $this->telefono,
|
'telefono' => $this->telefono,
|
||||||
'nombre_apellido' => $this->nombre_apellido,
|
'nombre_apellido' => $this->nombre_apellido,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
|
'items_source' => $itemsSource,
|
||||||
'items' => PurchaseItemResource::collection($items),
|
'items' => PurchaseItemResource::collection($items),
|
||||||
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
||||||
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
||||||
@@ -61,13 +73,21 @@ class PurchaseResource extends JsonResource
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveItemSubtotal(PurchaseItem $item): float
|
protected function resolveItemSubtotal(PurchaseItem|CartItem $item): float
|
||||||
{
|
{
|
||||||
|
if ($item instanceof CartItem) {
|
||||||
|
return (float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad;
|
||||||
|
}
|
||||||
|
|
||||||
return (float) $item->precio_unitario * $item->cantidad;
|
return (float) $item->precio_unitario * $item->cantidad;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveItemTotal(PurchaseItem $item): float
|
protected function resolveItemTotal(PurchaseItem|CartItem $item): float
|
||||||
{
|
{
|
||||||
|
if ($item instanceof CartItem) {
|
||||||
|
return $this->resolveItemSubtotal($item);
|
||||||
|
}
|
||||||
|
|
||||||
return (float) ($item->total ?? 0);
|
return (float) ($item->total ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace App\Domains\Purchase\Services\Checkout;
|
|||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
@@ -79,13 +78,4 @@ class CatalogSelectionResolver
|
|||||||
|
|
||||||
return $variant;
|
return $variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resolvePurchaseItem(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant
|
|
||||||
{
|
|
||||||
return $this->resolve(
|
|
||||||
$tenant,
|
|
||||||
(int) $item->source_catalog_item_id,
|
|
||||||
$item->source_variant_id === null ? null : (int) $item->source_variant_id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,19 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Services\Checkout;
|
namespace App\Domains\Purchase\Services\Checkout;
|
||||||
|
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class CompleteCheckoutService
|
class CompleteCheckoutService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CatalogInventoryService $inventory,
|
private readonly StockReservationService $reservations,
|
||||||
private readonly CatalogSelectionResolver $selections,
|
|
||||||
private readonly SourceCartService $sourceCart,
|
private readonly SourceCartService $sourceCart,
|
||||||
|
private readonly PurchaseItemSnapshotFactory $snapshots,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function complete(Purchase $purchase): Purchase
|
public function complete(Purchase $purchase): Purchase
|
||||||
@@ -59,6 +60,7 @@ class CompleteCheckoutService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$purchase->update(['expires_at' => null]);
|
$purchase->update(['expires_at' => null]);
|
||||||
|
$this->reservations->syncPurchaseExpiration($purchase);
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
});
|
});
|
||||||
@@ -83,25 +85,51 @@ class CompleteCheckoutService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = $purchase->items()
|
if ($purchase->items()->exists()) {
|
||||||
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
|
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||||
->lockForUpdate()
|
if ($cart?->status === 'converted' && $cart->trashed()) {
|
||||||
->get();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($items as $item) {
|
throw ValidationException::withMessages([
|
||||||
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item);
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
$cart = $purchase->cart()->lockForUpdate()->first();
|
||||||
$this->inventory->commit($selection, (int) $item->cantidad);
|
if ($cart === null || $cart->status !== 'checkout') {
|
||||||
} catch (\InvalidArgumentException) {
|
throw ValidationException::withMessages([
|
||||||
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
|
||||||
|
if ($cartItems->isEmpty()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadCartItems($cartItems);
|
||||||
|
$purchase->items()->createMany(
|
||||||
|
$this->snapshots->fromCartItems($cartItems),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($cartItems as $cartItem) {
|
||||||
|
$selection = $cartItem->selectedItem();
|
||||||
|
if ($selection === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'items' => __('api.purchase.inconsistent_reservation'),
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$item->update([
|
try {
|
||||||
'reservation_status' => PurchaseItem::RESERVATION_COMMITTED,
|
$this->reservations->commit($cartItem, $selection);
|
||||||
]);
|
} catch (\InvalidArgumentException) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->sourceCart->finalize($purchase);
|
$this->sourceCart->finalize($purchase);
|
||||||
@@ -126,6 +154,30 @@ class CompleteCheckoutService
|
|||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
private function loadPurchase(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $purchase->load(['items.imageAttachment']);
|
return $purchase->load([
|
||||||
|
'items.imageAttachment',
|
||||||
|
'cart.items.catalogItem.inventory',
|
||||||
|
'cart.items.catalogItem.attachments',
|
||||||
|
'cart.items.variant.inventory',
|
||||||
|
'cart.items.variant.attachments',
|
||||||
|
'cart.items.variant.definitions.itemAttribute.attribute',
|
||||||
|
'cart.items.variant.eventDates',
|
||||||
|
'cart.items.variant.eventDate',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Collection<int, CartItem> $cartItems */
|
||||||
|
private function loadCartItems(Collection $cartItems): void
|
||||||
|
{
|
||||||
|
$cartItems->load([
|
||||||
|
'catalogItem.inventory',
|
||||||
|
'catalogItem.attachments',
|
||||||
|
'variant.inventory',
|
||||||
|
'variant.attachments',
|
||||||
|
'variant.catalogItem',
|
||||||
|
'variant.definitions.itemAttribute.attribute',
|
||||||
|
'variant.eventDates',
|
||||||
|
'variant.eventDate',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Services\Checkout;
|
namespace App\Domains\Purchase\Services\Checkout;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Variant;
|
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
|
||||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
class EditCheckoutService
|
class EditCheckoutService
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private readonly CatalogInventoryService $inventory,
|
|
||||||
private readonly UserPurchaseLimitService $purchaseLimits,
|
|
||||||
private readonly CatalogSelectionResolver $selections,
|
|
||||||
private readonly SourceCartService $sourceCart,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** @param array<string, string> $customerData */
|
/** @param array<string, string> $customerData */
|
||||||
public function updateCustomer(Purchase $purchase, array $customerData): Purchase
|
public function updateCustomer(Purchase $purchase, array $customerData): Purchase
|
||||||
{
|
{
|
||||||
@@ -33,115 +21,6 @@ class EditCheckoutService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateItemQuantity(
|
|
||||||
Purchase $purchase,
|
|
||||||
PurchaseItem $purchaseItem,
|
|
||||||
int $quantity,
|
|
||||||
): Purchase {
|
|
||||||
return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase {
|
|
||||||
$purchase = $this->lockPurchase($purchase);
|
|
||||||
|
|
||||||
if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'purchase' => __('api.purchase.not_editable'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem);
|
|
||||||
$difference = $quantity - (int) $purchaseItem->cantidad;
|
|
||||||
|
|
||||||
if ($difference !== 0) {
|
|
||||||
$this->adjustReservation($purchase, $purchaseItem, $quantity, $difference);
|
|
||||||
|
|
||||||
$purchaseItem->update([
|
|
||||||
'cantidad' => $quantity,
|
|
||||||
'total' => (float) $purchaseItem->precio_unitario * $quantity,
|
|
||||||
]);
|
|
||||||
$this->sourceCart->syncItemQuantity($purchase, $purchaseItem, $quantity);
|
|
||||||
}
|
|
||||||
|
|
||||||
$purchase->update([
|
|
||||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function prepareItemEditing(Purchase $purchase): Purchase
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($purchase): Purchase {
|
|
||||||
$purchase = $this->lockPurchase($purchase);
|
|
||||||
$this->assertEditable($purchase);
|
|
||||||
|
|
||||||
$purchase->telepagosQr()->delete();
|
|
||||||
$purchase->update([
|
|
||||||
'status' => Purchase::STATUS_CREATED,
|
|
||||||
'payment_method' => null,
|
|
||||||
'transfer_payer_dni' => null,
|
|
||||||
'expires_at' => now()->addMinutes(
|
|
||||||
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function adjustReservation(
|
|
||||||
Purchase $purchase,
|
|
||||||
PurchaseItem $purchaseItem,
|
|
||||||
int $quantity,
|
|
||||||
int $difference,
|
|
||||||
): void {
|
|
||||||
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $purchaseItem);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ($difference > 0) {
|
|
||||||
$otherItemQuantity = (int) $purchase->items()
|
|
||||||
->where('source_catalog_item_id', $purchaseItem->source_catalog_item_id)
|
|
||||||
->whereKeyNot($purchaseItem->getKey())
|
|
||||||
->sum('cantidad');
|
|
||||||
$catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection;
|
|
||||||
|
|
||||||
$this->purchaseLimits->assertCanPurchase(
|
|
||||||
$catalogItem,
|
|
||||||
(int) $purchase->user_id,
|
|
||||||
$otherItemQuantity + $quantity,
|
|
||||||
$purchase->getKey(),
|
|
||||||
);
|
|
||||||
$this->inventory->reserve($selection, $difference);
|
|
||||||
} else {
|
|
||||||
$this->inventory->release($selection, abs($difference));
|
|
||||||
}
|
|
||||||
} catch (\InvalidArgumentException) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'quantity' => __('api.purchase.insufficient_stock'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function lockPurchaseItem(Purchase $purchase, PurchaseItem $item): PurchaseItem
|
|
||||||
{
|
|
||||||
/** @var PurchaseItem|null $lockedItem */
|
|
||||||
$lockedItem = $purchase->items()
|
|
||||||
->whereKey($item->getKey())
|
|
||||||
->lockForUpdate()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($lockedItem === null) {
|
|
||||||
throw new NotFoundHttpException('Purchase item not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($lockedItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'item' => __('api.purchase.item_not_editable'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $lockedItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assertEditable(Purchase $purchase): void
|
private function assertEditable(Purchase $purchase): void
|
||||||
{
|
{
|
||||||
if (
|
if (
|
||||||
@@ -170,6 +49,15 @@ class EditCheckoutService
|
|||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
private function loadPurchase(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $purchase->load(['items.imageAttachment']);
|
return $purchase->load([
|
||||||
|
'items.imageAttachment',
|
||||||
|
'cart.items.catalogItem.inventory',
|
||||||
|
'cart.items.catalogItem.attachments',
|
||||||
|
'cart.items.variant.inventory',
|
||||||
|
'cart.items.variant.attachments',
|
||||||
|
'cart.items.variant.definitions.itemAttribute.attribute',
|
||||||
|
'cart.items.variant.eventDates',
|
||||||
|
'cart.items.variant.eventDate',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace App\Domains\Purchase\Services\Checkout;
|
|||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class PurchaseItemSnapshotFactory
|
class PurchaseItemSnapshotFactory
|
||||||
@@ -38,7 +37,6 @@ class PurchaseItemSnapshotFactory
|
|||||||
'discount_total' => null,
|
'discount_total' => null,
|
||||||
'tax_total' => null,
|
'tax_total' => null,
|
||||||
'total' => $unitPrice * $quantity,
|
'total' => $unitPrice * $quantity,
|
||||||
'reservation_status' => PurchaseItem::RESERVATION_ACTIVE,
|
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
->all();
|
->all();
|
||||||
|
|||||||
@@ -2,17 +2,16 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Services\Checkout;
|
namespace App\Domains\Purchase\Services\Checkout;
|
||||||
|
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class ReleaseCheckoutService
|
class ReleaseCheckoutService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CatalogInventoryService $inventory,
|
private readonly StockReservationService $reservations,
|
||||||
private readonly CatalogSelectionResolver $selections,
|
|
||||||
private readonly SourceCartService $sourceCart,
|
private readonly SourceCartService $sourceCart,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -77,38 +76,71 @@ class ReleaseCheckoutService
|
|||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = $purchase->items()
|
if ($purchase->items()->exists()) {
|
||||||
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
|
throw ValidationException::withMessages([
|
||||||
->lockForUpdate()
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
->get();
|
|
||||||
$reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase);
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
|
||||||
if (! $reservationReturnedToCart) {
|
|
||||||
$this->releaseInventory($purchase, $item);
|
|
||||||
}
|
|
||||||
|
|
||||||
$item->update([
|
|
||||||
'reservation_status' => PurchaseItem::RESERVATION_RELEASED,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase);
|
||||||
|
$this->releaseCartReservations($purchase, $reservationReturnedToCart, $targetStatus);
|
||||||
|
|
||||||
$purchase->update(['status' => $targetStatus]);
|
$purchase->update(['status' => $targetStatus]);
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function releaseInventory(Purchase $purchase, PurchaseItem $item): void
|
private function releaseCartReservations(
|
||||||
{
|
Purchase $purchase,
|
||||||
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item);
|
bool $reservationReturnedToCart,
|
||||||
|
string $targetStatus,
|
||||||
|
): void {
|
||||||
|
if ($reservationReturnedToCart) {
|
||||||
|
$this->reservations->detachFromPurchase($purchase);
|
||||||
|
|
||||||
try {
|
return;
|
||||||
$this->inventory->release($selection, (int) $item->cantidad);
|
}
|
||||||
} catch (\InvalidArgumentException) {
|
|
||||||
throw ValidationException::withMessages([
|
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||||
'items' => __('api.purchase.inconsistent_reservation'),
|
if ($cart === null) {
|
||||||
]);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
|
||||||
|
$cartItems->load([
|
||||||
|
'catalogItem.inventory',
|
||||||
|
'catalogItem.bundleComponents.catalogItem.inventory',
|
||||||
|
'catalogItem.bundleComponents.variant.inventory',
|
||||||
|
'variant.inventory',
|
||||||
|
'variant.catalogItem',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($cartItems as $cartItem) {
|
||||||
|
$selection = $cartItem->selectedItem();
|
||||||
|
if ($selection === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->reservations->release(
|
||||||
|
$cartItem,
|
||||||
|
$selection,
|
||||||
|
(int) $cartItem->cantidad,
|
||||||
|
$targetStatus === Purchase::STATUS_EXPIRED
|
||||||
|
? StockReservation::STATUS_EXPIRED
|
||||||
|
: StockReservation::STATUS_RELEASED,
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $cart->trashed()) {
|
||||||
|
$cart->update(['status' => 'converted']);
|
||||||
|
$cart->delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +161,15 @@ class ReleaseCheckoutService
|
|||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
private function loadPurchase(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $purchase->load(['items.imageAttachment']);
|
return $purchase->load([
|
||||||
|
'items.imageAttachment',
|
||||||
|
'cart.items.catalogItem.inventory',
|
||||||
|
'cart.items.catalogItem.attachments',
|
||||||
|
'cart.items.variant.inventory',
|
||||||
|
'cart.items.variant.attachments',
|
||||||
|
'cart.items.variant.definitions.itemAttribute.attribute',
|
||||||
|
'cart.items.variant.eventDates',
|
||||||
|
'cart.items.variant.eventDate',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ namespace App\Domains\Purchase\Services\Checkout;
|
|||||||
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
|
||||||
|
|
||||||
class SourceCartService
|
class SourceCartService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly StockReservationService $reservations,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function restore(Purchase $purchase): bool
|
public function restore(Purchase $purchase): bool
|
||||||
{
|
{
|
||||||
$sourceCart = $this->findSourceCart($purchase);
|
$sourceCart = $this->findSourceCart($purchase);
|
||||||
@@ -17,6 +21,10 @@ class SourceCartService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($sourceCart->origin === Cart::ORIGIN_DIRECT_CHECKOUT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** @var Cart|null $activeCart */
|
/** @var Cart|null $activeCart */
|
||||||
$activeCart = Cart::query()
|
$activeCart = Cart::query()
|
||||||
->where('tenant_codigo', $purchase->tenant_codigo)
|
->where('tenant_codigo', $purchase->tenant_codigo)
|
||||||
@@ -54,23 +62,6 @@ class SourceCartService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncItemQuantity(
|
|
||||||
Purchase $purchase,
|
|
||||||
PurchaseItem $purchaseItem,
|
|
||||||
int $quantity,
|
|
||||||
): void {
|
|
||||||
$sourceCart = $this->findSourceCart($purchase);
|
|
||||||
|
|
||||||
if ($sourceCart === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sourceCart->items()
|
|
||||||
->where('catalog_item_id', $purchaseItem->source_catalog_item_id)
|
|
||||||
->where('variant_id', $purchaseItem->source_variant_id)
|
|
||||||
->update(['cantidad' => $quantity]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function finalize(Purchase $purchase): void
|
public function finalize(Purchase $purchase): void
|
||||||
{
|
{
|
||||||
$sourceCart = $this->findSourceCart($purchase);
|
$sourceCart = $this->findSourceCart($purchase);
|
||||||
@@ -112,7 +103,7 @@ class SourceCartService
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($activeItem === null) {
|
if ($activeItem === null) {
|
||||||
$activeCart->items()->create([
|
$activeItem = $activeCart->items()->create([
|
||||||
'catalog_item_id' => $sourceItem->catalog_item_id,
|
'catalog_item_id' => $sourceItem->catalog_item_id,
|
||||||
'variant_id' => $sourceItem->variant_id,
|
'variant_id' => $sourceItem->variant_id,
|
||||||
'cantidad' => $sourceItem->cantidad,
|
'cantidad' => $sourceItem->cantidad,
|
||||||
@@ -120,6 +111,8 @@ class SourceCartService
|
|||||||
} else {
|
} else {
|
||||||
$activeItem->increment('cantidad', (int) $sourceItem->cantidad);
|
$activeItem->increment('cantidad', (int) $sourceItem->cantidad);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->reservations->transfer($sourceItem, $activeItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Domains\Cart\Models\CartItem;
|
|||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||||
@@ -20,9 +21,9 @@ class StartCheckoutService
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CatalogInventoryService $inventory,
|
private readonly CatalogInventoryService $inventory,
|
||||||
|
private readonly StockReservationService $reservations,
|
||||||
private readonly UserPurchaseLimitService $purchaseLimits,
|
private readonly UserPurchaseLimitService $purchaseLimits,
|
||||||
private readonly CatalogSelectionResolver $selections,
|
private readonly CatalogSelectionResolver $selections,
|
||||||
private readonly PurchaseItemSnapshotFactory $snapshots,
|
|
||||||
private readonly InsufficientStockMessageBuilder $stockMessages,
|
private readonly InsufficientStockMessageBuilder $stockMessages,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -144,9 +145,24 @@ class StartCheckoutService
|
|||||||
throw new InsufficientStockException($unavailableItems);
|
throw new InsufficientStockException($unavailableItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cart = Cart::query()->create([
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'guest_token' => null,
|
||||||
|
'status' => 'checkout',
|
||||||
|
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cartItems = collect();
|
||||||
foreach ($resolvedLines as $line) {
|
foreach ($resolvedLines as $line) {
|
||||||
|
$cartItem = $cart->items()->create([
|
||||||
|
'catalog_item_id' => $line['catalog_item_id'],
|
||||||
|
'variant_id' => $line['variant_id'],
|
||||||
|
'cantidad' => $line['quantity'],
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->inventory->reserve($line['selection'], $line['quantity']);
|
$this->reservations->reserve($cartItem, $line['selection'], $line['quantity']);
|
||||||
} catch (\InvalidArgumentException) {
|
} catch (\InvalidArgumentException) {
|
||||||
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
|
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
|
||||||
|
|
||||||
@@ -154,6 +170,10 @@ class StartCheckoutService
|
|||||||
$this->unavailableItem($line, $availableQuantity),
|
$this->unavailableItem($line, $availableQuantity),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cartItem->setRelation('catalogItem', $line['catalog_item']);
|
||||||
|
$cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null);
|
||||||
|
$cartItems->push($cartItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
$purchase = $this->createPurchase(
|
$purchase = $this->createPurchase(
|
||||||
@@ -163,19 +183,16 @@ class StartCheckoutService
|
|||||||
(float) $resolvedLines->sum(
|
(float) $resolvedLines->sum(
|
||||||
fn (array $line): float => $line['selection']->getPrice() * $line['quantity'],
|
fn (array $line): float => $line['selection']->getPrice() * $line['quantity'],
|
||||||
),
|
),
|
||||||
null,
|
$cart->getKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$directCartItems = $resolvedLines->map(fn (array $line): CartItem => $this->makeDirectCartItem(
|
foreach ($cartItems as $index => $cartItem) {
|
||||||
$line['selection'],
|
$this->reservations->attachToPurchase(
|
||||||
$line['catalog_item_id'],
|
$cartItem,
|
||||||
$line['variant_id'],
|
$resolvedLines->get($index)['selection'],
|
||||||
$line['quantity'],
|
$purchase,
|
||||||
));
|
);
|
||||||
|
}
|
||||||
$purchase->items()->createMany(
|
|
||||||
$this->snapshots->fromCartItems($directCartItems),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
@@ -235,7 +252,13 @@ class StartCheckoutService
|
|||||||
$cart->getTotalAmount(),
|
$cart->getTotalAmount(),
|
||||||
$cart->getKey(),
|
$cart->getKey(),
|
||||||
);
|
);
|
||||||
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
|
foreach ($cartItems as $cartItem) {
|
||||||
|
$this->reservations->attachToPurchase(
|
||||||
|
$cartItem,
|
||||||
|
$cartItem->selectedItem(),
|
||||||
|
$purchase,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The purchase owns the reservation until checkout finishes. The cart is
|
// The purchase owns the reservation until checkout finishes. The cart is
|
||||||
// retained so it can be restored if the purchase is cancelled or expires.
|
// retained so it can be restored if the purchase is cancelled or expires.
|
||||||
@@ -336,35 +359,6 @@ class StartCheckoutService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function makeDirectCartItem(
|
|
||||||
CatalogItem|Variant $selection,
|
|
||||||
int $catalogItemId,
|
|
||||||
?int $variantId,
|
|
||||||
int $quantity,
|
|
||||||
): CartItem {
|
|
||||||
$catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection;
|
|
||||||
$catalogItem->loadMissing(['inventory', 'attachments']);
|
|
||||||
|
|
||||||
if ($selection instanceof Variant) {
|
|
||||||
$selection->loadMissing([
|
|
||||||
'inventory',
|
|
||||||
'attachments',
|
|
||||||
'catalogItem',
|
|
||||||
'definitions.itemAttribute.attribute',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$item = new CartItem([
|
|
||||||
'catalog_item_id' => $catalogItemId,
|
|
||||||
'variant_id' => $variantId,
|
|
||||||
'cantidad' => $quantity,
|
|
||||||
]);
|
|
||||||
$item->setRelation('catalogItem', $catalogItem);
|
|
||||||
$item->setRelation('variant', $selection instanceof Variant ? $selection : null);
|
|
||||||
|
|
||||||
return $item;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param Collection<int, CartItem> $cartItems */
|
/** @param Collection<int, CartItem> $cartItems */
|
||||||
private function loadCartItems(Collection $cartItems): void
|
private function loadCartItems(Collection $cartItems): void
|
||||||
{
|
{
|
||||||
@@ -382,6 +376,15 @@ class StartCheckoutService
|
|||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
private function loadPurchase(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $purchase->load(['items.imageAttachment']);
|
return $purchase->load([
|
||||||
|
'items.imageAttachment',
|
||||||
|
'cart.items.catalogItem.inventory',
|
||||||
|
'cart.items.catalogItem.attachments',
|
||||||
|
'cart.items.variant.inventory',
|
||||||
|
'cart.items.variant.attachments',
|
||||||
|
'cart.items.variant.definitions.itemAttribute.attribute',
|
||||||
|
'cart.items.variant.eventDates',
|
||||||
|
'cart.items.variant.eventDate',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Services;
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
|
||||||
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
|
||||||
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
|
||||||
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||||
@@ -23,6 +23,7 @@ class CheckoutService
|
|||||||
private readonly EditCheckoutService $editor,
|
private readonly EditCheckoutService $editor,
|
||||||
private readonly CompleteCheckoutService $completer,
|
private readonly CompleteCheckoutService $completer,
|
||||||
private readonly ReleaseCheckoutService $releaser,
|
private readonly ReleaseCheckoutService $releaser,
|
||||||
|
private readonly StockReservationService $reservations,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @param array<string, mixed> $purchaseData */
|
/** @param array<string, mixed> $purchaseData */
|
||||||
@@ -47,19 +48,6 @@ class CheckoutService
|
|||||||
return $this->editor->updateCustomer($purchase, $customerData);
|
return $this->editor->updateCustomer($purchase, $customerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateItemQuantity(
|
|
||||||
Purchase $purchase,
|
|
||||||
PurchaseItem $purchaseItem,
|
|
||||||
int $quantity,
|
|
||||||
): Purchase {
|
|
||||||
return $this->editor->updateItemQuantity($purchase, $purchaseItem, $quantity);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function prepareItemEditing(Purchase $purchase): Purchase
|
|
||||||
{
|
|
||||||
return $this->editor->prepareItemEditing($purchase);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function confirmPurchase(Purchase $purchase): void
|
public function confirmPurchase(Purchase $purchase): void
|
||||||
{
|
{
|
||||||
$this->completer->confirm($purchase);
|
$this->completer->confirm($purchase);
|
||||||
@@ -94,4 +82,9 @@ class CheckoutService
|
|||||||
{
|
{
|
||||||
return $this->releaser->expireOverdue();
|
return $this->releaser->expireOverdue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function syncReservationExpiration(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
$this->reservations->syncPurchaseExpiration($purchase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Services;
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
@@ -52,7 +53,24 @@ class UserPurchaseLimitService
|
|||||||
})
|
})
|
||||||
->sum('cantidad');
|
->sum('cantidad');
|
||||||
|
|
||||||
if ($purchasedQuantity + $requestedQuantity > $limit) {
|
$checkoutQuantity = (int) CartItem::query()
|
||||||
|
->where('catalog_item_id', $catalogItem->getKey())
|
||||||
|
->whereHas('cart.purchases', function ($query) use ($userId, $excludedPurchaseId): void {
|
||||||
|
$query
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->whereIn('status', [
|
||||||
|
Purchase::STATUS_CREATED,
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
])
|
||||||
|
->whereDoesntHave('items')
|
||||||
|
->when(
|
||||||
|
$excludedPurchaseId !== null,
|
||||||
|
fn ($query) => $query->whereKeyNot($excludedPurchaseId),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->sum('cantidad');
|
||||||
|
|
||||||
|
if ($purchasedQuantity + $checkoutQuantity + $requestedQuantity > $limit) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
$field => __('api.purchase_limit.exceeded', ['max' => $limit]),
|
$field => __('api.purchase_limit.exceeded', ['max' => $limit]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
## Propósito
|
## Propósito
|
||||||
|
|
||||||
Implementa el ciclo de compra y checkout: crea una compra desde el carrito, toma una instantánea de sus ítems, reserva inventario, permite ediciones, inicia el pago y confirma, cancela o vence la operación.
|
Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un carrito, mantiene sus líneas vivas contra catálogo durante el checkout, inicia el pago y materializa el snapshot definitivo al confirmar, o cancela y vence la operación.
|
||||||
|
|
||||||
## Modelo
|
## Modelo
|
||||||
|
|
||||||
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `paid`, `cancelled`, `rejected` y `expired`.
|
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `paid`, `cancelled`, `rejected` y `expired`.
|
||||||
- `PurchaseItem`: snapshot del producto o variante, cantidad, precio y total al comprar.
|
- `PurchaseItem`: snapshot definitivo del producto o variante, creado recién al confirmar la compra.
|
||||||
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
|
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
|
||||||
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
|
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
|
||||||
|
|
||||||
@@ -15,13 +15,17 @@ Implementa el ciclo de compra y checkout: crea una compra desde el carrito, toma
|
|||||||
|
|
||||||
`CheckoutService` es la fachada estable. Delega en:
|
`CheckoutService` es la fachada estable. Delega en:
|
||||||
|
|
||||||
- `StartCheckoutService`: inicia la compra desde el carrito.
|
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, sin crear todavía `PurchaseItem`.
|
||||||
- `EditCheckoutService`: modifica cliente o cantidades antes del cierre.
|
- `EditCheckoutService`: modifica los datos del comprador antes del cierre.
|
||||||
- `CompleteCheckoutService`: completa, envía a revisión o confirma el pago.
|
- `CompleteCheckoutService`: completa, envía a revisión o materializa los `PurchaseItem` al confirmar el pago.
|
||||||
- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes.
|
- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes.
|
||||||
- `SourceCartService`: sincroniza, restaura o finaliza el carrito fuente.
|
- `SourceCartService`: restaura o finaliza el carrito fuente.
|
||||||
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
|
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
|
||||||
|
|
||||||
|
Durante `created` y `pending_payment`, `PurchaseResource` publica las líneas del carrito con `items_source=cart`; una compra materializada publica `items_source=purchase`. Los datos descriptivos y económicos del checkout se resuelven siempre desde el catálogo vigente.
|
||||||
|
|
||||||
|
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, recalcula el total y renueva la reserva; Purchase no expone operaciones sobre líneas antes de la confirmación.
|
||||||
|
|
||||||
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.
|
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
|
|||||||
Route::get('compras', [PurchaseController::class, 'index']);
|
Route::get('compras', [PurchaseController::class, 'index']);
|
||||||
Route::post('compras/start-checkout', [PurchaseController::class, 'startCheckout']);
|
Route::post('compras/start-checkout', [PurchaseController::class, 'startCheckout']);
|
||||||
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
|
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
|
||||||
Route::post('compras/{compra}/edit-items', [PurchaseController::class, 'prepareItemEditing']);
|
|
||||||
Route::patch('compras/{compra}/items/{item}', [PurchaseController::class, 'updateItemQuantity']);
|
|
||||||
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
|
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
|
||||||
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
||||||
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
|
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
|
||||||
|
|||||||
5
config/catalog.php
Normal file
5
config/catalog.php
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'stock_reservation_expiration_minutes' => (int) env('STOCK_RESERVATION_EXPIRATION_MINUTES', 30),
|
||||||
|
];
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('carritos', function (Blueprint $table): void {
|
||||||
|
$table->string('origin')->default('user')->after('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('stock_reservations', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('inventory_id')->constrained('inventories')->restrictOnDelete();
|
||||||
|
$table->foreignId('cart_item_id')->nullable()->constrained('carrito_items')->nullOnDelete();
|
||||||
|
$table->foreignId('purchase_id')->nullable()->constrained('compras')->cascadeOnDelete();
|
||||||
|
$table->unsignedInteger('quantity');
|
||||||
|
$table->string('status')->default('active');
|
||||||
|
$table->dateTime('expires_at')->nullable();
|
||||||
|
$table->dateTime('committed_at')->nullable();
|
||||||
|
$table->dateTime('released_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['cart_item_id', 'inventory_id']);
|
||||||
|
$table->index(['purchase_id', 'status']);
|
||||||
|
$table->index(['status', 'expires_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
CartItem::query()
|
||||||
|
->whereHas('cart', fn ($query) => $query->where('status', 'active'))
|
||||||
|
->with([
|
||||||
|
'catalogItem.inventory',
|
||||||
|
'catalogItem.bundleComponents.catalogItem.inventory',
|
||||||
|
'catalogItem.bundleComponents.variant.inventory',
|
||||||
|
'variant.inventory',
|
||||||
|
'variant.catalogItem',
|
||||||
|
])
|
||||||
|
->eachById(function (CartItem $cartItem): void {
|
||||||
|
$selection = $cartItem->selectedItem();
|
||||||
|
if ($selection !== null) {
|
||||||
|
app(StockReservationService::class)->ensure($cartItem, $selection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('stock_reservations');
|
||||||
|
|
||||||
|
Schema::table('carritos', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('origin');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('compra_items', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('reservation_status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('compra_items', function (Blueprint $table): void {
|
||||||
|
$table->string('reservation_status')
|
||||||
|
->default('committed')
|
||||||
|
->after('total');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -37,7 +37,6 @@ return [
|
|||||||
'source_required' => 'A cart or direct item is required.',
|
'source_required' => 'A cart or direct item is required.',
|
||||||
'payment_method_required' => 'The purchase payment method must be selected before finalizing.',
|
'payment_method_required' => 'The purchase payment method must be selected before finalizing.',
|
||||||
'not_editable' => 'The purchase is no longer editable.',
|
'not_editable' => 'The purchase is no longer editable.',
|
||||||
'item_not_editable' => 'The purchase item is no longer editable.',
|
|
||||||
'insufficient_stock' => 'There is not enough stock available.',
|
'insufficient_stock' => 'There is not enough stock available.',
|
||||||
'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.',
|
'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.',
|
||||||
'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.',
|
'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.',
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ return [
|
|||||||
'source_required' => 'Se requiere un carrito o un producto directo.',
|
'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.',
|
'payment_method_required' => 'Debes seleccionar el método de pago antes de finalizar la compra.',
|
||||||
'not_editable' => 'La compra ya no se puede modificar.',
|
'not_editable' => 'La compra ya no se puede modificar.',
|
||||||
'item_not_editable' => 'El producto de la compra ya no se puede modificar.',
|
|
||||||
'insufficient_stock' => 'No hay suficiente stock disponible.',
|
'insufficient_stock' => 'No hay suficiente stock disponible.',
|
||||||
'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.',
|
'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.',
|
||||||
'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.',
|
'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.',
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ declare(strict_types=1);
|
|||||||
* Run from the backend root with:
|
* Run from the backend root with:
|
||||||
* php postman/generate-shopit-collection.php
|
* php postman/generate-shopit-collection.php
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$root = dirname(__DIR__);
|
$root = dirname(__DIR__);
|
||||||
chdir($root);
|
chdir($root);
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ function jsonBody(array $payload): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, string> $fields
|
/** @param array<string, string> $fields
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
function formDataBody(array $fields, array $fileFields = []): array
|
function formDataBody(array $fields, array $fileFields = []): array
|
||||||
{
|
{
|
||||||
@@ -85,7 +84,7 @@ function bodyFor(string $method, string $uri): ?array
|
|||||||
'POST api/tenants/{tenant:codigo}/catalog-items/{catalogItem}/variant-options' => ['selected_values' => ['color' => 'azul'], 'cart_item_id' => '{{cart_item_id}}'],
|
'POST api/tenants/{tenant:codigo}/catalog-items/{catalogItem}/variant-options' => ['selected_values' => ['color' => 'azul'], 'cart_item_id' => '{{cart_item_id}}'],
|
||||||
'POST api/tenants/{tenant:codigo}/compras/start-checkout' => ['cart_id' => '{{cart_id}}'],
|
'POST api/tenants/{tenant:codigo}/compras/start-checkout' => ['cart_id' => '{{cart_id}}'],
|
||||||
'PATCH api/tenants/{tenant:codigo}/compras/{compra}/customer-data' => ['dni' => '30123456', 'telefono' => '+5491112345678', 'nombre_apellido' => 'Usuario Demo', 'email' => '{{user_email}}'],
|
'PATCH api/tenants/{tenant:codigo}/compras/{compra}/customer-data' => ['dni' => '30123456', 'telefono' => '+5491112345678', 'nombre_apellido' => 'Usuario Demo', 'email' => '{{user_email}}'],
|
||||||
'PATCH api/tenants/{tenant:codigo}/compras/{compra}/items/{item}' => ['quantity' => 2],
|
'PATCH api/tenants/{tenant:codigo}/checkout-carts/{cart}/items/{cartItem}' => ['cantidad' => 2],
|
||||||
'POST api/tenants/{tenant:codigo}/compras/{compra}/payment-intent' => ['method' => 'transfer', 'transfer_payer_dni' => '30123456'],
|
'POST api/tenants/{tenant:codigo}/compras/{compra}/payment-intent' => ['method' => 'transfer', 'transfer_payer_dni' => '30123456'],
|
||||||
'POST api/tenants/{tenant:codigo}/tickets/pdf' => ['ticket_ids' => [1]],
|
'POST api/tenants/{tenant:codigo}/tickets/pdf' => ['ticket_ids' => [1]],
|
||||||
'POST api/v1/adminapp/login' => ['email' => '{{admin_email}}', 'password' => '{{admin_password}}'],
|
'POST api/v1/adminapp/login' => ['email' => '{{admin_email}}', 'password' => '{{admin_password}}'],
|
||||||
@@ -245,10 +244,11 @@ function requestName(string $method, string $action, bool $multiMethod): string
|
|||||||
$verbs = [
|
$verbs = [
|
||||||
'index' => 'List', 'store' => 'Create', 'show' => 'Get', 'update' => 'Update',
|
'index' => 'List', 'store' => 'Create', 'show' => 'Get', 'update' => 'Update',
|
||||||
'destroy' => 'Delete', 'addItem' => 'Add Item', 'updateItemQuantity' => 'Update Item Quantity',
|
'destroy' => 'Delete', 'addItem' => 'Add Item', 'updateItemQuantity' => 'Update Item Quantity',
|
||||||
|
'updateCheckoutItem' => 'Update Checkout Cart Item',
|
||||||
'removeItem' => 'Remove Item', 'search' => 'Search', 'category' => 'Get Category',
|
'removeItem' => 'Remove Item', 'search' => 'Search', 'category' => 'Get Category',
|
||||||
'featuredGroupItems' => 'List Featured Group Items', 'variantOptions' => 'Get Variant Options',
|
'featuredGroupItems' => 'List Featured Group Items', 'variantOptions' => 'Get Variant Options',
|
||||||
'startCheckout' => 'Start Checkout', 'updateCustomerData' => 'Update Customer Data',
|
'startCheckout' => 'Start Checkout', 'updateCustomerData' => 'Update Customer Data',
|
||||||
'prepareItemEditing' => 'Prepare Item Editing', 'paymentIntent' => 'Create Payment Intent',
|
'paymentIntent' => 'Create Payment Intent',
|
||||||
'submitForReview' => 'Submit for Review', 'cancel' => 'Cancel', 'complete' => 'Complete',
|
'submitForReview' => 'Submit for Review', 'cancel' => 'Cancel', 'complete' => 'Complete',
|
||||||
'downloadPdf' => 'Download PDF', 'downloadModificationsPdf' => 'Download Modifications PDF',
|
'downloadPdf' => 'Download PDF', 'downloadModificationsPdf' => 'Download Modifications PDF',
|
||||||
'modifications' => 'List Modifications', 'tickets' => 'List Tickets', 'confirm' => 'Confirm',
|
'modifications' => 'List Modifications', 'tickets' => 'List Tickets', 'confirm' => 'Confirm',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
@@ -9,13 +9,13 @@ Artisan::command('inspire', function () {
|
|||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
Artisan::command('purchases:expire', function (): void {
|
Artisan::command('reservations:expire', function (): void {
|
||||||
$expiredCount = app(CheckoutService::class)
|
$expired = app(ExpireStockReservationsService::class)->expireOverdue();
|
||||||
->expireOverduePurchases();
|
|
||||||
|
|
||||||
$this->info("Expired purchases: {$expiredCount}");
|
$this->info("Expired purchases: {$expired['purchases']}");
|
||||||
})->purpose('Release stock reservations from expired purchases');
|
$this->info("Expired cart items: {$expired['cart_items']}");
|
||||||
|
})->purpose('Release expired stock reservations from purchases and abandoned carts');
|
||||||
|
|
||||||
Schedule::command('purchases:expire')
|
Schedule::command('reservations:expire')
|
||||||
->everyMinute()
|
->everyMinute()
|
||||||
->withoutOverlapping();
|
->withoutOverlapping();
|
||||||
|
|||||||
@@ -73,6 +73,87 @@ class CartControllerTest extends TestCase
|
|||||||
'id' => $item->inventory_id,
|
'id' => $item->inventory_id,
|
||||||
'reserved_stock' => 2,
|
'reserved_stock' => 2,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'inventory_id' => $item->inventory_id,
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_sets_the_configured_expiration_when_creating_a_stock_reservation(): void
|
||||||
|
{
|
||||||
|
config()->set('catalog.stock_reservation_expiration_minutes', 45);
|
||||||
|
$now = now()->startOfSecond();
|
||||||
|
$this->travelTo($now);
|
||||||
|
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$item = $this->createDirectItem($tenant, 10, '49.90');
|
||||||
|
|
||||||
|
$this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'inventory_id' => $item->inventory_id,
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => 'active',
|
||||||
|
'expires_at' => $now->copy()->addMinutes(45)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_expires_abandoned_cart_reservations_and_removes_empty_carts(): void
|
||||||
|
{
|
||||||
|
config()->set('catalog.stock_reservation_expiration_minutes', 30);
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$item = $this->createDirectItem($tenant, 10, '49.90');
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
])->assertOk();
|
||||||
|
$cartId = $response->json('data.id');
|
||||||
|
$cartItemId = $response->json('data.items.0.id');
|
||||||
|
|
||||||
|
$this->artisan('reservations:expire')
|
||||||
|
->expectsOutput('Expired purchases: 0')
|
||||||
|
->expectsOutput('Expired cart items: 0')
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$this->travel(31)->minutes();
|
||||||
|
|
||||||
|
$this->artisan('reservations:expire')
|
||||||
|
->expectsOutput('Expired purchases: 0')
|
||||||
|
->expectsOutput('Expired cart items: 1')
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $item->inventory_id,
|
||||||
|
'real_stock' => 10,
|
||||||
|
'reserved_stock' => 0,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseMissing('carrito_items', ['id' => $cartItemId]);
|
||||||
|
$this->assertSoftDeleted('carritos', [
|
||||||
|
'id' => $cartId,
|
||||||
|
'status' => 'expired',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'inventory_id' => $item->inventory_id,
|
||||||
|
'cart_item_id' => null,
|
||||||
|
'purchase_id' => null,
|
||||||
|
'quantity' => 0,
|
||||||
|
'status' => 'expired',
|
||||||
|
'expires_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('reservations:expire')
|
||||||
|
->expectsOutput('Expired purchases: 0')
|
||||||
|
->expectsOutput('Expired cart items: 0')
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_filters_item_images_when_the_tenant_disables_them(): void
|
public function test_it_filters_item_images_when_the_tenant_disables_them(): void
|
||||||
@@ -129,6 +210,12 @@ class CartControllerTest extends TestCase
|
|||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
'reserved_stock' => 5,
|
'reserved_stock' => 5,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'cart_item_id' => $response->json('data.items.0.id'),
|
||||||
|
'inventory_id' => $variant->inventory_id,
|
||||||
|
'quantity' => 5,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_authenticated_cart_respects_previous_purchases_and_repeated_additions(): void
|
public function test_authenticated_cart_respects_previous_purchases_and_repeated_additions(): void
|
||||||
@@ -283,6 +370,12 @@ class CartControllerTest extends TestCase
|
|||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
'reserved_stock' => 0,
|
'reserved_stock' => 0,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'cart_item_id' => null,
|
||||||
|
'inventory_id' => $variant->inventory_id,
|
||||||
|
'quantity' => 0,
|
||||||
|
'status' => 'released',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_changes_an_item_variant_and_moves_the_stock_reservation(): void
|
public function test_it_changes_an_item_variant_and_moves_the_stock_reservation(): void
|
||||||
@@ -506,7 +599,6 @@ class CartControllerTest extends TestCase
|
|||||||
'cantidad' => $quantity,
|
'cantidad' => $quantity,
|
||||||
'precio_unitario' => $item->precio,
|
'precio_unitario' => $item->precio,
|
||||||
'total' => (float) $item->precio * $quantity,
|
'total' => (float) $item->precio * $quantity,
|
||||||
'reservation_status' => PurchaseItem::RESERVATION_COMMITTED,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,12 +193,14 @@ class TelepagosWebhookTest extends TestCase
|
|||||||
'sold_units' => 1,
|
'sold_units' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseMissing('compra_items', [
|
||||||
'compra_id' => $newerPurchase->id,
|
'compra_id' => $newerPurchase->id,
|
||||||
'source_catalog_item_id' => $variant->catalog_item_id,
|
]);
|
||||||
'source_variant_id' => $variant->id,
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'cantidad' => 2,
|
'purchase_id' => $newerPurchase->id,
|
||||||
'reservation_status' => 'active',
|
'inventory_id' => $variant->inventory_id,
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertSoftDeleted('carritos', [
|
$this->assertSoftDeleted('carritos', [
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class PurchaseCatalogItemTest extends TestCase
|
|||||||
$this->assertFalse(Schema::hasColumn('compra_items', 'variant_id'));
|
$this->assertFalse(Schema::hasColumn('compra_items', 'variant_id'));
|
||||||
$this->assertFalse(Schema::hasColumn('compra_items', 'buyable_type'));
|
$this->assertFalse(Schema::hasColumn('compra_items', 'buyable_type'));
|
||||||
$this->assertFalse(Schema::hasColumn('compra_items', 'buyable_id'));
|
$this->assertFalse(Schema::hasColumn('compra_items', 'buyable_id'));
|
||||||
|
$this->assertFalse(Schema::hasColumn('compra_items', 'reservation_status'));
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseHas('compra_items', [
|
||||||
'compra_id' => $purchase->id,
|
'compra_id' => $purchase->id,
|
||||||
'source_catalog_item_id' => $catalogItem->id,
|
'source_catalog_item_id' => $catalogItem->id,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
Queue::fake();
|
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 = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$tenant->update([
|
$tenant->update([
|
||||||
@@ -90,7 +90,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
$response->assertJsonPath('data.email', null);
|
$response->assertJsonPath('data.email', null);
|
||||||
$response->assertJsonPath('data.tenant_codigo', 'sonder');
|
$response->assertJsonPath('data.tenant_codigo', 'sonder');
|
||||||
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
|
$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->assertJsonCount(1, 'data.items');
|
||||||
$response->assertJsonPath('data.subtotal', '100.00');
|
$response->assertJsonPath('data.subtotal', '100.00');
|
||||||
$response->assertJsonPath('data.total', '100.00');
|
$response->assertJsonPath('data.total', '100.00');
|
||||||
@@ -109,12 +109,12 @@ class StorePurchaseTest extends TestCase
|
|||||||
'status' => Purchase::STATUS_CREATED,
|
'status' => Purchase::STATUS_CREATED,
|
||||||
'total' => 100,
|
'total' => 100,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]);
|
||||||
'compra_id' => $purchaseId,
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'source_catalog_item_id' => $catalogItem->id,
|
'inventory_id' => $inventory->id,
|
||||||
'source_variant_id' => $variant->id,
|
'purchase_id' => $purchaseId,
|
||||||
'cantidad' => 2,
|
'quantity' => 2,
|
||||||
'reservation_status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('carritos', [
|
$this->assertDatabaseHas('carritos', [
|
||||||
'id' => $cartId,
|
'id' => $cartId,
|
||||||
@@ -133,9 +133,22 @@ class StorePurchaseTest extends TestCase
|
|||||||
'real_stock' => 10,
|
'real_stock' => 10,
|
||||||
'reserved_stock' => 2,
|
'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');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -152,18 +165,23 @@ class StorePurchaseTest extends TestCase
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('data.cart_id', null)
|
->assertJsonPath('data.items_source', 'cart')
|
||||||
->assertJsonPath('data.items_source', 'purchase')
|
|
||||||
->assertJsonPath('data.items.0.quantity', 3)
|
->assertJsonPath('data.items.0.quantity', 3)
|
||||||
->assertJsonPath('data.total', '150.00');
|
->assertJsonPath('data.total', '150.00');
|
||||||
|
|
||||||
$this->assertDatabaseCount('carritos', 0);
|
$this->assertDatabaseHas('carritos', [
|
||||||
$this->assertDatabaseHas('compra_items', [
|
'id' => $response->json('data.cart_id'),
|
||||||
|
'status' => 'checkout',
|
||||||
|
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseMissing('compra_items', [
|
||||||
'compra_id' => $response->json('data.id'),
|
'compra_id' => $response->json('data.id'),
|
||||||
'source_catalog_item_id' => $variant->catalog_item_id,
|
]);
|
||||||
'source_variant_id' => $variant->id,
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'cantidad' => 3,
|
'inventory_id' => $variant->inventory_id,
|
||||||
'reservation_status' => 'active',
|
'purchase_id' => $response->json('data.id'),
|
||||||
|
'quantity' => 3,
|
||||||
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
@@ -176,9 +194,9 @@ class StorePurchaseTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
|
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
|
||||||
|
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'compra_id' => $response->json('data.id'),
|
'purchase_id' => $response->json('data.id'),
|
||||||
'reservation_status' => 'released',
|
'status' => 'released',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
@@ -215,7 +233,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('data.cart_id', null)
|
->assertJsonPath('data.items_source', 'cart')
|
||||||
->assertJsonCount(2, 'data.items')
|
->assertJsonCount(2, 'data.items')
|
||||||
->assertJsonPath('data.items.0.source_variant_id', $firstVariant->id)
|
->assertJsonPath('data.items.0.source_variant_id', $firstVariant->id)
|
||||||
->assertJsonPath('data.items.1.source_variant_id', $secondVariant->id)
|
->assertJsonPath('data.items.1.source_variant_id', $secondVariant->id)
|
||||||
@@ -223,18 +241,22 @@ class StorePurchaseTest extends TestCase
|
|||||||
|
|
||||||
$purchaseId = $response->json('data.id');
|
$purchaseId = $response->json('data.id');
|
||||||
|
|
||||||
$this->assertDatabaseCount('carritos', 0);
|
$this->assertDatabaseHas('carritos', [
|
||||||
$this->assertDatabaseHas('compra_items', [
|
'id' => $response->json('data.cart_id'),
|
||||||
'compra_id' => $purchaseId,
|
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
|
||||||
'source_variant_id' => $firstVariant->id,
|
|
||||||
'cantidad' => 1,
|
|
||||||
'reservation_status' => 'active',
|
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]);
|
||||||
'compra_id' => $purchaseId,
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'source_variant_id' => $secondVariant->id,
|
'inventory_id' => $firstVariant->inventory_id,
|
||||||
'cantidad' => 1,
|
'purchase_id' => $purchaseId,
|
||||||
'reservation_status' => 'active',
|
'quantity' => 1,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'inventory_id' => $secondVariant->inventory_id,
|
||||||
|
'purchase_id' => $purchaseId,
|
||||||
|
'quantity' => 1,
|
||||||
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $firstVariant->inventory_id,
|
'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');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -479,7 +501,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('data.items_source', 'purchase')
|
->assertJsonPath('data.items_source', 'cart')
|
||||||
->assertJsonCount(1, 'data.items')
|
->assertJsonCount(1, 'data.items')
|
||||||
->assertJsonPath('data.items.0.quantity', 2)
|
->assertJsonPath('data.items.0.quantity', 2)
|
||||||
->assertJsonPath('data.dni', null)
|
->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');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||||
$itemId = $purchase->items->firstOrFail()->id;
|
$itemId = $purchase->cart->items->firstOrFail()->id;
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
|
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
|
||||||
'quantity' => 4,
|
'cantidad' => 4,
|
||||||
])
|
])
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.items.0.quantity', 4)
|
->assertJsonPath('data.items.0.cantidad', 4)
|
||||||
->assertJsonPath('data.items.0.line_total', '200.00')
|
->assertJsonPath('data.subtotal', '200.00');
|
||||||
->assertJsonPath('data.subtotal', '200.00')
|
|
||||||
->assertJsonPath('data.total', '200.00');
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
@@ -567,12 +587,17 @@ class StorePurchaseTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
|
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
|
||||||
'quantity' => 1,
|
'cantidad' => 1,
|
||||||
])
|
])
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.items.0.quantity', 1)
|
->assertJsonPath('data.items.0.cantidad', 1)
|
||||||
->assertJsonPath('data.total', '50.00');
|
->assertJsonPath('data.subtotal', '50.00');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('compras', [
|
||||||
|
'id' => $purchase->id,
|
||||||
|
'total' => '50.00',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
@@ -587,16 +612,16 @@ class StorePurchaseTest extends TestCase
|
|||||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
$variant->catalogItem->update(['max_units_per_user' => 3]);
|
$variant->catalogItem->update(['max_units_per_user' => 3]);
|
||||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||||
$itemId = $purchase->items->firstOrFail()->id;
|
$itemId = $purchase->cart->items->firstOrFail()->id;
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
|
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
|
||||||
'quantity' => 4,
|
'cantidad' => 4,
|
||||||
])
|
])
|
||||||
->assertUnprocessable()
|
->assertUnprocessable()
|
||||||
->assertJsonValidationErrors('quantity');
|
->assertJsonValidationErrors('cantidad');
|
||||||
|
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseHas('carrito_items', [
|
||||||
'id' => $itemId,
|
'id' => $itemId,
|
||||||
'cantidad' => 2,
|
'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');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -620,17 +645,20 @@ class StorePurchaseTest extends TestCase
|
|||||||
'qr_order_id' => 'stale-order',
|
'qr_order_id' => 'stale-order',
|
||||||
'qr_code' => 'stale-qr',
|
'qr_code' => 'stale-qr',
|
||||||
]);
|
]);
|
||||||
|
$itemId = $purchase->cart->items->firstOrFail()->id;
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$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()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_CREATED)
|
->assertJsonPath('data.items.0.cantidad', 3);
|
||||||
->assertJsonPath('data.payment_method', null);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('compras', [
|
$this->assertDatabaseHas('compras', [
|
||||||
'id' => $purchase->id,
|
'id' => $purchase->id,
|
||||||
'status' => Purchase::STATUS_CREATED,
|
'status' => Purchase::STATUS_CREATED,
|
||||||
'payment_method' => null,
|
'payment_method' => null,
|
||||||
|
'total' => '150.00',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseMissing('telepagos_qr', [
|
$this->assertDatabaseMissing('telepagos_qr', [
|
||||||
'compra_id' => $purchase->id,
|
'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
|
public function test_start_checkout_rejects_customer_data(): void
|
||||||
{
|
{
|
||||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
@@ -789,17 +850,20 @@ class StorePurchaseTest extends TestCase
|
|||||||
|
|
||||||
$this->travel(31)->minutes();
|
$this->travel(31)->minutes();
|
||||||
|
|
||||||
$this->artisan('purchases:expire')
|
$this->artisan('reservations:expire')
|
||||||
->expectsOutput('Expired purchases: 1')
|
->expectsOutput('Expired purchases: 1')
|
||||||
|
->expectsOutput('Expired cart items: 0')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->assertDatabaseHas('compras', [
|
$this->assertDatabaseHas('compras', [
|
||||||
'id' => $purchase->id,
|
'id' => $purchase->id,
|
||||||
'status' => Purchase::STATUS_EXPIRED,
|
'status' => Purchase::STATUS_EXPIRED,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseMissing('compra_items', ['compra_id' => $purchase->id]);
|
||||||
'compra_id' => $purchase->id,
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'reservation_status' => 'released',
|
'purchase_id' => null,
|
||||||
|
'quantity' => 3,
|
||||||
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
@@ -814,12 +878,13 @@ class StorePurchaseTest extends TestCase
|
|||||||
'deleted_at' => null,
|
'deleted_at' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->artisan('purchases:expire')
|
$this->artisan('reservations:expire')
|
||||||
->expectsOutput('Expired purchases: 0')
|
->expectsOutput('Expired purchases: 0')
|
||||||
|
->expectsOutput('Expired cart items: 0')
|
||||||
->assertSuccessful();
|
->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');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
@@ -832,7 +897,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_CREATED)
|
->assertJsonPath('data.status', Purchase::STATUS_CREATED)
|
||||||
->assertJsonPath('data.items_source', 'purchase')
|
->assertJsonPath('data.items_source', 'cart')
|
||||||
->assertJsonCount(1, 'data.items')
|
->assertJsonCount(1, 'data.items')
|
||||||
->assertJsonPath('data.items.0.quantity', 2)
|
->assertJsonPath('data.items.0.quantity', 2)
|
||||||
->assertJsonPath('data.items.0.unit_price', '50.00')
|
->assertJsonPath('data.items.0.unit_price', '50.00')
|
||||||
@@ -863,7 +928,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
'type' => AttachmentType::Image,
|
'type' => AttachmentType::Image,
|
||||||
'mime_type' => 'image/png',
|
'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')
|
$this->actingAs($user, 'sanctum')
|
||||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||||
@@ -871,7 +936,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
->assertJsonPath('data.items.0.item_details.imagen', null);
|
->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');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
@@ -890,7 +955,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
|
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
|
||||||
->assertJsonPath('data.items_source', 'purchase')
|
->assertJsonPath('data.items_source', 'cart')
|
||||||
->assertJsonCount(1, 'data.items')
|
->assertJsonCount(1, 'data.items')
|
||||||
->assertJsonPath('data.items.0.quantity', 2)
|
->assertJsonPath('data.items.0.quantity', 2)
|
||||||
->assertJsonPath('data.items.0.unit_price', '50.00')
|
->assertJsonPath('data.items.0.unit_price', '50.00')
|
||||||
@@ -925,6 +990,12 @@ class StorePurchaseTest extends TestCase
|
|||||||
'reserved_stock' => 0,
|
'reserved_stock' => 0,
|
||||||
'sold_units' => 2,
|
'sold_units' => 2,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'purchase_id' => $purchase->id,
|
||||||
|
'inventory_id' => $variant->inventory_id,
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => 'committed',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->assertSoftDeleted('carritos', [
|
$this->assertSoftDeleted('carritos', [
|
||||||
'id' => $purchase->cart_id,
|
'id' => $purchase->cart_id,
|
||||||
@@ -956,7 +1027,9 @@ class StorePurchaseTest extends TestCase
|
|||||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
$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')
|
$this->actingAs($user, 'sanctum')
|
||||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||||
|
|||||||
33
tests/Unit/Catalog/ExpireStockReservationsServiceTest.php
Normal file
33
tests/Unit/Catalog/ExpireStockReservationsServiceTest.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Catalog;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Services\ExpireCartReservationsService;
|
||||||
|
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
||||||
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ExpireStockReservationsServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_expires_purchases_before_abandoned_cart_items(): void
|
||||||
|
{
|
||||||
|
$checkout = \Mockery::mock(CheckoutService::class);
|
||||||
|
$checkout->shouldReceive('expireOverduePurchases')
|
||||||
|
->once()
|
||||||
|
->ordered()
|
||||||
|
->andReturn(2);
|
||||||
|
|
||||||
|
$carts = \Mockery::mock(ExpireCartReservationsService::class);
|
||||||
|
$carts->shouldReceive('expireOverdue')
|
||||||
|
->once()
|
||||||
|
->ordered()
|
||||||
|
->andReturn(3);
|
||||||
|
|
||||||
|
$result = (new ExpireStockReservationsService($checkout, $carts))->expireOverdue();
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
'purchases' => 2,
|
||||||
|
'cart_items' => 3,
|
||||||
|
], $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user