From dc32eb9bee8dd4d6b2e63c7eb290d2e964f88e54 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 6 Jul 2026 14:06:25 -0300 Subject: [PATCH] feat: enhance checkout process with cart integration and stock confirmation --- app/Domains/Cart/Models/Cart.php | 2 + app/Domains/Catalog/Models/ProductVariant.php | 19 ++ .../Services/TelepagosWebhookService.php | 6 + .../Controllers/PurchaseController.php | 2 +- app/Domains/Purchase/Models/Purchase.php | 32 ++- .../Requests/StorePurchaseRequest.php | 4 +- .../Purchase/Resources/PurchaseResource.php | 1 + .../Purchase/Services/CheckoutService.php | 147 ++++++++++--- ...06_130000_add_cart_id_to_compras_table.php | 33 +++ ...30200_add_deleted_at_to_carritos_table.php | 28 +++ tests/Feature/Purchase/StorePurchaseTest.php | 193 +++++++++++++++--- 11 files changed, 403 insertions(+), 64 deletions(-) create mode 100644 database/migrations/2026_07_06_130000_add_cart_id_to_compras_table.php create mode 100644 database/migrations/2026_07_06_130200_add_deleted_at_to_carritos_table.php diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index b3e9103..d4aac7b 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -10,6 +10,7 @@ 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\SoftDeletes; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -23,6 +24,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class Cart extends Model { use HasFactory; + use SoftDeletes; protected $table = 'carritos'; diff --git a/app/Domains/Catalog/Models/ProductVariant.php b/app/Domains/Catalog/Models/ProductVariant.php index 2fd2b6e..1ff640a 100644 --- a/app/Domains/Catalog/Models/ProductVariant.php +++ b/app/Domains/Catalog/Models/ProductVariant.php @@ -85,6 +85,25 @@ class ProductVariant extends Model $this->save(); } + public function confirmReservedStock(int $amount): void + { + if ($amount < 0) { + throw new \InvalidArgumentException('El monto a confirmar debe ser positivo.'); + } + + if ($this->stock_real < $amount) { + throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la reserva.'); + } + + if ($this->stock_reservado < $amount) { + throw new \InvalidArgumentException('No hay suficiente stock reservado para confirmar la reserva.'); + } + + $this->stock_real -= $amount; + $this->stock_reservado -= $amount; + $this->save(); + } + protected function stockTecnico(): Attribute { return Attribute::get(fn () => $this->stock_real - $this->stock_reservado); diff --git a/app/Domains/Integration/Services/TelepagosWebhookService.php b/app/Domains/Integration/Services/TelepagosWebhookService.php index 19ae609..ceef23e 100644 --- a/app/Domains/Integration/Services/TelepagosWebhookService.php +++ b/app/Domains/Integration/Services/TelepagosWebhookService.php @@ -2,6 +2,7 @@ namespace App\Domains\Integration\Services; +use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Models\TelepagosPayment; use App\Domains\Purchase\Models\TelepagosQr; use App\Domains\Tenant\Models\Tenant; @@ -10,6 +11,10 @@ use Illuminate\Support\Facades\Log; class TelepagosWebhookService { + public function __construct( + private readonly CheckoutService $checkoutService, + ) {} + /** * Handle the Telepagos webhook notification. * @@ -79,6 +84,7 @@ class TelepagosWebhookService \Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) { TelepagosPayment::create($paymentData); + $this->checkoutService->confirmPurchase($compra); $compra->finalize(); }); diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 1d75712..aa45d6d 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -32,7 +32,7 @@ class PurchaseController extends Controller { $data = $request->validated(); - $purchase = $checkoutService->processCheckout( + $purchase = $checkoutService->startCheckout( $tenant, $request->user()->id, $data diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index 2b8df34..9760240 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -3,14 +3,17 @@ namespace App\Domains\Purchase\Models; use App\Domains\Auth\Models\User; +use App\Domains\Cart\Models\Cart; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; 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\Support\Collection; #[Fillable([ + 'cart_id', 'tenant_codigo', 'user_id', 'status', @@ -30,6 +33,7 @@ class Purchase extends Model protected function casts(): array { return [ + 'cart_id' => 'integer', 'user_id' => 'integer', ]; } @@ -50,6 +54,14 @@ class Purchase extends Model return $this->belongsTo(User::class); } + /** + * @return BelongsTo + */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class, 'cart_id')->withTrashed(); + } + /** * @return HasMany */ @@ -76,7 +88,25 @@ class Purchase extends Model public function getTotalAmount(): float { - return (float) $this->items()->sum('total'); + if ($this->items()->exists()) { + return (float) $this->items()->sum('total'); + } + + $cart = $this->relationLoaded('cart') ? $this->getRelation('cart') : $this->cart; + + if ($cart === null) { + return 0.0; + } + + /** @var Collection $items */ + $items = $cart->relationLoaded('items') + ? $cart->getRelation('items') + : $cart->items()->with('variant.product')->get(); + + return $items->reduce( + static fn (float $carry, $item): float => $carry + ((float) ($item->variant?->product?->precio ?? 0) * (int) $item->cantidad), + 0.0, + ); } public function finalize(): void diff --git a/app/Domains/Purchase/Requests/StorePurchaseRequest.php b/app/Domains/Purchase/Requests/StorePurchaseRequest.php index 1bb33af..c9f07da 100644 --- a/app/Domains/Purchase/Requests/StorePurchaseRequest.php +++ b/app/Domains/Purchase/Requests/StorePurchaseRequest.php @@ -19,13 +19,11 @@ class StorePurchaseRequest extends FormRequest { return [ 'status' => ['sometimes', 'string', Rule::in(['pending', 'paid', 'cancelled'])], + 'cart_id' => ['required', 'integer', 'exists:carritos,id'], 'dni' => ['required', 'string'], 'telefono' => ['required', 'string'], 'nombre_apellido' => ['required', 'string'], 'email' => ['required', 'string', 'email'], - 'items' => ['required', 'array', 'min:1'], - 'items.*.producto_variante_id' => ['required', 'integer', 'exists:productos_variantes,id'], - 'items.*.cantidad' => ['required', 'integer', 'min:1'], ]; } } diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index bf6ca9a..2bcaff8 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -31,6 +31,7 @@ class PurchaseResource extends JsonResource return [ 'id' => $this->id, + 'cart_id' => $this->cart_id, 'tenant_codigo' => $this->tenant_codigo, 'user_id' => $this->user_id, 'status' => $this->status, diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 413457e..c42b4b6 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -2,28 +2,37 @@ namespace App\Domains\Purchase\Services; +use App\Domains\Cart\Models\Cart; +use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class CheckoutService { - public function processCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase + public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase { - // 1. Preparar items de la compra - $items = $purchaseData['items']; - unset($purchaseData['items']); + $cartId = (int) $purchaseData['cart_id']; + unset($purchaseData['cart_id']); - $variants = $this->resolveTenantVariants($tenant, $items); - $purchaseItemsPayload = $this->buildPurchaseItemsPayload($items, $variants); + return DB::transaction(function () use ($tenant, $userId, $purchaseData, $cartId): Purchase { + $cart = $this->resolveCheckoutCart($tenant, $userId, $cartId); + $cartItems = $cart->items()->lockForUpdate()->get(); + + if ($cartItems->isEmpty()) { + throw ValidationException::withMessages([ + 'cart_id' => 'The selected cart does not contain items.', + ]); + } - // 2. Crear la orden de compra y vaciar carrito - return DB::transaction(function () use ($tenant, $userId, $purchaseData, $purchaseItemsPayload): Purchase { /** @var Purchase $purchase */ $purchase = Purchase::query()->create([ ...$purchaseData, + 'cart_id' => $cart->getKey(), 'tenant_codigo' => $tenant->codigo, 'user_id' => $userId, 'status' => 'pending', @@ -31,45 +40,66 @@ class CheckoutService 'payment_method' => null, ]); - $purchase->items()->createMany($purchaseItemsPayload); - - // Vaciar y eliminar el carrito activo del usuario - $cart = \App\Domains\Cart\Models\Cart::query() - ->where('tenant_codigo', $tenant->codigo) - ->where('user_id', $userId) - ->first(); - if ($cart) { - $cart->items()->delete(); - $cart->delete(); - } - return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']); }); } - /** - * @param array> $items - * @return \Illuminate\Support\Collection - */ - protected function resolveTenantVariants(Tenant $tenant, array $items) + public function confirmPurchase(Purchase $purchase): void { - $variantIds = collect($items) + DB::transaction(function () use ($purchase): void { + /** @var Cart|null $cart */ + $cart = $purchase->cart()->lockForUpdate()->first(); + + if ($cart === null) { + throw ValidationException::withMessages([ + 'cart_id' => 'The purchase cart is no longer available.', + ]); + } + + $cartItems = $cart->items()->lockForUpdate()->get(); + + if ($cartItems->isEmpty()) { + throw ValidationException::withMessages([ + 'cart_id' => 'The purchase cart does not contain items.', + ]); + } + + if ($purchase->items()->exists()) { + return; + } + + $cartItems->load('variant.product'); + $variants = $this->resolveTenantVariants($purchase->tenant, $cartItems); + $purchaseItemsPayload = $this->buildPurchaseItemsPayload($cartItems, $variants); + + $purchase->items()->createMany($purchaseItemsPayload); + $this->completeCartConversion($cart, $cartItems, $variants); + }); + } + + /** + * @return Collection + */ + protected function resolveTenantVariants(Tenant $tenant, Collection $cartItems): Collection + { + $variantIds = $cartItems ->pluck('producto_variante_id') - ->filter() ->map(static fn (mixed $id): int => (int) $id) ->unique() ->values(); + /** @var Collection $variants */ $variants = ProductVariant::query() ->with('product') ->whereIn('id', $variantIds) ->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo)) + ->lockForUpdate() ->get() ->keyBy('id'); if ($variants->count() !== $variantIds->count()) { throw ValidationException::withMessages([ - 'items' => 'One or more product variants do not belong to the tenant.', + 'cart_id' => 'One or more product variants do not belong to the tenant.', ]); } @@ -77,14 +107,38 @@ class CheckoutService } /** - * @param array> $items - * @param \Illuminate\Support\Collection $variants + * @param Collection $cartItems + * @return \Illuminate\Support\Collection + */ + protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart + { + /** @var Cart|null $cart */ + $cart = Cart::query() + ->lockForUpdate() + ->find($cartId); + + if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) { + throw new NotFoundHttpException('Cart not found for tenant.'); + } + + if ($cart->status !== 'active') { + throw ValidationException::withMessages([ + 'cart_id' => 'The selected cart is no longer active.', + ]); + } + + return $cart; + } + + /** + * @param Collection $cartItems + * @param Collection $variants * @return array> */ - protected function buildPurchaseItemsPayload(array $items, $variants): array + protected function buildPurchaseItemsPayload(Collection $cartItems, Collection $variants): array { - return collect($items) - ->map(function (array $item) use ($variants): array { + return $cartItems + ->map(function (CartItem $item) use ($variants): array { /** @var ProductVariant $variant */ $variant = $variants->get((int) $item['producto_variante_id']); $quantity = (int) $item['cantidad']; @@ -101,4 +155,31 @@ class CheckoutService }) ->all(); } + + /** + * @param Collection $cartItems + * @param Collection $variants + */ + protected function completeCartConversion(Cart $cart, Collection $cartItems, Collection $variants): void + { + foreach ($cartItems as $item) { + /** @var ProductVariant $variant */ + $variant = $variants->get((int) $item->producto_variante_id); + $quantity = (int) $item->cantidad; + + try { + $variant->confirmReservedStock($quantity); + } catch (\InvalidArgumentException $exception) { + throw ValidationException::withMessages([ + 'cart_id' => 'The selected cart has inconsistent stock state.', + ]); + } + } + + $cart->status = 'converted'; + $cart->user_id = null; + $cart->guest_token = null; + $cart->save(); + $cart->delete(); + } } diff --git a/database/migrations/2026_07_06_130000_add_cart_id_to_compras_table.php b/database/migrations/2026_07_06_130000_add_cart_id_to_compras_table.php new file mode 100644 index 0000000..4c4af6e --- /dev/null +++ b/database/migrations/2026_07_06_130000_add_cart_id_to_compras_table.php @@ -0,0 +1,33 @@ +foreignId('cart_id') + ->nullable() + ->after('user_id') + ->constrained('carritos') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('compras', function (Blueprint $table) { + $table->dropConstrainedForeignId('cart_id'); + }); + } +}; diff --git a/database/migrations/2026_07_06_130200_add_deleted_at_to_carritos_table.php b/database/migrations/2026_07_06_130200_add_deleted_at_to_carritos_table.php new file mode 100644 index 0000000..9e6ccb8 --- /dev/null +++ b/database/migrations/2026_07_06_130200_add_deleted_at_to_carritos_table.php @@ -0,0 +1,28 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('carritos', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index d1a8a42..05b4f56 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Purchase; use App\Domains\Auth\Models\User; +use App\Domains\Cart\Models\Cart; use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Tenant\Models\Tenant; @@ -13,17 +14,12 @@ class StorePurchaseTest extends TestCase { use RefreshDatabase; - public function test_it_creates_a_purchase_with_customer_details_and_clears_the_cart(): void + public function test_it_creates_a_purchase_from_cart_id_without_persisting_items_yet(): void { - // 1. Setup Tenant $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - - // 2. Setup User $user = User::factory()->create([ - 'email' => 'buyer@example.com' + 'email' => 'buyer@example.com', ]); - - // 3. Setup Product & Variant $category = \App\Domains\Catalog\Models\Category::query()->create([ 'tenant_code' => 'sonder', 'nombre' => 'Test Category', @@ -46,61 +42,206 @@ class StorePurchaseTest extends TestCase 'precio' => '50.00', ]); - // 4. Add item to user's cart - $this->actingAs($user, 'sanctum') + $cartResponse = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/sonder/cart/items', [ 'product_variant_id' => $variant->id, 'cantidad' => 2, ]) ->assertOk(); - // Check cart exists in DB + $cartId = $cartResponse->json('data.id'); + $this->assertDatabaseHas('carritos', [ + 'id' => $cartId, 'tenant_codigo' => 'sonder', 'user_id' => $user->id, + 'status' => 'active', ]); - // 5. Submit Purchase with payment_method at root $response = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/sonder/compras', [ + 'cart_id' => $cartId, 'dni' => '987654321', 'telefono' => '+54 9 341 555-4321', 'nombre_apellido' => 'Juan Perez', 'email' => 'juan.perez@example.com', - 'payment_method' => 'transferencia', - 'items' => [ - [ - 'producto_variante_id' => $variant->id, - 'cantidad' => 2, - ] - ] ]); - // 6. Assertions $response->assertCreated(); + $response->assertJsonPath('data.cart_id', $cartId); $response->assertJsonPath('data.dni', '987654321'); $response->assertJsonPath('data.telefono', '+54 9 341 555-4321'); $response->assertJsonPath('data.nombre_apellido', 'Juan Perez'); $response->assertJsonPath('data.email', 'juan.perez@example.com'); - $response->assertJsonPath('data.payment_method', 'transferencia'); $response->assertJsonPath('data.tenant_codigo', 'sonder'); + $response->assertJsonPath('data.items', []); + $response->assertJsonPath('data.total', '0.00'); + + $purchaseId = $response->json('data.id'); - // Assert Purchase saved in DB $this->assertDatabaseHas('compras', [ + 'id' => $purchaseId, + 'cart_id' => $cartId, 'tenant_codigo' => 'sonder', 'user_id' => $user->id, 'dni' => '987654321', 'telefono' => '+54 9 341 555-4321', 'nombre_apellido' => 'Juan Perez', 'email' => 'juan.perez@example.com', - 'payment_method' => 'transferencia', ]); - - // Assert Cart was cleared (deleted) - $this->assertDatabaseMissing('carritos', [ - 'tenant_codigo' => 'sonder', + $this->assertDatabaseMissing('compra_items', [ + 'compra_id' => $purchaseId, + ]); + $this->assertDatabaseHas('carritos', [ + 'id' => $cartId, + 'status' => 'active', 'user_id' => $user->id, ]); + $this->assertDatabaseMissing('carritos', [ + 'id' => $cartId, + 'deleted_at' => null, + ]); + $this->assertDatabaseHas('carrito_items', [ + 'cart_id' => $cartId, + 'producto_variante_id' => $variant->id, + 'cantidad' => 2, + ]); + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $variant->id, + 'stock_real' => 10, + 'stock_reservado' => 2, + ]); + } + + public function test_it_rejects_a_cart_from_another_user(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $owner = User::factory()->create(); + $attacker = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + + $cartId = $this->actingAs($owner, 'sanctum') + ->postJson('/api/tenants/sonder/cart/items', [ + 'product_variant_id' => $variant->id, + 'cantidad' => 1, + ]) + ->assertOk() + ->json('data.id'); + + $this->actingAs($attacker, 'sanctum') + ->postJson('/api/tenants/sonder/compras', [ + 'cart_id' => $cartId, + 'dni' => '12345678', + 'telefono' => '+54 9 341 555-1111', + 'nombre_apellido' => 'Intruso', + 'email' => 'intruso@example.com', + ]) + ->assertNotFound(); + } + + public function test_it_rejects_a_cart_from_another_tenant(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $this->createTenant('globex', 'Globex', 'globex.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('globex', 10, '50.00'); + + $cartId = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/globex/cart/items', [ + 'product_variant_id' => $variant->id, + 'cantidad' => 1, + ]) + ->assertOk() + ->json('data.id'); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras', [ + 'cart_id' => $cartId, + 'dni' => '12345678', + 'telefono' => '+54 9 341 555-1111', + 'nombre_apellido' => 'Juan Perez', + 'email' => 'juan.perez@example.com', + ]) + ->assertNotFound(); + } + + public function test_it_rejects_an_empty_cart(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $cart = Cart::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => 'active', + ]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras', [ + 'cart_id' => $cart->id, + 'dni' => '12345678', + 'telefono' => '+54 9 341 555-1111', + 'nombre_apellido' => 'Juan Perez', + 'email' => 'juan.perez@example.com', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['cart_id']); + } + + public function test_it_rejects_a_converted_cart(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $cart = Cart::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => 'converted', + ]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras', [ + 'cart_id' => $cart->id, + 'dni' => '12345678', + 'telefono' => '+54 9 341 555-1111', + 'nombre_apellido' => 'Juan Perez', + 'email' => 'juan.perez@example.com', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['cart_id']); + } + + protected function createVariantForTenant( + string $tenantCode, + int $stock, + string $price, + string $slugPrefix = 'shirt', + ): ProductVariant { + $tenant = Tenant::query()->where('codigo', $tenantCode)->first(); + if (! $tenant) { + $this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com"); + } + + $category = \App\Domains\Catalog\Models\Category::query()->create([ + 'tenant_code' => $tenantCode, + 'nombre' => "{$slugPrefix} category {$tenantCode}", + ]); + + $product = Product::query()->create([ + 'tenant_codigo' => $tenantCode, + 'categoria_id' => $category->id, + 'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(), + 'nombre' => ucfirst($slugPrefix)." {$tenantCode}", + 'descripcion' => 'Test product', + 'precio' => $price, + ]); + + return ProductVariant::query()->create([ + 'producto_id' => $product->id, + 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), + 'nombre' => ucfirst($slugPrefix).' Variant', + 'stock' => $stock, + 'descripcion' => 'Test variant', + 'precio' => $price, + ])->load('product'); } protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant