diff --git a/.env.example b/.env.example index 91e49a9..fe4fccc 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,11 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost + +PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30 +PURCHASE_QR_EXPIRATION_MINUTES=15 +PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30 +PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440 FRONTEND_URL=http://localhost:4200 APP_LOCALE=en diff --git a/app/Domains/Auth/Controllers/GoogleTokenExchangeController.php b/app/Domains/Auth/Controllers/GoogleTokenExchangeController.php index 2b9f14e..63c4250 100644 --- a/app/Domains/Auth/Controllers/GoogleTokenExchangeController.php +++ b/app/Domains/Auth/Controllers/GoogleTokenExchangeController.php @@ -5,22 +5,46 @@ namespace App\Domains\Auth\Controllers; use App\Domains\Auth\Requests\GoogleTokenExchangeRequest; use App\Domains\Auth\Resources\UserResource; use App\Domains\Auth\Services\GoogleAuthService; +use App\Domains\Cart\Services\GuestCartMergeService; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Cookie; class GoogleTokenExchangeController extends Controller { - public function __construct(private readonly GoogleAuthService $googleAuthService) {} + public function __construct( + private readonly GoogleAuthService $googleAuthService, + private readonly GuestCartMergeService $guestCartMergeService, + ) {} public function __invoke(GoogleTokenExchangeRequest $request): JsonResponse { - $authentication = $this->googleAuthService->exchange($request->validated('oauth_code')); + $authentication = $this->googleAuthService->exchange( + $request->validated('oauth_code'), + $request->validated('tenant_codigo'), + ); - return response()->json([ + $guestTokenCookie = $request->cookie('guest_token'); + $guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== '' + ? $guestTokenCookie + : null; + $this->guestCartMergeService->merge( + $authentication['tenant_codigo'], + $authentication['user'], + $guestToken, + ); + + $response = response()->json([ 'message' => 'Sesion iniciada correctamente.', 'token' => $authentication['token'], 'token_type' => 'Bearer', 'user' => UserResource::make($authentication['user']), ]); + + if ($guestToken !== null) { + $response->withCookie(Cookie::forget('guest_token')); + } + + return $response; } } diff --git a/app/Domains/Auth/Controllers/LoginController.php b/app/Domains/Auth/Controllers/LoginController.php index 7e4110a..00ad8f7 100644 --- a/app/Domains/Auth/Controllers/LoginController.php +++ b/app/Domains/Auth/Controllers/LoginController.php @@ -2,16 +2,22 @@ namespace App\Domains\Auth\Controllers; -use App\Domains\Auth\Requests\LoginUserRequest; use App\Domains\Auth\Models\User; +use App\Domains\Auth\Requests\LoginUserRequest; use App\Domains\Auth\Resources\UserResource; +use App\Domains\Cart\Services\GuestCartMergeService; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Cookie; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\ValidationException; class LoginController extends Controller { + public function __construct( + private readonly GuestCartMergeService $guestCartMergeService, + ) {} + /** * @throws ValidationException */ @@ -33,11 +39,27 @@ class LoginController extends Controller now()->addMinutes($expirationMinutes), )->plainTextToken; - return response()->json([ + $guestTokenCookie = $request->cookie('guest_token'); + $guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== '' + ? $guestTokenCookie + : null; + $this->guestCartMergeService->merge( + $credentials['tenant_codigo'], + $user, + $guestToken, + ); + + $response = response()->json([ 'message' => 'Sesion iniciada correctamente.', 'token' => $token, 'token_type' => 'Bearer', 'user' => UserResource::make($user), ]); + + if ($guestToken !== null) { + $response->withCookie(Cookie::forget('guest_token')); + } + + return $response; } } diff --git a/app/Domains/Auth/Requests/GoogleTokenExchangeRequest.php b/app/Domains/Auth/Requests/GoogleTokenExchangeRequest.php index ad04662..a2ded3c 100644 --- a/app/Domains/Auth/Requests/GoogleTokenExchangeRequest.php +++ b/app/Domains/Auth/Requests/GoogleTokenExchangeRequest.php @@ -16,6 +16,7 @@ class GoogleTokenExchangeRequest extends FormRequest { return [ 'oauth_code' => ['required', 'uuid'], + 'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'], ]; } } diff --git a/app/Domains/Auth/Requests/LoginUserRequest.php b/app/Domains/Auth/Requests/LoginUserRequest.php index 7c7caff..a924e2d 100644 --- a/app/Domains/Auth/Requests/LoginUserRequest.php +++ b/app/Domains/Auth/Requests/LoginUserRequest.php @@ -19,6 +19,7 @@ class LoginUserRequest extends FormRequest return [ 'email' => ['required', 'string', 'email', 'max:255'], 'password' => ['required', 'string'], + 'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'], ]; } } diff --git a/app/Domains/Auth/Services/GoogleAuthService.php b/app/Domains/Auth/Services/GoogleAuthService.php index 140109f..d90d774 100644 --- a/app/Domains/Auth/Services/GoogleAuthService.php +++ b/app/Domains/Auth/Services/GoogleAuthService.php @@ -72,6 +72,7 @@ class GoogleAuthService Cache::put("google-oauth-exchange:{$exchangeCode}", [ 'user_id' => $user->id, 'token' => $token, + 'tenant_codigo' => $tenant->codigo, ], now()->addMinutes(5)); return redirect()->to($context['return_url'].'/login?'.http_build_query([ @@ -79,10 +80,10 @@ class GoogleAuthService ])); } - /** @return array{user: User, token: string} */ - public function exchange(string $exchangeCode): array + /** @return array{user: User, token: string, tenant_codigo: string} */ + public function exchange(string $exchangeCode, string $tenantCodigo): array { - /** @var array{user_id: int, token: string}|null $authentication */ + /** @var array{user_id: int, token: string, tenant_codigo?: string}|null $authentication */ $authentication = Cache::pull("google-oauth-exchange:{$exchangeCode}"); if (! $authentication) { @@ -91,9 +92,16 @@ class GoogleAuthService ]); } + if (($authentication['tenant_codigo'] ?? null) !== $tenantCodigo) { + throw ValidationException::withMessages([ + 'tenant_codigo' => 'El tenant no coincide con la solicitud de autenticacion.', + ]); + } + return [ 'user' => User::query()->findOrFail($authentication['user_id']), 'token' => $authentication['token'], + 'tenant_codigo' => $tenantCodigo, ]; } diff --git a/app/Domains/Cart/Middleware/MergeGuestCartMiddleware.php b/app/Domains/Cart/Middleware/MergeGuestCartMiddleware.php deleted file mode 100644 index e36a265..0000000 --- a/app/Domains/Cart/Middleware/MergeGuestCartMiddleware.php +++ /dev/null @@ -1,73 +0,0 @@ -cookie('guest_token'); - - // Resolve authenticated user optionally (from default guard or sanctum guard) - $user = $request->user() ?? Auth::guard('sanctum')->user(); - $userId = $user?->getKey(); - - \Illuminate\Support\Facades\Log::info('MergeGuestCartMiddleware processed', [ - 'user_id' => $userId, - 'guest_token' => $guestToken, - ]); - - if ($user !== null && is_string($guestToken) && $guestToken !== '') { - $tenantParam = $request->route('tenant'); - $tenantCodigo = null; - - if ($tenantParam instanceof Tenant) { - $tenantCodigo = $tenantParam->codigo; - } elseif (is_string($tenantParam)) { - $tenantCodigo = $tenantParam; - } - - if ($tenantCodigo !== null) { - $guestCart = Cart::query() - ->where('tenant_codigo', $tenantCodigo) - ->where('guest_token', $guestToken) - ->first(); - - if ($guestCart !== null && $guestCart->items()->exists()) { - $userCart = Cart::query() - ->where('tenant_codigo', $tenantCodigo) - ->where('user_id', $userId) - ->first(); - - if ($userCart !== null) { - $userCart->delete(); - } - - $guestCart->update([ - 'user_id' => $userId, - 'guest_token' => null, - ]); - } - } - } - - $response = $next($request); - - if ($user !== null && is_string($guestToken) && $guestToken !== '') { - // Remove the cookie from the response since the user is authenticated. - if (method_exists($response, 'withCookie')) { - $response->withCookie(Cookie::forget('guest_token')); - } - } - - return $response; - } -} diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index cb81e35..affbb55 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -172,6 +172,7 @@ class CartService { return Cart::query() ->where('tenant_codigo', $tenant->codigo) + ->where('status', 'active') ->when( $identity['user_id'] !== null, fn ($query) => $query->where('user_id', $identity['user_id']), @@ -199,7 +200,10 @@ class CartService */ protected function findOrCreateCart(Tenant $tenant, array $identity): Cart { - $attributes = ['tenant_codigo' => $tenant->codigo]; + $attributes = [ + 'tenant_codigo' => $tenant->codigo, + 'status' => 'active', + ]; if ($identity['user_id'] !== null) { $attributes['user_id'] = $identity['user_id']; @@ -207,6 +211,6 @@ class CartService $attributes['guest_token'] = $identity['guest_token']; } - return Cart::query()->firstOrCreate($attributes, ['status' => 'active']); + return Cart::query()->firstOrCreate($attributes); } } diff --git a/app/Domains/Cart/Services/GuestCartMergeService.php b/app/Domains/Cart/Services/GuestCartMergeService.php new file mode 100644 index 0000000..5504a4e --- /dev/null +++ b/app/Domains/Cart/Services/GuestCartMergeService.php @@ -0,0 +1,49 @@ +where('tenant_codigo', $tenantCodigo) + ->where('guest_token', $guestToken) + ->where('status', 'active') + ->lockForUpdate() + ->first(); + + if ($guestCart === null || ! $guestCart->items()->exists()) { + return; + } + + $userCart = Cart::query() + ->where('tenant_codigo', $tenantCodigo) + ->where('user_id', $user->getKey()) + ->where('status', 'active') + ->lockForUpdate() + ->first(); + + if ($userCart !== null) { + $userCart->update([ + 'status' => 'converted', + ]); + $userCart->delete(); + } + + $guestCart->update([ + 'user_id' => $user->getKey(), + 'guest_token' => null, + ]); + }); + } +} diff --git a/app/Domains/Cart/routes/api.php b/app/Domains/Cart/routes/api.php index 4f8c347..28488a7 100644 --- a/app/Domains/Cart/routes/api.php +++ b/app/Domains/Cart/routes/api.php @@ -4,7 +4,6 @@ use App\Domains\Cart\Controllers\CartController; use Illuminate\Support\Facades\Route; Route::prefix('tenants/{tenant:codigo}') - ->middleware(\App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class) ->group(function (): void { Route::get('cart', [CartController::class, 'show']); Route::post('cart/items', [CartController::class, 'addItem']); diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index fffaf94..a02f034 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -4,16 +4,19 @@ namespace App\Domains\Purchase\Controllers; use App\Domains\Integration\Services\TelepagosIntegrationService; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Requests\PaymentIntentRequest; -use App\Domains\Purchase\Requests\StorePurchaseRequest; +use App\Domains\Purchase\Requests\StartCheckoutRequest; +use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest; +use App\Domains\Purchase\Requests\UpdatePurchaseItemQuantityRequest; use App\Domains\Purchase\Resources\PurchaseResource; use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; +use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class PurchaseController extends Controller @@ -32,8 +35,11 @@ class PurchaseController extends Controller )->response(); } - public function store(StorePurchaseRequest $request, Tenant $tenant, CheckoutService $checkoutService): JsonResponse - { + public function startCheckout( + StartCheckoutRequest $request, + Tenant $tenant, + CheckoutService $checkoutService, + ): JsonResponse { $data = $request->validated(); $purchase = $checkoutService->startCheckout( @@ -49,16 +55,56 @@ class PurchaseController extends Controller { $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); - $compra->loadMissing(['items', 'cart.items']); + $compra->loadMissing('items'); $compra->items->load('imageAttachment'); - if ($compra->cart !== null) { - $this->loadCartCatalogEntries($compra->cart->items); - } - return PurchaseResource::make($compra); } + public function updateCustomerData( + UpdatePurchaseCustomerRequest $request, + Tenant $tenant, + Purchase $compra, + CheckoutService $checkoutService, + ): PurchaseResource { + $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); + + return PurchaseResource::make( + $checkoutService->updateCustomerData($compra, $request->validated()), + ); + } + + public function updateItemQuantity( + UpdatePurchaseItemQuantityRequest $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, + Purchase $compra, + CheckoutService $checkoutService, + ): PurchaseResource { + $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); @@ -68,6 +114,9 @@ class PurchaseController extends Controller $purchaseUpdate = [ 'payment_method' => $method, 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'expires_at' => now()->addMinutes( + max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)) + ), 'total' => $totalAmount, ]; @@ -75,7 +124,22 @@ class PurchaseController extends Controller $purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni')); } - $compra->update($purchaseUpdate); + $updated = Purchase::query() + ->whereKey($compra->getKey()) + ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) + ->where(function ($query): void { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->update($purchaseUpdate); + + if ($updated === 0) { + throw ValidationException::withMessages([ + 'purchase' => 'The purchase is no longer available for payment.', + ]); + } + + $compra->refresh(); if ($method === 'transfer') { $telepagosService = new TelepagosIntegrationService; @@ -146,6 +210,15 @@ class PurchaseController extends Controller ); } + public function cancel(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource + { + $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); + + return PurchaseResource::make( + $checkoutService->cancelPurchase($compra) + ); + } + protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase { if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) { @@ -154,17 +227,4 @@ class PurchaseController extends Controller return $purchase; } - - /** - * @return list - */ - protected function loadCartCatalogEntries(Collection $items): void - { - $items->load([ - 'catalogItem.attachments', - 'variant.attachments', - 'variant.catalogItem.attachments', - 'variant.definitions.itemAttribute.attribute', - ]); - } } diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index ec973b0..70a2842 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -20,6 +20,7 @@ use Illuminate\Support\Facades\DB; 'user_id', 'status', 'payment_method', + 'expires_at', 'total', 'dni', 'transfer_payer_dni', @@ -41,6 +42,8 @@ class Purchase extends Model public const STATUS_REJECTED = 'rejected'; + public const STATUS_EXPIRED = 'expired'; + protected $table = 'compras'; protected function casts(): array @@ -48,6 +51,7 @@ class Purchase extends Model return [ 'cart_id' => 'integer', 'user_id' => 'integer', + 'expires_at' => 'datetime', 'total' => 'decimal:2', ]; } @@ -111,23 +115,11 @@ class Purchase extends Model public function calculateCurrentTotalAmount(): float { - if ($this->relationLoaded('items') && $this->getRelation('items')->isNotEmpty()) { + if ($this->relationLoaded('items')) { return (float) $this->getRelation('items')->sum('total'); } - if ($this->items()->exists()) { - return (float) $this->items()->sum('total'); - } - - $cart = $this->relationLoaded('cart') - ? $this->getRelation('cart') - : $this->cart()->with(['items.catalogItem', 'items.variant'])->first(); - - if (! $cart) { - return 0.0; - } - - return $cart->getTotalAmount(); + return (float) $this->items()->sum('total'); } public function markAsPendingPayment(): void diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index 2894caa..7f82f2c 100644 --- a/app/Domains/Purchase/Models/PurchaseItem.php +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -23,11 +23,18 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'discount_total', 'tax_total', 'total', + 'reservation_status', ])] class PurchaseItem extends Model { use HasFactory; + public const RESERVATION_ACTIVE = 'active'; + + public const RESERVATION_COMMITTED = 'committed'; + + public const RESERVATION_RELEASED = 'released'; + protected $table = 'compra_items'; protected function casts(): array diff --git a/app/Domains/Purchase/Requests/StartCheckoutRequest.php b/app/Domains/Purchase/Requests/StartCheckoutRequest.php new file mode 100644 index 0000000..4133dad --- /dev/null +++ b/app/Domains/Purchase/Requests/StartCheckoutRequest.php @@ -0,0 +1,51 @@ +user() !== null; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'cart_id' => [ + 'required_without:direct_item', + Rule::prohibitedIf(fn (): bool => $this->has('direct_item')), + 'integer', + 'exists:carritos,id', + ], + 'direct_item' => [ + 'required_without:cart_id', + Rule::prohibitedIf(fn (): bool => $this->has('cart_id')), + 'array', + ], + 'direct_item.catalog_item_id' => [ + 'required_with:direct_item', + 'integer', + ], + 'direct_item.variant_id' => [ + 'nullable', + 'integer', + ], + 'direct_item.cantidad' => [ + 'required_with:direct_item', + 'integer', + 'min:1', + ], + 'dni' => ['prohibited'], + 'telefono' => ['prohibited'], + 'nombre_apellido' => ['prohibited'], + 'email' => ['prohibited'], + ]; + } +} diff --git a/app/Domains/Purchase/Requests/StorePurchaseRequest.php b/app/Domains/Purchase/Requests/UpdatePurchaseCustomerRequest.php similarity index 81% rename from app/Domains/Purchase/Requests/StorePurchaseRequest.php rename to app/Domains/Purchase/Requests/UpdatePurchaseCustomerRequest.php index 3926f5b..4620461 100644 --- a/app/Domains/Purchase/Requests/StorePurchaseRequest.php +++ b/app/Domains/Purchase/Requests/UpdatePurchaseCustomerRequest.php @@ -4,7 +4,7 @@ namespace App\Domains\Purchase\Requests; use Illuminate\Foundation\Http\FormRequest; -class StorePurchaseRequest extends FormRequest +class UpdatePurchaseCustomerRequest extends FormRequest { public function authorize(): bool { @@ -17,7 +17,6 @@ class StorePurchaseRequest extends FormRequest public function rules(): array { return [ - 'cart_id' => ['required', 'integer', 'exists:carritos,id'], 'dni' => ['required', 'string'], 'telefono' => ['required', 'string'], 'nombre_apellido' => ['required', 'string'], diff --git a/app/Domains/Purchase/Requests/UpdatePurchaseItemQuantityRequest.php b/app/Domains/Purchase/Requests/UpdatePurchaseItemQuantityRequest.php new file mode 100644 index 0000000..9810352 --- /dev/null +++ b/app/Domains/Purchase/Requests/UpdatePurchaseItemQuantityRequest.php @@ -0,0 +1,23 @@ +user() !== null; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'quantity' => ['required', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index 4c842cf..13696c0 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -2,12 +2,10 @@ namespace App\Domains\Purchase\Resources; -use App\Domains\Cart\Models\CartItem; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Collection; /** * @mixin Purchase @@ -19,18 +17,20 @@ class PurchaseResource extends JsonResource */ public function toArray(Request $request): array { - [$items, $itemsSource] = $this->resolveItems(); + $items = $this->resource->relationLoaded('items') + ? $this->resource->getRelation('items') + : collect(); $subtotal = $items->isNotEmpty() ? $items->reduce( - fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemSubtotal($item), + fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemSubtotal($item), 0.0, ) : (float) ($this->total ?? 0); $total = $items->isNotEmpty() ? $items->reduce( - fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemTotal($item), + fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemTotal($item), 0.0, ) : (float) ($this->total ?? 0); @@ -43,62 +43,27 @@ class PurchaseResource extends JsonResource 'created_at' => $this->created_at, 'status' => $this->status, 'payment_method' => $this->payment_method, + 'expires_at' => $this->expires_at, 'dni' => $this->dni, 'transfer_payer_dni' => $this->transfer_payer_dni, 'telefono' => $this->telefono, 'nombre_apellido' => $this->nombre_apellido, 'email' => $this->email, - 'items_source' => $itemsSource, + 'items_source' => $items->isNotEmpty() ? 'purchase' : null, 'items' => PurchaseItemResource::collection($items), 'subtotal' => $this->formatMoney($subtotal), 'total' => $this->formatMoney($total), ]; } - /** - * @return array{0: Collection, 1: string|null} - */ - protected function resolveItems(): array + protected function resolveItemSubtotal(PurchaseItem $item): float { - if (! $this->resource->relationLoaded('items')) { - return [collect(), null]; - } - - $purchaseItems = $this->resource->getRelation('items'); - - if ($purchaseItems->isNotEmpty()) { - return [$purchaseItems, 'purchase']; - } - - if (! $this->resource->relationLoaded('cart')) { - return [collect(), null]; - } - - $cart = $this->resource->getRelation('cart'); - - if ($cart === null || ! $cart->relationLoaded('items')) { - return [collect(), null]; - } - - return [$cart->getRelation('items'), 'cart']; + return (float) $item->precio_unitario * $item->cantidad; } - protected function resolveItemSubtotal(PurchaseItem|CartItem $item): float + protected function resolveItemTotal(PurchaseItem $item): float { - if ($item instanceof PurchaseItem) { - return (float) $item->precio_unitario * $item->cantidad; - } - - return (float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad; - } - - protected function resolveItemTotal(PurchaseItem|CartItem $item): float - { - if ($item instanceof PurchaseItem) { - return (float) ($item->total ?? 0); - } - - return $this->resolveItemSubtotal($item); + return (float) ($item->total ?? 0); } protected function formatMoney(float|int|string|null $amount): string diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 1fbe869..e2707f3 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -3,77 +3,54 @@ namespace App\Domains\Purchase\Services; use App\Domains\Attachable\Models\Attachment; -use App\Domains\Attachable\Services\AttachmentService; use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; +use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -use Throwable; class CheckoutService { public function __construct( - private readonly AttachmentService $attachmentService, private readonly CatalogInventoryService $catalogInventoryService, ) {} public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase { - $cartId = (int) $purchaseData['cart_id']; - unset($purchaseData['cart_id']); + return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase { + $directItem = $purchaseData['direct_item'] ?? null; + $cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null; + unset($purchaseData['direct_item'], $purchaseData['cart_id']); - return DB::transaction(function () use ($tenant, $userId, $purchaseData, $cartId): Purchase { - $cart = $this->resolveCheckoutCart($tenant, $userId, $cartId); - $cartItems = $cart->items()->lockForUpdate()->get(); + if (is_array($directItem)) { + return $this->startDirectCheckout( + $tenant, + $userId, + $purchaseData, + $directItem, + ); + } - if ($cartItems->isEmpty()) { + if ($cartId === null) { throw ValidationException::withMessages([ - 'cart_id' => 'The selected cart does not contain items.', + 'cart_id' => 'A cart or direct item is required.', ]); } - $this->loadCartItems($cartItems); - $cart->setRelation('items', $cartItems); - $totalAmount = $cart->getTotalAmount(); - - /** @var Purchase|null $purchase */ - $purchase = Purchase::query() - ->where('cart_id', $cart->getKey()) - ->where('tenant_codigo', $tenant->codigo) - ->where('user_id', $userId) - ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) - ->latest('id') - ->first(); - - if ($purchase === null) { - /** @var Purchase $purchase */ - $purchase = Purchase::query()->create([ - ...$purchaseData, - 'cart_id' => $cart->getKey(), - 'tenant_codigo' => $tenant->codigo, - 'user_id' => $userId, - 'status' => Purchase::STATUS_CREATED, - 'payment_method' => null, - 'total' => $totalAmount, - ]); - } else { - $purchase->fill([ - ...$purchaseData, - 'status' => Purchase::STATUS_CREATED, - 'payment_method' => null, - 'total' => $totalAmount, - ]); - $purchase->save(); - } - - return $this->loadPurchase($purchase); + return $this->startCartCheckout( + $tenant, + $userId, + $purchaseData, + $cartId, + ); }); } @@ -91,7 +68,12 @@ class CheckoutService ]); } - if (in_array($purchase->status, [Purchase::STATUS_PAID, Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED], true)) { + if (in_array($purchase->status, [ + Purchase::STATUS_PAID, + Purchase::STATUS_CANCELLED, + Purchase::STATUS_REJECTED, + Purchase::STATUS_EXPIRED, + ], true)) { return $this->loadPurchase($purchase); } @@ -104,59 +86,515 @@ class CheckoutService }); } - public function confirmPurchase(Purchase $purchase): void + /** + * @param array $customerData + */ + public function updateCustomerData(Purchase $purchase, array $customerData): Purchase { - $snapshotPaths = []; + return DB::transaction(function () use ($purchase, $customerData): Purchase { + /** @var Purchase $purchase */ + $purchase = Purchase::query() + ->lockForUpdate() + ->findOrFail($purchase->getKey()); - try { - DB::transaction(function () use ($purchase, &$snapshotPaths): void { - /** @var Purchase $purchase */ - $purchase = Purchase::query() - ->lockForUpdate() - ->findOrFail($purchase->getKey()); - - if ($purchase->items()->exists()) { - return; - } - - /** @var Cart|null $cart */ - $cart = $purchase->cart()->lockForUpdate()->first(); - - if ($cart === null) { - throw ValidationException::withMessages([ - 'cart_id' => 'The purchase cart is no longer available.', - ]); - } - - $cartItems = $cart->items()->lockForUpdate()->get(); - - if ($cartItems->isEmpty()) { - throw ValidationException::withMessages([ - 'cart_id' => 'The purchase cart does not contain items.', - ]); - } - - $this->loadCartItems($cartItems); - $this->verifyTenantItems($purchase->tenant, $cartItems); - $purchaseItemsPayload = $this->buildPurchaseItemsPayload($purchase, $cartItems, $snapshotPaths); - - $purchase->items()->createMany($purchaseItemsPayload); - $this->completeCartConversion($cart, $cartItems); - }); - } catch (Throwable $throwable) { - foreach ($snapshotPaths as $snapshotPath) { - Storage::disk('s3')->delete($snapshotPath); + if ( + $purchase->status !== Purchase::STATUS_CREATED + || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) + ) { + throw ValidationException::withMessages([ + 'purchase' => 'The purchase is no longer editable.', + ]); } - throw $throwable; + $purchase->update($customerData); + + return $this->loadPurchase($purchase); + }); + } + + public function updateItemQuantity( + Purchase $purchase, + PurchaseItem $purchaseItem, + int $quantity, + ): Purchase { + return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase { + /** @var Purchase $purchase */ + $purchase = Purchase::query() + ->lockForUpdate() + ->findOrFail($purchase->getKey()); + + if ( + $purchase->status !== Purchase::STATUS_CREATED + || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) + ) { + throw ValidationException::withMessages([ + 'purchase' => 'The purchase is no longer editable.', + ]); + } + + /** @var PurchaseItem|null $purchaseItem */ + $purchaseItem = $purchase->items() + ->whereKey($purchaseItem->getKey()) + ->lockForUpdate() + ->first(); + + if ($purchaseItem === null) { + throw new NotFoundHttpException('Purchase item not found.'); + } + + if ($purchaseItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) { + throw ValidationException::withMessages([ + 'item' => 'The purchase item is no longer editable.', + ]); + } + + $currentQuantity = (int) $purchaseItem->cantidad; + $difference = $quantity - $currentQuantity; + + if ($difference !== 0) { + $selection = $this->resolvePurchaseItemSelection($purchase->tenant, $purchaseItem); + + try { + if ($difference > 0) { + $this->catalogInventoryService->reserve($selection, $difference); + } else { + $this->catalogInventoryService->release($selection, abs($difference)); + } + } catch (\InvalidArgumentException $exception) { + throw ValidationException::withMessages([ + 'quantity' => 'No hay suficiente stock disponible.', + ]); + } + + $purchaseItem->update([ + 'cantidad' => $quantity, + 'total' => (float) $purchaseItem->precio_unitario * $quantity, + ]); + $this->syncSourceCartItemQuantity($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 { + /** @var Purchase $purchase */ + $purchase = Purchase::query() + ->lockForUpdate() + ->findOrFail($purchase->getKey()); + + if ( + ! in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true) + || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) + ) { + throw ValidationException::withMessages([ + 'purchase' => 'The purchase is no longer editable.', + ]); + } + + $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); + }); + } + + public function confirmPurchase(Purchase $purchase): void + { + DB::transaction(function () use ($purchase): void { + /** @var Purchase $purchase */ + $purchase = Purchase::query() + ->lockForUpdate() + ->findOrFail($purchase->getKey()); + + if ($purchase->status === Purchase::STATUS_PAID) { + return; + } + + if (in_array($purchase->status, [ + Purchase::STATUS_CANCELLED, + Purchase::STATUS_REJECTED, + Purchase::STATUS_EXPIRED, + ], true)) { + throw ValidationException::withMessages([ + 'purchase' => 'A cancelled, rejected or expired purchase cannot be confirmed.', + ]); + } + + $items = $purchase->items() + ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) + ->lockForUpdate() + ->get(); + + foreach ($items as $item) { + $selection = $this->resolvePurchaseItemSelection($purchase->tenant, $item); + + try { + $this->catalogInventoryService->commit($selection, (int) $item->cantidad); + } catch (\InvalidArgumentException $exception) { + throw ValidationException::withMessages([ + 'items' => 'The purchase has an inconsistent stock reservation.', + ]); + } + + $item->update([ + 'reservation_status' => PurchaseItem::RESERVATION_COMMITTED, + ]); + } + + $this->finalizeSourceCart($purchase); + }); + } + + public function cancelPurchase(Purchase $purchase): Purchase + { + return $this->releasePurchase($purchase, Purchase::STATUS_CANCELLED); + } + + public function expirePurchase(Purchase $purchase): Purchase + { + return $this->releasePurchase($purchase, Purchase::STATUS_EXPIRED); + } + + public function expireOverduePurchases(): int + { + $expiredCount = 0; + + Purchase::query() + ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->orderBy('id') + ->eachById(function (Purchase $purchase) use (&$expiredCount): void { + $purchase = $this->expirePurchase($purchase); + + if ($purchase->status === Purchase::STATUS_EXPIRED) { + $expiredCount++; + } + }); + + return $expiredCount; + } + + private function releasePurchase(Purchase $purchase, string $targetStatus): Purchase + { + return DB::transaction(function () use ($purchase, $targetStatus): Purchase { + /** @var Purchase $purchase */ + $purchase = Purchase::query() + ->lockForUpdate() + ->findOrFail($purchase->getKey()); + + if ($purchase->status === Purchase::STATUS_PAID) { + if ($targetStatus === Purchase::STATUS_EXPIRED) { + return $this->loadPurchase($purchase); + } + + throw ValidationException::withMessages([ + 'purchase' => 'A paid purchase cannot be cancelled.', + ]); + } + + if (in_array($purchase->status, [ + Purchase::STATUS_CANCELLED, + Purchase::STATUS_REJECTED, + Purchase::STATUS_EXPIRED, + ], true)) { + return $this->loadPurchase($purchase); + } + + if ( + $targetStatus === Purchase::STATUS_EXPIRED + && ($purchase->expires_at === null || $purchase->expires_at->isFuture()) + ) { + return $this->loadPurchase($purchase); + } + + $items = $purchase->items() + ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) + ->lockForUpdate() + ->get(); + $reservationReturnedToCart = $this->restoreSourceCart($purchase); + + foreach ($items as $item) { + if (! $reservationReturnedToCart) { + $selection = $this->resolvePurchaseItemSelection($purchase->tenant, $item); + + try { + $this->catalogInventoryService->release($selection, (int) $item->cantidad); + } catch (\InvalidArgumentException $exception) { + throw ValidationException::withMessages([ + 'items' => 'The purchase has an inconsistent stock reservation.', + ]); + } + } + + $item->update([ + 'reservation_status' => PurchaseItem::RESERVATION_RELEASED, + ]); + } + + $purchase->update([ + 'status' => $targetStatus, + ]); + + return $this->loadPurchase($purchase); + }); + } + + /** + * @param array $purchaseData + * @param array $directItem + */ + private function startDirectCheckout( + Tenant $tenant, + int $userId, + array $purchaseData, + array $directItem, + ): Purchase { + $catalogItemId = (int) $directItem['catalog_item_id']; + $variantId = isset($directItem['variant_id']) ? (int) $directItem['variant_id'] : null; + $quantity = (int) $directItem['cantidad']; + $selection = $this->resolveSelection($tenant, $catalogItemId, $variantId); + $availableQuantity = $this->catalogInventoryService->availableQuantity($selection); + + if ($availableQuantity !== null && $availableQuantity < $quantity) { + throw ValidationException::withMessages([ + 'direct_item.cantidad' => "Stock insuficiente. Maximo disponible: {$availableQuantity}.", + ]); } + + try { + $this->catalogInventoryService->reserve($selection, $quantity); + } catch (\InvalidArgumentException $exception) { + throw ValidationException::withMessages([ + 'direct_item.cantidad' => 'No hay suficiente stock disponible.', + ]); + } + + $purchase = $this->createPurchase( + $tenant, + $userId, + $purchaseData, + $selection->getPrice() * $quantity, + null, + ); + $cartItem = $this->makeDirectCartItem($selection, $catalogItemId, $variantId, $quantity); + $purchase->items()->createMany( + $this->buildPurchaseItemsPayload(collect([$cartItem])), + ); + + return $this->loadPurchase($purchase); + } + + /** + * @param array $purchaseData + */ + private function startCartCheckout( + Tenant $tenant, + int $userId, + array $purchaseData, + int $cartId, + ): Purchase { + $cart = $this->resolveCheckoutCart($tenant, $userId, $cartId); + $cartItems = $cart->items()->lockForUpdate()->get(); + + if ($cartItems->isEmpty()) { + throw ValidationException::withMessages([ + 'cart_id' => 'The selected cart does not contain items.', + ]); + } + + $this->loadCartItems($cartItems); + $this->verifyTenantItems($tenant, $cartItems); + $cart->setRelation('items', $cartItems); + + $purchase = $this->createPurchase( + $tenant, + $userId, + $purchaseData, + $cart->getTotalAmount(), + $cart->getKey(), + ); + $purchase->items()->createMany( + $this->buildPurchaseItemsPayload($cartItems), + ); + + // PurchaseItem owns the reservation during checkout. The source cart is + // kept with its owner so it can be restored if the purchase is cancelled + // or expires. Only active carts participate in the identity constraint. + $cart->update([ + 'status' => 'checkout', + 'guest_token' => null, + ]); + + return $this->loadPurchase($purchase); + } + + private function restoreSourceCart(Purchase $purchase): bool + { + if ($purchase->cart_id === null) { + return false; + } + + /** @var Cart|null $sourceCart */ + $sourceCart = Cart::withTrashed() + ->whereKey($purchase->cart_id) + ->lockForUpdate() + ->first(); + + if ($sourceCart === null) { + return false; + } + + /** @var Cart|null $activeCart */ + $activeCart = Cart::query() + ->where('tenant_codigo', $purchase->tenant_codigo) + ->where('user_id', $purchase->user_id) + ->where('status', 'active') + ->where('id', '!=', $sourceCart->getKey()) + ->lockForUpdate() + ->first(); + + if ($activeCart !== null) { + $sourceItems = $sourceCart->items()->lockForUpdate()->get(); + + foreach ($sourceItems as $sourceItem) { + /** @var CartItem|null $activeItem */ + $activeItem = $activeCart->items() + ->where('catalog_item_id', $sourceItem->catalog_item_id) + ->where('variant_id', $sourceItem->variant_id) + ->lockForUpdate() + ->first(); + + if ($activeItem === null) { + $activeCart->items()->create([ + 'catalog_item_id' => $sourceItem->catalog_item_id, + 'variant_id' => $sourceItem->variant_id, + 'cantidad' => $sourceItem->cantidad, + ]); + } else { + $activeItem->increment('cantidad', (int) $sourceItem->cantidad); + } + } + + $sourceCart->update([ + 'status' => 'converted', + 'guest_token' => null, + ]); + + if (! $sourceCart->trashed()) { + $sourceCart->delete(); + } + + return true; + } + + if ($sourceCart->trashed()) { + $sourceCart->restore(); + } + + $sourceCart->update([ + 'status' => 'active', + 'user_id' => $purchase->user_id, + 'guest_token' => null, + ]); + + return true; + } + + private function syncSourceCartItemQuantity( + Purchase $purchase, + PurchaseItem $purchaseItem, + int $quantity, + ): void { + if ($purchase->cart_id === null) { + return; + } + + $sourceCart = Cart::withTrashed() + ->whereKey($purchase->cart_id) + ->lockForUpdate() + ->first(); + + 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, + ]); + } + + private function finalizeSourceCart(Purchase $purchase): void + { + if ($purchase->cart_id === null) { + return; + } + + /** @var Cart|null $sourceCart */ + $sourceCart = Cart::withTrashed() + ->whereKey($purchase->cart_id) + ->lockForUpdate() + ->first(); + + if ($sourceCart === null || $sourceCart->trashed()) { + return; + } + + $sourceCart->update([ + 'status' => 'converted', + 'guest_token' => null, + ]); + $sourceCart->delete(); + } + + /** + * @param array $purchaseData + */ + private function createPurchase( + Tenant $tenant, + int $userId, + array $purchaseData, + float $total, + ?int $cartId, + ): Purchase { + return Purchase::query()->create([ + ...$purchaseData, + 'cart_id' => $cartId, + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $userId, + 'status' => Purchase::STATUS_CREATED, + 'payment_method' => null, + 'expires_at' => now()->addMinutes( + max(1, (int) config('purchase.checkout_expiration_minutes', 30)), + ), + 'total' => $total, + ]); } protected function verifyTenantItems(Tenant $tenant, Collection $cartItems): void { foreach ($cartItems as $item) { - $selectedItem = $item->selectedItem(); - if ($selectedItem === null) { + if ($item->selectedItem() === null) { throw ValidationException::withMessages([ 'cart_id' => 'One or more catalog items could not be loaded.', ]); @@ -170,10 +608,6 @@ class CheckoutService } } - /** - * @param Collection $cartItems - * @return Collection - */ protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart { /** @var Cart|null $cart */ @@ -198,21 +632,14 @@ class CheckoutService * @param Collection $cartItems * @return array> */ - protected function buildPurchaseItemsPayload( - Purchase $purchase, - Collection $cartItems, - array &$snapshotPaths = [], - ): array { + protected function buildPurchaseItemsPayload(Collection $cartItems): array + { return $cartItems - ->map(function (CartItem $item) use ($purchase, &$snapshotPaths): array { + ->map(function (CartItem $item): array { $selectedItem = $item->selectedItem(); - $quantity = (int) $item['cantidad']; + $quantity = (int) $item->cantidad; $unitPrice = $selectedItem?->getPrice() ?? 0; - $imageAttachment = $this->snapshotFirstImage($purchase, $item); - - if ($imageAttachment !== null) { - $snapshotPaths[] = $imageAttachment->path; - } + $imageAttachment = $this->firstImageAttachment($item); return [ 'source_catalog_item_id' => $item->catalog_item_id, @@ -230,37 +657,117 @@ class CheckoutService 'discount_total' => null, 'tax_total' => null, 'total' => $unitPrice * $quantity, + 'reservation_status' => PurchaseItem::RESERVATION_ACTIVE, ]; }) ->all(); } - /** - * @param Collection $cartItems - */ - protected function completeCartConversion(Cart $cart, Collection $cartItems): void - { - foreach ($cartItems as $item) { - $selectedItem = $item->selectedItem(); - $quantity = (int) $item->cantidad; + private function resolveSelection( + Tenant $tenant, + int $catalogItemId, + ?int $variantId, + ): CatalogItem|Variant { + /** @var CatalogItem|null $catalogItem */ + $catalogItem = CatalogItem::query() + ->whereKey($catalogItemId) + ->where('tenant_code', $tenant->codigo) + ->lockForUpdate() + ->first(); - try { - $this->catalogInventoryService->commit( - $selectedItem, - $quantity, - ); - } catch (\InvalidArgumentException $exception) { - throw ValidationException::withMessages([ - 'cart_id' => 'The selected cart has inconsistent stock state.', - ]); - } + if ($catalogItem === null) { + throw new NotFoundHttpException('Catalog item not found for tenant.'); } - $cart->status = 'converted'; - $cart->user_id = null; - $cart->guest_token = null; - $cart->save(); - $cart->delete(); + if ($catalogItem->isBundle()) { + if ($variantId !== null) { + throw ValidationException::withMessages([ + 'direct_item.variant_id' => 'Un bundle no admite una variante.', + ]); + } + + if (! $catalogItem->bundleComponents()->exists()) { + throw ValidationException::withMessages([ + 'direct_item.catalog_item_id' => 'El bundle no tiene componentes.', + ]); + } + + return $catalogItem; + } + + if ($variantId === null) { + if ($catalogItem->inventory_id === null) { + throw ValidationException::withMessages([ + 'direct_item.variant_id' => 'Debe seleccionar una variante para este item.', + ]); + } + + $catalogItem->setRelation( + 'inventory', + Inventory::query()->whereKey($catalogItem->inventory_id)->lockForUpdate()->firstOrFail(), + ); + + return $catalogItem; + } + + /** @var Variant|null $variant */ + $variant = Variant::query() + ->whereKey($variantId) + ->where('catalog_item_id', $catalogItem->id) + ->lockForUpdate() + ->first(); + + if ($variant === null) { + throw new NotFoundHttpException('Variant not found for catalog item.'); + } + + $variant->setRelation('catalogItem', $catalogItem); + $variant->setRelation( + 'inventory', + Inventory::query()->whereKey($variant->inventory_id)->lockForUpdate()->firstOrFail(), + ); + + return $variant; + } + + private function resolvePurchaseItemSelection(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant + { + return $this->resolveSelection( + $tenant, + (int) $item->source_catalog_item_id, + $item->source_variant_id === null ? null : (int) $item->source_variant_id, + ); + } + + 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 $cartItems */ @@ -283,19 +790,10 @@ class CheckoutService ]); } - private function snapshotFirstImage(Purchase $purchase, CartItem $item): ?Attachment + private function firstImageAttachment(CartItem $item): ?Attachment { - $source = $item->variant?->attachments->first() + return $item->variant?->attachments->first() ?? $item->catalogItem?->attachments->first(); - - if ($source === null) { - return null; - } - - return $this->attachmentService->copy( - $source, - "purchase/{$purchase->id}", - ); } /** @return array */ diff --git a/app/Domains/Purchase/routes/api.php b/app/Domains/Purchase/routes/api.php index 3827292..7d5bf10 100644 --- a/app/Domains/Purchase/routes/api.php +++ b/app/Domains/Purchase/routes/api.php @@ -3,8 +3,14 @@ use App\Domains\Purchase\Controllers\PurchaseController; use Illuminate\Support\Facades\Route; -Route::prefix('tenants/{tenant:codigo}')->middleware(['auth:sanctum', \App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class])->group(function (): void { - Route::apiResource('compras', PurchaseController::class)->only(['index', 'store', 'show']); +Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(function (): void { + Route::get('compras', [PurchaseController::class, 'index']); + Route::post('compras/start-checkout', [PurchaseController::class, 'startCheckout']); + 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::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']); Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']); + Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']); }); diff --git a/composer.json b/composer.json index 4148fd2..efc4b13 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ ], "dev": [ "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=emails,default --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" + "npx concurrently -c \"#93c5fd,#c4b5fd,#a7f3d0,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=emails,default --tries=1 --timeout=0\" \"php artisan schedule:work\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,scheduler,logs,vite --kill-others" ], "test": [ "@php artisan config:clear --ansi @no_additional_args", diff --git a/config/purchase.php b/config/purchase.php new file mode 100644 index 0000000..53037a9 --- /dev/null +++ b/config/purchase.php @@ -0,0 +1,11 @@ + (int) env('PURCHASE_CHECKOUT_EXPIRATION_MINUTES', 30), + + 'payment_expiration_minutes' => [ + 'qr' => (int) env('PURCHASE_QR_EXPIRATION_MINUTES', 15), + 'telepagos' => (int) env('PURCHASE_TELEPAGOS_EXPIRATION_MINUTES', 30), + 'transfer' => (int) env('PURCHASE_TRANSFER_EXPIRATION_MINUTES', 1440), + ], +]; diff --git a/database/migrations/2026_07_27_000000_add_reservation_status_to_compra_items_table.php b/database/migrations/2026_07_27_000000_add_reservation_status_to_compra_items_table.php new file mode 100644 index 0000000..0bafac1 --- /dev/null +++ b/database/migrations/2026_07_27_000000_add_reservation_status_to_compra_items_table.php @@ -0,0 +1,29 @@ +string('reservation_status') + ->default('active') + ->after('total'); + }); + + DB::table('compra_items') + ->whereIn('compra_id', DB::table('compras')->where('status', 'paid')->select('id')) + ->update(['reservation_status' => 'committed']); + } + + public function down(): void + { + Schema::table('compra_items', function (Blueprint $table): void { + $table->dropColumn('reservation_status'); + }); + } +}; diff --git a/database/migrations/2026_07_27_000100_add_expires_at_to_compras_table.php b/database/migrations/2026_07_27_000100_add_expires_at_to_compras_table.php new file mode 100644 index 0000000..32397be --- /dev/null +++ b/database/migrations/2026_07_27_000100_add_expires_at_to_compras_table.php @@ -0,0 +1,32 @@ +timestamp('expires_at')->nullable()->index()->after('payment_method'); + }); + + DB::table('compras') + ->whereIn('status', ['created', 'pending_payment']) + ->whereNull('expires_at') + ->update([ + 'expires_at' => now()->addMinutes( + max(1, (int) config('purchase.checkout_expiration_minutes', 30)), + ), + ]); + } + + public function down(): void + { + Schema::table('compras', function (Blueprint $table): void { + $table->dropColumn('expires_at'); + }); + } +}; diff --git a/database/migrations/2026_07_27_000200_scope_cart_identity_uniques_to_active_carts.php b/database/migrations/2026_07_27_000200_scope_cart_identity_uniques_to_active_carts.php new file mode 100644 index 0000000..42d2730 --- /dev/null +++ b/database/migrations/2026_07_27_000200_scope_cart_identity_uniques_to_active_carts.php @@ -0,0 +1,115 @@ + $table->index( + 'tenant_codigo', + 'carritos_tenant_codigo_index', + ), + ); + + Schema::whenTableHasIndex( + 'carritos', + 'carritos_tenant_codigo_user_id_unique', + fn (Blueprint $table) => $table->dropUnique( + 'carritos_tenant_codigo_user_id_unique', + ), + ); + Schema::whenTableHasIndex( + 'carritos', + 'carritos_tenant_codigo_guest_token_unique', + fn (Blueprint $table) => $table->dropUnique( + 'carritos_tenant_codigo_guest_token_unique', + ), + ); + + Schema::whenTableDoesntHaveColumn('carritos', 'active_user_id', function (Blueprint $table): void { + $table->unsignedBigInteger('active_user_id') + ->virtualAs("CASE WHEN status = 'active' AND deleted_at IS NULL THEN user_id ELSE NULL END"); + }); + Schema::whenTableDoesntHaveColumn('carritos', 'active_guest_token', function (Blueprint $table): void { + $table->string('active_guest_token') + ->virtualAs("CASE WHEN status = 'active' AND deleted_at IS NULL THEN guest_token ELSE NULL END"); + }); + + Schema::whenTableDoesntHaveIndex( + 'carritos', + 'carritos_tenant_active_user_unique', + fn (Blueprint $table) => $table->unique( + ['tenant_codigo', 'active_user_id'], + 'carritos_tenant_active_user_unique', + ), + ); + Schema::whenTableDoesntHaveIndex( + 'carritos', + 'carritos_tenant_active_guest_unique', + fn (Blueprint $table) => $table->unique( + ['tenant_codigo', 'active_guest_token'], + 'carritos_tenant_active_guest_unique', + ), + ); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::whenTableHasIndex( + 'carritos', + 'carritos_tenant_active_user_unique', + fn (Blueprint $table) => $table->dropUnique( + 'carritos_tenant_active_user_unique', + ), + ); + Schema::whenTableHasIndex( + 'carritos', + 'carritos_tenant_active_guest_unique', + fn (Blueprint $table) => $table->dropUnique( + 'carritos_tenant_active_guest_unique', + ), + ); + + Schema::whenTableHasColumn( + 'carritos', + 'active_user_id', + fn (Blueprint $table) => $table->dropColumn('active_user_id'), + ); + Schema::whenTableHasColumn( + 'carritos', + 'active_guest_token', + fn (Blueprint $table) => $table->dropColumn('active_guest_token'), + ); + + Schema::whenTableDoesntHaveIndex( + 'carritos', + 'carritos_tenant_codigo_user_id_unique', + fn (Blueprint $table) => $table->unique(['tenant_codigo', 'user_id']), + ); + Schema::whenTableDoesntHaveIndex( + 'carritos', + 'carritos_tenant_codigo_guest_token_unique', + fn (Blueprint $table) => $table->unique(['tenant_codigo', 'guest_token']), + ); + Schema::whenTableHasIndex( + 'carritos', + 'carritos_tenant_codigo_index', + fn (Blueprint $table) => $table->dropIndex('carritos_tenant_codigo_index'), + ); + } +}; diff --git a/routes/console.php b/routes/console.php index 3c9adf1..0c301ca 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,21 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Artisan::command('purchases:expire', function (): void { + $expiredCount = app(CheckoutService::class) + ->expireOverduePurchases(); + + $this->info("Expired purchases: {$expiredCount}"); +})->purpose('Release stock reservations from expired purchases'); + +Schedule::command('purchases:expire') + ->everyMinute() + ->withoutOverlapping(); diff --git a/tests/Feature/Auth/GoogleTokenExchangeControllerTest.php b/tests/Feature/Auth/GoogleTokenExchangeControllerTest.php new file mode 100644 index 0000000..4d40de0 --- /dev/null +++ b/tests/Feature/Auth/GoogleTokenExchangeControllerTest.php @@ -0,0 +1,114 @@ +createTenant('acme'); + $user = User::factory()->create(); + $catalogItem = $this->createCatalogItem($tenant); + $userCart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => 'active', + ]); + $guestCart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'guest_token' => 'google-guest-token', + 'status' => 'active', + ]); + $guestCart->items()->create([ + 'catalog_item_id' => $catalogItem->id, + 'variant_id' => null, + 'cantidad' => 1, + ]); + + $exchangeCode = (string) Str::uuid(); + Cache::put("google-oauth-exchange:{$exchangeCode}", [ + 'user_id' => $user->id, + 'token' => 'google-access-token', + 'tenant_codigo' => $tenant->codigo, + ], now()->addMinutes(5)); + + $this->withCookie('guest_token', 'google-guest-token') + ->postJson('/api/auth/google/exchange', [ + 'oauth_code' => $exchangeCode, + 'tenant_codigo' => $tenant->codigo, + ]) + ->assertOk() + ->assertJsonPath('token', 'google-access-token') + ->assertCookieExpired('guest_token'); + + $this->assertSoftDeleted('carritos', [ + 'id' => $userCart->id, + 'status' => 'converted', + ]); + $this->assertDatabaseHas('carritos', [ + 'id' => $guestCart->id, + 'user_id' => $user->id, + 'guest_token' => null, + 'status' => 'active', + ]); + } + + private function createCatalogItem(Tenant $tenant): CatalogItem + { + $inventory = Inventory::query()->create(['real_stock' => 10]); + + return CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_id' => $inventory->id, + 'slug' => 'google-login-item-'.$tenant->codigo, + 'nombre' => 'Google login item', + 'precio' => '10.00', + 'inventory_policy' => InventoryPolicy::Tracked, + ]); + } + + private function createTenant(string $code): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header"); + $footerLogo = $this->createAttachment("{$code}-footer"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.local", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } +} diff --git a/tests/Feature/Auth/LoginControllerTest.php b/tests/Feature/Auth/LoginControllerTest.php index c266479..38de2c6 100644 --- a/tests/Feature/Auth/LoginControllerTest.php +++ b/tests/Feature/Auth/LoginControllerTest.php @@ -2,7 +2,14 @@ namespace Tests\Feature\Auth; +use App\Domains\Attachable\Enums\AttachmentType; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; +use App\Domains\Cart\Models\Cart; +use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use Tests\TestCase; @@ -13,6 +20,7 @@ class LoginControllerTest extends TestCase public function test_it_logs_in_a_user_and_returns_a_bearer_token(): void { + $tenant = $this->createTenant('acme'); $user = User::query()->create([ 'nombre_apellido' => 'Grace Hopper', 'email' => 'grace@example.com', @@ -22,6 +30,7 @@ class LoginControllerTest extends TestCase $response = $this->postJson('/api/login', [ 'email' => 'grace@example.com', 'password' => 'secret123', + 'tenant_codigo' => $tenant->codigo, ]); $response @@ -83,6 +92,7 @@ class LoginControllerTest extends TestCase public function test_it_returns_a_validation_error_for_invalid_credentials(): void { + $tenant = $this->createTenant('acme'); User::query()->create([ 'nombre_apellido' => 'Grace Hopper', 'email' => 'grace@example.com', @@ -92,6 +102,7 @@ class LoginControllerTest extends TestCase $this->postJson('/api/login', [ 'email' => 'grace@example.com', 'password' => 'wrong-password', + 'tenant_codigo' => $tenant->codigo, ])->assertUnprocessable()->assertJsonValidationErrors(['email']); } @@ -103,6 +114,96 @@ class LoginControllerTest extends TestCase ])->assertUnprocessable()->assertJsonValidationErrors([ 'email', 'password', + 'tenant_codigo', + ]); + } + + public function test_it_replaces_the_active_user_cart_with_the_guest_cart_on_login(): void + { + $tenant = $this->createTenant('acme'); + $user = User::factory()->create([ + 'email' => 'grace@example.com', + 'password' => Hash::make('secret123'), + ]); + $catalogItem = $this->createCatalogItem($tenant); + $userCart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => 'active', + ]); + $guestCart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'guest_token' => 'guest-cart-token', + 'status' => 'active', + ]); + $guestCart->items()->create([ + 'catalog_item_id' => $catalogItem->id, + 'variant_id' => null, + 'cantidad' => 2, + ]); + + $this->withCookie('guest_token', 'guest-cart-token') + ->postJson('/api/login', [ + 'email' => 'grace@example.com', + 'password' => 'secret123', + 'tenant_codigo' => $tenant->codigo, + ]) + ->assertOk() + ->assertCookieExpired('guest_token'); + + $this->assertSoftDeleted('carritos', [ + 'id' => $userCart->id, + 'status' => 'converted', + ]); + $this->assertDatabaseHas('carritos', [ + 'id' => $guestCart->id, + 'user_id' => $user->id, + 'guest_token' => null, + 'status' => 'active', + ]); + } + + private function createCatalogItem(Tenant $tenant): CatalogItem + { + $inventory = Inventory::query()->create(['real_stock' => 10]); + + return CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_id' => $inventory->id, + 'slug' => 'login-item-'.$tenant->codigo, + 'nombre' => 'Login item', + 'precio' => '10.00', + 'inventory_policy' => InventoryPolicy::Tracked, + ]); + } + + private function createTenant(string $code): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header"); + $footerLogo = $this->createAttachment("{$code}-footer"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.local", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', ]); } } diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index a8e91c0..0749066 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -193,8 +193,12 @@ class TelepagosWebhookTest extends TestCase 'sold_units' => 1, ]); - $this->assertDatabaseMissing('compra_items', [ + $this->assertDatabaseHas('compra_items', [ 'compra_id' => $newerPurchase->id, + 'source_catalog_item_id' => $variant->catalog_item_id, + 'source_variant_id' => $variant->id, + 'cantidad' => 2, + 'reservation_status' => 'active', ]); $this->assertSoftDeleted('carritos', [ diff --git a/tests/Feature/Purchase/PurchaseCatalogItemTest.php b/tests/Feature/Purchase/PurchaseCatalogItemTest.php index 5a94919..79627bd 100644 --- a/tests/Feature/Purchase/PurchaseCatalogItemTest.php +++ b/tests/Feature/Purchase/PurchaseCatalogItemTest.php @@ -86,12 +86,10 @@ class PurchaseCatalogItemTest extends TestCase ]); $purchaseItem = $purchase->items()->with('imageAttachment')->firstOrFail(); $this->assertNotNull($purchaseItem->imageAttachment); - $this->assertNotSame($productImage->id, $purchaseItem->image_attachment_id); + $this->assertSame($productImage->id, $purchaseItem->image_attachment_id); $this->assertSame('original-image', Storage::disk('s3')->get($purchaseItem->imageAttachment->path)); - $this->assertStringStartsWith( - "purchase/{$purchase->id}/", - $purchaseItem->imageAttachment->path, - ); + $this->assertSame($productImage->path, $purchaseItem->imageAttachment->path); + $this->assertDatabaseCount('attachments', 3); $catalogItem->update([ 'nombre' => 'Changed catalog item', 'descripcion' => 'Changed description', diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 97ba2cb..9ef69fb 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -30,7 +30,7 @@ class StorePurchaseTest extends TestCase Queue::fake(); } - public function test_it_creates_a_purchase_from_cart_id_without_persisting_items_yet(): void + public function test_it_creates_an_independent_purchase_snapshot_from_cart(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ @@ -74,24 +74,20 @@ class StorePurchaseTest extends TestCase ]); $response = $this->actingAs($user, 'sanctum') - ->postJson('/api/tenants/sonder/compras', [ + ->postJson('/api/tenants/sonder/compras/start-checkout', [ 'cart_id' => $cartId, - 'dni' => '987654321', - 'telefono' => '+54 9 341 555-4321', - 'nombre_apellido' => 'Juan Perez', - 'email' => 'juan.perez@example.com', ]); $response->assertCreated(); $response->assertJsonPath('data.cart_id', $cartId); - $response->assertJsonPath('data.dni', '987654321'); - $response->assertJsonPath('data.telefono', '+54 9 341 555-4321'); - $response->assertJsonPath('data.nombre_apellido', 'Juan Perez'); - $response->assertJsonPath('data.email', 'juan.perez@example.com'); + $response->assertJsonPath('data.dni', null); + $response->assertJsonPath('data.telefono', null); + $response->assertJsonPath('data.nombre_apellido', null); + $response->assertJsonPath('data.email', null); $response->assertJsonPath('data.tenant_codigo', 'sonder'); $response->assertJsonPath('data.status', Purchase::STATUS_CREATED); - $response->assertJsonPath('data.items_source', null); - $response->assertJsonPath('data.items', []); + $response->assertJsonPath('data.items_source', 'purchase'); + $response->assertJsonCount(1, 'data.items'); $response->assertJsonPath('data.subtotal', '100.00'); $response->assertJsonPath('data.total', '100.00'); @@ -102,20 +98,25 @@ class StorePurchaseTest extends TestCase 'cart_id' => $cartId, 'tenant_codigo' => 'sonder', 'user_id' => $user->id, - 'dni' => '987654321', - 'telefono' => '+54 9 341 555-4321', - 'nombre_apellido' => 'Juan Perez', - 'email' => 'juan.perez@example.com', + 'dni' => null, + 'telefono' => null, + 'nombre_apellido' => null, + 'email' => null, 'status' => Purchase::STATUS_CREATED, 'total' => 100, ]); - $this->assertDatabaseMissing('compra_items', [ + $this->assertDatabaseHas('compra_items', [ 'compra_id' => $purchaseId, + 'source_catalog_item_id' => $catalogItem->id, + 'source_variant_id' => $variant->id, + 'cantidad' => 2, + 'reservation_status' => 'active', ]); $this->assertDatabaseHas('carritos', [ 'id' => $cartId, - 'status' => 'active', 'user_id' => $user->id, + 'status' => 'checkout', + 'deleted_at' => null, ]); $this->assertDatabaseHas('carrito_items', [ 'cart_id' => $cartId, @@ -130,6 +131,281 @@ class StorePurchaseTest extends TestCase ]); } + public function test_it_creates_a_direct_purchase_without_creating_or_changing_a_cart(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + + $response = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'direct_item' => [ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 3, + ], + ]) + ->assertCreated() + ->assertJsonPath('data.cart_id', null) + ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonPath('data.items.0.quantity', 3) + ->assertJsonPath('data.total', '150.00'); + + $this->assertDatabaseCount('carritos', 0); + $this->assertDatabaseHas('compra_items', [ + 'compra_id' => $response->json('data.id'), + 'source_catalog_item_id' => $variant->catalog_item_id, + 'source_variant_id' => $variant->id, + 'cantidad' => 3, + 'reservation_status' => 'active', + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'real_stock' => 10, + 'reserved_stock' => 3, + ]); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$response->json('data.id')}/cancel") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); + + $this->assertDatabaseHas('compra_items', [ + 'compra_id' => $response->json('data.id'), + 'reservation_status' => 'released', + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'real_stock' => 10, + 'reserved_stock' => 0, + ]); + } + + public function test_it_restores_the_source_cart_when_checkout_is_cancelled(): 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, 3); + + $this->assertDatabaseHas('carritos', [ + 'id' => $purchase->cart_id, + 'status' => 'checkout', + 'deleted_at' => null, + ]); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); + + $this->assertDatabaseHas('carritos', [ + 'id' => $purchase->cart_id, + 'user_id' => $user->id, + 'status' => 'active', + 'deleted_at' => null, + ]); + $this->assertDatabaseHas('carrito_items', [ + 'cart_id' => $purchase->cart_id, + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 3, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 3, + ]); + } + + public function test_it_merges_the_checkout_cart_when_the_user_created_another_active_cart(): 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); + + $activeCartId = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/cart/items', [ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 1, + ]) + ->assertOk() + ->json('data.id'); + + $this->assertNotSame($purchase->cart_id, $activeCartId); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") + ->assertOk(); + + $this->assertDatabaseHas('carritos', [ + 'id' => $activeCartId, + 'user_id' => $user->id, + 'status' => 'active', + 'deleted_at' => null, + ]); + $this->assertDatabaseHas('carrito_items', [ + 'cart_id' => $activeCartId, + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 3, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 3, + ]); + } + + public function test_it_creates_purchase_items_before_checkout_and_updates_customer_data(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + + $purchaseResponse = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'direct_item' => [ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 2, + ], + ]) + ->assertCreated() + ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonCount(1, 'data.items') + ->assertJsonPath('data.items.0.quantity', 2) + ->assertJsonPath('data.dni', null) + ->assertJsonPath('data.telefono', null); + + $purchaseId = $purchaseResponse->json('data.id'); + + $this->actingAs($user, 'sanctum') + ->patchJson("/api/tenants/sonder/compras/{$purchaseId}/customer-data", [ + 'dni' => '987654321', + 'telefono' => '+54 9 341 555-4321', + 'nombre_apellido' => 'Juan Perez', + 'email' => 'juan.perez@example.com', + ]) + ->assertOk() + ->assertJsonPath('data.id', $purchaseId) + ->assertJsonPath('data.dni', '987654321') + ->assertJsonPath('data.telefono', '+54 9 341 555-4321') + ->assertJsonPath('data.nombre_apellido', 'Juan Perez') + ->assertJsonPath('data.email', 'juan.perez@example.com') + ->assertJsonCount(1, 'data.items'); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchaseId, + 'dni' => '987654321', + 'telefono' => '+54 9 341 555-4321', + 'nombre_apellido' => 'Juan Perez', + 'email' => 'juan.perez@example.com', + ]); + } + + public function test_it_updates_a_created_purchase_item_quantity_and_its_stock_reservation(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + $itemId = $purchase->items->firstOrFail()->id; + + $this->actingAs($user, 'sanctum') + ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [ + 'quantity' => 4, + ]) + ->assertOk() + ->assertJsonPath('data.items.0.quantity', 4) + ->assertJsonPath('data.items.0.line_total', '200.00') + ->assertJsonPath('data.subtotal', '200.00') + ->assertJsonPath('data.total', '200.00'); + + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 4, + ]); + $this->assertDatabaseHas('carrito_items', [ + 'cart_id' => $purchase->cart_id, + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 4, + ]); + + $this->actingAs($user, 'sanctum') + ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [ + 'quantity' => 1, + ]) + ->assertOk() + ->assertJsonPath('data.items.0.quantity', 1) + ->assertJsonPath('data.total', '50.00'); + + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 1, + ]); + } + + public function test_it_reopens_a_pending_purchase_before_editing_items(): 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' => 'stale-order', + 'qr_code' => 'stale-qr', + ]); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$purchase->id}/edit-items") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_CREATED) + ->assertJsonPath('data.payment_method', null); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'status' => Purchase::STATUS_CREATED, + 'payment_method' => null, + ]); + $this->assertDatabaseMissing('telepagos_qr', [ + 'compra_id' => $purchase->id, + 'qr_order_id' => 'stale-order', + ]); + } + + public function test_start_checkout_rejects_customer_data(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'direct_item' => [ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 1, + ], + 'dni' => '987654321', + 'telefono' => '+54 9 341 555-4321', + 'nombre_apellido' => 'Juan Perez', + 'email' => 'juan.perez@example.com', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'dni', + 'telefono', + 'nombre_apellido', + 'email', + ]); + } + public function test_it_moves_a_created_purchase_to_pending_payment_when_finalized(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); @@ -148,12 +424,8 @@ class StorePurchaseTest extends TestCase ->json('data.id'); $purchaseId = $this->actingAs($user, 'sanctum') - ->postJson('/api/tenants/sonder/compras', [ + ->postJson('/api/tenants/sonder/compras/start-checkout', [ 'cart_id' => $cartId, - 'dni' => '987654321', - 'telefono' => '+54 9 341 555-4321', - 'nombre_apellido' => 'Juan Perez', - 'email' => 'juan.perez@example.com', ]) ->assertCreated() ->json('data.id'); @@ -176,7 +448,52 @@ class StorePurchaseTest extends TestCase ]); } - public function test_purchase_detail_uses_cart_items_for_created_purchase(): void + public function test_it_expires_an_abandoned_purchase_and_restores_its_cart(): 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, 3); + + $this->assertNotNull($purchase->expires_at); + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 3, + ]); + + $this->travel(31)->minutes(); + + $this->artisan('purchases:expire') + ->expectsOutput('Expired purchases: 1') + ->assertSuccessful(); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'status' => Purchase::STATUS_EXPIRED, + ]); + $this->assertDatabaseHas('compra_items', [ + 'compra_id' => $purchase->id, + 'reservation_status' => 'released', + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'real_stock' => 10, + 'reserved_stock' => 3, + 'sold_units' => 0, + ]); + $this->assertDatabaseHas('carritos', [ + 'id' => $purchase->cart_id, + 'user_id' => $user->id, + 'status' => 'active', + 'deleted_at' => null, + ]); + + $this->artisan('purchases:expire') + ->expectsOutput('Expired purchases: 0') + ->assertSuccessful(); + } + + public function test_purchase_detail_uses_purchase_items_for_created_purchase(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ @@ -189,22 +506,22 @@ class StorePurchaseTest extends TestCase ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_CREATED) - ->assertJsonPath('data.items_source', 'cart') + ->assertJsonPath('data.items_source', 'purchase') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') ->assertJsonPath('data.items.0.line_total', '100.00') - ->assertJsonPath('data.items.0.product.id', $variant->catalogItem->id) - ->assertJsonPath('data.items.0.product.nombre', $variant->catalogItem->nombre) - ->assertJsonPath('data.items.0.product.slug', $variant->catalogItem->slug) - ->assertJsonPath('data.items.0.product.imagen', null) - ->assertJsonPath('data.items.0.variant.id', $variant->id) - ->assertJsonPath('data.items.0.variant.attributes', []) + ->assertJsonPath('data.items.0.source_catalog_item_id', $variant->catalogItem->id) + ->assertJsonPath('data.items.0.source_variant_id', $variant->id) + ->assertJsonPath('data.items.0.item_details.nombre', $variant->catalogItem->nombre) + ->assertJsonPath('data.items.0.item_details.slug', $variant->catalogItem->slug) + ->assertJsonPath('data.items.0.item_details.imagen', null) + ->assertJsonPath('data.items.0.item_details.attributes', []) ->assertJsonPath('data.subtotal', '100.00') ->assertJsonPath('data.total', '100.00'); } - public function test_purchase_detail_uses_cart_items_for_pending_payment_purchase(): void + public function test_purchase_detail_uses_purchase_items_for_pending_payment_purchase(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ @@ -223,13 +540,13 @@ class StorePurchaseTest extends TestCase ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT) - ->assertJsonPath('data.items_source', 'cart') + ->assertJsonPath('data.items_source', 'purchase') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') ->assertJsonPath('data.items.0.line_total', '100.00') - ->assertJsonPath('data.items.0.product.imagen', null) - ->assertJsonPath('data.items.0.variant.attributes', []) + ->assertJsonPath('data.items.0.item_details.imagen', null) + ->assertJsonPath('data.items.0.item_details.attributes', []) ->assertJsonPath('data.subtotal', '100.00') ->assertJsonPath('data.total', '100.00'); } @@ -280,7 +597,7 @@ class StorePurchaseTest extends TestCase ->assertJsonPath('data.total', '100.00'); } - public function test_purchase_detail_prefers_purchase_items_when_both_sources_exist(): void + public function test_purchase_detail_does_not_require_its_source_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ @@ -289,30 +606,17 @@ class StorePurchaseTest extends TestCase $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $purchase->items()->create([ - 'source_catalog_item_id' => $variant->catalog_item_id, - 'source_variant_id' => $variant->id, - 'nombre' => $variant->catalogItem->nombre, - 'descripcion' => $variant->catalogItem->descripcion, - 'slug' => $variant->catalogItem->slug, - 'item_nombre' => $variant->getName(), - 'variant_attributes' => [], - 'cantidad' => 1, - 'precio_unitario' => '50.00', - 'discount_total' => null, - 'tax_total' => null, - 'total' => '50.00', - ]); + $purchase->update(['cart_id' => null]); $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.items_source', 'purchase') ->assertJsonCount(1, 'data.items') - ->assertJsonPath('data.items.0.quantity', 1) - ->assertJsonPath('data.items.0.line_total', '50.00') - ->assertJsonPath('data.subtotal', '50.00') - ->assertJsonPath('data.total', '50.00'); + ->assertJsonPath('data.items.0.quantity', 2) + ->assertJsonPath('data.items.0.line_total', '100.00') + ->assertJsonPath('data.subtotal', '100.00') + ->assertJsonPath('data.total', '100.00'); } public function test_purchase_index_returns_empty_items_without_loaded_relations(): void @@ -350,12 +654,8 @@ class StorePurchaseTest extends TestCase ->json('data.id'); $this->actingAs($attacker, 'sanctum') - ->postJson('/api/tenants/sonder/compras', [ + ->postJson('/api/tenants/sonder/compras/start-checkout', [ 'cart_id' => $cartId, - 'dni' => '12345678', - 'telefono' => '+54 9 341 555-1111', - 'nombre_apellido' => 'Intruso', - 'email' => 'intruso@example.com', ]) ->assertNotFound(); } @@ -377,12 +677,8 @@ class StorePurchaseTest extends TestCase ->json('data.id'); $this->actingAs($user, 'sanctum') - ->postJson('/api/tenants/sonder/compras', [ + ->postJson('/api/tenants/sonder/compras/start-checkout', [ 'cart_id' => $cartId, - 'dni' => '12345678', - 'telefono' => '+54 9 341 555-1111', - 'nombre_apellido' => 'Juan Perez', - 'email' => 'juan.perez@example.com', ]) ->assertNotFound(); } @@ -398,12 +694,8 @@ class StorePurchaseTest extends TestCase ]); $this->actingAs($user, 'sanctum') - ->postJson('/api/tenants/sonder/compras', [ + ->postJson('/api/tenants/sonder/compras/start-checkout', [ 'cart_id' => $cart->id, - 'dni' => '12345678', - 'telefono' => '+54 9 341 555-1111', - 'nombre_apellido' => 'Juan Perez', - 'email' => 'juan.perez@example.com', ]) ->assertUnprocessable() ->assertJsonValidationErrors(['cart_id']); @@ -420,12 +712,8 @@ class StorePurchaseTest extends TestCase ]); $this->actingAs($user, 'sanctum') - ->postJson('/api/tenants/sonder/compras', [ + ->postJson('/api/tenants/sonder/compras/start-checkout', [ 'cart_id' => $cart->id, - 'dni' => '12345678', - 'telefono' => '+54 9 341 555-1111', - 'nombre_apellido' => 'Juan Perez', - 'email' => 'juan.perez@example.com', ]) ->assertUnprocessable() ->assertJsonValidationErrors(['cart_id']); @@ -510,10 +798,6 @@ class StorePurchaseTest extends TestCase return app(CheckoutService::class)->startCheckout($tenant, $user->id, [ 'cart_id' => $cart->id, - 'dni' => '987654321', - 'telefono' => '+54 9 341 555-4321', - 'nombre_apellido' => 'Juan Perez', - 'email' => 'juan.perez@example.com', ]); }