feat(checkout): track current cart purchase
This commit is contained in:
parent
a34d5cba1a
commit
afd69b8393
|
|
@ -27,6 +27,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
'guest_token',
|
'guest_token',
|
||||||
'status',
|
'status',
|
||||||
'origin',
|
'origin',
|
||||||
|
'current_purchase_id',
|
||||||
])]
|
])]
|
||||||
class Cart extends Model
|
class Cart extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -43,6 +44,7 @@ class Cart extends Model
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
|
'current_purchase_id' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,6 +78,12 @@ class Cart extends Model
|
||||||
return $this->hasMany(Purchase::class, 'cart_id');
|
return $this->hasMany(Purchase::class, 'cart_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Purchase, $this> */
|
||||||
|
public function currentPurchase(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Purchase::class, 'current_purchase_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function getTotalAmount(): float
|
public function getTotalAmount(): float
|
||||||
{
|
{
|
||||||
$items = $this->relationLoaded('items')
|
$items = $this->relationLoaded('items')
|
||||||
|
|
|
||||||
|
|
@ -37,16 +37,24 @@ class StockReservationService
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function commit(CartItem $cartItem, CatalogItem|Variant $selection): void
|
public function commit(
|
||||||
{
|
CartItem $cartItem,
|
||||||
DB::transaction(function () use ($cartItem, $selection): void {
|
CatalogItem|Variant $selection,
|
||||||
|
Purchase $purchase,
|
||||||
|
): void {
|
||||||
|
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
|
||||||
$this->ensure($cartItem, $selection);
|
$this->ensure($cartItem, $selection);
|
||||||
$this->inventory->commit($selection, (int) $cartItem->cantidad);
|
$this->inventory->commit($selection, (int) $cartItem->cantidad);
|
||||||
|
|
||||||
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
|
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
|
||||||
foreach ($requirements as $inventoryId => $quantity) {
|
foreach ($requirements as $inventoryId => $quantity) {
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
||||||
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
|
if (
|
||||||
|
$reservation === null
|
||||||
|
|| $reservation->status !== StockReservation::STATUS_ACTIVE
|
||||||
|
|| $reservation->purchase_id !== $purchase->getKey()
|
||||||
|
|| $reservation->quantity !== $quantity
|
||||||
|
) {
|
||||||
throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.');
|
throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class SaleFormService
|
||||||
Purchase::STATUS_CANCELLED => 'Cancelada',
|
Purchase::STATUS_CANCELLED => 'Cancelada',
|
||||||
Purchase::STATUS_REJECTED => 'Rechazada',
|
Purchase::STATUS_REJECTED => 'Rechazada',
|
||||||
Purchase::STATUS_EXPIRED => 'Vencida',
|
Purchase::STATUS_EXPIRED => 'Vencida',
|
||||||
|
Purchase::STATUS_SUPERSEDED => 'Reemplazada',
|
||||||
];
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,8 @@ class PurchaseController extends Controller
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$purchaseState->lockCurrentCart($purchase);
|
||||||
|
|
||||||
$purchaseUpdate = [
|
$purchaseUpdate = [
|
||||||
'payment_method' => $method,
|
'payment_method' => $method,
|
||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ class Purchase extends Model
|
||||||
|
|
||||||
public const STATUS_EXPIRED = 'expired';
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
|
||||||
|
public const STATUS_SUPERSEDED = 'superseded';
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
public static function statuses(): array
|
public static function statuses(): array
|
||||||
{
|
{
|
||||||
|
|
@ -57,6 +59,7 @@ class Purchase extends Model
|
||||||
self::STATUS_CANCELLED,
|
self::STATUS_CANCELLED,
|
||||||
self::STATUS_REJECTED,
|
self::STATUS_REJECTED,
|
||||||
self::STATUS_EXPIRED,
|
self::STATUS_EXPIRED,
|
||||||
|
self::STATUS_SUPERSEDED,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ class CompleteCheckoutService
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->purchaseState->lockCurrentCart($purchase);
|
||||||
|
|
||||||
$purchase->update([
|
$purchase->update([
|
||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||||
|
|
@ -53,6 +55,8 @@ class CompleteCheckoutService
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->purchaseState->lockCurrentCart($purchase);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
||||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||||
|
|
@ -83,6 +87,7 @@ class CompleteCheckoutService
|
||||||
Purchase::STATUS_CANCELLED,
|
Purchase::STATUS_CANCELLED,
|
||||||
Purchase::STATUS_REJECTED,
|
Purchase::STATUS_REJECTED,
|
||||||
Purchase::STATUS_EXPIRED,
|
Purchase::STATUS_EXPIRED,
|
||||||
|
Purchase::STATUS_SUPERSEDED,
|
||||||
], true)) {
|
], true)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'purchase' => __('api.purchase.cannot_confirm'),
|
'purchase' => __('api.purchase.cannot_confirm'),
|
||||||
|
|
@ -95,12 +100,13 @@ class CompleteCheckoutService
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
$cart = $this->purchaseState->lockCurrentCart($purchase);
|
||||||
if ($cart?->status === 'converted' && $cart->trashed()) {
|
|
||||||
|
if ($cart->status === 'converted' && $cart->trashed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($cart === null || ! in_array($cart->status, ['active', 'checkout'], true)) {
|
if (! in_array($cart->status, ['active', 'checkout'], true)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'items' => __('api.purchase.inconsistent_reservation'),
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
]);
|
]);
|
||||||
|
|
@ -149,7 +155,7 @@ class CompleteCheckoutService
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->reservations->commit($cartItem, $selection);
|
$this->reservations->commit($cartItem, $selection, $purchase);
|
||||||
} catch (\InvalidArgumentException) {
|
} catch (\InvalidArgumentException) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'items' => __('api.purchase.inconsistent_reservation'),
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
|
|
@ -168,6 +174,7 @@ class CompleteCheckoutService
|
||||||
Purchase::STATUS_CANCELLED,
|
Purchase::STATUS_CANCELLED,
|
||||||
Purchase::STATUS_REJECTED,
|
Purchase::STATUS_REJECTED,
|
||||||
Purchase::STATUS_EXPIRED,
|
Purchase::STATUS_EXPIRED,
|
||||||
|
Purchase::STATUS_SUPERSEDED,
|
||||||
], true);
|
], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ class EditCheckoutService
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->purchaseState->lockCurrentCart($purchase);
|
||||||
|
|
||||||
$purchase->update($customerData);
|
$purchase->update($customerData);
|
||||||
|
|
||||||
return $this->responses->load($purchase);
|
return $this->responses->load($purchase);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Services\Checkout;
|
namespace App\Domains\Purchase\Services\Checkout;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Catalog\Models\StockReservation;
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Services\StockReservationService;
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
|
@ -103,6 +104,10 @@ class ReleaseCheckoutService
|
||||||
|
|
||||||
if ($cart->status === 'active') {
|
if ($cart->status === 'active') {
|
||||||
$this->reservations->detachFromPurchase($purchase);
|
$this->reservations->detachFromPurchase($purchase);
|
||||||
|
Cart::query()
|
||||||
|
->whereKey($cart->getKey())
|
||||||
|
->where('current_purchase_id', $purchase->getKey())
|
||||||
|
->update(['current_purchase_id' => null]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +159,7 @@ class ReleaseCheckoutService
|
||||||
Purchase::STATUS_CANCELLED,
|
Purchase::STATUS_CANCELLED,
|
||||||
Purchase::STATUS_REJECTED,
|
Purchase::STATUS_REJECTED,
|
||||||
Purchase::STATUS_EXPIRED,
|
Purchase::STATUS_EXPIRED,
|
||||||
|
Purchase::STATUS_SUPERSEDED,
|
||||||
], true);
|
], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,7 @@ class StartCheckoutService
|
||||||
),
|
),
|
||||||
$cart->getKey(),
|
$cart->getKey(),
|
||||||
);
|
);
|
||||||
|
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
||||||
|
|
||||||
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
|
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
|
||||||
$this->loadCartItems($cartItems);
|
$this->loadCartItems($cartItems);
|
||||||
|
|
@ -264,6 +265,7 @@ class StartCheckoutService
|
||||||
$cart->getTotalAmount(),
|
$cart->getTotalAmount(),
|
||||||
$cart->getKey(),
|
$cart->getKey(),
|
||||||
);
|
);
|
||||||
|
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
||||||
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
|
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
|
||||||
|
|
||||||
foreach ($cartItems as $cartItem) {
|
foreach ($cartItems as $cartItem) {
|
||||||
|
|
@ -279,9 +281,52 @@ class StartCheckoutService
|
||||||
|
|
||||||
private function resolveCart(Tenant $tenant, int $userId, int $cartId): Cart
|
private function resolveCart(Tenant $tenant, int $userId, int $cartId): Cart
|
||||||
{
|
{
|
||||||
|
/** @var Cart|null $candidate */
|
||||||
|
$candidate = Cart::query()->find($cartId);
|
||||||
|
|
||||||
|
$this->assertCartCanCheckout($candidate, $tenant, $userId);
|
||||||
|
|
||||||
|
$candidatePurchaseId = $candidate->current_purchase_id;
|
||||||
|
$currentPurchase = $candidatePurchaseId === null
|
||||||
|
? null
|
||||||
|
: Purchase::query()->lockForUpdate()->find($candidatePurchaseId);
|
||||||
|
|
||||||
/** @var Cart|null $cart */
|
/** @var Cart|null $cart */
|
||||||
$cart = Cart::query()->lockForUpdate()->find($cartId);
|
$cart = Cart::query()->lockForUpdate()->find($cartId);
|
||||||
|
|
||||||
|
$this->assertCartCanCheckout($cart, $tenant, $userId);
|
||||||
|
|
||||||
|
if ($cart->current_purchase_id !== $candidatePurchaseId) {
|
||||||
|
if ($cart->current_purchase_id !== null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'cart_id' => __('api.purchase.checkout_in_progress'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentPurchase = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($currentPurchase?->status === Purchase::STATUS_PAID) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'cart_id' => __('api.purchase.checkout_in_progress'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($currentPurchase !== null && in_array($currentPurchase->status, [
|
||||||
|
Purchase::STATUS_CREATED,
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
], true)) {
|
||||||
|
$currentPurchase->update([
|
||||||
|
'status' => Purchase::STATUS_SUPERSEDED,
|
||||||
|
'expires_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCartCanCheckout(?Cart $cart, Tenant $tenant, int $userId): void
|
||||||
|
{
|
||||||
if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) {
|
if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) {
|
||||||
throw new NotFoundHttpException('Cart not found for tenant.');
|
throw new NotFoundHttpException('Cart not found for tenant.');
|
||||||
}
|
}
|
||||||
|
|
@ -291,16 +336,6 @@ class StartCheckoutService
|
||||||
'cart_id' => __('api.purchase.inactive_cart'),
|
'cart_id' => __('api.purchase.inactive_cart'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($cart->purchases()
|
|
||||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
|
||||||
->exists()) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'cart_id' => __('api.purchase.checkout_in_progress'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param Collection<int, CartItem> $cartItems */
|
/** @param Collection<int, CartItem> $cartItems */
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Services;
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class PurchaseStateGuard
|
class PurchaseStateGuard
|
||||||
{
|
{
|
||||||
|
|
@ -21,4 +23,18 @@ class PurchaseStateGuard
|
||||||
throw new PurchaseExpiredException;
|
throw new PurchaseExpiredException;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function lockCurrentCart(Purchase $purchase): Cart
|
||||||
|
{
|
||||||
|
/** @var Cart|null $cart */
|
||||||
|
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||||
|
|
||||||
|
if ($cart === null || $cart->current_purchase_id !== $purchase->getKey()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'purchase' => __('api.purchase.not_current'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cart;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ class AdminAppSaleService
|
||||||
|
|
||||||
return Purchase::query()
|
return Purchase::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('status', '!=', Purchase::STATUS_SUPERSEDED)
|
||||||
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
||||||
$term = trim($search);
|
$term = trim($search);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('carritos', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('current_purchase_id')
|
||||||
|
->nullable()
|
||||||
|
->after('origin')
|
||||||
|
->constrained('compras')
|
||||||
|
->nullOnDelete();
|
||||||
|
$table->unique('current_purchase_id', 'carts_current_purchase_unique');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('carritos')
|
||||||
|
->select('id')
|
||||||
|
->orderBy('id')
|
||||||
|
->each(function (object $cart): void {
|
||||||
|
$purchaseId = DB::table('compras')
|
||||||
|
->where('cart_id', $cart->id)
|
||||||
|
->whereIn('status', ['created', 'pending_payment'])
|
||||||
|
->latest('id')
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
if ($purchaseId !== null) {
|
||||||
|
DB::table('carritos')
|
||||||
|
->where('id', $cart->id)
|
||||||
|
->update(['current_purchase_id' => $purchaseId]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('carritos', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique('carts_current_purchase_unique');
|
||||||
|
$table->dropConstrainedForeignId('current_purchase_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -42,8 +42,9 @@ return [
|
||||||
'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.',
|
||||||
'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, expired, or superseded purchase cannot be confirmed.',
|
||||||
'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.',
|
'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.',
|
||||||
|
'not_current' => 'The purchase is no longer the cart\'s current checkout.',
|
||||||
'paid_cannot_cancel' => 'A paid purchase cannot be cancelled.',
|
'paid_cannot_cancel' => 'A paid purchase cannot be cancelled.',
|
||||||
'stock' => [
|
'stock' => [
|
||||||
'seat_unavailable' => 'Seat :selection is no longer available.',
|
'seat_unavailable' => 'Seat :selection is no longer available.',
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,9 @@ return [
|
||||||
'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.',
|
||||||
'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, vencida o reemplazada no se puede confirmar.',
|
||||||
'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.',
|
'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.',
|
||||||
|
'not_current' => 'La compra ya no es el checkout actual del carrito.',
|
||||||
'paid_cannot_cancel' => 'Una compra pagada no se puede cancelar.',
|
'paid_cannot_cancel' => 'Una compra pagada no se puede cancelar.',
|
||||||
'stock' => [
|
'stock' => [
|
||||||
'seat_unavailable' => 'El asiento :selection ya no está disponible.',
|
'seat_unavailable' => 'El asiento :selection ya no está disponible.',
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class StorePurchaseTest extends TestCase
|
class StorePurchaseTest extends TestCase
|
||||||
|
|
@ -128,6 +129,7 @@ class StorePurchaseTest extends TestCase
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'origin' => Cart::ORIGIN_USER,
|
'origin' => Cart::ORIGIN_USER,
|
||||||
|
'current_purchase_id' => $purchaseId,
|
||||||
'deleted_at' => null,
|
'deleted_at' => null,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('carrito_items', [
|
$this->assertDatabaseHas('carrito_items', [
|
||||||
|
|
@ -150,12 +152,31 @@ class StorePurchaseTest extends TestCase
|
||||||
->assertJsonCount(1, 'data.items')
|
->assertJsonCount(1, 'data.items')
|
||||||
->assertJsonPath('data.items.0.cantidad', 2);
|
->assertJsonPath('data.items.0.cantidad', 2);
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$replacementResponse = $this->actingAs($user, 'sanctum')
|
||||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||||
'cart_id' => $cartId,
|
'cart_id' => $cartId,
|
||||||
])
|
])
|
||||||
->assertUnprocessable()
|
->assertCreated();
|
||||||
->assertJsonValidationErrors('cart_id');
|
$replacementPurchaseId = $replacementResponse->json('data.id');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('compras', [
|
||||||
|
'id' => $purchaseId,
|
||||||
|
'status' => Purchase::STATUS_SUPERSEDED,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
'id' => $cartId,
|
||||||
|
'current_purchase_id' => $replacementPurchaseId,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'inventory_id' => $inventory->id,
|
||||||
|
'purchase_id' => $replacementPurchaseId,
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $inventory->id,
|
||||||
|
'reserved_stock' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
$catalogItem->update([
|
$catalogItem->update([
|
||||||
'nombre' => 'Updated Product',
|
'nombre' => 'Updated Product',
|
||||||
|
|
@ -196,6 +217,7 @@ class StorePurchaseTest extends TestCase
|
||||||
'id' => $response->json('data.cart_id'),
|
'id' => $response->json('data.cart_id'),
|
||||||
'status' => 'checkout',
|
'status' => 'checkout',
|
||||||
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
|
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
|
||||||
|
'current_purchase_id' => $response->json('data.id'),
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseHas('compra_items', [
|
||||||
'compra_id' => $response->json('data.id'),
|
'compra_id' => $response->json('data.id'),
|
||||||
|
|
@ -455,6 +477,7 @@ class StorePurchaseTest extends TestCase
|
||||||
'id' => $purchase->cart_id,
|
'id' => $purchase->cart_id,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'origin' => Cart::ORIGIN_USER,
|
'origin' => Cart::ORIGIN_USER,
|
||||||
|
'current_purchase_id' => $purchase->id,
|
||||||
'deleted_at' => null,
|
'deleted_at' => null,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseCount('carrito_items', 1);
|
$this->assertDatabaseCount('carrito_items', 1);
|
||||||
|
|
@ -469,6 +492,7 @@ class StorePurchaseTest extends TestCase
|
||||||
'id' => $activeCart->id,
|
'id' => $activeCart->id,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
|
'current_purchase_id' => null,
|
||||||
'deleted_at' => null,
|
'deleted_at' => null,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
|
@ -484,6 +508,60 @@ class StorePurchaseTest extends TestCase
|
||||||
$this->assertSame(1, $activeCart->items()->count());
|
$this->assertSame(1, $activeCart->items()->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_reassigns_a_terminal_purchase_reservation_and_rejects_a_late_confirmation(): void
|
||||||
|
{
|
||||||
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
|
$previousPurchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||||
|
$cart = $previousPurchase->cart;
|
||||||
|
|
||||||
|
$previousPurchase->update([
|
||||||
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
'payment_method' => 'transfer',
|
||||||
|
'expires_at' => now()->addMinutes(30),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentPurchase = app(CheckoutService::class)->startCheckout(
|
||||||
|
$previousPurchase->tenant,
|
||||||
|
$user->id,
|
||||||
|
['cart_id' => $cart->id],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('compras', [
|
||||||
|
'id' => $previousPurchase->id,
|
||||||
|
'status' => Purchase::STATUS_SUPERSEDED,
|
||||||
|
'expires_at' => null,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
'id' => $cart->id,
|
||||||
|
'current_purchase_id' => $currentPurchase->id,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'purchase_id' => $currentPurchase->id,
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(CheckoutService::class)->confirmPaidPurchase($previousPurchase->fresh());
|
||||||
|
$this->fail('A superseded purchase was allowed to commit the current reservation.');
|
||||||
|
} catch (ValidationException $exception) {
|
||||||
|
$this->assertArrayHasKey('purchase', $exception->errors());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $variant->inventory_id,
|
||||||
|
'reserved_stock' => 2,
|
||||||
|
'sold_units' => 0,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'purchase_id' => $currentPurchase->id,
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_expires_the_purchase_without_mutating_the_active_cart(): void
|
public function test_it_expires_the_purchase_without_mutating_the_active_cart(): void
|
||||||
{
|
{
|
||||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
|
|
@ -511,6 +589,7 @@ class StorePurchaseTest extends TestCase
|
||||||
'id' => $activeCart->id,
|
'id' => $activeCart->id,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
|
'current_purchase_id' => null,
|
||||||
'deleted_at' => null,
|
'deleted_at' => null,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,20 @@ class AdminAppSaleControllerTest extends TestCase
|
||||||
'total' => '20000.00',
|
'total' => '20000.00',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$supersededPurchase = Purchase::query()->create([
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'status' => Purchase::STATUS_SUPERSEDED,
|
||||||
|
'total' => '50000.00',
|
||||||
|
]);
|
||||||
|
PurchaseItem::query()->create([
|
||||||
|
'compra_id' => $supersededPurchase->id,
|
||||||
|
'source_catalog_item_id' => $catalogItem->id,
|
||||||
|
'item_nombre' => $catalogItem->nombre,
|
||||||
|
'cantidad' => 5,
|
||||||
|
'precio_unitario' => '10000.00',
|
||||||
|
'total' => '50000.00',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->getJson('/api/v1/adminapp/tenant/sales?sort_by=id&sort_direction=asc')
|
$this->getJson('/api/v1/adminapp/tenant/sales?sort_by=id&sort_direction=asc')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonCount(3, 'data')
|
->assertJsonCount(3, 'data')
|
||||||
|
|
@ -117,6 +131,10 @@ class AdminAppSaleControllerTest extends TestCase
|
||||||
->assertJsonPath('data.1.quantity', 4)
|
->assertJsonPath('data.1.quantity', 4)
|
||||||
->assertJsonPath('data.2.id', $paidPurchase->id)
|
->assertJsonPath('data.2.id', $paidPurchase->id)
|
||||||
->assertJsonPath('data.2.quantity', 2);
|
->assertJsonPath('data.2.quantity', 2);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/sales?status='.Purchase::STATUS_SUPERSEDED)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(0, 'data');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_authentication_is_required_to_read_a_sale_detail(): void
|
public function test_authentication_is_required_to_read_a_sale_detail(): void
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class SaleFormServiceTest extends TestCase
|
||||||
'Cancelada',
|
'Cancelada',
|
||||||
'Rechazada',
|
'Rechazada',
|
||||||
'Vencida',
|
'Vencida',
|
||||||
|
'Reemplazada',
|
||||||
], array_column($form['statuses'], 'name'));
|
], array_column($form['statuses'], 'name'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue