diff --git a/app/Domains/Catalog/Models/FeaturedGroup.php b/app/Domains/Catalog/Models/FeaturedGroup.php index 78af04d..fa62e0b 100644 --- a/app/Domains/Catalog/Models/FeaturedGroup.php +++ b/app/Domains/Catalog/Models/FeaturedGroup.php @@ -11,9 +11,11 @@ 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\Str; #[Fillable([ 'tenant_code', + 'code', 'source_type', 'category_id', 'product_layout', @@ -33,6 +35,29 @@ class FeaturedGroup extends Model 'source_type' => FeaturedGroupSource::Manual->value, ]; + protected static function booted(): void + { + static::creating(function (FeaturedGroup $featuredGroup): void { + if (filled($featuredGroup->code)) { + return; + } + + $baseCode = Str::slug($featuredGroup->group_name) ?: 'group'; + $code = $baseCode; + $suffix = 2; + + while (static::query() + ->where('tenant_code', $featuredGroup->tenant_code) + ->where('code', $code) + ->exists()) { + $code = "{$baseCode}-{$suffix}"; + $suffix++; + } + + $featuredGroup->code = $code; + }); + } + protected function casts(): array { return [ diff --git a/app/Domains/Catalog/Resources/AdminApp/OnTicketFeaturedGroupResource.php b/app/Domains/Catalog/Resources/AdminApp/OnTicketFeaturedGroupResource.php index 752f4b9..6ef384b 100644 --- a/app/Domains/Catalog/Resources/AdminApp/OnTicketFeaturedGroupResource.php +++ b/app/Domains/Catalog/Resources/AdminApp/OnTicketFeaturedGroupResource.php @@ -15,6 +15,7 @@ class OnTicketFeaturedGroupResource extends JsonResource { return [ 'id' => $this->id, + 'code' => $this->code, 'category_id' => $this->category_id, 'category_name' => $this->category->nombre, 'group_name' => $this->group_name, diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedGroupResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedGroupResource.php index 75fa70d..0bab72e 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedGroupResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedGroupResource.php @@ -20,6 +20,7 @@ class CatalogFeaturedGroupResource extends JsonResource { return [ 'id' => $this->id, + 'code' => $this->code, 'title' => $this->group_name, 'layout' => $this->product_layout->value, 'group_layout' => $this->group_layout->value, diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index dcf7c68..81ebcab 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -6,7 +6,9 @@ use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; +use Throwable; class ReleaseCheckoutService { @@ -40,7 +42,21 @@ class ReleaseCheckoutService ->where('expires_at', '<=', now()) ->orderBy('id') ->eachById(function (Purchase $purchase) use (&$expiredCount): void { - $purchase = $this->expire($purchase); + try { + $purchase = $this->expire($purchase); + } catch (Throwable $exception) { + Log::channel('commands')->error('Failed to expire overdue purchase.', [ + 'command' => 'reservations:expire', + 'purchase_id' => $purchase->getKey(), + 'tenant_codigo' => $purchase->tenant_codigo, + 'cart_id' => $purchase->cart_id, + 'status' => $purchase->status, + 'expires_at' => $purchase->expires_at, + 'exception' => $exception, + ]); + + return; + } if ($purchase->status === Purchase::STATUS_EXPIRED) { $expiredCount++; diff --git a/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php b/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php index d389d1a..f96235b 100644 --- a/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php +++ b/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php @@ -2,10 +2,12 @@ namespace App\Domains\Sale\Resources\AdminApp; +use App\Domains\Cart\Models\CartItem; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Collection; /** @mixin Purchase */ class SaleDetailResource extends JsonResource @@ -13,23 +15,44 @@ class SaleDetailResource extends JsonResource /** @return array */ public function toArray(Request $request): array { + $items = $this->saleItems(); + return [ 'id' => $this->id, - 'items' => $this->items->map(fn (PurchaseItem $item): array => [ + 'items' => $items->map(fn (PurchaseItem|CartItem $item): array => [ 'id' => $item->id, - 'product' => $item->item_nombre, + 'product' => $item instanceof PurchaseItem + ? $item->item_nombre + : $item->selectedItem()?->getName(), 'event_dates' => $this->eventDates($item), 'quantity' => (int) $item->cantidad, - 'unit_price' => $this->formatMoney($item->precio_unitario), - 'total' => $this->formatMoney($item->total), + 'unit_price' => $this->formatMoney($this->unitPrice($item)), + 'total' => $this->formatMoney($this->lineTotal($item)), ])->values(), 'total' => $this->formatMoney($this->total), ]; } - /** @return list */ - private function eventDates(PurchaseItem $item): array + /** @return Collection */ + private function saleItems(): Collection { + if ($this->items->isNotEmpty()) { + return $this->items; + } + + return $this->cart?->items ?? collect(); + } + + /** @return list */ + private function eventDates(PurchaseItem|CartItem $item): array + { + if ($item instanceof CartItem) { + return $item->variant?->selectedEventDates() + ->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d')) + ->values() + ->all() ?? []; + } + return collect($item->variant_attributes ?? []) ->filter(fn (mixed $attribute): bool => is_array($attribute) && mb_strtolower(trim((string) ($attribute['name'] ?? ''))) === 'fecha') @@ -43,6 +66,20 @@ class SaleDetailResource extends JsonResource ->all(); } + private function unitPrice(PurchaseItem|CartItem $item): float|int|string|null + { + return $item instanceof PurchaseItem + ? $item->precio_unitario + : $item->selectedItem()?->getPrice(); + } + + private function lineTotal(PurchaseItem|CartItem $item): float|int|string|null + { + return $item instanceof PurchaseItem + ? $item->total + : ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad; + } + private function formatMoney(float|int|string|null $amount): string { return number_format((float) ($amount ?? 0), 2, '.', ''); diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index 0a99f9b..95fec11 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -51,7 +51,13 @@ class AdminAppSaleService { return Purchase::query() ->where('tenant_codigo', $tenant->codigo) - ->with('items') + ->with([ + 'items', + 'cart.items.catalogItem', + 'cart.items.variant.catalogItem', + 'cart.items.variant.eventDates', + 'cart.items.variant.eventDate', + ]) ->findOrFail($saleId); } diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index 641fbe1..e2bb8f2 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -27,6 +27,8 @@ use Illuminate\Support\Facades\Schema; 'dominio', 'base_path', 'site_title', + 'address', + 'phone', 'primary_color', 'secondary_color', 'danger_color', diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index 2e53d7d..fa2480e 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -82,6 +82,8 @@ class StoreTenantRequest extends FormRequest ->where('dominio', $this->input('dominio')), ], 'site_title' => ['sometimes', 'nullable', 'string', 'max:255'], + 'address' => ['sometimes', 'nullable', 'string', 'max:255'], + 'phone' => ['sometimes', 'nullable', 'string', 'max:255'], 'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], 'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], 'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index c3ada00..b0ca850 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -103,6 +103,8 @@ class UpdateTenantRequest extends FormRequest ->ignore($tenant?->id), ], 'site_title' => ['sometimes', 'nullable', 'string', 'max:255'], + 'address' => ['sometimes', 'nullable', 'string', 'max:255'], + 'phone' => ['sometimes', 'nullable', 'string', 'max:255'], 'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], 'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], 'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 9cac3e6..b0f28cc 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -32,6 +32,8 @@ class TenantResource extends JsonResource 'site_title' => $this->site_title ?? $this->websiteType?->site_title ?? 'ShopitFront', + 'address' => $this->address, + 'phone' => $this->phone, 'favicon' => ($this->favicon ?? $this->websiteType?->favicon) ?->getTemporaryUrl(1440), 'primary_color' => $this->primary_color, diff --git a/database/migrations/2026_08_20_000000_add_contact_fields_to_tenants_table.php b/database/migrations/2026_08_20_000000_add_contact_fields_to_tenants_table.php new file mode 100644 index 0000000..5755517 --- /dev/null +++ b/database/migrations/2026_08_20_000000_add_contact_fields_to_tenants_table.php @@ -0,0 +1,29 @@ +string('address')->nullable()->after('site_title'); + $table->string('phone')->nullable()->after('address'); + }); + + DB::table('tenants')->update([ + 'address' => 'Av. San Lorenzo 1542, Rosario', + 'phone' => '54 9 (0341) 6658247', + ]); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn(['address', 'phone']); + }); + } +}; diff --git a/database/migrations/2026_08_20_010000_add_code_to_featured_groups.php b/database/migrations/2026_08_20_010000_add_code_to_featured_groups.php new file mode 100644 index 0000000..ba575ad --- /dev/null +++ b/database/migrations/2026_08_20_010000_add_code_to_featured_groups.php @@ -0,0 +1,53 @@ +string('code')->nullable()->after('tenant_code'); + }); + + $usedCodes = []; + + DB::table('featured_groups') + ->select(['id', 'tenant_code', 'group_name']) + ->orderBy('id') + ->get() + ->each(function (object $group) use (&$usedCodes): void { + $baseCode = Str::slug($group->group_name) ?: "group-{$group->id}"; + $code = $baseCode; + $suffix = 2; + + while (isset($usedCodes[$group->tenant_code][$code])) { + $code = "{$baseCode}-{$suffix}"; + $suffix++; + } + + $usedCodes[$group->tenant_code][$code] = true; + + DB::table('featured_groups') + ->where('id', $group->id) + ->update(['code' => $code]); + }); + + Schema::table('featured_groups', function (Blueprint $table): void { + $table->string('code')->nullable(false)->change(); + $table->unique(['tenant_code', 'code']); + }); + } + + public function down(): void + { + Schema::table('featured_groups', function (Blueprint $table): void { + $table->dropUnique(['tenant_code', 'code']); + $table->dropColumn('code'); + }); + } +}; diff --git a/tests/Feature/Catalog/OnTicketFeaturedGroupControllerTest.php b/tests/Feature/Catalog/OnTicketFeaturedGroupControllerTest.php index 9c0d73c..1c251b6 100644 --- a/tests/Feature/Catalog/OnTicketFeaturedGroupControllerTest.php +++ b/tests/Feature/Catalog/OnTicketFeaturedGroupControllerTest.php @@ -81,6 +81,7 @@ class OnTicketFeaturedGroupControllerTest extends TestCase 'is_featured' => true, ]) ->assertCreated() + ->assertJsonPath('data.code', 'food') ->assertJsonPath('data.category_name', 'Food') ->assertJsonPath('data.group_name', 'Food') ->assertJsonPath('data.is_featured', true) @@ -96,6 +97,7 @@ class OnTicketFeaturedGroupControllerTest extends TestCase ]); $this->assertDatabaseHas('featured_groups', [ 'tenant_code' => $tenant->codigo, + 'code' => 'food', 'source_type' => 'category', 'category_id' => $categoryId, 'product_layout' => 'row', @@ -130,6 +132,7 @@ class OnTicketFeaturedGroupControllerTest extends TestCase 'is_featured' => true, ]) ->assertOk() + ->assertJsonPath('data.code', 'old-name') ->assertJsonPath('data.category_name', 'New name') ->assertJsonPath('data.group_name', 'New name') ->assertJsonPath('data.is_featured', true) @@ -142,6 +145,7 @@ class OnTicketFeaturedGroupControllerTest extends TestCase ]); $this->assertDatabaseHas('featured_groups', [ 'id' => $group->id, + 'code' => 'old-name', 'group_name' => 'New name', 'product_layout' => 'row', 'group_layout' => 'paginated', diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 870d356..49060e2 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -1118,6 +1118,43 @@ class StorePurchaseTest extends TestCase ->assertSuccessful(); } + public function test_it_continues_expiring_purchases_after_an_inconsistent_reservation(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $inconsistentPurchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1); + $validPurchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1); + + $inconsistentPurchase->items()->create([ + 'source_catalog_item_id' => $variant->catalog_item_id, + 'source_variant_id' => $variant->id, + 'nombre' => 'Inconsistent item', + 'slug' => 'inconsistent-item', + 'cantidad' => 1, + 'precio_unitario' => '50.00', + 'discount_total' => '0.00', + 'tax_total' => '0.00', + 'total' => '50.00', + ]); + + $this->travel(31)->minutes(); + + $this->artisan('reservations:expire') + ->expectsOutput('Expired purchases: 1') + ->expectsOutput('Expired cart items: 0') + ->assertSuccessful(); + + $this->assertDatabaseHas('compras', [ + 'id' => $inconsistentPurchase->id, + 'status' => Purchase::STATUS_CREATED, + ]); + $this->assertDatabaseHas('compras', [ + 'id' => $validPurchase->id, + 'status' => Purchase::STATUS_EXPIRED, + ]); + } + public function test_purchase_detail_uses_cart_items_for_created_purchase(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index 6d1c414..5235e38 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Sale; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Cart\Models\Cart; +use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Purchase\Models\Purchase; @@ -71,6 +72,44 @@ class AdminAppSaleControllerTest extends TestCase ->assertJsonPath('data.total', '40000.00'); } + public function test_an_adminapp_user_can_read_cart_items_from_an_unconfirmed_sale(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'entrada-general', + 'nombre' => 'Entrada general', + 'descripcion' => 'Acceso general', + 'precio' => '12500.00', + ]); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => 'checkout', + ]); + $cartItem = CartItem::query()->create([ + 'cart_id' => $cart->id, + 'catalog_item_id' => $catalogItem->id, + 'cantidad' => 2, + ]); + $purchase = Purchase::query()->create([ + 'cart_id' => $cart->id, + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'total' => '25000.00', + ]); + + $this->getJson("/api/v1/adminapp/tenant/sales/{$purchase->id}") + ->assertOk() + ->assertJsonPath('data.items.0.id', $cartItem->id) + ->assertJsonPath('data.items.0.product', 'Entrada general') + ->assertJsonPath('data.items.0.event_dates', []) + ->assertJsonPath('data.items.0.quantity', 2) + ->assertJsonPath('data.items.0.unit_price', '12500.00') + ->assertJsonPath('data.items.0.total', '25000.00'); + } + public function test_an_adminapp_user_cannot_read_a_sale_from_another_tenant(): void { $tenant = $this->createTenant('acme'); diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index 4316235..f4734e4 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -59,6 +59,8 @@ class BootstrapTenantControllerTest extends TestCase 'codigo' => 'acme', 'nombre' => 'Acme', 'dominio' => 'acme.com', + 'address' => 'Calle Test 123, Rosario', + 'phone' => '+54 341 555 1234', 'primary_color' => '#ff0000', 'secondary_color' => '#00ff00', 'danger_color' => '#0000ff', @@ -84,6 +86,8 @@ class BootstrapTenantControllerTest extends TestCase ->assertOk() ->assertJsonPath('data.codigo', 'acme') ->assertJsonPath('data.dominio', 'acme.com') + ->assertJsonPath('data.address', 'Calle Test 123, Rosario') + ->assertJsonPath('data.phone', '+54 341 555 1234') ->assertJsonPath('data.primary_color', '#ff0000') ->assertJsonPath('data.secondary_color', '#00ff00') ->assertJsonPath('data.danger_color', '#0000ff') diff --git a/tests/Unit/Sale/SaleDetailResourceTest.php b/tests/Unit/Sale/SaleDetailResourceTest.php index 676bacf..3962e21 100644 --- a/tests/Unit/Sale/SaleDetailResourceTest.php +++ b/tests/Unit/Sale/SaleDetailResourceTest.php @@ -2,6 +2,9 @@ namespace Tests\Unit\Sale; +use App\Domains\Cart\Models\Cart; +use App\Domains\Cart\Models\CartItem; +use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Sale\Resources\AdminApp\SaleDetailResource; @@ -38,4 +41,39 @@ class SaleDetailResourceTest extends TestCase $this->assertSame('30000.00', $data['items'][0]['total']); $this->assertSame('30000.00', $data['total']); } + + public function test_it_uses_the_associated_cart_items_when_the_purchase_has_no_items(): void + { + $catalogItem = (new CatalogItem)->forceFill([ + 'id' => 21, + 'nombre' => 'Entrada general', + 'precio' => '12500.00', + ]); + $cartItem = (new CartItem)->forceFill([ + 'id' => 34, + 'catalog_item_id' => $catalogItem->id, + 'cantidad' => 2, + ]); + $cartItem->setRelation('catalogItem', $catalogItem); + $cartItem->setRelation('variant', null); + + $cart = new Cart; + $cart->setRelation('items', collect([$cartItem])); + + $purchase = (new Purchase)->forceFill([ + 'id' => 16, + 'total' => '25000.00', + ]); + $purchase->setRelation('items', collect()); + $purchase->setRelation('cart', $cart); + + $data = (new SaleDetailResource($purchase))->resolve(Request::create('/')); + + $this->assertSame(34, $data['items'][0]['id']); + $this->assertSame('Entrada general', $data['items'][0]['product']); + $this->assertSame([], $data['items'][0]['event_dates']); + $this->assertSame(2, $data['items'][0]['quantity']); + $this->assertSame('12500.00', $data['items'][0]['unit_price']); + $this->assertSame('25000.00', $data['items'][0]['total']); + } }