Compare commits

..

30 Commits

Author SHA1 Message Date
ncoronel d3182fbd13 feat: add migration to expand tenant_codigo in bundles and update related seeders and tests 2026-07-17 16:07:33 -03:00
ncoronel b8eb71896c feat: refactor featured variants to group items, including new controller and resource implementations 2026-07-17 14:42:53 -03:00
ncoronel cb0fb2899c feat: implement polymorphic relationships for group items to support bundles and product variants 2026-07-17 14:42:44 -03:00
ncoronel 615eacea91 feat: rename featured_variants to group_items and update related references 2026-07-17 14:31:05 -03:00
ncoronel 84c9fa4c9d feat: update purchase status to pending payment in payment intent and related test 2026-07-16 12:13:48 -03:00
ncoronel b3d06431e5 feat: rename payer_dni to transfer_payer_dni across purchase handling and update related tests 2026-07-16 11:45:34 -03:00
ncoronel 379c2bdbeb Merge branch 'dev' of https://gitea.quo.ar/tbianchini/shopit-back into dev 2026-07-16 10:00:12 -03:00
ncoronel 6fc6ed1fac feat: refactor cart and purchase handling to support polymorphic buyable types and improve code readability 2026-07-16 09:55:38 -03:00
ncoronel c05206cc51 feat: refactor cart item handling to use mapped buyable types and improve method signatures 2026-07-16 09:19:38 -03:00
ncoronel 480ee70393 feat: refactor cart and bundle handling to support Buyable interface for improved flexibility 2026-07-16 09:19:03 -03:00
ncoronel ceb1e2b04a feat: implement Stockable interface and methods for Bundle and ProductVariant models 2026-07-16 09:11:37 -03:00
ncoronel 1d56d27350 feat: implement Bundle and BundleItem models, migrations, and polymorphic relationships for cart and purchase items 2026-07-16 09:11:00 -03:00
ncoronel 18c80b075b feat: Update product seeder to include inventory policy and improve code readability 2026-07-16 09:07:05 -03:00
ncoronel 10157d7c63 feat: Implement inventory policy for product variants
- Introduced InventoryPolicy enum to manage tracked and unlimited inventory types.
- Updated ProductVariant model to include inventory_policy and cantidad_vendida attributes.
- Modified addItem and updateItem methods in Cart to check available quantity based on inventory policy.
- Added migration to include inventory_policy and cantidad_vendida in productos_variantes table.
- Enhanced tests to validate inventory behavior for both tracked and unlimited policies.
- Updated various request and resource classes to handle new inventory fields.
2026-07-16 08:36:59 -03:00
ncoronel 716d8e447c feat: add Fecha attribute seeder and integrate FiestaFutbolInfantilProductSeeder 2026-07-15 14:21:19 -03:00
ncoronel cababe1a43 feat: add ticket fields to Product and ProductVariant models, requests, and resources 2026-07-14 15:19:53 -03:00
ncoronel 0e9f7faaf1 feat: update CatalogController to return JsonResponse instead of AnonymousResourceCollection 2026-07-14 15:04:01 -03:00
ncoronel 13bdb436ed feat: implement FeaturedGroup and FeaturedVariant controllers, models, requests, resources, and routes for managing featured groups and variants 2026-07-14 14:58:28 -03:00
ncoronel c2efec8906 feat: implement FeaturedVariant and CatalogController for managing featured variants and catalog display 2026-07-14 14:55:48 -03:00
ncoronel 299a462283 feat: implement FeaturedGroup and FeaturedProduct controllers, requests, resources, and routes for managing featured products 2026-07-14 14:21:04 -03:00
ncoronel 38f36a3a23 feat: add hero background image functionality to Tenant model and related requests, resources, and seeder 2026-07-14 13:58:49 -03:00
ncoronel b4b888adef feat: add FeaturedGroup and FeaturedProduct models with migrations for database structure 2026-07-14 13:30:07 -03:00
ncoronel 3afef63fc7 feat: update MenuSeeder to dynamically fetch menu codes and adjust tenant associations 2026-07-14 12:10:39 -03:00
ncoronel e325c49a89 feat: enhance tenant resource to include menues and update seeder for new menu routes 2026-07-14 11:44:57 -03:00
ncoronel 9c7d025258 feat: implement Menu management with CRUD operations and database migrations 2026-07-14 11:31:30 -03:00
ncoronel ba58bf79f8 feat: add 'fiesta_futbol_infantil' tenant with associated images and configurations 2026-07-14 11:25:16 -03:00
ncoronel 2910d3f929 feat: add hero_config and event_config to Tenant model with validation and migration 2026-07-14 11:08:59 -03:00
nahu ca7a2ae55c feat: add payer_dni field and validation for transfer payments, update related logic and tests 2026-07-12 19:48:56 +00:00
ncoronel ca4fea6bcd feat: enhance purchase detail handling to support cart items and improve resource structure 2026-07-08 15:50:40 -03:00
ncoronel 73bf285bad feat: implement profile update functionality with validation and password handling 2026-07-08 12:10:23 -03:00
93 changed files with 3464 additions and 329 deletions

View File

@ -0,0 +1,18 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\UpdateProfileRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\ProfileService;
use Illuminate\Http\JsonResponse;
class UpdateProfileController
{
public function __invoke(UpdateProfileRequest $request, ProfileService $service): JsonResponse
{
$user = $service->update($request->user(), $request->validated());
return response()->json(UserResource::make($user)->resolve());
}
}

View File

@ -20,7 +20,7 @@ class RegisterUserRequest extends FormRequest
return [ return [
'nombre_apellido' => ['required', 'string', 'max:255'], 'nombre_apellido' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')], 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'confirmed'], 'password' => ['required', 'string', 'confirmed', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
'dni' => ['nullable', 'string', 'max:255'], 'dni' => ['nullable', 'string', 'max:255'],
'telefono' => ['nullable', 'string', 'max:255'], 'telefono' => ['nullable', 'string', 'max:255'],
]; ];

View File

@ -0,0 +1,29 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProfileRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'nombre_apellido' => ['required', 'string', 'max:255'],
'email' => [
'required',
'email',
Rule::unique('users', 'email')->ignore($this->user()->id),
],
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
];
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use Illuminate\Support\Facades\Hash;
class ProfileService
{
/**
* Update the given user's profile information.
*
* @param User $user
* @param array $data
* @return User
*/
public function update(User $user, array $data): User
{
// Handle password hashing if a new password is provided
if (!empty($data['password'])) {
$data['password'] = Hash::make($data['password']);
} else {
// Remove password from array if empty so we don't overwrite it with null
unset($data['password']);
}
// Standardize phone number (strip all but numbers and leading '+')
if (!empty($data['telefono'])) {
$data['telefono'] = preg_replace('/[^\+0-9]/', '', $data['telefono']);
}
// DNI is already validated as numbers only, but we can do a quick strip just in case
if (!empty($data['dni'])) {
$data['dni'] = preg_replace('/[^0-9]/', '', $data['dni']);
}
$user->update($data);
return $user;
}
}

View File

@ -4,9 +4,11 @@ use App\Domains\Auth\Controllers\LoginController;
use App\Domains\Auth\Controllers\LogoutController; use App\Domains\Auth\Controllers\LogoutController;
use App\Domains\Auth\Controllers\MeController; use App\Domains\Auth\Controllers\MeController;
use App\Domains\Auth\Controllers\RegisterController; use App\Domains\Auth\Controllers\RegisterController;
use App\Domains\Auth\Controllers\UpdateProfileController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::post('/register', RegisterController::class); Route::post('/register', RegisterController::class);
Route::post('/login', LoginController::class); Route::post('/login', LoginController::class);
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class); Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
Route::middleware('auth:sanctum')->get('/me', MeController::class); Route::middleware('auth:sanctum')->get('/me', MeController::class);
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);

View File

@ -0,0 +1,136 @@
<?php
namespace App\Domains\Bundle\Models;
use App\Domains\Catalog\Models\GroupItem;
use App\Domains\Shared\Contracts\Buyable;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
#[Fillable([
'tenant_codigo',
'nombre',
'descripcion',
'precio',
])]
class Bundle extends Model implements Buyable
{
use HasFactory;
protected $table = 'bundles';
protected $appends = ['stock_tecnico'];
protected function casts(): array
{
return [
'precio' => 'decimal:2',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<BundleItem, $this>
*/
public function items(): HasMany
{
return $this->hasMany(BundleItem::class, 'bundle_id');
}
/**
* @return MorphMany<GroupItem, $this>
*/
public function groupItems(): MorphMany
{
return $this->morphMany(GroupItem::class, 'groupable');
}
public function getPrice(): float
{
return (float) $this->precio;
}
public function getName(): string
{
return $this->nombre ?? 'Bundle';
}
public function availableQuantity(): ?int
{
if ($this->items->isEmpty()) {
return 0;
}
$availableQuantities = [];
foreach ($this->items as $item) {
$variantQuantity = $item->variant?->availableQuantity();
if ($variantQuantity !== null) {
$availableQuantities[] = intdiv($variantQuantity, $item->cantidad);
}
}
return $availableQuantities === [] ? null : min($availableQuantities);
}
public function reserveStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a reservar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->reserveStock($amount * $item->cantidad);
}
}
public function decrementReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->decrementReservedStock($amount * $item->cantidad);
}
}
public function buy(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a comprar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->buy($amount * $item->cantidad);
}
}
public function validateStock(): void
{
$availableQuantity = $this->availableQuantity();
if ($availableQuantity !== null && $availableQuantity <= 0) {
throw new \InvalidArgumentException('El bundle no tiene stock tecnico disponible.');
}
}
protected function stockTecnico(): Attribute
{
return Attribute::get(fn (): ?int => $this->availableQuantity());
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Domains\Bundle\Models;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'bundle_id',
'producto_variante_id',
'cantidad',
])]
class BundleItem extends Model
{
use HasFactory;
protected $table = 'bundle_items';
protected function casts(): array
{
return [
'bundle_id' => 'integer',
'producto_variante_id' => 'integer',
'cantidad' => 'integer',
];
}
/**
* @return BelongsTo<Bundle, $this>
*/
public function bundle(): BelongsTo
{
return $this->belongsTo(Bundle::class, 'bundle_id');
}
/**
* @return BelongsTo<ProductVariant, $this>
*/
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
}
}

View File

@ -2,11 +2,11 @@
namespace App\Domains\Cart\Controllers; namespace App\Domains\Cart\Controllers;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Requests\AddCartItemRequest; use App\Domains\Cart\Requests\AddCartItemRequest;
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest; use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
use App\Domains\Cart\Resources\CartResource; use App\Domains\Cart\Resources\CartResource;
use App\Domains\Cart\Services\CartService; use App\Domains\Cart\Services\CartService;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -16,8 +16,7 @@ class CartController extends Controller
{ {
public function __construct( public function __construct(
protected CartService $cartService, protected CartService $cartService,
) { ) {}
}
public function show(Request $request, Tenant $tenant): CartResource public function show(Request $request, Tenant $tenant): CartResource
{ {
@ -29,7 +28,8 @@ class CartController extends Controller
$result = $this->cartService->addItem( $result = $this->cartService->addItem(
$tenant, $tenant,
$request, $request,
(int) $request->validated('product_variant_id'), $request->mappedBuyableType(),
(int) $request->validated('buyable_id'),
(int) $request->validated('cantidad'), (int) $request->validated('cantidad'),
); );
@ -47,22 +47,22 @@ class CartController extends Controller
public function updateItemQuantity( public function updateItemQuantity(
UpdateCartItemQuantityRequest $request, UpdateCartItemQuantityRequest $request,
Tenant $tenant, Tenant $tenant,
ProductVariant $productVariant, CartItem $cartItem,
): CartResource { ): CartResource {
return CartResource::make( return CartResource::make(
$this->cartService->updateItemQuantity( $this->cartService->updateItemQuantity(
$tenant, $tenant,
$request, $request,
$productVariant->getKey(), $cartItem->getKey(),
(int) $request->validated('cantidad'), (int) $request->validated('cantidad'),
) )
)->additional(['message' => 'Cantidad de producto actualizada.']); )->additional(['message' => 'Cantidad de producto actualizada.']);
} }
public function removeItem(Request $request, Tenant $tenant, ProductVariant $productVariant): CartResource public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
{ {
return CartResource::make( return CartResource::make(
$this->cartService->removeItem($tenant, $request, $productVariant->getKey()) $this->cartService->removeItem($tenant, $request, $cartItem->getKey())
)->additional(['message' => 'Producto eliminado del carrito.']); )->additional(['message' => 'Producto eliminado del carrito.']);
} }
} }

View File

@ -3,7 +3,9 @@
namespace App\Domains\Cart\Models; namespace App\Domains\Cart\Models;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Shared\Contracts\Buyable;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -63,15 +65,15 @@ class Cart extends Model
{ {
$items = $this->relationLoaded('items') $items = $this->relationLoaded('items')
? $this->getRelation('items') ? $this->getRelation('items')
: $this->items()->with('variant.product')->get(); : $this->items()->with('buyable')->get();
return (float) $items->reduce( return (float) $items->reduce(
fn (float $carry, $item): float => $carry + ((float) ($item->variant?->product?->precio ?? 0) * $item->cantidad), fn (float $carry, $item): float => $carry + ($item->buyable?->getPrice() * $item->cantidad),
0.0, 0.0,
); );
} }
public function addItem(int $productVariantId, int $quantity): CartItem public function addItem(string $buyableType, int $buyableId, int $quantity): CartItem
{ {
if ($quantity <= 0) { if ($quantity <= 0) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@ -79,24 +81,28 @@ class Cart extends Model
]); ]);
} }
return DB::transaction(function () use ($productVariantId, $quantity): CartItem { return DB::transaction(function () use ($buyableType, $buyableId, $quantity): CartItem {
$variant = $this->resolveScopedVariant($productVariantId, true); $buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true);
$canonicalType = $buyable::class;
$availableQuantity = $buyable->availableQuantity();
if ($variant->stock_tecnico < $quantity) { if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cantidad' => "Stock insuficiente para la variante solicitada. Maximo disponible: {$variant->stock_tecnico}.", 'cantidad' => "Stock insuficiente para el producto solicitado. Maximo disponible: {$availableQuantity}.",
]); ]);
} }
/** @var CartItem|null $item */ /** @var CartItem|null $item */
$item = $this->items() $item = $this->items()
->where('producto_variante_id', $variant->getKey()) ->where('buyable_type', $canonicalType)
->where('buyable_id', $buyable->getKey())
->lockForUpdate() ->lockForUpdate()
->first(); ->first();
if ($item === null) { if ($item === null) {
$item = $this->items()->create([ $item = $this->items()->create([
'producto_variante_id' => $variant->getKey(), 'buyable_type' => $canonicalType,
'buyable_id' => $buyable->getKey(),
'cantidad' => $quantity, 'cantidad' => $quantity,
]); ]);
} else { } else {
@ -104,13 +110,13 @@ class Cart extends Model
$item->save(); $item->save();
} }
$variant->incrementReservedStock($quantity); $buyable->reserveStock($quantity);
return $item->fresh(); return $item->fresh();
}); });
} }
public function updateItem(int $productVariantId, int $quantity): CartItem public function updateItem(int $cartItemId, int $quantity): CartItem
{ {
if ($quantity <= 0) { if ($quantity <= 0) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@ -118,18 +124,19 @@ class Cart extends Model
]); ]);
} }
return DB::transaction(function () use ($productVariantId, $quantity): CartItem { return DB::transaction(function () use ($cartItemId, $quantity): CartItem {
/** @var CartItem $item */ /** @var CartItem $item */
$item = $this->items() $item = $this->items()
->where('producto_variante_id', $productVariantId) ->where('id', $cartItemId)
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
$variant = $this->resolveScopedVariant($productVariantId, true); $buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
$delta = $quantity - $item->cantidad; $delta = $quantity - $item->cantidad;
$availableQuantity = $buyable->availableQuantity();
if ($delta > 0 && $variant->stock_tecnico < $delta) { if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $variant->stock_tecnico + $item->cantidad; $maxAvailable = $availableQuantity + $item->cantidad;
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.", 'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
]); ]);
@ -139,49 +146,65 @@ class Cart extends Model
$item->save(); $item->save();
if ($delta > 0) { if ($delta > 0) {
$variant->incrementReservedStock($delta); $buyable->reserveStock($delta);
} }
if ($delta < 0) { if ($delta < 0) {
$variant->decrementReservedStock(abs($delta)); $buyable->decrementReservedStock(abs($delta));
} }
return $item->fresh(); return $item->fresh();
}); });
} }
public function removeItem(int $productVariantId): void public function removeItem(int $cartItemId): void
{ {
DB::transaction(function () use ($productVariantId): void { DB::transaction(function () use ($cartItemId): void {
/** @var CartItem $item */ /** @var CartItem $item */
$item = $this->items() $item = $this->items()
->where('producto_variante_id', $productVariantId) ->where('id', $cartItemId)
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
$variant = $this->resolveScopedVariant($productVariantId, true); $buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
$variant->decrementReservedStock($item->cantidad); $buyable->decrementReservedStock($item->cantidad);
$item->delete(); $item->delete();
}); });
} }
protected function resolveScopedVariant(int $productVariantId, bool $lockForUpdate = false): ProductVariant protected function resolveScopedBuyable(string $buyableType, int $buyableId, bool $lockForUpdate = false): Buyable
{ {
$query = ProductVariant::query() $buyableClass = $this->resolveBuyableClass($buyableType);
->whereKey($productVariantId)
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo)); if ($buyableClass === ProductVariant::class) {
$query = ProductVariant::query()
->whereKey($buyableId)
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
} else {
$query = Bundle::query()
->whereKey($buyableId)
->where('tenant_codigo', $this->tenant_codigo);
}
if ($lockForUpdate) { if ($lockForUpdate) {
$query->lockForUpdate(); $query->lockForUpdate();
} }
/** @var ProductVariant|null $variant */ $buyable = $query->first();
$variant = $query->first();
if ($variant === null) { if ($buyable === null) {
throw new NotFoundHttpException('Product variant not found for tenant.'); throw new NotFoundHttpException('Buyable not found for tenant.');
} }
return $variant; return $buyable;
}
protected function resolveBuyableClass(string $buyableType): string
{
return match ($buyableType) {
'variant', ProductVariant::class => ProductVariant::class,
'bundle', Bundle::class => Bundle::class,
default => throw new \InvalidArgumentException('Invalid buyable type'),
};
} }
} }

View File

@ -10,7 +10,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([ #[Fillable([
'cart_id', 'cart_id',
'producto_variante_id', 'buyable_id',
'buyable_type',
'cantidad', 'cantidad',
])] ])]
class CartItem extends Model class CartItem extends Model
@ -23,7 +24,8 @@ class CartItem extends Model
{ {
return [ return [
'cart_id' => 'integer', 'cart_id' => 'integer',
'producto_variante_id' => 'integer', 'buyable_id' => 'integer',
'buyable_type' => 'string',
'cantidad' => 'integer', 'cantidad' => 'integer',
]; ];
} }
@ -37,10 +39,10 @@ class CartItem extends Model
} }
/** /**
* @return BelongsTo<ProductVariant, $this> * @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/ */
public function variant(): BelongsTo public function buyable()
{ {
return $this->belongsTo(ProductVariant::class, 'producto_variante_id'); return $this->morphTo();
} }
} }

View File

@ -2,7 +2,10 @@
namespace App\Domains\Cart\Requests; namespace App\Domains\Cart\Requests;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AddCartItemRequest extends FormRequest class AddCartItemRequest extends FormRequest
{ {
@ -17,8 +20,18 @@ class AddCartItemRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'product_variant_id' => ['required', 'integer'], 'buyable_type' => ['required', 'string', Rule::in(['variant', 'bundle'])],
'buyable_id' => ['required', 'integer'],
'cantidad' => ['required', 'integer', 'min:1'], 'cantidad' => ['required', 'integer', 'min:1'],
]; ];
} }
public function mappedBuyableType(): string
{
return match ($this->input('buyable_type')) {
'variant' => ProductVariant::class,
'bundle' => Bundle::class,
default => throw new \InvalidArgumentException('Invalid buyable type'),
};
}
} }

View File

@ -18,6 +18,8 @@ class UpdateCartItemQuantityRequest extends FormRequest
{ {
return [ return [
'cantidad' => ['required', 'integer', 'min:1'], 'cantidad' => ['required', 'integer', 'min:1'],
'buyable_type' => ['prohibited'],
'buyable_id' => ['prohibited'],
]; ];
} }
} }

View File

@ -15,29 +15,15 @@ class CartItemResource extends JsonResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$variant = $this->variant; /** @var \App\Domains\Shared\Contracts\Buyable|null $buyable */
$product = $variant?->product; $buyable = $this->buyable;
$attributesText = ''; $productName = $buyable?->getName();
if ($variant && $variant->relationLoaded('definitions')) { $precio = $buyable?->getPrice();
$attributesText = $variant->definitions
->map(function ($definition) {
$attrName = $definition->productAttribute?->attribute?->nombre;
$value = $definition->value;
return $attrName ? "{$attrName}: {$value}" : $value;
})
->filter()
->implode(', ');
}
$productName = $product?->nombre;
if ($productName && $attributesText !== '') {
$productName .= " ({$attributesText})";
}
$imageUrl = null; $imageUrl = null;
if ($variant && $variant->relationLoaded('attachments')) { if ($this->buyable_type === \App\Domains\Catalog\Models\ProductVariant::class && $buyable && $buyable->relationLoaded('attachments')) {
$firstAttachment = $variant->attachments->first(); $firstAttachment = $buyable->attachments->first();
if ($firstAttachment) { if ($firstAttachment) {
$imageUrl = $firstAttachment->getTemporaryUrl(1440); $imageUrl = $firstAttachment->getTemporaryUrl(1440);
} }
@ -46,16 +32,25 @@ class CartItemResource extends JsonResource
return [ return [
'id' => $this->id, 'id' => $this->id,
'cantidad' => $this->cantidad, 'cantidad' => $this->cantidad,
'precio_unitario' => $this->formatMoney($product?->precio), 'precio_unitario' => $this->formatMoney($precio),
'product_id' => $product?->id, 'buyable_type' => $this->mapBuyableTypeToAlias($this->buyable_type),
'product_variant_id' => $this->producto_variante_id, 'buyable_id' => $this->buyable_id,
'product' => $product === null ? null : [ 'product' => $buyable === null ? null : [
'nombre' => $productName, 'nombre' => $productName,
'imagen' => $imageUrl, 'imagen' => $imageUrl,
], ],
]; ];
} }
protected function mapBuyableTypeToAlias(?string $type): string
{
return match ($type) {
\App\Domains\Catalog\Models\ProductVariant::class => 'variant',
\App\Domains\Bundle\Models\Bundle::class => 'bundle',
default => 'unknown',
};
}
protected function formatMoney(float|int|string|null $amount): string protected function formatMoney(float|int|string|null $amount): string
{ {
return number_format((float) ($amount ?? 0), 2, '.', ''); return number_format((float) ($amount ?? 0), 2, '.', '');

View File

@ -2,11 +2,12 @@
namespace App\Domains\Cart\Resources; namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\Cart;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/** /**
* @mixin \App\Domains\Cart\Models\Cart * @mixin Cart
*/ */
class CartResource extends JsonResource class CartResource extends JsonResource
{ {
@ -20,7 +21,7 @@ class CartResource extends JsonResource
: collect(); : collect();
$subtotal = $items->reduce( $subtotal = $items->reduce(
fn (float $carry, $item): float => $carry + ((float) ($item->variant?->product?->precio ?? 0) * $item->cantidad), fn (float $carry, $item): float => $carry + ((float) ($item->buyable?->getPrice() ?? 0) * $item->cantidad),
0.0, 0.0,
); );

View File

@ -2,10 +2,14 @@
namespace App\Domains\Cart\Services; namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -32,12 +36,12 @@ class CartService
/** /**
* @return array{cart: Cart, guest_token: ?string} * @return array{cart: Cart, guest_token: ?string}
*/ */
public function addItem(Tenant $tenant, Request $request, int $productVariantId, int $quantity): array public function addItem(Tenant $tenant, Request $request, string $buyableType, int $buyableId, int $quantity): array
{ {
$resolvedIdentity = $this->resolveIdentity($request, true); $resolvedIdentity = $this->resolveIdentity($request, true);
$identity = $resolvedIdentity['identity']; $identity = $resolvedIdentity['identity'];
$cart = $this->findOrCreateCart($tenant, $identity); $cart = $this->findOrCreateCart($tenant, $identity);
$cart->addItem($productVariantId, $quantity); $cart->addItem($buyableType, $buyableId, $quantity);
return [ return [
'cart' => $this->loadCart($cart), 'cart' => $this->loadCart($cart),
@ -45,20 +49,20 @@ class CartService
]; ];
} }
public function updateItemQuantity(Tenant $tenant, Request $request, int $productVariantId, int $quantity): Cart public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart
{ {
$identity = $this->requireIdentity($request); $identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity); $cart = $this->findCartOrFail($tenant, $identity);
$cart->updateItem($productVariantId, $quantity); $cart->updateItem($cartItemId, $quantity);
return $this->loadCart($cart); return $this->loadCart($cart);
} }
public function removeItem(Tenant $tenant, Request $request, int $productVariantId): Cart public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
{ {
$identity = $this->requireIdentity($request); $identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity); $cart = $this->findCartOrFail($tenant, $identity);
$cart->removeItem($productVariantId); $cart->removeItem($cartItemId);
return $this->loadCart($cart); return $this->loadCart($cart);
} }
@ -93,9 +97,16 @@ class CartService
protected function loadCart(Cart $cart): Cart protected function loadCart(Cart $cart): Cart
{ {
return $cart->fresh()->load([ return $cart->fresh()->load([
'items.variant.product', 'items.buyable' => function (MorphTo $morphTo): void {
'items.variant.definitions.productAttribute.attribute', $morphTo->morphWith([
'items.variant.attachments', ProductVariant::class => [
'product',
'definitions.productAttribute.attribute',
'attachments',
],
Bundle::class => ['items.variant'],
]);
},
]); ]);
} }
@ -104,7 +115,7 @@ class CartService
*/ */
protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array
{ {
$user = $request->user() ?? \Illuminate\Support\Facades\Auth::guard('sanctum')->user(); $user = $request->user() ?? Auth::guard('sanctum')->user();
if ($user instanceof User) { if ($user instanceof User) {
return [ return [
@ -201,5 +212,4 @@ class CartService
return Cart::query()->firstOrCreate($attributes, ['status' => 'active']); return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
} }
} }

View File

@ -8,6 +8,6 @@ Route::prefix('tenants/{tenant:codigo}')
->group(function (): void { ->group(function (): void {
Route::get('cart', [CartController::class, 'show']); Route::get('cart', [CartController::class, 'show']);
Route::post('cart/items', [CartController::class, 'addItem']); Route::post('cart/items', [CartController::class, 'addItem']);
Route::patch('cart/items/{productVariant}', [CartController::class, 'updateItemQuantity']); Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']);
Route::delete('cart/items/{productVariant}', [CartController::class, 'removeItem']); Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']);
}); });

View File

@ -0,0 +1,32 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
class CatalogController extends Controller
{
public function index(string $tenant): JsonResponse
{
$featuredGroups = FeaturedGroup::where('tenant_codigo', $tenant)
->with([
'groupItems' => fn ($query) => $query->orderBy('order'),
'groupItems.groupable' => function (MorphTo $morphTo): void {
$morphTo->morphWith([
ProductVariant::class => ['product', 'attachments', 'product.attachments'],
Bundle::class => ['items.variant.product', 'items.variant.attachments', 'items.variant.product.attachments'],
]);
},
])
->orderBy('group_order')
->get();
return response()->json(CatalogFeaturedGroupResource::collection($featuredGroups)->resolve());
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\StoreFeaturedGroupRequest;
use App\Domains\Catalog\Requests\UpdateFeaturedGroupRequest;
use App\Domains\Catalog\Resources\FeaturedGroupResource;
use App\Domains\Catalog\Services\FeaturedGroupService;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller;
use Illuminate\Http\JsonResponse;
class FeaturedGroupController extends Controller
{
public function __construct(
private readonly FeaturedGroupService $featuredGroupService
) {}
public function index(string $tenant): AnonymousResourceCollection
{
$groups = FeaturedGroup::where('tenant_codigo', $tenant)->get();
return FeaturedGroupResource::collection($groups);
}
public function store(StoreFeaturedGroupRequest $request, string $tenant): FeaturedGroupResource
{
$group = $this->featuredGroupService->createGroup($tenant, $request->validated());
return new FeaturedGroupResource($group);
}
public function show(string $tenant, FeaturedGroup $featuredGroup): FeaturedGroupResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
return new FeaturedGroupResource($featuredGroup->load('groupItems'));
}
public function update(UpdateFeaturedGroupRequest $request, string $tenant, FeaturedGroup $featuredGroup): FeaturedGroupResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$group = $this->featuredGroupService->updateGroup($featuredGroup, $request->validated());
return new FeaturedGroupResource($group);
}
public function destroy(string $tenant, FeaturedGroup $featuredGroup): JsonResponse
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$this->featuredGroupService->deleteGroup($featuredGroup);
return response()->json(null, 204);
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\GroupItem;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Requests\StoreGroupItemRequest;
use App\Domains\Catalog\Requests\UpdateGroupItemRequest;
use App\Domains\Catalog\Resources\GroupItemResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller;
class GroupItemController extends Controller
{
public function index(string $tenant, FeaturedGroup $featuredGroup): AnonymousResourceCollection
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
return GroupItemResource::collection($featuredGroup->groupItems);
}
public function store(StoreGroupItemRequest $request, string $tenant, FeaturedGroup $featuredGroup): GroupItemResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$groupableType = $request->mappedGroupableType();
$groupable = $groupableType::query()->findOrFail($request->integer('groupable_id'));
abort_if(
($groupable instanceof ProductVariant && $groupable->product()->where('tenant_codigo', $tenant)->doesntExist())
|| ($groupable instanceof Bundle && $groupable->tenant_codigo !== $tenant),
404
);
$groupItem = $featuredGroup->groupItems()->create([
'groupable_type' => $groupableType,
'groupable_id' => $groupable->getKey(),
'order' => $request->validated('order'),
]);
return new GroupItemResource($groupItem);
}
public function update(UpdateGroupItemRequest $request, string $tenant, FeaturedGroup $featuredGroup, GroupItem $groupItem): GroupItemResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant || $groupItem->featured_group_id !== $featuredGroup->id, 404);
$groupItem->update($request->validated());
return new GroupItemResource($groupItem);
}
public function destroy(string $tenant, FeaturedGroup $featuredGroup, GroupItem $groupItem): JsonResponse
{
abort_if($featuredGroup->tenant_codigo !== $tenant || $groupItem->featured_group_id !== $featuredGroup->id, 404);
$groupItem->delete();
return response()->json(null, 204);
}
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Domains\Catalog\Enums;
enum InventoryPolicy: string
{
case Tracked = 'tracked';
case Unlimited = 'unlimited';
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class FeaturedGroup extends Model
{
protected $fillable = [
'tenant_codigo',
'group_name',
'product_layout',
'group_order',
];
public function groupItems(): HasMany
{
return $this->hasMany(GroupItem::class);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class GroupItem extends Model
{
protected $table = 'group_items';
protected $fillable = [
'featured_group_id',
'groupable_type',
'groupable_id',
'order',
];
public function featuredGroup(): BelongsTo
{
return $this->belongsTo(FeaturedGroup::class);
}
public function groupable(): MorphTo
{
return $this->morphTo();
}
}

View File

@ -3,23 +3,29 @@
namespace App\Domains\Catalog\Models; namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Contracts\Buyable;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Casts\Attribute;
#[Fillable([ #[Fillable([
'producto_id', 'producto_id',
'inventory_policy',
'stock_real', 'stock_real',
'stock_reservado', 'stock_reservado',
'stock', 'stock',
'is_placeholder', 'is_placeholder',
'has_tickets',
'minimum_use_date',
'maximum_use_date',
])] ])]
class ProductVariant extends Model class ProductVariant extends Model implements Buyable
{ {
use HasFactory; use HasFactory;
@ -27,9 +33,20 @@ class ProductVariant extends Model
protected $appends = ['stock_tecnico']; protected $appends = ['stock_tecnico'];
protected $attributes = [
'inventory_policy' => 'tracked',
'stock_real' => 0,
'stock_reservado' => 0,
'cantidad_vendida' => 0,
];
protected static function booted(): void protected static function booted(): void
{ {
static::saving(function (ProductVariant $variant) { static::saving(function (ProductVariant $variant) {
if ($variant->exists && $variant->isDirty('inventory_policy')) {
throw new \InvalidArgumentException('La politica de inventario no puede modificarse.');
}
$variant->validateStock(); $variant->validateStock();
}); });
} }
@ -44,34 +61,69 @@ class ProductVariant extends Model
throw new \InvalidArgumentException('El stock reservado no puede ser negativo.'); throw new \InvalidArgumentException('El stock reservado no puede ser negativo.');
} }
if ($this->stock_reservado > $this->stock_real) { if ($this->cantidad_vendida < 0) {
throw new \InvalidArgumentException('La cantidad vendida no puede ser negativa.');
}
if ($this->tracksInventory() && $this->stock_reservado > $this->stock_real) {
throw new \InvalidArgumentException('El stock reservado no puede ser mayor que el stock real.'); throw new \InvalidArgumentException('El stock reservado no puede ser mayor que el stock real.');
} }
} }
public function incrementRealStock(int $amount): void public function tracksInventory(): bool
{
return $this->inventory_policy === InventoryPolicy::Tracked;
}
public function availableQuantity(): ?int
{
if (! $this->tracksInventory()) {
return null;
}
return $this->stock_real - $this->stock_reservado;
}
public function isAvailableForSale(): bool
{
return ! $this->tracksInventory() || $this->availableQuantity() > 0;
}
public function getPrice(): float
{
return (float) ($this->product->precio ?? 0.0);
}
public function getName(): string
{
$name = $this->product?->nombre ?? 'Producto';
if ($this->relationLoaded('definitions') && $this->definitions->isNotEmpty()) {
$definitions = $this->definitions->map(function ($def) {
$attributeName = $def->productAttribute?->attribute?->nombre;
$value = $def->value;
return $attributeName ? "{$attributeName}: {$value}" : $value;
})->filter()->implode(', ');
if ($definitions !== '') {
$name .= " ({$definitions})";
}
}
return $name;
}
public function reserveStock(int $amount): void
{ {
if ($amount < 0) { if ($amount < 0) {
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.'); throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
} }
$this->stock_real += $amount;
$this->save();
}
public function decrementRealStock(int $amount): void if ($this->tracksInventory() && $this->availableQuantity() < $amount) {
{ throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
} }
$this->stock_real -= $amount;
$this->save();
}
public function incrementReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
}
$this->stock_reservado += $amount; $this->stock_reservado += $amount;
$this->save(); $this->save();
} }
@ -85,13 +137,13 @@ class ProductVariant extends Model
$this->save(); $this->save();
} }
public function confirmReservedStock(int $amount): void public function buy(int $amount): void
{ {
if ($amount < 0) { if ($amount < 0) {
throw new \InvalidArgumentException('El monto a confirmar debe ser positivo.'); throw new \InvalidArgumentException('El monto a confirmar debe ser positivo.');
} }
if ($this->stock_real < $amount) { if ($this->tracksInventory() && $this->stock_real < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la reserva.'); throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la reserva.');
} }
@ -99,14 +151,18 @@ class ProductVariant extends Model
throw new \InvalidArgumentException('No hay suficiente stock reservado para confirmar la reserva.'); throw new \InvalidArgumentException('No hay suficiente stock reservado para confirmar la reserva.');
} }
$this->stock_real -= $amount; if ($this->tracksInventory()) {
$this->stock_real -= $amount;
}
$this->stock_reservado -= $amount; $this->stock_reservado -= $amount;
$this->cantidad_vendida += $amount;
$this->save(); $this->save();
} }
protected function stockTecnico(): Attribute protected function stockTecnico(): Attribute
{ {
return Attribute::get(fn () => $this->stock_real - $this->stock_reservado); return Attribute::get(fn (): ?int => $this->availableQuantity());
} }
protected function stock(): Attribute protected function stock(): Attribute
@ -123,9 +179,14 @@ class ProductVariant extends Model
{ {
return [ return [
'producto_id' => 'integer', 'producto_id' => 'integer',
'inventory_policy' => InventoryPolicy::class,
'stock_real' => 'integer', 'stock_real' => 'integer',
'stock_reservado' => 'integer', 'stock_reservado' => 'integer',
'cantidad_vendida' => 'integer',
'is_placeholder' => 'boolean', 'is_placeholder' => 'boolean',
'has_tickets' => 'boolean',
'minimum_use_date' => 'datetime',
'maximum_use_date' => 'datetime',
]; ];
} }
@ -157,4 +218,12 @@ class ProductVariant extends Model
'attachment_id' 'attachment_id'
)->withTimestamps(); )->withTimestamps();
} }
/**
* @return MorphMany<GroupItem, $this>
*/
public function groupItems(): MorphMany
{
return $this->morphMany(GroupItem::class, 'groupable');
}
} }

View File

@ -0,0 +1,22 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreFeaturedGroupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'group_name' => ['required', 'string', 'max:255'],
'product_layout' => ['required', 'string', 'in:row,column_with_image,column_with_cart,vertical_with_image,vertical_with_cart'],
'group_order' => ['nullable', 'integer'],
];
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreGroupItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'groupable_type' => ['required', 'string', Rule::in(['variant', 'bundle'])],
'groupable_id' => [
'required',
'integer',
Rule::exists(match ($this->input('groupable_type')) {
'bundle' => 'bundles',
default => 'productos_variantes',
}, 'id'),
],
'order' => ['nullable', 'integer'],
];
}
public function mappedGroupableType(): string
{
return match ($this->input('groupable_type')) {
'variant' => ProductVariant::class,
'bundle' => Bundle::class,
default => throw new \InvalidArgumentException('Invalid groupable type'),
};
}
}

View File

@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Requests; namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule; use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -32,6 +33,8 @@ class StoreProductRequest extends FormRequest
'descripcion' => ['nullable', 'string'], 'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'], 'precio' => ['required', 'numeric', 'min:0'],
'stock' => ['sometimes', 'integer', 'min:0'], 'stock' => ['sometimes', 'integer', 'min:0'],
'inventory_policy' => ['sometimes', Rule::enum(InventoryPolicy::class)],
'cantidad_vendida' => ['prohibited'],
'attribute_ids' => ['sometimes', 'array'], 'attribute_ids' => ['sometimes', 'array'],
'attribute_ids.*' => [ 'attribute_ids.*' => [
'required', 'required',
@ -41,7 +44,7 @@ class StoreProductRequest extends FormRequest
), ),
], ],
'images' => ['sometimes', 'nullable', 'array'], 'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()], 'images.*' => ['required', new ImageOrBase64Rule],
]; ];
} }
} }

View File

@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Requests; namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule; use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -20,6 +21,8 @@ class StoreProductVariantRequest extends FormRequest
{ {
return [ return [
'stock' => ['sometimes', 'integer', 'min:0'], 'stock' => ['sometimes', 'integer', 'min:0'],
'inventory_policy' => ['sometimes', Rule::enum(InventoryPolicy::class)],
'cantidad_vendida' => ['prohibited'],
'definitions' => ['sometimes', 'array'], 'definitions' => ['sometimes', 'array'],
'definitions.*.products_attribute_id' => [ 'definitions.*.products_attribute_id' => [
'required', 'required',
@ -31,7 +34,10 @@ class StoreProductVariantRequest extends FormRequest
], ],
'definitions.*.value' => ['nullable', 'string'], 'definitions.*.value' => ['nullable', 'string'],
'images' => ['sometimes', 'nullable', 'array'], 'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()], 'images.*' => ['required', new ImageOrBase64Rule],
'has_tickets' => ['boolean'],
'minimum_use_date' => ['nullable', 'date'],
'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'],
]; ];
} }
} }

View File

@ -0,0 +1,22 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateFeaturedGroupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'group_name' => ['sometimes', 'string', 'max:255'],
'product_layout' => ['sometimes', 'string', 'in:row,column_with_image,column_with_cart,vertical_with_image,vertical_with_cart'],
'group_order' => ['nullable', 'integer'],
];
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateGroupItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'order' => ['sometimes', 'integer'],
];
}
}

View File

@ -20,6 +20,8 @@ class UpdateProductVariantRequest extends FormRequest
{ {
return [ return [
'stock' => ['sometimes', 'integer', 'min:0'], 'stock' => ['sometimes', 'integer', 'min:0'],
'inventory_policy' => ['prohibited'],
'cantidad_vendida' => ['prohibited'],
'definitions' => ['sometimes', 'array'], 'definitions' => ['sometimes', 'array'],
'definitions.*.products_attribute_id' => [ 'definitions.*.products_attribute_id' => [
'required', 'required',
@ -31,7 +33,10 @@ class UpdateProductVariantRequest extends FormRequest
], ],
'definitions.*.value' => ['nullable', 'string'], 'definitions.*.value' => ['nullable', 'string'],
'images' => ['sometimes', 'nullable', 'array'], 'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()], 'images.*' => ['required', new ImageOrBase64Rule],
'has_tickets' => ['boolean'],
'minimum_use_date' => ['nullable', 'date'],
'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'],
]; ];
} }
} }

View File

@ -0,0 +1,68 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\GroupItem;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CatalogFeaturedGroupResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->group_name,
'layout' => $this->product_layout,
'group_order' => $this->group_order,
'items' => $this->whenLoaded('groupItems', function () use ($request) {
return $this->groupItems->map(function (GroupItem $groupItem) use ($request) {
$groupable = $groupItem->groupable;
if ($groupable instanceof Bundle) {
return [
'id' => $groupable->id,
'groupable_type' => 'bundle',
'groupable_id' => $groupable->id,
'nombre' => $groupable->nombre,
'descripcion' => $groupable->descripcion,
'precio' => $groupable->precio,
'cantidad_maxima' => $groupable->stock_tecnico,
'items' => $groupable->items->map(fn ($item) => [
'cantidad' => $item->cantidad,
'variant' => (new ProductVariantResource($item->variant))->toArray($request),
])->values(),
];
}
if (! $groupable instanceof ProductVariant) {
return null;
}
$variantResource = (new ProductVariantResource($groupable))->toArray($request);
$variantResource['groupable_type'] = 'variant';
$variantResource['groupable_id'] = $groupable->id;
if ($this->product_layout === 'row' || $this->product_layout === 'column_with_cart' || $this->product_layout === 'vertical_with_cart') {
// Devuelve el Product Variant Resource completo como pidio el usuario
// "Que todo devuelva el product variant resource"
return $variantResource;
}
// Para column_with_image o vertical_with_image
if ($this->product_layout === 'column_with_image' || $this->product_layout === 'vertical_with_image') {
if (isset($variantResource['images']) && count($variantResource['images']) > 0) {
$variantResource['images'] = [$variantResource['images'][0]];
}
return $variantResource;
}
return $variantResource;
})->filter()->values();
}),
];
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class FeaturedGroupResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_codigo' => $this->tenant_codigo,
'group_name' => $this->group_name,
'product_layout' => $this->product_layout,
'group_order' => $this->group_order,
'group_items' => GroupItemResource::collection($this->whenLoaded('groupItems')),
];
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class GroupItemResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'featured_group_id' => $this->featured_group_id,
'groupable_type' => match ($this->groupable_type) {
ProductVariant::class => 'variant',
Bundle::class => 'bundle',
default => 'unknown',
},
'groupable_id' => $this->groupable_id,
'order' => $this->order,
];
}
}

View File

@ -26,6 +26,7 @@ class ProductResource extends JsonResource
'precio' => $this->precio, 'precio' => $this->precio,
'category' => $this->whenLoaded('category', fn () => $this->category?->nombre), 'category' => $this->whenLoaded('category', fn () => $this->category?->nombre),
'brand' => $this->whenLoaded('brand', fn () => $this->brand?->nombre), 'brand' => $this->whenLoaded('brand', fn () => $this->brand?->nombre),
'images' => $this->whenLoaded('attachments', fn () => $this->attachments 'images' => $this->whenLoaded('attachments', fn () => $this->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values() ->values()
@ -34,7 +35,9 @@ class ProductResource extends JsonResource
'variants_map' => $this->whenLoaded('variants', fn () => $this->variants 'variants_map' => $this->whenLoaded('variants', fn () => $this->variants
->map(fn ($variant) => [ ->map(fn ($variant) => [
'variant_id' => $variant->id, 'variant_id' => $variant->id,
'inventory_policy' => $variant->inventory_policy->value,
'cantidad_maxima' => $variant->stock_tecnico, 'cantidad_maxima' => $variant->stock_tecnico,
'cantidad_vendida' => $variant->cantidad_vendida,
'attributes' => $variant->definitions 'attributes' => $variant->definitions
->mapWithKeys(fn ($definition) => [ ->mapWithKeys(fn ($definition) => [
$definition->productAttribute?->attribute?->codigo => $definition->value, $definition->productAttribute?->attribute?->codigo => $definition->value,

View File

@ -18,7 +18,12 @@ class ProductVariantResource extends JsonResource
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'inventory_policy' => $this->inventory_policy->value,
'cantidad_maxima' => $this->stock_tecnico, 'cantidad_maxima' => $this->stock_tecnico,
'cantidad_vendida' => $this->cantidad_vendida,
'has_tickets' => $this->has_tickets,
'minimum_use_date' => $this->minimum_use_date,
'maximum_use_date' => $this->maximum_use_date,
'product' => ProductResource::make($this->whenLoaded('product')), 'product' => ProductResource::make($this->whenLoaded('product')),
'definitions' => $this->whenLoaded( 'definitions' => $this->whenLoaded(
'definitions', 'definitions',

View File

@ -0,0 +1,25 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Models\FeaturedGroup;
class FeaturedGroupService
{
public function createGroup(string $tenantCodigo, array $data): FeaturedGroup
{
$data['tenant_codigo'] = $tenantCodigo;
return FeaturedGroup::create($data);
}
public function updateGroup(FeaturedGroup $group, array $data): FeaturedGroup
{
$group->update($data);
return $group;
}
public function deleteGroup(FeaturedGroup $group): bool|null
{
return $group->delete();
}
}

View File

@ -3,6 +3,7 @@
namespace App\Domains\Catalog\Services; namespace App\Domains\Catalog\Services;
use App\Domains\Attachable\Services\AttachmentService; use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Catalog\Models\ProductVariant;
@ -28,7 +29,8 @@ class ProductService
$attributeIds = $data['attribute_ids'] ?? []; $attributeIds = $data['attribute_ids'] ?? [];
$images = $data['images'] ?? []; $images = $data['images'] ?? [];
$stock = $data['stock'] ?? 0; $stock = $data['stock'] ?? 0;
unset($data['attribute_ids'], $data['images'], $data['stock']); $inventoryPolicy = $data['inventory_policy'] ?? InventoryPolicy::Tracked->value;
unset($data['attribute_ids'], $data['images'], $data['stock'], $data['inventory_policy']);
/** @var Product $product */ /** @var Product $product */
$product = Product::query()->create([ $product = Product::query()->create([
@ -45,6 +47,7 @@ class ProductService
// Create default variant with stock // Create default variant with stock
$this->createVariant($product, [ $this->createVariant($product, [
'stock' => $stock, 'stock' => $stock,
'inventory_policy' => $inventoryPolicy,
'is_placeholder' => true, 'is_placeholder' => true,
'definitions' => [], 'definitions' => [],
]); ]);
@ -179,6 +182,7 @@ class ProductService
if ($product->variants()->count() === 0) { if ($product->variants()->count() === 0) {
$product->createVariant([ $product->createVariant([
'stock' => 0, 'stock' => 0,
'inventory_policy' => InventoryPolicy::Tracked->value,
'is_placeholder' => true, 'is_placeholder' => true,
'definitions' => [], 'definitions' => [],
]); ]);
@ -327,13 +331,13 @@ class ProductService
$selectedVariant = $variantId !== null $selectedVariant = $variantId !== null
? $product->variants->firstWhere('id', $variantId) ? $product->variants->firstWhere('id', $variantId)
: $product->variants->first(fn (ProductVariant $variant) => $variant->stock_tecnico > 0); : $product->variants->first(fn (ProductVariant $variant) => $variant->isAvailableForSale());
if ($variantId !== null && $selectedVariant === null) { if ($variantId !== null && $selectedVariant === null) {
throw new NotFoundHttpException('Product variant not found for product.'); throw new NotFoundHttpException('Product variant not found for product.');
} }
if ($variantId !== null && $selectedVariant->stock_tecnico <= 0) { if ($variantId !== null && ! $selectedVariant->isAvailableForSale()) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'variant_id' => 'La variante seleccionada no tiene stock.', 'variant_id' => 'La variante seleccionada no tiene stock.',
]); ]);

View File

@ -1,13 +1,17 @@
<?php <?php
use App\Domains\Catalog\Controllers\BrandController;
use App\Domains\Catalog\Controllers\CategoryController;
use App\Domains\Catalog\Controllers\ProductController;
use App\Domains\Catalog\Controllers\AttributeController; use App\Domains\Catalog\Controllers\AttributeController;
use App\Domains\Catalog\Controllers\BrandController;
use App\Domains\Catalog\Controllers\CatalogController;
use App\Domains\Catalog\Controllers\CategoryController;
use App\Domains\Catalog\Controllers\FeaturedGroupController;
use App\Domains\Catalog\Controllers\GroupItemController;
use App\Domains\Catalog\Controllers\ProductController;
use App\Domains\Catalog\Controllers\ProductVariantController; use App\Domains\Catalog\Controllers\ProductVariantController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->group(function (): void { Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
Route::get('catalog', [CatalogController::class, 'index']);
Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']); Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']);
Route::apiResource('categorias', CategoryController::class)->parameters(['categorias' => 'categoria']); Route::apiResource('categorias', CategoryController::class)->parameters(['categorias' => 'categoria']);
Route::apiResource('productos', ProductController::class); Route::apiResource('productos', ProductController::class);
@ -17,4 +21,11 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
'productos' => 'producto', 'productos' => 'producto',
'variants' => 'productVariant', 'variants' => 'productVariant',
]); ]);
Route::apiResource('featured-groups', FeaturedGroupController::class);
Route::apiResource('featured-groups.items', GroupItemController::class)
->only(['index', 'store', 'update', 'destroy'])
->parameters([
'featured-groups' => 'featuredGroup',
'items' => 'groupItem',
]);
}); });

View File

@ -54,7 +54,7 @@ class TelepagosWebhookService
$dni = substr($cuit, 2, -1); $dni = substr($cuit, 2, -1);
$compra = Purchase::where('tenant_codigo', $tenantCodigo) $compra = Purchase::where('tenant_codigo', $tenantCodigo)
->whereRaw("REPLACE(dni, '.', '') = ?", [$dni]) ->where('transfer_payer_dni', $dni)
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->where('payment_method', 'transfer') ->where('payment_method', 'transfer')
->where('total', $amount) ->where('total', $amount)

View File

@ -0,0 +1,50 @@
<?php
namespace App\Domains\Menu\Controllers;
use App\Domains\Menu\Models\Menu;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class MenuController extends Controller
{
public function index(): JsonResponse
{
$menues = Menu::all();
return response()->json($menues);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'code' => 'required|string|unique:menues,code',
'route' => 'required|string',
]);
$menu = Menu::create($validated);
return response()->json($menu, 201);
}
public function show(Menu $menu): JsonResponse
{
return response()->json($menu);
}
public function update(Request $request, Menu $menu): JsonResponse
{
$validated = $request->validate([
'code' => 'sometimes|required|string|unique:menues,code,' . $menu->id,
'route' => 'sometimes|required|string',
]);
$menu->update($validated);
return response()->json($menu);
}
public function destroy(Menu $menu): JsonResponse
{
$menu->delete();
return response()->json(null, 204);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Domains\Menu\Models;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Menu extends Model
{
use HasFactory;
protected $table = 'menues';
protected $fillable = [
'code',
'route',
];
public function tenants(): BelongsToMany
{
return $this->belongsToMany(
Tenant::class,
'tenant_menues',
'menu_code',
'tenant_codigo',
'code',
'codigo'
)->withTimestamps();
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Domains\Menu\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
class TenantMenu extends Pivot
{
protected $table = 'tenant_menues';
}

View File

@ -0,0 +1,6 @@
<?php
use App\Domains\Menu\Controllers\MenuController;
use Illuminate\Support\Facades\Route;
Route::apiResource('menues', MenuController::class);

View File

@ -2,14 +2,20 @@
namespace App\Domains\Purchase\Controllers; namespace App\Domains\Purchase\Controllers;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Integration\Services\TelepagosIntegrationService;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Purchase\Requests\StorePurchaseRequest; use App\Domains\Purchase\Requests\StorePurchaseRequest;
use App\Domains\Purchase\Resources\PurchaseResource; use App\Domains\Purchase\Resources\PurchaseResource;
use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -19,7 +25,6 @@ class PurchaseController extends Controller
{ {
return PurchaseResource::collection( return PurchaseResource::collection(
Purchase::query() Purchase::query()
->with(['items.variant.product', 'items.variant.definitions.productAttribute.attribute'])
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->where('user_id', $request->user()->id) ->where('user_id', $request->user()->id)
->when($request->query('status'), function ($query, $status) { ->when($request->query('status'), function ($query, $status) {
@ -47,24 +52,36 @@ class PurchaseController extends Controller
{ {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make( $compra->loadMissing(['items', 'cart.items']);
$compra->loadMissing(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']) $this->loadBuyables($compra->items);
);
if ($compra->cart !== null) {
$this->loadBuyables($compra->cart->items);
}
return PurchaseResource::make($compra);
} }
public function paymentIntent(\App\Domains\Purchase\Requests\PaymentIntentRequest $request, Tenant $tenant, Purchase $compra): JsonResponse public function paymentIntent(PaymentIntentRequest $request, Tenant $tenant, Purchase $compra): JsonResponse
{ {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$method = $request->validated('method'); $method = $request->validated('method');
$totalAmount = $compra->calculateCurrentTotalAmount(); $totalAmount = $compra->calculateCurrentTotalAmount();
$compra->update([ $purchaseUpdate = [
'payment_method' => $method, 'payment_method' => $method,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => $totalAmount, 'total' => $totalAmount,
]); ];
if ($method === 'transfer') { if ($method === 'transfer') {
$telepagosService = new \App\Domains\Integration\Services\TelepagosIntegrationService(); $purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'));
}
$compra->update($purchaseUpdate);
if ($method === 'transfer') {
$telepagosService = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo); $telepagosService->forTenant($tenant->codigo);
try { try {
@ -82,13 +99,13 @@ class PurchaseController extends Controller
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'message' => 'Error getting account info: ' . $e->getMessage() 'message' => 'Error getting account info: '.$e->getMessage(),
], 500); ], 500);
} }
} }
if ($method === 'qr') { if ($method === 'qr') {
$telepagosService = new \App\Domains\Integration\Services\TelepagosIntegrationService(); $telepagosService = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo); $telepagosService->forTenant($tenant->codigo);
try { try {
@ -101,11 +118,11 @@ class PurchaseController extends Controller
$telepagosQr = $compra->telepagosQr()->create([ $telepagosQr = $compra->telepagosQr()->create([
'qr_order_id' => (string) ($qrResponse['qr_order_id'] ?? ''), 'qr_order_id' => (string) ($qrResponse['qr_order_id'] ?? ''),
'qr_code' => $qrResponse['qr_code'] ?? '', 'qr_code' => $qrResponse['qr_code'] ?? '',
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'message' => 'Error generating QR: ' . $e->getMessage() 'message' => 'Error generating QR: '.$e->getMessage(),
], 500); ], 500);
} }
@ -140,4 +157,23 @@ class PurchaseController extends Controller
return $purchase; return $purchase;
} }
/**
* @return list<string>
*/
protected function loadBuyables(Collection $items): void
{
$items->load([
'buyable' => function (MorphTo $morphTo): void {
$morphTo->morphWith([
ProductVariant::class => [
'product',
'definitions.productAttribute.attribute',
'attachments',
],
Bundle::class => ['items.variant'],
]);
},
]);
}
} }

View File

@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([ #[Fillable([
'cart_id', 'cart_id',
@ -19,6 +20,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'payment_method', 'payment_method',
'total', 'total',
'dni', 'dni',
'transfer_payer_dni',
'telefono', 'telefono',
'nombre_apellido', 'nombre_apellido',
'email', 'email',
@ -28,9 +30,13 @@ class Purchase extends Model
use HasFactory; use HasFactory;
public const STATUS_CREATED = 'created'; public const STATUS_CREATED = 'created';
public const STATUS_PENDING_PAYMENT = 'pending_payment'; public const STATUS_PENDING_PAYMENT = 'pending_payment';
public const STATUS_PAID = 'paid'; public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled'; public const STATUS_CANCELLED = 'cancelled';
public const STATUS_REJECTED = 'rejected'; public const STATUS_REJECTED = 'rejected';
protected $table = 'compras'; protected $table = 'compras';
@ -77,7 +83,7 @@ class Purchase extends Model
} }
/** /**
* @return \Illuminate\Database\Eloquent\Relations\HasOne<TelepagosQr, $this> * @return HasOne<TelepagosQr, $this>
*/ */
public function telepagosQr() public function telepagosQr()
{ {
@ -113,7 +119,7 @@ class Purchase extends Model
$cart = $this->relationLoaded('cart') $cart = $this->relationLoaded('cart')
? $this->getRelation('cart') ? $this->getRelation('cart')
: $this->cart()->with('items.variant.product')->first(); : $this->cart()->with('items.buyable')->first();
if (! $cart) { if (! $cart) {
return 0.0; return 0.0;

View File

@ -10,7 +10,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([ #[Fillable([
'compra_id', 'compra_id',
'producto_variante_id', 'buyable_id',
'buyable_type',
'cantidad', 'cantidad',
'precio_unitario', 'precio_unitario',
'discount_total', 'discount_total',
@ -27,7 +28,8 @@ class PurchaseItem extends Model
{ {
return [ return [
'compra_id' => 'integer', 'compra_id' => 'integer',
'producto_variante_id' => 'integer', 'buyable_id' => 'integer',
'buyable_type' => 'string',
'cantidad' => 'integer', 'cantidad' => 'integer',
'precio_unitario' => 'decimal:2', 'precio_unitario' => 'decimal:2',
'discount_total' => 'decimal:2', 'discount_total' => 'decimal:2',
@ -45,10 +47,10 @@ class PurchaseItem extends Model
} }
/** /**
* @return BelongsTo<ProductVariant, $this> * @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/ */
public function variant(): BelongsTo public function buyable()
{ {
return $this->belongsTo(ProductVariant::class, 'producto_variante_id'); return $this->morphTo();
} }
} }

View File

@ -19,6 +19,11 @@ class PaymentIntentRequest extends FormRequest
{ {
return [ return [
'method' => ['required', 'string', Rule::in(['qr', 'transfer'])], 'method' => ['required', 'string', Rule::in(['qr', 'transfer'])],
'transfer_payer_dni' => [
Rule::requiredIf(fn (): bool => $this->input('method') === 'transfer'),
'string',
'regex:/^\d{7,8}$/',
],
]; ];
} }
} }

View File

@ -2,12 +2,16 @@
namespace App\Domains\Purchase\Resources; namespace App\Domains\Purchase\Resources;
use App\Domains\Catalog\Resources\ProductVariantDefinitionResource; use App\Domains\Bundle\Models\Bundle;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Shared\Contracts\Buyable;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/** /**
* @mixin \App\Domains\Purchase\Models\PurchaseItem * @mixin PurchaseItem|CartItem
*/ */
class PurchaseItemResource extends JsonResource class PurchaseItemResource extends JsonResource
{ {
@ -16,26 +20,112 @@ class PurchaseItemResource extends JsonResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$variant = $this->variant; /** @var Buyable|null $buyable */
$product = $variant?->product; $buyable = $this->buyable;
$quantity = (int) ($this->cantidad ?? 0);
$unitPrice = $this->resolveUnitPrice();
$lineTotal = $this->resolveLineTotal($unitPrice, $quantity);
$variant = $buyable instanceof ProductVariant ? $buyable : null;
$imageUrl = null;
$attributes = [];
if ($this->buyable_type === ProductVariant::class && $buyable) {
$imageUrl = $this->resolveImageUrl($buyable);
$attributes = $this->resolveAttributes($buyable);
}
return [ return [
'id' => $this->id, 'id' => $this->id,
'cantidad' => $this->cantidad, 'quantity' => $quantity,
'precio_unitario' => $this->formatMoney($this->precio_unitario), 'unit_price' => $this->formatMoney($unitPrice),
'total' => $this->formatMoney($this->total), 'line_total' => $this->formatMoney($lineTotal),
'product' => $product === null ? null : [ 'buyable_type' => $this->mapBuyableTypeToAlias($this->buyable_type),
'id' => $product->id, 'buyable_id' => $this->buyable_id,
'nombre' => $product->nombre, 'product' => $variant === null ? null : [
'slug' => $product->slug, 'id' => $variant->product?->id,
'nombre' => $variant->product?->nombre,
'slug' => $variant->product?->slug,
'imagen' => $imageUrl,
], ],
'variant' => $variant === null ? null : [ 'variant' => $variant === null ? null : [
'id' => $variant->id, 'id' => $variant->id,
'definitions' => ProductVariantDefinitionResource::collection($variant->definitions), 'attributes' => $attributes,
],
'item_details' => $buyable === null ? null : [
'nombre' => $buyable->getName(),
'imagen' => $imageUrl,
'attributes' => $attributes,
], ],
]; ];
} }
protected function mapBuyableTypeToAlias(?string $type): string
{
return match ($type) {
ProductVariant::class => 'variant',
Bundle::class => 'bundle',
default => 'unknown',
};
}
protected function resolveUnitPrice(): float
{
if ($this->resource instanceof PurchaseItem) {
return (float) ($this->precio_unitario ?? 0);
}
if ($this->resource instanceof CartItem) {
return (float) ($this->buyable?->getPrice() ?? 0);
}
return 0.0;
}
protected function resolveLineTotal(float $unitPrice, int $quantity): float
{
if ($this->resource instanceof PurchaseItem) {
return (float) ($this->total ?? 0);
}
return $unitPrice * $quantity;
}
protected function resolveImageUrl($buyable): ?string
{
if (! $buyable->relationLoaded('attachments')) {
return null;
}
$attachment = $buyable->attachments->first();
if ($attachment === null) {
return null;
}
return $attachment->getTemporaryUrl(1440);
}
/**
* @return array<int, array{name: string, value: mixed}>
*/
protected function resolveAttributes($buyable): array
{
if (! $buyable->relationLoaded('definitions')) {
return [];
}
return $buyable->definitions
->map(function ($definition): array {
return [
'name' => (string) ($definition->productAttribute?->attribute?->nombre ?? ''),
'value' => $definition->value,
];
})
->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null)
->values()
->all();
}
protected function formatMoney(float|int|string|null $amount): ?string protected function formatMoney(float|int|string|null $amount): ?string
{ {
if ($amount === null) { if ($amount === null) {

View File

@ -2,11 +2,15 @@
namespace App\Domains\Purchase\Resources; namespace App\Domains\Purchase\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/** /**
* @mixin \App\Domains\Purchase\Models\Purchase * @mixin Purchase
*/ */
class PurchaseResource extends JsonResource class PurchaseResource extends JsonResource
{ {
@ -15,20 +19,18 @@ class PurchaseResource extends JsonResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$items = $this->resource->relationLoaded('items') [$items, $itemsSource] = $this->resolveItems();
? $this->resource->getRelation('items')
: collect();
$subtotal = $items->isNotEmpty() $subtotal = $items->isNotEmpty()
? $items->reduce( ? $items->reduce(
fn (float $carry, $item): float => $carry + ((float) $item->precio_unitario * $item->cantidad), fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemSubtotal($item),
0.0, 0.0,
) )
: (float) ($this->total ?? 0); : (float) ($this->total ?? 0);
$total = $items->isNotEmpty() $total = $items->isNotEmpty()
? $items->reduce( ? $items->reduce(
fn (float $carry, $item): float => $carry + (float) $item->total, fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemTotal($item),
0.0, 0.0,
) )
: (float) ($this->total ?? 0); : (float) ($this->total ?? 0);
@ -38,18 +40,67 @@ class PurchaseResource extends JsonResource
'cart_id' => $this->cart_id, 'cart_id' => $this->cart_id,
'tenant_codigo' => $this->tenant_codigo, 'tenant_codigo' => $this->tenant_codigo,
'user_id' => $this->user_id, 'user_id' => $this->user_id,
'created_at' => $this->created_at,
'status' => $this->status, 'status' => $this->status,
'payment_method' => $this->payment_method, 'payment_method' => $this->payment_method,
'dni' => $this->dni, 'dni' => $this->dni,
'transfer_payer_dni' => $this->transfer_payer_dni,
'telefono' => $this->telefono, 'telefono' => $this->telefono,
'nombre_apellido' => $this->nombre_apellido, 'nombre_apellido' => $this->nombre_apellido,
'email' => $this->email, 'email' => $this->email,
'items_source' => $itemsSource,
'items' => PurchaseItemResource::collection($items), 'items' => PurchaseItemResource::collection($items),
'subtotal' => $this->formatMoney($subtotal), 'subtotal' => $this->formatMoney($subtotal),
'total' => $this->formatMoney($total), 'total' => $this->formatMoney($total),
]; ];
} }
/**
* @return array{0: Collection<int, PurchaseItem|CartItem>, 1: string|null}
*/
protected function resolveItems(): array
{
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'];
}
protected function resolveItemSubtotal(PurchaseItem|CartItem $item): float
{
if ($item instanceof PurchaseItem) {
return (float) $item->precio_unitario * $item->cantidad;
}
return (float) ($item->buyable?->getPrice() ?? 0) * $item->cantidad;
}
protected function resolveItemTotal(PurchaseItem|CartItem $item): float
{
if ($item instanceof PurchaseItem) {
return (float) ($item->total ?? 0);
}
return $this->resolveItemSubtotal($item);
}
protected function formatMoney(float|int|string|null $amount): string protected function formatMoney(float|int|string|null $amount): string
{ {
return number_format((float) ($amount ?? 0), 2, '.', ''); return number_format((float) ($amount ?? 0), 2, '.', '');

View File

@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services;
use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem; use App\Domains\Cart\Models\CartItem;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
@ -29,7 +30,7 @@ class CheckoutService
]); ]);
} }
$cartItems->load('variant.product'); $cartItems->load('buyable');
$cart->setRelation('items', $cartItems); $cart->setRelation('items', $cartItems);
$totalAmount = $cart->getTotalAmount(); $totalAmount = $cart->getTotalAmount();
@ -63,7 +64,7 @@ class CheckoutService
$purchase->save(); $purchase->save();
} }
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']); return $purchase->load(['items.buyable']);
}); });
} }
@ -82,7 +83,7 @@ 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], true)) {
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']); return $purchase->load(['items.buyable']);
} }
$purchase->update([ $purchase->update([
@ -90,13 +91,22 @@ class CheckoutService
'total' => $purchase->calculateCurrentTotalAmount(), 'total' => $purchase->calculateCurrentTotalAmount(),
]); ]);
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']); return $purchase->load(['items.buyable']);
}); });
} }
public function confirmPurchase(Purchase $purchase): void public function confirmPurchase(Purchase $purchase): void
{ {
DB::transaction(function () use ($purchase): void { DB::transaction(function () use ($purchase): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if ($purchase->items()->exists()) {
return;
}
/** @var Cart|null $cart */ /** @var Cart|null $cart */
$cart = $purchase->cart()->lockForUpdate()->first(); $cart = $purchase->cart()->lockForUpdate()->first();
@ -114,51 +124,42 @@ class CheckoutService
]); ]);
} }
if ($purchase->items()->exists()) { $cartItems->load('buyable');
return; $this->verifyTenantBuyables($purchase->tenant, $cartItems);
} $purchaseItemsPayload = $this->buildPurchaseItemsPayload($cartItems);
$cartItems->load('variant.product');
$variants = $this->resolveTenantVariants($purchase->tenant, $cartItems);
$purchaseItemsPayload = $this->buildPurchaseItemsPayload($cartItems, $variants);
$purchase->items()->createMany($purchaseItemsPayload); $purchase->items()->createMany($purchaseItemsPayload);
$this->completeCartConversion($cart, $cartItems, $variants); $this->completeCartConversion($cart, $cartItems);
}); });
} }
/** protected function verifyTenantBuyables(Tenant $tenant, Collection $cartItems): void
* @return Collection<int, ProductVariant>
*/
protected function resolveTenantVariants(Tenant $tenant, Collection $cartItems): Collection
{ {
$variantIds = $cartItems foreach ($cartItems as $item) {
->pluck('producto_variante_id') $buyable = $item->buyable;
->map(static fn (mixed $id): int => (int) $id) if ($buyable === null) {
->unique() throw ValidationException::withMessages([
->values(); 'cart_id' => 'One or more buyables could not be loaded.',
]);
}
/** @var Collection<int, ProductVariant> $variants */ if ($item->buyable_type === ProductVariant::class && $buyable->product->tenant_codigo !== $tenant->codigo) {
$variants = ProductVariant::query() throw ValidationException::withMessages([
->with('product') 'cart_id' => 'One or more product variants do not belong to the tenant.',
->whereIn('id', $variantIds) ]);
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo)) }
->lockForUpdate()
->get()
->keyBy('id');
if ($variants->count() !== $variantIds->count()) { if ($item->buyable_type === Bundle::class && $buyable->tenant_codigo !== $tenant->codigo) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cart_id' => 'One or more product variants do not belong to the tenant.', 'cart_id' => 'One or more bundles do not belong to the tenant.',
]); ]);
}
} }
return $variants;
} }
/** /**
* @param Collection<int, CartItem> $cartItems * @param Collection<int, CartItem> $cartItems
* @return \Illuminate\Support\Collection<int, ProductVariant> * @return Collection<int, ProductVariant>
*/ */
protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart
{ {
@ -182,20 +183,19 @@ class CheckoutService
/** /**
* @param Collection<int, CartItem> $cartItems * @param Collection<int, CartItem> $cartItems
* @param Collection<int, ProductVariant> $variants
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
protected function buildPurchaseItemsPayload(Collection $cartItems, Collection $variants): array protected function buildPurchaseItemsPayload(Collection $cartItems): array
{ {
return $cartItems return $cartItems
->map(function (CartItem $item) use ($variants): array { ->map(function (CartItem $item): array {
/** @var ProductVariant $variant */ $buyable = $item->buyable;
$variant = $variants->get((int) $item['producto_variante_id']);
$quantity = (int) $item['cantidad']; $quantity = (int) $item['cantidad'];
$unitPrice = (float) ($variant->product?->precio ?? 0); $unitPrice = $buyable?->getPrice() ?? 0;
return [ return [
'producto_variante_id' => $variant->getKey(), 'buyable_type' => $item->buyable_type,
'buyable_id' => $item->buyable_id,
'cantidad' => $quantity, 'cantidad' => $quantity,
'precio_unitario' => $unitPrice, 'precio_unitario' => $unitPrice,
'discount_total' => null, 'discount_total' => null,
@ -208,17 +208,15 @@ class CheckoutService
/** /**
* @param Collection<int, CartItem> $cartItems * @param Collection<int, CartItem> $cartItems
* @param Collection<int, ProductVariant> $variants
*/ */
protected function completeCartConversion(Cart $cart, Collection $cartItems, Collection $variants): void protected function completeCartConversion(Cart $cart, Collection $cartItems): void
{ {
foreach ($cartItems as $item) { foreach ($cartItems as $item) {
/** @var ProductVariant $variant */ $buyable = $item->buyable;
$variant = $variants->get((int) $item->producto_variante_id);
$quantity = (int) $item->cantidad; $quantity = (int) $item->cantidad;
try { try {
$variant->confirmReservedStock($quantity); $buyable->buy($quantity);
} catch (\InvalidArgumentException $exception) { } catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cart_id' => 'The selected cart has inconsistent stock state.', 'cart_id' => 'The selected cart has inconsistent stock state.',

View File

@ -0,0 +1,20 @@
<?php
namespace App\Domains\Shared\Contracts;
interface Buyable
{
public function getPrice(): float;
public function getName(): string;
public function availableQuantity(): ?int;
public function reserveStock(int $amount): void;
public function decrementReservedStock(int $amount): void;
public function buy(int $amount): void;
public function validateStock(): void;
}

View File

@ -15,7 +15,7 @@ class BootstrapTenantController extends Controller
$dominio = $request->validated('dominio'); $dominio = $request->validated('dominio');
return TenantResource::make( return TenantResource::make(
Tenant::query()->with(['headerLogo', 'footerLogo'])->where('dominio', $dominio)->firstOrFail() Tenant::query()->with(['headerLogo', 'footerLogo', 'menues'])->where('dominio', $dominio)->firstOrFail()
); );
} }
} }

View File

@ -22,6 +22,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'footer_bg_color', 'footer_bg_color',
'header_logo_id', 'header_logo_id',
'footer_logo_id', 'footer_logo_id',
'hero_config',
'event_config',
])] ])]
class Tenant extends Model class Tenant extends Model
{ {
@ -32,6 +34,19 @@ class Tenant extends Model
return 'codigo'; return 'codigo';
} }
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'hero_config' => 'array',
'event_config' => 'array',
];
}
/** /**
* @return BelongsTo<Attachment, $this> * @return BelongsTo<Attachment, $this>
*/ */
@ -48,9 +63,29 @@ class Tenant extends Model
return $this->belongsTo(Attachment::class, 'footer_logo_id'); return $this->belongsTo(Attachment::class, 'footer_logo_id');
} }
/**
* @return BelongsTo<Attachment, $this>
*/
public function heroBgImage(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'hero_bg_image_id');
}
public function productos(): HasMany public function productos(): HasMany
{ {
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo'); return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
} }
public function menues(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(
\App\Domains\Menu\Models\Menu::class,
'tenant_menues',
'tenant_codigo',
'menu_code',
'codigo',
'code'
)->withTimestamps();
}
} }

View File

@ -61,6 +61,17 @@ class StoreTenantRequest extends FormRequest
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], 'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule, 'header_logo' => $logoRule,
'footer_logo' => $logoRule, 'footer_logo' => $logoRule,
'hero_bg_image' => ['nullable', new ImageOrBase64Rule()],
'hero_config' => ['nullable', 'array'],
'hero_config.title_html' => ['nullable', 'string'],
'hero_config.description_html' => ['nullable', 'string'],
'hero_config.button_text' => ['nullable', 'string'],
'hero_config.button_href' => ['nullable', 'string'],
'event_config' => ['nullable', 'array'],
'event_config.title' => ['nullable', 'string'],
'event_config.location' => ['nullable', 'string'],
'event_config.dates' => ['nullable', 'array'],
'event_config.dates.*' => ['required', 'string'],
]; ];
} }
} }

View File

@ -72,6 +72,17 @@ class UpdateTenantRequest extends FormRequest
'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], 'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule, 'header_logo' => $logoRule,
'footer_logo' => $logoRule, 'footer_logo' => $logoRule,
'hero_bg_image' => ['nullable', new ImageOrBase64Rule()],
'hero_config' => ['nullable', 'array'],
'hero_config.title_html' => ['nullable', 'string'],
'hero_config.description_html' => ['nullable', 'string'],
'hero_config.button_text' => ['nullable', 'string'],
'hero_config.button_href' => ['nullable', 'string'],
'event_config' => ['nullable', 'array'],
'event_config.title' => ['nullable', 'string'],
'event_config.location' => ['nullable', 'string'],
'event_config.dates' => ['nullable', 'array'],
'event_config.dates.*' => ['required', 'string'],
]; ];
} }
} }

View File

@ -15,6 +15,12 @@ class TenantResource extends JsonResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$heroConfig = $this->hero_config;
if (is_array($heroConfig)) {
$heroConfig['background_image'] = $this->heroBgImage?->getTemporaryUrl(1440);
unset($heroConfig['background_image_id']);
}
return [ return [
'id' => $this->id, 'id' => $this->id,
'codigo' => $this->codigo, 'codigo' => $this->codigo,
@ -29,6 +35,9 @@ class TenantResource extends JsonResource
// 1 day // 1 day
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440), 'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440 ), 'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440 ),
'hero_config' => $heroConfig,
'event_config' => $this->event_config,
'menues' => $this->whenLoaded('menues'),
]; ];
} }
} }

View File

@ -24,8 +24,9 @@ class TenantService
return DB::transaction(function () use ($data): Tenant { return DB::transaction(function () use ($data): Tenant {
$headerLogo = $data['header_logo'] ?? null; $headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null; $footerLogo = $data['footer_logo'] ?? null;
$heroBgImage = $data['hero_bg_image'] ?? null;
unset($data['header_logo'], $data['footer_logo']); unset($data['header_logo'], $data['footer_logo'], $data['hero_bg_image']);
$headerAttachmentId = null; $headerAttachmentId = null;
if ($headerLogo) { if ($headerLogo) {
@ -52,6 +53,18 @@ class TenantService
$data['header_logo_id'] = $headerAttachmentId; $data['header_logo_id'] = $headerAttachmentId;
$data['footer_logo_id'] = $footerAttachmentId; $data['footer_logo_id'] = $footerAttachmentId;
if ($heroBgImage) {
$attachment = Str::isUuid($heroBgImage)
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $heroBgImage)->first()
: $this->attachmentService->store($heroBgImage, 'tenants');
if ($attachment) {
$heroConfig = $data['hero_config'] ?? [];
$heroConfig['background_image_id'] = $attachment->id;
$data['hero_config'] = $heroConfig;
}
}
/** @var Tenant $tenant */ /** @var Tenant $tenant */
$tenant = Tenant::query()->create($data); $tenant = Tenant::query()->create($data);
@ -71,10 +84,14 @@ class TenantService
return DB::transaction(function () use ($tenant, $data): Tenant { return DB::transaction(function () use ($tenant, $data): Tenant {
$hasHeaderLogoKey = array_key_exists('header_logo', $data); $hasHeaderLogoKey = array_key_exists('header_logo', $data);
$hasFooterLogoKey = array_key_exists('footer_logo', $data); $hasFooterLogoKey = array_key_exists('footer_logo', $data);
$hasHeroBgImageKey = array_key_exists('hero_bg_image', $data);
$headerLogo = $data['header_logo'] ?? null; $headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null; $footerLogo = $data['footer_logo'] ?? null;
$heroBgImage = $data['hero_bg_image'] ?? null;
unset($data['header_logo'], $data['footer_logo']); unset($data['header_logo'], $data['footer_logo'], $data['hero_bg_image']);
$oldHeroBgId = $tenant->hero_bg_image_id;
$tenant->fill($data); $tenant->fill($data);
@ -110,6 +127,28 @@ class TenantService
} }
} }
$currentHeroConfig = $tenant->hero_config ?? [];
if ($hasHeroBgImageKey) {
if ($heroBgImage) {
$attachment = Str::isUuid($heroBgImage)
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $heroBgImage)->first()
: $this->attachmentService->store($heroBgImage, 'tenants');
if ($attachment) {
$currentHeroConfig['background_image_id'] = $attachment->id;
} else {
unset($currentHeroConfig['background_image_id']);
}
} else {
unset($currentHeroConfig['background_image_id']);
}
} else {
if ($oldHeroBgId) {
$currentHeroConfig['background_image_id'] = $oldHeroBgId;
}
}
$tenant->hero_config = empty($currentHeroConfig) ? null : $currentHeroConfig;
$tenant->save(); $tenant->save();
return $tenant; return $tenant;

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('compras', function (Blueprint $table) {
$table->string('transfer_payer_dni', 8)->nullable()->after('dni');
});
}
public function down(): void
{
Schema::table('compras', function (Blueprint $table) {
$table->dropColumn('transfer_payer_dni');
});
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->json('hero_config')->nullable();
$table->json('event_config')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn(['hero_config', 'event_config']);
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->unsignedBigInteger('hero_bg_image_id')
->virtualAs('hero_config->>"$.background_image_id"')
->nullable()
->after('footer_bg_color');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('hero_bg_image_id');
});
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('menues', function (Blueprint $table) {
$table->id();
$table->string('code')->unique();
$table->string('route');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('menues');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_menues', function (Blueprint $table) {
$table->id();
$table->string('tenant_codigo');
$table->string('menu_code');
$table->timestamps();
$table->foreign('tenant_codigo')->references('codigo')->on('tenants')->onDelete('cascade');
$table->foreign('menu_code')->references('code')->on('menues')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tenant_menues');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('featured_groups', function (Blueprint $table) {
$table->id();
$table->string('tenant_codigo');
$table->string('group_name');
$table->enum('product_layout', ['row', 'column_with_image', 'column_with_cart']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('featured_groups');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('featured_products', function (Blueprint $table) {
$table->id();
$table->foreignId('featured_group_id')->constrained('featured_groups')->onDelete('cascade');
$table->unsignedBigInteger('product_id');
$table->integer('order')->default(0);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('featured_products');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('featured_groups', function (Blueprint $table) {
$table->integer('group_order')->default(0)->after('product_layout');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('featured_groups', function (Blueprint $table) {
$table->dropColumn('group_order');
});
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (Schema::hasTable('featured_products')) {
Schema::rename('featured_products', 'featured_variants');
}
if (Schema::hasColumn('featured_variants', 'product_id')) {
Schema::table('featured_variants', function (Blueprint $table) {
$table->renameColumn('product_id', 'product_variant_id');
});
}
Schema::table('featured_variants', function (Blueprint $table) {
$table->foreign('product_variant_id')->references('id')->on('productos_variantes')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('featured_variants', function (Blueprint $table) {
//
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('productos', function (Blueprint $table) {
$table->boolean('has_tickets')->default(false);
$table->dateTime('minimum_use_date')->nullable();
$table->dateTime('maximum_use_date')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('productos', function (Blueprint $table) {
$table->dropColumn(['has_tickets', 'minimum_use_date', 'maximum_use_date']);
});
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('productos', function (Blueprint $table) {
$table->dropColumn(['has_tickets', 'minimum_use_date', 'maximum_use_date']);
});
Schema::table('productos_variantes', function (Blueprint $table) {
$table->boolean('has_tickets')->default(false);
$table->dateTime('minimum_use_date')->nullable();
$table->dateTime('maximum_use_date')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('productos', function (Blueprint $table) {
$table->boolean('has_tickets')->default(false);
$table->dateTime('minimum_use_date')->nullable();
$table->dateTime('maximum_use_date')->nullable();
});
Schema::table('productos_variantes', function (Blueprint $table) {
$table->dropColumn(['has_tickets', 'minimum_use_date', 'maximum_use_date']);
});
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bundles', function (Blueprint $table) {
$table->id();
$table->string('tenant_codigo', 10);
$table->string('nombre');
$table->text('descripcion')->nullable();
$table->decimal('precio', 10, 2);
$table->timestamps();
$table->foreign('tenant_codigo')->references('codigo')->on('tenants')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bundles');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bundle_items', function (Blueprint $table) {
$table->id();
$table->foreignId('bundle_id')->constrained('bundles')->onDelete('cascade');
$table->foreignId('producto_variante_id')->constrained('productos_variantes')->onDelete('cascade');
$table->integer('cantidad');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bundle_items');
}
};

View File

@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('carrito_items', function (Blueprint $table) {
$table->string('buyable_type')->nullable();
$table->unsignedBigInteger('buyable_id')->nullable();
$table->index('cart_id');
});
// Copy existing data
DB::table('carrito_items')->update([
'buyable_type' => 'App\Domains\Catalog\Models\ProductVariant',
'buyable_id' => DB::raw('producto_variante_id'),
]);
Schema::table('carrito_items', function (Blueprint $table) {
$table->dropForeign(['producto_variante_id']);
});
Schema::table('carrito_items', function (Blueprint $table) {
$table->dropUnique(['cart_id', 'producto_variante_id']);
$table->dropColumn('producto_variante_id');
$table->unique(['cart_id', 'buyable_type', 'buyable_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('carrito_items', function (Blueprint $table) {
$table->dropUnique(['cart_id', 'buyable_type', 'buyable_id']);
$table->foreignId('producto_variante_id')->nullable()->constrained('productos_variantes')->onDelete('cascade');
});
DB::table('carrito_items')->update([
'producto_variante_id' => DB::raw('buyable_id'),
]);
Schema::table('carrito_items', function (Blueprint $table) {
$table->dropColumn(['buyable_type', 'buyable_id']);
$table->dropIndex(['cart_id']);
$table->unique(['cart_id', 'producto_variante_id']);
});
}
};

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('compra_items', function (Blueprint $table) {
$table->string('buyable_type')->nullable();
$table->unsignedBigInteger('buyable_id')->nullable();
});
// Copy existing data
\Illuminate\Support\Facades\DB::table('compra_items')->update([
'buyable_type' => 'App\Domains\Catalog\Models\ProductVariant',
'buyable_id' => \Illuminate\Support\Facades\DB::raw('producto_variante_id'),
]);
Schema::table('compra_items', function (Blueprint $table) {
$table->dropForeign(['producto_variante_id']);
$table->dropColumn('producto_variante_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('compra_items', function (Blueprint $table) {
$table->foreignId('producto_variante_id')->nullable()->constrained('productos_variantes')->onDelete('cascade');
});
\Illuminate\Support\Facades\DB::table('compra_items')->update([
'producto_variante_id' => \Illuminate\Support\Facades\DB::raw('buyable_id'),
]);
Schema::table('compra_items', function (Blueprint $table) {
$table->dropColumn(['buyable_type', 'buyable_id']);
});
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('productos_variantes', function (Blueprint $table) {
$table->string('inventory_policy')->default('tracked')->after('producto_id');
$table->unsignedBigInteger('cantidad_vendida')->default(0)->after('stock_reservado');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('productos_variantes', function (Blueprint $table) {
$table->dropColumn(['inventory_policy', 'cantidad_vendida']);
});
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::rename('featured_variants', 'group_items');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::rename('group_items', 'featured_variants');
}
};

View File

@ -0,0 +1,62 @@
<?php
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('group_items', function (Blueprint $table) {
$table->nullableMorphs('groupable');
});
DB::table('group_items')->update([
'groupable_type' => ProductVariant::class,
'groupable_id' => DB::raw('product_variant_id'),
]);
$productVariantForeignKey = collect(Schema::getForeignKeys('group_items'))
->first(fn (array $foreignKey): bool => $foreignKey['columns'] === ['product_variant_id']);
Schema::table('group_items', function (Blueprint $table) use ($productVariantForeignKey) {
if ($productVariantForeignKey !== null) {
$table->dropForeign($productVariantForeignKey['name']);
}
$table->dropColumn('product_variant_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (DB::table('group_items')->where('groupable_type', '!=', ProductVariant::class)->exists()) {
throw new RuntimeException('Cannot roll back groupable group items while bundle items exist.');
}
Schema::table('group_items', function (Blueprint $table) {
$table->unsignedBigInteger('product_variant_id')->nullable();
});
DB::table('group_items')->update([
'product_variant_id' => DB::raw('groupable_id'),
]);
Schema::table('group_items', function (Blueprint $table) {
$table->dropMorphs('groupable');
$table->foreign('product_variant_id')
->references('id')
->on('productos_variantes')
->cascadeOnDelete();
});
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('bundles', function (Blueprint $table) {
$table->dropForeign(['tenant_codigo']);
});
Schema::table('bundles', function (Blueprint $table) {
$table->string('tenant_codigo')->change();
$table->foreign('tenant_codigo')->references('codigo')->on('tenants')->onDelete('cascade');
});
}
public function down(): void
{
Schema::table('bundles', function (Blueprint $table) {
$table->dropForeign(['tenant_codigo']);
});
Schema::table('bundles', function (Blueprint $table) {
$table->string('tenant_codigo', 10)->change();
$table->foreign('tenant_codigo')->references('codigo')->on('tenants')->onDelete('cascade');
});
}
};

View File

@ -18,7 +18,7 @@ class AttributeSeeder extends Seeder
$tenants = Tenant::all(); $tenants = Tenant::all();
foreach ($tenants as $tenant) { foreach ($tenants as $tenant) {
// Seed Color attribute // Fiesta Futbol Infantil only uses the Fecha attribute.
$existingColor = Attribute::query() $existingColor = Attribute::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->where('codigo', 'color') ->where('codigo', 'color')
@ -28,41 +28,43 @@ class AttributeSeeder extends Seeder
Product::deleteAttribute($existingColor); Product::deleteAttribute($existingColor);
} }
Product::createAttribute($tenant, [ if ($tenant->codigo !== 'fiesta_futbol_infantil') {
'codigo' => 'color', Product::createAttribute($tenant, [
'nombre' => 'Color', 'codigo' => 'color',
'type' => FieldType::Select->value, 'nombre' => 'Color',
'is_required' => true, 'type' => FieldType::Select->value,
'metadata_schema' => [ 'is_required' => true,
'hex' => ['type' => 'string'], 'metadata_schema' => [
], 'hex' => ['type' => 'string'],
'options' => [
[
'value' => 'Negro',
'label' => 'Negro',
'sort_order' => 1,
'metadata' => ['hex' => '#000000'],
], ],
[ 'options' => [
'value' => 'Gris', [
'label' => 'Gris', 'value' => 'Negro',
'sort_order' => 2, 'label' => 'Negro',
'metadata' => ['hex' => '#808080'], 'sort_order' => 1,
'metadata' => ['hex' => '#000000'],
],
[
'value' => 'Gris',
'label' => 'Gris',
'sort_order' => 2,
'metadata' => ['hex' => '#808080'],
],
[
'value' => 'Blanco',
'label' => 'Blanco',
'sort_order' => 3,
'metadata' => ['hex' => '#FFFFFF'],
],
[
'value' => 'Azul',
'label' => 'Azul',
'sort_order' => 4,
'metadata' => ['hex' => '#0000FF'],
],
], ],
[ ]);
'value' => 'Blanco', }
'label' => 'Blanco',
'sort_order' => 3,
'metadata' => ['hex' => '#FFFFFF'],
],
[
'value' => 'Azul',
'label' => 'Azul',
'sort_order' => 4,
'metadata' => ['hex' => '#0000FF'],
],
],
]);
// Seed Talle (Size - Text options) attribute // Seed Talle (Size - Text options) attribute
$existingTalle = Attribute::query() $existingTalle = Attribute::query()
@ -74,18 +76,20 @@ class AttributeSeeder extends Seeder
Product::deleteAttribute($existingTalle); Product::deleteAttribute($existingTalle);
} }
Product::createAttribute($tenant, [ if ($tenant->codigo !== 'fiesta_futbol_infantil') {
'codigo' => 'talle', Product::createAttribute($tenant, [
'nombre' => 'Talle', 'codigo' => 'talle',
'type' => FieldType::Select->value, 'nombre' => 'Talle',
'is_required' => true, 'type' => FieldType::Select->value,
'options' => [ 'is_required' => true,
['value' => 'S', 'label' => 'S', 'sort_order' => 1], 'options' => [
['value' => 'M', 'label' => 'M', 'sort_order' => 2], ['value' => 'S', 'label' => 'S', 'sort_order' => 1],
['value' => 'L', 'label' => 'L', 'sort_order' => 3], ['value' => 'M', 'label' => 'M', 'sort_order' => 2],
['value' => 'XL', 'label' => 'XL', 'sort_order' => 4], ['value' => 'L', 'label' => 'L', 'sort_order' => 3],
], ['value' => 'XL', 'label' => 'XL', 'sort_order' => 4],
]); ],
]);
}
// Seed Talle Numérico (Numeric Size options) attribute // Seed Talle Numérico (Numeric Size options) attribute
$existingTalleNumerico = Attribute::query() $existingTalleNumerico = Attribute::query()
@ -97,16 +101,41 @@ class AttributeSeeder extends Seeder
Product::deleteAttribute($existingTalleNumerico); Product::deleteAttribute($existingTalleNumerico);
} }
if ($tenant->codigo !== 'fiesta_futbol_infantil') {
Product::createAttribute($tenant, [
'codigo' => 'talle_numerico',
'nombre' => 'Talle Numérico',
'type' => FieldType::Select->value,
'is_required' => true,
'options' => [
['value' => '38', 'label' => '38', 'sort_order' => 1],
['value' => '40', 'label' => '40', 'sort_order' => 2],
['value' => '42', 'label' => '42', 'sort_order' => 3],
['value' => '44', 'label' => '44', 'sort_order' => 4],
],
]);
}
// Seed Fecha attribute
$existingFecha = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->where('codigo', 'fecha')
->first();
if ($existingFecha) {
Product::deleteAttribute($existingFecha);
}
Product::createAttribute($tenant, [ Product::createAttribute($tenant, [
'codigo' => 'talle_numerico', 'codigo' => 'fecha',
'nombre' => 'Talle Numérico', 'nombre' => 'Fecha',
'type' => FieldType::Select->value, 'type' => FieldType::Select->value,
'is_required' => true, 'is_required' => true,
'options' => [ 'options' => [
['value' => '38', 'label' => '38', 'sort_order' => 1], ['value' => '2026-10-09', 'label' => '09/10/2026', 'sort_order' => 1],
['value' => '40', 'label' => '40', 'sort_order' => 2], ['value' => '2026-10-10', 'label' => '10/10/2026', 'sort_order' => 2],
['value' => '42', 'label' => '42', 'sort_order' => 3], ['value' => '2026-10-11', 'label' => '11/10/2026', 'sort_order' => 3],
['value' => '44', 'label' => '44', 'sort_order' => 4], ['value' => '2026-10-12', 'label' => '12/10/2026', 'sort_order' => 4],
], ],
]); ]);

View File

@ -28,7 +28,9 @@ class DatabaseSeeder extends Seeder
CategorySeeder::class, CategorySeeder::class,
BrandSeeder::class, BrandSeeder::class,
ProductCatalogFromImagesSeeder::class, ProductCatalogFromImagesSeeder::class,
FiestaFutbolInfantilProductSeeder::class,
TelepagosIntegrationSeeder::class, TelepagosIntegrationSeeder::class,
MenuSeeder::class,
]); ]);
} }
} }

View File

@ -0,0 +1,134 @@
<?php
namespace Database\Seeders;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class FiestaFutbolInfantilProductSeeder extends Seeder
{
public function __construct(private readonly ProductService $productService) {}
public function run(): void
{
$tenant = Tenant::query()->where('codigo', 'fiesta_futbol_infantil')->first();
if (! $tenant) {
throw new RuntimeException("Tenant 'fiesta_futbol_infantil' no encontrado.");
}
// Bundles reference product variants, so remove them before reseeding products.
Bundle::query()->where('tenant_codigo', $tenant->codigo)->delete();
// Delete existing products for this tenant
$existingProducts = Product::query()
->where('tenant_codigo', $tenant->codigo)
->get();
foreach ($existingProducts as $product) {
$this->productService->delete($product);
}
// We need a category, let's just use 'Accesorios' or create an 'Entradas' category
$category = Category::firstOrCreate(['nombre' => 'Entradas', 'tenant_code' => null]);
$dates = ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'];
$entradaVariants = [];
// 1. Entrada General
$entrada = $this->productService->create($tenant, [
'categoria_id' => $category->id,
'slug' => 'entrada-general',
'nombre' => 'Entrada General',
'descripcion' => 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.',
'precio' => 10000,
'stock' => 0,
'inventory_policy' => InventoryPolicy::Unlimited->value,
'attribute_ids' => [Attribute::where('codigo', 'fecha')->where('tenant_codigo', $tenant->codigo)->first()?->id],
]);
foreach ($dates as $date) {
$entradaVariants[] = $this->productService->createVariant($entrada, [
'stock' => 0,
'inventory_policy' => InventoryPolicy::Unlimited->value,
'has_tickets' => true,
'minimum_use_date' => $date.' 00:00:00',
'maximum_use_date' => $date.' 23:59:59',
'definitions' => [
[
'products_attribute_id' => DB::table('products_attributes')
->where('product_id', $entrada->id)
->first()?->id,
'value' => $date,
],
],
]);
}
// Other products without variants
$gastronomiaCategory = Category::firstOrCreate(['nombre' => 'Gastronomía', 'tenant_code' => null]);
$simpleProducts = [
['slug' => 'hamburguesa-papa-frita', 'nombre' => 'Hamburguesa con papa frita', 'precio' => 8000, 'cat' => $gastronomiaCategory->id],
['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'cat' => $gastronomiaCategory->id],
['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, 'cat' => $gastronomiaCategory->id],
['slug' => 'agua-mineral-1l', 'nombre' => 'Agua Mineral 1L', 'precio' => 2500, 'cat' => $gastronomiaCategory->id],
['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'cat' => $category->id],
['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'cat' => $category->id],
];
$createdProducts = [];
foreach ($simpleProducts as $p) {
$createdProducts[$p['slug']] = $this->productService->create($tenant, [
'categoria_id' => $p['cat'],
'slug' => $p['slug'],
'nombre' => $p['nombre'],
'descripcion' => $p['nombre'],
'precio' => $p['precio'],
'stock' => 0,
'inventory_policy' => InventoryPolicy::Unlimited->value,
]);
}
$allDaysBundle = Bundle::query()->create([
'tenant_codigo' => $tenant->codigo,
'nombre' => 'Entrada General - Todos los días',
'descripcion' => 'Incluye una entrada para cada día de la Fiesta Nacional del Fútbol Infantil.',
'precio' => 40000,
]);
foreach ($entradaVariants as $variant) {
$allDaysBundle->items()->create([
'producto_variante_id' => $variant->id,
'cantidad' => 1,
]);
}
$foodBundle = Bundle::query()->create([
'tenant_codigo' => $tenant->codigo,
'nombre' => 'Combo 2 Panchos + 2 Hamburguesas',
'descripcion' => 'Incluye 2 panchos y 2 hamburguesas con papa frita.',
'precio' => 24000,
]);
$foodBundle->items()->createMany([
[
'producto_variante_id' => $createdProducts['pancho']->variants()->sole()->id,
'cantidad' => 2,
],
[
'producto_variante_id' => $createdProducts['hamburguesa-papa-frita']->variants()->sole()->id,
'cantidad' => 2,
],
]);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
class MenuSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$menus = [
['code' => 'index', 'route' => '/'],
['code' => 'product.detail', 'route' => '/product/:id'],
['code' => 'checkout', 'route' => '/checkout'],
['code' => 'profile', 'route' => '/profile'],
['code' => 'purchases', 'route' => '/purchases'],
['code' => 'tickets', 'route' => '/tickets'],
];
foreach ($menus as $menuData) {
Menu::firstOrCreate(['code' => $menuData['code']], $menuData);
}
$tenants = Tenant::all();
$allMenus = Menu::pluck('code')->toArray();
foreach ($tenants as $tenant) {
$menuCodes = $allMenus;
if ($tenant->codigo === 'sonder') {
// Sonder NO tiene tickets
$menuCodes = array_diff($menuCodes, ['tickets']);
} else {
// Los demás NO tienen product.detail
$menuCodes = array_diff($menuCodes, ['product.detail']);
}
// Usar sync para asociar los menues al tenant
$tenant->menues()->sync($menuCodes);
}
}
}

View File

@ -2,6 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Brand; use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
@ -64,9 +65,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
'istockphoto-1675347112-2048x2048.jpg', 'istockphoto-1675347112-2048x2048.jpg',
]; ];
public function __construct(private readonly ProductService $productService) public function __construct(private readonly ProductService $productService) {}
{
}
/** /**
* Run the database seeds. * Run the database seeds.
@ -162,9 +161,9 @@ class ProductCatalogFromImagesSeeder extends Seeder
} }
/** /**
* @param array{attribute_codes: array<int, string>, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string} $metadata * @param array{attribute_codes: array<int, string>, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string} $metadata
* @param array{price: int} $pricing * @param array{price: int} $pricing
* @param array<int, array{files: array<int, \SplFileInfo>, group_key: string, metadata: array{attribute_codes: array<int, string>, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string}, stock: int}> $variantGroups * @param array<int, array{files: array<int, \SplFileInfo>, group_key: string, metadata: array{attribute_codes: array<int, string>, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string}, stock: int}> $variantGroups
*/ */
private function seedProduct(Tenant $tenant, array $metadata, array $pricing, array $variantGroups): void private function seedProduct(Tenant $tenant, array $metadata, array $pricing, array $variantGroups): void
{ {
@ -206,6 +205,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
'nombre' => $metadata['product_name'], 'nombre' => $metadata['product_name'],
'descripcion' => $metadata['description'], 'descripcion' => $metadata['description'],
'precio' => $pricing['price'], 'precio' => $pricing['price'],
'inventory_policy' => InventoryPolicy::Tracked->value,
'attribute_ids' => array_values($attributeIds->all()), 'attribute_ids' => array_values($attributeIds->all()),
]); ]);
@ -226,9 +226,11 @@ class ProductCatalogFromImagesSeeder extends Seeder
if ($attributeIds->isEmpty()) { if ($attributeIds->isEmpty()) {
$this->productService->createVariant($product, [ $this->productService->createVariant($product, [
'stock' => $variantGroup['stock'], 'stock' => $variantGroup['stock'],
'inventory_policy' => InventoryPolicy::Tracked->value,
'definitions' => [], 'definitions' => [],
'images' => $images, 'images' => $images,
]); ]);
continue; continue;
} }
@ -280,6 +282,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
$this->productService->createVariant($product, [ $this->productService->createVariant($product, [
'stock' => $stock, 'stock' => $stock,
'inventory_policy' => InventoryPolicy::Tracked->value,
'definitions' => $definitions, 'definitions' => $definitions,
'images' => $images, 'images' => $images,
]); ]);
@ -288,7 +291,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
} }
/** /**
* @param array<int, array{files: array<int, \SplFileInfo>, group_key: string, metadata: array{attribute_codes: array<int, string>, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string}, stock: int}> $variantGroups * @param array<int, array{files: array<int, \SplFileInfo>, group_key: string, metadata: array{attribute_codes: array<int, string>, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string}, stock: int}> $variantGroups
* @return array{attribute_codes: array<int, string>, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string} * @return array{attribute_codes: array<int, string>, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string}
*/ */
private function buildProductMetadata(string $productKey, array $variantGroups): array private function buildProductMetadata(string $productKey, array $variantGroups): array

View File

@ -85,5 +85,99 @@ class TenantSeeder extends Seeder
'header_logo' => $headerLogo, 'header_logo' => $headerLogo,
'footer_logo' => $footerLogo, 'footer_logo' => $footerLogo,
]); ]);
// Check if tenant 'fiesta_futbol_infantil' already exists
$existingFiesta = Tenant::query()->where('codigo', 'fiesta_futbol_infantil')->first();
if ($existingFiesta) {
$attachmentService = app(\App\Domains\Attachable\Services\AttachmentService::class);
if ($existingFiesta->headerLogo) {
try {
$attachmentService->delete($existingFiesta->headerLogo);
} catch (\Throwable $e) {}
}
if ($existingFiesta->footerLogo && $existingFiesta->footer_logo_id !== $existingFiesta->header_logo_id) {
try {
$attachmentService->delete($existingFiesta->footerLogo);
} catch (\Throwable $e) {}
}
if ($existingFiesta->heroBgImage) {
try {
$attachmentService->delete($existingFiesta->heroBgImage);
} catch (\Throwable $e) {}
}
$existingFiesta->delete();
}
$fiestaDomain = 'fiesta-futbol-infantil.localhost';
$existingFiestaDomain = Tenant::query()->where('dominio', $fiestaDomain)->first();
if ($existingFiestaDomain) {
$existingFiestaDomain->delete();
}
$fiestaHeaderImagePath = public_path('images/futbol_infantil_header.png');
$fiestaFooterImagePath = public_path('images/futbol_infantil_footer.png');
$fiestaHeroBgImagePath = public_path('images/futbol_infantil_hero.jpg');
if (! file_exists($fiestaHeaderImagePath)) {
throw new \RuntimeException("Image not found at path: {$fiestaHeaderImagePath}");
}
if (! file_exists($fiestaFooterImagePath)) {
throw new \RuntimeException("Image not found at path: {$fiestaFooterImagePath}");
}
if (! file_exists($fiestaHeroBgImagePath)) {
throw new \RuntimeException("Image not found at path: {$fiestaHeroBgImagePath}");
}
$fiestaHeaderLogo = new UploadedFile(
$fiestaHeaderImagePath,
'futbol_infantil_header.png',
'image/png',
null,
true
);
$fiestaFooterLogo = new UploadedFile(
$fiestaFooterImagePath,
'futbol_infantil_footer.png',
'image/png',
null,
true
);
$fiestaHeroBgImage = new UploadedFile(
$fiestaHeroBgImagePath,
'futbol_infantil_hero.jpg',
'image/jpeg',
null,
true
);
$this->tenantService->create([
'codigo' => 'fiesta_futbol_infantil',
'nombre' => 'Fiesta Fútbol Infantil',
'dominio' => $fiestaDomain,
'primary_color' => '#00973F',
'secondary_color' => '#A0A0A0',
'danger_color' => '#FF8888',
'success_color' => '#198754',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#015327',
'header_logo' => $fiestaHeaderLogo,
'footer_logo' => $fiestaFooterLogo,
'hero_bg_image' => $fiestaHeroBgImage,
'hero_config' => [
'title_html' => '<strong>ASEGURÁ TU LUGAR</strong>',
'description_html' => '<strong>Comprá tu entrada oficial en segundos</strong> de forma 100% segura. Preparate para vivir la experiencia completa.',
'button_text' => 'Quiero mi entrada',
'button_href' => null,
],
'event_config' => [
'title' => 'FIESTA NACIONAL DEL FÚTBOL INFANTIL',
'location' => 'Sunchales, Santa Fe',
'dates' => ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'],
],
]);
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -10,3 +10,5 @@ require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
require __DIR__.'/../app/Domains/Purchase/routes/api.php'; require __DIR__.'/../app/Domains/Purchase/routes/api.php';
require __DIR__.'/../app/Domains/Tenant/routes/api.php'; require __DIR__.'/../app/Domains/Tenant/routes/api.php';
require __DIR__.'/../app/Domains/Integration/routes/api.php'; require __DIR__.'/../app/Domains/Integration/routes/api.php';
require __DIR__.'/../app/Domains/Menu/routes/api.php';

View File

@ -2,14 +2,19 @@
namespace Tests\Feature\Cart; namespace Tests\Feature\Cart;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductAttribute; use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Models\ProductVariantDefinition; use App\Domains\Catalog\Models\ProductVariantDefinition;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase; use Tests\TestCase;
class CartControllerTest extends TestCase class CartControllerTest extends TestCase
@ -29,7 +34,7 @@ class CartControllerTest extends TestCase
'status' => 'active', 'status' => 'active',
'items' => [], 'items' => [],
'subtotal' => '0.00', 'subtotal' => '0.00',
] ],
]); ]);
} }
@ -55,7 +60,8 @@ class CartControllerTest extends TestCase
]); ]);
$response = $this->postJson('/api/tenants/acme/cart/items', [ $response = $this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id, 'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 2, 'cantidad' => 2,
]); ]);
@ -65,7 +71,8 @@ class CartControllerTest extends TestCase
->assertJsonPath('data.tenant_codigo', 'acme') ->assertJsonPath('data.tenant_codigo', 'acme')
->assertJsonPath('data.items.0.cantidad', 2) ->assertJsonPath('data.items.0.cantidad', 2)
->assertJsonPath('data.items.0.precio_unitario', '49.90') ->assertJsonPath('data.items.0.precio_unitario', '49.90')
->assertJsonPath('data.items.0.product_id', $variant->product->id) ->assertJsonPath('data.items.0.buyable_type', 'variant')
->assertJsonPath('data.items.0.buyable_id', $variant->id)
->assertJsonPath('data.items.0.product.nombre', 'Shirt acme (Color: Red)') ->assertJsonPath('data.items.0.product.nombre', 'Shirt acme (Color: Red)')
->assertJsonPath('data.items.0.product.imagen', null) ->assertJsonPath('data.items.0.product.imagen', null)
->assertJsonPath('data.subtotal', '99.80'); ->assertJsonPath('data.subtotal', '99.80');
@ -77,7 +84,8 @@ class CartControllerTest extends TestCase
]); ]);
$this->assertDatabaseHas('carrito_items', [ $this->assertDatabaseHas('carrito_items', [
'producto_variante_id' => $variant->id, 'buyable_type' => ProductVariant::class,
'buyable_id' => $variant->id,
'cantidad' => 2, 'cantidad' => 2,
]); ]);
@ -93,7 +101,8 @@ class CartControllerTest extends TestCase
$variant = $this->createVariantForTenant('acme', 12, '25.00'); $variant = $this->createVariantForTenant('acme', 12, '25.00');
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [ $firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id, 'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 2, 'cantidad' => 2,
]); ]);
@ -107,7 +116,8 @@ class CartControllerTest extends TestCase
[], [],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode([ json_encode([
'product_variant_id' => $variant->id, 'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 3, 'cantidad' => 3,
]) ])
); );
@ -120,7 +130,8 @@ class CartControllerTest extends TestCase
$this->assertDatabaseCount('carritos', 1); $this->assertDatabaseCount('carritos', 1);
$this->assertDatabaseCount('carrito_items', 1); $this->assertDatabaseCount('carrito_items', 1);
$this->assertDatabaseHas('carrito_items', [ $this->assertDatabaseHas('carrito_items', [
'producto_variante_id' => $variant->id, 'buyable_type' => ProductVariant::class,
'buyable_id' => $variant->id,
'cantidad' => 5, 'cantidad' => 5,
]); ]);
$this->assertDatabaseHas('productos_variantes', [ $this->assertDatabaseHas('productos_variantes', [
@ -135,15 +146,17 @@ class CartControllerTest extends TestCase
$variant = $this->createVariantForTenant('acme', 10, '15.00'); $variant = $this->createVariantForTenant('acme', 10, '15.00');
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [ $createResponse = $this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id, 'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 2, 'cantidad' => 2,
]); ]);
$guestToken = $createResponse->getCookie('guest_token', false)?->getValue(); $guestToken = $createResponse->getCookie('guest_token', false)?->getValue();
$cartItemId = $createResponse->json('data.items.0.id');
$response = $this->call( $response = $this->call(
'PATCH', 'PATCH',
"/api/tenants/acme/cart/items/{$variant->id}", "/api/tenants/acme/cart/items/{$cartItemId}",
[], [],
['guest_token' => $guestToken], ['guest_token' => $guestToken],
[], [],
@ -159,7 +172,8 @@ class CartControllerTest extends TestCase
->assertJsonPath('data.subtotal', '75.00'); ->assertJsonPath('data.subtotal', '75.00');
$this->assertDatabaseHas('carrito_items', [ $this->assertDatabaseHas('carrito_items', [
'producto_variante_id' => $variant->id, 'buyable_type' => ProductVariant::class,
'buyable_id' => $variant->id,
'cantidad' => 5, 'cantidad' => 5,
]); ]);
$this->assertDatabaseHas('productos_variantes', [ $this->assertDatabaseHas('productos_variantes', [
@ -174,15 +188,17 @@ class CartControllerTest extends TestCase
$variant = $this->createVariantForTenant('acme', 10, '15.00'); $variant = $this->createVariantForTenant('acme', 10, '15.00');
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [ $createResponse = $this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id, 'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 4, 'cantidad' => 4,
]); ]);
$guestToken = $createResponse->getCookie('guest_token', false)?->getValue(); $guestToken = $createResponse->getCookie('guest_token', false)?->getValue();
$cartItemId = $createResponse->json('data.items.0.id');
$response = $this->call( $response = $this->call(
'DELETE', 'DELETE',
"/api/tenants/acme/cart/items/{$variant->id}", "/api/tenants/acme/cart/items/{$cartItemId}",
[], [],
['guest_token' => $guestToken], ['guest_token' => $guestToken],
[], [],
@ -211,14 +227,16 @@ class CartControllerTest extends TestCase
$this->actingAs($user) $this->actingAs($user)
->postJson('/api/tenants/acme/cart/items', [ ->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $acmeVariantA->id, 'buyable_type' => 'variant',
'buyable_id' => $acmeVariantA->id,
'cantidad' => 1, 'cantidad' => 1,
]) ])
->assertOk(); ->assertOk();
$this->actingAs($user) $this->actingAs($user)
->postJson('/api/tenants/acme/cart/items', [ ->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $acmeVariantB->id, 'buyable_type' => 'variant',
'buyable_id' => $acmeVariantB->id,
'cantidad' => 2, 'cantidad' => 2,
]) ])
->assertOk() ->assertOk()
@ -226,7 +244,8 @@ class CartControllerTest extends TestCase
$this->actingAs($user) $this->actingAs($user)
->postJson('/api/tenants/globex/cart/items', [ ->postJson('/api/tenants/globex/cart/items', [
'product_variant_id' => $globexVariant->id, 'buyable_type' => 'variant',
'buyable_id' => $globexVariant->id,
'cantidad' => 1, 'cantidad' => 1,
]) ])
->assertOk(); ->assertOk();
@ -248,7 +267,8 @@ class CartControllerTest extends TestCase
$otherVariant = $this->createVariantForTenant('globex', 10, '20.00'); $otherVariant = $this->createVariantForTenant('globex', 10, '20.00');
$this->postJson('/api/tenants/acme/cart/items', [ $this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $otherVariant->id, 'buyable_type' => 'variant',
'buyable_id' => $otherVariant->id,
'cantidad' => 1, 'cantidad' => 1,
])->assertNotFound(); ])->assertNotFound();
} }
@ -270,16 +290,19 @@ class CartControllerTest extends TestCase
$variant = $this->createVariantForTenant('acme', 2, '10.00'); $variant = $this->createVariantForTenant('acme', 2, '10.00');
$this->postJson('/api/tenants/acme/cart/items', [ $this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id, 'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 0, 'cantidad' => 0,
])->assertUnprocessable()->assertJsonValidationErrors(['cantidad']); ])->assertUnprocessable()->assertJsonValidationErrors(['cantidad']);
$response = $this->postJson('/api/tenants/acme/cart/items', [ $response = $this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id, 'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 2, 'cantidad' => 2,
]); ]);
$guestToken = $response->getCookie('guest_token', false)?->getValue(); $guestToken = $response->getCookie('guest_token', false)?->getValue();
$cartItemId = $response->json('data.items.0.id');
$response1 = $this->call( $response1 = $this->call(
'POST', 'POST',
@ -289,7 +312,8 @@ class CartControllerTest extends TestCase
[], [],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode([ json_encode([
'product_variant_id' => $variant->id, 'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 1, 'cantidad' => 1,
]) ])
); );
@ -300,7 +324,7 @@ class CartControllerTest extends TestCase
$response2 = $this->call( $response2 = $this->call(
'PATCH', 'PATCH',
"/api/tenants/acme/cart/items/{$variant->id}", "/api/tenants/acme/cart/items/{$cartItemId}",
[], [],
['guest_token' => $guestToken], ['guest_token' => $guestToken],
[], [],
@ -315,18 +339,101 @@ class CartControllerTest extends TestCase
->assertJsonValidationErrors(['cantidad' => 'El máximo que se puede agregar es 2.']); ->assertJsonValidationErrors(['cantidad' => 'El máximo que se puede agregar es 2.']);
} }
public function test_it_only_accepts_buyable_identity_when_adding_an_item(): void
{
$variant = $this->createVariantForTenant('acme', 10, '10.00');
$this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 1,
])->assertUnprocessable()
->assertJsonValidationErrors(['buyable_type', 'buyable_id']);
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 1,
])->assertOk();
$cartItemId = $createResponse->json('data.items.0.id');
$this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [
'cantidad' => 2,
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
])->assertUnprocessable()
->assertJsonValidationErrors(['buyable_type', 'buyable_id']);
}
public function test_unlimited_inventory_can_be_reserved_updated_and_released_without_real_stock(): void
{
$variant = $this->createVariantForTenant(
'acme',
0,
'10.00',
'unlimited',
InventoryPolicy::Unlimited,
);
$response = $this->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 100,
])->assertOk();
$guestToken = $response->getCookie('guest_token', false)?->getValue();
$cartItemId = $response->json('data.items.0.id');
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 0,
'stock_reservado' => 100,
]);
$this->call(
'PATCH',
"/api/tenants/acme/cart/items/{$cartItemId}",
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode(['cantidad' => 150]),
)->assertOk();
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 0,
'stock_reservado' => 150,
]);
$this->call(
'DELETE',
"/api/tenants/acme/cart/items/{$cartItemId}",
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json'],
)->assertOk();
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 0,
'stock_reservado' => 0,
]);
}
protected function createVariantForTenant( protected function createVariantForTenant(
string $tenantCode, string $tenantCode,
int $stock, int $stock,
string $price, string $price,
string $slugPrefix = 'shirt', string $slugPrefix = 'shirt',
InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked,
): ProductVariant { ): ProductVariant {
$tenant = Tenant::query()->where('codigo', $tenantCode)->first(); $tenant = Tenant::query()->where('codigo', $tenantCode)->first();
if (! $tenant) { if (! $tenant) {
$this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com"); $this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com");
} }
$category = \App\Domains\Catalog\Models\Category::query()->create([ $category = Category::query()->create([
'tenant_code' => $tenantCode, 'tenant_code' => $tenantCode,
'nombre' => "{$slugPrefix} category {$tenantCode}", 'nombre' => "{$slugPrefix} category {$tenantCode}",
]); ]);
@ -342,6 +449,7 @@ class CartControllerTest extends TestCase
return ProductVariant::query()->create([ return ProductVariant::query()->create([
'producto_id' => $product->id, 'producto_id' => $product->id,
'inventory_policy' => $inventoryPolicy->value,
'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(),
'nombre' => ucfirst($slugPrefix).' Variant', 'nombre' => ucfirst($slugPrefix).' Variant',
'stock' => $stock, 'stock' => $stock,
@ -352,21 +460,21 @@ class CartControllerTest extends TestCase
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
{ {
$hdrKey = (string) \Illuminate\Support\Str::uuid(); $hdrKey = (string) Str::uuid();
$ftrKey = (string) \Illuminate\Support\Str::uuid(); $ftrKey = (string) Str::uuid();
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([ $headerAttachment = Attachment::create([
'key' => $hdrKey, 'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png', 'path' => 'tenants/'.$hdrKey.'.png',
'filename' => 'logo_header.png', 'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, 'type' => AttachmentType::Image,
'mime_type' => 'image/png', 'mime_type' => 'image/png',
]); ]);
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([ $footerAttachment = Attachment::create([
'key' => $ftrKey, 'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png', 'path' => 'tenants/'.$ftrKey.'.png',
'filename' => 'logo_footer.png', 'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, 'type' => AttachmentType::Image,
'mime_type' => 'image/png', 'mime_type' => 'image/png',
]); ]);

View File

@ -0,0 +1,114 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class GroupItemTest extends TestCase
{
use RefreshDatabase;
private FeaturedGroup $featuredGroup;
private ProductVariant $variant;
private Bundle $bundle;
protected function setUp(): void
{
parent::setUp();
$headerAttachment = $this->createAttachment('header.png');
$footerAttachment = $this->createAttachment('footer.png');
$tenant = Tenant::query()->create([
'codigo' => 'group-test',
'nombre' => 'Group Test',
'dominio' => 'group.test',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#28a745',
'header_bg_color' => '#444444',
'footer_bg_color' => '#555555',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
$category = Category::query()->create([
'tenant_code' => $tenant->codigo,
'nombre' => 'Group items',
]);
$product = Product::query()->create([
'tenant_codigo' => $tenant->codigo,
'categoria_id' => $category->id,
'slug' => 'group-item-product',
'nombre' => 'Group Item Product',
'precio' => 100,
]);
$this->variant = ProductVariant::query()->create([
'producto_id' => $product->id,
'stock' => 10,
]);
$this->bundle = Bundle::query()->create([
'tenant_codigo' => $tenant->codigo,
'nombre' => 'Group Item Bundle',
'precio' => 150,
]);
$this->featuredGroup = FeaturedGroup::query()->create([
'tenant_codigo' => $tenant->codigo,
'group_name' => 'Featured',
'product_layout' => 'row',
'group_order' => 0,
]);
}
public function test_a_group_item_can_reference_a_product_variant(): void
{
$groupItem = $this->variant->groupItems()->create([
'featured_group_id' => $this->featuredGroup->id,
'order' => 1,
]);
$this->assertInstanceOf(ProductVariant::class, $groupItem->groupable);
$this->assertTrue($groupItem->groupable->is($this->variant));
$this->assertTrue($this->variant->groupItems->first()->is($groupItem));
}
public function test_a_group_item_can_reference_a_bundle(): void
{
$groupItem = $this->bundle->groupItems()->create([
'featured_group_id' => $this->featuredGroup->id,
'order' => 2,
]);
$this->assertInstanceOf(Bundle::class, $groupItem->groupable);
$this->assertTrue($groupItem->groupable->is($this->bundle));
$this->assertTrue($this->bundle->groupItems->first()->is($groupItem));
}
private function createAttachment(string $filename): Attachment
{
return Attachment::query()->create([
'key' => (string) Str::uuid(),
'path' => 'tests/'.$filename,
'filename' => $filename,
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
}

View File

@ -4,6 +4,7 @@ namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Brand; use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\Product;
@ -768,6 +769,67 @@ class ProductControllerTest extends TestCase
$response->assertJsonValidationErrors(['variant_id']); $response->assertJsonValidationErrors(['variant_id']);
} }
public function test_it_creates_an_unlimited_default_variant_and_exposes_inventory_fields(): void
{
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", [
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'unlimited-product',
'nombre' => 'Unlimited Product',
'precio' => 100,
'stock' => 0,
'inventory_policy' => InventoryPolicy::Unlimited->value,
]);
$response->assertCreated();
$product = Product::query()->where('slug', 'unlimited-product')->firstOrFail();
$variant = $product->variants()->firstOrFail();
$this->assertSame(InventoryPolicy::Unlimited, $variant->inventory_policy);
$this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}")
->assertOk()
->assertJsonPath('data.variant.id', $variant->id)
->assertJsonPath('data.variant.inventory_policy', InventoryPolicy::Unlimited->value)
->assertJsonPath('data.variant.cantidad_maxima', null)
->assertJsonPath('data.variant.cantidad_vendida', 0)
->assertJsonPath('data.variants_map.0.inventory_policy', InventoryPolicy::Unlimited->value)
->assertJsonPath('data.variants_map.0.cantidad_maxima', null)
->assertJsonPath('data.variants_map.0.cantidad_vendida', 0);
}
public function test_it_rejects_invalid_or_updated_inventory_policies(): void
{
$this->postJson("/api/tenants/{$this->tenant->codigo}/productos", [
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'invalid-policy',
'nombre' => 'Invalid Policy',
'precio' => 100,
'inventory_policy' => 'sometimes',
])->assertUnprocessable()->assertJsonValidationErrors(['inventory_policy']);
$product = Product::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'immutable-policy',
'nombre' => 'Immutable Policy',
'precio' => 100,
]);
$variant = $product->variants()->create([
'stock' => 0,
'inventory_policy' => InventoryPolicy::Unlimited->value,
]);
$this->putJson(
"/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants/{$variant->id}",
['inventory_policy' => InventoryPolicy::Tracked->value],
)->assertUnprocessable()->assertJsonValidationErrors(['inventory_policy']);
$this->assertSame(InventoryPolicy::Unlimited, $variant->fresh()->inventory_policy);
}
private function createAttachment(string $path): Attachment private function createAttachment(string $path): Attachment
{ {
return Attachment::create([ return Attachment::create([

View File

@ -2,8 +2,12 @@
namespace Tests\Feature\Integration; namespace Tests\Feature\Integration;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Models\Integration;
@ -14,6 +18,7 @@ use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\TestCase; use Tests\TestCase;
class TelepagosWebhookTest extends TestCase class TelepagosWebhookTest extends TestCase
@ -24,10 +29,74 @@ class TelepagosWebhookTest extends TestCase
{ {
parent::setUp(); parent::setUp();
config(['services.integrations.secret' => 'base64:' . base64_encode(random_bytes(32))]); config(['services.integrations.secret' => 'base64:'.base64_encode(random_bytes(32))]);
Cache::flush(); Cache::flush();
} }
public function test_transfer_payment_intent_requires_a_valid_transfer_payer_dni(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createPendingTransferPurchase($tenant, $user->id, $variant->id, 1, '12345678');
$this->actingAs($user, 'sanctum')
->postJson("/api/tenants/sonder/compras/{$purchase->id}/payment-intent", [
'method' => 'transfer',
])
->assertUnprocessable()
->assertJsonValidationErrors(['transfer_payer_dni']);
$this->actingAs($user, 'sanctum')
->postJson("/api/tenants/sonder/compras/{$purchase->id}/payment-intent", [
'method' => 'transfer',
'transfer_payer_dni' => '12.345.678',
])
->assertUnprocessable()
->assertJsonValidationErrors(['transfer_payer_dni']);
}
public function test_transfer_payment_intent_persists_transfer_payer_dni_without_replacing_customer_dni(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$this->configureTelepagosIntegration($tenant);
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createPendingTransferPurchase($tenant, $user->id, $variant->id, 1, '12345678');
$purchase->update(['status' => Purchase::STATUS_CREATED]);
Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok',
'token' => 'test-token',
'expires_at' => now()->addHour()->toIso8601String(),
]),
'https://api.telepagos.com.ar/v2/account/info' => Http::response([
'status' => 'ok',
'holder' => 'Telepagos Test',
'cvu' => '0000003100000000000001',
'alias' => 'telepagos.test',
'entity' => 'Telepagos S.A.',
]),
]);
$this->actingAs($user, 'sanctum')
->postJson("/api/tenants/sonder/compras/{$purchase->id}/payment-intent", [
'method' => 'transfer',
'transfer_payer_dni' => '23456789',
])
->assertOk()
->assertJsonPath('transfer_data.alias', 'telepagos.test');
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'dni' => '87654321',
'transfer_payer_dni' => '23456789',
'payment_method' => 'transfer',
'status' => Purchase::STATUS_PENDING_PAYMENT,
]);
}
public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void
{ {
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
@ -108,11 +177,19 @@ class TelepagosWebhookTest extends TestCase
$this->assertDatabaseHas('compra_items', [ $this->assertDatabaseHas('compra_items', [
'compra_id' => $matchingPurchase->id, 'compra_id' => $matchingPurchase->id,
'producto_variante_id' => $variant->id, 'buyable_type' => ProductVariant::class,
'buyable_id' => $variant->id,
'cantidad' => 1, 'cantidad' => 1,
'total' => 50, 'total' => 50,
]); ]);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 9,
'stock_reservado' => 2,
'cantidad_vendida' => 1,
]);
$this->assertDatabaseMissing('compra_items', [ $this->assertDatabaseMissing('compra_items', [
'compra_id' => $newerPurchase->id, 'compra_id' => $newerPurchase->id,
]); ]);
@ -122,6 +199,60 @@ class TelepagosWebhookTest extends TestCase
]); ]);
} }
public function test_transfer_webhook_buys_unlimited_inventory_without_reducing_real_stock(): void
{
$tenant = $this->createTenant('unlimited', 'Unlimited', 'unlimited.com.ar');
$this->configureTelepagosIntegration($tenant);
$user = User::factory()->create();
$variant = $this->createVariantForTenant(
'unlimited',
0,
'50.00',
'service',
InventoryPolicy::Unlimited,
);
$purchase = $this->createPendingTransferPurchase(
$tenant,
$user->id,
$variant->id,
3,
'87654321',
);
Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok',
'token' => 'test-token',
'expires_at' => now()->addHour()->toIso8601String(),
]),
'https://api.telepagos.com.ar/v2/payment/cashin/7000' => Http::response([
'status' => 'ok',
'data' => [
'amount' => 150,
'operation_id' => 1,
'transaction_id' => 'tx-unlimited',
'buyer' => ['cuit' => '20876543219'],
],
]),
]);
$this->postJson('/api/webhooks/telepagos/unlimited', ['id' => '7000'])
->assertOk()
->assertJsonPath('status', 'success');
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'status' => Purchase::STATUS_PAID,
]);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'inventory_policy' => InventoryPolicy::Unlimited->value,
'stock_real' => 0,
'stock_reservado' => 0,
'cantidad_vendida' => 3,
]);
}
private function createPendingTransferPurchase( private function createPendingTransferPurchase(
Tenant $tenant, Tenant $tenant,
int $userId, int $userId,
@ -135,14 +266,14 @@ class TelepagosWebhookTest extends TestCase
'status' => 'active', 'status' => 'active',
]); ]);
$cart->addItem($variantId, $quantity); $cart->addItem(ProductVariant::class, $variantId, $quantity);
/** @var CheckoutService $checkoutService */ /** @var CheckoutService $checkoutService */
$checkoutService = app(CheckoutService::class); $checkoutService = app(CheckoutService::class);
$purchase = $checkoutService->startCheckout($tenant, $userId, [ $purchase = $checkoutService->startCheckout($tenant, $userId, [
'cart_id' => $cart->id, 'cart_id' => $cart->id,
'dni' => $dni, 'dni' => '87654321',
'telefono' => '+54 9 341 555-4321', 'telefono' => '+54 9 341 555-4321',
'nombre_apellido' => 'Juan Perez', 'nombre_apellido' => 'Juan Perez',
'email' => 'juan.perez@example.com', 'email' => 'juan.perez@example.com',
@ -150,6 +281,7 @@ class TelepagosWebhookTest extends TestCase
$purchase->update([ $purchase->update([
'payment_method' => 'transfer', 'payment_method' => 'transfer',
'transfer_payer_dni' => $dni,
]); ]);
$checkoutService->completePurchase($purchase); $checkoutService->completePurchase($purchase);
@ -184,8 +316,9 @@ class TelepagosWebhookTest extends TestCase
int $stock, int $stock,
string $price, string $price,
string $slugPrefix = 'shirt', string $slugPrefix = 'shirt',
InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked,
): ProductVariant { ): ProductVariant {
$category = \App\Domains\Catalog\Models\Category::query()->create([ $category = Category::query()->create([
'tenant_code' => $tenantCode, 'tenant_code' => $tenantCode,
'nombre' => "{$slugPrefix} category {$tenantCode}", 'nombre' => "{$slugPrefix} category {$tenantCode}",
]); ]);
@ -201,6 +334,7 @@ class TelepagosWebhookTest extends TestCase
return ProductVariant::query()->create([ return ProductVariant::query()->create([
'producto_id' => $product->id, 'producto_id' => $product->id,
'inventory_policy' => $inventoryPolicy->value,
'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(),
'nombre' => ucfirst($slugPrefix).' Variant', 'nombre' => ucfirst($slugPrefix).' Variant',
'stock' => $stock, 'stock' => $stock,
@ -211,21 +345,21 @@ class TelepagosWebhookTest extends TestCase
private function createTenant(string $codigo, string $nombre, string $dominio): Tenant private function createTenant(string $codigo, string $nombre, string $dominio): Tenant
{ {
$hdrKey = (string) \Illuminate\Support\Str::uuid(); $hdrKey = (string) Str::uuid();
$ftrKey = (string) \Illuminate\Support\Str::uuid(); $ftrKey = (string) Str::uuid();
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([ $headerAttachment = Attachment::create([
'key' => $hdrKey, 'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png', 'path' => 'tenants/'.$hdrKey.'.png',
'filename' => 'logo_header.png', 'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, 'type' => AttachmentType::Image,
'mime_type' => 'image/png', 'mime_type' => 'image/png',
]); ]);
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([ $footerAttachment = Attachment::create([
'key' => $ftrKey, 'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png', 'path' => 'tenants/'.$ftrKey.'.png',
'filename' => 'logo_footer.png', 'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, 'type' => AttachmentType::Image,
'mime_type' => 'image/png', 'mime_type' => 'image/png',
]); ]);

View File

@ -2,13 +2,19 @@
namespace Tests\Feature\Purchase; namespace Tests\Feature\Purchase;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase; use Tests\TestCase;
class StorePurchaseTest extends TestCase class StorePurchaseTest extends TestCase
@ -21,7 +27,7 @@ class StorePurchaseTest extends TestCase
$user = User::factory()->create([ $user = User::factory()->create([
'email' => 'buyer@example.com', 'email' => 'buyer@example.com',
]); ]);
$category = \App\Domains\Catalog\Models\Category::query()->create([ $category = Category::query()->create([
'tenant_code' => 'sonder', 'tenant_code' => 'sonder',
'nombre' => 'Test Category', 'nombre' => 'Test Category',
]); ]);
@ -45,7 +51,8 @@ class StorePurchaseTest extends TestCase
$cartResponse = $this->actingAs($user, 'sanctum') $cartResponse = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/cart/items', [ ->postJson('/api/tenants/sonder/cart/items', [
'product_variant_id' => $variant->id, 'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 2, 'cantidad' => 2,
]) ])
->assertOk(); ->assertOk();
@ -76,6 +83,7 @@ class StorePurchaseTest extends TestCase
$response->assertJsonPath('data.email', 'juan.perez@example.com'); $response->assertJsonPath('data.email', 'juan.perez@example.com');
$response->assertJsonPath('data.tenant_codigo', 'sonder'); $response->assertJsonPath('data.tenant_codigo', 'sonder');
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED); $response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
$response->assertJsonPath('data.items_source', null);
$response->assertJsonPath('data.items', []); $response->assertJsonPath('data.items', []);
$response->assertJsonPath('data.subtotal', '100.00'); $response->assertJsonPath('data.subtotal', '100.00');
$response->assertJsonPath('data.total', '100.00'); $response->assertJsonPath('data.total', '100.00');
@ -104,7 +112,8 @@ class StorePurchaseTest extends TestCase
]); ]);
$this->assertDatabaseHas('carrito_items', [ $this->assertDatabaseHas('carrito_items', [
'cart_id' => $cartId, 'cart_id' => $cartId,
'producto_variante_id' => $variant->id, 'buyable_type' => ProductVariant::class,
'buyable_id' => $variant->id,
'cantidad' => 2, 'cantidad' => 2,
]); ]);
$this->assertDatabaseHas('productos_variantes', [ $this->assertDatabaseHas('productos_variantes', [
@ -124,7 +133,8 @@ class StorePurchaseTest extends TestCase
$cartId = $this->actingAs($user, 'sanctum') $cartId = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/cart/items', [ ->postJson('/api/tenants/sonder/cart/items', [
'product_variant_id' => $variant->id, 'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 2, 'cantidad' => 2,
]) ])
->assertOk() ->assertOk()
@ -159,6 +169,158 @@ class StorePurchaseTest extends TestCase
]); ]);
} }
public function test_purchase_detail_uses_cart_items_for_created_purchase(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create([
'email' => 'buyer@example.com',
]);
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_CREATED)
->assertJsonPath('data.items_source', 'cart')
->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->product->id)
->assertJsonPath('data.items.0.product.nombre', $variant->product->nombre)
->assertJsonPath('data.items.0.product.slug', $variant->product->slug)
->assertJsonPath('data.items.0.product.imagen', null)
->assertJsonPath('data.items.0.variant.id', $variant->id)
->assertJsonPath('data.items.0.variant.attributes', [])
->assertJsonPath('data.subtotal', '100.00')
->assertJsonPath('data.total', '100.00');
}
public function test_purchase_detail_uses_cart_items_for_pending_payment_purchase(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create([
'email' => 'buyer@example.com',
]);
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$purchase->update([
'payment_method' => 'transfer',
]);
app(CheckoutService::class)->completePurchase($purchase);
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
->assertJsonPath('data.items_source', 'cart')
->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.subtotal', '100.00')
->assertJsonPath('data.total', '100.00');
}
public function test_purchase_detail_uses_purchase_items_for_paid_purchase_even_without_cart(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create([
'email' => 'buyer@example.com',
]);
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$purchase->update([
'payment_method' => 'transfer',
]);
$checkoutService = app(CheckoutService::class);
$purchase = $checkoutService->completePurchase($purchase);
$checkoutService->confirmPurchase($purchase);
$purchase->refresh()->markAsPaid();
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 8,
'stock_reservado' => 0,
'cantidad_vendida' => 2,
]);
$this->assertSoftDeleted('carritos', [
'id' => $purchase->cart_id,
]);
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_PAID)
->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->product->id)
->assertJsonPath('data.items.0.product.imagen', null)
->assertJsonPath('data.items.0.variant.id', $variant->id)
->assertJsonPath('data.items.0.variant.attributes', [])
->assertJsonPath('data.subtotal', '100.00')
->assertJsonPath('data.total', '100.00');
}
public function test_purchase_detail_prefers_purchase_items_when_both_sources_exist(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create([
'email' => 'buyer@example.com',
]);
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$purchase->items()->create([
'buyable_type' => ProductVariant::class,
'buyable_id' => $variant->id,
'cantidad' => 1,
'precio_unitario' => '50.00',
'discount_total' => null,
'tax_total' => null,
'total' => '50.00',
]);
$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');
}
public function test_purchase_index_returns_empty_items_without_loaded_relations(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create([
'email' => 'buyer@example.com',
]);
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$this->actingAs($user, 'sanctum')
->getJson('/api/tenants/sonder/compras?status=created')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.items_source', null)
->assertJsonPath('data.0.items', [])
->assertJsonPath('data.0.total', '100.00');
}
public function test_it_rejects_a_cart_from_another_user(): void public function test_it_rejects_a_cart_from_another_user(): void
{ {
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
@ -168,7 +330,8 @@ class StorePurchaseTest extends TestCase
$cartId = $this->actingAs($owner, 'sanctum') $cartId = $this->actingAs($owner, 'sanctum')
->postJson('/api/tenants/sonder/cart/items', [ ->postJson('/api/tenants/sonder/cart/items', [
'product_variant_id' => $variant->id, 'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 1, 'cantidad' => 1,
]) ])
->assertOk() ->assertOk()
@ -194,7 +357,8 @@ class StorePurchaseTest extends TestCase
$cartId = $this->actingAs($user, 'sanctum') $cartId = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/globex/cart/items', [ ->postJson('/api/tenants/globex/cart/items', [
'product_variant_id' => $variant->id, 'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 1, 'cantidad' => 1,
]) ])
->assertOk() ->assertOk()
@ -255,18 +419,48 @@ class StorePurchaseTest extends TestCase
->assertJsonValidationErrors(['cart_id']); ->assertJsonValidationErrors(['cart_id']);
} }
public function test_it_confirms_unlimited_inventory_without_reducing_real_stock_and_is_idempotent(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$variant = $this->createVariantForTenant(
'sonder',
0,
'50.00',
'unlimited',
InventoryPolicy::Unlimited,
);
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 25);
$purchase->update(['payment_method' => 'transfer']);
$checkoutService = app(CheckoutService::class);
$purchase = $checkoutService->completePurchase($purchase);
$checkoutService->confirmPurchase($purchase);
$checkoutService->confirmPurchase($purchase);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'inventory_policy' => InventoryPolicy::Unlimited->value,
'stock_real' => 0,
'stock_reservado' => 0,
'cantidad_vendida' => 25,
]);
$this->assertDatabaseCount('compra_items', 1);
}
protected function createVariantForTenant( protected function createVariantForTenant(
string $tenantCode, string $tenantCode,
int $stock, int $stock,
string $price, string $price,
string $slugPrefix = 'shirt', string $slugPrefix = 'shirt',
InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked,
): ProductVariant { ): ProductVariant {
$tenant = Tenant::query()->where('codigo', $tenantCode)->first(); $tenant = Tenant::query()->where('codigo', $tenantCode)->first();
if (! $tenant) { if (! $tenant) {
$this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com"); $this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com");
} }
$category = \App\Domains\Catalog\Models\Category::query()->create([ $category = Category::query()->create([
'tenant_code' => $tenantCode, 'tenant_code' => $tenantCode,
'nombre' => "{$slugPrefix} category {$tenantCode}", 'nombre' => "{$slugPrefix} category {$tenantCode}",
]); ]);
@ -282,6 +476,7 @@ class StorePurchaseTest extends TestCase
return ProductVariant::query()->create([ return ProductVariant::query()->create([
'producto_id' => $product->id, 'producto_id' => $product->id,
'inventory_policy' => $inventoryPolicy->value,
'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(),
'nombre' => ucfirst($slugPrefix).' Variant', 'nombre' => ucfirst($slugPrefix).' Variant',
'stock' => $stock, 'stock' => $stock,
@ -290,23 +485,47 @@ class StorePurchaseTest extends TestCase
])->load('product'); ])->load('product');
} }
protected function createCheckoutPurchase(
User $user,
string $tenantCode,
ProductVariant $variant,
int $quantity,
): Purchase {
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$cart = Cart::query()->create([
'tenant_codigo' => $tenantCode,
'user_id' => $user->id,
'status' => 'active',
]);
$cart->addItem(ProductVariant::class, $variant->id, $quantity);
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',
]);
}
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
{ {
$hdrKey = (string) \Illuminate\Support\Str::uuid(); $hdrKey = (string) Str::uuid();
$ftrKey = (string) \Illuminate\Support\Str::uuid(); $ftrKey = (string) Str::uuid();
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([ $headerAttachment = Attachment::create([
'key' => $hdrKey, 'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png', 'path' => 'tenants/'.$hdrKey.'.png',
'filename' => 'logo_header.png', 'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, 'type' => AttachmentType::Image,
'mime_type' => 'image/png', 'mime_type' => 'image/png',
]); ]);
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([ $footerAttachment = Attachment::create([
'key' => $ftrKey, 'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png', 'path' => 'tenants/'.$ftrKey.'.png',
'filename' => 'logo_footer.png', 'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, 'type' => AttachmentType::Image,
'mime_type' => 'image/png', 'mime_type' => 'image/png',
]); ]);

View File

@ -0,0 +1,95 @@
<?php
namespace Tests\Feature\Seeders;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Tenant\Models\Tenant;
use Database\Seeders\AttributeSeeder;
use Database\Seeders\FiestaFutbolInfantilProductSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class FiestaFutbolInfantilProductSeederTest extends TestCase
{
use RefreshDatabase;
public function test_it_seeds_event_attributes_and_bundles(): void
{
$headerLogo = Attachment::query()->create([
'path' => 'tests/header.png',
'filename' => 'header.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerLogo = Attachment::query()->create([
'path' => 'tests/footer.png',
'filename' => 'footer.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$tenant = Tenant::query()->create([
'codigo' => 'fiesta_futbol_infantil',
'nombre' => 'Fiesta Fútbol Infantil',
'dominio' => 'fiesta-futbol-infantil.localhost',
'primary_color' => '#00973F',
'secondary_color' => '#A0A0A0',
'danger_color' => '#FF8888',
'success_color' => '#198754',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#015327',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
]);
$this->seed([
AttributeSeeder::class,
FiestaFutbolInfantilProductSeeder::class,
]);
$this->assertFalse(Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', ['color', 'talle', 'talle_numerico'])
->exists());
$this->assertSame(
['fecha'],
Attribute::query()->where('tenant_codigo', $tenant->codigo)->pluck('codigo')->all()
);
$allDaysBundle = Bundle::query()
->where('tenant_codigo', $tenant->codigo)
->where('nombre', 'Entrada General - Todos los días')
->with('items.variant.product', 'items.variant.definitions')
->sole();
$this->assertSame('40000.00', $allDaysBundle->precio);
$this->assertCount(4, $allDaysBundle->items);
$this->assertEqualsCanonicalizing(
['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'],
$allDaysBundle->items->map(fn ($item) => $item->variant->definitions->sole()->value)->all()
);
$this->assertTrue($allDaysBundle->items->every(
fn ($item) => $item->cantidad === 1 && $item->variant->product->slug === 'entrada-general'
));
$foodBundle = Bundle::query()
->where('tenant_codigo', $tenant->codigo)
->where('nombre', 'Combo 2 Panchos + 2 Hamburguesas')
->with('items.variant.product')
->sole();
$this->assertSame('24000.00', $foodBundle->precio);
$this->assertEqualsCanonicalizing(
[
'pancho' => 2,
'hamburguesa-papa-frita' => 2,
],
$foodBundle->items->mapWithKeys(
fn ($item) => [$item->variant->product->slug => $item->cantidad]
)->all()
);
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace Tests\Unit\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class ProductVariantInventoryTest extends TestCase
{
use RefreshDatabase;
private Product $product;
protected function setUp(): void
{
parent::setUp();
$headerAttachment = $this->createAttachment('header.png');
$footerAttachment = $this->createAttachment('footer.png');
$tenant = Tenant::query()->create([
'codigo' => 'inventory-test',
'nombre' => 'Inventory Test',
'dominio' => 'inventory.test',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#28a745',
'header_bg_color' => '#444444',
'footer_bg_color' => '#444444',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
$category = Category::query()->create([
'tenant_code' => $tenant->codigo,
'nombre' => 'Inventory',
]);
$this->product = Product::query()->create([
'tenant_codigo' => $tenant->codigo,
'categoria_id' => $category->id,
'slug' => 'inventory-product',
'nombre' => 'Inventory Product',
'precio' => 100,
]);
}
public function test_it_defaults_to_tracked_inventory_with_no_sales(): void
{
$variant = $this->createVariant(5);
$this->assertSame(InventoryPolicy::Tracked, $variant->inventory_policy);
$this->assertSame(5, $variant->availableQuantity());
$this->assertSame(0, $variant->cantidad_vendida);
$this->assertTrue($variant->isAvailableForSale());
}
public function test_tracked_inventory_cannot_reserve_more_than_available_stock(): void
{
$variant = $this->createVariant(5);
$variant->reserveStock(3);
$this->assertSame(2, $variant->fresh()->availableQuantity());
$this->expectException(\InvalidArgumentException::class);
$variant->reserveStock(3);
}
public function test_unlimited_inventory_can_reserve_more_than_real_stock(): void
{
$variant = $this->createVariant(0, InventoryPolicy::Unlimited);
$variant->reserveStock(50);
$variant->refresh();
$this->assertNull($variant->availableQuantity());
$this->assertSame(50, $variant->stock_reservado);
$this->assertTrue($variant->isAvailableForSale());
}
public function test_buying_tracked_inventory_consumes_stock_and_records_the_sale(): void
{
$variant = $this->createVariant(10);
$variant->reserveStock(4);
$variant->buy(3);
$variant->refresh();
$this->assertSame(7, $variant->stock_real);
$this->assertSame(1, $variant->stock_reservado);
$this->assertSame(3, $variant->cantidad_vendida);
}
public function test_buying_unlimited_inventory_preserves_real_stock_and_records_the_sale(): void
{
$variant = $this->createVariant(0, InventoryPolicy::Unlimited);
$variant->reserveStock(4);
$variant->buy(3);
$variant->refresh();
$this->assertSame(0, $variant->stock_real);
$this->assertSame(1, $variant->stock_reservado);
$this->assertSame(3, $variant->cantidad_vendida);
}
public function test_buy_requires_enough_reserved_stock(): void
{
$variant = $this->createVariant(10);
$variant->reserveStock(1);
$this->expectException(\InvalidArgumentException::class);
$variant->buy(2);
}
public function test_inventory_policy_cannot_change_after_creation(): void
{
$variant = $this->createVariant(10);
$variant->inventory_policy = InventoryPolicy::Unlimited;
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('La politica de inventario no puede modificarse.');
$variant->save();
}
private function createVariant(
int $stock,
InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked,
): ProductVariant {
return ProductVariant::query()->create([
'producto_id' => $this->product->id,
'stock' => $stock,
'inventory_policy' => $inventoryPolicy->value,
]);
}
private function createAttachment(string $filename): Attachment
{
return Attachment::query()->create([
'key' => (string) Str::uuid(),
'path' => 'tests/'.$filename,
'filename' => $filename,
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
}