Files
shopit-back/app/Domains/Purchase/Services/CheckoutService.php
ncoronel ff9d7728e8 Implement localization for API responses and error messages
- Added localization support for API responses in English and Spanish.
- Introduced a middleware to set the API locale based on the Accept-Language header.
- Updated various exception messages and validation responses to use localized strings.
- Created new language files for English and Spanish translations.
- Refactored existing code to replace hardcoded messages with localized strings.
- Added tests to verify localization functionality and response correctness.
2026-07-28 10:19:39 -03:00

812 lines
27 KiB
PHP

<?php
namespace App\Domains\Purchase\Services;
use App\Domains\Attachable\Models\Attachment;
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\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CheckoutService
{
public function __construct(
private readonly CatalogInventoryService $catalogInventoryService,
) {}
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
{
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']);
if (is_array($directItem)) {
return $this->startDirectCheckout(
$tenant,
$userId,
$purchaseData,
$directItem,
);
}
if ($cartId === null) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.source_required'),
]);
}
return $this->startCartCheckout(
$tenant,
$userId,
$purchaseData,
$cartId,
);
});
}
public function completePurchase(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if ($purchase->payment_method === null) {
throw ValidationException::withMessages([
'payment_method' => __('api.purchase.payment_method_required'),
]);
}
if (in_array($purchase->status, [
Purchase::STATUS_PAID,
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
], true)) {
return $this->loadPurchase($purchase);
}
$purchase->update([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => $purchase->calculateCurrentTotalAmount(),
]);
return $this->loadPurchase($purchase);
});
}
/**
* @param array<string, string> $customerData
*/
public function updateCustomerData(Purchase $purchase, array $customerData): Purchase
{
return DB::transaction(function () use ($purchase, $customerData): 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' => __('api.purchase.not_editable'),
]);
}
$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' => __('api.purchase.not_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' => __('api.purchase.item_not_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' => __('api.purchase.insufficient_stock'),
]);
}
$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' => __('api.purchase.not_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' => __('api.purchase.cannot_confirm'),
]);
}
$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' => __('api.purchase.inconsistent_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' => __('api.purchase.paid_cannot_cancel'),
]);
}
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' => __('api.purchase.inconsistent_reservation'),
]);
}
}
$item->update([
'reservation_status' => PurchaseItem::RESERVATION_RELEASED,
]);
}
$purchase->update([
'status' => $targetStatus,
]);
return $this->loadPurchase($purchase);
});
}
/**
* @param array<string, mixed> $purchaseData
* @param array<string, mixed> $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' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]),
]);
}
try {
$this->catalogInventoryService->reserve($selection, $quantity);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'direct_item.cantidad' => __('api.purchase.insufficient_stock'),
]);
}
$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<string, mixed> $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' => __('api.purchase.empty_cart'),
]);
}
$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<string, mixed> $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) {
if ($item->selectedItem() === null) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.catalog_item_missing'),
]);
}
if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
]);
}
}
}
protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart
{
/** @var Cart|null $cart */
$cart = Cart::query()
->lockForUpdate()
->find($cartId);
if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) {
throw new NotFoundHttpException('Cart not found for tenant.');
}
if ($cart->status !== 'active') {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.inactive_cart'),
]);
}
return $cart;
}
/**
* @param Collection<int, CartItem> $cartItems
* @return array<int, array<string, mixed>>
*/
protected function buildPurchaseItemsPayload(Collection $cartItems): array
{
return $cartItems
->map(function (CartItem $item): array {
$selectedItem = $item->selectedItem();
$quantity = (int) $item->cantidad;
$unitPrice = $selectedItem?->getPrice() ?? 0;
$imageAttachment = $this->firstImageAttachment($item);
return [
'source_catalog_item_id' => $item->catalog_item_id,
'source_variant_id' => $item->variant_id,
'image_attachment_id' => $imageAttachment?->id,
'nombre' => $item->catalogItem->nombre,
'descripcion' => $item->catalogItem->descripcion,
'slug' => $item->catalogItem->slug,
'item_nombre' => $selectedItem->getName(),
'variant_attributes' => $item->variant === null
? []
: $this->snapshotAttributes($item->variant),
'cantidad' => $quantity,
'precio_unitario' => $unitPrice,
'discount_total' => null,
'tax_total' => null,
'total' => $unitPrice * $quantity,
'reservation_status' => PurchaseItem::RESERVATION_ACTIVE,
];
})
->all();
}
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();
if ($catalogItem === null) {
throw new NotFoundHttpException('Catalog item not found for tenant.');
}
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'direct_item.catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
return $catalogItem;
}
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => __('api.cart.variant_required'),
]);
}
$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<int, CartItem> $cartItems */
private function loadCartItems(Collection $cartItems): void
{
$cartItems->load([
'catalogItem.inventory',
'catalogItem.attachments',
'variant.inventory',
'variant.attachments',
'variant.catalogItem',
'variant.definitions.itemAttribute.attribute',
]);
}
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load([
'items.imageAttachment',
]);
}
private function firstImageAttachment(CartItem $item): ?Attachment
{
return $item->variant?->attachments->first()
?? $item->catalogItem?->attachments->first();
}
/** @return array<int, array{name: string, value: mixed}> */
private function snapshotAttributes(Variant $variant): array
{
return $variant->definitions
->map(fn ($definition): array => [
'name' => (string) ($definition->itemAttribute?->attribute?->nombre ?? ''),
'value' => $definition->value,
])
->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null)
->values()
->all();
}
}