diff --git a/.env.example b/.env.example index a82d56d..fd77644 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,7 @@ COMMANDS_LOG_LEVEL=info COMMANDS_LOG_DAYS=30 EMAILS_LOG_LEVEL=info EMAILS_LOG_DAYS=30 +EMAIL_DELIVERY_LEASE_SECONDS=300 DB_CONNECTION=mysql DB_HOST=127.0.0.1 diff --git a/AGENTS.md b/AGENTS.md index 011848b..075fcaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # Project Conventions +## Test database safety + +- Tests must use SQLite `:memory:` through `tests/bootstrap.php` and `Tests\TestCase`. +- Never run tests, `migrate:fresh`, `migrate:refresh`, or `db:wipe` against a persistent database, including the developer's `shopit` database. +- Never bypass the connection safety guard to resolve test failures. Use `php tests/verify-database-safety.php` to verify isolation without queries or migrations. + ## Architecture This project uses a domain-oriented structure under `app/Domains`. diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index b895726..c855602 100644 --- a/ShopIt_API_Postman_Collection.json +++ b/ShopIt_API_Postman_Collection.json @@ -1249,7 +1249,7 @@ "name": "Ticket", "item": [ { - "name": "List Ticket", + "name": "List Scan Attempt", "request": { "method": "GET", "header": [ @@ -5998,9 +5998,9 @@ "type": "text" } ], - "description": "Ruta Laravel: `GET /api/v1/scanner/tickets`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\Scanner\\TicketController@index`\n\nRequiere autenticación Sanctum.", + "description": "Ruta Laravel: `GET /api/v1/scanner/attempts`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\Scanner\\ScanAttemptController`\n\nRequiere autenticación Sanctum.", "url": { - "raw": "{{base_url}}/api/v1/scanner/tickets?page=1&per_page=20", + "raw": "{{base_url}}/api/v1/scanner/attempts?page=1&per_page=20", "host": [ "{{base_url}}" ], @@ -6008,7 +6008,7 @@ "api", "v1", "scanner", - "tickets" + "attempts" ], "query": [ { @@ -6086,11 +6086,16 @@ "key": "Accept", "value": "application/json", "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" } ], - "description": "Ruta Laravel: `POST /api/v1/scanner/tickets/{ticketUuid}/scan`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\Scanner\\TicketController@scan`\n\nRequiere autenticación Sanctum.", + "description": "Ruta Laravel: `POST /api/v1/scanner/tickets/scan`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\Scanner\\TicketController@scan`\n\nRequiere autenticación Sanctum.", "url": { - "raw": "{{base_url}}/api/v1/scanner/tickets/{{ticket_uuid}}/scan", + "raw": "{{base_url}}/api/v1/scanner/tickets/scan", "host": [ "{{base_url}}" ], @@ -6099,10 +6104,18 @@ "v1", "scanner", "tickets", - "{{ticket_uuid}}", "scan" ] }, + "body": { + "mode": "raw", + "raw": "{\n \"data\": \"{{ticket_uuid}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, "auth": { "type": "bearer", "bearer": [ diff --git a/app/Domains/Auth/Models/User.php b/app/Domains/Auth/Models/User.php index 8a3979b..34f3d49 100644 --- a/app/Domains/Auth/Models/User.php +++ b/app/Domains/Auth/Models/User.php @@ -5,7 +5,9 @@ namespace App\Domains\Auth\Models; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Models\Role; use App\Domains\Catalog\Models\Category; +use App\Domains\Event\Models\EventDateChangeView; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Models\ScanAttempt; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; @@ -46,6 +48,18 @@ class User extends Authenticatable return $this->hasMany(LoginAttempt::class); } + /** @return HasMany */ + public function scanAttempts(): HasMany + { + return $this->hasMany(ScanAttempt::class, 'scanner_user_id'); + } + + /** @return HasMany */ + public function eventDateChangeViews(): HasMany + { + return $this->hasMany(EventDateChangeView::class); + } + /** * @return BelongsTo */ diff --git a/app/Domains/Auth/Resources/UserResource.php b/app/Domains/Auth/Resources/UserResource.php index 89cee51..661aa09 100644 --- a/app/Domains/Auth/Resources/UserResource.php +++ b/app/Domains/Auth/Resources/UserResource.php @@ -24,6 +24,11 @@ class UserResource extends JsonResource 'telefono' => $this->telefono, 'rol_codigo' => $this->rol_codigo, 'tenant_codigo' => $this->tenant_codigo, + 'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories + ->map(fn ($category) => [ + 'id' => $category->id, + 'nombre' => $category->nombre, + ])->values()), ]; } } diff --git a/app/Domains/Auth/Services/ScannerContextService.php b/app/Domains/Auth/Services/ScannerContextService.php index b4acbb3..313d0a5 100644 --- a/app/Domains/Auth/Services/ScannerContextService.php +++ b/app/Domains/Auth/Services/ScannerContextService.php @@ -19,6 +19,16 @@ class ScannerContextService $user->setRelation('tenant', $tenant); + if ($tenant->requiresScannerCategoryValidation()) { + $categories = $user->scanCategories() + ->orderBy('nombre') + ->get(); + + if ($categories->isNotEmpty()) { + $user->setRelation('scanCategories', $categories); + } + } + return $user; } } diff --git a/app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php b/app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php index 63ba328..e81effb 100644 --- a/app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php +++ b/app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php @@ -17,6 +17,7 @@ class AdminAppBootstrapResource extends JsonResource return [ 'website_type_code' => $websiteType->codigo, + 'site_title' => $websiteType->site_title, 'primary_color' => $websiteType->primary_color, 'secondary_color' => $websiteType->secondary_color, 'danger_color' => $websiteType->danger_color, diff --git a/app/Domains/Bootstrap/Services/TenantBootstrapService.php b/app/Domains/Bootstrap/Services/TenantBootstrapService.php index 0327c4e..b4c6352 100644 --- a/app/Domains/Bootstrap/Services/TenantBootstrapService.php +++ b/app/Domains/Bootstrap/Services/TenantBootstrapService.php @@ -32,6 +32,7 @@ class TenantBootstrapService return $this->tenantInformationService->load( $tenant, [ + 'eventDateChanges', 'menues' => fn ($query) => $query->whereHas( 'roles', fn ($query) => $query->where('codigo', RoleCode::User->value) diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 9c6ea42..afdaa79 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -400,6 +400,17 @@ class Cart extends Model ]); } + if ($catalogItem->bundleComponents() + ->whereNotNull('component_variant_id') + ->whereHas('variant', fn ($query) => $query + ->whereNotNull('sales_disabled_at') + ->orWhereNotNull('replaced_by_variant_id')) + ->exists()) { + throw ValidationException::withMessages([ + 'catalog_item_id' => [__('api.cart.bundle_component_unavailable')], + ]); + } + return $catalogItem; } @@ -430,6 +441,12 @@ class Cart extends Model throw new NotFoundHttpException('Variant not found for catalog item.'); } + if (! $variant->isSellable()) { + throw ValidationException::withMessages([ + 'variant_id' => [__('api.cart.variant_unavailable')], + ]); + } + $inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate); $variant->setRelation('catalogItem', $catalogItem); $variant->setRelation('inventory', $inventory); diff --git a/app/Domains/Cart/Services/CartVariantReplacementService.php b/app/Domains/Cart/Services/CartVariantReplacementService.php new file mode 100644 index 0000000..0dda8eb --- /dev/null +++ b/app/Domains/Cart/Services/CartVariantReplacementService.php @@ -0,0 +1,99 @@ +items() + ->whereNotNull('variant_id') + ->orderBy('id') + ->lockForUpdate() + ->get(); + + foreach ($items as $item) { + $variant = Variant::query()->lockForUpdate()->find($item->variant_id); + + if ($variant === null) { + throw $this->unavailableVariant(); + } + + $replacement = $this->latestReplacement($variant); + + if ($replacement->is($variant)) { + if (! $variant->isSellable()) { + throw $this->unavailableVariant(); + } + + continue; + } + + if (! $replacement->isSellable()) { + throw $this->unavailableVariant(); + } + + /** @var CartItem|null $targetItem */ + $targetItem = $cart->items() + ->whereKeyNot($item->getKey()) + ->where('catalog_item_id', $item->catalog_item_id) + ->where('variant_id', $replacement->getKey()) + ->lockForUpdate() + ->first(); + + $replacementQuantity = $item->cantidad + ($targetItem?->cantidad ?? 0); + if ($replacement->inventory_id !== $variant->inventory_id) { + $available = $this->inventory->availableQuantity($replacement); + + if ($available !== null && $available < $replacementQuantity) { + throw $this->unavailableVariant(); + } + } + + if ($targetItem !== null) { + $targetItem->cantidad += $item->cantidad; + $targetItem->save(); + $item->delete(); + + continue; + } + + $item->update(['variant_id' => $replacement->getKey()]); + } + } + + private function latestReplacement(Variant $variant): Variant + { + $current = $variant; + $visited = []; + + while ($current->replaced_by_variant_id !== null) { + if (isset($visited[$current->getKey()])) { + throw $this->unavailableVariant(); + } + + $visited[$current->getKey()] = true; + $current = Variant::query() + ->lockForUpdate() + ->find($current->replaced_by_variant_id) + ?? throw $this->unavailableVariant(); + } + + return $current; + } + + private function unavailableVariant(): ValidationException + { + return ValidationException::withMessages([ + 'cart_id' => [__('api.cart.cart_variant_unavailable')], + ]); + } +} diff --git a/app/Domains/Cart/Services/InvalidateEventDateCartsService.php b/app/Domains/Cart/Services/InvalidateEventDateCartsService.php new file mode 100644 index 0000000..0d92748 --- /dev/null +++ b/app/Domains/Cart/Services/InvalidateEventDateCartsService.php @@ -0,0 +1,59 @@ + $eventDateIds */ + public function invalidate( + Tenant $tenant, + Collection $eventDateIds, + string $reason = StockReservationService::REASON_EVENT_DATE_RESCHEDULED, + ): void { + DB::transaction(function () use ($tenant, $eventDateIds, $reason): void { + $carts = Cart::query() + ->where('tenant_codigo', $tenant->codigo) + ->where('status', Cart::STATUS_ACTIVE) + ->whereNull('current_purchase_id') + ->whereHas('currentStockReservation', fn ($reservation) => $reservation + ->where('status', StockReservation::STATUS_ACTIVE)) + ->whereHas('items.variant', fn ($variant) => $variant + ->withTrashed() + ->where(fn ($dates) => $dates + ->whereIn('event_date_id', $eventDateIds) + ->orWhereHas('eventDates', fn ($date) => $date + ->whereIn('event_dates.id', $eventDateIds)))) + ->orderBy('id') + ->lockForUpdate() + ->get(); + + foreach ($carts as $cart) { + // Checkout keeps the cart and purchase attached to the same reservation. + if (Purchase::query() + ->where('stock_reservation_id', $cart->current_stock_reservation_id) + ->exists()) { + continue; + } + + $this->reservations->releaseCurrentCartReservation( + $cart, + $reason, + ); + + // Reuse the existing expired-cart flow: stale mutations receive the + // expiration response and the next GET replaces the whole cart. + $cart->update(['status' => Cart::STATUS_EXPIRED]); + } + }); + } +} diff --git a/app/Domains/Catalog/Models/Attribute.php b/app/Domains/Catalog/Models/Attribute.php index 58e1f99..c4c9dee 100644 --- a/app/Domains/Catalog/Models/Attribute.php +++ b/app/Domains/Catalog/Models/Attribute.php @@ -59,6 +59,8 @@ class Attribute extends Model public function eventDates(): HasMany { return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo') + ->whereNull('rescheduled_to_event_date_id') + ->whereNull('suspended_at') ->orderBy('date') ->orderBy('time_start'); } diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 401d670..18309a0 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -179,11 +179,27 @@ class CatalogItem extends Model { return $query->where(function (Builder $query): void { $query - ->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) + ->where(function (Builder $unlimitedQuery): void { + $unlimitedQuery + ->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) + ->where(function (Builder $selectionQuery): void { + $selectionQuery + ->whereDoesntHave('variants') + ->orWhereHas('variants', fn (Builder $variantQuery): Builder => $variantQuery + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id')); + }); + }) ->orWhereHas( - 'variants.inventory', - fn (Builder $inventoryQuery): Builder => $inventoryQuery - ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') + 'variants', + fn (Builder $variantQuery): Builder => $variantQuery + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->whereHas( + 'inventory', + fn (Builder $inventoryQuery): Builder => $inventoryQuery + ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') + ) ) ->orWhere(function (Builder $directItemQuery): void { $directItemQuery @@ -205,9 +221,12 @@ class CatalogItem extends Model public function visibleVariants(?int $includedVariantId = null): Collection { return $this->variants - ->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId) - || $this->inventory_policy === InventoryPolicy::Unlimited - || ($variant->inventory?->availableStock() ?? 0) > 0) + ->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates() + && (($includedVariantId !== null && $variant->id === $includedVariantId) + || ($variant->isSellable() && ( + $this->inventory_policy === InventoryPolicy::Unlimited + || ($variant->inventory?->availableStock() ?? 0) > 0 + )))) ->values(); } diff --git a/app/Domains/Catalog/Models/Inventory.php b/app/Domains/Catalog/Models/Inventory.php index 4a1e567..ad20142 100644 --- a/app/Domains/Catalog/Models/Inventory.php +++ b/app/Domains/Catalog/Models/Inventory.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; #[Fillable([ 'sold_units', + 'refunded_units', 'reserved_stock', 'real_stock', ])] @@ -23,6 +24,7 @@ class Inventory extends Model protected $attributes = [ 'sold_units' => 0, + 'refunded_units' => 0, 'reserved_stock' => 0, 'real_stock' => 0, ]; @@ -31,6 +33,7 @@ class Inventory extends Model { return [ 'sold_units' => 'integer', + 'refunded_units' => 'integer', 'reserved_stock' => 'integer', 'real_stock' => 'integer', ]; @@ -42,10 +45,10 @@ class Inventory extends Model return $this->hasOne(CatalogItem::class); } - /** @return HasOne */ - public function variant(): HasOne + /** @return HasMany */ + public function variants(): HasMany { - return $this->hasOne(Variant::class); + return $this->hasMany(Variant::class); } /** @return HasMany */ diff --git a/app/Domains/Catalog/Models/Variant.php b/app/Domains/Catalog/Models/Variant.php index 581afc3..0873958 100644 --- a/app/Domains/Catalog/Models/Variant.php +++ b/app/Domains/Catalog/Models/Variant.php @@ -20,6 +20,8 @@ use Illuminate\Support\Str; 'catalog_item_id', 'event_date_id', 'inventory_id', + 'replaced_by_variant_id', + 'sales_disabled_at', 'descripcion', 'precio', ])] @@ -37,6 +39,8 @@ class Variant extends Model 'catalog_item_id' => 'integer', 'event_date_id' => 'integer', 'inventory_id' => 'integer', + 'replaced_by_variant_id' => 'integer', + 'sales_disabled_at' => 'datetime', 'precio' => 'decimal:2', ]; } @@ -76,6 +80,39 @@ class Variant extends Model return $this->belongsTo(Inventory::class); } + /** @return BelongsTo */ + public function replacement(): BelongsTo + { + return $this->belongsTo(self::class, 'replaced_by_variant_id'); + } + + /** @return HasMany */ + public function replacedVariants(): HasMany + { + return $this->hasMany(self::class, 'replaced_by_variant_id'); + } + + public function isSellable(): bool + { + return $this->sales_disabled_at === null + && $this->replaced_by_variant_id === null + && $this->hasOnlyActiveEventDates(); + } + + public function hasOnlyActiveEventDates(): bool + { + if (! $this->exists + && $this->event_date_id === null + && ! $this->relationLoaded('eventDates')) { + return true; + } + + return $this->selectedEventDates()->every( + fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null + && $eventDate->suspended_at === null, + ); + } + /** @return HasMany */ public function definitions(): HasMany { diff --git a/app/Domains/Catalog/Services/CatalogInventoryService.php b/app/Domains/Catalog/Services/CatalogInventoryService.php index 30e433a..34de52f 100644 --- a/app/Domains/Catalog/Services/CatalogInventoryService.php +++ b/app/Domains/Catalog/Services/CatalogInventoryService.php @@ -63,9 +63,14 @@ class CatalogInventoryService $selection->loadMissing('variants.inventory'); - return $selection->variants->sum( - fn (Variant $variant): int => $variant->inventory->availableStock(), - ); + return $selection->variants + ->filter(fn (Variant $variant): bool => $variant->isSellable()) + ->unique(fn (Variant $variant): string => $variant->inventory_id === null + ? 'object:'.spl_object_id($variant->inventory) + : 'id:'.$variant->inventory_id) + ->sum( + fn (Variant $variant): int => $variant->inventory->availableStock(), + ); } $requirements = $this->inventoryRequirements($selection); diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index 2b46377..4cc8989 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -207,7 +207,8 @@ class CatalogService $visibleVariants = $catalogItem->visibleVariants(); if ($catalogItem->type === CatalogItemType::Standard && ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty()) - && ! $catalogItem->isAvailable()) { + && (($catalogItem->variants->isNotEmpty() && $visibleVariants->isEmpty()) + || ! $catalogItem->isAvailable())) { throw new NotFoundHttpException('Catalog item is out of stock.'); } @@ -324,9 +325,13 @@ class CatalogService ->findOrFail($variant->catalog_item_id); $variant->delete(); - if (! $catalogItem->variants()->exists()) { + $sellableVariants = $catalogItem->variants() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id'); + + if (! (clone $sellableVariants)->exists()) { $this->delete($catalogItem); - } elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) { + } elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) { $catalogItem->update(['precio' => $minimumPrice]); } @@ -399,7 +404,11 @@ class CatalogService ]); } - if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) { + if ($variantId !== null && ! $componentItem->variants() + ->whereKey($variantId) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->exists()) { throw ValidationException::withMessages([ "components.{$index}.variant_id" => [ __('api.catalog.component_variant_invalid'), diff --git a/app/Domains/Catalog/Services/StockReservationService.php b/app/Domains/Catalog/Services/StockReservationService.php index 96d931b..87228a5 100644 --- a/app/Domains/Catalog/Services/StockReservationService.php +++ b/app/Domains/Catalog/Services/StockReservationService.php @@ -19,6 +19,10 @@ class StockReservationService public const REASON_CART_CHANGED = 'cart_changed'; + public const REASON_EVENT_DATE_RESCHEDULED = 'event_date_rescheduled'; + + public const REASON_EVENT_DATE_SUSPENDED = 'event_date_suspended'; + public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded'; public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled'; diff --git a/app/Domains/Catalog/Services/VariantReplacementService.php b/app/Domains/Catalog/Services/VariantReplacementService.php new file mode 100644 index 0000000..1b744da --- /dev/null +++ b/app/Domains/Catalog/Services/VariantReplacementService.php @@ -0,0 +1,263 @@ + */ + public function replaceEventDate(EventDate $source, EventDate $destination): Collection + { + $variants = Variant::query() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->where(function ($query) use ($source): void { + $query->where('event_date_id', $source->getKey()) + ->orWhereHas('eventDates', fn ($eventDates) => $eventDates + ->where('event_dates.id', $source->getKey())); + }) + ->with(['eventDates', 'eventDate', 'definitions', 'allAttachments']) + ->orderBy('id') + ->lockForUpdate() + ->get(); + + return $variants->map(function (Variant $variant) use ($source, $destination): Variant { + $destinationDateIds = $variant->selectedEventDates() + ->pluck('id') + ->map(fn ($id): int => (int) $id === (int) $source->getKey() + ? (int) $destination->getKey() + : (int) $id) + ->unique() + ->sort() + ->values(); + + $replacement = $this->findEquivalent($variant, $destinationDateIds) + ?? $this->cloneWithDates($variant, $destinationDateIds); + + $variant->update([ + 'replaced_by_variant_id' => $replacement->getKey(), + 'sales_disabled_at' => now(), + ]); + + BundleComponent::query() + ->where('component_variant_id', $variant->getKey()) + ->update(['component_variant_id' => $replacement->getKey()]); + + return $replacement; + })->values(); + } + + public function disableForSuspension(EventDate $eventDate): void + { + $variants = Variant::query() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->where(function ($query) use ($eventDate): void { + $query->where('event_date_id', $eventDate->getKey()) + ->orWhereHas('eventDates', fn ($eventDates) => $eventDates + ->where('event_dates.id', $eventDate->getKey())); + }) + ->with(['eventDates', 'eventDate', 'definitions', 'allAttachments']) + ->orderBy('id') + ->lockForUpdate() + ->get(); + + foreach ($variants as $variant) { + $remainingDateIds = $variant->selectedEventDates() + ->filter(fn (EventDate $date): bool => $date->suspended_at === null + && $date->rescheduled_to_event_date_id === null) + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values(); + + if ($remainingDateIds->isEmpty()) { + $variant->update(['sales_disabled_at' => now()]); + + continue; + } + + $replacement = $this->findEquivalent($variant, $remainingDateIds); + if ($replacement === null) { + $replacement = $this->cloneWithDates($variant, $remainingDateIds); + } else { + $this->mergeInventoryInto($variant, $replacement); + } + + $variant->update([ + 'replaced_by_variant_id' => $replacement->getKey(), + 'sales_disabled_at' => now(), + ]); + + BundleComponent::query() + ->where('component_variant_id', $variant->getKey()) + ->update(['component_variant_id' => $replacement->getKey()]); + } + } + + /** @param Collection $eventDateIds */ + private function findEquivalent(Variant $source, Collection $eventDateIds): ?Variant + { + $definitionSignature = $this->definitionSignature($source); + $dateSignature = $eventDateIds->map(fn ($id): int => (int) $id)->sort()->values()->all(); + + return Variant::query() + ->where('catalog_item_id', $source->catalog_item_id) + ->whereKeyNot($source->getKey()) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['eventDates', 'eventDate', 'definitions']) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->first(fn (Variant $candidate): bool => $this->definitionSignature($candidate) === $definitionSignature + && $candidate->selectedEventDates() + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->sort() + ->values() + ->all() === $dateSignature + ); + } + + /** @param Collection $eventDateIds */ + private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant + { + $replacementInventory = $this->cloneInventory($source); + $replacement = $source->replicate([ + 'event_date_id', + 'inventory_id', + 'replaced_by_variant_id', + 'sales_disabled_at', + ]); + $replacement->inventory_id = $replacementInventory->getKey(); + $replacement->event_date_id = $eventDateIds->count() === 1 + ? $eventDateIds->first() + : null; + $replacement->save(); + $replacement->eventDates()->sync($eventDateIds->all()); + + $replacement->definitions()->createMany( + $source->definitions + ->map(fn ($definition): array => [ + 'item_attribute_id' => $definition->item_attribute_id, + 'value' => $definition->value, + ]) + ->all(), + ); + + $attachments = $source->allAttachments + ->mapWithKeys(fn ($attachment): array => [ + $attachment->getKey() => [ + 'orden' => $attachment->pivot->orden, + 'is_enabled' => $attachment->pivot->is_enabled, + ], + ]) + ->all(); + $replacement->allAttachments()->sync($attachments); + + return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']); + } + + private function cloneInventory(Variant $source): Inventory + { + $activeLines = StockReservationLine::query() + ->where('inventory_id', $source->inventory_id) + ->whereHas('reservation', fn ($reservation) => $reservation + ->where('status', StockReservation::STATUS_ACTIVE)) + ->orderBy('id') + ->lockForUpdate() + ->get(); + $sourceInventory = Inventory::query() + ->whereKey($source->inventory_id) + ->lockForUpdate() + ->firstOrFail(); + $reservedStock = (int) $activeLines->sum('quantity'); + + if ($sourceInventory->reserved_stock !== $reservedStock) { + throw new \LogicException('El inventario reservado de la variante es inconsistente.'); + } + + $replacementInventory = Inventory::query()->create([ + 'sold_units' => $sourceInventory->sold_units, + 'refunded_units' => $sourceInventory->refunded_units, + 'reserved_stock' => $reservedStock, + 'real_stock' => $sourceInventory->real_stock, + ]); + + if ($activeLines->isNotEmpty()) { + StockReservationLine::query() + ->whereKey($activeLines->modelKeys()) + ->update(['inventory_id' => $replacementInventory->getKey()]); + } + $sourceInventory->update(['reserved_stock' => 0]); + + return $replacementInventory; + } + + private function mergeInventoryInto(Variant $source, Variant $destination): void + { + if ($source->inventory_id === $destination->inventory_id) { + return; + } + + $inventories = Inventory::query() + ->whereKey([$source->inventory_id, $destination->inventory_id]) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy('id'); + $sourceInventory = $inventories->get($source->inventory_id); + $destinationInventory = $inventories->get($destination->inventory_id); + if ($sourceInventory === null || $destinationInventory === null) { + throw new \LogicException('No se encontró el inventario de una variante.'); + } + + $activeLines = StockReservationLine::query() + ->where('inventory_id', $source->inventory_id) + ->whereHas('reservation', fn ($reservation) => $reservation + ->where('status', StockReservation::STATUS_ACTIVE)) + ->orderBy('id') + ->lockForUpdate() + ->get(); + if ($sourceInventory->reserved_stock !== (int) $activeLines->sum('quantity')) { + throw new \LogicException('El inventario reservado de la variante es inconsistente.'); + } + + $destinationInventory->update([ + 'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock, + 'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock, + 'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units, + 'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units, + ]); + if ($activeLines->isNotEmpty()) { + StockReservationLine::query() + ->whereKey($activeLines->modelKeys()) + ->update(['inventory_id' => $destinationInventory->getKey()]); + } + $sourceInventory->update([ + 'real_stock' => 0, + 'reserved_stock' => 0, + 'sold_units' => 0, + 'refunded_units' => 0, + ]); + } + + /** @return list */ + private function definitionSignature(Variant $variant): array + { + return $variant->definitions + ->map(fn ($definition): string => $definition->item_attribute_id.'\0'.$definition->value) + ->sort() + ->values() + ->all(); + } +} diff --git a/app/Domains/Event/Controllers/AdminApp/EventController.php b/app/Domains/Event/Controllers/AdminApp/EventController.php index b6036d9..bb1a783 100644 --- a/app/Domains/Event/Controllers/AdminApp/EventController.php +++ b/app/Domains/Event/Controllers/AdminApp/EventController.php @@ -2,7 +2,11 @@ namespace App\Domains\Event\Controllers\AdminApp; +use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Requests\RescheduleEventDateRequest; +use App\Domains\Event\Requests\StoreEventDateRequest; use App\Domains\Event\Requests\UpdateEventRequest; +use App\Domains\Event\Resources\EventDateResource; use App\Domains\Event\Resources\EventResource; use App\Domains\Event\Services\EventService; use App\Http\Controllers\Controller; @@ -28,4 +32,39 @@ class EventController extends Controller ) ); } + + public function storeDate(StoreEventDateRequest $request): EventDateResource + { + return EventDateResource::make( + $this->eventService->createDateForTenant( + $request->user()->tenant()->firstOrFail(), + $request->validated(), + ) + ); + } + + public function rescheduleDate( + RescheduleEventDateRequest $request, + EventDate $eventDate, + ): EventDateResource { + return EventDateResource::make( + $this->eventService->rescheduleDateForTenant( + $request->user()->tenant()->firstOrFail(), + $eventDate, + $request->validated(), + $request->user(), + ) + ); + } + + public function suspendDate(Request $request, EventDate $eventDate): EventDateResource + { + return EventDateResource::make( + $this->eventService->suspendDateForTenant( + $request->user()->tenant()->firstOrFail(), + $eventDate, + $request->user(), + ) + ); + } } diff --git a/app/Domains/Event/Controllers/EventDateNoticeController.php b/app/Domains/Event/Controllers/EventDateNoticeController.php new file mode 100644 index 0000000..e427663 --- /dev/null +++ b/app/Domains/Event/Controllers/EventDateNoticeController.php @@ -0,0 +1,22 @@ +noticeService->claimFor($request->user(), $tenant) + ); + } +} diff --git a/app/Domains/Event/Enums/EventDateChangeType.php b/app/Domains/Event/Enums/EventDateChangeType.php new file mode 100644 index 0000000..1dba386 --- /dev/null +++ b/app/Domains/Event/Enums/EventDateChangeType.php @@ -0,0 +1,9 @@ +}> $purchaseTickets + */ + public function __construct( + public readonly string $tenantCode, + public readonly int $sourceEventDateId, + public readonly int $destinationEventDateId, + public readonly string $previousDate, + public readonly string $newDate, + public readonly array $purchaseTickets, + ) {} +} diff --git a/app/Domains/Event/Events/EventDateSuspended.php b/app/Domains/Event/Events/EventDateSuspended.php new file mode 100644 index 0000000..fe99365 --- /dev/null +++ b/app/Domains/Event/Events/EventDateSuspended.php @@ -0,0 +1,20 @@ +}> $purchaseTickets + */ + public function __construct( + public readonly string $tenantCode, + public readonly int $eventDateId, + public readonly string $date, + public readonly array $purchaseTickets, + ) {} +} diff --git a/app/Domains/Event/Models/EventDate.php b/app/Domains/Event/Models/EventDate.php index ef7838b..02add11 100644 --- a/app/Domains/Event/Models/EventDate.php +++ b/app/Domains/Event/Models/EventDate.php @@ -3,6 +3,8 @@ namespace App\Domains\Event\Models; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Enums\EventDateStatus; +use App\Domains\Event\Services\EffectiveEventDateResolver; use App\Domains\Event\Services\EventDateTextFormatter; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Enums\ValidityTimeType; @@ -21,6 +23,8 @@ use Illuminate\Support\Carbon; 'date', 'time_start', 'time_end', + 'rescheduled_to_event_date_id', + 'suspended_at', ])] class EventDate extends Model { @@ -28,6 +32,8 @@ class EventDate extends Model public $timestamps = false; + protected $appends = ['status']; + protected static function booted(): void { static::creating(fn (self $eventDate) => $eventDate->syncValidityTime()); @@ -52,6 +58,8 @@ class EventDate extends Model return [ 'date' => 'date:Y-m-d', 'validity_time_id' => 'integer', + 'rescheduled_to_event_date_id' => 'integer', + 'suspended_at' => 'datetime', ]; } @@ -67,6 +75,35 @@ class EventDate extends Model return $this->belongsTo(ValidityTime::class); } + /** @return BelongsTo */ + public function rescheduledTo(): BelongsTo + { + return $this->belongsTo(self::class, 'rescheduled_to_event_date_id'); + } + + public function effectiveDate(): ?self + { + return app(EffectiveEventDateResolver::class)->resolve($this); + } + + /** @return HasMany */ + public function rescheduledFrom(): HasMany + { + return $this->hasMany(self::class, 'rescheduled_to_event_date_id'); + } + + /** @return HasMany */ + public function changeHistory(): HasMany + { + return $this->hasMany(EventDateChange::class, 'source_event_date_id'); + } + + /** @return HasMany */ + public function destinationChangeHistory(): HasMany + { + return $this->hasMany(EventDateChange::class, 'destination_event_date_id'); + } + /** @return HasMany */ public function variants(): HasMany { @@ -91,7 +128,32 @@ class EventDate extends Model public function endsAt(): CarbonInterface { - return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end); + $endsAt = Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end); + + return $endsAt->lessThanOrEqualTo($this->startsAt()) + ? $endsAt->addDay() + : $endsAt; + } + + public function getStatusAttribute(): EventDateStatus + { + if ($this->rescheduled_to_event_date_id !== null) { + return EventDateStatus::Rescheduled; + } + + if ($this->suspended_at !== null) { + return EventDateStatus::Suspended; + } + + if (now()->lt($this->startsAt())) { + return EventDateStatus::Scheduled; + } + + if (now()->lt($this->endsAt())) { + return EventDateStatus::InProgress; + } + + return EventDateStatus::Completed; } private function syncTenantDateText(): void @@ -104,7 +166,10 @@ class EventDate extends Model $tenant->update([ 'event_date_text' => app(EventDateTextFormatter::class)->format( - $tenant->eventDates()->pluck('date') + $tenant->eventDates() + ->whereNull('rescheduled_to_event_date_id') + ->whereNull('suspended_at') + ->pluck('date') ), ]); } @@ -114,10 +179,6 @@ class EventDate extends Model $startsAt = $this->startsAt(); $expiresAt = $this->endsAt(); - if ($expiresAt->lessThanOrEqualTo($startsAt)) { - $expiresAt = $expiresAt->addDay(); - } - $attributes = [ 'type' => ValidityTimeType::FixedWindow, 'start_time' => null, diff --git a/app/Domains/Event/Models/EventDateChange.php b/app/Domains/Event/Models/EventDateChange.php new file mode 100644 index 0000000..5fcbfab --- /dev/null +++ b/app/Domains/Event/Models/EventDateChange.php @@ -0,0 +1,68 @@ + EventDateChangeType::class, + 'source_event_date_id' => 'integer', + 'destination_event_date_id' => 'integer', + 'created_by_user_id' => 'integer', + 'previous_date' => 'date:Y-m-d', + 'new_date' => 'date:Y-m-d', + 'created_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); + } + + /** @return BelongsTo */ + public function sourceEventDate(): BelongsTo + { + return $this->belongsTo(EventDate::class, 'source_event_date_id'); + } + + /** @return BelongsTo */ + public function destinationEventDate(): BelongsTo + { + return $this->belongsTo(EventDate::class, 'destination_event_date_id'); + } + + /** @return BelongsTo */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed(); + } + + /** @return HasMany */ + public function views(): HasMany + { + return $this->hasMany(EventDateChangeView::class); + } +} diff --git a/app/Domains/Event/Models/EventDateChangeView.php b/app/Domains/Event/Models/EventDateChangeView.php new file mode 100644 index 0000000..199418f --- /dev/null +++ b/app/Domains/Event/Models/EventDateChangeView.php @@ -0,0 +1,41 @@ + 'integer', + 'event_date_change_id' => 'integer', + 'display_count' => 'integer', + 'last_displayed_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** @return BelongsTo */ + public function eventDateChange(): BelongsTo + { + return $this->belongsTo(EventDateChange::class); + } +} diff --git a/app/Domains/Event/Requests/RescheduleEventDateRequest.php b/app/Domains/Event/Requests/RescheduleEventDateRequest.php new file mode 100644 index 0000000..3573c8a --- /dev/null +++ b/app/Domains/Event/Requests/RescheduleEventDateRequest.php @@ -0,0 +1,21 @@ + */ + public function rules(): array + { + return [ + 'date' => ['required', 'date_format:Y-m-d'], + ]; + } +} diff --git a/app/Domains/Event/Requests/StoreEventDateRequest.php b/app/Domains/Event/Requests/StoreEventDateRequest.php new file mode 100644 index 0000000..74bbe0e --- /dev/null +++ b/app/Domains/Event/Requests/StoreEventDateRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'date' => ['required', 'date_format:Y-m-d'], + 'start_time' => ['required', 'date_format:H:i'], + 'end_time' => ['required', 'date_format:H:i'], + ]; + } +} diff --git a/app/Domains/Event/Requests/UpdateEventRequest.php b/app/Domains/Event/Requests/UpdateEventRequest.php index 987dccb..6867b03 100644 --- a/app/Domains/Event/Requests/UpdateEventRequest.php +++ b/app/Domains/Event/Requests/UpdateEventRequest.php @@ -19,11 +19,6 @@ class UpdateEventRequest extends FormRequest return [ 'title' => ['required', 'string', 'max:255'], 'location' => ['required', 'string', 'max:255'], - 'dates' => ['required', 'array', 'min:1'], - 'dates.*' => ['required', 'array:date,start_time,end_time'], - 'dates.*.date' => ['required', 'date_format:Y-m-d', 'distinct'], - 'dates.*.start_time' => ['required', 'date_format:H:i'], - 'dates.*.end_time' => ['required', 'date_format:H:i'], 'social_media' => ['sometimes', 'array'], 'social_media.*' => ['required', 'array:code,url,orden'], 'social_media.*.code' => [ @@ -38,6 +33,16 @@ class UpdateEventRequest extends FormRequest 'contact.whatsapp_url' => ['nullable', 'url', 'max:2048'], 'contact.instagram_url' => ['nullable', 'url', 'max:2048'], 'contact.facebook_url' => ['nullable', 'url', 'max:2048'], + 'allow_ticket_refund' => ['sometimes', 'boolean'], + 'allow_ticket_total_refund' => ['sometimes', 'boolean'], + 'allow_ticket_partial_refund' => ['sometimes', 'boolean'], + 'ticket_partial_refund_percentage' => [ + 'sometimes', + 'numeric', + 'decimal:0,2', + 'min:0', + 'max:99.99', + ], ]; } @@ -54,6 +59,41 @@ class UpdateEventRequest extends FormRequest 'The social media field is required.' ); } + + if (! array_key_exists('allow_ticket_refund', $input)) { + return; + } + + foreach ([ + 'allow_ticket_total_refund', + 'allow_ticket_partial_refund', + 'ticket_partial_refund_percentage', + ] as $field) { + if (! array_key_exists($field, $input)) { + $validator->errors()->add($field, 'El campo es obligatorio.'); + } + } + + $totalEnabled = $this->boolean('allow_ticket_total_refund'); + $partialEnabled = $this->boolean('allow_ticket_partial_refund'); + + $refundEnabled = $this->boolean('allow_ticket_refund'); + + if ($refundEnabled && ! $totalEnabled && ! $partialEnabled) { + $validator->errors()->add( + 'allow_ticket_refund', + 'Seleccioná al menos un tipo de reembolso.' + ); + } + + if ($refundEnabled + && $partialEnabled + && (float) ($input['ticket_partial_refund_percentage'] ?? 0) <= 0) { + $validator->errors()->add( + 'ticket_partial_refund_percentage', + 'Ingresá un porcentaje mayor que cero para el reembolso parcial.' + ); + } }, ]; } diff --git a/app/Domains/Event/Resources/EventDateChangeResource.php b/app/Domains/Event/Resources/EventDateChangeResource.php new file mode 100644 index 0000000..025e053 --- /dev/null +++ b/app/Domains/Event/Resources/EventDateChangeResource.php @@ -0,0 +1,24 @@ + */ + public function toArray(Request $request): array + { + return [ + 'type' => $this->change_type->value, + 'source_event_date_id' => $this->source_event_date_id, + 'destination_event_date_id' => $this->destination_event_date_id, + 'previous_date' => $this->previous_date->format('Y-m-d'), + 'new_date' => $this->new_date?->format('Y-m-d'), + 'occurred_at' => $this->created_at->toISOString(), + ]; + } +} diff --git a/app/Domains/Event/Resources/EventDateNoticeResource.php b/app/Domains/Event/Resources/EventDateNoticeResource.php new file mode 100644 index 0000000..dd661b8 --- /dev/null +++ b/app/Domains/Event/Resources/EventDateNoticeResource.php @@ -0,0 +1,20 @@ + */ + public function toArray(Request $request): array + { + return [ + 'type' => $this->resource['type'], + 'change_ids' => $this->resource['change_ids'], + 'title' => $this->resource['title'], + 'message' => $this->resource['message'], + ]; + } +} diff --git a/app/Domains/Event/Resources/EventDateResource.php b/app/Domains/Event/Resources/EventDateResource.php new file mode 100644 index 0000000..6ebba0b --- /dev/null +++ b/app/Domains/Event/Resources/EventDateResource.php @@ -0,0 +1,31 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'validity_time_id' => $this->validity_time_id, + 'validity_time' => ValidityTimeResource::make($this->whenLoaded('validityTime')), + 'date' => $this->date->format('Y-m-d'), + 'start_time' => substr($this->time_start, 0, 5), + 'end_time' => substr($this->time_end, 0, 5), + 'status' => $this->status->value, + 'rescheduled_to_event_date_id' => $this->rescheduled_to_event_date_id, + 'suspended_at' => $this->suspended_at?->toISOString(), + 'rescheduled_dates' => EventDateResource::collection( + $this->whenLoaded('adminRescheduledDates') + ), + ]; + } +} diff --git a/app/Domains/Event/Resources/EventResource.php b/app/Domains/Event/Resources/EventResource.php index 1dd4735..150dcc7 100644 --- a/app/Domains/Event/Resources/EventResource.php +++ b/app/Domains/Event/Resources/EventResource.php @@ -2,8 +2,8 @@ namespace App\Domains\Event\Resources; +use App\Domains\Event\Services\EventDateGroupingService; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -19,14 +19,13 @@ class EventResource extends JsonResource 'id' => $this->id, 'title' => $this->event_title, 'location' => $this->event_location, - 'dates' => $this->eventDates->map(fn ($eventDate): array => [ - 'id' => $eventDate->id, - 'validity_time_id' => $eventDate->validity_time_id, - 'validity_time' => ValidityTimeResource::make($eventDate->validityTime), - 'date' => $eventDate->date->format('Y-m-d'), - 'start_time' => substr($eventDate->time_start, 0, 5), - 'end_time' => substr($eventDate->time_end, 0, 5), - ])->values(), + 'allow_ticket_refund' => $this->allow_ticket_refund, + 'allow_ticket_total_refund' => $this->allow_ticket_total_refund, + 'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund, + 'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage, + 'dates' => EventDateResource::collection( + app(EventDateGroupingService::class)->group($this->eventDates) + ), 'social_media' => $this->socialMedia->map(fn ($item): array => [ 'code' => $item->code, 'url' => $item->pivot->url, diff --git a/app/Domains/Event/Services/AffectedEventDatePurchaseResolver.php b/app/Domains/Event/Services/AffectedEventDatePurchaseResolver.php new file mode 100644 index 0000000..23b7e04 --- /dev/null +++ b/app/Domains/Event/Services/AffectedEventDatePurchaseResolver.php @@ -0,0 +1,57 @@ +|list $eventDateIds + * @return list}> + */ + public function resolve(Tenant $tenant, Collection|array $eventDateIds): array + { + $eventDateIds = collect($eventDateIds)->map(fn (mixed $id): int => (int) $id)->unique()->values(); + + if ($eventDateIds->isEmpty()) { + return []; + } + + /** @var Collection $tickets */ + $tickets = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->whereHas('sourcePurchaseItem.purchase', fn (Builder $query) => $query + ->where('status', Purchase::STATUS_PAID)) + ->whereHas('sourceVariant', function (Builder $query) use ($eventDateIds): void { + $query->whereIn('event_date_id', $eventDateIds) + ->orWhereHas('eventDates', fn (Builder $eventDates) => $eventDates + ->whereIn('event_dates.id', $eventDateIds)); + }) + ->with([ + ...TicketValidityResolver::RELATIONS, + 'sourcePurchaseItem.purchase', + ]) + ->get() + ->filter(fn (Ticket $ticket): bool => $ticket->is_active()) + ->values(); + + return $tickets + ->groupBy(fn (Ticket $ticket): int => (int) $ticket->sourcePurchaseItem->purchase->getKey()) + ->map(function (Collection $purchaseTickets): array { + return [ + 'purchase_id' => (int) $purchaseTickets->first()->sourcePurchaseItem->purchase->getKey(), + 'ticket_ids' => $purchaseTickets->modelKeys(), + ]; + }) + ->values() + ->all(); + } +} diff --git a/app/Domains/Event/Services/EffectiveEventDateResolver.php b/app/Domains/Event/Services/EffectiveEventDateResolver.php new file mode 100644 index 0000000..f74db64 --- /dev/null +++ b/app/Domains/Event/Services/EffectiveEventDateResolver.php @@ -0,0 +1,45 @@ +resolveLatest($eventDate); + + return $date !== null && $date->suspended_at === null ? $date : null; + } + + /** Sigue las reprogramaciones para presentación, incluso si el destino está suspendido. */ + public function resolveLatest(EventDate $eventDate): ?EventDate + { + $current = $eventDate; + $visited = []; + + while (true) { + $identity = $current->getKey() === null + ? 'object:'.spl_object_id($current) + : 'key:'.$current->getKey(); + + if (isset($visited[$identity])) { + return null; + } + + $visited[$identity] = true; + + if ($current->rescheduled_to_event_date_id === null) { + return $current; + } + + $current->loadMissing('rescheduledTo'); + $current = $current->rescheduledTo; + + if ($current === null) { + return null; + } + } + } +} diff --git a/app/Domains/Event/Services/EventDateGroupingService.php b/app/Domains/Event/Services/EventDateGroupingService.php new file mode 100644 index 0000000..83dff7c --- /dev/null +++ b/app/Domains/Event/Services/EventDateGroupingService.php @@ -0,0 +1,101 @@ + $dates + * @return Collection + */ + public function group(Collection $dates): Collection + { + $byId = $dates->keyBy(fn (EventDate $date): int => (int) $date->getKey()); + $groups = collect(); + + foreach ($dates as $date) { + $destination = $this->finalDestination($date, $byId); + $key = (int) $destination->getKey(); + + if (! $groups->has($key)) { + $groups->put($key, [ + 'destination' => $destination, + 'rescheduled' => collect(), + ]); + } + + if (! $date->is($destination)) { + $group = $groups->get($key); + $historicalDate = clone $date; + $historicalDate->setAttribute( + 'rescheduled_to_event_date_id', + $destination->getKey(), + ); + $group['rescheduled']->push($historicalDate); + $groups->put($key, $group); + } + } + + return $groups + ->map(function (array $group): EventDate { + /** @var EventDate $destination */ + $destination = clone $group['destination']; + /** @var Collection $rescheduled */ + $rescheduled = $group['rescheduled']; + $destination->setRelation( + 'adminRescheduledDates', + new EloquentCollection($rescheduled->sort($this->dateSorter())->values()->all()), + ); + + return $destination; + }) + ->sort($this->dateSorter()) + ->values(); + } + + /** @param Collection $byId */ + private function finalDestination(EventDate $date, Collection $byId): EventDate + { + $current = $date; + $visited = collect(); + + while ($current->rescheduled_to_event_date_id !== null) { + $currentId = (int) $current->getKey(); + + if ($visited->contains($currentId)) { + break; + } + + $visited->push($currentId); + $destination = $byId->get((int) $current->rescheduled_to_event_date_id); + + if (! $destination instanceof EventDate) { + break; + } + + $current = $destination; + } + + return $current; + } + + /** @return callable(EventDate, EventDate): int */ + private function dateSorter(): callable + { + return fn (EventDate $left, EventDate $right): int => [ + $left->date->format('Y-m-d'), + $left->time_start, + $left->getKey(), + ] <=> [ + $right->date->format('Y-m-d'), + $right->time_start, + $right->getKey(), + ]; + } +} diff --git a/app/Domains/Event/Services/EventDateInfoFormatter.php b/app/Domains/Event/Services/EventDateInfoFormatter.php new file mode 100644 index 0000000..9442d14 --- /dev/null +++ b/app/Domains/Event/Services/EventDateInfoFormatter.php @@ -0,0 +1,76 @@ + $changes */ + public function format(EventDate $eventDate, Collection $changes): ?string + { + $messages = collect(); + + if ($eventDate->suspended_at !== null) { + $messages->push('Esta fecha fue cancelada.'); + } + + $sourceDates = $this->reschedulesEndingAt($eventDate, $changes) + ->pluck('previous_date') + ->filter() + ->map(fn ($date): string => $date->format('d/m/Y')) + ->unique() + ->values(); + + if ($sourceDates->isNotEmpty()) { + $verb = $sourceDates->count() === 1 ? 'se reprogramó' : 'se reprogramaron'; + $messages->push("{$sourceDates->join(', ', ' y ')} {$verb} para este día."); + } + + return $messages->isEmpty() ? null : $messages->join(' '); + } + + /** + * Includes direct and intermediate reschedules that ultimately end at the + * displayed event date, while preserving the original change order. + * + * @param Collection $changes + * @return Collection + */ + private function reschedulesEndingAt(EventDate $eventDate, Collection $changes): Collection + { + $eventDateId = $eventDate->getKey(); + $reschedules = $changes->where('change_type', EventDateChangeType::Rescheduled); + + if ($eventDateId === null) { + return $reschedules->where('destination_event_date_id', null); + } + + $destinationIds = [(int) $eventDateId => true]; + + do { + $foundAncestor = false; + + foreach ($reschedules as $change) { + $destinationId = $change->destination_event_date_id; + $sourceId = $change->source_event_date_id; + + if ($destinationId === null || $sourceId === null) { + continue; + } + + if (isset($destinationIds[(int) $destinationId]) && ! isset($destinationIds[(int) $sourceId])) { + $destinationIds[(int) $sourceId] = true; + $foundAncestor = true; + } + } + } while ($foundAncestor); + + return $reschedules + ->filter(fn (EventDateChange $change): bool => $change->destination_event_date_id !== null + && isset($destinationIds[(int) $change->destination_event_date_id])); + } +} diff --git a/app/Domains/Event/Services/EventDateNoticeFormatter.php b/app/Domains/Event/Services/EventDateNoticeFormatter.php new file mode 100644 index 0000000..0a4c69d --- /dev/null +++ b/app/Domains/Event/Services/EventDateNoticeFormatter.php @@ -0,0 +1,126 @@ + $changes + * @return list, + * title: string, + * message: list + * }> + */ + public function format(Collection $changes): array + { + return collect([ + $this->suspensionNotice( + $changes->where('change_type', EventDateChangeType::Suspended) + ), + $this->rescheduleNotice( + $changes->where('change_type', EventDateChangeType::Rescheduled) + ), + ])->filter()->values()->all(); + } + + /** + * @param Collection $changes + * @return array{type: string, change_ids: list, title: string, message: list}|null + */ + private function suspensionNotice(Collection $changes): ?array + { + $dates = $this->formatDates($changes, 'previous_date'); + + if ($dates === null) { + return null; + } + + $plural = $changes->count() > 1; + + return [ + 'type' => EventDateChangeType::Suspended->value, + 'change_ids' => $this->changeIds($changes), + 'title' => $plural ? 'FECHAS CANCELADAS!' : 'FECHA CANCELADA!', + 'message' => [ + ['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false], + ['text' => $dates, 'bold' => true], + ['text' => $plural ? ' han sido canceladas.' : ' ha sido cancelada.', 'bold' => false], + ], + ]; + } + + /** + * @param Collection $changes + * @return array{type: string, change_ids: list, title: string, message: list}|null + */ + private function rescheduleNotice(Collection $changes): ?array + { + $changes = $changes->whereNotNull('new_date'); + $sourceDates = $this->formatDates($changes, 'previous_date'); + $destinationDates = $this->formatDates($changes, 'new_date'); + + if ($sourceDates === null || $destinationDates === null) { + return null; + } + + $plural = $changes->count() > 1; + $message = [ + ['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false], + ['text' => $sourceDates, 'bold' => true], + [ + 'text' => $plural ? ' han sido reprogramadas para el ' : ' ha sido reprogramada para el ', + 'bold' => false, + ], + ['text' => $destinationDates, 'bold' => true], + ]; + + if ($plural) { + $message[] = ['text' => ', ', 'bold' => false]; + $message[] = ['text' => 'respectivamente', 'bold' => true]; + } + + $message[] = ['text' => '.', 'bold' => false]; + + return [ + 'type' => EventDateChangeType::Rescheduled->value, + 'change_ids' => $this->changeIds($changes), + 'title' => $plural ? 'FECHAS REPROGRAMADAS!' : 'FECHA REPROGRAMADA!', + 'message' => $message, + ]; + } + + /** + * @param Collection $changes + */ + private function formatDates(Collection $changes, string $attribute): ?string + { + return $this->dateTextFormatter->formatForSentence( + $changes + ->pluck($attribute) + ->filter() + ->map(fn ($date): string => $date->format('Y-m-d')) + ); + } + + /** + * @param Collection $changes + * @return list + */ + private function changeIds(Collection $changes): array + { + return $changes + ->pluck('id') + ->filter(fn ($id): bool => $id !== null) + ->map(fn ($id): int => (int) $id) + ->values() + ->all(); + } +} diff --git a/app/Domains/Event/Services/EventDateNoticeService.php b/app/Domains/Event/Services/EventDateNoticeService.php new file mode 100644 index 0000000..239e3fa --- /dev/null +++ b/app/Domains/Event/Services/EventDateNoticeService.php @@ -0,0 +1,60 @@ +, + * title: string, + * message: list + * }> + */ + public function claimFor(User $user, Tenant $tenant): array + { + return DB::transaction(function () use ($user, $tenant): array { + $lockedUser = User::query()->whereKey($user->getKey())->lockForUpdate()->firstOrFail(); + + $changes = EventDateChange::query() + ->where('tenant_code', $tenant->codigo) + ->whereDoesntHave('views', fn ($query) => $query + ->where('user_id', $lockedUser->getKey()) + ->where('display_count', '>=', self::MAX_DISPLAYS)) + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + $notices = $this->formatter->format($changes); + $claimedChangeIds = collect($notices)->pluck('change_ids')->flatten()->unique(); + + foreach ($claimedChangeIds as $changeId) { + $view = EventDateChangeView::query()->firstOrNew([ + 'user_id' => $lockedUser->getKey(), + 'event_date_change_id' => $changeId, + ]); + $view->display_count = min( + self::MAX_DISPLAYS, + ((int) $view->display_count) + 1, + ); + $view->last_displayed_at = now(); + $view->save(); + } + + return $notices; + }); + } +} diff --git a/app/Domains/Event/Services/EventDateTextFormatter.php b/app/Domains/Event/Services/EventDateTextFormatter.php index 2731390..99d2f31 100644 --- a/app/Domains/Event/Services/EventDateTextFormatter.php +++ b/app/Domains/Event/Services/EventDateTextFormatter.php @@ -25,6 +25,21 @@ class EventDateTextFormatter /** @param iterable $dates */ public function format(iterable $dates): ?string { + return $this->formatWithOptions($dates, false, false); + } + + /** @param iterable $dates */ + public function formatForSentence(iterable $dates): ?string + { + return $this->formatWithOptions($dates, true, true); + } + + /** @param iterable $dates */ + private function formatWithOptions( + iterable $dates, + bool $padDays, + bool $includeYearPreposition, + ): ?string { $normalizedDates = collect($dates) ->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date)) ->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d')) @@ -37,12 +52,14 @@ class EventDateTextFormatter $years = $normalizedDates ->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y')) - ->map(function ($yearDates, string $year): string { + ->map(function ($yearDates, string $year) use ($padDays, $includeYearPreposition): string { $months = $yearDates ->groupBy(fn (DateTimeImmutable $date): string => $date->format('n')) - ->map(function ($monthDates, string $month): string { + ->map(function ($monthDates, string $month) use ($padDays): string { $days = $monthDates - ->map(fn (DateTimeImmutable $date): string => (string) ((int) $date->format('j'))) + ->map(fn (DateTimeImmutable $date): string => $padDays + ? $date->format('d') + : (string) ((int) $date->format('j'))) ->values() ->all(); @@ -51,7 +68,7 @@ class EventDateTextFormatter ->values() ->all(); - return $this->join($months).' '.$year; + return $this->join($months).($includeYearPreposition ? ' de ' : ' ').$year; }) ->values() ->all(); diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index 3425eae..7cecbd1 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -2,7 +2,19 @@ namespace App\Domains\Event\Services; +use App\Domains\Auth\Models\User; +use App\Domains\Cart\Services\InvalidateEventDateCartsService; +use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Services\StockReservationService; +use App\Domains\Catalog\Services\VariantReplacementService; +use App\Domains\Event\Enums\EventDateChangeType; +use App\Domains\Event\Events\EventDateRescheduled; +use App\Domains\Event\Events\EventDateSuspended; +use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Models\EventDateChange; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Models\Ticket; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -14,6 +26,13 @@ class EventService 'facebook_url' => 'facebook', ]; + public function __construct( + private readonly EffectiveEventDateResolver $effectiveEventDateResolver, + private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver, + private readonly VariantReplacementService $variantReplacementService, + private readonly InvalidateEventDateCartsService $invalidateEventDateCarts, + ) {} + public function forTenant(Tenant $tenant): Tenant { return $tenant->load(['eventDates.validityTime', 'socialMedia']); @@ -27,9 +46,14 @@ class EventService $tenant->update([ 'event_title' => $data['title'], 'event_location' => $data['location'], + ...array_intersect_key($data, array_flip([ + 'allow_ticket_refund', + 'allow_ticket_total_refund', + 'allow_ticket_partial_refund', + 'ticket_partial_refund_percentage', + ])), ]); - $this->syncDates($tenant, $data['dates']); if (array_key_exists('social_media', $data)) { $this->syncSocialMedia($tenant, $data['social_media']); } else { @@ -40,41 +64,252 @@ class EventService }); } - /** @param array $dates */ - private function syncDates(Tenant $tenant, array $dates): void + /** @param array{date: string, start_time: string, end_time: string} $data */ + public function createDateForTenant(Tenant $tenant, array $data): EventDate { - $existingDates = $tenant->eventDates()->get()->values(); + return DB::transaction(function () use ($tenant, $data): EventDate { + $attributes = $this->dateAttributes($data); - foreach (array_values($dates) as $index => $date) { - $attributes = [ - 'date' => $date['date'], - 'time_start' => $date['start_time'], - 'time_end' => $date['end_time'], - ]; + if ($tenant->eventDates()->where($attributes)->exists()) { + throw ValidationException::withMessages([ + 'date' => ['La fecha y el horario ya existen.'], + ]); + } - $existingDate = $existingDates->get($index); + return $tenant->eventDates()->create($attributes)->load('validityTime'); + }); + } - if ($existingDate) { - $existingDate->update($attributes); - } else { - $tenant->eventDates()->create($attributes); + /** @param array{date: string} $data */ + public function rescheduleDateForTenant( + Tenant $tenant, + EventDate $eventDate, + array $data, + ?User $createdBy = null, + ): EventDate { + return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate { + $source = $this->lockedDateForTenant($tenant, $eventDate); + + if ($source->suspended_at !== null) { + throw ValidationException::withMessages([ + 'event_date' => ['No se puede reprogramar una fecha suspendida.'], + ]); + } + + if ($source->rescheduled_to_event_date_id !== null) { + throw ValidationException::withMessages([ + 'event_date' => ['La fecha ya fue reprogramada.'], + ]); + } + + $destination = $tenant->eventDates() + ->whereDate('date', $data['date']) + ->lockForUpdate() + ->first(); + + if ($destination === null) { + $destination = $tenant->eventDates()->create([ + 'date' => $data['date'], + 'time_start' => $source->time_start, + 'time_end' => $source->time_end, + ]); + } + + if ($destination->is($source) || $this->chainContains($destination, $source)) { + throw ValidationException::withMessages([ + 'date' => ['La reprogramación generaría una referencia circular.'], + ]); + } + + $effectiveDestination = $this->effectiveEventDateResolver->resolve($destination); + if ($effectiveDestination === null) { + throw ValidationException::withMessages([ + 'date' => ['La fecha de destino no es utilizable.'], + ]); + } + + $affectedDateIds = $this->affectedDateIds($tenant, $source); + $this->invalidateEventDateCarts->invalidate($tenant, $affectedDateIds); + $purchaseTickets = $this->affectedPurchaseResolver->resolve( + $tenant, + $affectedDateIds, + ); + $source->update(['rescheduled_to_event_date_id' => $destination->getKey()]); + $this->variantReplacementService->replaceEventDate($source, $effectiveDestination); + + EventDateChange::query()->create([ + 'tenant_code' => $tenant->codigo, + 'change_type' => EventDateChangeType::Rescheduled, + 'source_event_date_id' => $source->getKey(), + 'destination_event_date_id' => $destination->getKey(), + 'created_by_user_id' => $createdBy?->getKey(), + 'previous_date' => $source->date->format('Y-m-d'), + 'new_date' => $destination->date->format('Y-m-d'), + ]); + + EventDateRescheduled::dispatch( + $tenant->codigo, + $source->getKey(), + $destination->getKey(), + $source->date->format('d/m/Y'), + $destination->date->format('d/m/Y'), + $purchaseTickets, + ); + + return $source->fresh(['validityTime', 'rescheduledTo.validityTime']); + }); + } + + public function suspendDateForTenant( + Tenant $tenant, + EventDate $eventDate, + ?User $createdBy = null, + ): EventDate { + return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate { + $date = $this->lockedDateForTenant($tenant, $eventDate); + + if ($date->rescheduled_to_event_date_id !== null) { + throw ValidationException::withMessages([ + 'event_date' => ['No se puede suspender una fecha que ya fue reprogramada.'], + ]); + } + + if ($date->suspended_at !== null) { + return $date->load('validityTime'); + } + + $affectedDateIds = $this->affectedDateIds($tenant, $date); + $this->invalidateEventDateCarts->invalidate( + $tenant, + $affectedDateIds, + StockReservationService::REASON_EVENT_DATE_SUSPENDED, + ); + $purchaseTickets = $this->affectedPurchaseResolver->resolve($tenant, $affectedDateIds); + $date->update(['suspended_at' => now()]); + $this->variantReplacementService->disableForSuspension($date); + $this->disableTicketsWithoutUsableDates($tenant, $date); + + EventDateChange::query()->create([ + 'tenant_code' => $tenant->codigo, + 'change_type' => EventDateChangeType::Suspended, + 'source_event_date_id' => $date->getKey(), + 'destination_event_date_id' => null, + 'created_by_user_id' => $createdBy?->getKey(), + 'previous_date' => $date->date->format('Y-m-d'), + 'new_date' => null, + ]); + + EventDateSuspended::dispatch( + $tenant->codigo, + $date->getKey(), + $date->date->format('d/m/Y'), + $purchaseTickets, + ); + + return $date->fresh('validityTime'); + }); + } + + private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate + { + return $tenant->eventDates() + ->whereKey($eventDate->getKey()) + ->lockForUpdate() + ->firstOrFail(); + } + + private function chainContains(EventDate $start, EventDate $expected): bool + { + $current = $start; + $visited = []; + + while ($current->rescheduled_to_event_date_id !== null) { + if ($current->is($expected)) { + return true; + } + + if (isset($visited[$current->getKey()])) { + return true; + } + + $visited[$current->getKey()] = true; + $current = $current->rescheduledTo()->lockForUpdate()->first(); + + if ($current === null) { + return false; } } - $datesToDelete = $existingDates->slice(count($dates)); + return $current->is($expected); + } - if ($datesToDelete->contains(fn ($eventDate): bool => $eventDate - ->selectedByVariants() - ->whereHas('sourceTickets') - ->exists() - || $eventDate->variants()->whereHas('sourceTickets')->exists())) { - throw ValidationException::withMessages([ - 'dates' => ['No se puede eliminar una fecha utilizada por tickets generados.'], - ]); + /** @return Collection */ + private function affectedDateIds(Tenant $tenant, EventDate $eventDate): Collection + { + $affectedDateIds = collect([$eventDate->getKey()]); + $frontier = $affectedDateIds; + + while ($frontier->isNotEmpty()) { + $predecessors = $tenant->eventDates() + ->whereIn('rescheduled_to_event_date_id', $frontier) + ->pluck('id') + ->diff($affectedDateIds) + ->values(); + $affectedDateIds = $affectedDateIds->merge($predecessors)->unique()->values(); + $frontier = $predecessors; } - $datesToDelete->each->delete(); - $tenant->unsetRelation('eventDates'); + return $affectedDateIds; + } + + private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void + { + $affectedDateIds = $this->affectedDateIds($tenant, $suspendedDate); + + $variants = Variant::withTrashed() + ->where(function ($query) use ($affectedDateIds): void { + $query->whereIn('event_date_id', $affectedDateIds) + ->orWhereHas('eventDates', fn ($eventDates) => $eventDates + ->whereIn('event_dates.id', $affectedDateIds)); + }) + ->with(['eventDates', 'eventDate']) + ->get(); + + foreach ($variants as $variant) { + $hasUsableDate = $variant->selectedEventDates()->contains( + fn (EventDate $candidate): bool => $this->effectiveEventDateResolver->resolve($candidate) !== null + ); + + if ($hasUsableDate) { + continue; + } + + Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->where('source_variant_id', $variant->getKey()) + ->whereNull('disabled_at') + ->whereNull('cancelled_at') + ->whereNull('refunded_at') + ->lockForUpdate() + ->get() + ->each(function (Ticket $ticket): void { + $ticket->markAsDisabled(); + $ticket->save(); + }); + } + } + + /** + * @param array{date: string, start_time: string, end_time: string} $data + * @return array{date: string, time_start: string, time_end: string} + */ + private function dateAttributes(array $data): array + { + return [ + 'date' => $data['date'], + 'time_start' => $data['start_time'].':00', + 'time_end' => $data['end_time'].':00', + ]; } /** @param array $contact */ diff --git a/app/Domains/Event/documentacion/README.md b/app/Domains/Event/documentacion/README.md index 6959a0b..dd46873 100644 --- a/app/Domains/Event/documentacion/README.md +++ b/app/Domains/Event/documentacion/README.md @@ -8,6 +8,7 @@ Administra la configuración temporal de un tenant orientado a eventos y sus fec - `Models/EventDate.php`: fecha del evento con inicio, fin, tenant y variantes asociadas. - `Services/EventService.php`: obtiene y actualiza la configuración de evento del tenant. +- `Services/EventDateNoticeService.php`: reclama y agrupa los cambios pendientes de cada usuario. - `Controllers/AdminApp/EventController.php`: consulta y modificación desde AdminApp. - `UpdateEventRequest`: valida datos y reglas cruzadas de fechas. - `EventResource`: serializa la configuración de salida. @@ -19,10 +20,18 @@ Bajo `/v1/adminapp/tenant/event`, protegidos por `auth:sanctum` y `adminapp.tena - `GET`: obtiene la configuración. - `PUT`: actualiza la configuración. +Para el storefront autenticado: + +- `POST /tenants/{tenant}/event-date-notices/claim`: devuelve hasta un aviso de suspensiones y otro de + reprogramaciones. Cada cambio se muestra como máximo tres veces por usuario. + ## Dependencias Depende de `Tenant`. Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar tickets. ## Consideraciones -El archivo `routes/api.php` no publica operaciones adicionales. Al modificar fechas debe mantenerse la validación de orden y coherencia temporal de `UpdateEventRequest`. +Los avisos se construyen dinámicamente después de excluir los cambios que el usuario ya vio +tres veces. Al reclamar los avisos se incrementa una vez cada cambio incluido, aunque varios +cambios aparezcan agrupados en el mismo mensaje. El reclamo bloquea al usuario durante la +transacción para impedir que pestañas concurrentes superen el máximo. diff --git a/app/Domains/Event/routes/adminapp.php b/app/Domains/Event/routes/adminapp.php index 75ae9ea..529e0b6 100644 --- a/app/Domains/Event/routes/adminapp.php +++ b/app/Domains/Event/routes/adminapp.php @@ -8,4 +8,7 @@ Route::prefix('v1/adminapp/tenant') ->group(function (): void { Route::get('event', [EventController::class, 'show']); Route::put('event', [EventController::class, 'update']); + Route::post('event-dates', [EventController::class, 'storeDate']); + Route::post('event-dates/{eventDate}/reschedule', [EventController::class, 'rescheduleDate']); + Route::post('event-dates/{eventDate}/suspend', [EventController::class, 'suspendDate']); }); diff --git a/app/Domains/Event/routes/api.php b/app/Domains/Event/routes/api.php index 062e0fe..19a1261 100644 --- a/app/Domains/Event/routes/api.php +++ b/app/Domains/Event/routes/api.php @@ -1,3 +1,11 @@ post( + 'tenants/{tenant:codigo}/event-date-notices/claim', + [EventDateNoticeController::class, 'claim'], +); diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php b/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php index 109a836..103bdf0 100644 --- a/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php +++ b/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php @@ -2,6 +2,7 @@ namespace App\Domains\FiestaFutbolInfantil\Controllers; +use App\Domains\FiestaFutbolInfantil\Requests\UpdateHistoricalFoodStockRequest; use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest; use App\Domains\FiestaFutbolInfantil\Resources\FoodResource; use App\Domains\FiestaFutbolInfantil\Services\FoodService; @@ -32,6 +33,16 @@ class FoodController extends Controller ); } + public function updateHistoricalStock(UpdateHistoricalFoodStockRequest $request): FoodResource + { + return FoodResource::make( + $this->foodService->updateHistoricalStock( + $request->user()->tenant()->firstOrFail(), + $request->validated('variants'), + ) + ); + } + public function destroy(Request $request, int $food): Response { $this->foodService->delete( diff --git a/app/Domains/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php b/app/Domains/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php new file mode 100644 index 0000000..b75832d --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php @@ -0,0 +1,24 @@ + */ + public function rules(): array + { + return [ + 'variants' => ['required', 'array', 'min:1', 'max:500'], + 'variants.*' => ['required', 'array:id,stock'], + 'variants.*.id' => ['required', 'integer', 'distinct'], + 'variants.*.stock' => ['required', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php index 41e414f..9e32742 100644 --- a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php +++ b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php @@ -5,6 +5,7 @@ namespace App\Domains\FiestaFutbolInfantil\Resources; use App\Domains\Catalog\Models\CatalogItem; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Validation\ValidationException; /** @mixin CatalogItem */ class EntryResource extends JsonResource @@ -12,7 +13,20 @@ class EntryResource extends JsonResource /** @return array */ public function toArray(Request $request): array { - $variant = $this->variants->sole(); + $variants = $this->variants->whereNull('replaced_by_variant_id'); + + if ($variants->count() !== 1) { + throw ValidationException::withMessages([ + 'entries' => [sprintf( + 'La entrada %s tiene %d variantes sin reemplazar (IDs: %s). Se esperaba una.', + $this->id, + $variants->count(), + $variants->pluck('id')->implode(', '), + )], + ]); + } + + $variant = $variants->first(); return [ 'id' => $this->id, diff --git a/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php b/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php index ad1f800..e3e854c 100644 --- a/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php +++ b/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php @@ -3,8 +3,14 @@ namespace App\Domains\FiestaFutbolInfantil\Resources; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Enums\EventDateChangeType; +use App\Domains\Event\Enums\EventDateStatus; +use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Models\EventDateChange; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Collection; /** @mixin CatalogItem */ class FoodResource extends JsonResource @@ -17,27 +23,134 @@ class FoodResource extends JsonResource 'id' => null, 'name' => 'Comida', 'variants' => [], + 'history' => [], ]; } + $currentVariants = $this->variants + ->filter(fn (Variant $variant): bool => $this->isCurrent($variant)); + $historicalVariants = $this->variants + ->filter(fn (Variant $variant): bool => $this->isHistorical($variant)); + return [ 'id' => $this->id, 'name' => $this->nombre, - 'variants' => $this->variants->map(function ($variant): array { - $values = $variant->selectionValues(); - $eventDate = $variant->selectedEventDates()->first(); - - return [ - 'id' => $variant->id, - 'event_date_id' => $eventDate?->id, - 'event_date' => $eventDate?->date?->format('Y-m-d'), - 'schedule' => $values->get('horario'), - 'service' => $values->get('servicio'), - 'description' => $variant->descripcion, - 'stock' => $variant->inventory->real_stock, - 'price' => number_format($variant->getPrice(), 2, '.', ''), - ]; - })->values(), + 'variants' => $currentVariants->map($this->variantData(...))->values(), + 'history' => $this->historyData($historicalVariants), ]; } + + private function isCurrent(Variant $variant): bool + { + if ($variant->sales_disabled_at !== null || $variant->replaced_by_variant_id !== null) { + return false; + } + + $status = $variant->selectedEventDates()->first()?->status; + + return $status === null || in_array( + $status, + [EventDateStatus::Scheduled, EventDateStatus::InProgress], + true, + ); + } + + private function isHistorical(Variant $variant): bool + { + return in_array( + $variant->selectedEventDates()->first()?->status, + [ + EventDateStatus::Rescheduled, + EventDateStatus::Suspended, + EventDateStatus::Completed, + ], + true, + ); + } + + /** @return array */ + private function variantData(Variant $variant): array + { + $values = $variant->selectionValues(); + $eventDate = $variant->selectedEventDates()->first(); + + return [ + 'id' => $variant->id, + 'event_date_id' => $eventDate?->id, + 'event_date' => $eventDate?->date?->format('Y-m-d'), + 'schedule' => $values->get('horario'), + 'service' => $values->get('servicio'), + 'description' => $variant->descripcion, + 'stock' => $variant->inventory->real_stock, + 'price' => number_format($variant->getPrice(), 2, '.', ''), + ]; + } + + /** + * @param Collection $variants + * @return Collection> + */ + private function historyData(Collection $variants): Collection + { + return $variants + ->filter(fn (Variant $variant): bool => $variant->selectedEventDates()->first() !== null) + ->groupBy(fn (Variant $variant): int => (int) $variant->selectedEventDates()->first()->id) + ->map(function (Collection $dateVariants): array { + /** @var EventDate $eventDate */ + $eventDate = $dateVariants->first()->selectedEventDates()->first(); + $status = $this->historicalStatus($eventDate); + $change = $this->changeForStatus($eventDate, $status); + + return [ + 'id' => $eventDate->id, + 'change_id' => $change?->id, + 'status' => $status->value, + 'status_text' => match ($status) { + EventDateStatus::Rescheduled => 'REPROGRAMADA', + EventDateStatus::Suspended => 'CANCELADA', + EventDateStatus::Completed => 'FINALIZADA', + default => '', + }, + 'event_date_id' => $eventDate->id, + 'event_date' => $eventDate->date->format('Y-m-d'), + 'replacement_event_date_id' => $status === EventDateStatus::Rescheduled + ? ($change?->destination_event_date_id + ?? $eventDate->rescheduled_to_event_date_id) + : null, + 'replacement_event_date' => $status === EventDateStatus::Rescheduled + ? ($change?->new_date?->format('Y-m-d') + ?? $eventDate->rescheduledTo?->date?->format('Y-m-d')) + : null, + 'occurred_at' => ($change?->created_at ?? $eventDate->endsAt())->toISOString(), + 'variants' => $dateVariants->map($this->variantData(...))->values(), + ]; + }) + ->sortByDesc('occurred_at') + ->values(); + } + + private function historicalStatus(EventDate $eventDate): EventDateStatus + { + return match ($eventDate->status) { + EventDateStatus::Rescheduled => EventDateStatus::Rescheduled, + EventDateStatus::Suspended => EventDateStatus::Suspended, + default => EventDateStatus::Completed, + }; + } + + private function changeForStatus( + EventDate $eventDate, + EventDateStatus $status, + ): ?EventDateChange { + $changeType = match ($status) { + EventDateStatus::Rescheduled => EventDateChangeType::Rescheduled, + EventDateStatus::Suspended => EventDateChangeType::Suspended, + default => null, + }; + + return $changeType === null + ? null + : $eventDate->changeHistory + ->first(fn (EventDateChange $change): bool => $change->change_type === $changeType); + } } diff --git a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php index 0c44363..26372a2 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php @@ -24,7 +24,13 @@ class EntryService return CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas')) + ->whereHas('variants', fn ($query) => $query + ->whereNull('replaced_by_variant_id') + ->whereNull('sales_disabled_at')) ->with([ + 'variants' => fn ($query) => $query + ->whereNull('replaced_by_variant_id') + ->whereNull('sales_disabled_at'), 'variants.inventory', 'variants.eventDate', 'variants.eventDates', @@ -97,6 +103,7 @@ class EntryService $variants = Variant::query() ->where('catalog_item_id', $catalogItem->id) + ->whereNull('replaced_by_variant_id') ->lockForUpdate() ->get(); diff --git a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php index 7551b69..d9a4064 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php @@ -11,6 +11,8 @@ use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogService; +use App\Domains\Event\Enums\EventDateStatus; +use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -36,8 +38,10 @@ class FoodService ->with([ 'variants.catalogItem', 'variants.inventory', - 'variants.eventDate', - 'variants.eventDates', + 'variants.eventDate.rescheduledTo', + 'variants.eventDate.changeHistory.destinationEventDate', + 'variants.eventDates.rescheduledTo', + 'variants.eventDates.changeHistory.destinationEventDate', 'variants.definitions.itemAttribute.attribute', ]) ->first(); @@ -55,11 +59,16 @@ class FoodService $food->variants()->whereNull('precio')->update(['precio' => $food->precio]); $existingVariants = $food->variants() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') ->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute']) ->lockForUpdate() - ->get(); + ->get() + ->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant)) + ->values(); $resolvedVariants = $this->resolveVariants($variants, $attributes); + $this->validateCurrentEventDates($tenant, $resolvedVariants); $this->validateCombinations($resolvedVariants, $existingVariants); foreach ($resolvedVariants as $index => $data) { @@ -80,7 +89,13 @@ class FoodService } } - $minimumPrice = $food->variants()->min('precio'); + $minimumPrice = $food->variants() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['eventDate', 'eventDates']) + ->get() + ->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant)) + ->min('precio'); if ($minimumPrice !== null) { $food->update(['precio' => $minimumPrice]); } @@ -88,22 +103,103 @@ class FoodService return $food->fresh()->load([ 'variants.catalogItem', 'variants.inventory', - 'variants.eventDate', - 'variants.eventDates', + 'variants.eventDate.rescheduledTo', + 'variants.eventDate.changeHistory.destinationEventDate', + 'variants.eventDates.rescheduledTo', + 'variants.eventDates.changeHistory.destinationEventDate', 'variants.definitions.itemAttribute.attribute', ]); }); } + /** + * @param array $variants + */ + public function updateHistoricalStock(Tenant $tenant, array $variants): CatalogItem + { + return DB::transaction(function () use ($tenant, $variants): CatalogItem { + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->lockForUpdate() + ->firstOrFail(); + $variantIds = collect($variants)->pluck('id')->map(fn ($id): int => (int) $id); + $historicalVariants = $food->variants() + ->whereIn('id', $variantIds) + ->with(['inventory', 'eventDate', 'eventDates']) + ->lockForUpdate() + ->get() + ->filter(fn (Variant $variant): bool => $this->hasHistoricalDate($variant)) + ->keyBy('id'); + + foreach ($variants as $index => $data) { + $variant = $historicalVariants->get((int) $data['id']); + if ($variant === null) { + throw ValidationException::withMessages([ + "variants.{$index}.id" => [ + 'La variante no pertenece al historial de Comida.', + ], + ]); + } + + $stock = (int) $data['stock']; + $inventory = $this->inventoryForHistoricalStockUpdate($variant); + if ($stock < $inventory->reserved_stock) { + throw ValidationException::withMessages([ + "variants.{$index}.stock" => [ + 'El stock no puede ser menor que la cantidad actualmente reservada.', + ], + ]); + } + + $inventory->update(['real_stock' => $stock]); + } + + return $this->current($tenant) ?? $food; + }); + } + + private function inventoryForHistoricalStockUpdate(Variant $variant): Inventory + { + $inventory = Inventory::query() + ->whereKey($variant->inventory_id) + ->lockForUpdate() + ->firstOrFail(); + $variantsSharingInventory = Variant::query() + ->where('inventory_id', $inventory->getKey()) + ->orderBy('id') + ->lockForUpdate() + ->get(['id']); + + if ($variantsSharingInventory->count() === 1) { + return $inventory; + } + + $historicalInventory = Inventory::query()->create([ + 'sold_units' => $inventory->sold_units, + 'refunded_units' => $inventory->refunded_units, + 'reserved_stock' => 0, + 'real_stock' => $inventory->real_stock, + ]); + $variant->update(['inventory_id' => $historicalInventory->getKey()]); + + return $historicalInventory; + } + public function delete(Tenant $tenant, int $foodId): void { $variant = Variant::query() ->whereKey($foodId) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['eventDate', 'eventDates']) ->whereHas('catalogItem', fn ($query) => $query ->where('tenant_code', $tenant->codigo) ->where('slug', 'comida')) ->firstOrFail(); + abort_unless($this->hasCurrentDate($variant), 404); + $this->catalogService->deleteVariant($variant); } @@ -225,6 +321,30 @@ class FoodService return $option; } + /** @param array> $variants */ + private function validateCurrentEventDates(Tenant $tenant, array $variants): void + { + $eventDates = EventDate::query() + ->where('tenant_code', $tenant->codigo) + ->whereIn('id', collect($variants)->pluck('event_date_id')->unique()) + ->get() + ->keyBy('id'); + + foreach ($variants as $index => $variant) { + $eventDate = $eventDates->get($variant['event_date_id']); + + if ($eventDate !== null && $this->isCurrentStatus($eventDate->status)) { + continue; + } + + throw ValidationException::withMessages([ + "variants.{$index}.event_date_id" => [ + 'La fecha seleccionada ya no está disponible.', + ], + ]); + } + } + /** * @param array> $incoming * @param Collection $existing @@ -329,4 +449,25 @@ class FoodService mb_strtolower(trim($service)), ]); } + + private function hasCurrentDate(Variant $variant): bool + { + $status = $variant->selectedEventDates()->first()?->status; + + return $status === null || $this->isCurrentStatus($status); + } + + private function hasHistoricalDate(Variant $variant): bool + { + return in_array( + $variant->selectedEventDates()->first()?->status, + [EventDateStatus::Rescheduled, EventDateStatus::Suspended, EventDateStatus::Completed], + true, + ); + } + + private function isCurrentStatus(EventDateStatus $status): bool + { + return in_array($status, [EventDateStatus::Scheduled, EventDateStatus::InProgress], true); + } } diff --git a/app/Domains/FiestaFutbolInfantil/routes/api.php b/app/Domains/FiestaFutbolInfantil/routes/api.php index 39d4a47..242cad0 100644 --- a/app/Domains/FiestaFutbolInfantil/routes/api.php +++ b/app/Domains/FiestaFutbolInfantil/routes/api.php @@ -39,6 +39,9 @@ Route::prefix('v1/adminapp/tenant') Route::post('foods', [FoodController::class, 'store']) ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') ->name('adminapp.fiesta-futbol-infantil.foods.store'); + Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') + ->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update'); Route::delete('foods/{food}', [FoodController::class, 'destroy']) ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') ->name('adminapp.fiesta-futbol-infantil.foods.destroy'); diff --git a/app/Domains/Forms/Controllers/AdminApp/EntryFormController.php b/app/Domains/Forms/Controllers/AdminApp/EntryFormController.php new file mode 100644 index 0000000..518425e --- /dev/null +++ b/app/Domains/Forms/Controllers/AdminApp/EntryFormController.php @@ -0,0 +1,22 @@ +entryFormService->get( + $request->user('sanctum')->tenant()->firstOrFail() + ) + ); + } +} diff --git a/app/Domains/Forms/Resources/EntryFormResource.php b/app/Domains/Forms/Resources/EntryFormResource.php new file mode 100644 index 0000000..87f2bef --- /dev/null +++ b/app/Domains/Forms/Resources/EntryFormResource.php @@ -0,0 +1,26 @@ + */ + public function toArray(Request $request): array + { + return [ + 'event_dates' => $this->resource['event_dates']->map( + fn (EventDate $eventDate): array => [ + 'id' => $eventDate->id, + 'validity_time_id' => $eventDate->validity_time_id, + 'validity_time' => ValidityTimeResource::make($eventDate->validityTime), + 'date' => $eventDate->date->format('Y-m-d'), + ] + )->values(), + ]; + } +} diff --git a/app/Domains/Forms/Services/EntryFormService.php b/app/Domains/Forms/Services/EntryFormService.php new file mode 100644 index 0000000..33311dc --- /dev/null +++ b/app/Domains/Forms/Services/EntryFormService.php @@ -0,0 +1,22 @@ +} */ + public function get(Tenant $tenant): array + { + return [ + 'event_dates' => $tenant->eventDates() + ->whereNull('rescheduled_to_event_date_id') + ->whereNull('suspended_at') + ->with('validityTime') + ->get(), + ]; + } +} diff --git a/app/Domains/Forms/Services/FoodFormService.php b/app/Domains/Forms/Services/FoodFormService.php index 72f2b07..5ed7636 100644 --- a/app/Domains/Forms/Services/FoodFormService.php +++ b/app/Domains/Forms/Services/FoodFormService.php @@ -4,6 +4,7 @@ namespace App\Domains\Forms\Services; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\AttributeOption; +use App\Domains\Event\Enums\EventDateStatus; use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Collection; @@ -27,7 +28,17 @@ class FoodFormService ->keyBy('codigo'); return [ - 'event_dates' => $tenant->eventDates()->with('validityTime')->get(), + 'event_dates' => $tenant->eventDates() + ->whereNull('rescheduled_to_event_date_id') + ->whereNull('suspended_at') + ->with('validityTime') + ->get() + ->filter(fn (EventDate $eventDate): bool => in_array( + $eventDate->status, + [EventDateStatus::Scheduled, EventDateStatus::InProgress], + true, + )) + ->values(), 'schedules' => $attributes->get('horario')?->options ?? new Collection, 'services' => $attributes->get('servicio')?->options ?? new Collection, ]; diff --git a/app/Domains/Forms/Services/TicketFilterFormService.php b/app/Domains/Forms/Services/TicketFilterFormService.php index 72ccc65..2089413 100644 --- a/app/Domains/Forms/Services/TicketFilterFormService.php +++ b/app/Domains/Forms/Services/TicketFilterFormService.php @@ -124,11 +124,10 @@ class TicketFilterFormService 'required' => false, 'default' => null, 'placeholder' => 'Estado', - 'options' => [ - ['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'], - ['value' => Ticket::STATUS_USED, 'label' => 'Usado'], - ['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'], - ], + 'options' => array_values(array_filter( + Ticket::statusOptions(), + fn (array $option): bool => $option['value'] !== Ticket::STATUS_CANCELLED, + )), ], ]; } diff --git a/app/Domains/Forms/Services/TicketFormService.php b/app/Domains/Forms/Services/TicketFormService.php index b9436d2..ff50bb2 100644 --- a/app/Domains/Forms/Services/TicketFormService.php +++ b/app/Domains/Forms/Services/TicketFormService.php @@ -236,11 +236,7 @@ class TicketFormService ?: $left['label'] <=> $right['label']); return [ - 'statuses' => [ - ['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'], - ['value' => Ticket::STATUS_USED, 'label' => 'Usado'], - ['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'], - ], + 'statuses' => Ticket::statusOptions(), 'categories' => array_values(array_map( fn (array $category): array => [ 'value' => $category['value'], diff --git a/app/Domains/Forms/routes/adminapp.php b/app/Domains/Forms/routes/adminapp.php index 70f9ee3..93c41e5 100644 --- a/app/Domains/Forms/routes/adminapp.php +++ b/app/Domains/Forms/routes/adminapp.php @@ -1,5 +1,6 @@ */ + public array $backoff = [30, 120, 300]; + + public function handle(EventDateRescheduled $event): void + { + app(NotificationMailService::class)->sendEventDateRescheduled( + $event->tenantCode, + $event->sourceEventDateId, + $event->destinationEventDateId, + $event->previousDate, + $event->newDate, + $event->purchaseTickets, + ); + } +} diff --git a/app/Domains/Notification/Listeners/SendEventDateSuspendedEmails.php b/app/Domains/Notification/Listeners/SendEventDateSuspendedEmails.php new file mode 100644 index 0000000..93af492 --- /dev/null +++ b/app/Domains/Notification/Listeners/SendEventDateSuspendedEmails.php @@ -0,0 +1,30 @@ + */ + public array $backoff = [30, 120, 300]; + + public function handle(EventDateSuspended $event): void + { + app(NotificationMailService::class)->sendEventDateSuspended( + $event->tenantCode, + $event->eventDateId, + $event->date, + $event->purchaseTickets, + ); + } +} diff --git a/app/Domains/Notification/Models/EmailDelivery.php b/app/Domains/Notification/Models/EmailDelivery.php new file mode 100644 index 0000000..8b16a6d --- /dev/null +++ b/app/Domains/Notification/Models/EmailDelivery.php @@ -0,0 +1,44 @@ + 'integer', + 'context' => 'array', + 'claimed_at' => 'datetime', + 'lease_expires_at' => 'datetime', + 'sent_at' => 'datetime', + 'failed_at' => 'datetime', + ]; + } +} diff --git a/app/Domains/Notification/Services/IdempotentEmailDeliveryService.php b/app/Domains/Notification/Services/IdempotentEmailDeliveryService.php new file mode 100644 index 0000000..48e8c37 --- /dev/null +++ b/app/Domains/Notification/Services/IdempotentEmailDeliveryService.php @@ -0,0 +1,112 @@ + $context + * @param Closure(): void $send + */ + public function sendOnce( + string $key, + string $type, + ?string $tenantCode, + array $context, + string $recipient, + Closure $send, + ): bool { + $now = now(); + + EmailDelivery::query()->insertOrIgnore([ + 'idempotency_key' => $key, + 'email_type' => $type, + 'tenant_code' => $tenantCode, + 'status' => EmailDelivery::STATUS_PENDING, + 'attempts' => 0, + 'context' => json_encode($context, JSON_THROW_ON_ERROR), + 'recipient_fingerprint' => $this->recipientFingerprint($recipient), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $claimToken = (string) Str::uuid(); + $leaseExpiresAt = $now->copy()->addSeconds( + max(1, (int) config('mail.delivery_lease_seconds', 300)), + ); + + $claimed = EmailDelivery::query() + ->where('idempotency_key', $key) + ->where(function ($query) use ($now): void { + $query->whereIn('status', [ + EmailDelivery::STATUS_PENDING, + EmailDelivery::STATUS_FAILED, + ])->orWhere(function ($query) use ($now): void { + $query->where('status', EmailDelivery::STATUS_PROCESSING) + ->where('lease_expires_at', '<=', $now); + }); + }) + ->update([ + 'status' => EmailDelivery::STATUS_PROCESSING, + 'attempts' => new Expression('attempts + 1'), + 'context' => json_encode($context, JSON_THROW_ON_ERROR), + 'recipient_fingerprint' => $this->recipientFingerprint($recipient), + 'claim_token' => $claimToken, + 'claimed_at' => $now, + 'lease_expires_at' => $leaseExpiresAt, + 'failed_at' => null, + 'last_error' => null, + 'updated_at' => $now, + ]) === 1; + + if (! $claimed) { + return false; + } + + try { + $send(); + + EmailDelivery::query() + ->where('idempotency_key', $key) + ->where('claim_token', $claimToken) + ->update([ + 'status' => EmailDelivery::STATUS_SENT, + 'claim_token' => null, + 'lease_expires_at' => null, + 'sent_at' => now(), + 'updated_at' => now(), + ]); + } catch (Throwable $exception) { + EmailDelivery::query() + ->where('idempotency_key', $key) + ->where('claim_token', $claimToken) + ->update([ + 'status' => EmailDelivery::STATUS_FAILED, + 'claim_token' => null, + 'lease_expires_at' => null, + 'failed_at' => now(), + 'last_error' => Str::limit($exception::class, 2000, ''), + 'updated_at' => now(), + ]); + + throw $exception; + } + + return true; + } + + private function recipientFingerprint(string $recipient): string + { + return hash_hmac( + 'sha256', + mb_strtolower(trim($recipient)), + (string) config('app.key'), + ); + } +} diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index d053032..6652a04 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -11,6 +11,7 @@ use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Services\TicketPdfService; use App\Domains\Ticket\Services\TicketPresentationResolver; +use App\Domains\Ticket\Services\TicketValidityResolver; use Closure; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; @@ -21,32 +22,42 @@ class NotificationMailService public function __construct( private readonly MailService $mailService, private readonly TicketPdfService $ticketPdfService, + private readonly IdempotentEmailDeliveryService $emailDeliveryService, ) {} public function sendWelcome(int $userId, string $tenantCode): void { - $this->sendLogged('welcome', [ + $context = [ 'user_id' => $userId, 'tenant_code' => $tenantCode, - ], function () use ($userId, $tenantCode): array { - $tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail(); - $user = User::query()->findOrFail($userId); - $brand = $tenant->websiteType ?? $tenant; - $tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path; + ]; + $tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail(); + $user = User::query()->findOrFail($userId); - $this->mailService - ->forTenant($tenantCode) - ->send( - $user->email, - "Bienvenido a {$brand->nombre}", - view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(), - $brand, - ); + $this->sendIdempotently( + "welcome:{$tenantCode}:{$userId}", + 'welcome', + $tenantCode, + $context, + $user->email, + function () use ($user, $tenant, $tenantCode): array { + $brand = $tenant->websiteType ?? $tenant; + $tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path; - return [ - 'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type', - ]; - }); + $this->mailService + ->forTenant($tenantCode) + ->send( + $user->email, + "Bienvenido a {$brand->nombre}", + view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(), + $brand, + ); + + return [ + 'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type', + ]; + }, + ); } public function sendPasswordResetCode( @@ -60,128 +71,356 @@ class NotificationMailService 'channel' => $channel, ]; - $this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array { - $tenant = Tenant::query() - ->with('websiteType') - ->where('codigo', $tenantCode) - ->firstOrFail(); - $attempt = ResetPasswordAttempt::query() - ->with('user') - ->findOrFail($attemptId); + $tenant = Tenant::query() + ->with('websiteType') + ->where('codigo', $tenantCode) + ->firstOrFail(); + $attempt = ResetPasswordAttempt::query() + ->with('user') + ->findOrFail($attemptId); - if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) { - $this->logSkipped('password_reset', array_merge($context, [ - 'reason' => 'attempt_not_pending', - 'attempt_status' => $attempt->status, - 'user_id' => $attempt->user_id, - ])); - - return null; - } - - $recoveryDomain = match ($channel) { - PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio, - PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain, - default => $tenant->dominio, - }; - $recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT - && $tenant->base_path !== '/' - ? $tenant->base_path - : ''; - $recoveryQuery = ['email' => $attempt->user->email]; - if ( - $channel === PasswordResetRequested::CHANNEL_SCANNER - && $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED - ) { - $recoveryQuery['code'] = $attempt->codigo; - } - $recoveryUrl = $recoveryDomain === null - ? null - : 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery); - $brand = $tenant->websiteType ?? $tenant; - - [$subject, $template] = match ($attempt->reason) { - ResetPasswordAttempt::REASON_STAFF_CREATED => [ - 'Tu cuenta de escáner está lista', 'scanner-created', - ], - ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [ - 'Tu cuenta de administrador está lista', 'administrator-created', - ], - ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [ - 'Desbloqueá tu cuenta', 'account-locked', - ], - default => ['Código para recuperar tu contraseña', 'password-reset'], - }; - - $this->mailService - ->forTenant($tenantCode) - ->send( - $attempt->user->email, - "{$subject} - {$brand->nombre}", - view("mail.notifications.{$template}", [ - 'attempt' => $attempt, - 'recoveryUrl' => $recoveryUrl, - 'brand' => $brand, - ])->render(), - $brand, - ); - - return [ + if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) { + $this->logSkipped('password_reset', array_merge($context, [ + 'reason' => 'attempt_not_pending', + 'attempt_status' => $attempt->status, 'user_id' => $attempt->user_id, - 'recovery_domain_available' => $recoveryDomain !== null, - ]; - }); + ])); + + return; + } + + $this->sendIdempotently( + "password-reset:{$attemptId}", + 'password_reset', + $tenantCode, + $context, + $attempt->user->email, + function () use ($attempt, $tenant, $tenantCode, $channel): array { + $recoveryDomain = match ($channel) { + PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio, + PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain, + default => $tenant->dominio, + }; + $recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT + && $tenant->base_path !== '/' + ? $tenant->base_path + : ''; + $recoveryQuery = ['email' => $attempt->user->email]; + if ( + $channel === PasswordResetRequested::CHANNEL_SCANNER + && $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED + ) { + $recoveryQuery['code'] = $attempt->codigo; + } + $recoveryUrl = $recoveryDomain === null + ? null + : 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery); + $brand = $tenant->websiteType ?? $tenant; + + [$subject, $template] = match ($attempt->reason) { + ResetPasswordAttempt::REASON_STAFF_CREATED => [ + 'Tu cuenta de escáner está lista', 'scanner-created', + ], + ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [ + 'Tu cuenta de administrador está lista', 'administrator-created', + ], + ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [ + 'Desbloqueá tu cuenta', 'account-locked', + ], + default => ['Código para recuperar tu contraseña', 'password-reset'], + }; + + $this->mailService + ->forTenant($tenantCode) + ->send( + $attempt->user->email, + "{$subject} - {$brand->nombre}", + view("mail.notifications.{$template}", [ + 'attempt' => $attempt, + 'recoveryUrl' => $recoveryUrl, + 'brand' => $brand, + ])->render(), + $brand, + ); + + return [ + 'user_id' => $attempt->user_id, + 'recovery_domain_available' => $recoveryDomain !== null, + ]; + }, + ); } public function sendPurchaseConfirmed(int $purchaseId): void { $context = ['purchase_id' => $purchaseId]; - $this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array { - $purchase = Purchase::query() - ->with(['tenant', 'user', 'items']) - ->find($purchaseId); + $purchase = Purchase::query() + ->with(['tenant', 'user', 'items']) + ->find($purchaseId); - if ($purchase === null) { - $this->logSkipped('purchase_confirmed', array_merge($context, [ - 'reason' => 'purchase_not_found', - 'missing_model' => Purchase::class, + if ($purchase === null) { + $this->logSkipped('purchase_confirmed', array_merge($context, [ + 'reason' => 'purchase_not_found', + 'missing_model' => Purchase::class, + ])); + + return; + } + + $recipient = $this->recipientFor($purchase); + if ($recipient === '') { + $this->logSkipped('purchase_confirmed', array_merge($context, ['reason' => 'missing_recipient'])); + + return; + } + + $this->sendIdempotently( + "purchase-confirmed:{$purchaseId}", + 'purchase_confirmed', + $purchase->tenant_codigo, + $context, + $recipient, + function () use ($purchase, $recipient): array { + /** @var Collection $tickets */ + $tickets = $purchase->tickets() + ->where('tenant_code', $purchase->tenant_codigo) + ->with(TicketPresentationResolver::RELATIONS) + ->get(); + $attachments = $tickets->isEmpty() + ? [] + : [[ + 'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets), + 'name' => $this->ticketPdfService->filename($tickets), + 'mime' => 'application/pdf', + ]]; + + $this->mailService + ->forTenant($purchase->tenant_codigo) + ->send( + $recipient, + "Compra confirmada - Compra #{$purchase->getKey()}", + view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(), + attachments: $attachments, + ); + + return [ + 'tenant_code' => $purchase->tenant_codigo, + 'user_id' => $purchase->user_id, + 'purchase_status' => $purchase->status, + 'purchase_item_count' => $purchase->items->count(), + 'ticket_count' => $tickets->count(), + 'ticket_ids' => $tickets->modelKeys(), + ]; + }, + ); + } + + /** + * @param list}> $purchaseTickets + */ + public function sendEventDateRescheduled( + string $tenantCode, + int $sourceEventDateId, + int $destinationEventDateId, + string $previousDate, + string $newDate, + array $purchaseTickets, + ): void { + foreach ($purchaseTickets as $purchaseTicketGroup) { + $purchaseId = $purchaseTicketGroup['purchase_id']; + $ticketIds = $purchaseTicketGroup['ticket_ids']; + $context = [ + 'tenant_code' => $tenantCode, + 'event_date_id' => $sourceEventDateId, + 'destination_event_date_id' => $destinationEventDateId, + 'purchase_id' => $purchaseId, + 'ticket_ids' => $ticketIds, + ]; + $purchase = $this->eventDateNotificationPurchase($tenantCode, $purchaseId); + + if ($purchase === null || $purchase->status !== Purchase::STATUS_PAID) { + $this->logSkipped('event_date_rescheduled', array_merge($context, [ + 'reason' => 'purchase_not_paid_or_not_found', ])); - return null; + continue; } /** @var Collection $tickets */ $tickets = $purchase->tickets() - ->where('tenant_code', $purchase->tenant_codigo) - ->with(TicketPresentationResolver::RELATIONS) - ->get(); - $attachments = $tickets->isEmpty() - ? [] - : [[ - 'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets), - 'name' => $this->ticketPdfService->filename($tickets), - 'mime' => 'application/pdf', - ]]; + ->where('tenant_code', $tenantCode) + ->whereKey($ticketIds) + ->with([ + ...TicketPresentationResolver::RELATIONS, + ...TicketValidityResolver::RELATIONS, + ]) + ->get() + ->filter(fn (Ticket $ticket): bool => $ticket->is_active()) + ->values(); - $this->mailService - ->forTenant($purchase->tenant_codigo) - ->send( - $this->recipientFor($purchase), - "Compra confirmada - Compra #{$purchase->getKey()}", - view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(), - attachments: $attachments, - ); + if ($tickets->isEmpty()) { + $this->logSkipped('event_date_rescheduled', array_merge($context, [ + 'reason' => 'no_longer_active_tickets', + ])); - return [ - 'tenant_code' => $purchase->tenant_codigo, - 'user_id' => $purchase->user_id, - 'purchase_status' => $purchase->status, - 'purchase_item_count' => $purchase->items->count(), - 'ticket_count' => $tickets->count(), - 'ticket_ids' => $tickets->modelKeys(), + continue; + } + + $recipient = $this->recipientFor($purchase); + if ($recipient === '') { + $this->logSkipped('event_date_rescheduled', array_merge($context, [ + 'reason' => 'missing_recipient', + ])); + + continue; + } + + $deliveryKey = "event-date-rescheduled:{$sourceEventDateId}:{$destinationEventDateId}:{$purchaseId}"; + $this->sendIdempotently( + $deliveryKey, + 'event_date_rescheduled', + $tenantCode, + $context, + $recipient, + function () use ( + $tenantCode, + $purchase, + $recipient, + $previousDate, + $newDate, + $tickets, + ): array { + $brand = $purchase->tenant->websiteType ?? $purchase->tenant; + + $this->mailService + ->forTenant($tenantCode) + ->send( + $recipient, + "Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}", + view('mail.notifications.event-date-rescheduled', compact( + 'purchase', 'previousDate', 'newDate', 'tickets' + ))->render(), + $brand, + ); + + return ['ticket_count' => $tickets->count()]; + }, + ); + } + } + + /** + * @param list}> $purchaseTickets + */ + public function sendEventDateSuspended( + string $tenantCode, + int $eventDateId, + string $date, + array $purchaseTickets, + ): void { + foreach ($purchaseTickets as $purchaseTicketGroup) { + $purchaseId = $purchaseTicketGroup['purchase_id']; + $ticketIds = $purchaseTicketGroup['ticket_ids']; + $context = [ + 'tenant_code' => $tenantCode, + 'event_date_id' => $eventDateId, + 'purchase_id' => $purchaseId, + 'ticket_ids' => $ticketIds, ]; - }); + $purchase = $this->eventDateNotificationPurchase($tenantCode, $purchaseId); + + if ($purchase === null || $purchase->status !== Purchase::STATUS_PAID) { + $this->logSkipped('event_date_suspended', array_merge($context, [ + 'reason' => 'purchase_not_paid_or_not_found', + ])); + + continue; + } + + /** @var Collection $tickets */ + $tickets = $purchase->tickets() + ->where('tenant_code', $tenantCode) + ->whereKey($ticketIds) + ->with([ + ...TicketPresentationResolver::RELATIONS, + ...TicketValidityResolver::RELATIONS, + ]) + ->get() + ->filter(fn (Ticket $ticket): bool => in_array($ticket->status, [ + Ticket::STATUS_ACTIVE, + Ticket::STATUS_DISABLED, + ], true)) + ->values(); + + if ($tickets->isEmpty()) { + $this->logSkipped('event_date_suspended', array_merge($context, [ + 'reason' => 'no_longer_relevant_tickets', + ])); + + continue; + } + + $recipient = $this->recipientFor($purchase); + if ($recipient === '') { + $this->logSkipped('event_date_suspended', array_merge($context, [ + 'reason' => 'missing_recipient', + ])); + + continue; + } + + $deliveryKey = "event-date-suspended:{$eventDateId}:{$purchaseId}"; + $disabledTickets = $tickets + ->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_DISABLED) + ->values(); + $activeTickets = $tickets + ->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_ACTIVE) + ->values(); + + $this->sendIdempotently( + $deliveryKey, + 'event_date_suspended', + $tenantCode, + $context, + $recipient, + function () use ( + $tenantCode, + $purchase, + $recipient, + $date, + $disabledTickets, + $activeTickets, + ): array { + $brand = $purchase->tenant->websiteType ?? $purchase->tenant; + + $this->mailService + ->forTenant($tenantCode) + ->send( + $recipient, + "Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}", + view('mail.notifications.event-date-suspended', compact( + 'purchase', 'date', 'disabledTickets', 'activeTickets' + ))->render(), + $brand, + ); + + return [ + 'ticket_count' => $disabledTickets->count() + $activeTickets->count(), + 'disabled_ticket_ids' => $disabledTickets->modelKeys(), + 'active_ticket_ids' => $activeTickets->modelKeys(), + ]; + }, + ); + } + } + + private function eventDateNotificationPurchase(string $tenantCode, int $purchaseId): ?Purchase + { + return Purchase::query() + ->where('tenant_codigo', $tenantCode) + ->with(['tenant.websiteType', 'user']) + ->find($purchaseId); } private function recipientFor(Purchase $purchase): string @@ -189,6 +428,34 @@ class NotificationMailService return (string) ($purchase->email ?: $purchase->user?->email); } + /** + * @param array $context + * @param Closure(): array $send + */ + private function sendIdempotently( + string $key, + string $emailType, + ?string $tenantCode, + array $context, + string $recipient, + Closure $send, + ): void { + $sent = $this->emailDeliveryService->sendOnce( + $key, + $emailType, + $tenantCode, + $context, + $recipient, + function () use ($emailType, $context, $send): void { + $this->sendLogged($emailType, $context, $send); + }, + ); + + if (! $sent) { + $this->logSkipped($emailType, array_merge($context, ['reason' => 'already_claimed'])); + } + } + /** * @param array $context * @param Closure(): (array|null) $send diff --git a/app/Domains/Notification/documentacion/README.md b/app/Domains/Notification/documentacion/README.md index 467c0dd..683402a 100644 --- a/app/Domains/Notification/documentacion/README.md +++ b/app/Domains/Notification/documentacion/README.md @@ -12,7 +12,19 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin ## Componentes -Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`. +Los listeners delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`. + +`IdempotentEmailDeliveryService` coordina los envíos automáticos mediante la tabla +`email_deliveries`. Cada correo utiliza una clave de negocio única: + +- bienvenida: `welcome:{tenant_code}:{user_id}`; +- recuperación: `password-reset:{attempt_id}`; +- compra confirmada: `purchase-confirmed:{purchase_id}`; +- reprogramación: `event-date-rescheduled:{source_event_date_id}:{destination_event_date_id}:{purchase_id}`; +- suspensión: `event-date-suspended:{event_date_id}:{purchase_id}`. + +Los correos de prueba y de validación de una integración SMTP no usan esta capa, +porque su reenvío explícito es parte de su comportamiento esperado. ## API y dependencias @@ -25,3 +37,12 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`, - Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, con fallback al tenant si no tiene uno configurado. - El correo transaccional de compra confirmada usa la identidad visual del tenant y adjunta un único PDF cuando la compra generó tickets. - Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola. +- Una entrega queda en estado `processing` mientras un worker posee su claim. Si + el worker se interrumpe, el claim vence según `EMAIL_DELIVERY_LEASE_SECONDS` y + otro intento puede recuperarlo. +- Los fallos quedan registrados como `failed` y pueden ser retomados por los + reintentos de la cola. Los envíos exitosos permanecen como `sent` y las llamadas + posteriores con la misma clave no vuelven a enviar el correo. +- SMTP no ofrece una confirmación transaccional junto con la base de datos. Una + interrupción ocurrida después de entregar el correo y antes de registrar + `sent` puede producir un duplicado excepcional al recuperar el claim. diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index f15bf6b..7a6d52b 100644 --- a/app/Domains/Purchase/Models/PurchaseItem.php +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -6,6 +6,7 @@ use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketRefund; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -64,6 +65,12 @@ class PurchaseItem extends Model return $this->hasMany(Ticket::class, 'source_purchase_item_id'); } + /** @return HasMany */ + public function ticketRefunds(): HasMany + { + return $this->hasMany(TicketRefund::class); + } + /** @return BelongsTo */ public function imageAttachment(): BelongsTo { diff --git a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php index cfa9233..15e09d2 100644 --- a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php +++ b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php @@ -41,6 +41,17 @@ class CatalogSelectionResolver ]); } + if ($catalogItem->bundleComponents() + ->whereNotNull('component_variant_id') + ->whereHas('variant', fn ($query) => $query + ->whereNotNull('sales_disabled_at') + ->orWhereNotNull('replaced_by_variant_id')) + ->exists()) { + throw ValidationException::withMessages([ + "{$fieldPrefix}.catalog_item_id" => [__('api.cart.bundle_component_unavailable')], + ]); + } + return $catalogItem; } @@ -70,6 +81,12 @@ class CatalogSelectionResolver throw new NotFoundHttpException('Variant not found for catalog item.'); } + if (! $variant->isSellable()) { + throw ValidationException::withMessages([ + "{$fieldPrefix}.variant_id" => [__('api.cart.variant_unavailable')], + ]); + } + $variant->setRelation('catalogItem', $catalogItem); $variant->setRelation( 'inventory', diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index 70ce711..e64ef79 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -3,6 +3,7 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Cart\Models\Cart; +use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Exceptions\PurchaseExpiredException; @@ -91,7 +92,16 @@ class ReleaseCheckoutService Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, ], true)) { - $this->reservations->returnToCart($purchase, $cart); + if ($this->hasUnavailableVariants($cart)) { + $this->releasePurchaseReservations($purchase, $targetStatus, $cart); + $cart->update([ + 'status' => Cart::STATUS_EXPIRED, + 'current_purchase_id' => null, + 'current_stock_reservation_id' => null, + ]); + } else { + $this->reservations->returnToCart($purchase, $cart); + } $purchase->update(['status' => Purchase::STATUS_CANCELLED]); return $this->loadPurchase($purchase); @@ -115,6 +125,17 @@ class ReleaseCheckoutService }); } + private function hasUnavailableVariants(Cart $cart): bool + { + return $cart->items() + ->whereNotNull('variant_id') + ->with(['variant.eventDate', 'variant.eventDates']) + ->lockForUpdate() + ->get() + ->contains(fn (CartItem $item): bool => $item->variant === null + || ! $item->variant->isSellable()); + } + private function releasePurchaseReservations( Purchase $purchase, string $targetStatus, diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index b34fd41..71cc159 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; +use App\Domains\Cart\Services\CartVariantReplacementService; use App\Domains\Catalog\Exceptions\StockReservationExpiredException; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; @@ -29,6 +30,7 @@ class StartCheckoutService private readonly InsufficientStockMessageBuilder $stockMessages, private readonly PurchaseResponseLoader $responses, private readonly PurchaseItemSnapshotFactory $snapshots, + private readonly CartVariantReplacementService $variantReplacements, ) {} /** @param array $purchaseData */ @@ -250,6 +252,7 @@ class StartCheckoutService int $cartId, ): Purchase { $cart = $this->resolveCart($tenant, $userId, $cartId); + $this->variantReplacements->replaceHistoricalVariants($cart); $cartItems = $cart->items()->lockForUpdate()->get(); if ($cartItems->isEmpty()) { diff --git a/app/Domains/Purchase/Services/PurchaseRefundSummaryService.php b/app/Domains/Purchase/Services/PurchaseRefundSummaryService.php new file mode 100644 index 0000000..7b67fbc --- /dev/null +++ b/app/Domains/Purchase/Services/PurchaseRefundSummaryService.php @@ -0,0 +1,22 @@ +whereHas( + 'purchaseItem.purchase', + fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo) + ) + ->sum('amount'); + + return number_format((float) $total, 2, '.', ''); + } +} diff --git a/app/Domains/Purchase/Services/TenantTransactionResetService.php b/app/Domains/Purchase/Services/TenantTransactionResetService.php index be6b7c2..cd1d450 100644 --- a/app/Domains/Purchase/Services/TenantTransactionResetService.php +++ b/app/Domains/Purchase/Services/TenantTransactionResetService.php @@ -65,9 +65,10 @@ class TenantTransactionResetService DB::table('inventories') ->whereIn('id', $scope['inventory_ids']) ->update([ - 'real_stock' => DB::raw('real_stock + sold_units'), + 'real_stock' => DB::raw('real_stock + sold_units - refunded_units'), 'reserved_stock' => 0, 'sold_units' => 0, + 'refunded_units' => 0, ]); return $summary; diff --git a/app/Domains/Sale/Controllers/AdminApp/SaleController.php b/app/Domains/Sale/Controllers/AdminApp/SaleController.php index 383f700..71fe5f6 100644 --- a/app/Domains/Sale/Controllers/AdminApp/SaleController.php +++ b/app/Domains/Sale/Controllers/AdminApp/SaleController.php @@ -35,6 +35,7 @@ class SaleController extends Controller $this->saleService->sales($tenant, $request->validated()) )->additional([ 'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant), + 'refunded_total' => $this->saleService->refundedTotal($tenant), ]); } diff --git a/app/Domains/Sale/Resources/AdminApp/SaleTicketResource.php b/app/Domains/Sale/Resources/AdminApp/SaleTicketResource.php index 5070b1a..33ba09b 100644 --- a/app/Domains/Sale/Resources/AdminApp/SaleTicketResource.php +++ b/app/Domains/Sale/Resources/AdminApp/SaleTicketResource.php @@ -17,6 +17,7 @@ class SaleTicketResource extends JsonResource 'id' => $this->id, 'expires_at' => $this->getEffectiveExpiresAt(), 'status' => $this->status, + 'status_label' => $this->status_label, ]; } } diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index eecd5fc..4003634 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -5,6 +5,7 @@ namespace App\Domains\Sale\Services; use App\Domains\Logging\Models\ValueChange; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; +use App\Domains\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Services\TicketPresentationResolver; @@ -17,6 +18,7 @@ class AdminAppSaleService { public function __construct( protected CheckoutService $checkoutService, + protected PurchaseRefundSummaryService $refundSummaryService, ) {} public function confirmedSalesTotal(Tenant $tenant): string @@ -29,6 +31,11 @@ class AdminAppSaleService return number_format((float) $total, 2, '.', ''); } + public function refundedTotal(Tenant $tenant): string + { + return $this->refundSummaryService->totalForTenant($tenant); + } + /** * @param array{ * q?: string|null, @@ -60,7 +67,7 @@ class AdminAppSaleService { return $this->findForTenant($tenant, $saleId) ->tickets() - ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS]) + ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund']) ->orderBy('id') ->get(); } diff --git a/app/Domains/Staff/Controllers/AdminAppStaffController.php b/app/Domains/Staff/Controllers/AdminAppStaffController.php index 5d82f36..f6ef5c4 100644 --- a/app/Domains/Staff/Controllers/AdminAppStaffController.php +++ b/app/Domains/Staff/Controllers/AdminAppStaffController.php @@ -6,6 +6,9 @@ use App\Domains\Staff\Requests\StoreStaffRequest; use App\Domains\Staff\Requests\UpdateStaffRequest; use App\Domains\Staff\Resources\StaffResource; use App\Domains\Staff\Services\StaffService; +use App\Domains\Ticket\Requests\ScanAttemptIndexRequest; +use App\Domains\Ticket\Resources\Scanner\ScanAttemptResource; +use App\Domains\Ticket\Services\ScannerTicketService; use App\Http\Controllers\Controller; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -13,7 +16,10 @@ use Symfony\Component\HttpFoundation\Response; class AdminAppStaffController extends Controller { - public function __construct(private readonly StaffService $staffService) {} + public function __construct( + private readonly StaffService $staffService, + private readonly ScannerTicketService $scannerTicketService, + ) {} public function index(Request $request): AnonymousResourceCollection { @@ -46,4 +52,18 @@ class AdminAppStaffController extends Controller return response()->noContent(); } + + public function scanAttempts( + ScanAttemptIndexRequest $request, + int $staff, + ): AnonymousResourceCollection { + $scanner = $this->staffService->find( + $request->user()->tenant()->firstOrFail(), + $staff, + ); + + return ScanAttemptResource::collection( + $this->scannerTicketService->attemptsByStaff($scanner, $request->validated()) + ); + } } diff --git a/app/Domains/Staff/routes/api.php b/app/Domains/Staff/routes/api.php index ad3481a..470f1fc 100644 --- a/app/Domains/Staff/routes/api.php +++ b/app/Domains/Staff/routes/api.php @@ -6,5 +6,6 @@ use Illuminate\Support\Facades\Route; Route::prefix('v1/adminapp/tenant') ->middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { + Route::get('staff/{staff}/scan-attempts', [AdminAppStaffController::class, 'scanAttempts']); Route::apiResource('staff', AdminAppStaffController::class)->except('show'); }); diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index e2bb8f2..e119319 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -9,9 +9,11 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Client\Models\Client; use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Models\EventDateChange; use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\TenantMenu; use App\Domains\Tenant\Enums\CartEditingPolicy; +use App\Domains\Ticket\Models\ScanAttempt; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -51,6 +53,10 @@ use Illuminate\Support\Facades\Schema; 'checkout_editing_policy', 'display_cart_item_images', 'scanner_category_validation_enabled', + 'allow_ticket_refund', + 'allow_ticket_total_refund', + 'allow_ticket_partial_refund', + 'ticket_partial_refund_percentage', 'event_title', 'event_location', 'event_date_text', @@ -71,6 +77,10 @@ class Tenant extends Model 'checkout_editing_policy' => CartEditingPolicy::Disabled->value, 'display_cart_item_images' => true, 'scanner_category_validation_enabled' => true, + 'allow_ticket_refund' => false, + 'allow_ticket_total_refund' => false, + 'allow_ticket_partial_refund' => false, + 'ticket_partial_refund_percentage' => 0, ]; public function getRouteKeyName(): string @@ -105,6 +115,35 @@ class Tenant extends Model return $this->scanner_category_validation_enabled; } + public function allow_refund(): bool + { + return (bool) $this->allow_ticket_refund + && ((bool) $this->allow_ticket_total_refund || $this->allow_partial_refund()); + } + + public function allow_partial_refund(): bool + { + return (bool) $this->allow_ticket_refund + && (bool) $this->allow_ticket_partial_refund + && $this->ticket_partial_refund_percentage !== null + && (float) $this->ticket_partial_refund_percentage > 0; + } + + public function allowRefund(): bool + { + return $this->allow_refund(); + } + + public function allowPartialRefund(): bool + { + return $this->allow_partial_refund(); + } + + public function getAllowRefundAttribute(): bool + { + return $this->allow_refund(); + } + /** * Get the attributes that should be cast. * @@ -123,6 +162,10 @@ class Tenant extends Model 'checkout_editing_policy' => CartEditingPolicy::class, 'display_cart_item_images' => 'boolean', 'scanner_category_validation_enabled' => 'boolean', + 'allow_ticket_refund' => 'boolean', + 'allow_ticket_total_refund' => 'boolean', + 'allow_ticket_partial_refund' => 'boolean', + 'ticket_partial_refund_percentage' => 'decimal:2', ]; } @@ -187,6 +230,14 @@ class Tenant extends Model ->orderBy('time_start'); } + /** @return HasMany */ + public function eventDateChanges(): HasMany + { + return $this->hasMany(EventDateChange::class, 'tenant_code', 'codigo') + ->orderBy('created_at') + ->orderBy('id'); + } + /** * @return HasMany */ @@ -195,6 +246,12 @@ class Tenant extends Model return $this->hasMany(Category::class, 'tenant_code', 'codigo'); } + /** @return HasMany */ + public function scanAttempts(): HasMany + { + return $this->hasMany(ScanAttempt::class, 'tenant_code', 'codigo'); + } + /** * @return BelongsToMany */ diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index ee37d92..2dc435b 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -117,6 +117,16 @@ class StoreTenantRequest extends FormRequest ], 'display_cart_item_images' => ['sometimes', 'boolean'], 'scanner_category_validation_enabled' => ['sometimes', 'boolean'], + 'allow_ticket_refund' => ['sometimes', 'boolean'], + 'allow_ticket_total_refund' => ['sometimes', 'boolean'], + 'allow_ticket_partial_refund' => ['sometimes', 'boolean'], + 'ticket_partial_refund_percentage' => [ + 'sometimes', + 'numeric', + 'decimal:0,2', + 'min:0', + 'max:99.99', + ], 'website_type_code' => [ 'required_with:extras', 'sometimes', diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index e7a06bc..4acc110 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -138,6 +138,16 @@ class UpdateTenantRequest extends FormRequest ], 'display_cart_item_images' => ['sometimes', 'boolean'], 'scanner_category_validation_enabled' => ['sometimes', 'boolean'], + 'allow_ticket_refund' => ['sometimes', 'boolean'], + 'allow_ticket_total_refund' => ['sometimes', 'boolean'], + 'allow_ticket_partial_refund' => ['sometimes', 'boolean'], + 'ticket_partial_refund_percentage' => [ + 'sometimes', + 'numeric', + 'decimal:0,2', + 'min:0', + 'max:99.99', + ], ]; } } diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 1c3b14b..da71d67 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -5,6 +5,8 @@ namespace App\Domains\Tenant\Resources; use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\AttachmentCrop; use App\Domains\Catalog\Models\Category; +use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Services\EventDateInfoFormatter; use App\Domains\Menu\Models\Menu; use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; @@ -22,6 +24,10 @@ class TenantResource extends JsonResource */ public function toArray(Request $request): array { + $eventDateChanges = $this->relationLoaded('eventDateChanges') + ? $this->eventDateChanges + : collect(); + return [ 'id' => $this->id, 'client_id' => $this->client_id, @@ -50,12 +56,19 @@ class TenantResource extends JsonResource : [ 'title' => $this->event_title, 'location' => $this->event_location, - 'dates' => $this->eventDates->map(fn ($eventDate): array => [ - 'id' => $eventDate->id, - 'date' => $eventDate->date->format('Y-m-d'), - 'time_start' => $eventDate->time_start, - 'time_end' => $eventDate->time_end, - ])->values(), + 'dates' => $this->eventDates + ->filter(fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null) + ->map(fn (EventDate $eventDate): array => [ + 'id' => $eventDate->id, + 'date' => $eventDate->date->format('Y-m-d'), + 'time_start' => $eventDate->time_start, + 'time_end' => $eventDate->time_end, + 'info_text' => app(EventDateInfoFormatter::class)->format( + $eventDate, + $eventDateChanges, + ), + 'isCanceled' => $eventDate->suspended_at !== null, + ])->values(), ]), 'extras' => $this->whenLoaded( 'websiteExtras', @@ -82,6 +95,10 @@ class TenantResource extends JsonResource 'checkout_editing_policy' => CartEditingPolicyResource::make($this->checkout_editing_policy), 'display_cart_item_images' => $this->display_cart_item_images, 'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled, + 'allow_ticket_refund' => $this->allow_ticket_refund, + 'allow_ticket_total_refund' => $this->allow_ticket_total_refund, + 'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund, + 'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage, 'social_media' => $this->whenLoaded( 'socialMedia', fn () => $this->socialMedia diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php index 00194f4..d041fe6 100644 --- a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -4,11 +4,15 @@ namespace App\Domains\Ticket\Controllers\AdminApp; use App\Domains\Ticket\Requests\AdminAppTicketExportRequest; use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest; +use App\Domains\Ticket\Requests\AdminAppTicketRefundRequest; use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection; +use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketRefundCalculationResource; +use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketResource; use App\Domains\Ticket\Services\AdminAppTicketExcelService; use App\Domains\Ticket\Services\AdminAppTicketPdfService; use App\Domains\Ticket\Services\AdminAppTicketService; use App\Http\Controllers\Controller; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -29,6 +33,36 @@ class TicketController extends Controller ); } + public function cancel(Request $request, int $ticket): AdminAppTicketResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket)); + } + + public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return new AdminAppTicketRefundCalculationResource( + $this->ticketService->calculateRefund($tenant, $ticket) + ); + } + + public function refund(AdminAppTicketRefundRequest $request, int $ticket): AdminAppTicketResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return new AdminAppTicketResource( + $this->ticketService->refund( + $tenant, + $ticket, + $request->validated('refund_type'), + $request->user(), + ) + ); + } + public function downloadPdf(AdminAppTicketExportRequest $request): Response { $tenant = $request->user()->tenant()->firstOrFail(); diff --git a/app/Domains/Ticket/Controllers/Scanner/ScanAttemptController.php b/app/Domains/Ticket/Controllers/Scanner/ScanAttemptController.php new file mode 100644 index 0000000..8ca53df --- /dev/null +++ b/app/Domains/Ticket/Controllers/Scanner/ScanAttemptController.php @@ -0,0 +1,37 @@ +user(); + + return ScanAttemptResource::collection( + $this->ticketService->attemptsBy($scanner, $request->validated()) + ); + } + + public function show(Request $request, int $scanAttempt): ScannerScanResultResource + { + /** @var User $scanner */ + $scanner = $request->user(); + + return ScannerScanResultResource::make( + $this->ticketService->scanAttemptDetail($scanner, $scanAttempt) + ); + } +} diff --git a/app/Domains/Ticket/Controllers/Scanner/TicketController.php b/app/Domains/Ticket/Controllers/Scanner/TicketController.php index fde6b2a..d079cd7 100644 --- a/app/Domains/Ticket/Controllers/Scanner/TicketController.php +++ b/app/Domains/Ticket/Controllers/Scanner/TicketController.php @@ -3,28 +3,18 @@ namespace App\Domains\Ticket\Controllers\Scanner; use App\Domains\Auth\Models\User; -use App\Domains\Ticket\Requests\ScannerTicketIndexRequest; -use App\Domains\Ticket\Resources\Scanner\ScannedTicketResource; +use App\Domains\Ticket\Resources\Scanner\ScannerScanResultResource; use App\Domains\Ticket\Resources\TicketResource; use App\Domains\Ticket\Services\ScannerTicketService; use App\Http\Controllers\Controller; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Symfony\Component\HttpFoundation\Response; class TicketController extends Controller { public function __construct(private readonly ScannerTicketService $ticketService) {} - public function index(ScannerTicketIndexRequest $request): AnonymousResourceCollection - { - /** @var User $scanner */ - $scanner = $request->user(); - - return ScannedTicketResource::collection( - $this->ticketService->scannedBy($scanner, $request->validated()) - ); - } - public function show(Request $request, string $ticketUuid): TicketResource { /** @var User $scanner */ @@ -35,13 +25,13 @@ class TicketController extends Controller ); } - public function scan(Request $request, string $ticketUuid): TicketResource + public function scan(Request $request): JsonResponse { /** @var User $scanner */ $scanner = $request->user(); - return TicketResource::make( - $this->ticketService->scan($scanner, $ticketUuid) - ); + return ScannerScanResultResource::make( + $this->ticketService->scan($scanner, $request->input('data')) + )->response()->setStatusCode(Response::HTTP_OK); } } diff --git a/app/Domains/Ticket/Enums/ScanAttemptResult.php b/app/Domains/Ticket/Enums/ScanAttemptResult.php new file mode 100644 index 0000000..6516803 --- /dev/null +++ b/app/Domains/Ticket/Enums/ScanAttemptResult.php @@ -0,0 +1,16 @@ + */ + public function scanner(): BelongsTo + { + return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed(); + } + + /** @return BelongsTo */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); + } + + protected function casts(): array + { + return [ + 'scanner_user_id' => 'integer', + 'ticket_id' => 'integer', + 'result' => ScanAttemptResult::class, + 'created_at' => 'datetime', + 'resolved_at' => 'datetime', + ]; + } +} diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index ab71c90..31041db 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -5,6 +5,7 @@ namespace App\Domains\Ticket\Models; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; +use App\Domains\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Services\ResolvedTicketValidity; @@ -16,7 +17,10 @@ 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\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Collection; +use Illuminate\Validation\ValidationException; #[Fillable([ 'tenant_code', @@ -25,12 +29,15 @@ use Illuminate\Support\Collection; 'source_catalog_item_id', 'source_variant_id', 'used_at', + 'disabled_at', + 'cancelled_at', + 'refunded_at', 'scanner_user_id', 'user_id', ])] class Ticket extends Model { - use HasFactory; + use HasFactory, LogsValueChanges; private ?ResolvedTicketValidity $resolvedValidity = null; @@ -40,8 +47,22 @@ class Ticket extends Model public const STATUS_USED = 'used'; + public const STATUS_DISABLED = 'disabled'; + + public const STATUS_CANCELLED = 'cancelled'; + + public const STATUS_REFUNDED = 'refunded'; + public $timestamps = false; + /** @var list */ + protected array $loggedAttributes = [ + 'used_at', + 'disabled_at', + 'cancelled_at', + 'refunded_at', + ]; + protected $appends = [ 'name', 'description', @@ -58,17 +79,123 @@ class Ticket extends Model 'source_variant_id' => 'integer', 'source_purchase_item_id' => 'integer', 'used_at' => 'datetime', + 'disabled_at' => 'datetime', + 'cancelled_at' => 'datetime', + 'refunded_at' => 'datetime', 'scanner_user_id' => 'integer', 'user_id' => 'integer', ]; } + /** @return list */ + public static function statuses(): array + { + return array_keys(self::statusLabels()); + } + + /** @return array */ + public static function statusLabels(): array + { + return [ + self::STATUS_ACTIVE => 'Activo', + self::STATUS_USED => 'Usado', + self::STATUS_EXPIRED => 'Vencido', + self::STATUS_DISABLED => 'Inhabilitado', + self::STATUS_CANCELLED => 'Cancelado', + self::STATUS_REFUNDED => 'Reembolsado', + ]; + } + + /** @return list */ + public static function statusOptions(): array + { + return collect(self::statusLabels()) + ->map(fn (string $label, string $status): array => [ + 'value' => $status, + 'label' => $label, + ]) + ->values() + ->all(); + } + + public static function statusLabel(string $status): string + { + return self::statusLabels()[$status] ?? $status; + } + + protected static function booted(): void + { + static::saving(function (self $ticket): void { + $ticket->ensureTerminalStatusTransitionIsAllowed(); + }); + } + /** @return BelongsTo */ public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); } + public function allow_refund(): bool + { + return $this->tenant?->allow_refund() ?? false; + } + + public function allowRefund(): bool + { + return $this->allow_refund(); + } + + public function getAllowRefundAttribute(): bool + { + return $this->allow_refund(); + } + + public function is_active(): bool + { + return $this->status === self::STATUS_ACTIVE; + } + + public function isActive(): bool + { + return $this->is_active(); + } + + public function getIsActiveAttribute(): bool + { + return $this->is_active(); + } + + public function can_cancel(): bool + { + return $this->is_active(); + } + + public function canCancel(): bool + { + return $this->can_cancel(); + } + + public function getCanCancelAttribute(): bool + { + return $this->can_cancel(); + } + + public function can_refund(): bool + { + return $this->is_active() && $this->allow_refund(); + } + + public function canRefund(): bool + { + return $this->can_refund(); + } + + public function getCanRefundAttribute(): bool + { + return $this->can_refund(); + } + /** @return BelongsTo */ public function user(): BelongsTo { @@ -81,12 +208,24 @@ class Ticket extends Model return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed(); } + /** @return HasMany */ + public function scanAttempts(): HasMany + { + return $this->hasMany(ScanAttempt::class); + } + /** @return BelongsTo */ public function sourcePurchaseItem(): BelongsTo { return $this->belongsTo(PurchaseItem::class, 'source_purchase_item_id'); } + /** @return HasOne */ + public function refund(): HasOne + { + return $this->hasOne(TicketRefund::class); + } + /** @return BelongsTo */ public function sourceCatalogItem(): BelongsTo { @@ -101,7 +240,7 @@ class Ticket extends Model public function isValid(): bool { - if ($this->used_at !== null) { + if ($this->hasTerminalStatus() || $this->used_at !== null) { return false; } @@ -115,7 +254,9 @@ class Ticket extends Model public function getIsExpiredAttribute(): bool { - return $this->used_at === null && $this->resolvedValidity()->isExpired(); + return ! $this->hasTerminalStatus() + && $this->used_at === null + && $this->resolvedValidity()->isExpired(); } public function getIsUsedAttribute(): bool @@ -125,6 +266,18 @@ class Ticket extends Model public function getStatusAttribute(): string { + if ($this->refunded_at !== null) { + return self::STATUS_REFUNDED; + } + + if ($this->cancelled_at !== null) { + return self::STATUS_CANCELLED; + } + + if ($this->disabled_at !== null) { + return self::STATUS_DISABLED; + } + if ($this->is_used) { return self::STATUS_USED; } @@ -136,6 +289,110 @@ class Ticket extends Model return self::STATUS_ACTIVE; } + public function getStatusLabelAttribute(): string + { + if ($this->status === self::STATUS_REFUNDED && $this->relationLoaded('refund')) { + $refund = $this->getRelation('refund'); + + if ($refund instanceof TicketRefund) { + return $refund->typeLabel(); + } + } + + return self::statusLabel($this->status); + } + + public function markAsDisabled(): void + { + $this->markAsTerminalStatus(self::STATUS_DISABLED); + } + + public function markAsCancelled(): void + { + $this->markAsTerminalStatus(self::STATUS_CANCELLED); + } + + public function markAsRefunded(): void + { + $this->markAsTerminalStatus(self::STATUS_REFUNDED); + } + + protected function valueChangeTenantCode(): string + { + return $this->tenant_code; + } + + private function hasTerminalStatus(): bool + { + return $this->terminalStatus() !== null; + } + + private function markAsTerminalStatus(string $status): void + { + $currentStatus = $this->terminalStatus(); + + if ($currentStatus === $status) { + return; + } + + if ($currentStatus !== null) { + $this->throwTerminalStatusTransitionException(); + } + + $this->ensureTerminalStatusTransitionIsAllowed($status); + + $this->{self::terminalStatusTimestampColumn($status)} = now(); + } + + private function ensureTerminalStatusTransitionIsAllowed(?string $targetStatus = null): void + { + $currentStatus = $this->terminalStatusFromAttributes($this->getRawOriginal()); + $nextStatus = $targetStatus ?? $this->terminalStatus(); + + if ($currentStatus === null || $nextStatus === null || $currentStatus === $nextStatus) { + return; + } + + $this->throwTerminalStatusTransitionException(); + } + + private function throwTerminalStatusTransitionException(): never + { + throw ValidationException::withMessages([ + 'status' => 'No se puede cambiar un ticket con estado terminal a otro estado terminal.', + ]); + } + + private function terminalStatus(): ?string + { + return $this->terminalStatusFromAttributes($this->getAttributes()); + } + + /** @param array $attributes */ + private function terminalStatusFromAttributes(array $attributes): ?string + { + foreach ([ + self::STATUS_REFUNDED, + self::STATUS_CANCELLED, + self::STATUS_DISABLED, + ] as $status) { + if (($attributes[self::terminalStatusTimestampColumn($status)] ?? null) !== null) { + return $status; + } + } + + return null; + } + + private static function terminalStatusTimestampColumn(string $status): string + { + return match ($status) { + self::STATUS_DISABLED => 'disabled_at', + self::STATUS_CANCELLED => 'cancelled_at', + self::STATUS_REFUNDED => 'refunded_at', + }; + } + public function getNameAttribute(): string { return app(TicketPresentationResolver::class)->name($this); diff --git a/app/Domains/Ticket/Models/TicketRefund.php b/app/Domains/Ticket/Models/TicketRefund.php new file mode 100644 index 0000000..6af68f6 --- /dev/null +++ b/app/Domains/Ticket/Models/TicketRefund.php @@ -0,0 +1,66 @@ + 'integer', + 'purchase_item_id' => 'integer', + 'created_by_user_id' => 'integer', + 'amount' => 'decimal:2', + ]; + } + + /** @return list */ + public static function types(): array + { + return [self::TYPE_PARTIAL, self::TYPE_TOTAL]; + } + + public function typeLabel(): string + { + return match ($this->type) { + self::TYPE_PARTIAL => 'Reembolso parcial', + self::TYPE_TOTAL => 'Reembolso total', + default => 'Reembolsado', + }; + } + + /** @return BelongsTo */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } + + /** @return BelongsTo */ + public function purchaseItem(): BelongsTo + { + return $this->belongsTo(PurchaseItem::class); + } + + /** @return BelongsTo */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed(); + } +} diff --git a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php index 5a80395..90d8e35 100644 --- a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php +++ b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php @@ -32,11 +32,7 @@ class AdminAppTicketIndexRequest extends FormRequest 'status' => [ 'sometimes', 'nullable', - Rule::in([ - Ticket::STATUS_ACTIVE, - Ticket::STATUS_USED, - Ticket::STATUS_EXPIRED, - ]), + Rule::in(Ticket::statuses()), ], 'page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], diff --git a/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php new file mode 100644 index 0000000..4de9323 --- /dev/null +++ b/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'refund_type' => ['required', 'string', Rule::in(TicketRefund::types())], + ]; + } +} diff --git a/app/Domains/Ticket/Requests/ScannerTicketIndexRequest.php b/app/Domains/Ticket/Requests/ScanAttemptIndexRequest.php similarity index 90% rename from app/Domains/Ticket/Requests/ScannerTicketIndexRequest.php rename to app/Domains/Ticket/Requests/ScanAttemptIndexRequest.php index e1e24e8..4bb06af 100644 --- a/app/Domains/Ticket/Requests/ScannerTicketIndexRequest.php +++ b/app/Domains/Ticket/Requests/ScanAttemptIndexRequest.php @@ -4,7 +4,7 @@ namespace App\Domains\Ticket\Requests; use Illuminate\Foundation\Http\FormRequest; -class ScannerTicketIndexRequest extends FormRequest +class ScanAttemptIndexRequest extends FormRequest { public function authorize(): bool { diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php index 3af312c..3b7c0e6 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php @@ -15,20 +15,24 @@ class AdminAppTicketCollection extends ResourceCollection private readonly int $totalTickets; + private readonly string $refundedTotal; + public function __construct(AdminAppTicketResult $result) { parent::__construct($result->tickets); $this->scannedTickets = $result->scannedTickets; $this->totalTickets = $result->totalTickets; + $this->refundedTotal = $result->refundedTotal; } - /** @return array{scanned_tickets: int, total_tickets: int} */ + /** @return array{scanned_tickets: int, total_tickets: int, refunded_total: string} */ public function with(Request $request): array { return [ 'scanned_tickets' => $this->scannedTickets, 'total_tickets' => $this->totalTickets, + 'refunded_total' => $this->refundedTotal, ]; } } diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketRefundCalculationResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketRefundCalculationResource.php new file mode 100644 index 0000000..40ebac4 --- /dev/null +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketRefundCalculationResource.php @@ -0,0 +1,26 @@ + $this->resource['total'], + 'partial' => $this->resource['partial'], + ]; + } +} diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php index feda27c..4205f4e 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -19,6 +19,17 @@ class AdminAppTicketResource extends TicketResource return [ ...parent::toArray($request), ...$details, + 'allow_refund' => $this->resource->allow_refund(), + 'is_active' => $this->resource->is_active(), + 'can_cancel' => $this->resource->can_cancel(), + 'can_refund' => $this->resource->can_refund(), + 'refund' => $this->resource->refund === null ? null : [ + 'type' => $this->resource->refund->type, + 'type_label' => $this->resource->refund->typeLabel(), + 'amount' => $this->resource->refund->amount, + 'created_at' => $this->resource->refund->created_at, + 'created_by' => $this->resource->refund->createdBy?->nombre_apellido, + ], 'values' => $rowService->values($this->resource, $details), ]; } diff --git a/app/Domains/Ticket/Resources/Scanner/ScanAttemptResource.php b/app/Domains/Ticket/Resources/Scanner/ScanAttemptResource.php new file mode 100644 index 0000000..0d777b6 --- /dev/null +++ b/app/Domains/Ticket/Resources/Scanner/ScanAttemptResource.php @@ -0,0 +1,46 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'data' => $this->data, + 'ticket_id' => $this->ticket_id, + 'ticket' => $this->ticket?->ticket, + 'category' => $this->ticket?->sourceCatalogItem?->category?->nombre, + 'attempted_at' => $this->created_at, + 'resolved_at' => $this->resolved_at, + 'result' => $this->result->value, + 'result_label' => match ($this->result) { + ScanAttemptResult::Accepted => 'Verificado', + ScanAttemptResult::AlreadyScanned => 'Usado', + ScanAttemptResult::Expired => 'Vencido', + default => 'Error', + }, + 'result_detail_label' => match ($this->result) { + ScanAttemptResult::Processing => 'Error', + ScanAttemptResult::Accepted => 'Verificado', + ScanAttemptResult::InvalidQr => 'QR no pertenece al evento', + ScanAttemptResult::TicketNotFound => 'Error', + ScanAttemptResult::CategoryForbidden => 'Error', + ScanAttemptResult::AlreadyScanned => 'Usado', + ScanAttemptResult::Expired => 'Vencido', + ScanAttemptResult::NotValid => 'No válido', + ScanAttemptResult::UnexpectedError => 'Error', + }, + 'can_view_ticket' => $this->ticket !== null + && $this->result !== ScanAttemptResult::CategoryForbidden, + ]; + } +} diff --git a/app/Domains/Ticket/Resources/Scanner/ScannedTicketResource.php b/app/Domains/Ticket/Resources/Scanner/ScannedTicketResource.php deleted file mode 100644 index ed901fc..0000000 --- a/app/Domains/Ticket/Resources/Scanner/ScannedTicketResource.php +++ /dev/null @@ -1,24 +0,0 @@ - */ - public function toArray(Request $request): array - { - return [ - 'product' => $this->name, - 'id' => $this->id, - 'ticket' => $this->ticket, - 'used_at' => $this->used_at, - 'expires_at' => $this->getEffectiveExpiresAt(), - 'status' => $this->status, - ]; - } -} diff --git a/app/Domains/Ticket/Resources/Scanner/ScannerScanResultResource.php b/app/Domains/Ticket/Resources/Scanner/ScannerScanResultResource.php new file mode 100644 index 0000000..1b38a88 --- /dev/null +++ b/app/Domains/Ticket/Resources/Scanner/ScannerScanResultResource.php @@ -0,0 +1,28 @@ + */ + public function toArray(Request $request): array + { + $ticket = $this->ticket; + $client = $ticket?->user; + + return [ + 'scan_attempt' => ScanAttemptResource::make($this->resource), + 'ticket' => $ticket === null ? null : TicketResource::make($ticket), + 'client' => $client === null ? null : [ + 'id' => $client->id, + 'nombre_apellido' => $client->nombre_apellido, + ], + ]; + } +} diff --git a/app/Domains/Ticket/Resources/TicketResource.php b/app/Domains/Ticket/Resources/TicketResource.php index 8e4c0e8..16f7531 100644 --- a/app/Domains/Ticket/Resources/TicketResource.php +++ b/app/Domains/Ticket/Resources/TicketResource.php @@ -16,6 +16,14 @@ class TicketResource extends JsonResource 'id' => $this->id, 'tenant_code' => $this->tenant_code, 'ticket' => $this->ticket, + 'status' => $this->status, + 'status_label' => $this->status_label, + 'refund' => $this->whenLoaded('refund', fn (): ?array => $this->refund === null ? null : [ + 'type' => $this->refund->type, + 'type_label' => $this->refund->typeLabel(), + 'amount' => $this->refund->amount, + 'created_at' => $this->refund->created_at, + ]), 'name' => $this->name, 'description' => $this->description, 'client' => $this->user?->nombre_apellido, diff --git a/app/Domains/Ticket/Services/AdminAppTicketExcelService.php b/app/Domains/Ticket/Services/AdminAppTicketExcelService.php index c34496e..d89024f 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketExcelService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketExcelService.php @@ -41,7 +41,9 @@ class AdminAppTicketExcelService $row = $index + 2; foreach ($columns as $columnIndex => $column) { $coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).$row; - $value = $ticket[$column['key']] ?? null; + $value = $column['type'] === 'status' + ? ($ticket['status_label'] ?? $ticket[$column['key']] ?? null) + : ($ticket[$column['key']] ?? null); if ($column['type'] === 'currency' && $value !== null) { $sheet->setCellValue($coordinate, (float) $value); diff --git a/app/Domains/Ticket/Services/AdminAppTicketResult.php b/app/Domains/Ticket/Services/AdminAppTicketResult.php index e9f65f4..bce92bc 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketResult.php +++ b/app/Domains/Ticket/Services/AdminAppTicketResult.php @@ -12,5 +12,6 @@ final readonly class AdminAppTicketResult public LengthAwarePaginator $tickets, public int $scannedTickets, public int $totalTickets, + public string $refundedTotal, ) {} } diff --git a/app/Domains/Ticket/Services/AdminAppTicketRowService.php b/app/Domains/Ticket/Services/AdminAppTicketRowService.php index e29b879..cf831cf 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketRowService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketRowService.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticket\Services; use App\Domains\Catalog\Models\ItemAttribute; +use App\Domains\Event\Models\EventDate; use App\Domains\Ticket\Models\Ticket; use Illuminate\Support\Collection; @@ -11,18 +12,19 @@ class AdminAppTicketRowService private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; private const CATEGORY_PRESENTATIONS = [ - 'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null], - 'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null], - 'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'date' => null, 'size' => null], - 'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null], - 'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null], - 'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'date' => null, 'size' => 'talle'], + 'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null], + 'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null], + 'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'size' => null], + 'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null], + 'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null], + 'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'size' => 'talle'], ]; /** @return array */ public function details(Ticket $ticket): array { $purchaseItem = $ticket->sourcePurchaseItem; + $refund = $ticket->refund; return [ 'source_purchase_item_id' => $ticket->source_purchase_item_id, @@ -31,10 +33,13 @@ class AdminAppTicketRowService ?? $ticket->sourceCatalogItem?->nombre ?? $ticket->name, 'amount' => $purchaseItem?->precio_unitario, + 'refund_type' => $refund?->type, + 'refund_type_label' => $refund?->typeLabel(), 'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido, 'status' => $ticket->status, 'scanned_by' => $ticket->scannerUser?->nombre_apellido, 'variant_properties' => $this->variantProperties($ticket), + 'allow_refund' => $ticket->allow_refund(), ]; } @@ -55,6 +60,7 @@ class AdminAppTicketRowService 'client' => $details['client'] ?? 'Sin nombre', 'id' => $ticket->id, 'status' => $details['status'], + 'status_label' => $ticket->status_label, 'scanned_by' => $details['scanned_by'] ?? '-', ]; } @@ -78,7 +84,9 @@ class AdminAppTicketRowService return $rows->map(fn (array $row): array => collect($columns) ->mapWithKeys(fn (array $column): array => [ $column['key'] => $this->displayValue( - $row[$column['key']] ?? null, + $column['type'] === 'status' + ? ($row['status_label'] ?? $row[$column['key']] ?? null) + : ($row[$column['key']] ?? null), $column['type'], $timeZone, ), @@ -95,11 +103,7 @@ class AdminAppTicketRowService return match ($type) { 'order_number' => '#'.$value, 'currency' => '$'.number_format((float) $value, 2, ',', '.'), - 'status' => match ((string) $value) { - Ticket::STATUS_USED => 'Usado', - Ticket::STATUS_EXPIRED => 'Vencido', - default => 'Activo', - }, + 'status' => Ticket::statusLabel((string) $value), default => (string) $value, }; } @@ -108,13 +112,14 @@ class AdminAppTicketRowService private function presentation(Ticket $ticket, array $details): array { $sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-'; + $effectiveDates = $this->effectiveEventDateLabels($ticket) ?: '-'; if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) { return [ 'category' => $sourceCategory, 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), 'type' => $this->allPropertyLabels($details) ?: '-', - 'date' => '-', + 'date' => $effectiveDates, 'size' => '-', ]; } @@ -125,7 +130,7 @@ class AdminAppTicketRowService 'category' => $sourceCategory, 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), 'type' => $this->allPropertyLabels($details) ?: '-', - 'date' => '-', + 'date' => $effectiveDates, 'size' => '-', ]; } @@ -138,29 +143,30 @@ class AdminAppTicketRowService 'type' => $configuration['type'] === null ? '-' : ($this->propertyLabels($details, $configuration['type']) ?: '-'), - 'date' => $configuration['date'] === null - ? '-' - : ($this->propertyLabels($details, $configuration['date']) ?: '-'), + 'date' => $effectiveDates, 'size' => $configuration['size'] === null ? '-' : ($this->propertyLabels($details, $configuration['size']) ?: '-'), ]; } + private function effectiveEventDateLabels(Ticket $ticket): string + { + return $ticket->sourceVariant?->selectedEventDates() + ->map(fn (EventDate $date): ?EventDate => $date->effectiveDate()) + ->filter() + ->unique(fn (EventDate $date): int => $date->getKey()) + ->sortBy(fn (EventDate $date): string => $date->date->format('Y-m-d')) + ->map(fn (EventDate $date): string => $date->date->format('d/m')) + ->implode(', ') ?? ''; + } + /** @param array $details */ private function propertyLabels(array $details, string $code): string { $property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code); $labels = collect($property['values'] ?? [])->pluck('label')->filter(); - if ($code === 'event_date') { - $labels = $labels->map(function (string $label): string { - [$day, $month] = array_pad(explode('/', $label), 2, null); - - return $day !== null && $month !== null ? "{$day}/{$month}" : $label; - }); - } - return $labels->implode(', '); } diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index cc41319..1c626c9 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -3,27 +3,37 @@ namespace App\Domains\Ticket\Services; use App\Domains\Auth\Models\User; +use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\Variant; use App\Domains\Purchase\Models\PurchaseItem; +use App\Domains\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketRefund; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class AdminAppTicketService { private const RELATIONS = [ ...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, + 'tenant', 'user', 'scannerUser', 'sourceCatalogItem.category', 'sourcePurchaseItem.purchase', + 'refund.createdBy', ]; public function __construct( private readonly AdminAppTicketColumnService $columnService, private readonly AdminAppTicketRowService $rowService, + private readonly PurchaseRefundSummaryService $refundSummaryService, ) {} /** @@ -42,20 +52,30 @@ class AdminAppTicketService ->get(); $matchingTickets = $this->sortTickets($matchingTickets, $tenant, $filters); $tickets = $this->paginate($matchingTickets, $filters); - $scannedTickets = $matchingTickets->whereNotNull('used_at')->count(); + $scannedTickets = $matchingTickets + ->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_USED) + ->count(); + $activeTickets = $matchingTickets + ->filter(fn (Ticket $ticket): bool => $ticket->is_active()) + ->count(); + $totalTickets = $activeTickets + $scannedTickets; } else { $tickets = (clone $query) ->with(self::RELATIONS) ->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id')) ->paginateFromRequest() ->withQueryString(); - $scannedTickets = $countQuery->whereNotNull('used_at')->count(); + + $counts = $this->calculateTicketCounts($countQuery); + $scannedTickets = $counts['scanned']; + $totalTickets = $counts['total']; } return new AdminAppTicketResult( tickets: $tickets, scannedTickets: $scannedTickets, - totalTickets: $tickets->total(), + totalTickets: $totalTickets, + refundedTotal: $this->refundSummaryService->totalForTenant($tenant), ); } @@ -75,6 +95,219 @@ class AdminAppTicketService return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters); } + public function cancel(Tenant $tenant, int $ticketId): Ticket + { + return DB::transaction(function () use ($tenant, $ticketId): Ticket { + $ticket = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->lockForUpdate() + ->findOrFail($ticketId); + + if (! $ticket->can_cancel()) { + throw ValidationException::withMessages([ + 'status' => 'El ticket debe estar activo para poder cancelarlo.', + ]); + } + + $ticket->markAsCancelled(); + $ticket->save(); + + return $ticket->refresh()->load(self::RELATIONS); + }); + } + + /** + * @return array{ + * total: string|null, + * partial: string|null, + * } + */ + public function calculateRefund(Tenant $tenant, int $ticketId): array + { + $ticket = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->findOrFail($ticketId); + + if (! $ticket->can_refund()) { + throw ValidationException::withMessages([ + 'refund' => 'El reembolso no está disponible para este ticket.', + ]); + } + + $purchaseItem = PurchaseItem::query() + ->find($ticket->source_purchase_item_id); + + if ($purchaseItem === null) { + throw ValidationException::withMessages([ + 'ticket' => 'El ticket no tiene un ítem de compra asociado para reembolsar.', + ]); + } + + $unitPrice = (float) $purchaseItem->precio_unitario; + $itemTotal = (float) $purchaseItem->total; + $itemRefundedAmount = $this->refundedAmountForPurchaseItem($purchaseItem); + $remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2)); + + $total = null; + if ($tenant->allow_refund() && $tenant->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) { + $total = number_format($unitPrice, 2, '.', ''); + } + + $partial = null; + if ($tenant->allow_refund() && $tenant->allow_partial_refund()) { + $partialAmount = $this->refundAmount($purchaseItem, $tenant, 'partial'); + if ($partialAmount <= $remainingItemAmount) { + $partial = number_format($partialAmount, 2, '.', ''); + } + } + + return [ + 'total' => $total, + 'partial' => $partial, + ]; + } + + public function refund( + Tenant $tenant, + int $ticketId, + string $refundType, + ?User $createdBy = null, + ): Ticket { + $this->ensureRefundIsAllowed($tenant, $refundType); + + return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): Ticket { + $ticket = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->lockForUpdate() + ->findOrFail($ticketId); + + if (! $ticket->can_refund()) { + if ($ticket->status !== Ticket::STATUS_ACTIVE) { + throw ValidationException::withMessages([ + 'status' => 'El ticket debe estar activo para poder reembolsarlo.', + ]); + } + + throw ValidationException::withMessages([ + 'refund' => 'El reembolso no está disponible para este ticket.', + ]); + } + + $purchaseItem = PurchaseItem::query() + ->lockForUpdate() + ->find($ticket->source_purchase_item_id); + + if ($purchaseItem === null) { + throw ValidationException::withMessages([ + 'ticket' => 'El ticket no tiene un ítem de compra asociado para reembolsar.', + ]); + } + + $refundAmount = $this->refundAmount($purchaseItem, $tenant, $refundType); + $refundedAmount = round( + $this->refundedAmountForPurchaseItem($purchaseItem) + $refundAmount, + 2, + ); + + if ($refundedAmount > (float) $purchaseItem->total) { + throw ValidationException::withMessages([ + 'refund_type' => 'El importe reembolsado no puede superar el total del ítem de compra.', + ]); + } + + $ticket->markAsRefunded(); + $ticket->save(); + + TicketRefund::query()->create([ + 'ticket_id' => $ticket->id, + 'purchase_item_id' => $purchaseItem->id, + 'created_by_user_id' => $createdBy?->id, + 'type' => $refundType, + 'amount' => number_format($refundAmount, 2, '.', ''), + ]); + + $this->restoreInventory($ticket, $purchaseItem); + + return $ticket->refresh()->load(self::RELATIONS); + }); + } + + private function restoreInventory(Ticket $ticket, PurchaseItem $purchaseItem): void + { + $catalogItem = $ticket->sourceCatalogItem; + if ($catalogItem === null) { + throw ValidationException::withMessages([ + 'ticket' => 'El ticket no tiene un producto con inventario reponible.', + ]); + } + + // Bundle components need a per-ticket allocation before they can be restored. + if ($catalogItem->isBundle() || $purchaseItem->sourceCatalogItem?->isBundle()) { + return; + } + + $inventoryId = $catalogItem->inventory_id; + if ($ticket->source_variant_id !== null) { + $variant = Variant::withTrashed()->find($ticket->source_variant_id); + if ($variant === null) { + throw ValidationException::withMessages(['ticket' => 'No se encontró la variante del ticket.']); + } + + // A replacement can move the sellable inventory to a newer variant. + $visited = []; + while ($variant->replaced_by_variant_id !== null) { + if (isset($visited[$variant->id])) { + throw new \LogicException('La cadena de reemplazos de variantes es circular.'); + } + $visited[$variant->id] = true; + $variant = Variant::withTrashed()->findOrFail($variant->replaced_by_variant_id); + } + $inventoryId = $variant->inventory_id; + } + + $inventory = Inventory::query()->lockForUpdate()->find($inventoryId); + if ($inventory === null) { + throw ValidationException::withMessages(['ticket' => 'No se encontró el inventario del ticket.']); + } + + if ($catalogItem->inventory_policy === InventoryPolicy::Tracked) { + $inventory->real_stock++; + } + $inventory->refunded_units++; + $inventory->save(); + } + + private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float + { + return round((float) TicketRefund::query() + ->where('purchase_item_id', $purchaseItem->id) + ->sum('amount'), 2); + } + + private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void + { + $isAllowed = match ($refundType) { + TicketRefund::TYPE_PARTIAL => $tenant->allow_refund() && $tenant->allow_partial_refund(), + TicketRefund::TYPE_TOTAL => $tenant->allow_refund() && (bool) $tenant->allow_ticket_total_refund, + }; + + if (! $isAllowed) { + throw ValidationException::withMessages([ + 'refund_type' => 'El tipo de reembolso solicitado no está habilitado para este tenant.', + ]); + } + } + + private function refundAmount(PurchaseItem $purchaseItem, Tenant $tenant, string $refundType): float + { + $ticketAmount = (float) $purchaseItem->precio_unitario; + + return match ($refundType) { + TicketRefund::TYPE_PARTIAL => round($ticketAmount * (float) $tenant->ticket_partial_refund_percentage / 100, 2), + TicketRefund::TYPE_TOTAL => $ticketAmount, + }; + } + /** * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters * @return Builder @@ -255,13 +488,41 @@ class AdminAppTicketService } if ($status === Ticket::STATUS_USED) { - $query->whereNotNull('used_at'); + $query + ->whereNotNull('used_at') + ->whereNull('disabled_at') + ->whereNull('cancelled_at') + ->whereNull('refunded_at'); + + return; + } + + $timestampColumn = match ($status) { + Ticket::STATUS_DISABLED => 'disabled_at', + Ticket::STATUS_CANCELLED => 'cancelled_at', + Ticket::STATUS_REFUNDED => 'refunded_at', + default => null, + }; + + if ($timestampColumn !== null) { + $query->whereNotNull($timestampColumn); + + if ($status === Ticket::STATUS_DISABLED) { + $query->whereNull('cancelled_at')->whereNull('refunded_at'); + } + + if ($status === Ticket::STATUS_CANCELLED) { + $query->whereNull('refunded_at'); + } return; } $matchingIds = (clone $query) ->whereNull('used_at') + ->whereNull('disabled_at') + ->whereNull('cancelled_at') + ->whereNull('refunded_at') ->with(TicketValidityResolver::RELATIONS) ->get() ->filter(fn (Ticket $ticket): bool => $ticket->status === $status) @@ -270,6 +531,35 @@ class AdminAppTicketService $query->whereIn('tickets.id', $matchingIds); } + /** + * @param Builder $countQuery + * @return array{scanned: int, total: int} + */ + private function calculateTicketCounts(Builder $countQuery): array + { + $scannedTickets = (clone $countQuery) + ->whereNotNull('used_at') + ->whereNull('disabled_at') + ->whereNull('cancelled_at') + ->whereNull('refunded_at') + ->count(); + + $activeTickets = (clone $countQuery) + ->whereNull('used_at') + ->whereNull('disabled_at') + ->whereNull('cancelled_at') + ->whereNull('refunded_at') + ->with(TicketValidityResolver::RELATIONS) + ->get() + ->filter(fn (Ticket $ticket): bool => $ticket->is_active()) + ->count(); + + return [ + 'scanned' => $scannedTickets, + 'total' => $activeTickets + $scannedTickets, + ]; + } + private function normalizedCategory(string $category): string { return mb_strtolower(trim($category)); diff --git a/app/Domains/Ticket/Services/BackfillRefundedUnitsService.php b/app/Domains/Ticket/Services/BackfillRefundedUnitsService.php new file mode 100644 index 0000000..95c0b40 --- /dev/null +++ b/app/Domains/Ticket/Services/BackfillRefundedUnitsService.php @@ -0,0 +1,121 @@ + */ + public function run(): array + { + $summary = DB::transaction(function (): array { + if (DB::table('inventories')->where('refunded_units', '>', 0)->exists()) { + throw new RuntimeException('El backfill requiere que refunded_units sea cero en todos los inventarios.'); + } + + $counts = []; + $variants = []; + $refundsSeen = 0; + $bundlesSkipped = 0; + + DB::table('ticket_refunds as refunds') + ->join('tickets', 'tickets.id', '=', 'refunds.ticket_id') + ->join('compra_items as purchase_items', 'purchase_items.id', '=', 'refunds.purchase_item_id') + ->leftJoin('catalog_items as purchase_catalog', 'purchase_catalog.id', '=', 'purchase_items.source_catalog_item_id') + ->leftJoin('catalog_items as ticket_catalog', 'ticket_catalog.id', '=', 'tickets.source_catalog_item_id') + ->select([ + 'refunds.id', + 'refunds.ticket_id', + 'tickets.source_variant_id', + 'ticket_catalog.inventory_id', + 'ticket_catalog.inventory_policy', + 'ticket_catalog.type as ticket_catalog_type', + 'purchase_catalog.type as purchase_catalog_type', + ]) + ->chunkById(500, function ($refunds) use (&$counts, &$variants, &$refundsSeen, &$bundlesSkipped): void { + foreach ($refunds as $refund) { + $refundsSeen++; + if ($refund->ticket_catalog_type === 'bundle' || $refund->purchase_catalog_type === 'bundle') { + $bundlesSkipped++; + + continue; + } + + if ($refund->inventory_policy === null) { + throw new RuntimeException("El reembolso {$refund->id} no tiene un producto de catálogo asociado."); + } + + $inventoryId = $refund->source_variant_id === null + ? $refund->inventory_id + : $this->currentVariantInventoryId((int) $refund->source_variant_id, $variants); + + if ($inventoryId === null) { + throw new RuntimeException("El reembolso {$refund->id} no tiene un inventario asociado."); + } + + $counts[$inventoryId]['refunded'] = ($counts[$inventoryId]['refunded'] ?? 0) + 1; + if ($refund->inventory_policy === 'tracked') { + $counts[$inventoryId]['stock'] = ($counts[$inventoryId]['stock'] ?? 0) + 1; + } + } + }, 'refunds.id', 'id'); + + foreach ($counts as $inventoryId => $count) { + $updates = ['refunded_units' => DB::raw('refunded_units + '.$count['refunded'])]; + if (($count['stock'] ?? 0) > 0) { + $updates['real_stock'] = DB::raw('real_stock + '.$count['stock']); + } + + if (DB::table('inventories')->where('id', $inventoryId)->update($updates) !== 1) { + throw new RuntimeException("No se encontró el inventario {$inventoryId} para reponerlo."); + } + } + + $refundedUnitsAdded = array_sum(array_column($counts, 'refunded')); + + return [ + 'refunds_seen' => $refundsSeen, + 'refunds_applied' => $refundedUnitsAdded, + 'bundles_skipped' => $bundlesSkipped, + 'inventories_updated' => count($counts), + 'refunded_units_added' => $refundedUnitsAdded, + 'real_stock_added' => array_sum(array_column($counts, 'stock')), + ]; + }); + + Log::info('inventory.refunded_units_backfill.completed', $summary); + + return $summary; + } + + /** @param array $variants */ + private function currentVariantInventoryId(int $variantId, array &$variants): ?int + { + $visited = []; + + while (true) { + if (isset($visited[$variantId])) { + throw new RuntimeException("La cadena de reemplazos de la variante {$variantId} es circular."); + } + $visited[$variantId] = true; + + if (! array_key_exists($variantId, $variants)) { + $variants[$variantId] = DB::table('variantes') + ->where('id', $variantId) + ->first(['inventory_id', 'replaced_by_variant_id']); + } + $variant = $variants[$variantId]; + if ($variant === null) { + throw new RuntimeException("No se encontró la variante {$variantId} de un ticket reembolsado."); + } + if ($variant->replaced_by_variant_id === null) { + return $variant->inventory_id === null ? null : (int) $variant->inventory_id; + } + + $variantId = (int) $variant->replaced_by_variant_id; + } + } +} diff --git a/app/Domains/Ticket/Services/ScannerTicketService.php b/app/Domains/Ticket/Services/ScannerTicketService.php index fc47b61..e696de6 100644 --- a/app/Domains/Ticket/Services/ScannerTicketService.php +++ b/app/Domains/Ticket/Services/ScannerTicketService.php @@ -3,46 +3,116 @@ namespace App\Domains\Ticket\Services; use App\Domains\Auth\Models\User; +use App\Domains\Ticket\Enums\ScanAttemptResult; +use App\Domains\Ticket\Models\ScanAttempt; use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; -use Illuminate\Validation\ValidationException; +use Illuminate\Support\Str; +use Throwable; class ScannerTicketService { /** * @param array{q?: string|null, page?: int, per_page?: int} $filters - * @return LengthAwarePaginator + * @return LengthAwarePaginator */ - public function scannedBy(User $scanner, array $filters = []): LengthAwarePaginator + public function attemptsBy(User $scanner, array $filters = []): LengthAwarePaginator { $search = trim((string) ($filters['q'] ?? '')); - return $this->baseQuery() + return ScanAttempt::query() + ->with('ticket.sourceCatalogItem.category') ->where('tenant_code', $scanner->tenant_codigo) ->where('scanner_user_id', $scanner->getKey()) ->when($search !== '', function (Builder $query) use ($search): void { - $usedAtDate = $this->parseSearchDate($search); + $attemptedAtDate = $this->parseSearchDate($search); - $query->where(function (Builder $searchQuery) use ($search, $usedAtDate): void { - $searchQuery->where('ticket', 'like', "%{$search}%"); + $query->where(function (Builder $searchQuery) use ($search, $attemptedAtDate): void { + $searchQuery->where('data', 'like', "%{$search}%"); if (ctype_digit($search)) { $searchQuery->orWhere('id', (int) $search); } - if ($usedAtDate !== null) { - $searchQuery->orWhereDate('used_at', $usedAtDate); + if ($attemptedAtDate !== null) { + $searchQuery->orWhereDate('created_at', $attemptedAtDate); } }); }) - ->orderByDesc('used_at') + ->orderByDesc('created_at') ->orderByDesc('id') ->paginateFromRequest() ->withQueryString(); } + /** + * @param array{q?: string|null, page?: int, per_page?: int} $filters + * @return LengthAwarePaginator + */ + public function attemptsByStaff(User $scanner, array $filters = []): LengthAwarePaginator + { + $search = trim((string) ($filters['q'] ?? '')); + + return ScanAttempt::query() + ->with('ticket.sourceCatalogItem.category') + ->where('tenant_code', $scanner->tenant_codigo) + ->where('scanner_user_id', $scanner->getKey()) + ->when($search !== '', function (Builder $query) use ($search): void { + $attemptedAtDate = $this->parseSearchDate($search); + $attemptedAtDayMonth = $this->parseSearchDayMonth($search); + + $query->where(function (Builder $searchQuery) use ( + $search, + $attemptedAtDate, + $attemptedAtDayMonth, + ): void { + $searchQuery + ->whereHas( + 'ticket.sourceCatalogItem.category', + fn (Builder $categoryQuery): Builder => $categoryQuery + ->where('nombre', 'like', "%{$search}%") + ) + ->orWhere('created_at', 'like', "%{$search}%"); + + if (ctype_digit($search)) { + $searchQuery->orWhere('ticket_id', (int) $search); + } + + if ($attemptedAtDate !== null) { + $searchQuery->orWhereDate('created_at', $attemptedAtDate); + } + + if ($attemptedAtDayMonth !== null) { + $searchQuery->orWhere(function (Builder $dateQuery) use ($attemptedAtDayMonth): void { + $dateQuery + ->whereDay('created_at', $attemptedAtDayMonth['day']) + ->whereMonth('created_at', $attemptedAtDayMonth['month']); + }); + } + }); + }) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->paginateFromRequest() + ->withQueryString(); + } + + public function scanAttemptDetail(User $scanner, int $scanAttemptId): ScanAttempt + { + $scanAttempt = ScanAttempt::query() + ->with('ticket') + ->where('tenant_code', $scanner->tenant_codigo) + ->where('scanner_user_id', $scanner->getKey()) + ->findOrFail($scanAttemptId); + + $scanAttempt->ticket?->loadMissing($this->relations()); + + return $scanAttempt; + } + private function parseSearchDate(string $search): ?string { if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) { @@ -67,6 +137,19 @@ class ScannerTicketService return null; } + /** @return array{day: int, month: int}|null */ + private function parseSearchDayMonth(string $search): ?array + { + if (preg_match('/^(\d{1,2})\/(\d{1,2})$/', $search, $matches) !== 1) { + return null; + } + + $day = (int) $matches[1]; + $month = (int) $matches[2]; + + return checkdate($month, $day, 2000) ? compact('day', 'month') : null; + } + public function detail(User $scanner, string $ticketUuid): Ticket { $query = $this->baseQuery() @@ -90,42 +173,117 @@ class ScannerTicketService return $query->firstOrFail(); } - public function scan(User $scanner, string $ticketUuid): Ticket + public function scan(User $scanner, mixed $scannedData): ScanAttempt { - return DB::transaction(function () use ($scanner, $ticketUuid): Ticket { - $ticket = $this->baseQuery() - ->where('tenant_code', $scanner->tenant_codigo) - ->where('ticket', $ticketUuid) - ->lockForUpdate() - ->firstOrFail(); + $scanAttempt = ScanAttempt::query()->create([ + 'tenant_code' => $scanner->tenant_codigo, + 'scanner_user_id' => $scanner->getKey(), + 'data' => $this->serializeScannedData($scannedData), + 'result' => ScanAttemptResult::Processing, + ]); - if (! $this->scannerCanScan($scanner, $ticket)) { - throw ValidationException::withMessages([ - 'ticket' => __('api.ticket.scanner_category_forbidden'), - ]); - } + if (! is_string($scannedData) || ! Str::isUuid($scannedData)) { + $this->resolveScanAttempt($scanAttempt, ScanAttemptResult::InvalidQr); - if ($ticket->is_used) { - throw ValidationException::withMessages([ - 'ticket' => __('api.ticket.already_scanned'), - ]); - } + return $scanAttempt->refresh(); + } - if (! $ticket->is_valid) { - throw ValidationException::withMessages([ - 'ticket' => $ticket->is_expired - ? __('api.ticket.expired_for_scan') - : __('api.ticket.not_valid_for_scan'), - ]); - } + $ticketId = null; - $ticket->forceFill([ - 'used_at' => now(), - 'scanner_user_id' => $scanner->getKey(), - ])->save(); + try { + return DB::transaction(function () use ( + $scanner, + $scannedData, + $scanAttempt, + &$ticketId, + ): ScanAttempt { + $ticket = $this->baseQuery() + ->where('tenant_code', $scanner->tenant_codigo) + ->where('ticket', $scannedData) + ->lockForUpdate() + ->firstOrFail(); + $ticketId = (int) $ticket->getKey(); - return $ticket->refresh()->load($this->relations()); - }); + if (! $this->scannerCanScan($scanner, $ticket)) { + $this->resolveScanAttempt( + $scanAttempt, + ScanAttemptResult::CategoryForbidden, + $ticketId, + ); + + return $scanAttempt->refresh()->setRelation('ticket', $ticket); + } + + if ($ticket->is_used) { + $this->resolveScanAttempt( + $scanAttempt, + ScanAttemptResult::AlreadyScanned, + $ticketId, + ); + + return $scanAttempt->refresh()->setRelation('ticket', $ticket); + } + + if (! $ticket->is_valid) { + $result = $ticket->is_expired + ? ScanAttemptResult::Expired + : ScanAttemptResult::NotValid; + $this->resolveScanAttempt($scanAttempt, $result, $ticketId); + + return $scanAttempt->refresh()->setRelation('ticket', $ticket); + } + + $ticket->forceFill([ + 'used_at' => now(), + 'scanner_user_id' => $scanner->getKey(), + ])->save(); + + $this->resolveScanAttempt( + $scanAttempt, + ScanAttemptResult::Accepted, + $ticketId, + ); + + $ticket = $ticket->refresh()->load($this->relations()); + + return $scanAttempt->refresh()->setRelation('ticket', $ticket); + }); + } catch (ModelNotFoundException) { + $this->resolveScanAttempt($scanAttempt, ScanAttemptResult::TicketNotFound); + + return $scanAttempt->refresh(); + } catch (Throwable $exception) { + report($exception); + $this->resolveScanAttempt($scanAttempt, ScanAttemptResult::UnexpectedError, $ticketId); + + return $scanAttempt->refresh(); + } + } + + private function resolveScanAttempt( + ScanAttempt $scanAttempt, + ScanAttemptResult $result, + ?int $ticketId = null, + ): void { + $scanAttempt->forceFill([ + 'ticket_id' => $ticketId, + 'result' => $result, + 'resolved_at' => now(), + ])->save(); + } + + private function serializeScannedData(mixed $scannedData): ?string + { + if ($scannedData === null || is_string($scannedData)) { + return $scannedData; + } + + $encoded = json_encode( + $scannedData, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE, + ); + + return $encoded === false ? get_debug_type($scannedData) : $encoded; } /** @return Builder */ diff --git a/app/Domains/Ticket/Services/TicketPresentationResolver.php b/app/Domains/Ticket/Services/TicketPresentationResolver.php index 19de892..f2e7681 100644 --- a/app/Domains/Ticket/Services/TicketPresentationResolver.php +++ b/app/Domains/Ticket/Services/TicketPresentationResolver.php @@ -2,10 +2,14 @@ namespace App\Domains\Ticket\Services; +use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Services\EffectiveEventDateResolver; use App\Domains\Ticket\Models\Ticket; class TicketPresentationResolver { + public function __construct(private readonly EffectiveEventDateResolver $effectiveEventDateResolver) {} + /** Relaciones necesarias para calcular nombre y descripción sin consultas N+1. */ public const RELATIONS = [ 'sourceCatalogItem', @@ -30,9 +34,15 @@ class TicketPresentationResolver } $itemAttributes = $variant->catalogItem->itemAttributes; + $eventDateLabels = $variant->selectedEventDates() + ->map(fn (EventDate $date): EventDate => $this->effectiveEventDateResolver->resolveLatest($date) ?? $date) + ->unique(fn (EventDate $date): int => $date->getKey()) + ->map(fn (EventDate $date): string => $date->date->format('d/m/Y')) + ->implode(', '); + $properties = $variant->selectionOptions($itemAttributes) - ->map(function (array $option, string $attributeCode) use ($itemAttributes): ?string { - $labels = collect(array_is_list($option) ? $option : [$option]) + ->map(function (array $option, string $attributeCode) use ($itemAttributes, $eventDateLabels): ?string { + $labels = $attributeCode === 'event_date' ? $eventDateLabels : collect(array_is_list($option) ? $option : [$option]) ->pluck('label') ->filter(fn ($label): bool => is_string($label) && $label !== '') ->implode(', '); diff --git a/app/Domains/Ticket/Services/TicketValidityResolver.php b/app/Domains/Ticket/Services/TicketValidityResolver.php index 0c0c34d..69714d5 100644 --- a/app/Domains/Ticket/Services/TicketValidityResolver.php +++ b/app/Domains/Ticket/Services/TicketValidityResolver.php @@ -4,6 +4,8 @@ namespace App\Domains\Ticket\Services; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\VariantDefinition; +use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Services\EffectiveEventDateResolver; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Support\Collection; @@ -17,6 +19,14 @@ use Illuminate\Support\Collection; */ class TicketValidityResolver { + private readonly EffectiveEventDateResolver $effectiveEventDateResolver; + + public function __construct(?EffectiveEventDateResolver $effectiveEventDateResolver = null) + { + $this->effectiveEventDateResolver = $effectiveEventDateResolver + ?? new EffectiveEventDateResolver; + } + /** Relaciones necesarias para resolver tickets sin consultas N+1. */ public const RELATIONS = [ 'sourceVariant.eventDates.validityTime', @@ -56,7 +66,18 @@ class TicketValidityResolver ]); $dimensions = collect(); - $eventDates = $variant->selectedEventDates(); + $selectedEventDates = $variant->selectedEventDates(); + $eventDates = $selectedEventDates + ->map(fn (EventDate $eventDate): ?EventDate => $this->effectiveEventDateResolver->resolve($eventDate)) + ->filter() + ->unique(fn (EventDate $eventDate): int => $eventDate->getKey() ?? spl_object_id($eventDate)) + ->values(); + + if ($selectedEventDates->isNotEmpty() && $eventDates->isEmpty()) { + return ResolvedTicketValidity::unresolvable(); + } + + $eventDates->each->loadMissing('validityTime'); if ($eventDates->contains(fn ($eventDate): bool => $eventDate->validityTime === null)) { return ResolvedTicketValidity::unresolvable(); diff --git a/app/Domains/Ticket/routes/adminapp.php b/app/Domains/Ticket/routes/adminapp.php index f2602cc..66abe50 100644 --- a/app/Domains/Ticket/routes/adminapp.php +++ b/app/Domains/Ticket/routes/adminapp.php @@ -9,6 +9,18 @@ Route::prefix('v1/adminapp/tenant') Route::get('tickets', [TicketController::class, 'index']) ->middleware('tenant.menu:adminapp.tickets') ->name('adminapp.tickets.index'); + Route::post('tickets/{ticket}/cancel', [TicketController::class, 'cancel']) + ->whereNumber('ticket') + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.tickets.cancel'); + Route::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund']) + ->whereNumber('ticket') + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.tickets.calculate-refund'); + Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund']) + ->whereNumber('ticket') + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.tickets.refund'); Route::get('tickets/pdf', [TicketController::class, 'downloadPdf']) ->middleware('tenant.menu:adminapp.tickets') ->name('adminapp.tickets.pdf'); diff --git a/app/Domains/Ticket/routes/scanner.php b/app/Domains/Ticket/routes/scanner.php index 383bf6b..a92ccbb 100644 --- a/app/Domains/Ticket/routes/scanner.php +++ b/app/Domains/Ticket/routes/scanner.php @@ -1,14 +1,18 @@ middleware(['auth:sanctum', 'scanner.tenant']) +Route::middleware(['auth:sanctum', 'scanner.tenant']) ->group(function (): void { - Route::get('/', [TicketController::class, 'index']); - Route::get('{ticketUuid}', [TicketController::class, 'show']) - ->whereUuid('ticketUuid'); - Route::post('{ticketUuid}/scan', [TicketController::class, 'scan']) - ->whereUuid('ticketUuid'); + Route::get('v1/scanner/attempts', ScanAttemptController::class); + Route::get('v1/scanner/attempts/{scanAttempt}', [ScanAttemptController::class, 'show']) + ->whereNumber('scanAttempt'); + + Route::prefix('v1/scanner/tickets')->group(function (): void { + Route::post('scan', [TicketController::class, 'scan']); + Route::get('{ticketUuid}', [TicketController::class, 'show']) + ->whereUuid('ticketUuid'); + }); }); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e687726..d528318 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,10 +2,14 @@ namespace App\Providers; +use App\Domains\Event\Events\EventDateRescheduled; +use App\Domains\Event\Events\EventDateSuspended; use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Policies\IntegrationPolicy; use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Notification\Events\UserRegistered; +use App\Domains\Notification\Listeners\SendEventDateRescheduledEmails; +use App\Domains\Notification\Listeners\SendEventDateSuspendedEmails; use App\Domains\Notification\Listeners\SendPasswordResetEmail; use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail; use App\Domains\Notification\Listeners\SendWelcomeEmail; @@ -40,6 +44,8 @@ class AppServiceProvider extends ServiceProvider ); Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class); Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class); + Event::listen(EventDateRescheduled::class, SendEventDateRescheduledEmails::class); + Event::listen(EventDateSuspended::class, SendEventDateSuspendedEmails::class); Event::listen(UserRegistered::class, SendWelcomeEmail::class); Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class); diff --git a/composer.json b/composer.json index 78ca5d7..d806e5f 100644 --- a/composer.json +++ b/composer.json @@ -52,8 +52,7 @@ "npx concurrently -c \"#93c5fd,#c4b5fd,#a7f3d0,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=emails,default --tries=1 --timeout=0\" \"php artisan schedule:work\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,scheduler,logs,vite --kill-others" ], "test": [ - "@php artisan config:clear --ansi @no_additional_args", - "@php artisan test" + "@php vendor/phpunit/phpunit/phpunit" ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", diff --git a/config/mail.php b/config/mail.php index e32e88d..90fb4f5 100644 --- a/config/mail.php +++ b/config/mail.php @@ -16,6 +16,8 @@ return [ 'default' => env('MAIL_MAILER', 'log'), + 'delivery_lease_seconds' => (int) env('EMAIL_DELIVERY_LEASE_SECONDS', 300), + /* |-------------------------------------------------------------------------- | Mailer Configurations diff --git a/database/migrations/2026_09_07_000000_create_scan_attempts_table.php b/database/migrations/2026_09_07_000000_create_scan_attempts_table.php new file mode 100644 index 0000000..8361878 --- /dev/null +++ b/database/migrations/2026_09_07_000000_create_scan_attempts_table.php @@ -0,0 +1,46 @@ +id(); + $table->string('tenant_code'); + $table->foreignId('scanner_user_id') + ->nullable() + ->constrained('users') + ->cascadeOnUpdate() + ->nullOnDelete(); + $table->foreignId('ticket_id') + ->nullable() + ->constrained('tickets') + ->cascadeOnUpdate() + ->nullOnDelete(); + $table->text('data')->nullable(); + $table->string('result', 32); + $table->timestamp('resolved_at')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('tenant_code') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->restrictOnDelete(); + + $table->index(['scanner_user_id', 'created_at']); + $table->index(['tenant_code', 'created_at']); + $table->index(['ticket_id', 'created_at']); + $table->index(['tenant_code', 'result', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('scan_attempts'); + } +}; diff --git a/database/migrations/2026_09_10_000000_add_ticket_refund_configuration_to_tenants_table.php b/database/migrations/2026_09_10_000000_add_ticket_refund_configuration_to_tenants_table.php new file mode 100644 index 0000000..3f8b55d --- /dev/null +++ b/database/migrations/2026_09_10_000000_add_ticket_refund_configuration_to_tenants_table.php @@ -0,0 +1,28 @@ +boolean('allow_ticket_total_refund')->default(false); + $table->boolean('allow_ticket_partial_refund')->default(false); + $table->decimal('ticket_partial_refund_percentage', 4, 2)->default(0); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn([ + 'allow_ticket_total_refund', + 'allow_ticket_partial_refund', + 'ticket_partial_refund_percentage', + ]); + }); + } +}; diff --git a/database/migrations/2026_09_10_010000_add_state_timestamps_to_tickets_and_refunded_amount_to_purchase_items.php b/database/migrations/2026_09_10_010000_add_state_timestamps_to_tickets_and_refunded_amount_to_purchase_items.php new file mode 100644 index 0000000..5c087b0 --- /dev/null +++ b/database/migrations/2026_09_10_010000_add_state_timestamps_to_tickets_and_refunded_amount_to_purchase_items.php @@ -0,0 +1,36 @@ +dateTime('disabled_at')->nullable()->after('used_at'); + $table->dateTime('cancelled_at')->nullable()->after('disabled_at'); + $table->dateTime('refunded_at')->nullable()->after('cancelled_at'); + }); + + Schema::table('compra_items', function (Blueprint $table): void { + $table->decimal('refunded_amount', 10, 2)->default(0)->after('total'); + }); + } + + public function down(): void + { + Schema::table('compra_items', function (Blueprint $table): void { + $table->dropColumn('refunded_amount'); + }); + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropColumn([ + 'disabled_at', + 'cancelled_at', + 'refunded_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_09_11_000000_add_rescheduling_state_to_event_dates.php b/database/migrations/2026_09_11_000000_add_rescheduling_state_to_event_dates.php new file mode 100644 index 0000000..54c999f --- /dev/null +++ b/database/migrations/2026_09_11_000000_add_rescheduling_state_to_event_dates.php @@ -0,0 +1,30 @@ +foreignId('rescheduled_to_event_date_id') + ->nullable() + ->after('validity_time_id') + ->constrained('event_dates') + ->restrictOnDelete(); + $table->dateTime('cancelled_at') + ->nullable() + ->after('rescheduled_to_event_date_id'); + }); + } + + public function down(): void + { + Schema::table('event_dates', function (Blueprint $table): void { + $table->dropConstrainedForeignId('rescheduled_to_event_date_id'); + $table->dropColumn('cancelled_at'); + }); + } +}; diff --git a/database/migrations/2026_09_11_010000_add_ticket_refund_master_toggle_to_tenants_table.php b/database/migrations/2026_09_11_010000_add_ticket_refund_master_toggle_to_tenants_table.php new file mode 100644 index 0000000..e831d5e --- /dev/null +++ b/database/migrations/2026_09_11_010000_add_ticket_refund_master_toggle_to_tenants_table.php @@ -0,0 +1,34 @@ +boolean('allow_ticket_refund') + ->default(false) + ->after('scanner_category_validation_enabled'); + }); + + DB::table('tenants') + ->where('allow_ticket_total_refund', true) + ->orWhere(function ($query): void { + $query + ->where('allow_ticket_partial_refund', true) + ->where('ticket_partial_refund_percentage', '>', 0); + }) + ->update(['allow_ticket_refund' => true]); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn('allow_ticket_refund'); + }); + } +}; diff --git a/database/migrations/2026_09_11_020000_rename_cancelled_at_to_suspended_at_on_event_dates.php b/database/migrations/2026_09_11_020000_rename_cancelled_at_to_suspended_at_on_event_dates.php new file mode 100644 index 0000000..feec1d2 --- /dev/null +++ b/database/migrations/2026_09_11_020000_rename_cancelled_at_to_suspended_at_on_event_dates.php @@ -0,0 +1,22 @@ +renameColumn('cancelled_at', 'suspended_at'); + }); + } + + public function down(): void + { + Schema::table('event_dates', function (Blueprint $table): void { + $table->renameColumn('suspended_at', 'cancelled_at'); + }); + } +}; diff --git a/database/migrations/2026_09_14_000000_create_email_deliveries_table.php b/database/migrations/2026_09_14_000000_create_email_deliveries_table.php new file mode 100644 index 0000000..eb0cb98 --- /dev/null +++ b/database/migrations/2026_09_14_000000_create_email_deliveries_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('idempotency_key')->unique(); + $table->string('email_type')->index(); + $table->string('tenant_code')->nullable()->index(); + $table->string('status')->index(); + $table->unsignedInteger('attempts')->default(0); + $table->json('context')->nullable(); + $table->string('recipient_fingerprint', 64)->nullable(); + $table->uuid('claim_token')->nullable()->index(); + $table->timestamp('claimed_at')->nullable(); + $table->timestamp('lease_expires_at')->nullable()->index(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_deliveries'); + } +}; diff --git a/database/migrations/2026_09_14_010000_create_ticket_refunds_table.php b/database/migrations/2026_09_14_010000_create_ticket_refunds_table.php new file mode 100644 index 0000000..e19a686 --- /dev/null +++ b/database/migrations/2026_09_14_010000_create_ticket_refunds_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('ticket_id')->unique()->constrained('tickets')->cascadeOnDelete(); + $table->foreignId('purchase_item_id')->constrained('compra_items')->restrictOnDelete(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('type', 16); + $table->decimal('amount', 10, 2); + $table->timestamps(); + + $table->index(['purchase_item_id', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_refunds'); + } +}; diff --git a/database/migrations/2026_09_14_020000_create_event_date_reschedules_table.php b/database/migrations/2026_09_14_020000_create_event_date_reschedules_table.php new file mode 100644 index 0000000..b01dd6e --- /dev/null +++ b/database/migrations/2026_09_14_020000_create_event_date_reschedules_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('tenant_code'); + $table->foreignId('source_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete(); + $table->foreignId('destination_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->date('previous_date'); + $table->date('new_date'); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('tenant_code') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->index(['tenant_code', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_date_reschedules'); + } +}; diff --git a/database/migrations/2026_09_14_030000_add_variant_replacement_state.php b/database/migrations/2026_09_14_030000_add_variant_replacement_state.php new file mode 100644 index 0000000..2769c9d --- /dev/null +++ b/database/migrations/2026_09_14_030000_add_variant_replacement_state.php @@ -0,0 +1,86 @@ +dropForeign(['inventory_id']); + } + + $table->dropUnique('variantes_inventory_id_unique'); + $table->index('inventory_id'); + + if ($requiresForeignKeyRecreation) { + $table->foreign('inventory_id')->references('id')->on('inventories')->restrictOnDelete(); + } + + $table->foreignId('replaced_by_variant_id') + ->nullable() + ->after('inventory_id') + ->constrained('variantes') + ->nullOnDelete(); + $table->timestamp('sales_disabled_at') + ->nullable() + ->after('replaced_by_variant_id'); + $table->index( + ['sales_disabled_at', 'replaced_by_variant_id'], + 'variants_sellable_index', + ); + }); + } + + public function down(): void + { + $requiresForeignKeyRecreation = in_array(DB::getDriverName(), ['mysql', 'mariadb'], true); + + DB::table('variantes') + ->orderBy('id') + ->get() + ->groupBy('inventory_id') + ->each(function ($variants): void { + $variants->skip(1)->each(function (object $variant): void { + $inventory = DB::table('inventories')->where('id', $variant->inventory_id)->first(); + + if ($inventory === null) { + return; + } + + $inventoryId = DB::table('inventories')->insertGetId([ + 'sold_units' => $inventory->sold_units, + 'reserved_stock' => 0, + 'real_stock' => $inventory->real_stock, + ]); + + DB::table('variantes')->where('id', $variant->id)->update([ + 'inventory_id' => $inventoryId, + ]); + }); + }); + + Schema::table('variantes', function (Blueprint $table) use ($requiresForeignKeyRecreation): void { + $table->dropIndex('variants_sellable_index'); + $table->dropConstrainedForeignId('replaced_by_variant_id'); + $table->dropColumn('sales_disabled_at'); + + if ($requiresForeignKeyRecreation) { + $table->dropForeign(['inventory_id']); + } + + $table->dropIndex(['inventory_id']); + $table->unique('inventory_id'); + + if ($requiresForeignKeyRecreation) { + $table->foreign('inventory_id')->references('id')->on('inventories')->restrictOnDelete(); + } + }); + } +}; diff --git a/database/migrations/2026_09_14_040000_generalize_event_date_reschedules_as_changes.php b/database/migrations/2026_09_14_040000_generalize_event_date_reschedules_as_changes.php new file mode 100644 index 0000000..294211c --- /dev/null +++ b/database/migrations/2026_09_14_040000_generalize_event_date_reschedules_as_changes.php @@ -0,0 +1,115 @@ +createEventDateChangesTable(); + + DB::table('event_date_changes')->insertUsing( + [ + 'tenant_code', + 'change_type', + 'source_event_date_id', + 'destination_event_date_id', + 'created_by_user_id', + 'previous_date', + 'new_date', + 'created_at', + ], + DB::table('event_date_reschedules')->select([ + 'tenant_code', + DB::raw("'rescheduled'"), + 'source_event_date_id', + 'destination_event_date_id', + 'created_by_user_id', + 'previous_date', + 'new_date', + 'created_at', + ]), + ); + + Schema::drop('event_date_reschedules'); + } + + public function down(): void + { + $this->createEventDateReschedulesTable(); + + DB::table('event_date_reschedules')->insertUsing( + [ + 'tenant_code', + 'source_event_date_id', + 'destination_event_date_id', + 'created_by_user_id', + 'previous_date', + 'new_date', + 'created_at', + ], + DB::table('event_date_changes') + ->where('change_type', 'rescheduled') + ->whereNotNull('destination_event_date_id') + ->whereNotNull('new_date') + ->select([ + 'tenant_code', + 'source_event_date_id', + 'destination_event_date_id', + 'created_by_user_id', + 'previous_date', + 'new_date', + 'created_at', + ]), + ); + + Schema::drop('event_date_changes'); + } + + private function createEventDateChangesTable(): void + { + Schema::create('event_date_changes', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('change_type', 16); + $table->foreignId('source_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete(); + $table->foreignId('destination_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->date('previous_date'); + $table->date('new_date')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('tenant_code') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->index(['tenant_code', 'created_at']); + $table->index(['source_event_date_id', 'change_type']); + }); + } + + private function createEventDateReschedulesTable(): void + { + Schema::create('event_date_reschedules', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->foreignId('source_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete(); + $table->foreignId('destination_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->date('previous_date'); + $table->date('new_date'); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('tenant_code') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->index(['tenant_code', 'created_at']); + }); + } +}; diff --git a/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php b/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php new file mode 100644 index 0000000..ffc3ae6 --- /dev/null +++ b/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php @@ -0,0 +1,128 @@ +backfillLegacyRefunds(); + + $mismatchedItem = DB::table('compra_items as purchase_items') + ->leftJoin('ticket_refunds as refunds', 'refunds.purchase_item_id', '=', 'purchase_items.id') + ->where('purchase_items.refunded_amount', '>', 0) + ->groupBy('purchase_items.id', 'purchase_items.refunded_amount') + ->selectRaw( + 'purchase_items.id, purchase_items.refunded_amount, COALESCE(SUM(refunds.amount), 0) as refund_total' + ) + ->get() + ->first(fn (object $item): bool => abs( + (float) $item->refunded_amount - (float) $item->refund_total + ) > 0.005); + + if ($mismatchedItem !== null) { + throw new RuntimeException( + "No se puede eliminar compra_items.refunded_amount: el ítem {$mismatchedItem->id} " + .'contiene un importe histórico que no se pudo respaldar con ticket_refunds.' + ); + } + + Schema::table('compra_items', function (Blueprint $table): void { + $table->dropColumn('refunded_amount'); + }); + } + + public function down(): void + { + Schema::table('compra_items', function (Blueprint $table): void { + $table->decimal('refunded_amount', 10, 2)->default(0)->after('total'); + }); + + DB::table('ticket_refunds') + ->selectRaw('purchase_item_id, SUM(amount) as refund_total') + ->groupBy('purchase_item_id') + ->orderBy('purchase_item_id') + ->eachById(function (object $refund): void { + DB::table('compra_items') + ->where('id', $refund->purchase_item_id) + ->update(['refunded_amount' => $refund->refund_total]); + }, column: 'purchase_item_id'); + } + + private function backfillLegacyRefunds(): void + { + $items = DB::table('compra_items as purchase_items') + ->leftJoin('ticket_refunds as refunds', 'refunds.purchase_item_id', '=', 'purchase_items.id') + ->where('purchase_items.refunded_amount', '>', 0) + ->groupBy( + 'purchase_items.id', + 'purchase_items.refunded_amount', + 'purchase_items.precio_unitario', + ) + ->selectRaw( + 'purchase_items.id, purchase_items.refunded_amount, purchase_items.precio_unitario, ' + .'COALESCE(SUM(refunds.amount), 0) as refund_total' + ) + ->orderBy('purchase_items.id') + ->get(); + + foreach ($items as $item) { + $legacyAmountInCents = (int) round( + ((float) $item->refunded_amount - (float) $item->refund_total) * 100 + ); + + if ($legacyAmountInCents <= 0) { + continue; + } + + $tickets = DB::table('tickets as tickets') + ->leftJoin('ticket_refunds as refunds', 'refunds.ticket_id', '=', 'tickets.id') + ->where('tickets.source_purchase_item_id', $item->id) + ->whereNotNull('tickets.refunded_at') + ->whereNull('refunds.id') + ->orderBy('tickets.refunded_at') + ->orderBy('tickets.id') + ->get(['tickets.id', 'tickets.refunded_at']); + + $ticketCount = $tickets->count(); + $unitPriceInCents = (int) round((float) $item->precio_unitario * 100); + + if ($ticketCount === 0 + || $unitPriceInCents <= 0 + || $legacyAmountInCents > $ticketCount * $unitPriceInCents) { + continue; + } + + $baseAmountInCents = intdiv($legacyAmountInCents, $ticketCount); + $remainderInCents = $legacyAmountInCents % $ticketCount; + + if ($baseAmountInCents === 0) { + continue; + } + + $refunds = $tickets->values()->map(function (object $ticket, int $index) use ( + $item, + $baseAmountInCents, + $remainderInCents, + $unitPriceInCents, + ): array { + $amountInCents = $baseAmountInCents + ($index < $remainderInCents ? 1 : 0); + + return [ + 'ticket_id' => $ticket->id, + 'purchase_item_id' => $item->id, + 'created_by_user_id' => null, + 'type' => $amountInCents === $unitPriceInCents ? 'total' : 'partial', + 'amount' => number_format($amountInCents / 100, 2, '.', ''), + 'created_at' => $ticket->refunded_at, + 'updated_at' => $ticket->refunded_at, + ]; + })->all(); + + DB::table('ticket_refunds')->insert($refunds); + } + } +}; diff --git a/database/migrations/2026_09_15_000000_create_user_event_date_change_views_table.php b/database/migrations/2026_09_15_000000_create_user_event_date_change_views_table.php new file mode 100644 index 0000000..a1695a4 --- /dev/null +++ b/database/migrations/2026_09_15_000000_create_user_event_date_change_views_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('event_date_change_id')->constrained('event_date_changes')->cascadeOnDelete(); + $table->unsignedTinyInteger('display_count')->default(0); + $table->timestamp('last_displayed_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'event_date_change_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_event_date_change_views'); + } +}; diff --git a/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php b/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php new file mode 100644 index 0000000..a512d88 --- /dev/null +++ b/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php @@ -0,0 +1,22 @@ +unsignedBigInteger('refunded_units')->default(0); + }); + } + + public function down(): void + { + Schema::table('inventories', function (Blueprint $table): void { + $table->dropColumn('refunded_units'); + }); + } +}; diff --git a/database/migrations/2026_09_16_000100_backfill_refunded_units.php b/database/migrations/2026_09_16_000100_backfill_refunded_units.php new file mode 100644 index 0000000..b31d2a7 --- /dev/null +++ b/database/migrations/2026_09_16_000100_backfill_refunded_units.php @@ -0,0 +1,16 @@ + 'You can add a maximum of :max.', 'bundle_variant_forbidden' => 'A bundle cannot have a variant.', 'empty_bundle' => 'The bundle has no components.', + 'bundle_component_unavailable' => 'The bundle contains a variant that is no longer available for sale.', 'variant_required' => 'You must select a variant for this item.', + 'variant_unavailable' => 'The selected variant was replaced or is no longer available for sale.', + 'cart_variant_unavailable' => 'The cart contains a replaced variant or one that is no longer available for sale.', 'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.', ], 'purchase' => [ diff --git a/lang/es/api.php b/lang/es/api.php index 55be341..d4fd6cc 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -34,7 +34,10 @@ return [ 'max_quantity' => 'El máximo que se puede agregar es :max.', 'bundle_variant_forbidden' => 'Un bundle no admite una variante.', 'empty_bundle' => 'El bundle no tiene componentes.', + 'bundle_component_unavailable' => 'El bundle contiene una variante que ya no está disponible para la venta.', 'variant_required' => 'Debe seleccionar una variante para este ítem.', + 'variant_unavailable' => 'La variante seleccionada fue reemplazada o ya no está disponible para la venta.', + 'cart_variant_unavailable' => 'El carrito contiene una variante reemplazada o que ya no está disponible para la venta.', 'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.', ], 'purchase' => [ diff --git a/phpunit.xml b/phpunit.xml index 0bce1ba..9963a5e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ @@ -19,7 +19,9 @@ - + + + diff --git a/postman/generate-shopit-collection.php b/postman/generate-shopit-collection.php index ad2498e..7393dee 100644 --- a/postman/generate-shopit-collection.php +++ b/postman/generate-shopit-collection.php @@ -113,6 +113,7 @@ function bodyFor(string $method, string $uri): ?array 'POST api/v1/scanner/password/reset-attempts' => ['email' => '{{scanner_email}}'], 'POST api/v1/scanner/password/reset-attempts/validate' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}'], 'POST api/v1/scanner/password/reset' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{scanner_password}}', 'password_confirmation' => '{{scanner_password}}'], + 'POST api/v1/scanner/tickets/scan' => ['data' => '{{ticket_uuid}}'], ]; if (isset($exact[$key])) { @@ -201,7 +202,7 @@ function queryFor(string $uri): array ['key' => 'page', 'value' => '1'], ['key' => 'per_page', 'value' => '20'], ], - 'api/v1/scanner/tickets' => [['key' => 'q', 'value' => '', 'disabled' => true], ['key' => 'page', 'value' => '1'], ['key' => 'per_page', 'value' => '20']], + 'api/v1/scanner/attempts' => [['key' => 'q', 'value' => '', 'disabled' => true], ['key' => 'page', 'value' => '1'], ['key' => 'per_page', 'value' => '20']], 'api/storage-test/s3/temporary-url' => [['key' => 'path', 'value' => '{{s3_path}}'], ['key' => 'expires_in_minutes', 'value' => '60']], default => [], }; diff --git a/resources/views/mail/notifications/event-date-rescheduled.blade.php b/resources/views/mail/notifications/event-date-rescheduled.blade.php new file mode 100644 index 0000000..bffd003 --- /dev/null +++ b/resources/views/mail/notifications/event-date-rescheduled.blade.php @@ -0,0 +1,14 @@ +

Tu evento fue reprogramado

+

Te informamos que la fecha de tu evento cambió.

+

+ Fecha anterior: {{ $previousDate }}
+ Nueva fecha: {{ $newDate }} +

+

Tus tickets continúan siendo válidos para la nueva fecha.

+

Tickets afectados

+
    + @foreach ($tickets as $ticket) +
  • {{ $ticket->name }} · N° de Ticket #{{ $ticket->id }}
  • + @endforeach +
+

N° de Orden #{{ $purchase->id }}

diff --git a/resources/views/mail/notifications/event-date-suspended.blade.php b/resources/views/mail/notifications/event-date-suspended.blade.php new file mode 100644 index 0000000..4aabbb4 --- /dev/null +++ b/resources/views/mail/notifications/event-date-suspended.blade.php @@ -0,0 +1,23 @@ +

Actualización sobre tu evento

+

La fecha {{ $date }} fue suspendida.

+ +@if ($disabledTickets->isNotEmpty()) +

Los siguientes tickets quedaron inhabilitados porque no tienen otra fecha disponible:

+
    + @foreach ($disabledTickets as $ticket) +
  • {{ $ticket->name }} · N° de Ticket #{{ $ticket->id }}
  • + @endforeach +
+

Para conocer las alternativas o condiciones de devolución, comunicate con la organización.

+@endif + +@if ($activeTickets->isNotEmpty()) +

Estos tickets conservan otras fechas disponibles:

+
    + @foreach ($activeTickets as $ticket) +
  • {{ $ticket->name }} · N° de Ticket #{{ $ticket->id }}
  • + @endforeach +
+@endif + +

N° de Orden #{{ $purchase->id }}

diff --git a/routes/console.php b/routes/console.php index 1d98696..3df5861 100644 --- a/routes/console.php +++ b/routes/console.php @@ -3,6 +3,7 @@ use App\Domains\Auth\Services\AdminCredentialVerifier; use App\Domains\Catalog\Services\ExpireStockReservationsService; use App\Domains\Purchase\Services\TenantTransactionResetService; +use App\Domains\Ticket\Services\BackfillRefundedUnitsService; use App\Domains\Ticket\Services\LoadTestTicketDatasetService; use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; @@ -13,6 +14,27 @@ Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); +Artisan::command('tickets:backfill-refunded-units', function (BackfillRefundedUnitsService $service): int { + try { + $summary = $service->run(); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->info(sprintf( + 'Refund backfill: %d applied, %d bundles skipped, %d inventories updated, +%d refunded_units, +%d real_stock', + $summary['refunds_applied'], + $summary['bundles_skipped'], + $summary['inventories_updated'], + $summary['refunded_units_added'], + $summary['real_stock_added'], + )); + + return self::SUCCESS; +})->purpose('Reconcile historical ticket refunds with inventory after reviewing existing refunded units'); + Artisan::command('reservations:expire', function (): void { $expired = app(ExpireStockReservationsService::class)->expireOverdue(); diff --git a/storage/framework/lsp-1ab91c4294c8e5da.php b/storage/framework/lsp-1ab91c4294c8e5da.php new file mode 100644 index 0000000..f14d849 --- /dev/null +++ b/storage/framework/lsp-1ab91c4294c8e5da.php @@ -0,0 +1,130 @@ +hasDefaultValue()) { +return ['default' => $property->getDefaultValue()]; +} + +if ($parameter?->isDefaultValueAvailable()) { +return ['default' => $parameter->getDefaultValue()]; +} + +return []; +} + +public static function formatDefaultValue(mixed $value): mixed +{ +return match (true) { +is_array($value) => 'array(...)', +$value instanceof UnitEnum => get_class($value) . '::' . $value->name, +$value instanceof Closure => 'Closure', +is_object($value) => get_class($value), +is_string($value) => var_export($value, true), +is_null($value) => 'null', +is_bool($value) => $value ? 'true' : 'false', +default => $value, +}; +} +} + +use Pest\Expectation; +use Pest\TestSuite; + +$pest = new class +{ +public function __construct() +{ +if ($this->isInstalled()) { +$this->boot(); +} +} + +public function isInstalled(): bool +{ +return class_exists(TestSuite::class); +} + +protected function boot(): void +{ +require_once base_path('vendor/pestphp/pest/overrides/Runner/TestSuiteLoader.php'); + +TestSuite::getInstance(base_path(), 'tests'); + +if (file_exists($pestFile = base_path('tests/Pest.php'))) { +require_once $pestFile; +} +} + +public function config(): ?array +{ +if (!$this->isInstalled()) { +return null; +} + +return [ +'uses' => $this->uses(), +'expectations' => $this->expectations(), +]; +} + +protected function uses(): array +{ +if (is_null($instance = TestSuite::getInstance())) { +return []; +} + +$reflection = new ReflectionProperty($instance->tests, 'uses'); +$uses = $reflection->getValue($instance->tests); + +return collect($uses)->map(function (array $use, string $path) { +[$classOrTraits] = $use; + +return [ +'path' => LspHelper::relativePath($path), +'classes' => array_values(array_filter($classOrTraits, fn ($c) => class_exists($c))), +'traits' => array_values(array_filter($classOrTraits, fn ($c) => trait_exists($c))), +]; +})->values()->all(); +} + +protected function expectations(): array +{ +$reflection = new ReflectionProperty(Expectation::class, 'extends'); +$extends = $reflection->getValue(); + +return collect($extends)->map(function (Closure $closure, string $name) { +$parameters = collect((new ReflectionFunction($closure))->getParameters()) +->map(function (ReflectionParameter $param) { +$type = $param->hasType() ? $param->getType() . ' ' : ''; + +$default = $param->isOptional() && $param->isDefaultValueAvailable() +? ' = ' . var_export($param->getDefaultValue(), true) +: ''; + +return $type . '$' . $param->getName() . $default; +}) +->join(', '); + +return compact('name', 'parameters'); +})->values()->all(); +} +}; + +echo json_encode($pest->config()); diff --git a/tests/Feature/Auth/ScannerMeControllerTest.php b/tests/Feature/Auth/ScannerMeControllerTest.php index c5ddec5..f5ec62b 100644 --- a/tests/Feature/Auth/ScannerMeControllerTest.php +++ b/tests/Feature/Auth/ScannerMeControllerTest.php @@ -8,6 +8,7 @@ use App\Domains\Authorization\Enums\PermissionCode; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Models\Permission; use App\Domains\Authorization\Models\Role; +use App\Domains\Catalog\Models\Category; use App\Domains\Menu\Models\Menu; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -85,6 +86,65 @@ class ScannerMeControllerTest extends TestCase $this->getJson('/api/v1/scanner/me')->assertForbidden(); } + public function test_it_returns_assigned_scan_categories_when_validation_is_enabled(): void + { + $this->createScannerRole(); + $tenant = $this->createTenant(); + $scanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $tenant->codigo, + ]); + $meals = Category::query()->create(['tenant_code' => $tenant->codigo, 'nombre' => 'Comidas']); + $entries = Category::query()->create(['tenant_code' => $tenant->codigo, 'nombre' => 'Entradas']); + $scanner->scanCategories()->attach([$meals->id, $entries->id]); + Sanctum::actingAs($scanner); + + $this->getJson('/api/v1/scanner/me') + ->assertOk() + ->assertJsonPath('data.user.categories.0.id', $meals->id) + ->assertJsonPath('data.user.categories.0.nombre', 'Comidas') + ->assertJsonPath('data.user.categories.1.id', $entries->id) + ->assertJsonPath('data.user.categories.1.nombre', 'Entradas'); + } + + public function test_it_omits_scan_categories_when_validation_is_disabled_or_none_are_assigned(): void + { + $this->createScannerRole(); + $tenant = $this->createTenant(); + $scanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $tenant->codigo, + ]); + Sanctum::actingAs($scanner); + + $this->getJson('/api/v1/scanner/me') + ->assertOk() + ->assertJsonMissingPath('data.user.categories'); + + $category = Category::query()->create(['tenant_code' => $tenant->codigo, 'nombre' => 'Entradas']); + $scanner->scanCategories()->attach($category); + $tenant->update(['scanner_category_validation_enabled' => false]); + + $this->getJson('/api/v1/scanner/me') + ->assertOk() + ->assertJsonMissingPath('data.user.categories'); + } + + private function createScannerRole(): Role + { + $role = Role::query()->create([ + 'codigo' => RoleCode::Scanner->value, + 'nombre' => 'Scanner', + ]); + $permission = Permission::query()->create([ + 'codigo' => PermissionCode::ScanTickets->value, + 'nombre' => 'Escanear tickets', + ]); + $role->permissions()->attach($permission->codigo); + + return $role; + } + private function createTenant(): Tenant { $logo = Attachment::query()->create([ diff --git a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php index 5d8d3a8..b466afb 100644 --- a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php +++ b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php @@ -220,6 +220,18 @@ class CatalogItemDetailControllerTest extends TestCase 'time_start' => '10:00', 'time_end' => '19:00', ]); + $rescheduledEventDate = $tenant->eventDates()->create([ + 'date' => '2026-10-08', + 'time_start' => '10:00', + 'time_end' => '19:00', + 'rescheduled_to_event_date_id' => $unusedEventDate->id, + ]); + $suspendedEventDate = $tenant->eventDates()->create([ + 'date' => '2026-10-11', + 'time_start' => '10:00', + 'time_end' => '19:00', + 'suspended_at' => now(), + ]); $item = $this->createItem($tenant, 'Entry'); $attribute = Attribute::query()->create([ 'tenant_codigo' => $tenant->codigo, @@ -245,6 +257,14 @@ class CatalogItemDetailControllerTest extends TestCase ->assertJsonPath('data.attributes.0.options.0.validity_time.type', 'fixed_window') ->assertJsonPath('data.attributes.0.options.1.id', $unusedEventDate->id) ->assertJsonPath('data.attributes.0.options.1.value', (string) $unusedEventDate->id) + ->assertJsonMissing([ + 'id' => $rescheduledEventDate->id, + 'value' => (string) $rescheduledEventDate->id, + ]) + ->assertJsonMissing([ + 'id' => $suspendedEventDate->id, + 'value' => (string) $suspendedEventDate->id, + ]) ->assertJsonPath('data.variants.0.values.event_date.value', (string) $eventDate->id) ->assertJsonPath( 'data.variants.0.values.event_date.label', diff --git a/tests/Feature/Catalog/CatalogSchemaTest.php b/tests/Feature/Catalog/CatalogSchemaTest.php index 68b2508..94cb8a2 100644 --- a/tests/Feature/Catalog/CatalogSchemaTest.php +++ b/tests/Feature/Catalog/CatalogSchemaTest.php @@ -200,10 +200,12 @@ class CatalogSchemaTest extends TestCase ]); } - public function test_variants_can_override_catalog_item_use_dates(): void + public function test_variants_support_event_dates_and_commercial_replacements(): void { $this->assertTrue(Schema::hasColumns('variantes', [ 'event_date_id', + 'replaced_by_variant_id', + 'sales_disabled_at', ])); } diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index cdf9a98..5c4f79a 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -6,13 +6,27 @@ use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; +use App\Domains\Cart\Models\Cart; +use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\StockReservation; +use App\Domains\Catalog\Models\StockReservationLine; +use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Events\EventDateRescheduled; +use App\Domains\Event\Events\EventDateSuspended; +use App\Domains\Purchase\Services\Checkout\CatalogSelectionResolver; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Enums\ValidityTimeType; +use App\Domains\Ticket\Models\Ticket; use Database\Seeders\AuthorizationSeeder; use Database\Seeders\SocialMediaSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use Laravel\Sanctum\Sanctum; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; class AdminAppEventControllerTest extends TestCase @@ -34,9 +48,10 @@ class AdminAppEventControllerTest extends TestCase { $this->getJson('/api/v1/adminapp/tenant/event')->assertUnauthorized(); $this->putJson('/api/v1/adminapp/tenant/event', $this->eventPayload())->assertUnauthorized(); + $this->postJson('/api/v1/adminapp/tenant/event-dates', $this->datePayload())->assertUnauthorized(); } - public function test_an_adminapp_user_can_create_the_active_event_and_contact_information(): void + public function test_an_adminapp_user_can_update_event_and_contact_information_without_synchronizing_dates(): void { $tenant = $this->createTenant('acme'); Sanctum::actingAs($this->createAdminAppUser($tenant)); @@ -45,10 +60,11 @@ class AdminAppEventControllerTest extends TestCase ->assertOk() ->assertJsonPath('data.title', 'Festival Acme') ->assertJsonPath('data.location', 'Predio Ferial, Rosario') - ->assertJsonPath('data.dates.0.date', '2026-10-09') - ->assertJsonPath('data.dates.0.validity_time.type', 'fixed_window') - ->assertJsonPath('data.dates.0.start_time', '09:00') - ->assertJsonPath('data.dates.0.end_time', '18:30') + ->assertJsonPath('data.allow_ticket_refund', true) + ->assertJsonPath('data.allow_ticket_total_refund', true) + ->assertJsonPath('data.allow_ticket_partial_refund', true) + ->assertJsonPath('data.ticket_partial_refund_percentage', '25.50') + ->assertJsonCount(0, 'data.dates') ->assertJsonPath('data.contact.whatsapp_url', 'https://wa.me/5493415550101') ->assertJsonPath('data.contact.instagram_url', 'https://instagram.com/acme') ->assertJsonPath('data.contact.facebook_url', null); @@ -58,25 +74,13 @@ class AdminAppEventControllerTest extends TestCase 'id' => $tenant->id, 'event_title' => 'Festival Acme', 'event_location' => 'Predio Ferial, Rosario', - 'event_date_text' => '9 de Octubre 2026', + 'event_date_text' => null, + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.50, ]); - $this->assertDatabaseHas('event_dates', [ - 'tenant_code' => $tenant->codigo, - 'date' => '2026-10-09', - 'time_start' => '09:00:00', - 'time_end' => '18:30:00', - ]); - $eventDate = $tenant->eventDates()->with('validityTime')->sole(); - $this->assertSame($eventDate->validity_time_id, $response->json('data.dates.0.validity_time_id')); - $this->assertSame(ValidityTimeType::FixedWindow, $eventDate->validityTime->type); - $this->assertSame( - '2026-10-09 09:00:00', - $eventDate->validityTime->fixed_starts_at->format('Y-m-d H:i:s'), - ); - $this->assertSame( - '2026-10-09 18:30:00', - $eventDate->validityTime->fixed_expires_at->format('Y-m-d H:i:s'), - ); + $this->assertDatabaseCount('event_dates', 0); $this->assertDatabaseHas('tenant_social_media', [ 'tenant_code' => $tenant->codigo, 'social_media_code' => 'whatsapp', @@ -84,6 +88,56 @@ class AdminAppEventControllerTest extends TestCase ]); } + public function test_disabling_refunds_preserves_the_configured_types_and_percentage(): void + { + $tenant = $this->createTenant('acme'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 35.50, + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $payload = $this->eventPayload(); + $payload['allow_ticket_refund'] = false; + $payload['ticket_partial_refund_percentage'] = 35.50; + + $this->putJson('/api/v1/adminapp/tenant/event', $payload) + ->assertOk() + ->assertJsonPath('data.allow_ticket_refund', false) + ->assertJsonPath('data.allow_ticket_total_refund', true) + ->assertJsonPath('data.allow_ticket_partial_refund', true) + ->assertJsonPath('data.ticket_partial_refund_percentage', '35.50'); + + $tenant->refresh(); + $this->assertFalse($tenant->allow_refund()); + $this->assertTrue($tenant->allow_ticket_total_refund); + $this->assertTrue($tenant->allow_ticket_partial_refund); + $this->assertSame('35.50', $tenant->ticket_partial_refund_percentage); + } + + public function test_enabled_refunds_require_a_type_and_a_valid_partial_percentage(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $payload = $this->eventPayload(); + $payload['allow_ticket_total_refund'] = false; + $payload['allow_ticket_partial_refund'] = false; + + $this->putJson('/api/v1/adminapp/tenant/event', $payload) + ->assertUnprocessable() + ->assertJsonValidationErrors('allow_ticket_refund'); + + $payload['allow_ticket_partial_refund'] = true; + $payload['ticket_partial_refund_percentage'] = 0; + + $this->putJson('/api/v1/adminapp/tenant/event', $payload) + ->assertUnprocessable() + ->assertJsonValidationErrors('ticket_partial_refund_percentage'); + } + public function test_an_adminapp_user_can_read_only_its_tenant_active_event(): void { $tenant = $this->createTenant('acme'); @@ -98,6 +152,52 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonMissing(['title' => 'Other Event']); } + public function test_dates_are_grouped_by_their_final_destination_and_ordered_by_active_date(): void + { + $tenant = $this->createActiveEvent($this->createTenant('acme'), 'Acme Event'); + $source13 = $tenant->eventDates()->create([ + 'date' => '2026-10-13', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $source22 = $tenant->eventDates()->create([ + 'date' => '2026-10-22', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $middle24 = $tenant->eventDates()->create([ + 'date' => '2026-10-24', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $active25 = $tenant->eventDates()->create([ + 'date' => '2026-10-25', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $destination30 = $tenant->eventDates()->create([ + 'date' => '2026-10-30', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $source13->update(['rescheduled_to_event_date_id' => $destination30->id]); + $source22->update(['rescheduled_to_event_date_id' => $middle24->id]); + $middle24->update(['rescheduled_to_event_date_id' => $destination30->id]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/event') + ->assertOk() + ->assertJsonCount(2, 'data.dates') + ->assertJsonPath('data.dates.0.id', $active25->id) + ->assertJsonCount(0, 'data.dates.0.rescheduled_dates') + ->assertJsonPath('data.dates.1.id', $destination30->id) + ->assertJsonPath('data.dates.1.rescheduled_dates.0.id', $source13->id) + ->assertJsonPath( + 'data.dates.1.rescheduled_dates.0.rescheduled_to_event_date_id', + $destination30->id, + ) + ->assertJsonPath('data.dates.1.rescheduled_dates.1.id', $source22->id) + ->assertJsonPath( + 'data.dates.1.rescheduled_dates.1.rescheduled_to_event_date_id', + $destination30->id, + ) + ->assertJsonPath('data.dates.1.rescheduled_dates.2.id', $middle24->id) + ->assertJsonPath( + 'data.dates.1.rescheduled_dates.2.rescheduled_to_event_date_id', + $destination30->id, + ); + } + public function test_reading_a_tenant_without_event_configuration_returns_empty_values(): void { $tenant = $this->createTenant('acme'); @@ -109,7 +209,7 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonCount(0, 'data.dates'); } - public function test_updating_reuses_the_active_event_and_synchronizes_dates_and_contact(): void + public function test_updating_event_does_not_change_or_delete_existing_dates(): void { $tenant = $this->createTenant('acme'); $eventTenant = $this->createActiveEvent($tenant, 'Old Event'); @@ -124,7 +224,6 @@ class AdminAppEventControllerTest extends TestCase 'time_end' => '12:00', ]); $firstValidityTimeId = $firstDate->validity_time_id; - $removedValidityTimeId = $removedDate->validity_time_id; $tenant->socialMedia()->attach('facebook', [ 'url' => 'https://facebook.com/old', 'orden' => 2, @@ -136,12 +235,6 @@ class AdminAppEventControllerTest extends TestCase Sanctum::actingAs($this->createAdminAppUser($tenant)); $payload = $this->eventPayload(); - $payload['dates'] = [[ - 'date' => '2026-11-15', - 'start_time' => '10:00', - 'end_time' => '20:00', - ]]; - $this->putJson('/api/v1/adminapp/tenant/event', $payload) ->assertOk() ->assertJsonPath('data.id', $tenant->id) @@ -151,17 +244,16 @@ class AdminAppEventControllerTest extends TestCase $this->assertDatabaseHas('event_dates', [ 'id' => $firstDate->id, 'validity_time_id' => $firstValidityTimeId, - 'date' => '2026-11-15', + 'date' => '2026-10-01', ]); $this->assertDatabaseHas('validity_times', [ 'id' => $firstValidityTimeId, 'type' => ValidityTimeType::FixedWindow->value, - 'fixed_starts_at' => '2026-11-15 10:00:00', - 'fixed_expires_at' => '2026-11-15 20:00:00', + 'fixed_starts_at' => '2026-10-01 08:00:00', + 'fixed_expires_at' => '2026-10-01 12:00:00', ]); - $this->assertDatabaseMissing('event_dates', ['id' => $removedDate->id]); - $this->assertDatabaseMissing('validity_times', ['id' => $removedValidityTimeId]); - $this->assertSame('15 de Noviembre 2026', $tenant->fresh()->event_date_text); + $this->assertDatabaseHas('event_dates', ['id' => $removedDate->id]); + $this->assertSame('1 y 2 de Octubre 2026', $tenant->fresh()->event_date_text); $this->assertDatabaseMissing('tenant_social_media', [ 'tenant_code' => $tenant->codigo, 'social_media_code' => 'facebook', @@ -173,20 +265,17 @@ class AdminAppEventControllerTest extends TestCase ]); } - public function test_updating_event_dates_recalculates_the_tenant_date_text(): void + public function test_dates_are_created_independently_and_recalculate_the_tenant_date_text(): void { $tenant = $this->createTenant('acme'); Sanctum::actingAs($this->createAdminAppUser($tenant)); - $payload = $this->eventPayload(); - $payload['dates'] = collect([9, 10, 11, 12]) - ->map(fn (int $day): array => [ + foreach ([9, 10, 11, 12] as $day) { + $this->postJson('/api/v1/adminapp/tenant/event-dates', [ 'date' => sprintf('2026-10-%02d', $day), 'start_time' => '09:00', 'end_time' => '18:30', - ]) - ->all(); - - $this->putJson('/api/v1/adminapp/tenant/event', $payload)->assertOk(); + ])->assertCreated()->assertJsonPath('data.status', 'scheduled'); + } $this->assertSame( '9, 10, 11 y 12 de Octubre 2026', @@ -194,7 +283,349 @@ class AdminAppEventControllerTest extends TestCase ); } - public function test_update_validates_event_dates_and_contact_urls(): void + public function test_rescheduling_reuses_an_existing_date_and_tickets_resolve_its_validity(): void + { + Event::fake([EventDateRescheduled::class]); + $tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme'); + $admin = $this->createAdminAppUser($tenant); + $original = $tenant->eventDates()->create([ + 'date' => '2027-10-09', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $destination = $tenant->eventDates()->create([ + 'date' => '2027-10-20', + 'time_start' => '11:00', + 'time_end' => '20:00', + ]); + $variant = $this->createVariant($tenant, $original->id); + $variant->inventory()->update(['real_stock' => 5, 'reserved_stock' => 2]); + $reservation = StockReservation::query()->create([ + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => now()->addHour(), + ]); + $reservationLine = StockReservationLine::query()->create([ + 'stock_reservation_id' => $reservation->id, + 'inventory_id' => $variant->inventory_id, + 'quantity' => 2, + 'tracks_inventory' => true, + ]); + $ticket = $this->createTicket($tenant, $admin, $variant); + Sanctum::actingAs($admin); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$original->id}/reschedule", [ + 'date' => '2027-10-20', + ]) + ->assertOk() + ->assertJsonPath('data.status', 'rescheduled') + ->assertJsonPath('data.rescheduled_to_event_date_id', $destination->id); + + Event::assertDispatched(EventDateRescheduled::class, function (EventDateRescheduled $event) use ($tenant, $original, $destination): bool { + return $event->tenantCode === $tenant->codigo + && $event->sourceEventDateId === $original->id + && $event->destinationEventDateId === $destination->id + && $event->previousDate === '09/10/2027' + && $event->newDate === '20/10/2027' + && $event->purchaseTickets === []; + }); + + $this->assertDatabaseCount('event_dates', 2); + $this->assertDatabaseHas('event_date_changes', [ + 'tenant_code' => $tenant->codigo, + 'change_type' => 'rescheduled', + 'source_event_date_id' => $original->id, + 'destination_event_date_id' => $destination->id, + 'created_by_user_id' => $admin->id, + 'previous_date' => '2027-10-09', + 'new_date' => '2027-10-20', + ]); + $variant->refresh(); + $replacement = $variant->replacement()->firstOrFail(); + $this->assertSame($original->id, $variant->event_date_id); + $this->assertSame($destination->id, $replacement->event_date_id); + $this->assertNotSame($variant->inventory_id, $replacement->inventory_id); + $this->assertSame(5, $variant->inventory->fresh()->real_stock); + $this->assertSame(0, $variant->inventory->fresh()->reserved_stock); + $this->assertSame(5, $replacement->inventory->real_stock); + $this->assertSame(2, $replacement->inventory->reserved_stock); + $this->assertSame($replacement->inventory_id, $reservationLine->fresh()->inventory_id); + $this->assertNotNull($variant->sales_disabled_at); + $this->assertSame($replacement->id, $variant->replaced_by_variant_id); + $this->assertSame( + [$replacement->id], + $variant->catalogItem->fresh(['variants.inventory'])->visibleVariants()->modelKeys(), + ); + $this->assertSame($variant->id, $ticket->fresh()->source_variant_id); + + try { + app(CatalogSelectionResolver::class)->resolve( + $tenant, + $variant->catalog_item_id, + $variant->id, + 'direct_items.0', + ); + $this->fail('The historical variant should not be sellable.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('direct_items.0.variant_id', $exception->errors()); + } + + $this->assertSame('20 de Octubre 2027', $tenant->fresh()->event_date_text); + $this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F') + ->assertOk() + ->assertJsonCount(1, 'data.event.dates') + ->assertJsonPath('data.event.dates.0.id', $destination->id) + ->assertJsonPath('data.event.dates.0.info_text', '09/10/2027 se reprogramó para este día.') + ->assertJsonPath('data.event.dates.0.isCanceled', false) + ->assertJsonMissingPath('data.event.date_changes') + ->assertJsonMissingPath('data.event.date_notices') + ->assertJsonPath('data.event_date_text', '20 de Octubre 2027'); + $this->assertSame( + '2027-10-20 11:00:00', + $ticket->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), + ); + $this->assertDatabaseHas('validity_times', [ + 'id' => $original->validity_time_id, + 'fixed_starts_at' => '2027-10-09 09:00:00', + ]); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$destination->id}/reschedule", [ + 'date' => '2027-10-25', + ])->assertOk()->assertJsonPath('data.status', 'rescheduled'); + + $this->assertDatabaseCount('event_dates', 3); + $this->assertDatabaseCount('event_date_changes', 2); + $this->assertDatabaseHas('event_date_changes', [ + 'tenant_code' => $tenant->codigo, + 'change_type' => 'rescheduled', + 'source_event_date_id' => $destination->id, + 'created_by_user_id' => $admin->id, + 'previous_date' => '2027-10-20', + 'new_date' => '2027-10-25', + ]); + $replacement->refresh(); + $latestReplacement = $replacement->replacement()->firstOrFail(); + $this->assertNotSame($replacement->inventory_id, $latestReplacement->inventory_id); + $this->assertSame(5, $latestReplacement->inventory->real_stock); + $this->assertSame('2027-10-25', $latestReplacement->eventDate->date->format('Y-m-d')); + $this->assertFalse($replacement->isSellable()); + $this->assertTrue($latestReplacement->isSellable()); + $this->assertSame('25 de Octubre 2027', $tenant->fresh()->event_date_text); + $this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F') + ->assertOk() + ->assertJsonCount(1, 'data.event.dates') + ->assertJsonPath('data.event.dates.0.date', '2027-10-25') + ->assertJsonMissingPath('data.event.date_changes') + ->assertJsonMissingPath('data.event.date_notices') + ->assertJsonPath('data.event_date_text', '25 de Octubre 2027'); + $this->assertSame( + '2027-10-25 11:00:00', + $ticket->fresh()->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), + ); + } + + public static function cartInvalidatingDateChanges(): array + { + return [ + 'reschedule' => ['reschedule', ['date' => '2027-10-20'], 'event_date_rescheduled'], + 'suspend' => ['suspend', [], 'event_date_suspended'], + ]; + } + + #[DataProvider('cartInvalidatingDateChanges')] + public function test_date_changes_invalidate_reserved_carts(string $action, array $payload, string $reason): void + { + Event::fake([EventDateRescheduled::class, EventDateSuspended::class]); + $tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme'); + $source = $tenant->eventDates()->create([ + 'date' => '2027-10-09', 'time_start' => '09:00', 'time_end' => '18:30', + ]); + $variant = $this->createVariant($tenant, $source->id); + $variant->inventory->update(['real_stock' => 5, 'reserved_stock' => 2]); + $reservation = StockReservation::query()->create([ + 'status' => StockReservation::STATUS_ACTIVE, 'expires_at' => now()->addHour(), + ]); + StockReservationLine::query()->create([ + 'stock_reservation_id' => $reservation->id, 'inventory_id' => $variant->inventory_id, + 'quantity' => 2, 'tracks_inventory' => true, + ]); + $admin = $this->createAdminAppUser($tenant); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, 'user_id' => $admin->id, + 'status' => Cart::STATUS_ACTIVE, 'origin' => Cart::ORIGIN_USER, + 'current_stock_reservation_id' => $reservation->id, + ]); + $cart->items()->create([ + 'catalog_item_id' => $variant->catalog_item_id, 'variant_id' => $variant->id, 'cantidad' => 2, + ]); + Sanctum::actingAs($admin); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$source->id}/{$action}", $payload)->assertOk(); + + $this->assertSame(Cart::STATUS_EXPIRED, $cart->fresh()->status); + $this->assertSame(StockReservation::STATUS_RELEASED, $reservation->fresh()->status); + $this->assertSame($reason, $reservation->fresh()->release_reason); + $this->assertSame(0, $variant->fresh()->inventory->reserved_stock); + if ($action === 'reschedule') { + $this->assertSame(0, $variant->fresh()->replacement->inventory->reserved_stock); + } + $this->getJson('/api/tenants/acme/cart')->assertOk()->assertJsonCount(0, 'data.items'); + } + + public function test_rescheduling_reuses_an_equivalent_destination_variant(): void + { + Event::fake([EventDateRescheduled::class]); + $tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme'); + $original = $tenant->eventDates()->create([ + 'date' => '2027-10-09', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $destination = $tenant->eventDates()->create([ + 'date' => '2027-10-20', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $historicalVariant = $this->createVariant($tenant, $original->id); + $destinationVariant = Variant::query()->create([ + 'catalog_item_id' => $historicalVariant->catalog_item_id, + 'inventory_id' => Inventory::query()->create(['real_stock' => 5])->id, + 'event_date_id' => $destination->id, + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$original->id}/reschedule", [ + 'date' => '2027-10-20', + ])->assertOk(); + + $this->assertSame(2, Variant::query()->count()); + $this->assertSame( + $destinationVariant->id, + $historicalVariant->fresh()->replaced_by_variant_id, + ); + $this->assertSame($original->id, $historicalVariant->fresh()->event_date_id); + $this->assertTrue($destinationVariant->fresh()->isSellable()); + } + + public function test_suspending_disables_only_tickets_without_another_usable_date(): void + { + Event::fake([EventDateSuspended::class]); + $tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme'); + $admin = $this->createAdminAppUser($tenant); + $suspendedDate = $tenant->eventDates()->create([ + 'date' => '2027-10-09', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $otherDate = $tenant->eventDates()->create([ + 'date' => '2027-10-10', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $singleDateVariant = $this->createVariant($tenant, $suspendedDate->id); + $multipleDateVariant = $this->createVariant($tenant); + $multipleDateVariant->eventDates()->sync([$suspendedDate->id, $otherDate->id]); + $remainingDateVariant = Variant::query()->create([ + 'catalog_item_id' => $multipleDateVariant->catalog_item_id, + 'inventory_id' => Inventory::query()->create()->id, + 'event_date_id' => $otherDate->id, + ]); + $singleDateVariant->inventory->update(['real_stock' => 5]); + $multipleDateVariant->inventory->update(['real_stock' => 5]); + $singleDateTicket = $this->createTicket($tenant, $admin, $singleDateVariant); + $multipleDateTicket = $this->createTicket($tenant, $admin, $multipleDateVariant); + Sanctum::actingAs($admin); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$suspendedDate->id}/suspend") + ->assertOk() + ->assertJsonPath('data.status', 'suspended') + ->assertJsonPath('data.suspended_at', fn ($value) => is_string($value)); + + Event::assertDispatched(EventDateSuspended::class, function (EventDateSuspended $event) use ($tenant, $suspendedDate): bool { + return $event->tenantCode === $tenant->codigo + && $event->eventDateId === $suspendedDate->id + && $event->date === '09/10/2027' + && $event->purchaseTickets === []; + }); + + $this->assertDatabaseHas('event_date_changes', [ + 'tenant_code' => $tenant->codigo, + 'change_type' => 'suspended', + 'source_event_date_id' => $suspendedDate->id, + 'destination_event_date_id' => null, + 'created_by_user_id' => $admin->id, + 'previous_date' => '2027-10-09', + 'new_date' => null, + ]); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$suspendedDate->id}/suspend") + ->assertOk(); + $this->assertDatabaseCount('event_date_changes', 1); + + $this->assertNotNull($singleDateTicket->fresh()->disabled_at); + $this->assertNull($multipleDateTicket->fresh()->disabled_at); + $this->assertNotNull($singleDateVariant->fresh()->sales_disabled_at); + $this->assertNotNull($multipleDateVariant->fresh()->sales_disabled_at); + $replacement = $multipleDateVariant->fresh()->replacement; + $this->assertNotNull($replacement); + $this->assertSame($remainingDateVariant->id, $replacement->id); + $this->assertTrue($replacement->isSellable()); + $this->assertSame([$otherDate->id], $replacement->selectedEventDates()->pluck('id')->all()); + $this->assertSame(5, $replacement->inventory->fresh()->availableStock()); + $this->assertTrue(CatalogItem::query()->whereKey($multipleDateVariant->catalog_item_id)->whereAvailable()->exists()); + $this->assertFalse(CatalogItem::query()->whereKey($singleDateVariant->catalog_item_id)->whereAvailable()->exists()); + $this->assertSame('10 de Octubre 2027', $tenant->fresh()->event_date_text); + $this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F') + ->assertOk() + ->assertJsonCount(2, 'data.event.dates') + ->assertJsonPath('data.event.dates.0.id', $suspendedDate->id) + ->assertJsonPath('data.event.dates.0.info_text', 'Esta fecha fue cancelada.') + ->assertJsonPath('data.event.dates.0.isCanceled', true) + ->assertJsonPath('data.event.dates.1.id', $otherDate->id) + ->assertJsonPath('data.event.dates.1.info_text', null) + ->assertJsonPath('data.event.dates.1.isCanceled', false) + ->assertJsonMissingPath('data.event.date_changes') + ->assertJsonMissingPath('data.event.date_notices') + ->assertJsonPath('data.event_date_text', '10 de Octubre 2027'); + $this->assertSame( + '2027-10-10 09:00:00', + $multipleDateTicket->fresh()->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), + ); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$otherDate->id}/suspend") + ->assertOk(); + $this->assertFalse(CatalogItem::query()->whereKey($multipleDateVariant->catalog_item_id)->whereAvailable()->exists()); + } + + public function test_suspending_a_reschedule_destination_disables_tickets_from_predecessor_dates(): void + { + $tenant = $this->createTenant('acme'); + $admin = $this->createAdminAppUser($tenant); + $original = $tenant->eventDates()->create([ + 'date' => '2027-10-09', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $destination = $tenant->eventDates()->create([ + 'date' => '2027-10-20', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $original->update(['rescheduled_to_event_date_id' => $destination->id]); + $ticket = $this->createTicket( + $tenant, + $admin, + $this->createVariant($tenant, $original->id), + ); + Sanctum::actingAs($admin); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$destination->id}/suspend") + ->assertOk(); + + $this->assertNotNull($ticket->fresh()->disabled_at); + $this->assertFalse($ticket->fresh()->resolvedValidity()->isResolvable); + } + + public function test_update_and_date_creation_validate_their_own_payloads(): void { $tenant = $this->createTenant('acme'); Sanctum::actingAs($this->createAdminAppUser($tenant)); @@ -202,10 +633,6 @@ class AdminAppEventControllerTest extends TestCase $this->putJson('/api/v1/adminapp/tenant/event', [ 'title' => '', 'location' => '', - 'dates' => [ - ['date' => '09/10/2026', 'start_time' => '9am', 'end_time' => '18:00'], - ['date' => '09/10/2026', 'start_time' => '09:00', 'end_time' => '18:00'], - ], 'contact' => [ 'whatsapp_url' => 'not-a-url', 'instagram_url' => null, @@ -216,12 +643,15 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonValidationErrors([ 'title', 'location', - 'dates.0.date', - 'dates.0.start_time', - 'dates.1.date', 'contact.whatsapp_url', ]); + $this->postJson('/api/v1/adminapp/tenant/event-dates', [ + 'date' => '09/10/2026', + 'start_time' => '9am', + 'end_time' => '18:00', + ])->assertUnprocessable()->assertJsonValidationErrors(['date', 'start_time']); + $this->assertNull($tenant->fresh()->event_title); } @@ -273,11 +703,10 @@ class AdminAppEventControllerTest extends TestCase return [ 'title' => 'Festival Acme', 'location' => 'Predio Ferial, Rosario', - 'dates' => [[ - 'date' => '2026-10-09', - 'start_time' => '09:00', - 'end_time' => '18:30', - ]], + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.50, 'contact' => [ 'whatsapp_url' => 'https://wa.me/5493415550101', 'instagram_url' => 'https://instagram.com/acme', @@ -286,6 +715,43 @@ class AdminAppEventControllerTest extends TestCase ]; } + /** @return array{date: string, start_time: string, end_time: string} */ + private function datePayload(): array + { + return [ + 'date' => '2026-10-09', + 'start_time' => '09:00', + 'end_time' => '18:30', + ]; + } + + private function createVariant(Tenant $tenant, ?int $eventDateId = null): Variant + { + $item = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'item-'.Str::uuid(), + 'nombre' => 'Entrada', + 'precio' => '1000.00', + ]); + + return Variant::query()->create([ + 'catalog_item_id' => $item->id, + 'inventory_id' => Inventory::query()->create()->id, + 'event_date_id' => $eventDateId, + ]); + } + + private function createTicket(Tenant $tenant, User $user, Variant $variant): Ticket + { + return Ticket::query()->create([ + 'tenant_code' => $tenant->codigo, + 'ticket' => (string) Str::uuid(), + 'user_id' => $user->id, + 'source_catalog_item_id' => $variant->catalog_item_id, + 'source_variant_id' => $variant->id, + ]); + } + private function createTenant(string $code): Tenant { $headerLogo = $this->createAttachment("{$code}-header"); diff --git a/tests/Feature/Event/EventDateNoticeControllerTest.php b/tests/Feature/Event/EventDateNoticeControllerTest.php new file mode 100644 index 0000000..77e3f38 --- /dev/null +++ b/tests/Feature/Event/EventDateNoticeControllerTest.php @@ -0,0 +1,145 @@ +seed(AuthorizationSeeder::class); + } + + public function test_authentication_is_required_to_claim_notices(): void + { + $tenant = $this->createTenant('acme'); + + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertUnauthorized(); + } + + public function test_changes_are_grouped_dynamically_for_each_users_pending_history(): void + { + $tenant = $this->createTenant('acme'); + $userA = $this->createUser($tenant); + + $rescheduled = collect([ + $this->createChange($tenant, EventDateChangeType::Rescheduled, '2027-10-01', '2027-10-11'), + $this->createChange($tenant, EventDateChangeType::Rescheduled, '2027-10-02', '2027-10-12'), + ]); + $suspended = collect([ + $this->createChange($tenant, EventDateChangeType::Suspended, '2027-10-03'), + $this->createChange($tenant, EventDateChangeType::Suspended, '2027-10-04'), + ]); + + Sanctum::actingAs($userA); + + for ($display = 1; $display <= 3; $display++) { + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.type', 'suspended') + ->assertJsonPath('data.0.change_ids', $suspended->modelKeys()) + ->assertJsonPath('data.1.type', 'rescheduled') + ->assertJsonPath('data.1.change_ids', $rescheduled->modelKeys()); + } + + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertOk() + ->assertJsonCount(0, 'data'); + + $latestReschedule = $this->createChange( + $tenant, + EventDateChangeType::Rescheduled, + '2027-10-05', + '2027-10-15', + ); + + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.type', 'rescheduled') + ->assertJsonPath('data.0.change_ids', [$latestReschedule->id]) + ->assertJsonPath('data.0.title', 'FECHA REPROGRAMADA!'); + + $userB = $this->createUser($tenant); + Sanctum::actingAs($userB); + + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.type', 'suspended') + ->assertJsonPath('data.0.change_ids', $suspended->modelKeys()) + ->assertJsonPath('data.1.type', 'rescheduled') + ->assertJsonPath( + 'data.1.change_ids', + [...$rescheduled->modelKeys(), $latestReschedule->id], + ) + ->assertJsonPath('data.1.title', 'FECHAS REPROGRAMADAS!'); + + foreach ([...$rescheduled, ...$suspended] as $change) { + $this->assertDatabaseHas('user_event_date_change_views', [ + 'user_id' => $userA->id, + 'event_date_change_id' => $change->id, + 'display_count' => 3, + ]); + } + + $this->assertDatabaseHas('user_event_date_change_views', [ + 'user_id' => $userA->id, + 'event_date_change_id' => $latestReschedule->id, + 'display_count' => 1, + ]); + $this->assertDatabaseCount('user_event_date_change_views', 9); + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + ]); + } + + private function createUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function createChange( + Tenant $tenant, + EventDateChangeType $type, + string $previousDate, + ?string $newDate = null, + ): EventDateChange { + return EventDateChange::query()->create([ + 'tenant_code' => $tenant->codigo, + 'change_type' => $type, + 'previous_date' => $previousDate, + 'new_date' => $newDate, + ]); + } +} diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index 9b084f4..0a1249b 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -7,6 +7,7 @@ use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; +use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Menu\Models\Menu; use App\Domains\Shared\Enums\FieldType; @@ -221,6 +222,66 @@ class EntryControllerTest extends TestCase ]); } + public function test_it_reads_and_edits_the_latest_replacement_even_when_sales_are_disabled(): void + { + $tenant = $this->createFiestaTenant(); + $this->createEventDateAttribute($tenant); + $date = $tenant->eventDates()->create([ + 'date' => '2027-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $otherDate = $tenant->eventDates()->create([ + 'date' => '2027-10-10', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + $entryId = $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [[ + 'title' => 'Abono', + 'event_date_ids' => [$date->id], + 'stock' => 10, + 'price' => 100, + ]], + ])->assertOk()->json('data.0.id'); + + $original = Variant::query()->where('catalog_item_id', $entryId)->sole(); + $intermediate = $original->replicate(); + $intermediate->save(); + $replacement = $original->replicate(); + $replacement->inventory_id = Inventory::query()->create(['real_stock' => 20])->id; + $replacement->event_date_id = $otherDate->id; + $replacement->sales_disabled_at = now(); + $replacement->save(); + $replacement->eventDates()->sync([$otherDate->id]); + $original->update(['replaced_by_variant_id' => $intermediate->id, 'sales_disabled_at' => now()]); + $intermediate->update(['replaced_by_variant_id' => $replacement->id, 'sales_disabled_at' => now()]); + + $this->getJson('/api/v1/adminapp/tenant/entries') + ->assertOk() + ->assertJsonPath('data.0.stock', 20) + ->assertJsonPath('data.0.event_date_ids', [$otherDate->id]); + + $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [[ + 'id' => $entryId, + 'title' => 'Abono editado', + 'event_date_ids' => [$date->id, $otherDate->id], + 'stock' => 30, + 'price' => 200, + ]], + ])->assertOk() + ->assertJsonPath('data.0.stock', 30) + ->assertJsonPath('data.0.event_date_ids', [$date->id, $otherDate->id]); + + $this->assertSame(10, $original->fresh()->inventory->real_stock); + $this->assertSame([$date->id], $original->fresh()->selectedEventDates()->pluck('id')->all()); + $this->assertSame(30, $replacement->fresh()->inventory->real_stock); + $this->assertNotNull($replacement->fresh()->sales_disabled_at); + $this->assertDatabaseCount('variantes', 3); + } + public function test_it_deletes_an_entry_and_its_inventory(): void { $tenant = $this->createFiestaTenant(); diff --git a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php index f03dac5..3bda7f1 100644 --- a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php @@ -7,13 +7,16 @@ use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; +use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Services\EventService; use App\Domains\Menu\Models\Menu; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -32,6 +35,13 @@ class FoodControllerTest extends TestCase ]); } + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + public function test_authentication_is_required(): void { $this->postJson('/api/v1/adminapp/tenant/foods', ['variants' => []]) @@ -142,6 +152,118 @@ class FoodControllerTest extends TestCase ]); } + public function test_it_separates_current_and_historical_food_variants(): void + { + Carbon::setTestNow('2026-09-01 12:00:00'); + [$tenant, $rescheduledDate, $suspendedDate] = $this->configuredTenant(); + $completedDate = $tenant->eventDates()->create([ + 'date' => '2026-10-11', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $activeDate = $tenant->eventDates()->create([ + 'date' => '2026-10-21', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($rescheduledDate->id, 'Almuerzo', 'Comedor', 100, 10000), + $this->variantPayload($suspendedDate->id, 'Cena', 'Vianda', 90, 9000), + $this->variantPayload($completedDate->id, 'Cena', 'Comedor', 80, 8000), + $this->variantPayload($activeDate->id, 'Almuerzo', 'Vianda', 70, 7000), + ], + ])->assertOk(); + + $eventService = app(EventService::class); + $eventService->rescheduleDateForTenant( + $tenant, + $rescheduledDate, + ['date' => '2026-10-20'], + $admin, + ); + $eventService->suspendDateForTenant($tenant, $suspendedDate, $admin); + + Carbon::setTestNow('2026-10-15 12:00:00'); + $response = $this->getJson('/api/v1/adminapp/tenant/foods')->assertOk(); + + $response->assertJsonCount(2, 'data.variants')->assertJsonCount(3, 'data.history'); + $history = collect($response->json('data.history')); + + $rescheduled = $history->firstWhere('status', 'rescheduled'); + $this->assertSame('REPROGRAMADA', $rescheduled['status_text']); + $this->assertSame('2026-10-09', $rescheduled['event_date']); + $this->assertSame('2026-10-20', $rescheduled['replacement_event_date']); + $this->assertCount(1, $rescheduled['variants']); + + $suspended = $history->firstWhere('status', 'suspended'); + $this->assertSame('CANCELADA', $suspended['status_text']); + $this->assertNull($suspended['replacement_event_date']); + + $completed = $history->firstWhere('status', 'completed'); + $this->assertSame('FINALIZADA', $completed['status_text']); + $this->assertSame('2026-10-11', $completed['event_date']); + $this->assertNull($completed['replacement_event_date']); + + } + + public function test_it_updates_only_the_stock_of_historical_food_variants(): void + { + [$tenant, $historicalDate, $activeDate] = $this->configuredTenant(); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $created = $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($historicalDate->id, 'Almuerzo', 'Comedor', 100, 10000), + $this->variantPayload($activeDate->id, 'Cena', 'Vianda', 80, 8000), + ], + ])->assertOk(); + $historicalVariantId = $created->json('data.variants.0.id'); + $activeVariantId = $created->json('data.variants.1.id'); + + app(EventService::class)->rescheduleDateForTenant( + $tenant, + $historicalDate, + ['date' => $activeDate->date->format('Y-m-d')], + $admin, + ); + $historicalVariant = Variant::query()->findOrFail($historicalVariantId); + $replacementVariant = $historicalVariant->replacement()->firstOrFail(); + $replacementInventoryId = $replacementVariant->inventory_id; + + // Simula variantes creadas antes de que la reprogramación separara sus inventarios. + $replacementVariant->update(['inventory_id' => $historicalVariant->inventory_id]); + Inventory::query()->whereKey($replacementInventoryId)->delete(); + + $updated = $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [ + 'variants' => [['id' => $historicalVariantId, 'stock' => 45]], + ]) + ->assertOk() + ->assertJsonPath('data.history.0.variants.0.id', $historicalVariantId) + ->assertJsonPath('data.history.0.variants.0.stock', 45); + $replacement = collect($updated->json('data.variants')) + ->firstWhere('schedule', 'Almuerzo'); + $this->assertSame(100, $replacement['stock']); + + $historicalInventoryId = Variant::query()->findOrFail($historicalVariantId)->inventory_id; + $currentInventoryId = $replacementVariant->fresh()->inventory_id; + $this->assertNotSame($historicalInventoryId, $currentInventoryId); + $this->assertDatabaseHas('inventories', [ + 'id' => $historicalInventoryId, + 'real_stock' => 45, + ]); + + $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [ + 'variants' => [['id' => $activeVariantId, 'stock' => 20]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['variants.0.id']); + } + public function test_it_deletes_food_records_and_removes_the_empty_product(): void { [$tenant, $firstDate, $secondDate] = $this->configuredTenant(); diff --git a/tests/Feature/Forms/AdminAppEntryFormControllerTest.php b/tests/Feature/Forms/AdminAppEntryFormControllerTest.php new file mode 100644 index 0000000..6a0722d --- /dev/null +++ b/tests/Feature/Forms/AdminAppEntryFormControllerTest.php @@ -0,0 +1,82 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/entry') + ->assertUnauthorized(); + } + + public function test_it_returns_only_selectable_event_dates_for_the_tenant(): void + { + $tenant = $this->createTenant('fiesta'); + $otherTenant = $this->createTenant('other'); + $available = $this->createEventDate($tenant, '2026-10-09'); + $rescheduled = $this->createEventDate($tenant, '2026-10-10'); + $replacement = $this->createEventDate($tenant, '2026-10-11'); + $rescheduled->update(['rescheduled_to_event_date_id' => $replacement->id]); + $this->createEventDate($tenant, '2026-10-12', ['suspended_at' => now()]); + $this->createEventDate($otherTenant, '2026-10-13'); + + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ])); + + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/entry') + ->assertOk() + ->assertJsonCount(2, 'data.event_dates') + ->assertJsonFragment(['id' => $available->id, 'date' => '2026-10-09']) + ->assertJsonFragment(['id' => $replacement->id, 'date' => '2026-10-11']) + ->assertJsonMissing(['date' => '2026-10-10']) + ->assertJsonMissing(['date' => '2026-10-12']) + ->assertJsonMissing(['date' => '2026-10-13']); + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => 'onticket', + ]); + } + + /** @param array $overrides */ + private function createEventDate( + Tenant $tenant, + string $date, + array $overrides = [] + ): EventDate { + return $tenant->eventDates()->create([ + 'date' => $date, + 'time_start' => '00:00', + 'time_end' => '23:59', + ...$overrides, + ]); + } +} diff --git a/tests/Feature/Forms/AdminAppFoodFormControllerTest.php b/tests/Feature/Forms/AdminAppFoodFormControllerTest.php index f3e9db3..50f01e1 100644 --- a/tests/Feature/Forms/AdminAppFoodFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppFoodFormControllerTest.php @@ -67,6 +67,51 @@ class AdminAppFoodFormControllerTest extends TestCase ->assertJsonPath('data.services.0.value', 'Comedor'); } + public function test_it_excludes_rescheduled_and_suspended_event_dates(): void + { + $tenant = Tenant::query()->create([ + 'codigo' => 'fiesta', + 'nombre' => 'Fiesta', + 'dominio' => 'fiesta.test', + 'website_type_code' => 'onticket', + ]); + $available = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $rescheduled = $tenant->eventDates()->create([ + 'date' => '2026-10-10', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $replacement = $tenant->eventDates()->create([ + 'date' => '2026-10-11', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $rescheduled->update(['rescheduled_to_event_date_id' => $replacement->id]); + $tenant->eventDates()->create([ + 'date' => '2026-10-12', + 'time_start' => '00:00', + 'time_end' => '23:59', + 'suspended_at' => now(), + ]); + + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ])); + + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/food') + ->assertOk() + ->assertJsonCount(2, 'data.event_dates') + ->assertJsonFragment(['id' => $available->id, 'date' => '2026-10-09']) + ->assertJsonFragment(['id' => $replacement->id, 'date' => '2026-10-11']) + ->assertJsonMissing(['date' => '2026-10-10']) + ->assertJsonMissing(['date' => '2026-10-12']); + } + /** @param array $options */ private function createAttribute(Tenant $tenant, string $code, array $options): void { diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php index 43d19da..3174d77 100644 --- a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -78,6 +78,8 @@ class AdminAppTicketFilterFormControllerTest extends TestCase ['value' => 'active', 'label' => 'Activo'], ['value' => 'used', 'label' => 'Usado'], ['value' => 'expired', 'label' => 'Vencido'], + ['value' => 'disabled', 'label' => 'Inhabilitado'], + ['value' => 'refunded', 'label' => 'Reembolsado'], ], ], ], diff --git a/tests/Feature/Forms/AdminAppTicketFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFormControllerTest.php index 5bb4ef3..78c97d7 100644 --- a/tests/Feature/Forms/AdminAppTicketFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFormControllerTest.php @@ -63,6 +63,9 @@ class AdminAppTicketFormControllerTest extends TestCase ['value' => 'active', 'label' => 'Activo'], ['value' => 'used', 'label' => 'Usado'], ['value' => 'expired', 'label' => 'Vencido'], + ['value' => 'disabled', 'label' => 'Inhabilitado'], + ['value' => 'cancelled', 'label' => 'Cancelado'], + ['value' => 'refunded', 'label' => 'Reembolsado'], ], 'categories' => [ [ diff --git a/tests/Feature/Logging/LogsValueChangesTest.php b/tests/Feature/Logging/LogsValueChangesTest.php index bd4dc8c..11d6d64 100644 --- a/tests/Feature/Logging/LogsValueChangesTest.php +++ b/tests/Feature/Logging/LogsValueChangesTest.php @@ -6,11 +6,13 @@ use App\Domains\Logging\Enums\ValueChangeActorType; use App\Domains\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Logging\Models\ValueChange; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Schema; +use Illuminate\Validation\ValidationException; use Tests\TestCase; class LogsValueChangesTest extends TestCase @@ -52,6 +54,16 @@ class LogsValueChangesTest extends TestCase $table->timestamps(); }); + Schema::create('tickets', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->uuid('ticket'); + $table->dateTime('used_at')->nullable(); + $table->dateTime('disabled_at')->nullable(); + $table->dateTime('cancelled_at')->nullable(); + $table->dateTime('refunded_at')->nullable(); + }); + $migration = require database_path('migrations/2026_08_03_000200_create_value_changes_table.php'); $migration->up(); $tenantMigration = require database_path('migrations/2026_08_04_000000_add_tenant_code_to_value_changes_table.php'); @@ -144,6 +156,39 @@ class LogsValueChangesTest extends TestCase 'user_id' => null, ]); } + + public function test_ticket_logs_its_status_changes(): void + { + $ticket = Ticket::query()->create([ + 'tenant_code' => 'test', + 'ticket' => '794606d5-5f69-458d-9de7-03494757d626', + ]); + + $ticket->update(['disabled_at' => now()]); + + $this->assertDatabaseHas('value_changes', [ + 'tenant_code' => 'test', + 'trackable_type' => $ticket->getMorphClass(), + 'trackable_id' => $ticket->id, + 'attribute' => 'disabled_at', + 'old_value' => null, + 'actor_type' => ValueChangeActorType::System->value, + 'user_id' => null, + ]); + } + + public function test_ticket_cannot_transition_between_terminal_statuses(): void + { + $ticket = Ticket::query()->create([ + 'tenant_code' => 'test', + 'ticket' => '794606d5-5f69-458d-9de7-03494757d626', + ]); + + $ticket->update(['disabled_at' => now()]); + + $this->expectException(ValidationException::class); + $ticket->update(['cancelled_at' => now()]); + } } #[Fillable(['name', 'price', 'description'])] diff --git a/tests/Feature/Migrations/RemoveRefundedAmountFromPurchaseItemsTest.php b/tests/Feature/Migrations/RemoveRefundedAmountFromPurchaseItemsTest.php new file mode 100644 index 0000000..c26aa95 --- /dev/null +++ b/tests/Feature/Migrations/RemoveRefundedAmountFromPurchaseItemsTest.php @@ -0,0 +1,151 @@ +originalConnection = DB::getDefaultConnection(); + config()->set('database.connections.refund_migration_test', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); + DB::setDefaultConnection('refund_migration_test'); + + Schema::create('compra_items', function (Blueprint $table): void { + $table->id(); + $table->decimal('precio_unitario', 10, 2); + $table->decimal('total', 10, 2); + $table->decimal('refunded_amount', 10, 2)->default(0); + }); + Schema::create('tickets', function (Blueprint $table): void { + $table->id(); + $table->foreignId('source_purchase_item_id')->nullable(); + $table->dateTime('refunded_at')->nullable(); + }); + Schema::create('ticket_refunds', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->unique(); + $table->foreignId('purchase_item_id'); + $table->foreignId('created_by_user_id')->nullable(); + $table->string('type', 16); + $table->decimal('amount', 10, 2); + $table->timestamps(); + }); + } + + protected function tearDown(): void + { + DB::purge('refund_migration_test'); + DB::setDefaultConnection($this->originalConnection); + + parent::tearDown(); + } + + public function test_it_backfills_a_refund_created_before_ticket_refunds_existed(): void + { + DB::table('compra_items')->insert([ + 'id' => 254, + 'precio_unitario' => '100.00', + 'total' => '100.00', + 'refunded_amount' => '40.00', + ]); + DB::table('tickets')->insert([ + 'id' => 501, + 'source_purchase_item_id' => 254, + 'refunded_at' => '2026-09-13 18:30:00', + ]); + + $this->migration()->up(); + + $this->assertFalse(Schema::hasColumn('compra_items', 'refunded_amount')); + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => 501, + 'purchase_item_id' => 254, + 'created_by_user_id' => null, + 'type' => 'partial', + 'amount' => 40, + 'created_at' => '2026-09-13 18:30:00', + ]); + } + + public function test_it_only_backfills_the_amount_not_already_in_ticket_refunds(): void + { + DB::table('compra_items')->insert([ + 'id' => 254, + 'precio_unitario' => '100.00', + 'total' => '200.00', + 'refunded_amount' => '140.00', + ]); + DB::table('tickets')->insert([ + [ + 'id' => 501, + 'source_purchase_item_id' => 254, + 'refunded_at' => '2026-09-13 18:30:00', + ], + [ + 'id' => 502, + 'source_purchase_item_id' => 254, + 'refunded_at' => '2026-09-14 10:00:00', + ], + ]); + DB::table('ticket_refunds')->insert([ + 'ticket_id' => 502, + 'purchase_item_id' => 254, + 'created_by_user_id' => 7, + 'type' => 'total', + 'amount' => '100.00', + 'created_at' => '2026-09-14 10:00:00', + 'updated_at' => '2026-09-14 10:00:00', + ]); + + $this->migration()->up(); + + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => 501, + 'purchase_item_id' => 254, + 'created_by_user_id' => null, + 'type' => 'partial', + 'amount' => 40, + ]); + $this->assertSame(2, DB::table('ticket_refunds')->count()); + } + + public function test_it_still_refuses_to_drop_an_amount_without_a_refunded_ticket(): void + { + DB::table('compra_items')->insert([ + 'id' => 254, + 'precio_unitario' => '100.00', + 'total' => '100.00', + 'refunded_amount' => '40.00', + ]); + + try { + $this->migration()->up(); + $this->fail('The migration should preserve an amount that cannot be backfilled.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('ítem 254', $exception->getMessage()); + $this->assertTrue(Schema::hasColumn('compra_items', 'refunded_amount')); + } + } + + private function migration(): object + { + return require database_path( + 'migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php' + ); + } +} diff --git a/tests/Feature/Notification/IdempotentEmailDeliveryServiceTest.php b/tests/Feature/Notification/IdempotentEmailDeliveryServiceTest.php new file mode 100644 index 0000000..5b666a1 --- /dev/null +++ b/tests/Feature/Notification/IdempotentEmailDeliveryServiceTest.php @@ -0,0 +1,136 @@ +sendOnce( + 'welcome:tenant:10', + 'welcome', + 'tenant', + ['user_id' => 10], + 'ada@example.com', + function () use (&$calls): void { + $calls++; + }, + ); + $second = $service->sendOnce( + 'welcome:tenant:10', + 'welcome', + 'tenant', + ['user_id' => 10], + 'ada@example.com', + function () use (&$calls): void { + $calls++; + }, + ); + + $this->assertTrue($first); + $this->assertFalse($second); + $this->assertSame(1, $calls); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => 'welcome:tenant:10', + 'status' => EmailDelivery::STATUS_SENT, + 'attempts' => 1, + ]); + } + + public function test_it_records_a_failure_and_allows_a_retry(): void + { + $service = app(IdempotentEmailDeliveryService::class); + + try { + $service->sendOnce( + 'purchase-confirmed:20', + 'purchase_confirmed', + 'tenant', + ['purchase_id' => 20], + 'buyer@example.com', + fn () => throw new RuntimeException('Sensitive SMTP detail'), + ); + $this->fail('The delivery exception was not rethrown.'); + } catch (RuntimeException) { + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => 'purchase-confirmed:20', + 'status' => EmailDelivery::STATUS_FAILED, + 'attempts' => 1, + 'last_error' => RuntimeException::class, + ]); + } + + $sent = $service->sendOnce( + 'purchase-confirmed:20', + 'purchase_confirmed', + 'tenant', + ['purchase_id' => 20], + 'buyer@example.com', + static function (): void {}, + ); + + $this->assertTrue($sent); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => 'purchase-confirmed:20', + 'status' => EmailDelivery::STATUS_SENT, + 'attempts' => 2, + 'last_error' => null, + ]); + } + + public function test_it_recovers_an_expired_claim_but_not_an_active_one(): void + { + config(['mail.delivery_lease_seconds' => 300]); + $service = app(IdempotentEmailDeliveryService::class); + $delivery = EmailDelivery::query()->create([ + 'idempotency_key' => 'password-reset:30', + 'email_type' => 'password_reset', + 'tenant_code' => 'tenant', + 'status' => EmailDelivery::STATUS_PROCESSING, + 'attempts' => 1, + 'context' => ['attempt_id' => 30], + 'recipient_fingerprint' => str_repeat('a', 64), + 'claim_token' => fake()->uuid(), + 'claimed_at' => now(), + 'lease_expires_at' => now()->addMinute(), + ]); + + $activeClaim = $service->sendOnce( + $delivery->idempotency_key, + $delivery->email_type, + $delivery->tenant_code, + $delivery->context, + 'ada@example.com', + static function (): void {}, + ); + $this->assertFalse($activeClaim); + + $delivery->update(['lease_expires_at' => now()->subSecond()]); + $expiredClaim = $service->sendOnce( + $delivery->idempotency_key, + $delivery->email_type, + $delivery->tenant_code, + $delivery->context, + 'ada@example.com', + static function (): void {}, + ); + + $this->assertTrue($expiredClaim); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => 'password-reset:30', + 'status' => EmailDelivery::STATUS_SENT, + 'attempts' => 2, + ]); + } +} diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index eef0c1c..22c1ef0 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -14,6 +14,7 @@ use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; +use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Mail\Mailable; use Illuminate\Support\Facades\Mail; @@ -31,6 +32,7 @@ class NotificationMailServiceTest extends TestCase { parent::setUp(); + $this->app->register(DomPdfServiceProvider::class); Mail::fake(); Integration::query()->create([ 'integration_code' => 'email', @@ -75,6 +77,9 @@ class NotificationMailServiceTest extends TestCase $this->useWebsiteTypeBranding(); app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo); + app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo); + + Mail::assertSent(Mailable::class, 1); Mail::assertSent(Mailable::class, function (Mailable $mail): bool { $mail->assertTo('ada@example.com'); @@ -98,6 +103,12 @@ class NotificationMailServiceTest extends TestCase $attempt->id, $this->tenant->codigo, ); + app(NotificationMailService::class)->sendPasswordResetCode( + $attempt->id, + $this->tenant->codigo, + ); + + Mail::assertSent(Mailable::class, 1); Mail::assertSent(Mailable::class, function (Mailable $mail): bool { $mail->assertTo('ada@example.com'); @@ -113,6 +124,27 @@ class NotificationMailServiceTest extends TestCase }); } + public function test_password_reset_idempotency_is_scoped_to_each_attempt(): void + { + $firstAttempt = $this->user->resetPasswordAttempts()->create(['codigo' => '0123']); + $secondAttempt = $this->user->resetPasswordAttempts()->create(['codigo' => '4567']); + $service = app(NotificationMailService::class); + + $service->sendPasswordResetCode($firstAttempt->id, $this->tenant->codigo); + $service->sendPasswordResetCode($firstAttempt->id, $this->tenant->codigo); + $service->sendPasswordResetCode($secondAttempt->id, $this->tenant->codigo); + + Mail::assertSent(Mailable::class, 2); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => "password-reset:{$firstAttempt->id}", + 'status' => 'sent', + ]); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => "password-reset:{$secondAttempt->id}", + 'status' => 'sent', + ]); + } + public function test_it_links_scanner_password_resets_to_the_scanner_domain(): void { $websiteType = WebsiteType::query()->create([ @@ -261,6 +293,9 @@ class NotificationMailServiceTest extends TestCase ]); app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id); + app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id); + + Mail::assertSent(Mailable::class, 1); Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool { return $mail->subject === "Compra confirmada - Compra #{$purchase->id}" @@ -270,6 +305,144 @@ class NotificationMailServiceTest extends TestCase }); } + public function test_it_sends_one_rescheduling_email_per_purchase_and_does_not_duplicate_it(): void + { + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'user_id' => $this->user->id, + 'status' => Purchase::STATUS_PAID, + 'payment_method' => 'transfer', + 'total' => 25, + 'email' => 'checkout@example.com', + ]); + $purchaseItem = $purchase->items()->create([ + 'source_catalog_item_id' => $this->catalogItem()->id, + 'nombre' => 'Entrada', + 'item_nombre' => 'Entrada general', + 'variant_attributes' => [], + 'cantidad' => 2, + 'precio_unitario' => 25, + 'total' => 25, + ]); + $firstTicket = $this->ticketFor($purchaseItem->id); + $secondTicket = $this->ticketFor($purchaseItem->id); + $purchaseTickets = [[ + 'purchase_id' => $purchase->id, + 'ticket_ids' => [$firstTicket->id, $secondTicket->id], + ]]; + + app(NotificationMailService::class)->sendEventDateRescheduled( + $this->tenant->codigo, + 10, + 20, + '09/10/2027', + '20/10/2027', + $purchaseTickets, + ); + app(NotificationMailService::class)->sendEventDateRescheduled( + $this->tenant->codigo, + 10, + 20, + '09/10/2027', + '20/10/2027', + $purchaseTickets, + ); + + Mail::assertSent(Mailable::class, 1); + Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $firstTicket, $secondTicket): bool { + $mail->assertTo('checkout@example.com'); + + return $mail->subject === "Tu evento fue reprogramado - N° de Orden #{$purchase->id}" + && str_contains($mail->render(), '09/10/2027') + && str_contains($mail->render(), '20/10/2027') + && str_contains($mail->render(), 'N° de Ticket #'.$firstTicket->id) + && str_contains($mail->render(), 'N° de Ticket #'.$secondTicket->id) + && str_contains($mail->render(), 'N° de Orden #'.$purchase->id); + }); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => "event-date-rescheduled:10:20:{$purchase->id}", + 'email_type' => 'event_date_rescheduled', + 'status' => 'sent', + ]); + } + + public function test_suspension_email_uses_the_account_email_and_separates_disabled_tickets(): void + { + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'user_id' => $this->user->id, + 'status' => Purchase::STATUS_PAID, + 'payment_method' => 'transfer', + 'total' => 25, + 'email' => null, + ]); + $purchaseItem = $purchase->items()->create([ + 'source_catalog_item_id' => $this->catalogItem()->id, + 'nombre' => 'Entrada', + 'item_nombre' => 'Entrada general', + 'variant_attributes' => [], + 'cantidad' => 2, + 'precio_unitario' => 25, + 'total' => 25, + ]); + $disabledTicket = $this->ticketFor($purchaseItem->id); + $disabledTicket->markAsDisabled(); + $disabledTicket->save(); + $activeTicket = $this->ticketFor($purchaseItem->id); + + app(NotificationMailService::class)->sendEventDateSuspended( + $this->tenant->codigo, + 10, + '09/10/2027', + [[ + 'purchase_id' => $purchase->id, + 'ticket_ids' => [$disabledTicket->id, $activeTicket->id], + ]], + ); + app(NotificationMailService::class)->sendEventDateSuspended( + $this->tenant->codigo, + 10, + '09/10/2027', + [[ + 'purchase_id' => $purchase->id, + 'ticket_ids' => [$disabledTicket->id, $activeTicket->id], + ]], + ); + + Mail::assertSent(Mailable::class, 1); + + Mail::assertSent(Mailable::class, function (Mailable $mail) use ($disabledTicket, $activeTicket): bool { + $mail->assertTo('ada@example.com'); + + return str_contains($mail->render(), 'quedaron inhabilitados') + && str_contains($mail->render(), 'conservan otras fechas disponibles') + && str_contains($mail->render(), '#'.$disabledTicket->id) + && str_contains($mail->render(), '#'.$activeTicket->id); + }); + } + + private function ticketFor(int $purchaseItemId): Ticket + { + return Ticket::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'ticket' => fake()->uuid(), + 'source_purchase_item_id' => $purchaseItemId, + 'user_id' => $this->user->id, + ]); + } + + private function catalogItem(): CatalogItem + { + return CatalogItem::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => fake()->unique()->slug(), + 'nombre' => 'Entrada', + 'descripcion' => 'Entrada general', + 'precio' => 25, + 'has_tickets' => true, + ]); + } + private function useWebsiteTypeBranding(): void { $websiteType = WebsiteType::query()->create([ diff --git a/tests/Feature/Notification/QueuedNotificationListenerTest.php b/tests/Feature/Notification/QueuedNotificationListenerTest.php index fbf446f..f9234ce 100644 --- a/tests/Feature/Notification/QueuedNotificationListenerTest.php +++ b/tests/Feature/Notification/QueuedNotificationListenerTest.php @@ -2,6 +2,10 @@ namespace Tests\Feature\Notification; +use App\Domains\Event\Events\EventDateRescheduled; +use App\Domains\Event\Events\EventDateSuspended; +use App\Domains\Notification\Listeners\SendEventDateRescheduledEmails; +use App\Domains\Notification\Listeners\SendEventDateSuspendedEmails; use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail; use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Purchase\Events\PurchasePaid; @@ -27,4 +31,42 @@ class QueuedNotificationListenerTest extends TestCase $this->assertSame(123, $purchasePaid->purchaseId); } + + public function test_rescheduled_date_email_listener_delegates_the_captured_purchase_tickets(): void + { + $mailService = Mockery::mock(NotificationMailService::class); + $mailService->shouldReceive('sendEventDateRescheduled') + ->once() + ->with('acme', 10, 20, '2027-10-09', '2027-10-20', [[ + 'purchase_id' => 123, + 'ticket_ids' => [456, 789], + ]]); + $this->app->instance(NotificationMailService::class, $mailService); + + (new SendEventDateRescheduledEmails)->handle(new EventDateRescheduled( + 'acme', 10, 20, '2027-10-09', '2027-10-20', [[ + 'purchase_id' => 123, + 'ticket_ids' => [456, 789], + ]] + )); + } + + public function test_suspended_date_email_listener_delegates_the_captured_purchase_tickets(): void + { + $mailService = Mockery::mock(NotificationMailService::class); + $mailService->shouldReceive('sendEventDateSuspended') + ->once() + ->with('acme', 10, '2027-10-09', [[ + 'purchase_id' => 123, + 'ticket_ids' => [456], + ]]); + $this->app->instance(NotificationMailService::class, $mailService); + + (new SendEventDateSuspendedEmails)->handle(new EventDateSuspended( + 'acme', 10, '2027-10-09', [[ + 'purchase_id' => 123, + 'ticket_ids' => [456], + ]] + )); + } } diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index e7fd951..65ec3f6 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -11,7 +11,9 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\StockReservation; +use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Models\EventDate; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\UserPurchaseLimitService; @@ -20,6 +22,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; class StorePurchaseTest extends TestCase @@ -72,6 +75,79 @@ class StorePurchaseTest extends TestCase $this->travelBack(); } + public function test_checkout_replaces_a_historical_cart_variant_without_duplicating_stock(): void + { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $historicalVariant = $this->createVariantForTenant('sonder', 10, '50.00'); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => Cart::STATUS_ACTIVE, + ]); + $cart->addItem($historicalVariant->catalog_item_id, $historicalVariant->id, 2); + + $replacement = Variant::query()->create([ + 'catalog_item_id' => $historicalVariant->catalog_item_id, + 'inventory_id' => $historicalVariant->inventory_id, + 'precio' => '50.00', + ]); + $historicalVariant->update([ + 'replaced_by_variant_id' => $replacement->id, + 'sales_disabled_at' => now(), + ]); + + $purchase = app(CheckoutService::class)->startCheckout($tenant, $user->id, [ + 'cart_id' => $cart->id, + ]); + + $this->assertDatabaseHas('carrito_items', [ + 'cart_id' => $cart->id, + 'variant_id' => $replacement->id, + 'cantidad' => 2, + ]); + $this->assertDatabaseHas('compra_items', [ + 'compra_id' => $purchase->id, + 'source_variant_id' => $replacement->id, + 'cantidad' => 2, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $historicalVariant->inventory_id, + 'real_stock' => 10, + 'reserved_stock' => 2, + ]); + $this->assertSame(8, $historicalVariant->catalogItem->fresh()->availableStock()); + } + + public function test_direct_checkout_rejects_a_historical_variant(): void + { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $historicalVariant = $this->createVariantForTenant('sonder', 10, '50.00'); + $replacement = Variant::query()->create([ + 'catalog_item_id' => $historicalVariant->catalog_item_id, + 'inventory_id' => $historicalVariant->inventory_id, + 'precio' => '50.00', + ]); + $historicalVariant->update([ + 'replaced_by_variant_id' => $replacement->id, + 'sales_disabled_at' => now(), + ]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'direct_items' => [[ + 'catalog_item_id' => $historicalVariant->catalog_item_id, + 'variant_id' => $historicalVariant->id, + 'cantidad' => 1, + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('direct_items.0.variant_id'); + + $this->assertDatabaseCount('compras', 0); + } + public function test_checkout_cannot_replace_an_overdue_cart_reservation(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); @@ -569,6 +645,80 @@ class StorePurchaseTest extends TestCase $this->assertSame(1, $activeCart->items()->count()); } + /** @return array */ + public static function unavailableCancellationCases(): array + { + return [ + 'created with disabled variant' => [Purchase::STATUS_CREATED, 'disabled'], + 'pending payment with replaced variant' => [Purchase::STATUS_PENDING_PAYMENT, 'replaced'], + 'pending payment with suspended date' => [Purchase::STATUS_PENDING_PAYMENT, 'suspended'], + ]; + } + + #[DataProvider('unavailableCancellationCases')] + public function test_cancelling_a_purchase_with_unavailable_variants_invalidates_the_whole_cart( + string $purchaseStatus, + string $change, + ): void { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $otherVariant = $this->createVariantForTenant('sonder', 10, '25.00', 'other'); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, 'user_id' => $user->id, + 'status' => Cart::STATUS_ACTIVE, 'origin' => Cart::ORIGIN_USER, + ]); + $cart->addItem($variant->catalog_item_id, $variant->id, 2); + $cart->addItem($otherVariant->catalog_item_id, $otherVariant->id, 1); + $purchase = app(CheckoutService::class)->startCheckout($tenant, $user->id, ['cart_id' => $cart->id]); + $purchase->update(['status' => $purchaseStatus]); + $reservedInventoryId = $variant->inventory_id; + + if ($change === 'replaced') { + // A reprogramming can move reservation lines away from the original variant. + $replacementInventory = Inventory::query()->create(['real_stock' => 10, 'reserved_stock' => 2]); + $replacement = Variant::query()->create([ + 'catalog_item_id' => $variant->catalog_item_id, 'inventory_id' => $replacementInventory->id, + ]); + StockReservationLine::query()->where('stock_reservation_id', $purchase->stock_reservation_id) + ->where('inventory_id', $variant->inventory_id) + ->update(['inventory_id' => $replacementInventory->id]); + $variant->inventory->update(['reserved_stock' => 0]); + $variant->update(['replaced_by_variant_id' => $replacement->id, 'sales_disabled_at' => now()]); + $reservedInventoryId = $replacementInventory->id; + } elseif ($change === 'suspended') { + $date = EventDate::query()->create([ + 'tenant_code' => $tenant->codigo, 'date' => '2027-10-09', + 'time_start' => '09:00', 'time_end' => '18:00', 'suspended_at' => now(), + ]); + $variant->eventDates()->sync([$date->id]); + } else { + $variant->update(['sales_disabled_at' => now()]); + } + + $this->actingAs($user, 'sanctum'); + for ($attempt = 0; $attempt < 2; $attempt++) { + $this->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") + ->assertOk()->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); + } + + $this->assertDatabaseHas('carritos', [ + 'id' => $cart->id, 'status' => Cart::STATUS_EXPIRED, + 'current_purchase_id' => null, 'current_stock_reservation_id' => null, + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $purchase->stock_reservation_id, 'status' => StockReservation::STATUS_RELEASED, + 'release_reason' => 'purchase_cancelled', + ]); + foreach ([$reservedInventoryId, $otherVariant->inventory_id] as $inventoryId) { + $this->assertDatabaseHas('inventories', [ + 'id' => $inventoryId, 'real_stock' => 10, 'reserved_stock' => 0, + ]); + } + $this->getJson('/api/tenants/sonder/cart')->assertOk()->assertJsonCount(0, 'data.items'); + $this->assertSame(Cart::STATUS_ABANDONED, $cart->fresh()->status); + } + public function test_it_reuses_the_cart_reservation_for_a_new_checkout_and_rejects_a_late_confirmation(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index 1cc0d7f..f995133 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -20,6 +20,7 @@ use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketRefund; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; @@ -131,7 +132,8 @@ class AdminAppSaleControllerTest extends TestCase public function test_sales_list_uses_purchase_item_snapshots_for_every_status(): void { $tenant = $this->createTenant('acme'); - Sanctum::actingAs($this->createAdminAppUser($tenant)); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); $catalogItem = CatalogItem::query()->create([ 'tenant_code' => $tenant->codigo, @@ -155,7 +157,7 @@ class AdminAppSaleControllerTest extends TestCase 'status' => Purchase::STATUS_CREATED, 'total' => '30000.00', ]); - PurchaseItem::query()->create([ + $createdPurchaseItem = PurchaseItem::query()->create([ 'compra_id' => $createdPurchase->id, 'source_catalog_item_id' => $catalogItem->id, 'nombre' => $catalogItem->nombre, @@ -164,6 +166,20 @@ class AdminAppSaleControllerTest extends TestCase 'precio_unitario' => '10000.00', 'total' => '30000.00', ]); + $createdRefundTicket = Ticket::query()->create([ + 'tenant_code' => $tenant->codigo, + 'ticket' => 'created-purchase-refund', + 'user_id' => $admin->id, + 'source_purchase_item_id' => $createdPurchaseItem->id, + 'refunded_at' => now(), + ]); + TicketRefund::query()->create([ + 'ticket_id' => $createdRefundTicket->id, + 'purchase_item_id' => $createdPurchaseItem->id, + 'created_by_user_id' => $admin->id, + 'type' => TicketRefund::TYPE_PARTIAL, + 'amount' => '1250.00', + ]); $pendingCart = Cart::query()->create([ 'tenant_codigo' => $tenant->codigo, @@ -195,7 +211,7 @@ class AdminAppSaleControllerTest extends TestCase 'status' => Purchase::STATUS_PAID, 'total' => '20000.00', ]); - PurchaseItem::query()->create([ + $paidPurchaseItem = PurchaseItem::query()->create([ 'compra_id' => $paidPurchase->id, 'source_catalog_item_id' => $catalogItem->id, 'nombre' => $catalogItem->nombre, @@ -204,6 +220,20 @@ class AdminAppSaleControllerTest extends TestCase 'precio_unitario' => '10000.00', 'total' => '20000.00', ]); + $paidRefundTicket = Ticket::query()->create([ + 'tenant_code' => $tenant->codigo, + 'ticket' => 'paid-purchase-refund', + 'user_id' => $admin->id, + 'source_purchase_item_id' => $paidPurchaseItem->id, + 'refunded_at' => now(), + ]); + TicketRefund::query()->create([ + 'ticket_id' => $paidRefundTicket->id, + 'purchase_item_id' => $paidPurchaseItem->id, + 'created_by_user_id' => $admin->id, + 'type' => TicketRefund::TYPE_PARTIAL, + 'amount' => '2500.00', + ]); $supersededPurchase = Purchase::query()->create([ 'tenant_codigo' => $tenant->codigo, @@ -237,7 +267,8 @@ class AdminAppSaleControllerTest extends TestCase ->assertJsonPath('data.2.status_label', 'Confirmado') ->assertJsonPath('data.3.id', $supersededPurchase->id) ->assertJsonPath('data.3.admin_status', Purchase::ADMIN_STATUS_CANCELLED) - ->assertJsonPath('data.3.status_label', 'Anulado'); + ->assertJsonPath('data.3.status_label', 'Anulado') + ->assertJsonPath('refunded_total', '3750.00'); $this->getJson('/api/v1/adminapp/tenant/sales?status='.Purchase::STATUS_SUPERSEDED) ->assertUnprocessable(); @@ -507,12 +538,14 @@ class AdminAppSaleControllerTest extends TestCase 'id' => $firstTicket->id, 'expires_at' => null, 'status' => Ticket::STATUS_ACTIVE, + 'status_label' => 'Activo', ], [ 'product' => 'Abono general', 'id' => $usedTicket->id, 'expires_at' => null, 'status' => Ticket::STATUS_USED, + 'status_label' => 'Usado', ], ], ]); diff --git a/tests/Feature/Staff/StaffControllerTest.php b/tests/Feature/Staff/StaffControllerTest.php index 9f4ebd0..b8bfb89 100644 --- a/tests/Feature/Staff/StaffControllerTest.php +++ b/tests/Feature/Staff/StaffControllerTest.php @@ -7,10 +7,13 @@ use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; +use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; +use App\Domains\Ticket\Enums\ScanAttemptResult; +use App\Domains\Ticket\Models\ScanAttempt; use App\Domains\Ticket\Models\Ticket; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -264,6 +267,159 @@ class StaffControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/staff')->assertForbidden(); } + public function test_adminapp_can_search_staff_scan_attempts_by_ticket_id_category_and_date(): void + { + $scanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + $otherScanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + $category = $this->createCategory('Alojamiento'); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'category_id' => $category->id, + 'slug' => 'alojamiento-test', + 'nombre' => 'Hotel', + 'precio' => 100, + ]); + $ticket = Ticket::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'ticket' => (string) Str::uuid(), + 'source_catalog_item_id' => $catalogItem->id, + 'user_id' => $this->admin->id, + ]); + $matching = $this->createScanAttempt($scanner, 'matching-qr', [ + 'ticket_id' => $ticket->id, + 'created_at' => '2026-08-11 14:30:00', + ]); + $this->createScanAttempt($scanner, 'another-qr', [ + 'created_at' => '2026-08-22 22:22:22', + ]); + $this->createScanAttempt($otherScanner, 'matching-qr', [ + 'ticket_id' => $ticket->id, + 'created_at' => '2026-08-11 14:30:00', + ]); + Sanctum::actingAs($this->admin); + + $this->getJson( + "/api/v1/adminapp/tenant/staff/{$scanner->id}/scan-attempts?q={$ticket->id}" + )->assertOk() + ->assertJsonFragment([ + 'id' => $matching->id, + 'ticket_id' => $ticket->id, + ]); + + $assertSingleMatchingAttempt = function (string $search) use ($scanner, $matching, $ticket): void { + $this->getJson( + "/api/v1/adminapp/tenant/staff/{$scanner->id}/scan-attempts?q=".urlencode($search).'&per_page=1' + ) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id) + ->assertJsonPath('data.0.ticket_id', $ticket->id) + ->assertJsonPath('data.0.category', 'Alojamiento') + ->assertJsonPath('data.0.result_label', 'Verificado') + ->assertJsonPath('meta.current_page', 1) + ->assertJsonPath('meta.per_page', 1) + ->assertJsonPath('meta.total', 1); + }; + + $assertSingleMatchingAttempt('alojamiento'); + $assertSingleMatchingAttempt('11/08/26'); + } + + public function test_adminapp_cannot_list_scan_attempts_for_non_scanner_staff(): void + { + $customer = User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + Sanctum::actingAs($this->admin); + + $this->getJson("/api/v1/adminapp/tenant/staff/{$customer->id}/scan-attempts") + ->assertNotFound(); + } + + public function test_adminapp_can_search_staff_scan_attempts_by_day_and_month_across_years(): void + { + $scanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + $firstMatch = $this->createScanAttempt($scanner, 'first-match', [ + 'created_at' => '2024-09-07 10:00:00', + ]); + $secondMatch = $this->createScanAttempt($scanner, 'second-match', [ + 'created_at' => '2026-09-07 10:00:00', + ]); + $this->createScanAttempt($scanner, 'different-day', [ + 'created_at' => '2026-09-08 10:00:00', + ]); + Sanctum::actingAs($this->admin); + + $this->getJson("/api/v1/adminapp/tenant/staff/{$scanner->id}/scan-attempts?q=07%2F09") + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $secondMatch->id) + ->assertJsonPath('data.1.id', $firstMatch->id) + ->assertJsonPath('meta.total', 2); + } + + public function test_adminapp_datetime_search_matches_text_in_any_datetime_component(): void + { + $scanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + $matches = [ + $this->createScanAttempt($scanner, 'year-match', [ + 'created_at' => '2007-11-12 10:00:08', + ]), + $this->createScanAttempt($scanner, 'month-match', [ + 'created_at' => '2026-07-12 10:00:08', + ]), + $this->createScanAttempt($scanner, 'day-match', [ + 'created_at' => '2026-11-07 10:00:08', + ]), + $this->createScanAttempt($scanner, 'seconds-match', [ + 'created_at' => '2026-11-12 10:00:07', + ]), + ]; + $this->createScanAttempt($scanner, 'no-match', [ + 'created_at' => '2026-11-12 10:00:08', + ]); + Sanctum::actingAs($this->admin); + + $response = $this->getJson( + "/api/v1/adminapp/tenant/staff/{$scanner->id}/scan-attempts?q=07" + )->assertOk() + ->assertJsonCount(4, 'data') + ->assertJsonPath('meta.total', 4); + + foreach ($matches as $match) { + $response->assertJsonFragment(['id' => $match->id]); + } + } + + /** @param array $attributes */ + private function createScanAttempt(User $scanner, string $data, array $attributes = []): ScanAttempt + { + $attempt = new ScanAttempt; + $attempt->forceFill(array_merge([ + 'tenant_code' => $this->tenant->codigo, + 'scanner_user_id' => $scanner->id, + 'data' => $data, + 'result' => ScanAttemptResult::Accepted, + 'resolved_at' => now(), + ], $attributes)); + $attempt->save(); + + return $attempt; + } + private function createAttachment(string $filename): Attachment { return Attachment::query()->create([ diff --git a/tests/Feature/Tenant/BootstrapAdminAppControllerTest.php b/tests/Feature/Tenant/BootstrapAdminAppControllerTest.php index 9912805..c9fdd36 100644 --- a/tests/Feature/Tenant/BootstrapAdminAppControllerTest.php +++ b/tests/Feature/Tenant/BootstrapAdminAppControllerTest.php @@ -20,6 +20,7 @@ class BootstrapAdminAppControllerTest extends TestCase 'codigo' => 'shopit', 'nombre' => 'ShopIt', 'dominio' => 'admin.shopit.test', + 'site_title' => 'ShopIt Website Type', 'primary_color' => '#112233', 'secondary_color' => '#445566', 'danger_color' => '#aa0000', @@ -38,6 +39,7 @@ class BootstrapAdminAppControllerTest extends TestCase $this->getJson('/api/v1/adminapp/bootstrap/ADMIN.SHOPIT.TEST') ->assertOk() ->assertJsonPath('data.website_type_code', 'shopit') + ->assertJsonPath('data.site_title', 'ShopIt Website Type') ->assertJsonPath('data.primary_color', '#112233') ->assertJsonPath('data.warning_color', '#ffaa00') ->assertJsonPath('data.login_header_footer_color', '#313131') diff --git a/tests/Feature/Tenant/BootstrapScannerControllerTest.php b/tests/Feature/Tenant/BootstrapScannerControllerTest.php index c40c9e1..79ca2c8 100644 --- a/tests/Feature/Tenant/BootstrapScannerControllerTest.php +++ b/tests/Feature/Tenant/BootstrapScannerControllerTest.php @@ -21,6 +21,7 @@ class BootstrapScannerControllerTest extends TestCase 'nombre' => 'ShopIt', 'dominio' => 'admin.shopit.test', 'scanner_domain' => 'scanner.shopit.test', + 'site_title' => 'ShopIt Website Type', 'primary_color' => '#112233', 'secondary_color' => '#445566', 'danger_color' => '#aa0000', @@ -39,6 +40,7 @@ class BootstrapScannerControllerTest extends TestCase $this->getJson('/api/v1/scanner/bootstrap/SCANNER.SHOPIT.TEST') ->assertOk() ->assertJsonPath('data.website_type_code', 'shopit') + ->assertJsonPath('data.site_title', 'ShopIt Website Type') ->assertJsonPath('data.primary_color', '#112233') ->assertJsonPath('data.site_logo', $siteLogo->getTemporaryUrl(1440)) ->assertJsonPath('data.footer_logo', null) diff --git a/tests/Feature/Tenant/TenantRefundConfigurationTest.php b/tests/Feature/Tenant/TenantRefundConfigurationTest.php new file mode 100644 index 0000000..6091bbb --- /dev/null +++ b/tests/Feature/Tenant/TenantRefundConfigurationTest.php @@ -0,0 +1,159 @@ +createTenant('refund-defaults'); + + $this->assertFalse($tenant->allow_ticket_refund); + $this->assertFalse($tenant->allow_ticket_total_refund); + $this->assertFalse($tenant->allow_ticket_partial_refund); + $this->assertSame('0.00', $tenant->ticket_partial_refund_percentage); + + $this->getJson("/api/tenants/{$tenant->codigo}") + ->assertOk() + ->assertJsonPath('data.allow_ticket_refund', false) + ->assertJsonPath('data.allow_ticket_total_refund', false) + ->assertJsonPath('data.allow_ticket_partial_refund', false) + ->assertJsonPath('data.ticket_partial_refund_percentage', '0.00'); + } + + public function test_refund_configuration_can_be_updated_and_validates_its_precision(): void + { + $tenant = $this->createTenant('refund-update'); + + $this->putJson("/api/tenants/{$tenant->codigo}", [ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.50, + ]) + ->assertOk() + ->assertJsonPath('data.allow_ticket_refund', true) + ->assertJsonPath('data.allow_ticket_total_refund', true) + ->assertJsonPath('data.allow_ticket_partial_refund', true) + ->assertJsonPath('data.ticket_partial_refund_percentage', '25.50'); + + $this->assertDatabaseHas('tenants', [ + 'id' => $tenant->id, + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.50, + ]); + + $this->putJson("/api/tenants/{$tenant->codigo}", [ + 'ticket_partial_refund_percentage' => 100, + ])->assertUnprocessable() + ->assertJsonValidationErrors('ticket_partial_refund_percentage'); + + $this->putJson("/api/tenants/{$tenant->codigo}", [ + 'ticket_partial_refund_percentage' => 12.345, + ])->assertUnprocessable() + ->assertJsonValidationErrors('ticket_partial_refund_percentage'); + } + + public function test_tenant_allow_refund_logic(): void + { + $tenant = new Tenant([ + 'allow_ticket_refund' => false, + 'allow_ticket_total_refund' => false, + 'allow_ticket_partial_refund' => false, + 'ticket_partial_refund_percentage' => 0, + ]); + + $this->assertFalse($tenant->allow_refund()); + $this->assertFalse($tenant->allowRefund()); + $this->assertFalse($tenant->allow_refund); + $this->assertFalse($tenant->allow_partial_refund()); + $this->assertFalse($tenant->allowPartialRefund()); + + // Partial refund enabled but percentage is 0 / unset + $tenant->allow_ticket_partial_refund = true; + $tenant->ticket_partial_refund_percentage = 0; + $this->assertFalse($tenant->allow_refund()); + $this->assertFalse($tenant->allow_partial_refund()); + + // Partial refund enabled and percentage is set + $tenant->ticket_partial_refund_percentage = 25.50; + $this->assertFalse($tenant->allow_refund()); + $this->assertFalse($tenant->allow_partial_refund()); + + $tenant->allow_ticket_refund = true; + $this->assertTrue($tenant->allow_refund()); + $this->assertTrue($tenant->allowRefund()); + $this->assertTrue($tenant->allow_refund); + $this->assertTrue($tenant->allow_partial_refund()); + $this->assertTrue($tenant->allowPartialRefund()); + + // Total refund enabled, partial refund disabled + $tenant->allow_ticket_partial_refund = false; + $tenant->allow_ticket_total_refund = true; + $tenant->ticket_partial_refund_percentage = 0; + $this->assertTrue($tenant->allow_refund()); + $this->assertFalse($tenant->allow_partial_refund()); + + // Total refund enabled and partial refund enabled with percentage + $tenant->allow_ticket_partial_refund = true; + $tenant->ticket_partial_refund_percentage = 50.00; + $this->assertTrue($tenant->allow_refund()); + $this->assertTrue($tenant->allow_partial_refund()); + + $tenant->allow_ticket_refund = false; + $this->assertFalse($tenant->allow_refund()); + $this->assertFalse($tenant->allow_partial_refund()); + $this->assertTrue($tenant->allow_ticket_total_refund); + $this->assertTrue($tenant->allow_ticket_partial_refund); + $this->assertSame('50.00', $tenant->ticket_partial_refund_percentage); + } + + private function createTenant(string $code): Tenant + { + $attachmentIds = collect(['header', 'footer'])->map(function (string $name): int { + $key = (string) Str::uuid(); + + return Attachment::query()->create([ + 'key' => $key, + 'path' => "tenants/{$key}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ])->id; + }); + + $clientId = DB::table('clients')->insertGetId([ + 'code' => $code, + 'name' => Str::headline($code), + ]); + + $tenantId = DB::table('tenants')->insertGetId([ + 'client_id' => $clientId, + 'codigo' => $code, + 'nombre' => Str::headline($code), + 'dominio' => "{$code}.test", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $attachmentIds[0], + 'footer_logo_id' => $attachmentIds[1], + ]); + + return Tenant::query()->findOrFail($tenantId); + } +} diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 0ac1a67..cba569f 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -17,11 +17,13 @@ use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketRefund; use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider; use Database\Seeders\AttributeSeeder; use Database\Seeders\AuthorizationSeeder; use Database\Seeders\FiestaFutbolInfantilProductSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -72,6 +74,11 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.tenant_code', $tenant->codigo) ->assertJsonPath('data.0.values.id', $ticket->id) ->assertJsonPath('data.0.values.status', Ticket::STATUS_ACTIVE) + ->assertJsonPath('data.0.status_label', 'Activo') + ->assertJsonPath('data.0.allow_refund', false) + ->assertJsonPath('data.0.is_active', true) + ->assertJsonPath('data.0.can_cancel', true) + ->assertJsonPath('data.0.can_refund', false) ->assertJsonMissingPath('data.0.values.ticket') ->assertJsonPath('data.0.values.date', '-') ->assertJsonPath('data.0.values.size', '-') @@ -79,6 +86,434 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('meta.total', 1); } + public function test_it_exposes_allow_refund_flag_in_tickets_list(): void + { + $tenant = $this->createTenant('ticket-allow-refund'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $this->createTicket($tenant, $admin); + + // Default: neither total nor partial refund allowed + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.allow_refund', false) + ->assertJsonPath('data.0.can_refund', false); + + // Total refund allowed + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.allow_refund', true) + ->assertJsonPath('data.0.can_refund', true); + + // Partial refund allowed with percentage set + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => false, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 20.00, + ]); + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.allow_refund', true) + ->assertJsonPath('data.0.can_refund', true); + + // Master toggle disabled while preserving the partial refund preference + $tenant->update(['allow_ticket_refund' => false]); + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.allow_refund', false) + ->assertJsonPath('data.0.can_refund', false); + $this->assertTrue($tenant->fresh()->allow_ticket_partial_refund); + $this->assertSame('20.00', $tenant->fresh()->ticket_partial_refund_percentage); + + $tenant->update(['allow_ticket_refund' => true]); + + // Partial refund enabled but percentage is 0 + $tenant->update([ + 'ticket_partial_refund_percentage' => 0, + ]); + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.allow_refund', false) + ->assertJsonPath('data.0.can_refund', false); + } + + public function test_it_cancels_a_ticket_from_the_authenticated_tenant(): void + { + $tenant = $this->createTenant('ticket-cancellation'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + $ticket = $this->createTicket($tenant, $admin); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/cancel") + ->assertOk() + ->assertJsonPath('data.id', $ticket->id) + ->assertJsonPath('data.status', Ticket::STATUS_CANCELLED) + ->assertJsonPath('data.status_label', 'Cancelado'); + + $this->assertNotNull($ticket->fresh()->cancelled_at); + } + + public function test_it_does_not_cancel_a_ticket_with_another_terminal_status(): void + { + $tenant = $this->createTenant('ticket-cancellation'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + $ticket = $this->createTicket($tenant, $admin, ['disabled_at' => now()]); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/cancel") + ->assertUnprocessable() + ->assertJsonValidationErrors('status'); + + $this->assertNull($ticket->fresh()->cancelled_at); + } + + public function test_it_totally_refunds_a_ticket_when_the_tenant_allows_it(): void + { + $tenant = $this->createTenant('ticket-total-refund'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + $inventory = $ticket->sourceCatalogItem->inventory; + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ + 'refund_type' => 'total', + ]) + ->assertOk() + ->assertJsonPath('data.id', $ticket->id) + ->assertJsonPath('data.status', Ticket::STATUS_REFUNDED) + ->assertJsonPath('data.status_label', 'Reembolso total') + ->assertJsonMissingPath('data.refunded_amount') + ->assertJsonPath('data.refund.type', TicketRefund::TYPE_TOTAL) + ->assertJsonPath('data.refund.type_label', 'Reembolso total') + ->assertJsonPath('data.refund.amount', '100.00') + ->assertJsonPath('data.refund.created_by', $admin->nombre_apellido); + + $this->assertNotNull($ticket->fresh()->refunded_at); + $this->assertSame(1, $inventory->fresh()->real_stock); + $this->assertSame(1, $inventory->fresh()->sold_units); + $this->assertSame(1, $inventory->fresh()->refunded_units); + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => $ticket->id, + 'purchase_item_id' => $purchaseItem->id, + 'created_by_user_id' => $admin->id, + 'type' => TicketRefund::TYPE_TOTAL, + 'amount' => '100.00', + ]); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ + 'refund_type' => 'total', + ])->assertUnprocessable(); + $this->assertSame(1, $inventory->fresh()->real_stock); + $this->assertSame(1, $inventory->fresh()->refunded_units); + $this->assertSame(1, $purchaseItem->ticketRefunds()->count()); + } + + public function test_it_partially_refunds_a_ticket_using_the_tenant_percentage(): void + { + $tenant = $this->createTenant('ticket-partial-refund'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.50, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + $inventory = $ticket->sourceCatalogItem->inventory; + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ + 'refund_type' => 'partial', + ]) + ->assertOk() + ->assertJsonPath('data.status', Ticket::STATUS_REFUNDED) + ->assertJsonPath('data.status_label', 'Reembolso parcial') + ->assertJsonMissingPath('data.refunded_amount') + ->assertJsonPath('data.refund.type', TicketRefund::TYPE_PARTIAL) + ->assertJsonPath('data.refund.amount', '25.50'); + + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => $ticket->id, + 'type' => TicketRefund::TYPE_PARTIAL, + 'amount' => '25.50', + ]); + $this->assertSame(1, $inventory->fresh()->real_stock); + $this->assertSame(1, $inventory->fresh()->refunded_units); + } + + public function test_backfill_restores_existing_refunds_before_new_refunds(): void + { + $tenant = $this->createTenant('ticket-historical-refund'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$historicalTicket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + $inventory = $historicalTicket->sourceCatalogItem->inventory; + $inventory->update(['sold_units' => 2]); + $purchaseItem->update(['cantidad' => 2, 'total' => '200.00']); + $historicalTicket->markAsRefunded(); + $historicalTicket->save(); + TicketRefund::query()->create([ + 'ticket_id' => $historicalTicket->id, + 'purchase_item_id' => $purchaseItem->id, + 'type' => TicketRefund::TYPE_TOTAL, + 'amount' => '100.00', + ]); + + $this->assertSame(0, $inventory->fresh()->refunded_units); + $this->assertSame(0, $inventory->fresh()->real_stock); + + Log::shouldReceive('info')->once()->with('inventory.refunded_units_backfill.completed', [ + 'refunds_seen' => 1, + 'refunds_applied' => 1, + 'bundles_skipped' => 0, + 'inventories_updated' => 1, + 'refunded_units_added' => 1, + 'real_stock_added' => 1, + ]); + $this->artisan('tickets:backfill-refunded-units')->assertSuccessful(); + + $this->assertSame(1, $inventory->fresh()->refunded_units); + $this->assertSame(1, $inventory->fresh()->real_stock); + + $newTicket = $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $purchaseItem->id, + 'source_catalog_item_id' => $historicalTicket->source_catalog_item_id, + ]); + $this->postJson("/api/v1/adminapp/tenant/tickets/{$newTicket->id}/refund", [ + 'refund_type' => TicketRefund::TYPE_TOTAL, + ])->assertOk(); + + $this->assertSame(2, $inventory->fresh()->sold_units); + $this->assertSame(2, $inventory->fresh()->refunded_units); + $this->assertSame(2, $inventory->fresh()->real_stock); + } + + public function test_backfill_command_fails_without_changing_existing_refunded_units(): void + { + $inventory = Inventory::query()->create([ + 'real_stock' => 3, + 'refunded_units' => 1, + ]); + + $this->artisan('tickets:backfill-refunded-units')->assertFailed(); + + $this->assertSame(1, $inventory->fresh()->refunded_units); + $this->assertSame(3, $inventory->fresh()->real_stock); + } + + public function test_it_records_different_refund_types_for_tickets_from_the_same_purchase_item(): void + { + $tenant = $this->createTenant('ticket-mixed-refunds'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.00, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$partialTicket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + $purchaseItem->update(['cantidad' => 2, 'total' => '200.00']); + $purchaseItem->purchase->update(['total' => '200.00']); + $totalTicket = $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $purchaseItem->id, + 'source_catalog_item_id' => $partialTicket->source_catalog_item_id, + ]); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$partialTicket->id}/refund", [ + 'refund_type' => TicketRefund::TYPE_PARTIAL, + ])->assertOk()->assertJsonPath('data.status_label', 'Reembolso parcial'); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$totalTicket->id}/refund", [ + 'refund_type' => TicketRefund::TYPE_TOTAL, + ])->assertOk()->assertJsonPath('data.status_label', 'Reembolso total'); + + $this->assertSame( + '125.00', + number_format((float) $purchaseItem->ticketRefunds()->sum('amount'), 2, '.', ''), + ); + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => $partialTicket->id, + 'type' => TicketRefund::TYPE_PARTIAL, + 'amount' => '25.00', + ]); + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => $totalTicket->id, + 'type' => TicketRefund::TYPE_TOTAL, + 'amount' => '100.00', + ]); + } + + public function test_it_does_not_refund_a_ticket_when_the_requested_refund_type_is_disabled(): void + { + $tenant = $this->createTenant('ticket-refund-disabled'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ + 'refund_type' => 'total', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('refund_type'); + + $this->assertNull($ticket->fresh()->refunded_at); + $this->assertSame(0, $purchaseItem->ticketRefunds()->count()); + } + + public function test_it_validates_the_refund_type(): void + { + $tenant = $this->createTenant('ticket-refund-validation'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ + 'refund_type' => 'invalid', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('refund_type'); + } + + public function test_it_calculates_total_and_partial_refund_for_a_ticket(): void + { + $tenant = $this->createTenant('ticket-calc-both'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 30.00, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") + ->assertOk() + ->assertJsonPath('data.total', '100.00') + ->assertJsonPath('data.partial', '30.00'); + } + + public function test_it_calculates_only_total_when_partial_is_disabled(): void + { + $tenant = $this->createTenant('ticket-calc-total'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => false, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket] = $this->createRefundableTicket($tenant, $admin, '150.00'); + + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") + ->assertOk() + ->assertJsonPath('data.total', '150.00') + ->assertJsonPath('data.partial', null); + } + + public function test_it_returns_null_when_remaining_item_balance_is_insufficient(): void + { + $tenant = $this->createTenant('ticket-calc-insufficient'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 50.00, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + // Simulate 70 already refunded out of 100 on the item (remaining is 30). + $previousTicket = $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $purchaseItem->id, + 'refunded_at' => now(), + ]); + TicketRefund::query()->create([ + 'ticket_id' => $previousTicket->id, + 'purchase_item_id' => $purchaseItem->id, + 'created_by_user_id' => $admin->id, + 'type' => TicketRefund::TYPE_PARTIAL, + 'amount' => '70.00', + ]); + + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") + ->assertOk() + // Total is 100, which exceeds remaining 30 -> total is null + ->assertJsonPath('data.total', null) + // Partial is 50, which exceeds remaining 30 -> partial is null + ->assertJsonPath('data.partial', null); + } + + public function test_it_fails_calculating_refund_if_ticket_is_not_active(): void + { + $tenant = $this->createTenant('ticket-calc-inactive'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + $ticket->update(['cancelled_at' => now()]); + + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") + ->assertUnprocessable() + ->assertJsonValidationErrors('refund'); + } + + public function test_it_cannot_calculate_refund_for_another_tenants_ticket(): void + { + $tenantA = $this->createTenant('ticket-calc-a'); + $tenantB = $this->createTenant('ticket-calc-b'); + $tenantA->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); + $tenantB->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); + + $adminA = $this->createAdminAppUser($tenantA); + $adminB = $this->createAdminAppUser($tenantB); + $this->grantTicketsMenu($tenantA); + + [$ticketB] = $this->createRefundableTicket($tenantB, $adminB, '100.00'); + + Sanctum::actingAs($adminA); + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticketB->id}/refund") + ->assertNotFound(); + } + public function test_it_searches_by_id_and_does_not_search_by_uuid(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); @@ -254,18 +689,79 @@ class AdminAppTicketControllerTest extends TestCase $this->createTicket($tenant, $admin)->update(['used_at' => now()]); $this->createTicket($tenant, $admin); + $this->createTicket($tenant, $admin)->update(['cancelled_at' => now()]); + $refundedTicket = $this->createTicket($tenant, $admin); + $refundedTicket->update(['refunded_at' => now()]); + $this->createTicket($tenant, $admin)->update(['disabled_at' => now()]); $this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'refund-summary-item', + 'nombre' => 'Entrada', + 'precio' => '1000.00', + ]); + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PAID, + 'total' => '1000.00', + ]); + $purchaseItem = PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => 'Entrada', + 'item_nombre' => 'Entrada', + 'cantidad' => 1, + 'precio_unitario' => '1000.00', + 'total' => '1000.00', + ]); + $refundedTicket->update(['source_purchase_item_id' => $purchaseItem->id]); + TicketRefund::query()->create([ + 'ticket_id' => $refundedTicket->id, + 'purchase_item_id' => $purchaseItem->id, + 'created_by_user_id' => $admin->id, + 'type' => TicketRefund::TYPE_PARTIAL, + 'amount' => '250.00', + ]); + $otherPurchase = Purchase::query()->create([ + 'tenant_codigo' => $otherTenant->codigo, + 'status' => Purchase::STATUS_PAID, + 'total' => '2000.00', + ]); + $otherPurchaseItem = PurchaseItem::query()->create([ + 'compra_id' => $otherPurchase->id, + 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => 'Otra entrada', + 'item_nombre' => 'Otra entrada', + 'cantidad' => 1, + 'precio_unitario' => '2000.00', + 'total' => '2000.00', + ]); + $otherRefundedTicket = $this->createTicket($otherTenant, $otherUser, [ + 'source_purchase_item_id' => $otherPurchaseItem->id, + 'refunded_at' => now(), + ]); + TicketRefund::query()->create([ + 'ticket_id' => $otherRefundedTicket->id, + 'purchase_item_id' => $otherPurchaseItem->id, + 'created_by_user_id' => $otherUser->id, + 'type' => TicketRefund::TYPE_TOTAL, + 'amount' => '2000.00', + ]); + $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') ->assertOk() ->assertJsonCount(0, 'data') ->assertJsonPath('scanned_tickets', 0) - ->assertJsonPath('total_tickets', 0); + ->assertJsonPath('total_tickets', 0) + ->assertJsonPath('refunded_total', '250.00'); $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() + ->assertJsonCount(5, 'data') ->assertJsonPath('scanned_tickets', 1) - ->assertJsonPath('total_tickets', 2); + ->assertJsonPath('total_tickets', 2) + ->assertJsonPath('refunded_total', '250.00'); } public function test_it_returns_structured_variant_properties(): void @@ -335,6 +831,7 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.order_number', $purchase->id) ->assertJsonPath('data.0.product', 'Remera') ->assertJsonPath('data.0.amount', '8000.00') + ->assertJsonMissingPath('data.0.refunded_amount') ->assertJsonPath('data.0.status', Ticket::STATUS_USED) ->assertJsonPath('data.0.scanned_by', $admin->nombre_apellido) ->assertJsonPath('data.0.variant_properties.0.code', 'size') @@ -418,6 +915,50 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('total_tickets', 1); } + public function test_it_shows_the_effective_date_after_multiple_reschedules_and_suspension(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + Sanctum::actingAs($admin); + + $entry = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', 'abono')->firstOrFail(); + $variant = $entry->variants()->whereHas('eventDates')->firstOrFail(); + $original = $variant->selectedEventDates()->firstOrFail(); + $middle = $tenant->eventDates()->create([ + 'date' => '2026-11-01', 'time_start' => '09:00', 'time_end' => '18:00', + ]); + $latest = $tenant->eventDates()->create([ + 'date' => '2026-11-02', 'time_start' => '09:00', 'time_end' => '18:00', + ]); + $original->update(['rescheduled_to_event_date_id' => $middle->id]); + $middle->update(['rescheduled_to_event_date_id' => $latest->id]); + $ticket = $this->createTicket($tenant, $admin, [ + 'source_catalog_item_id' => $entry->id, + 'source_variant_id' => $variant->id, + ]); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.values.date', '10/10, 11/10, 12/10, 02/11'); + + $latest->update(['suspended_at' => now()]); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.values.date', '10/10, 11/10, 12/10'); + + foreach ($variant->selectedEventDates()->skip(1) as $date) { + $date->update(['suspended_at' => now()]); + } + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.values.date', '-'); + } + public function test_it_exposes_and_filters_merchandise_color_and_size(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); @@ -498,6 +1039,28 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.id', $active->id); } + public function test_it_filters_persisted_ticket_statuses(): void + { + $tenant = $this->createTenant('ticket-statuses'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + foreach ([ + Ticket::STATUS_DISABLED => 'disabled_at', + Ticket::STATUS_CANCELLED => 'cancelled_at', + Ticket::STATUS_REFUNDED => 'refunded_at', + ] as $status => $timestamp) { + $ticket = $this->createTicket($tenant, $admin, [$timestamp => now()]); + + $this->getJson('/api/v1/adminapp/tenant/tickets?status='.$status) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.status', $status); + } + } + public function test_it_downloads_filtered_ticket_reports(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); @@ -603,6 +1166,45 @@ class AdminAppTicketControllerTest extends TestCase ]); } + /** @return array{Ticket, PurchaseItem} */ + private function createRefundableTicket(Tenant $tenant, User $admin, string $amount): array + { + $inventory = Inventory::query()->create(['sold_units' => 1]); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_id' => $inventory->id, + 'slug' => 'ticket-reembolsable-'.Str::uuid(), + 'nombre' => 'Ticket reembolsable', + 'precio' => $amount, + ]); + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $admin->id, + 'status' => Purchase::STATUS_PAID, + 'nombre_apellido' => $admin->nombre_apellido, + 'total' => $amount, + ]); + $purchaseItem = PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => 'Ticket reembolsable', + 'descripcion' => '', + 'slug' => 'ticket-reembolsable', + 'item_nombre' => 'Ticket reembolsable', + 'cantidad' => 1, + 'precio_unitario' => $amount, + 'total' => $amount, + ]); + + return [ + $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $purchaseItem->id, + 'source_catalog_item_id' => $catalogItem->id, + ]), + $purchaseItem, + ]; + } + private function grantTicketsMenu(Tenant $tenant): void { $menu = Menu::query()->create([ diff --git a/tests/Feature/Ticket/PrepareLoadTestTicketsCommandTest.php b/tests/Feature/Ticket/PrepareLoadTestTicketsCommandTest.php index b7e2867..0124460 100644 --- a/tests/Feature/Ticket/PrepareLoadTestTicketsCommandTest.php +++ b/tests/Feature/Ticket/PrepareLoadTestTicketsCommandTest.php @@ -68,7 +68,7 @@ class PrepareLoadTestTicketsCommandTest extends TestCase $first = $rows[0]; $this->withToken($first['scanner_token']) - ->postJson("/api/v1/scanner/tickets/{$first['ticket_uuid']}/scan") + ->postJson('/api/v1/scanner/tickets/scan', ['data' => $first['ticket_uuid']]) ->assertOk() ->assertJsonPath('data.ticket', $first['ticket_uuid']); diff --git a/tests/Feature/Ticket/ScannerTicketControllerTest.php b/tests/Feature/Ticket/ScannerTicketControllerTest.php index 0ed42df..71b7a22 100644 --- a/tests/Feature/Ticket/ScannerTicketControllerTest.php +++ b/tests/Feature/Ticket/ScannerTicketControllerTest.php @@ -10,6 +10,8 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; +use App\Domains\Ticket\Enums\ScanAttemptResult; +use App\Domains\Ticket\Models\ScanAttempt; use App\Domains\Ticket\Models\Ticket; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -50,108 +52,163 @@ class ScannerTicketControllerTest extends TestCase public function test_scanner_routes_require_authentication_and_scan_permission(): void { - $this->getJson('/api/v1/scanner/tickets')->assertUnauthorized(); + $this->getJson('/api/v1/scanner/attempts')->assertUnauthorized(); Sanctum::actingAs(User::factory()->create([ 'rol_codigo' => RoleCode::User->value, 'tenant_codigo' => $this->tenant->codigo, ])); - $this->getJson('/api/v1/scanner/tickets')->assertForbidden(); + $this->getJson('/api/v1/scanner/attempts')->assertForbidden(); } - public function test_scanner_can_list_only_its_scanned_tickets_using_adminapp_format(): void + public function test_scanner_can_list_only_its_scan_attempts(): void { - $older = $this->createTicket('11111111-1111-4111-8111-111111111111', [ - 'used_at' => now()->subMinutes(2), - 'scanner_user_id' => $this->scanner->id, + $olderTicket = $this->createTicket('11111111-1111-4111-8111-111111111111'); + $newerTicket = $this->createTicket('22222222-2222-4222-8222-222222222222'); + $older = $this->createScanAttempt($olderTicket->ticket, [ + 'ticket_id' => $olderTicket->id, + 'created_at' => now()->subMinutes(2), ]); - $newer = $this->createTicket('22222222-2222-4222-8222-222222222222', [ - 'used_at' => now()->subMinute(), - 'scanner_user_id' => $this->scanner->id, + $newer = $this->createScanAttempt($newerTicket->ticket, [ + 'ticket_id' => $newerTicket->id, + 'created_at' => now()->subMinute(), ]); $otherScanner = User::factory()->create([ 'rol_codigo' => RoleCode::Scanner->value, 'tenant_codigo' => $this->tenant->codigo, ]); - $this->createTicket('33333333-3333-4333-8333-333333333333', [ - 'used_at' => now(), - 'scanner_user_id' => $otherScanner->id, - ]); + $this->createScanAttempt('not-this-scanner', [], $otherScanner); Sanctum::actingAs($this->scanner); - $this->getJson('/api/v1/scanner/tickets') + $this->getJson('/api/v1/scanner/attempts') ->assertOk() ->assertJsonCount(2, 'data') ->assertJsonPath('data.0.id', $newer->id) - ->assertJsonPath('data.0.ticket', $newer->ticket) - ->assertJsonPath('data.0.status', Ticket::STATUS_USED) + ->assertJsonPath('data.0.data', $newer->data) + ->assertJsonPath('data.0.ticket_id', $newerTicket->id) + ->assertJsonPath('data.0.ticket', $newerTicket->ticket) + ->assertJsonPath('data.0.resolved_at', $newer->resolved_at->toJSON()) + ->assertJsonPath('data.0.result', ScanAttemptResult::Accepted->value) + ->assertJsonPath('data.0.result_label', 'Verificado') + ->assertJsonPath('data.0.result_detail_label', 'Verificado') + ->assertJsonPath('data.0.can_view_ticket', true) ->assertJsonPath('data.1.id', $older->id) ->assertJsonPath('meta.current_page', 1) ->assertJsonPath('meta.total', 2); } - public function test_scanner_ticket_history_supports_id_search_and_pagination(): void + public function test_scanner_attempt_history_supports_data_search_and_pagination(): void { - $matching = $this->createTicket('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', [ - 'used_at' => now(), - 'scanner_user_id' => $this->scanner->id, - ]); - $this->createTicket('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', [ - 'used_at' => now()->subMinute(), - 'scanner_user_id' => $this->scanner->id, - ]); + $matching = $this->createScanAttempt('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); + $this->createScanAttempt('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); Sanctum::actingAs($this->scanner); - $this->getJson('/api/v1/scanner/tickets?q=aaaaaaaa&per_page=1') + $this->getJson('/api/v1/scanner/attempts?q=aaaaaaaa&per_page=1') ->assertOk() ->assertJsonCount(1, 'data') - ->assertJsonPath('data.0.ticket', $matching->ticket) + ->assertJsonPath('data.0.data', $matching->data) ->assertJsonPath('meta.current_page', 1) ->assertJsonPath('meta.per_page', 1) ->assertJsonPath('meta.total', 1); } - public function test_scanner_ticket_history_can_be_searched_by_database_id(): void + public function test_scanner_attempt_history_can_be_searched_by_database_id(): void { - $matching = $this->createTicket('cccccccc-cccc-4ccc-8ccc-cccccccccccc', [ - 'used_at' => now(), - 'scanner_user_id' => $this->scanner->id, - ]); - $this->createTicket('dddddddd-dddd-4ddd-8ddd-dddddddddddd', [ - 'used_at' => now()->subMinute(), - 'scanner_user_id' => $this->scanner->id, - ]); + $matching = $this->createScanAttempt('cccccccc-cccc-4ccc-8ccc-cccccccccccc'); + $this->createScanAttempt('dddddddd-dddd-4ddd-8ddd-dddddddddddd'); Sanctum::actingAs($this->scanner); - $this->getJson("/api/v1/scanner/tickets?q={$matching->id}") + $this->getJson("/api/v1/scanner/attempts?q={$matching->id}") ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $matching->id); } - public function test_scanner_ticket_history_can_be_searched_by_used_date(): void + public function test_scanner_attempt_history_can_be_searched_by_attempt_date(): void { - $matching = $this->createTicket('eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', [ - 'used_at' => '2026-08-11 14:30:00', - 'scanner_user_id' => $this->scanner->id, + $matching = $this->createScanAttempt('eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', [ + 'created_at' => '2026-08-11 14:30:00', ]); - $this->createTicket('ffffffff-ffff-4fff-8fff-ffffffffffff', [ - 'used_at' => '2026-08-10 14:30:00', - 'scanner_user_id' => $this->scanner->id, + $this->createScanAttempt('ffffffff-ffff-4fff-8fff-ffffffffffff', [ + 'created_at' => '2026-08-10 14:30:00', ]); Sanctum::actingAs($this->scanner); - $this->getJson('/api/v1/scanner/tickets?q=11%2F08%2F26') + $this->getJson('/api/v1/scanner/attempts?q=11%2F08%2F26') ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $matching->id); } + public function test_scanner_can_read_its_scan_attempt_detail(): void + { + $ticket = $this->createTicket('abababab-abab-4bab-8bab-abababababab'); + $scanAttempt = $this->createScanAttempt($ticket->ticket, [ + 'ticket_id' => $ticket->id, + ]); + Sanctum::actingAs($this->scanner); + + $this->getJson("/api/v1/scanner/attempts/{$scanAttempt->id}") + ->assertOk() + ->assertJsonPath('data.scan_attempt.id', $scanAttempt->id) + ->assertJsonPath('data.scan_attempt.ticket_id', $ticket->id) + ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::Accepted->value) + ->assertJsonPath('data.scan_attempt.result_label', 'Verificado') + ->assertJsonPath('data.scan_attempt.result_detail_label', 'Verificado') + ->assertJsonPath('data.ticket.id', $ticket->id) + ->assertJsonPath('data.ticket.ticket', $ticket->ticket) + ->assertJsonPath('data.ticket.client', $this->ticketOwner->nombre_apellido) + ->assertJsonPath('data.client.id', $this->ticketOwner->id) + ->assertJsonPath('data.client.nombre_apellido', $this->ticketOwner->nombre_apellido); + } + + public function test_scanner_cannot_read_another_scanners_attempt_detail(): void + { + $otherScanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + $scanAttempt = $this->createScanAttempt('not-this-scanner', [], $otherScanner); + Sanctum::actingAs($this->scanner); + + $this->getJson("/api/v1/scanner/attempts/{$scanAttempt->id}") + ->assertNotFound(); + } + + public function test_scan_attempt_detail_returns_null_ticket_and_client_when_unassociated(): void + { + $scanAttempt = $this->createScanAttempt('not-a-ticket', [ + 'result' => ScanAttemptResult::TicketNotFound, + ]); + Sanctum::actingAs($this->scanner); + + $this->getJson("/api/v1/scanner/attempts/{$scanAttempt->id}") + ->assertOk() + ->assertJsonPath('data.scan_attempt.id', $scanAttempt->id) + ->assertJsonPath('data.scan_attempt.result_label', 'Error') + ->assertJsonPath('data.scan_attempt.result_detail_label', 'Error') + ->assertJsonPath('data.ticket', null) + ->assertJsonPath('data.client', null); + } + + public function test_scan_attempt_detail_returns_specific_invalid_qr_label(): void + { + $scanAttempt = $this->createScanAttempt('not-a-valid-qr', [ + 'result' => ScanAttemptResult::InvalidQr, + ]); + Sanctum::actingAs($this->scanner); + + $this->getJson("/api/v1/scanner/attempts/{$scanAttempt->id}") + ->assertOk() + ->assertJsonPath('data.scan_attempt.result_label', 'Error') + ->assertJsonPath('data.scan_attempt.result_detail_label', 'QR no pertenece al evento'); + } + public function test_scanner_can_read_an_authorized_ticket_detail_by_uuid(): void { $ticket = $this->createTicket('44444444-4444-4444-8444-444444444444'); @@ -203,25 +260,80 @@ class ScannerTicketControllerTest extends TestCase $ticket = $this->createTicket('77777777-7777-4777-8777-777777777777'); Sanctum::actingAs($this->scanner); - $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") + $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket]) ->assertOk() - ->assertJsonPath('data.ticket', $ticket->ticket) - ->assertJsonPath('data.scanner_user_id', $this->scanner->id) - ->assertJsonPath('data.is_valid', false) - ->assertJsonPath('data.is_used', true); + ->assertJsonPath('data.scan_attempt.data', $ticket->ticket) + ->assertJsonPath('data.scan_attempt.ticket_id', $ticket->id) + ->assertJsonPath('data.scan_attempt.ticket', $ticket->ticket) + ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::Accepted->value) + ->assertJsonPath('data.scan_attempt.result_label', 'Verificado') + ->assertJsonPath('data.scan_attempt.result_detail_label', 'Verificado') + ->assertJsonPath('data.ticket.ticket', $ticket->ticket) + ->assertJsonPath('data.ticket.scanner_user_id', $this->scanner->id) + ->assertJsonPath('data.ticket.is_valid', false) + ->assertJsonPath('data.ticket.is_used', true) + ->assertJsonPath('data.ticket.status', Ticket::STATUS_USED) + ->assertJsonPath('data.ticket.status_label', 'Usado') + ->assertJsonMissingPath('data.ticket.disabled_at') + ->assertJsonMissingPath('data.ticket.cancelled_at') + ->assertJsonMissingPath('data.ticket.refunded_at') + ->assertJsonPath('data.ticket.client', $this->ticketOwner->nombre_apellido) + ->assertJsonPath('data.client.id', $this->ticketOwner->id) + ->assertJsonPath('data.client.nombre_apellido', $this->ticketOwner->nombre_apellido); $this->assertDatabaseHas('tickets', [ 'id' => $ticket->id, 'scanner_user_id' => $this->scanner->id, ]); + $this->assertDatabaseHas('value_changes', [ + 'tenant_code' => $this->tenant->codigo, + 'trackable_type' => $ticket->getMorphClass(), + 'trackable_id' => $ticket->id, + 'attribute' => 'used_at', + 'old_value' => null, + 'user_id' => $this->scanner->id, + ]); $this->assertNotNull($ticket->fresh()->used_at); + + $scanAttempt = ScanAttempt::query()->sole(); + $this->assertSame($ticket->ticket, $scanAttempt->data); + $this->assertSame(ScanAttemptResult::Accepted, $scanAttempt->result); + $this->assertTrue($scanAttempt->scanner->is($this->scanner)); + $this->assertTrue($scanAttempt->ticket->is($ticket)); + $this->assertTrue($scanAttempt->tenant->is($this->tenant)); + $this->assertNotNull($scanAttempt->resolved_at); + } + + public function test_scan_returns_an_attempt_for_invalid_qr_data(): void + { + Sanctum::actingAs($this->scanner); + + $this->postJson('/api/v1/scanner/tickets/scan') + ->assertOk() + ->assertJsonPath('data.scan_attempt.id', fn (mixed $id): bool => is_int($id)) + ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::InvalidQr->value); + + $this->postJson('/api/v1/scanner/tickets/scan', ['data' => 'not-a-uuid']) + ->assertOk() + ->assertJsonPath('data.scan_attempt.id', fn (mixed $id): bool => is_int($id)) + ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::InvalidQr->value); + + $scanAttempts = ScanAttempt::query()->orderBy('id')->get(); + $this->assertCount(2, $scanAttempts); + $this->assertNull($scanAttempts[0]->data); + $this->assertSame('not-a-uuid', $scanAttempts[1]->data); + $this->assertTrue($scanAttempts->every( + fn (ScanAttempt $scanAttempt): bool => $scanAttempt->result === ScanAttemptResult::InvalidQr + && $scanAttempt->scanner_user_id === $this->scanner->id + && $scanAttempt->resolved_at !== null + )); } public function test_ticket_cannot_be_scanned_twice(): void { $ticket = $this->createTicket('88888888-8888-4888-8888-888888888888'); Sanctum::actingAs($this->scanner); - $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")->assertOk(); + $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket])->assertOk(); $otherScanner = User::factory()->create([ 'rol_codigo' => RoleCode::Scanner->value, @@ -230,10 +342,16 @@ class ScannerTicketControllerTest extends TestCase $otherScanner->scanCategories()->attach($this->category); Sanctum::actingAs($otherScanner); - $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") - ->assertUnprocessable() - ->assertJsonValidationErrors('ticket'); + $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket]) + ->assertOk() + ->assertJsonPath('data.scan_attempt.id', fn (mixed $id): bool => is_int($id)) + ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::AlreadyScanned->value); $this->assertSame($this->scanner->id, $ticket->fresh()->scanner_user_id); + $this->assertDatabaseHas('scan_attempts', [ + 'scanner_user_id' => $otherScanner->id, + 'ticket_id' => $ticket->id, + 'result' => ScanAttemptResult::AlreadyScanned->value, + ]); } public function test_scanner_cannot_scan_a_ticket_from_an_unassigned_category(): void @@ -249,10 +367,16 @@ class ScannerTicketControllerTest extends TestCase ); Sanctum::actingAs($this->scanner); - $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") - ->assertUnprocessable() - ->assertJsonValidationErrors('ticket'); + $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket]) + ->assertOk() + ->assertJsonPath('data.scan_attempt.id', fn (mixed $id): bool => is_int($id)) + ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::CategoryForbidden->value); $this->assertNull($ticket->fresh()->used_at); + $this->assertDatabaseHas('scan_attempts', [ + 'scanner_user_id' => $this->scanner->id, + 'ticket_id' => $ticket->id, + 'result' => ScanAttemptResult::CategoryForbidden->value, + ]); } public function test_scanner_can_read_and_scan_any_category_when_tenant_disables_validation(): void @@ -274,9 +398,9 @@ class ScannerTicketControllerTest extends TestCase ->assertOk() ->assertJsonPath('data.id', $ticket->id); - $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") + $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket]) ->assertOk() - ->assertJsonPath('data.scanner_user_id', $this->scanner->id); + ->assertJsonPath('data.ticket.scanner_user_id', $this->scanner->id); } public function test_disabled_category_validation_does_not_allow_scanning_another_tenant(): void @@ -297,8 +421,17 @@ class ScannerTicketControllerTest extends TestCase $this->getJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}") ->assertNotFound(); - $this->postJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}/scan") - ->assertNotFound(); + $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $foreignTicket->ticket]) + ->assertOk() + ->assertJsonPath('data.scan_attempt.id', fn (mixed $id): bool => is_int($id)) + ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::TicketNotFound->value); + $this->assertDatabaseHas('scan_attempts', [ + 'tenant_code' => $this->tenant->codigo, + 'scanner_user_id' => $this->scanner->id, + 'ticket_id' => null, + 'data' => $foreignTicket->ticket, + 'result' => ScanAttemptResult::TicketNotFound->value, + ]); } public function test_adminapp_cannot_access_scanner_routes_even_with_scan_permission(): void @@ -311,10 +444,30 @@ class ScannerTicketControllerTest extends TestCase $ticket = $this->createTicket((string) Str::uuid()); Sanctum::actingAs($admin); - $this->getJson('/api/v1/scanner/tickets')->assertForbidden(); + $this->getJson('/api/v1/scanner/attempts')->assertForbidden(); $this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}")->assertForbidden(); - $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")->assertForbidden(); + $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket])->assertForbidden(); $this->assertNull($ticket->fresh()->used_at); + $this->assertDatabaseCount('scan_attempts', 0); + } + + /** @param array $attributes */ + private function createScanAttempt( + ?string $data, + array $attributes = [], + ?User $scanner = null, + ): ScanAttempt { + $scanAttempt = new ScanAttempt; + $scanAttempt->forceFill(array_merge([ + 'tenant_code' => $this->tenant->codigo, + 'scanner_user_id' => ($scanner ?? $this->scanner)->id, + 'data' => $data, + 'result' => ScanAttemptResult::Accepted, + 'resolved_at' => now(), + ], $attributes)); + $scanAttempt->save(); + + return $scanAttempt; } /** @param array $attributes */ diff --git a/tests/Feature/Ticket/TicketControllerTest.php b/tests/Feature/Ticket/TicketControllerTest.php index 588746e..dc9e0a0 100644 --- a/tests/Feature/Ticket/TicketControllerTest.php +++ b/tests/Feature/Ticket/TicketControllerTest.php @@ -5,12 +5,17 @@ namespace Tests\Feature\Ticket; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; +use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; +use App\Domains\Event\Events\EventDateRescheduled; +use App\Domains\Event\Events\EventDateSuspended; use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Services\EventService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Str; use Tests\TestCase; @@ -18,6 +23,72 @@ class TicketControllerTest extends TestCase { use RefreshDatabase; + public function test_ticket_names_follow_reschedules_without_changing_variant_selections(): void + { + Event::fake([EventDateRescheduled::class, EventDateSuspended::class]); + $tenant = $this->createTenant('rescheduled'); + $user = User::factory()->create(); + $ticket = $this->createTicket($tenant, $user, 'COMIDA'); + $item = $ticket->sourceCatalogItem; + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'service', + 'nombre' => 'Servicio', + 'type' => 'string', + ]); + $itemAttribute = $item->itemAttributes()->create([ + 'attribute_id' => $attribute->id, + 'ticket_label' => 'Servicio', + ]); + $source = $tenant->eventDates()->create([ + 'date' => '2026-10-29', + 'time_start' => '08:00:00', + 'time_end' => '10:00:00', + ]); + $variant = $item->variants()->create([ + 'event_date_id' => $source->id, + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->definitions()->create([ + 'item_attribute_id' => $itemAttribute->id, + 'value' => 'COMEDOR', + ]); + $ticket->update(['source_variant_id' => $variant->id]); + + foreach (['29/10/2026', '02/11/2026', '05/11/2026'] as $step => $expectedDate) { + if ($step > 0) { + $previous = $step === 1 ? $source : $tenant->eventDates()->whereDate('date', '2026-11-02')->firstOrFail(); + app(EventService::class)->rescheduleDateForTenant($tenant, $previous, [ + 'date' => $step === 1 ? '2026-11-02' : '2026-11-05', + ]); + } + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/{$tenant->codigo}/tickets") + ->assertOk() + ->assertJsonPath('data.0.name', "COMIDA ({$expectedDate}, Servicio COMEDOR)") + ->assertJsonPath('data.0.ticket', $ticket->ticket); + } + + $destination = $tenant->eventDates()->whereDate('date', '2026-11-05')->firstOrFail(); + $variant->eventDates()->sync([$source->id, $destination->id]); + $this->assertSame('COMIDA (05/11/2026, Servicio COMEDOR)', $ticket->fresh()->name); + $this->assertSame('29/10/2026', $variant->fresh()->selectionOptions()->get('event_date')[0]['label']); + $this->assertSame($source->id, $variant->fresh()->event_date_id); + + app(EventService::class)->suspendDateForTenant($tenant, $destination); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/{$tenant->codigo}/tickets") + ->assertOk() + ->assertJsonPath('data.0.name', 'COMIDA (05/11/2026, Servicio COMEDOR)') + ->assertJsonPath('data.0.status', 'disabled') + ->assertJsonPath('data.0.is_valid', false) + ->assertJsonPath('data.0.starts_at', null) + ->assertJsonPath('data.0.expires_at', null) + ->assertJsonPath('data.0.ticket', $ticket->ticket); + } + public function test_an_authenticated_user_can_list_their_tickets_for_the_tenant(): void { $tenant = $this->createTenant('current'); diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index b1415cc..5edbf37 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -471,7 +471,9 @@ class TicketGeneratorServiceTest extends TestCase $this->assertCount(2, $ticket->resolvedValidityGroups()); $this->assertTrue($ticket->resolvedValidityGroups()->every( fn ($group): bool => $group->validityTimes->count() === 2 - && $group->validityTimes->contains($lunch) + && $group->validityTimes->contains( + fn (ValidityTime $validityTime): bool => $validityTime->is($lunch) + ) )); $this->assertEqualsCanonicalizing( $dates->pluck('validity_time_id')->all(), diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d22d58f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,17 @@ +# Database isolation + +Run the suite with `composer test` or `vendor/bin/phpunit`. Both use +`tests/bootstrap.php`, which forces SQLite `:memory:` in all environment sources. +Tests never need a MySQL test database or the local database credentials. + +`Tests\TestCase` rejects cached configuration and validates the default connection +before application providers boot. Its connection factory also rejects persistent +databases, URLs, and alternate endpoints for named or dynamically built connections. +Tests that need Laravel must extend this base class. Do not bypass these guards to +make a failing test pass; adapt database-specific tests to SQLite or use a separately +designed disposable database workflow. + +`php tests/verify-database-safety.php` checks the guard and application wiring without +running test setup, queries, migrations, or opening PDO connections. + +The suite does not validate MySQL-specific behavior when using SQLite. diff --git a/tests/Support/InMemoryConnectionFactory.php b/tests/Support/InMemoryConnectionFactory.php new file mode 100644 index 0000000..71b7c18 --- /dev/null +++ b/tests/Support/InMemoryConnectionFactory.php @@ -0,0 +1,31 @@ +traitsUsedByTest = class_uses_recursive(static::class); - $database = (string) $app['config']->get( - 'database.connections.'.$app['config']->get('database.default').'.database' - ); - - if (! preg_match('/^shopit_(?:test|testing)(?:_\d+)?$/', $database)) { - throw new RuntimeException(sprintf( - 'Refusing to run tests against database [%s]. Use [shopit_test] or [shopit_testing].', - $database !== '' ? $database : '(empty)' - )); + if ($app->configurationIsCached()) { + throw new RuntimeException('Tests refuse cached configuration. Remove the test config cache before retrying.'); } + // Validate before providers boot or RefreshDatabase can run migrations. + $app->afterBootstrapping(LoadConfiguration::class, function (Application $app): void { + if (! $app->environment('testing')) { + throw new RuntimeException('Tests require APP_ENV=testing.'); + } + + InMemoryConnectionFactory::assertSafe((array) $app['config']->get( + 'database.connections.'.$app['config']->get('database.default') + )); + }); + + // Also guard named/dynamic connections and changes made by individual tests. + $app->extend('db.factory', fn () => new InMemoryConnectionFactory($app)); + $app->make(Kernel::class)->bootstrap(); + return $app; } } diff --git a/tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php b/tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php index 7a01e39..688f3ab 100644 --- a/tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php +++ b/tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php @@ -14,6 +14,7 @@ class AdminAppBootstrapResourceTest extends TestCase 'codigo' => 'shopit', 'nombre' => 'ShopIt', 'dominio' => 'admin.shopit.test', + 'site_title' => 'ShopIt Website Type', ]); $websiteType->setRelation('siteLogo', null); $websiteType->setRelation('footerLogo', null); @@ -23,6 +24,7 @@ class AdminAppBootstrapResourceTest extends TestCase ])->resolve(request()); $this->assertSame('shopit', $data['website_type_code']); + $this->assertSame('ShopIt Website Type', $data['site_title']); $this->assertNull($data['favicon']); $this->assertArrayNotHasKey('forms', $data); } diff --git a/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php b/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php new file mode 100644 index 0000000..3d59a4d --- /dev/null +++ b/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php @@ -0,0 +1,175 @@ +id(); + $table->string('tenant_codigo'); + $table->string('status'); + $table->unsignedBigInteger('current_purchase_id')->nullable(); + $table->unsignedBigInteger('current_stock_reservation_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Schema::create('carrito_items', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('cart_id'); + $table->unsignedBigInteger('variant_id'); + }); + Schema::create('variantes', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('event_date_id')->nullable(); + $table->softDeletes(); + }); + Schema::create('event_dates', function (Blueprint $table): void { + $table->id(); + $table->date('date')->nullable(); + $table->time('time_start')->nullable(); + }); + Schema::create('variant_event_dates', function (Blueprint $table): void { + $table->unsignedBigInteger('variant_id'); + $table->unsignedBigInteger('event_date_id'); + }); + Schema::create('compras', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('stock_reservation_id'); + }); + Schema::create('inventories', function (Blueprint $table): void { + $table->id(); + $table->integer('real_stock')->default(10); + $table->integer('reserved_stock')->default(2); + $table->integer('sold_units')->default(0); + $table->integer('refunded_units')->default(0); + }); + Schema::create('stock_reservations', function (Blueprint $table): void { + $table->id(); + $table->string('status'); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('released_at')->nullable(); + $table->timestamp('expired_at')->nullable(); + $table->string('release_reason')->nullable(); + $table->timestamps(); + }); + Schema::create('stock_reservation_lines', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('stock_reservation_id'); + $table->unsignedBigInteger('inventory_id'); + $table->integer('quantity'); + }); + DB::table('event_dates')->insert([['id' => 1], ['id' => 2]]); + DB::table('variantes')->insert([ + ['id' => 1, 'event_date_id' => 1], + ['id' => 2, 'event_date_id' => 2], + ['id' => 3, 'event_date_id' => null], + ]); + DB::table('variant_event_dates')->insert(['variant_id' => 3, 'event_date_id' => 1]); + } + + public static function releaseReasons(): array + { + return [ + [StockReservationService::REASON_EVENT_DATE_RESCHEDULED], + [StockReservationService::REASON_EVENT_DATE_SUSPENDED], + ]; + } + + #[DataProvider('releaseReasons')] + public function test_invalidates_whole_cart_and_releases_all_its_stock_only_once(string $reason): void + { + $cart = $this->cart(1); + $otherInventory = DB::table('inventories')->insertGetId([]); + DB::table('carrito_items')->insert(['cart_id' => $cart->id, 'variant_id' => 2]); + DB::table('stock_reservation_lines')->insert([ + 'stock_reservation_id' => $cart->current_stock_reservation_id, + 'inventory_id' => $otherInventory, + 'quantity' => 2, + ]); + $reservationId = $cart->current_stock_reservation_id; + $this->invalidate($reason); + $this->invalidate($reason); + + $this->assertSame(Cart::STATUS_EXPIRED, $cart->fresh()->status); + $this->assertNull($cart->fresh()->current_stock_reservation_id); + $this->assertSame(0, (int) Inventory::query()->sum('reserved_stock')); + $this->assertSame(20, (int) Inventory::query()->sum('real_stock')); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $reservationId, 'status' => StockReservation::STATUS_RELEASED, + 'release_reason' => $reason, + ]); + } + + public function test_preserves_other_dates_tenants_and_purchase_reservations(): void + { + $otherDate = $this->cart(2); + $otherTenant = $this->cart(1, 'other'); + $purchaseCart = $this->cart(1); + DB::table('compras')->insert(['stock_reservation_id' => $purchaseCart->current_stock_reservation_id]); + $currentPurchaseCart = $this->cart(1); + $currentPurchaseCart->update(['current_purchase_id' => 99]); + $affected = $this->cart(3); + + $this->invalidate(); + + foreach ([$otherDate, $otherTenant, $purchaseCart, $currentPurchaseCart] as $cart) { + $this->assertSame(Cart::STATUS_ACTIVE, $cart->fresh()->status); + $this->assertSame(StockReservation::STATUS_ACTIVE, $cart->fresh()->currentStockReservation->status); + } + $this->assertSame(Cart::STATUS_EXPIRED, $affected->fresh()->status); + $this->assertSame(8, (int) Inventory::query()->sum('reserved_stock')); + } + + public function test_rolls_back_invalidation_when_the_date_change_fails(): void + { + $cart = $this->cart(1); + DB::beginTransaction(); + $this->invalidate(); + DB::rollBack(); + + $this->assertSame(Cart::STATUS_ACTIVE, $cart->fresh()->status); + $this->assertSame(StockReservation::STATUS_ACTIVE, $cart->fresh()->currentStockReservation->status); + $this->assertSame(2, (int) Inventory::query()->sum('reserved_stock')); + } + + private function invalidate(string $reason = StockReservationService::REASON_EVENT_DATE_RESCHEDULED): void + { + app(InvalidateEventDateCartsService::class)->invalidate(new Tenant(['codigo' => 'acme']), collect([1]), $reason); + } + + private function cart(int $variantId, string $tenantCode = 'acme'): Cart + { + $reservation = StockReservation::query()->create([ + 'status' => StockReservation::STATUS_ACTIVE, 'expires_at' => now()->addHour(), + ]); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenantCode, 'status' => Cart::STATUS_ACTIVE, + 'current_stock_reservation_id' => $reservation->id, + ]); + DB::table('carrito_items')->insert(['cart_id' => $cart->id, 'variant_id' => $variantId]); + DB::table('stock_reservation_lines')->insert([ + 'stock_reservation_id' => $reservation->id, + 'inventory_id' => DB::table('inventories')->insertGetId([]), + 'quantity' => 2, + ]); + + return $cart; + } +} diff --git a/tests/Unit/Catalog/CatalogModelsTest.php b/tests/Unit/Catalog/CatalogModelsTest.php index 84ef63d..45ca556 100644 --- a/tests/Unit/Catalog/CatalogModelsTest.php +++ b/tests/Unit/Catalog/CatalogModelsTest.php @@ -309,7 +309,7 @@ class CatalogModelsTest extends TestCase $this->assertSame(2, $inventory->sold_units); $this->assertSame(7, $inventory->availableStock()); $this->assertInstanceOf(CatalogItem::class, $inventory->catalogItem()->getRelated()); - $this->assertInstanceOf(Variant::class, $inventory->variant()->getRelated()); + $this->assertInstanceOf(Variant::class, $inventory->variants()->getRelated()); } public function test_catalog_item_aggregates_variant_inventory(): void @@ -346,6 +346,34 @@ class CatalogModelsTest extends TestCase $this->assertSame([$unavailable, $available], $item->visibleVariants()->all()); } + public function test_catalog_item_never_exposes_variants_with_inactive_event_dates(): void + { + $activeDate = new EventDate(['date' => '2026-10-10']); + $rescheduledDate = new EventDate([ + 'date' => '2026-09-08', + 'rescheduled_to_event_date_id' => 100, + ]); + $suspendedDate = new EventDate([ + 'date' => '2026-10-08', + 'suspended_at' => now(), + ]); + + $active = (new Variant)->setRelation('eventDates', new EloquentCollection([$activeDate])); + $rescheduled = (new Variant)->setRelation('eventDates', new EloquentCollection([$rescheduledDate])); + $suspended = (new Variant)->setRelation('eventDates', new EloquentCollection([$suspendedDate])); + $active->id = 10; + $rescheduled->id = 20; + $suspended->id = 30; + + $item = new CatalogItem; + $item->inventory_policy = InventoryPolicy::Unlimited; + $item->setRelation('variants', new EloquentCollection([$active, $rescheduled, $suspended])); + + $this->assertSame([$active], $item->visibleVariants()->all()); + $this->assertSame([$active], $item->visibleVariants($rescheduled->id)->all()); + $this->assertSame([$active], $item->visibleVariants($suspended->id)->all()); + } + public function test_catalog_item_prioritizes_its_inventory_over_variants(): void { $item = new CatalogItem; diff --git a/tests/Unit/Event/EventDateGroupingServiceTest.php b/tests/Unit/Event/EventDateGroupingServiceTest.php new file mode 100644 index 0000000..2662b4c --- /dev/null +++ b/tests/Unit/Event/EventDateGroupingServiceTest.php @@ -0,0 +1,78 @@ +date(1, '2026-10-13', 5); + $source22 = $this->date(2, '2026-10-22', 3); + $middle24 = $this->date(3, '2026-10-24', 5); + $active25 = $this->date(4, '2026-10-25'); + $destination30 = $this->date(5, '2026-10-30'); + + $grouped = (new EventDateGroupingService)->group(new Collection([ + $source13, + $source22, + $middle24, + $active25, + $destination30, + ])); + + $this->assertSame([4, 5], $grouped->map->getKey()->all()); + $this->assertSame( + [1, 2, 3], + $grouped->last()->getRelation('adminRescheduledDates')->modelKeys(), + ); + $this->assertSame( + [5, 5, 5], + $grouped->last()->getRelation('adminRescheduledDates') + ->pluck('rescheduled_to_event_date_id') + ->all(), + ); + + $tenant = new Tenant(['codigo' => 'acme']); + $tenant->id = 1; + $tenant->setRelation('eventDates', new Collection([ + $source13, + $source22, + $middle24, + $active25, + $destination30, + ])); + $tenant->setRelation('socialMedia', new Collection); + $payload = EventResource::make($tenant)->response()->getData(true)['data']; + + $this->assertCount(2, $payload['dates']); + $this->assertSame(4, $payload['dates'][0]['id']); + $this->assertSame([1, 2, 3], array_column($payload['dates'][1]['rescheduled_dates'], 'id')); + $this->assertSame( + [5, 5, 5], + array_column( + $payload['dates'][1]['rescheduled_dates'], + 'rescheduled_to_event_date_id', + ), + ); + } + + private function date(int $id, string $date, ?int $destinationId = null): EventDate + { + $eventDate = new EventDate([ + 'date' => $date, + 'time_start' => '00:00:00', + 'time_end' => '23:59:00', + 'rescheduled_to_event_date_id' => $destinationId, + ]); + $eventDate->id = $id; + + return $eventDate; + } +} diff --git a/tests/Unit/Event/EventDateInfoFormatterTest.php b/tests/Unit/Event/EventDateInfoFormatterTest.php new file mode 100644 index 0000000..0742a73 --- /dev/null +++ b/tests/Unit/Event/EventDateInfoFormatterTest.php @@ -0,0 +1,70 @@ +id = 10; + + $this->assertNull((new EventDateInfoFormatter)->format($date, collect())); + } + + public function test_it_formats_cancellation_and_source_dates_for_a_reschedule_destination(): void + { + $date = new EventDate; + $date->id = 20; + $date->suspended_at = Carbon::parse('2026-09-15'); + + $changes = collect([ + $this->reschedule(1, 20, '2026-10-09'), + $this->reschedule(2, 20, '2026-10-10'), + ]); + + $this->assertSame( + 'Esta fecha fue cancelada. 09/10/2026 y 10/10/2026 se reprogramaron para este día.', + (new EventDateInfoFormatter)->format($date, $changes), + ); + } + + public function test_it_includes_sources_that_reach_the_destination_through_intermediate_dates(): void + { + $date = new EventDate; + $date->id = 30; + + $changes = collect([ + $this->reschedule(13, 24, '2026-10-13'), + $this->reschedule(22, 24, '2026-10-22'), + $this->reschedule(24, 30, '2026-10-24'), + $this->reschedule(29, 30, '2026-10-29'), + $this->reschedule(10, 11, '2026-10-10'), + ]); + + $this->assertSame( + '13/10/2026, 22/10/2026, 24/10/2026 y 29/10/2026 se reprogramaron para este día.', + (new EventDateInfoFormatter)->format($date, $changes), + ); + } + + private function reschedule(int $sourceId, int $destinationId, string $previousDate): EventDateChange + { + $change = new EventDateChange; + $change->setRawAttributes([ + 'change_type' => EventDateChangeType::Rescheduled->value, + 'source_event_date_id' => $sourceId, + 'destination_event_date_id' => $destinationId, + 'previous_date' => $previousDate, + ]); + + return $change; + } +} diff --git a/tests/Unit/Event/EventDateNoticeFormatterTest.php b/tests/Unit/Event/EventDateNoticeFormatterTest.php new file mode 100644 index 0000000..8ff0c73 --- /dev/null +++ b/tests/Unit/Event/EventDateNoticeFormatterTest.php @@ -0,0 +1,101 @@ +format(collect([ + $this->change(EventDateChangeType::Suspended, '2026-10-09'), + $this->change(EventDateChangeType::Rescheduled, '2026-10-10', '2026-10-13'), + ])); + + $this->assertSame([ + [ + 'type' => 'suspended', + 'change_ids' => [], + 'title' => 'FECHA CANCELADA!', + 'message' => [ + ['text' => 'La fecha del ', 'bold' => false], + ['text' => '09 de Octubre de 2026', 'bold' => true], + ['text' => ' ha sido cancelada.', 'bold' => false], + ], + ], + [ + 'type' => 'rescheduled', + 'change_ids' => [], + 'title' => 'FECHA REPROGRAMADA!', + 'message' => [ + ['text' => 'La fecha del ', 'bold' => false], + ['text' => '10 de Octubre de 2026', 'bold' => true], + ['text' => ' ha sido reprogramada para el ', 'bold' => false], + ['text' => '13 de Octubre de 2026', 'bold' => true], + ['text' => '.', 'bold' => false], + ], + ], + ], $notices); + } + + public function test_it_formats_suspension_and_reschedule_notices_for_the_storefront(): void + { + $formatter = new EventDateNoticeFormatter(new EventDateTextFormatter); + + $notices = $formatter->format(collect([ + $this->change(EventDateChangeType::Rescheduled, '2026-10-09', '2026-10-13'), + $this->change(EventDateChangeType::Suspended, '2026-10-09'), + $this->change(EventDateChangeType::Suspended, '2026-10-10'), + $this->change(EventDateChangeType::Rescheduled, '2026-10-10', '2026-10-14'), + ])); + + $this->assertSame([ + [ + 'type' => 'suspended', + 'change_ids' => [], + 'title' => 'FECHAS CANCELADAS!', + 'message' => [ + ['text' => 'Las fechas del ', 'bold' => false], + ['text' => '09 y 10 de Octubre de 2026', 'bold' => true], + ['text' => ' han sido canceladas.', 'bold' => false], + ], + ], + [ + 'type' => 'rescheduled', + 'change_ids' => [], + 'title' => 'FECHAS REPROGRAMADAS!', + 'message' => [ + ['text' => 'Las fechas del ', 'bold' => false], + ['text' => '09 y 10 de Octubre de 2026', 'bold' => true], + ['text' => ' han sido reprogramadas para el ', 'bold' => false], + ['text' => '13 y 14 de Octubre de 2026', 'bold' => true], + ['text' => ', ', 'bold' => false], + ['text' => 'respectivamente', 'bold' => true], + ['text' => '.', 'bold' => false], + ], + ], + ], $notices); + } + + private function change( + EventDateChangeType $type, + string $previousDate, + ?string $newDate = null, + ): EventDateChange { + $change = new EventDateChange; + $change->setRawAttributes([ + 'change_type' => $type->value, + 'previous_date' => $previousDate, + 'new_date' => $newDate, + ]); + + return $change; + } +} diff --git a/tests/Unit/Event/EventDateTextFormatterTest.php b/tests/Unit/Event/EventDateTextFormatterTest.php index ca86e70..a5f1f7b 100644 --- a/tests/Unit/Event/EventDateTextFormatterTest.php +++ b/tests/Unit/Event/EventDateTextFormatterTest.php @@ -35,4 +35,12 @@ class EventDateTextFormatterTest extends TestCase ], ]; } + + public function test_it_formats_dates_for_use_inside_sentences(): void + { + $this->assertSame( + '09 y 10 de Octubre de 2026', + (new EventDateTextFormatter)->formatForSentence(['2026-10-10', '2026-10-09']) + ); + } } diff --git a/tests/Unit/Event/EventModelsTest.php b/tests/Unit/Event/EventModelsTest.php index 0650596..7dc90d2 100644 --- a/tests/Unit/Event/EventModelsTest.php +++ b/tests/Unit/Event/EventModelsTest.php @@ -3,9 +3,11 @@ namespace Tests\Unit\Event; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Enums\EventDateStatus; use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\ValidityTime; +use Illuminate\Support\Carbon; use Tests\TestCase; class EventModelsTest extends TestCase @@ -30,6 +32,55 @@ class EventModelsTest extends TestCase $this->assertInstanceOf(ValidityTime::class, $eventDate->validityTime()->getRelated()); $this->assertInstanceOf(EventDate::class, (new ValidityTime)->eventDate()->getRelated()); $this->assertInstanceOf(Variant::class, $eventDate->variants()->getRelated()); + $this->assertInstanceOf(EventDate::class, $eventDate->rescheduledTo()->getRelated()); + $this->assertInstanceOf(EventDate::class, $eventDate->rescheduledFrom()->getRelated()); + } + + public function test_event_date_status_is_computed_in_business_priority_order(): void + { + Carbon::setTestNow('2026-10-09 10:00:00'); + + try { + $eventDate = new EventDate([ + 'date' => '2026-10-09', + 'time_start' => '09:00:00', + 'time_end' => '18:30:00', + ]); + + $this->assertSame(EventDateStatus::InProgress, $eventDate->status); + + $eventDate->date = '2026-10-10'; + $this->assertSame(EventDateStatus::Scheduled, $eventDate->status); + + $eventDate->date = '2026-10-08'; + $this->assertSame(EventDateStatus::Completed, $eventDate->status); + + $eventDate->suspended_at = now(); + $this->assertSame(EventDateStatus::Suspended, $eventDate->status); + + $eventDate->rescheduled_to_event_date_id = 123; + $this->assertSame(EventDateStatus::Rescheduled, $eventDate->status); + } finally { + Carbon::setTestNow(); + } + } + + public function test_an_overnight_event_finishes_on_the_following_day(): void + { + Carbon::setTestNow('2026-10-10 01:00:00'); + + try { + $eventDate = new EventDate([ + 'date' => '2026-10-09', + 'time_start' => '20:00:00', + 'time_end' => '02:00:00', + ]); + + $this->assertSame('2026-10-10 02:00:00', $eventDate->endsAt()->format('Y-m-d H:i:s')); + $this->assertSame(EventDateStatus::InProgress, $eventDate->status); + } finally { + Carbon::setTestNow(); + } } public function test_tenant_has_many_event_dates(): void @@ -38,4 +89,21 @@ class EventModelsTest extends TestCase $this->assertInstanceOf(EventDate::class, $tenant->eventDates()->getRelated()); } + + public function test_effective_date_follows_all_replacements_and_returns_null_when_suspended(): void + { + $original = new EventDate; + $original->setRawAttributes(['id' => 1, 'rescheduled_to_event_date_id' => 2]); + $middle = new EventDate; + $middle->setRawAttributes(['id' => 2, 'rescheduled_to_event_date_id' => 3]); + $latest = new EventDate; + $latest->setRawAttributes(['id' => 3, 'date' => '2026-11-02']); + $original->setRelation('rescheduledTo', $middle); + $middle->setRelation('rescheduledTo', $latest); + + $this->assertSame($latest, $original->effectiveDate()); + + $latest->suspended_at = '2026-11-01 12:00:00'; + $this->assertNull($original->effectiveDate()); + } } diff --git a/tests/Unit/FiestaFutbolInfantil/EntryResourceTest.php b/tests/Unit/FiestaFutbolInfantil/EntryResourceTest.php new file mode 100644 index 0000000..3bbf8d1 --- /dev/null +++ b/tests/Unit/FiestaFutbolInfantil/EntryResourceTest.php @@ -0,0 +1,45 @@ + 2]); + $intermediate = new Variant(['replaced_by_variant_id' => 3]); + $current = new Variant(['sales_disabled_at' => now()]); + $date = new EventDate; + $date->id = 13; + $current->setRelation('eventDates', collect([$date])); + $current->setRelation('inventory', new Inventory(['real_stock' => 20])); + $entry = new CatalogItem(['nombre' => 'Abono', 'precio' => 100]); + $entry->setRelation('variants', collect([$original, $intermediate, $current])); + + $data = (new EntryResource($entry))->resolve(Request::create('/')); + + $this->assertSame([13], $data['event_date_ids']->all()); + $this->assertSame(20, $data['stock']); + $this->assertSame('Abono', $data['title']); + $this->assertNotNull($current->sales_disabled_at); + } + + public function test_it_does_not_choose_arbitrarily_between_current_variants(): void + { + $entry = new CatalogItem; + $entry->setRelation('variants', collect([new Variant, new Variant])); + + $this->expectException(ValidationException::class); + + (new EntryResource($entry))->resolve(Request::create('/')); + } +} diff --git a/tests/Unit/Notification/NotificationMailServiceLoggingTest.php b/tests/Unit/Notification/NotificationMailServiceLoggingTest.php index 6733354..9f69677 100644 --- a/tests/Unit/Notification/NotificationMailServiceLoggingTest.php +++ b/tests/Unit/Notification/NotificationMailServiceLoggingTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Notification; use App\Domains\Integration\Services\MailService; +use App\Domains\Notification\Services\IdempotentEmailDeliveryService; use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Ticket\Services\TicketPdfService; @@ -28,6 +29,7 @@ class NotificationMailServiceLoggingTest extends TestCase $this->service = new NotificationMailService( $this->mailService, Mockery::mock(TicketPdfService::class), + Mockery::mock(IdempotentEmailDeliveryService::class), ); } diff --git a/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php index 0bf122e..fd352b5 100644 --- a/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php +++ b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php @@ -98,6 +98,21 @@ class AdminAppTicketExportServiceTest extends TestCase $this->assertStringContainsString('Vianda', $html); } + public function test_it_uses_the_refund_type_label_when_displaying_a_refunded_status(): void + { + $row = $this->row(); + $row['status'] = 'refunded'; + $row['status_label'] = 'Reembolso parcial'; + + $displayRow = (new AdminAppTicketRowService)->displayRows( + collect([$row]), + $this->columnService()->columns($this->tenant()), + 'America/La_Paz', + )->first(); + + $this->assertSame('Reembolso parcial', $displayRow['status']); + } + private function reportService(): AdminAppTicketReportService { $rowService = Mockery::mock(AdminAppTicketRowService::class)->makePartial(); diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php index 4f4c099..dc0b663 100644 --- a/tests/Unit/Ticket/TicketTest.php +++ b/tests/Unit/Ticket/TicketTest.php @@ -13,6 +13,7 @@ use App\Domains\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticket\Services\ResolvedValidityGroup; use App\Domains\Ticket\Services\TicketValidityResolver; use Illuminate\Support\Carbon; +use Illuminate\Validation\ValidationException; use Tests\TestCase; class TicketTest extends TestCase @@ -31,6 +32,9 @@ class TicketTest extends TestCase 'source_catalog_item_id' => '20', 'source_variant_id' => '30', 'used_at' => null, + 'disabled_at' => '2026-09-10 10:00:00', + 'cancelled_at' => null, + 'refunded_at' => null, 'scanner_user_id' => '15', 'user_id' => '10', ]); @@ -40,6 +44,9 @@ class TicketTest extends TestCase $this->assertSame(20, $ticket->source_catalog_item_id); $this->assertSame(30, $ticket->source_variant_id); $this->assertNull($ticket->used_at); + $this->assertSame('2026-09-10 10:00:00', $ticket->disabled_at->format('Y-m-d H:i:s')); + $this->assertNull($ticket->cancelled_at); + $this->assertNull($ticket->refunded_at); $this->assertSame(15, $ticket->scanner_user_id); $this->assertSame(10, $ticket->user_id); $this->assertInstanceOf(Tenant::class, $ticket->tenant()->getRelated()); @@ -57,6 +64,25 @@ class TicketTest extends TestCase $this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status); } + public function test_it_exposes_admin_action_capabilities(): void + { + $tenant = new Tenant([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); + $active = (new Ticket)->setRelation('tenant', $tenant); + + $this->assertTrue($active->is_active()); + $this->assertTrue($active->can_cancel()); + $this->assertTrue($active->can_refund()); + + $active->used_at = now(); + + $this->assertFalse($active->is_active()); + $this->assertFalse($active->can_cancel()); + $this->assertFalse($active->can_refund()); + } + public function test_fixed_window_controls_ticket_validity(): void { Carbon::setTestNow('2026-07-21 10:00:00'); @@ -154,6 +180,78 @@ class TicketTest extends TestCase $this->assertSame(Ticket::STATUS_USED, $ticket->status); } + public function test_persisted_terminal_statuses_make_the_ticket_invalid(): void + { + foreach ([ + 'disabled_at' => Ticket::STATUS_DISABLED, + 'cancelled_at' => Ticket::STATUS_CANCELLED, + 'refunded_at' => Ticket::STATUS_REFUNDED, + ] as $timestamp => $status) { + $ticket = new Ticket([$timestamp => now()]); + + $this->assertSame($status, $ticket->status); + $this->assertFalse($ticket->is_valid); + $this->assertFalse($ticket->is_expired); + } + } + + public function test_it_exposes_every_supported_status(): void + { + $this->assertSame([ + Ticket::STATUS_ACTIVE, + Ticket::STATUS_USED, + Ticket::STATUS_EXPIRED, + Ticket::STATUS_DISABLED, + Ticket::STATUS_CANCELLED, + Ticket::STATUS_REFUNDED, + ], Ticket::statuses()); + + $this->assertSame('Inhabilitado', Ticket::statusLabel(Ticket::STATUS_DISABLED)); + $this->assertSame('Cancelado', Ticket::statusLabel(Ticket::STATUS_CANCELLED)); + $this->assertSame('Reembolsado', Ticket::statusLabel(Ticket::STATUS_REFUNDED)); + } + + public function test_refunded_has_priority_when_multiple_state_timestamps_exist(): void + { + $ticket = new Ticket([ + 'disabled_at' => now()->subHours(2), + 'cancelled_at' => now()->subHour(), + 'refunded_at' => now(), + ]); + + $this->assertSame(Ticket::STATUS_REFUNDED, $ticket->status); + } + + public function test_it_marks_tickets_with_terminal_statuses(): void + { + Carbon::setTestNow('2026-09-10 12:00:00'); + + $disabled = new Ticket; + $disabled->markAsDisabled(); + + $cancelled = new Ticket; + $cancelled->markAsCancelled(); + + $refunded = new Ticket; + $refunded->markAsRefunded(); + + $this->assertSame(Ticket::STATUS_DISABLED, $disabled->status); + $this->assertSame('2026-09-10 12:00:00', $disabled->disabled_at->format('Y-m-d H:i:s')); + $this->assertSame(Ticket::STATUS_CANCELLED, $cancelled->status); + $this->assertSame('2026-09-10 12:00:00', $cancelled->cancelled_at->format('Y-m-d H:i:s')); + $this->assertSame(Ticket::STATUS_REFUNDED, $refunded->status); + $this->assertSame('2026-09-10 12:00:00', $refunded->refunded_at->format('Y-m-d H:i:s')); + } + + public function test_it_does_not_allow_a_transition_between_terminal_statuses(): void + { + $ticket = new Ticket; + $ticket->markAsDisabled(); + + $this->expectException(ValidationException::class); + $ticket->markAsRefunded(); + } + public function test_all_validity_times_in_the_same_group_must_be_active(): void { Carbon::setTestNow('2026-08-20 13:00:00'); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..913086d --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,16 @@ + 'testing', + 'DB_CONNECTION' => 'sqlite', + 'DB_DATABASE' => ':memory:', + 'DB_URL' => 'null', + 'APP_CONFIG_CACHE' => __DIR__.'/../bootstrap/cache/phpunit-config.php', +] as $key => $value) { + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; +} + +require __DIR__.'/../vendor/autoload.php'; diff --git a/tests/verify-database-safety.php b/tests/verify-database-safety.php new file mode 100644 index 0000000..2a2e1e8 --- /dev/null +++ b/tests/verify-database-safety.php @@ -0,0 +1,80 @@ + 'sqlite', 'database' => ':memory:']; +$unsafe = [ + [], + ['driver' => 'mysql', 'database' => 'shopit'], + ['driver' => 'mysql', 'database' => 'shopit_test'], + ['driver' => 'sqlite', 'database' => 'database/database.sqlite'], + ['driver' => 'sqlite', 'database' => 'shopit_test'], + array_merge($safe, ['url' => 'mysql://localhost/shopit']), + array_merge($safe, ['read' => ['database' => 'shopit']]), + array_merge($safe, ['write' => ['database' => 'shopit']]), + array_merge($safe, ['direct' => ['database' => 'shopit']]), +]; +$factory = new InMemoryConnectionFactory(new Container); +foreach ($unsafe as $config) { + try { + $factory->make($config); + } catch (RuntimeException) { + continue; + } + + throw new RuntimeException('Unsafe connection was accepted.'); +} + +$connection = $factory->make($safe); +if (! $connection->getRawPdo() instanceof Closure) { + throw new RuntimeException('Verification must not open a PDO connection.'); +} + +$case = new class('safetyCheck') extends Tests\TestCase {}; +$app = $case->createApplication(); +if (! $app['db.factory'] instanceof InMemoryConnectionFactory + || $app['config']->get('database.default') !== 'sqlite' + || ! $app['db']->connection()->getRawPdo() instanceof Closure) { + throw new RuntimeException('Application database isolation is not active.'); +} + +foreach (['mysql', 'pgsql', 'mariadb', 'sqlsrv'] as $name) { + try { + $app['db']->connection($name); + } catch (RuntimeException) { + continue; + } + + throw new RuntimeException('A persistent application connection was accepted.'); +} + +// Include URL overrides resolved by Laravel and dynamically built connections. +foreach ([ + ['driver' => 'mysql', 'database' => 'shopit'], + array_merge($safe, ['url' => 'mysql://localhost/shopit']), + array_merge($safe, ['url' => 'sqlite:///database/database.sqlite']), +] as $config) { + $app['config']->set('database.connections.unsafe', $config); + + foreach ([ + fn () => $app['db']->connection('unsafe'), + fn () => $app['db']->build($config), + ] as $connect) { + try { + $connect(); + } catch (RuntimeException) { + continue; + } + + throw new LogicException('A dynamically configured persistent connection was accepted.'); + } +} + +echo "Database safety verified: unsafe connections rejected; no PDO connections or migrations executed.\n";