Compare commits

..

19 Commits

Author SHA1 Message Date
5503559899 feat(refund): implement backfill command for historical ticket refunds reconciliation 2026-09-17 12:29:33 -03:00
830f65c402 feat(refund): add support for event date suspension in cart invalidation process 2026-09-17 11:42:33 -03:00
df6892c662 feat(checkout): enhance cancellation logic to handle unavailable variants and update cart status 2026-09-17 11:34:26 -03:00
de08ee4ac7 feat(cart): implement InvalidateEventDateCartsService to handle cart invalidation for rescheduled event dates 2026-09-17 10:27:27 -03:00
1269ebcda0 feat(ticket): exclude cancelled status from common ticket filter fields 2026-09-17 09:38:57 -03:00
04e54fefad feat(notification): update ticket and order number formatting in event suspension emails 2026-09-16 16:35:51 -03:00
a46bf905e8 feat(refund): enhance refund process by adding backfill for refunded_units and updating inventory restoration logic 2026-09-16 15:00:41 -03:00
353fd34db2 feat(inventory): add refunded_units to inventory model and update related services 2026-09-16 15:00:28 -03:00
f8a3296821 feat(event): add effective date resolution for rescheduled events and update tests 2026-09-16 14:27:35 -03:00
9aa0c1f3b9 feat(entry): enhance variant filtering in all method to exclude replaced and disabled variants 2026-09-16 10:14:54 -03:00
708037677a feat(entry): add validation for single non-replaced variant in EntryResource and update tests 2026-09-16 10:08:39 -03:00
e6b5da366c feat(entry): filter out replaced variants in EntryResource to improve data accuracy 2026-09-16 09:55:28 -03:00
69bea10519 feat(entry): simplify variant data handling in EntryResource by removing replaced variant logic 2026-09-16 09:50:16 -03:00
3d6548ce4a feat(entry): refine variant selection logic to exclude replaced variants and add unit tests for replacement behavior 2026-09-16 09:34:02 -03:00
8ad9b3a4e3 Revert "feat(variants): implement restoration of previously suspended variants with usable dates"
This reverts commit 3a4f6b64d2.
2026-09-16 09:08:43 -03:00
3a4f6b64d2 feat(variants): implement restoration of previously suspended variants with usable dates 2026-09-16 09:05:21 -03:00
44178d72a5 feat(variants): enhance disableForSuspension method to manage variant replacements and inventory merging 2026-09-16 08:56:01 -03:00
d02ad5fce2 feat(event): add per-user date change notices 2026-09-15 17:06:12 -03:00
69f2dbe056 refactor(event): remove date notices from tenant config 2026-09-15 17:06:06 -03:00
44 changed files with 1561 additions and 88 deletions

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Models;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role; use App\Domains\Authorization\Models\Role;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Event\Models\EventDateChangeView;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\ScanAttempt; use App\Domains\Ticket\Models\ScanAttempt;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
@@ -53,6 +54,12 @@ class User extends Authenticatable
return $this->hasMany(ScanAttempt::class, 'scanner_user_id'); return $this->hasMany(ScanAttempt::class, 'scanner_user_id');
} }
/** @return HasMany<EventDateChangeView, $this> */
public function eventDateChangeViews(): HasMany
{
return $this->hasMany(EventDateChangeView::class);
}
/** /**
* @return BelongsTo<Role, $this> * @return BelongsTo<Role, $this>
*/ */

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class InvalidateEventDateCartsService
{
public function __construct(private readonly StockReservationService $reservations) {}
/** @param Collection<int, int> $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]);
}
});
}
}

View File

@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([ #[Fillable([
'sold_units', 'sold_units',
'refunded_units',
'reserved_stock', 'reserved_stock',
'real_stock', 'real_stock',
])] ])]
@@ -23,6 +24,7 @@ class Inventory extends Model
protected $attributes = [ protected $attributes = [
'sold_units' => 0, 'sold_units' => 0,
'refunded_units' => 0,
'reserved_stock' => 0, 'reserved_stock' => 0,
'real_stock' => 0, 'real_stock' => 0,
]; ];
@@ -31,6 +33,7 @@ class Inventory extends Model
{ {
return [ return [
'sold_units' => 'integer', 'sold_units' => 'integer',
'refunded_units' => 'integer',
'reserved_stock' => 'integer', 'reserved_stock' => 'integer',
'real_stock' => 'integer', 'real_stock' => 'integer',
]; ];

View File

@@ -19,6 +19,10 @@ class StockReservationService
public const REASON_CART_CHANGED = 'cart_changed'; 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_SUPERSEDED = 'purchase_superseded';
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled'; public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';

View File

@@ -56,7 +56,7 @@ class VariantReplacementService
public function disableForSuspension(EventDate $eventDate): void public function disableForSuspension(EventDate $eventDate): void
{ {
Variant::query() $variants = Variant::query()
->whereNull('sales_disabled_at') ->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id') ->whereNull('replaced_by_variant_id')
->where(function ($query) use ($eventDate): void { ->where(function ($query) use ($eventDate): void {
@@ -64,7 +64,43 @@ class VariantReplacementService
->orWhereHas('eventDates', fn ($eventDates) => $eventDates ->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $eventDate->getKey())); ->where('event_dates.id', $eventDate->getKey()));
}) })
->update(['sales_disabled_at' => now()]); ->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<int, int> $eventDateIds */ /** @param Collection<int, int> $eventDateIds */
@@ -152,6 +188,7 @@ class VariantReplacementService
$replacementInventory = Inventory::query()->create([ $replacementInventory = Inventory::query()->create([
'sold_units' => $sourceInventory->sold_units, 'sold_units' => $sourceInventory->sold_units,
'refunded_units' => $sourceInventory->refunded_units,
'reserved_stock' => $reservedStock, 'reserved_stock' => $reservedStock,
'real_stock' => $sourceInventory->real_stock, 'real_stock' => $sourceInventory->real_stock,
]); ]);
@@ -166,6 +203,54 @@ class VariantReplacementService
return $replacementInventory; 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<string> */ /** @return list<string> */
private function definitionSignature(Variant $variant): array private function definitionSignature(Variant $variant): array
{ {

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Domains\Event\Resources\EventDateNoticeResource;
use App\Domains\Event\Services\EventDateNoticeService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class EventDateNoticeController extends Controller
{
public function __construct(private readonly EventDateNoticeService $noticeService) {}
public function claim(Request $request, Tenant $tenant): AnonymousResourceCollection
{
return EventDateNoticeResource::collection(
$this->noticeService->claimFor($request->user(), $tenant)
);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Enums\EventDateStatus; use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Services\EffectiveEventDateResolver;
use App\Domains\Event\Services\EventDateTextFormatter; use App\Domains\Event\Services\EventDateTextFormatter;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Enums\ValidityTimeType;
@@ -80,6 +81,11 @@ class EventDate extends Model
return $this->belongsTo(self::class, 'rescheduled_to_event_date_id'); return $this->belongsTo(self::class, 'rescheduled_to_event_date_id');
} }
public function effectiveDate(): ?self
{
return app(EffectiveEventDateResolver::class)->resolve($this);
}
/** @return HasMany<EventDate, $this> */ /** @return HasMany<EventDate, $this> */
public function rescheduledFrom(): HasMany public function rescheduledFrom(): HasMany
{ {

View File

@@ -8,6 +8,7 @@ use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([ #[Fillable([
'tenant_code', 'tenant_code',
@@ -58,4 +59,10 @@ class EventDateChange extends Model
{ {
return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed(); return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed();
} }
/** @return HasMany<EventDateChangeView, $this> */
public function views(): HasMany
{
return $this->hasMany(EventDateChangeView::class);
}
} }

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Auth\Models\User;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'user_id',
'event_date_change_id',
'display_count',
'last_displayed_at',
])]
class EventDateChangeView extends Model
{
protected $table = 'user_event_date_change_views';
protected function casts(): array
{
return [
'user_id' => 'integer',
'event_date_change_id' => 'integer',
'display_count' => 'integer',
'last_displayed_at' => 'datetime',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return BelongsTo<EventDateChange, $this> */
public function eventDateChange(): BelongsTo
{
return $this->belongsTo(EventDateChange::class);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Event\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventDateNoticeResource extends JsonResource
{
/** @return array<string, mixed> */
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'],
];
}
}

View File

@@ -14,6 +14,7 @@ class EventDateNoticeFormatter
* @param Collection<int, EventDateChange> $changes * @param Collection<int, EventDateChange> $changes
* @return list<array{ * @return list<array{
* type: string, * type: string,
* change_ids: list<int>,
* title: string, * title: string,
* message: list<array{text: string, bold: bool}> * message: list<array{text: string, bold: bool}>
* }> * }>
@@ -32,7 +33,7 @@ class EventDateNoticeFormatter
/** /**
* @param Collection<int, EventDateChange> $changes * @param Collection<int, EventDateChange> $changes
* @return array{type: string, title: string, message: list<array{text: string, bold: bool}>}|null * @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|null
*/ */
private function suspensionNotice(Collection $changes): ?array private function suspensionNotice(Collection $changes): ?array
{ {
@@ -46,6 +47,7 @@ class EventDateNoticeFormatter
return [ return [
'type' => EventDateChangeType::Suspended->value, 'type' => EventDateChangeType::Suspended->value,
'change_ids' => $this->changeIds($changes),
'title' => $plural ? 'FECHAS CANCELADAS!' : 'FECHA CANCELADA!', 'title' => $plural ? 'FECHAS CANCELADAS!' : 'FECHA CANCELADA!',
'message' => [ 'message' => [
['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false], ['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false],
@@ -57,7 +59,7 @@ class EventDateNoticeFormatter
/** /**
* @param Collection<int, EventDateChange> $changes * @param Collection<int, EventDateChange> $changes
* @return array{type: string, title: string, message: list<array{text: string, bold: bool}>}|null * @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|null
*/ */
private function rescheduleNotice(Collection $changes): ?array private function rescheduleNotice(Collection $changes): ?array
{ {
@@ -89,6 +91,7 @@ class EventDateNoticeFormatter
return [ return [
'type' => EventDateChangeType::Rescheduled->value, 'type' => EventDateChangeType::Rescheduled->value,
'change_ids' => $this->changeIds($changes),
'title' => $plural ? 'FECHAS REPROGRAMADAS!' : 'FECHA REPROGRAMADA!', 'title' => $plural ? 'FECHAS REPROGRAMADAS!' : 'FECHA REPROGRAMADA!',
'message' => $message, 'message' => $message,
]; ];
@@ -106,4 +109,18 @@ class EventDateNoticeFormatter
->map(fn ($date): string => $date->format('Y-m-d')) ->map(fn ($date): string => $date->format('Y-m-d'))
); );
} }
/**
* @param Collection<int, EventDateChange> $changes
* @return list<int>
*/
private function changeIds(Collection $changes): array
{
return $changes
->pluck('id')
->filter(fn ($id): bool => $id !== null)
->map(fn ($id): int => (int) $id)
->values()
->all();
}
} }

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Event\Models\EventDateChangeView;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
class EventDateNoticeService
{
public const MAX_DISPLAYS = 3;
public function __construct(private readonly EventDateNoticeFormatter $formatter) {}
/**
* Claims one display of every pending change and returns them grouped by type.
*
* @return list<array{
* type: string,
* change_ids: list<int>,
* title: string,
* message: list<array{text: string, bold: bool}>
* }>
*/
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;
});
}
}

View File

@@ -3,7 +3,9 @@
namespace App\Domains\Event\Services; namespace App\Domains\Event\Services;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Cart\Services\InvalidateEventDateCartsService;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Catalog\Services\VariantReplacementService; use App\Domains\Catalog\Services\VariantReplacementService;
use App\Domains\Event\Enums\EventDateChangeType; use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Events\EventDateRescheduled; use App\Domains\Event\Events\EventDateRescheduled;
@@ -28,6 +30,7 @@ class EventService
private readonly EffectiveEventDateResolver $effectiveEventDateResolver, private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver, private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
private readonly VariantReplacementService $variantReplacementService, private readonly VariantReplacementService $variantReplacementService,
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
) {} ) {}
public function forTenant(Tenant $tenant): Tenant public function forTenant(Tenant $tenant): Tenant
@@ -125,9 +128,11 @@ class EventService
]); ]);
} }
$affectedDateIds = $this->affectedDateIds($tenant, $source);
$this->invalidateEventDateCarts->invalidate($tenant, $affectedDateIds);
$purchaseTickets = $this->affectedPurchaseResolver->resolve( $purchaseTickets = $this->affectedPurchaseResolver->resolve(
$tenant, $tenant,
$this->affectedDateIds($tenant, $source), $affectedDateIds,
); );
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]); $source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
$this->variantReplacementService->replaceEventDate($source, $effectiveDestination); $this->variantReplacementService->replaceEventDate($source, $effectiveDestination);
@@ -173,10 +178,13 @@ class EventService
return $date->load('validityTime'); return $date->load('validityTime');
} }
$purchaseTickets = $this->affectedPurchaseResolver->resolve( $affectedDateIds = $this->affectedDateIds($tenant, $date);
$this->invalidateEventDateCarts->invalidate(
$tenant, $tenant,
$this->affectedDateIds($tenant, $date), $affectedDateIds,
StockReservationService::REASON_EVENT_DATE_SUSPENDED,
); );
$purchaseTickets = $this->affectedPurchaseResolver->resolve($tenant, $affectedDateIds);
$date->update(['suspended_at' => now()]); $date->update(['suspended_at' => now()]);
$this->variantReplacementService->disableForSuspension($date); $this->variantReplacementService->disableForSuspension($date);
$this->disableTicketsWithoutUsableDates($tenant, $date); $this->disableTicketsWithoutUsableDates($tenant, $date);

View File

@@ -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. - `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/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. - `Controllers/AdminApp/EventController.php`: consulta y modificación desde AdminApp.
- `UpdateEventRequest`: valida datos y reglas cruzadas de fechas. - `UpdateEventRequest`: valida datos y reglas cruzadas de fechas.
- `EventResource`: serializa la configuración de salida. - `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. - `GET`: obtiene la configuración.
- `PUT`: actualiza 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 ## Dependencias
Depende de `Tenant`. Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar tickets. Depende de `Tenant`. Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar tickets.
## Consideraciones ## 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.

View File

@@ -1,3 +1,11 @@
<?php <?php
use App\Domains\Event\Controllers\EventDateNoticeController;
use Illuminate\Support\Facades\Route;
require __DIR__.'/adminapp.php'; require __DIR__.'/adminapp.php';
Route::middleware('auth:sanctum')->post(
'tenants/{tenant:codigo}/event-date-notices/claim',
[EventDateNoticeController::class, 'claim'],
);

View File

@@ -5,6 +5,7 @@ namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Validation\ValidationException;
/** @mixin CatalogItem */ /** @mixin CatalogItem */
class EntryResource extends JsonResource class EntryResource extends JsonResource
@@ -12,7 +13,20 @@ class EntryResource extends JsonResource
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function toArray(Request $request): 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 [ return [
'id' => $this->id, 'id' => $this->id,

View File

@@ -24,7 +24,13 @@ class EntryService
return CatalogItem::query() return CatalogItem::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas')) ->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->whereHas('variants', fn ($query) => $query
->whereNull('replaced_by_variant_id')
->whereNull('sales_disabled_at'))
->with([ ->with([
'variants' => fn ($query) => $query
->whereNull('replaced_by_variant_id')
->whereNull('sales_disabled_at'),
'variants.inventory', 'variants.inventory',
'variants.eventDate', 'variants.eventDate',
'variants.eventDates', 'variants.eventDates',
@@ -97,6 +103,7 @@ class EntryService
$variants = Variant::query() $variants = Variant::query()
->where('catalog_item_id', $catalogItem->id) ->where('catalog_item_id', $catalogItem->id)
->whereNull('replaced_by_variant_id')
->lockForUpdate() ->lockForUpdate()
->get(); ->get();

View File

@@ -177,6 +177,7 @@ class FoodService
$historicalInventory = Inventory::query()->create([ $historicalInventory = Inventory::query()->create([
'sold_units' => $inventory->sold_units, 'sold_units' => $inventory->sold_units,
'refunded_units' => $inventory->refunded_units,
'reserved_stock' => 0, 'reserved_stock' => 0,
'real_stock' => $inventory->real_stock, 'real_stock' => $inventory->real_stock,
]); ]);

View File

@@ -124,7 +124,10 @@ class TicketFilterFormService
'required' => false, 'required' => false,
'default' => null, 'default' => null,
'placeholder' => 'Estado', 'placeholder' => 'Estado',
'options' => Ticket::statusOptions(), 'options' => array_values(array_filter(
Ticket::statusOptions(),
fn (array $option): bool => $option['value'] !== Ticket::STATUS_CANCELLED,
)),
], ],
]; ];
} }

View File

@@ -297,7 +297,7 @@ class NotificationMailService
->forTenant($tenantCode) ->forTenant($tenantCode)
->send( ->send(
$recipient, $recipient,
"Tu evento fue reprogramado - Compra #{$purchase->getKey()}", "Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}",
view('mail.notifications.event-date-rescheduled', compact( view('mail.notifications.event-date-rescheduled', compact(
'purchase', 'previousDate', 'newDate', 'tickets' 'purchase', 'previousDate', 'newDate', 'tickets'
))->render(), ))->render(),
@@ -398,7 +398,7 @@ class NotificationMailService
->forTenant($tenantCode) ->forTenant($tenantCode)
->send( ->send(
$recipient, $recipient,
"Una fecha de tu evento fue suspendida - Compra #{$purchase->getKey()}", "Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}",
view('mail.notifications.event-date-suspended', compact( view('mail.notifications.event-date-suspended', compact(
'purchase', 'date', 'disabledTickets', 'activeTickets' 'purchase', 'date', 'disabledTickets', 'activeTickets'
))->render(), ))->render(),

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Purchase\Services\Checkout; namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException; use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
@@ -91,7 +92,16 @@ class ReleaseCheckoutService
Purchase::STATUS_CREATED, Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_PENDING_PAYMENT,
], true)) { ], true)) {
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); $this->reservations->returnToCart($purchase, $cart);
}
$purchase->update(['status' => Purchase::STATUS_CANCELLED]); $purchase->update(['status' => Purchase::STATUS_CANCELLED]);
return $this->loadPurchase($purchase); 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( private function releasePurchaseReservations(
Purchase $purchase, Purchase $purchase,
string $targetStatus, string $targetStatus,

View File

@@ -65,9 +65,10 @@ class TenantTransactionResetService
DB::table('inventories') DB::table('inventories')
->whereIn('id', $scope['inventory_ids']) ->whereIn('id', $scope['inventory_ids'])
->update([ ->update([
'real_stock' => DB::raw('real_stock + sold_units'), 'real_stock' => DB::raw('real_stock + sold_units - refunded_units'),
'reserved_stock' => 0, 'reserved_stock' => 0,
'sold_units' => 0, 'sold_units' => 0,
'refunded_units' => 0,
]); ]);
return $summary; return $summary;

View File

@@ -6,9 +6,7 @@ use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Models\AttachmentCrop; use App\Domains\Attachable\Models\AttachmentCrop;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Event\Models\EventDate; use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Resources\EventDateChangeResource;
use App\Domains\Event\Services\EventDateInfoFormatter; use App\Domains\Event\Services\EventDateInfoFormatter;
use App\Domains\Event\Services\EventDateNoticeFormatter;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -71,14 +69,6 @@ class TenantResource extends JsonResource
), ),
'isCanceled' => $eventDate->suspended_at !== null, 'isCanceled' => $eventDate->suspended_at !== null,
])->values(), ])->values(),
'date_changes' => $this->whenLoaded(
'eventDateChanges',
fn () => EventDateChangeResource::collection($this->eventDateChanges)
),
'date_notices' => $this->whenLoaded(
'eventDateChanges',
fn () => app(EventDateNoticeFormatter::class)->format($this->eventDateChanges)
),
]), ]),
'extras' => $this->whenLoaded( 'extras' => $this->whenLoaded(
'websiteExtras', 'websiteExtras',

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Ticket\Services; namespace App\Domains\Ticket\Services;
use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -11,12 +12,12 @@ class AdminAppTicketRowService
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
private const CATEGORY_PRESENTATIONS = [ private const CATEGORY_PRESENTATIONS = [
'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null], 'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null],
'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null], 'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null],
'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'date' => null, 'size' => null], 'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'size' => null],
'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null], 'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null],
'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null], 'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null],
'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'date' => null, 'size' => 'talle'], 'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'size' => 'talle'],
]; ];
/** @return array<string, mixed> */ /** @return array<string, mixed> */
@@ -111,13 +112,14 @@ class AdminAppTicketRowService
private function presentation(Ticket $ticket, array $details): array private function presentation(Ticket $ticket, array $details): array
{ {
$sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-'; $sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-';
$effectiveDates = $this->effectiveEventDateLabels($ticket) ?: '-';
if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) { if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) {
return [ return [
'category' => $sourceCategory, 'category' => $sourceCategory,
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
'type' => $this->allPropertyLabels($details) ?: '-', 'type' => $this->allPropertyLabels($details) ?: '-',
'date' => '-', 'date' => $effectiveDates,
'size' => '-', 'size' => '-',
]; ];
} }
@@ -128,7 +130,7 @@ class AdminAppTicketRowService
'category' => $sourceCategory, 'category' => $sourceCategory,
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
'type' => $this->allPropertyLabels($details) ?: '-', 'type' => $this->allPropertyLabels($details) ?: '-',
'date' => '-', 'date' => $effectiveDates,
'size' => '-', 'size' => '-',
]; ];
} }
@@ -141,29 +143,30 @@ class AdminAppTicketRowService
'type' => $configuration['type'] === null 'type' => $configuration['type'] === null
? '-' ? '-'
: ($this->propertyLabels($details, $configuration['type']) ?: '-'), : ($this->propertyLabels($details, $configuration['type']) ?: '-'),
'date' => $configuration['date'] === null 'date' => $effectiveDates,
? '-'
: ($this->propertyLabels($details, $configuration['date']) ?: '-'),
'size' => $configuration['size'] === null 'size' => $configuration['size'] === null
? '-' ? '-'
: ($this->propertyLabels($details, $configuration['size']) ?: '-'), : ($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<string, mixed> $details */ /** @param array<string, mixed> $details */
private function propertyLabels(array $details, string $code): string private function propertyLabels(array $details, string $code): string
{ {
$property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code); $property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code);
$labels = collect($property['values'] ?? [])->pluck('label')->filter(); $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(', '); return $labels->implode(', ');
} }

View File

@@ -3,6 +3,9 @@
namespace App\Domains\Ticket\Services; namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User; 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\Models\PurchaseItem;
use App\Domains\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Purchase\Services\PurchaseRefundSummaryService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
@@ -223,10 +226,57 @@ class AdminAppTicketService
'amount' => number_format($refundAmount, 2, '.', ''), 'amount' => number_format($refundAmount, 2, '.', ''),
]); ]);
$this->restoreInventory($ticket, $purchaseItem);
return $ticket->refresh()->load(self::RELATIONS); 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 private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float
{ {
return round((float) TicketRefund::query() return round((float) TicketRefund::query()

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Domains\Ticket\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class BackfillRefundedUnitsService
{
/** @return array<string, int> */
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<int, object|null> $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;
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_event_date_change_views', function (Blueprint $table): void {
$table->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');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('inventories', function (Blueprint $table): void {
$table->unsignedBigInteger('refunded_units')->default(0);
});
}
public function down(): void
{
Schema::table('inventories', function (Blueprint $table): void {
$table->dropColumn('refunded_units');
});
}
};

View File

@@ -0,0 +1,16 @@
<?php
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
// Historical reconciliation runs explicitly via tickets:backfill-refunded-units.
}
public function down(): void
{
// Reconciliation cannot be reversed without auditing stock.
}
};

View File

@@ -8,7 +8,7 @@
<p><strong>Tickets afectados</strong></p> <p><strong>Tickets afectados</strong></p>
<ul> <ul>
@foreach ($tickets as $ticket) @foreach ($tickets as $ticket)
<li>{{ $ticket->name }} · #{{ $ticket->id }}</li> <li>{{ $ticket->name }} · N° de Ticket #{{ $ticket->id }}</li>
@endforeach @endforeach
</ul> </ul>
<p style="color: #64748b; font-size: 13px;">Compra #{{ $purchase->id }}</p> <p style="color: #64748b; font-size: 13px;">N° de Orden #{{ $purchase->id }}</p>

View File

@@ -5,7 +5,7 @@
<p>Los siguientes tickets quedaron inhabilitados porque no tienen otra fecha disponible:</p> <p>Los siguientes tickets quedaron inhabilitados porque no tienen otra fecha disponible:</p>
<ul> <ul>
@foreach ($disabledTickets as $ticket) @foreach ($disabledTickets as $ticket)
<li>{{ $ticket->name }} · #{{ $ticket->id }}</li> <li>{{ $ticket->name }} · N° de Ticket #{{ $ticket->id }}</li>
@endforeach @endforeach
</ul> </ul>
<p>Para conocer las alternativas o condiciones de devolución, comunicate con la organización.</p> <p>Para conocer las alternativas o condiciones de devolución, comunicate con la organización.</p>
@@ -15,9 +15,9 @@
<p>Estos tickets conservan otras fechas disponibles:</p> <p>Estos tickets conservan otras fechas disponibles:</p>
<ul> <ul>
@foreach ($activeTickets as $ticket) @foreach ($activeTickets as $ticket)
<li>{{ $ticket->name }} · #{{ $ticket->id }}</li> <li>{{ $ticket->name }} · N° de Ticket #{{ $ticket->id }}</li>
@endforeach @endforeach
</ul> </ul>
@endif @endif
<p style="color: #64748b; font-size: 13px;">Compra #{{ $purchase->id }}</p> <p style="color: #64748b; font-size: 13px;">N° de Orden #{{ $purchase->id }}</p>

View File

@@ -3,6 +3,7 @@
use App\Domains\Auth\Services\AdminCredentialVerifier; use App\Domains\Auth\Services\AdminCredentialVerifier;
use App\Domains\Catalog\Services\ExpireStockReservationsService; use App\Domains\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Purchase\Services\TenantTransactionResetService; use App\Domains\Purchase\Services\TenantTransactionResetService;
use App\Domains\Ticket\Services\BackfillRefundedUnitsService;
use App\Domains\Ticket\Services\LoadTestTicketDatasetService; use App\Domains\Ticket\Services\LoadTestTicketDatasetService;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
@@ -13,6 +14,27 @@ Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an 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 { Artisan::command('reservations:expire', function (): void {
$expired = app(ExpireStockReservationsService::class)->expireOverdue(); $expired = app(ExpireStockReservationsService::class)->expireOverdue();

View File

@@ -0,0 +1,130 @@
<?php
error_reporting(error_reporting() & ~(E_WARNING | E_CORE_WARNING | E_COMPILE_WARNING | E_USER_WARNING | E_DEPRECATED | E_USER_DEPRECATED));
class LspHelper
{
public static function relativePath($path)
{
if (!str_contains($path, base_path())) {
return (string) $path;
}
return ltrim(str_replace(base_path(), '', realpath($path) ?: $path), DIRECTORY_SEPARATOR);
}
public static function isVendor($path)
{
return str_contains($path, base_path('vendor'));
}
public static function propertyDefault(ReflectionProperty $property, ?ReflectionParameter $parameter = null): array
{
if ($property->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());

View File

@@ -6,6 +6,7 @@ use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservation;
@@ -25,6 +26,7 @@ use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase; use Tests\TestCase;
class AdminAppEventControllerTest extends TestCase class AdminAppEventControllerTest extends TestCase
@@ -374,21 +376,8 @@ class AdminAppEventControllerTest extends TestCase
->assertJsonPath('data.event.dates.0.id', $destination->id) ->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.info_text', '09/10/2027 se reprogramó para este día.')
->assertJsonPath('data.event.dates.0.isCanceled', false) ->assertJsonPath('data.event.dates.0.isCanceled', false)
->assertJsonCount(1, 'data.event.date_changes') ->assertJsonMissingPath('data.event.date_changes')
->assertJsonPath('data.event.date_changes.0.type', 'rescheduled') ->assertJsonMissingPath('data.event.date_notices')
->assertJsonPath('data.event.date_changes.0.source_event_date_id', $original->id)
->assertJsonPath('data.event.date_changes.0.destination_event_date_id', $destination->id)
->assertJsonPath('data.event.date_changes.0.previous_date', '2027-10-09')
->assertJsonPath('data.event.date_changes.0.new_date', '2027-10-20')
->assertJsonPath('data.event.date_changes.0.occurred_at', fn ($value) => is_string($value))
->assertJsonMissingPath('data.event.date_changes.0.created_by_user_id')
->assertJsonCount(1, 'data.event.date_notices')
->assertJsonPath('data.event.date_notices.0.type', 'rescheduled')
->assertJsonPath('data.event.date_notices.0.title', 'FECHA REPROGRAMADA!')
->assertJsonPath('data.event.date_notices.0.message.0.text', 'La fecha del ')
->assertJsonPath('data.event.date_notices.0.message.1.text', '09 de Octubre de 2027')
->assertJsonPath('data.event.date_notices.0.message.1.bold', true)
->assertJsonPath('data.event.date_notices.0.message.3.text', '20 de Octubre de 2027')
->assertJsonPath('data.event_date_text', '20 de Octubre 2027'); ->assertJsonPath('data.event_date_text', '20 de Octubre 2027');
$this->assertSame( $this->assertSame(
'2027-10-20 11:00:00', '2027-10-20 11:00:00',
@@ -425,15 +414,8 @@ class AdminAppEventControllerTest extends TestCase
->assertOk() ->assertOk()
->assertJsonCount(1, 'data.event.dates') ->assertJsonCount(1, 'data.event.dates')
->assertJsonPath('data.event.dates.0.date', '2027-10-25') ->assertJsonPath('data.event.dates.0.date', '2027-10-25')
->assertJsonCount(2, 'data.event.date_changes') ->assertJsonMissingPath('data.event.date_changes')
->assertJsonPath('data.event.date_changes.1.type', 'rescheduled') ->assertJsonMissingPath('data.event.date_notices')
->assertJsonPath('data.event.date_changes.1.previous_date', '2027-10-20')
->assertJsonPath('data.event.date_changes.1.new_date', '2027-10-25')
->assertJsonPath('data.event.date_notices.0.title', 'FECHAS REPROGRAMADAS!')
->assertJsonPath('data.event.date_notices.0.message.1.text', '09 y 20 de Octubre de 2027')
->assertJsonPath('data.event.date_notices.0.message.3.text', '20 y 25 de Octubre de 2027')
->assertJsonPath('data.event.date_notices.0.message.5.text', 'respectivamente')
->assertJsonPath('data.event.date_notices.0.message.5.bold', true)
->assertJsonPath('data.event_date_text', '25 de Octubre 2027'); ->assertJsonPath('data.event_date_text', '25 de Octubre 2027');
$this->assertSame( $this->assertSame(
'2027-10-25 11:00:00', '2027-10-25 11:00:00',
@@ -441,6 +423,54 @@ class AdminAppEventControllerTest extends TestCase
); );
} }
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 public function test_rescheduling_reuses_an_equivalent_destination_variant(): void
{ {
Event::fake([EventDateRescheduled::class]); Event::fake([EventDateRescheduled::class]);
@@ -494,6 +524,13 @@ class AdminAppEventControllerTest extends TestCase
$singleDateVariant = $this->createVariant($tenant, $suspendedDate->id); $singleDateVariant = $this->createVariant($tenant, $suspendedDate->id);
$multipleDateVariant = $this->createVariant($tenant); $multipleDateVariant = $this->createVariant($tenant);
$multipleDateVariant->eventDates()->sync([$suspendedDate->id, $otherDate->id]); $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); $singleDateTicket = $this->createTicket($tenant, $admin, $singleDateVariant);
$multipleDateTicket = $this->createTicket($tenant, $admin, $multipleDateVariant); $multipleDateTicket = $this->createTicket($tenant, $admin, $multipleDateVariant);
Sanctum::actingAs($admin); Sanctum::actingAs($admin);
@@ -528,6 +565,14 @@ class AdminAppEventControllerTest extends TestCase
$this->assertNull($multipleDateTicket->fresh()->disabled_at); $this->assertNull($multipleDateTicket->fresh()->disabled_at);
$this->assertNotNull($singleDateVariant->fresh()->sales_disabled_at); $this->assertNotNull($singleDateVariant->fresh()->sales_disabled_at);
$this->assertNotNull($multipleDateVariant->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->assertSame('10 de Octubre 2027', $tenant->fresh()->event_date_text);
$this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F') $this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F')
->assertOk() ->assertOk()
@@ -538,23 +583,17 @@ class AdminAppEventControllerTest extends TestCase
->assertJsonPath('data.event.dates.1.id', $otherDate->id) ->assertJsonPath('data.event.dates.1.id', $otherDate->id)
->assertJsonPath('data.event.dates.1.info_text', null) ->assertJsonPath('data.event.dates.1.info_text', null)
->assertJsonPath('data.event.dates.1.isCanceled', false) ->assertJsonPath('data.event.dates.1.isCanceled', false)
->assertJsonCount(1, 'data.event.date_changes') ->assertJsonMissingPath('data.event.date_changes')
->assertJsonPath('data.event.date_changes.0.type', 'suspended') ->assertJsonMissingPath('data.event.date_notices')
->assertJsonPath('data.event.date_changes.0.source_event_date_id', $suspendedDate->id)
->assertJsonPath('data.event.date_changes.0.destination_event_date_id', null)
->assertJsonPath('data.event.date_changes.0.previous_date', '2027-10-09')
->assertJsonPath('data.event.date_changes.0.new_date', null)
->assertJsonMissingPath('data.event.date_changes.0.created_by_user_id')
->assertJsonCount(1, 'data.event.date_notices')
->assertJsonPath('data.event.date_notices.0.type', 'suspended')
->assertJsonPath('data.event.date_notices.0.title', 'FECHA CANCELADA!')
->assertJsonPath('data.event.date_notices.0.message.1.text', '09 de Octubre de 2027')
->assertJsonPath('data.event.date_notices.0.message.1.bold', true)
->assertJsonPath('data.event_date_text', '10 de Octubre 2027'); ->assertJsonPath('data.event_date_text', '10 de Octubre 2027');
$this->assertSame( $this->assertSame(
'2027-10-10 09:00:00', '2027-10-10 09:00:00',
$multipleDateTicket->fresh()->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), $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 public function test_suspending_a_reschedule_destination_disables_tickets_from_predecessor_dates(): void

View File

@@ -0,0 +1,145 @@
<?php
namespace Tests\Feature\Event;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Tenant\Models\Tenant;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class EventDateNoticeControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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,
]);
}
}

View File

@@ -7,6 +7,7 @@ use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType; 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 public function test_it_deletes_an_entry_and_its_inventory(): void
{ {
$tenant = $this->createFiestaTenant(); $tenant = $this->createFiestaTenant();

View File

@@ -79,7 +79,6 @@ class AdminAppTicketFilterFormControllerTest extends TestCase
['value' => 'used', 'label' => 'Usado'], ['value' => 'used', 'label' => 'Usado'],
['value' => 'expired', 'label' => 'Vencido'], ['value' => 'expired', 'label' => 'Vencido'],
['value' => 'disabled', 'label' => 'Inhabilitado'], ['value' => 'disabled', 'label' => 'Inhabilitado'],
['value' => 'cancelled', 'label' => 'Cancelado'],
['value' => 'refunded', 'label' => 'Reembolsado'], ['value' => 'refunded', 'label' => 'Reembolsado'],
], ],
], ],

View File

@@ -352,11 +352,12 @@ class NotificationMailServiceTest extends TestCase
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $firstTicket, $secondTicket): bool { Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $firstTicket, $secondTicket): bool {
$mail->assertTo('checkout@example.com'); $mail->assertTo('checkout@example.com');
return $mail->subject === "Tu evento fue reprogramado - Compra #{$purchase->id}" return $mail->subject === "Tu evento fue reprogramado - N° de Orden #{$purchase->id}"
&& str_contains($mail->render(), '09/10/2027') && str_contains($mail->render(), '09/10/2027')
&& str_contains($mail->render(), '20/10/2027') && str_contains($mail->render(), '20/10/2027')
&& str_contains($mail->render(), '#'.$firstTicket->id) && str_contains($mail->render(), 'N° de Ticket #'.$firstTicket->id)
&& str_contains($mail->render(), '#'.$secondTicket->id); && str_contains($mail->render(), 'N° de Ticket #'.$secondTicket->id)
&& str_contains($mail->render(), 'N° de Orden #'.$purchase->id);
}); });
$this->assertDatabaseHas('email_deliveries', [ $this->assertDatabaseHas('email_deliveries', [
'idempotency_key' => "event-date-rescheduled:10:20:{$purchase->id}", 'idempotency_key' => "event-date-rescheduled:10:20:{$purchase->id}",

View File

@@ -11,7 +11,9 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Purchase\Services\UserPurchaseLimitService;
@@ -20,6 +22,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase; use Tests\TestCase;
class StorePurchaseTest extends TestCase class StorePurchaseTest extends TestCase
@@ -642,6 +645,80 @@ class StorePurchaseTest extends TestCase
$this->assertSame(1, $activeCart->items()->count()); $this->assertSame(1, $activeCart->items()->count());
} }
/** @return array<string, array{string, string}> */
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 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'); $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');

View File

@@ -23,6 +23,7 @@ use Database\Seeders\AttributeSeeder;
use Database\Seeders\AuthorizationSeeder; use Database\Seeders\AuthorizationSeeder;
use Database\Seeders\FiestaFutbolInfantilProductSeeder; use Database\Seeders\FiestaFutbolInfantilProductSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use Tests\TestCase; use Tests\TestCase;
@@ -186,6 +187,7 @@ class AdminAppTicketControllerTest extends TestCase
$this->grantTicketsMenu($tenant); $this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin); Sanctum::actingAs($admin);
[$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00');
$inventory = $ticket->sourceCatalogItem->inventory;
$this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [
'refund_type' => 'total', 'refund_type' => 'total',
@@ -201,6 +203,9 @@ class AdminAppTicketControllerTest extends TestCase
->assertJsonPath('data.refund.created_by', $admin->nombre_apellido); ->assertJsonPath('data.refund.created_by', $admin->nombre_apellido);
$this->assertNotNull($ticket->fresh()->refunded_at); $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', [ $this->assertDatabaseHas('ticket_refunds', [
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'purchase_item_id' => $purchaseItem->id, 'purchase_item_id' => $purchaseItem->id,
@@ -208,6 +213,13 @@ class AdminAppTicketControllerTest extends TestCase
'type' => TicketRefund::TYPE_TOTAL, 'type' => TicketRefund::TYPE_TOTAL,
'amount' => '100.00', '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 public function test_it_partially_refunds_a_ticket_using_the_tenant_percentage(): void
@@ -222,6 +234,7 @@ class AdminAppTicketControllerTest extends TestCase
$this->grantTicketsMenu($tenant); $this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin); Sanctum::actingAs($admin);
[$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00');
$inventory = $ticket->sourceCatalogItem->inventory;
$this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [
'refund_type' => 'partial', 'refund_type' => 'partial',
@@ -238,6 +251,73 @@ class AdminAppTicketControllerTest extends TestCase
'type' => TicketRefund::TYPE_PARTIAL, 'type' => TicketRefund::TYPE_PARTIAL,
'amount' => '25.50', '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 public function test_it_records_different_refund_types_for_tickets_from_the_same_purchase_item(): void
@@ -835,6 +915,50 @@ class AdminAppTicketControllerTest extends TestCase
->assertJsonPath('total_tickets', 1); ->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 public function test_it_exposes_and_filters_merchandise_color_and_size(): void
{ {
$tenant = $this->createTenant('fiesta_futbol_infantil'); $tenant = $this->createTenant('fiesta_futbol_infantil');
@@ -1045,8 +1169,10 @@ class AdminAppTicketControllerTest extends TestCase
/** @return array{Ticket, PurchaseItem} */ /** @return array{Ticket, PurchaseItem} */
private function createRefundableTicket(Tenant $tenant, User $admin, string $amount): array private function createRefundableTicket(Tenant $tenant, User $admin, string $amount): array
{ {
$inventory = Inventory::query()->create(['sold_units' => 1]);
$catalogItem = CatalogItem::query()->create([ $catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo, 'tenant_code' => $tenant->codigo,
'inventory_id' => $inventory->id,
'slug' => 'ticket-reembolsable-'.Str::uuid(), 'slug' => 'ticket-reembolsable-'.Str::uuid(),
'nombre' => 'Ticket reembolsable', 'nombre' => 'Ticket reembolsable',
'precio' => $amount, 'precio' => $amount,

View File

@@ -0,0 +1,175 @@
<?php
namespace Tests\Unit\Cart;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Services\InvalidateEventDateCartsService;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class InvalidateEventDateCartsServiceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Minimal service schema, still using the guarded SQLite :memory: connection.
Schema::create('carritos', function (Blueprint $table): void {
$table->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;
}
}

View File

@@ -22,6 +22,7 @@ class EventDateNoticeFormatterTest extends TestCase
$this->assertSame([ $this->assertSame([
[ [
'type' => 'suspended', 'type' => 'suspended',
'change_ids' => [],
'title' => 'FECHA CANCELADA!', 'title' => 'FECHA CANCELADA!',
'message' => [ 'message' => [
['text' => 'La fecha del ', 'bold' => false], ['text' => 'La fecha del ', 'bold' => false],
@@ -31,6 +32,7 @@ class EventDateNoticeFormatterTest extends TestCase
], ],
[ [
'type' => 'rescheduled', 'type' => 'rescheduled',
'change_ids' => [],
'title' => 'FECHA REPROGRAMADA!', 'title' => 'FECHA REPROGRAMADA!',
'message' => [ 'message' => [
['text' => 'La fecha del ', 'bold' => false], ['text' => 'La fecha del ', 'bold' => false],
@@ -57,6 +59,7 @@ class EventDateNoticeFormatterTest extends TestCase
$this->assertSame([ $this->assertSame([
[ [
'type' => 'suspended', 'type' => 'suspended',
'change_ids' => [],
'title' => 'FECHAS CANCELADAS!', 'title' => 'FECHAS CANCELADAS!',
'message' => [ 'message' => [
['text' => 'Las fechas del ', 'bold' => false], ['text' => 'Las fechas del ', 'bold' => false],
@@ -66,6 +69,7 @@ class EventDateNoticeFormatterTest extends TestCase
], ],
[ [
'type' => 'rescheduled', 'type' => 'rescheduled',
'change_ids' => [],
'title' => 'FECHAS REPROGRAMADAS!', 'title' => 'FECHAS REPROGRAMADAS!',
'message' => [ 'message' => [
['text' => 'Las fechas del ', 'bold' => false], ['text' => 'Las fechas del ', 'bold' => false],

View File

@@ -89,4 +89,21 @@ class EventModelsTest extends TestCase
$this->assertInstanceOf(EventDate::class, $tenant->eventDates()->getRelated()); $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());
}
} }

View File

@@ -0,0 +1,45 @@
<?php
namespace Tests\Unit\FiestaFutbolInfantil;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use App\Domains\FiestaFutbolInfantil\Resources\EntryResource;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
class EntryResourceTest extends TestCase
{
public function test_it_reads_the_final_replacement_even_with_disabled_sales(): void
{
$original = new Variant(['replaced_by_variant_id' => 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('/'));
}
}