Compare commits

...

13 Commits

56 changed files with 1733 additions and 78 deletions

View File

@@ -1,5 +1,11 @@
# Project Conventions
## Test database safety
- Tests must use SQLite `:memory:` through `tests/bootstrap.php` and `Tests\TestCase`.
- Never run tests, `migrate:fresh`, `migrate:refresh`, or `db:wipe` against a persistent database, including the developer's `shopit` database.
- Never bypass the connection safety guard to resolve test failures. Use `php tests/verify-database-safety.php` to verify isolation without queries or migrations.
## Architecture
This project uses a domain-oriented structure under `app/Domains`.

View File

@@ -32,6 +32,7 @@ class TenantBootstrapService
return $this->tenantInformationService->load(
$tenant,
[
'eventDateChanges',
'menues' => fn ($query) => $query->whereHas(
'roles',
fn ($query) => $query->where('codigo', RoleCode::User->value)

View File

@@ -400,6 +400,17 @@ class Cart extends Model
]);
}
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem;
}
@@ -430,6 +441,12 @@ class Cart extends Model
throw new NotFoundHttpException('Variant not found for catalog item.');
}
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
'variant_id' => [__('api.cart.variant_unavailable')],
]);
}
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation('inventory', $inventory);

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use Illuminate\Validation\ValidationException;
class CartVariantReplacementService
{
public function __construct(private readonly CatalogInventoryService $inventory) {}
public function replaceHistoricalVariants(Cart $cart): void
{
$items = $cart->items()
->whereNotNull('variant_id')
->orderBy('id')
->lockForUpdate()
->get();
foreach ($items as $item) {
$variant = Variant::query()->lockForUpdate()->find($item->variant_id);
if ($variant === null) {
throw $this->unavailableVariant();
}
$replacement = $this->latestReplacement($variant);
if ($replacement->is($variant)) {
if (! $variant->isSellable()) {
throw $this->unavailableVariant();
}
continue;
}
if (! $replacement->isSellable()) {
throw $this->unavailableVariant();
}
/** @var CartItem|null $targetItem */
$targetItem = $cart->items()
->whereKeyNot($item->getKey())
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $replacement->getKey())
->lockForUpdate()
->first();
$replacementQuantity = $item->cantidad + ($targetItem?->cantidad ?? 0);
if ($replacement->inventory_id !== $variant->inventory_id) {
$available = $this->inventory->availableQuantity($replacement);
if ($available !== null && $available < $replacementQuantity) {
throw $this->unavailableVariant();
}
}
if ($targetItem !== null) {
$targetItem->cantidad += $item->cantidad;
$targetItem->save();
$item->delete();
continue;
}
$item->update(['variant_id' => $replacement->getKey()]);
}
}
private function latestReplacement(Variant $variant): Variant
{
$current = $variant;
$visited = [];
while ($current->replaced_by_variant_id !== null) {
if (isset($visited[$current->getKey()])) {
throw $this->unavailableVariant();
}
$visited[$current->getKey()] = true;
$current = Variant::query()
->lockForUpdate()
->find($current->replaced_by_variant_id)
?? throw $this->unavailableVariant();
}
return $current;
}
private function unavailableVariant(): ValidationException
{
return ValidationException::withMessages([
'cart_id' => [__('api.cart.cart_variant_unavailable')],
]);
}
}

View File

@@ -179,11 +179,27 @@ class CatalogItem extends Model
{
return $query->where(function (Builder $query): void {
$query
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->where(function (Builder $unlimitedQuery): void {
$unlimitedQuery
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->where(function (Builder $selectionQuery): void {
$selectionQuery
->whereDoesntHave('variants')
->orWhereHas('variants', fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id'));
});
})
->orWhereHas(
'variants.inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
'variants',
fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->whereHas(
'inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
)
)
->orWhere(function (Builder $directItemQuery): void {
$directItemQuery
@@ -206,8 +222,10 @@ class CatalogItem extends Model
{
return $this->variants
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId)
|| $this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0)
|| ($variant->isSellable() && (
$this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0
)))
->values();
}

View File

@@ -42,10 +42,10 @@ class Inventory extends Model
return $this->hasOne(CatalogItem::class);
}
/** @return HasOne<Variant, $this> */
public function variant(): HasOne
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{
return $this->hasOne(Variant::class);
return $this->hasMany(Variant::class);
}
/** @return HasMany<StockReservationLine, $this> */

View File

@@ -20,6 +20,8 @@ use Illuminate\Support\Str;
'catalog_item_id',
'event_date_id',
'inventory_id',
'replaced_by_variant_id',
'sales_disabled_at',
'descripcion',
'precio',
])]
@@ -37,6 +39,8 @@ class Variant extends Model
'catalog_item_id' => 'integer',
'event_date_id' => 'integer',
'inventory_id' => 'integer',
'replaced_by_variant_id' => 'integer',
'sales_disabled_at' => 'datetime',
'precio' => 'decimal:2',
];
}
@@ -76,6 +80,24 @@ class Variant extends Model
return $this->belongsTo(Inventory::class);
}
/** @return BelongsTo<Variant, $this> */
public function replacement(): BelongsTo
{
return $this->belongsTo(self::class, 'replaced_by_variant_id');
}
/** @return HasMany<Variant, $this> */
public function replacedVariants(): HasMany
{
return $this->hasMany(self::class, 'replaced_by_variant_id');
}
public function isSellable(): bool
{
return $this->sales_disabled_at === null
&& $this->replaced_by_variant_id === null;
}
/** @return HasMany<VariantDefinition, $this> */
public function definitions(): HasMany
{

View File

@@ -63,9 +63,14 @@ class CatalogInventoryService
$selection->loadMissing('variants.inventory');
return $selection->variants->sum(
fn (Variant $variant): int => $variant->inventory->availableStock(),
);
return $selection->variants
->filter(fn (Variant $variant): bool => $variant->isSellable())
->unique(fn (Variant $variant): string => $variant->inventory_id === null
? 'object:'.spl_object_id($variant->inventory)
: 'id:'.$variant->inventory_id)
->sum(
fn (Variant $variant): int => $variant->inventory->availableStock(),
);
}
$requirements = $this->inventoryRequirements($selection);

View File

@@ -207,7 +207,8 @@ class CatalogService
$visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& ! $catalogItem->isAvailable()) {
&& (($catalogItem->variants->isNotEmpty() && $visibleVariants->isEmpty())
|| ! $catalogItem->isAvailable())) {
throw new NotFoundHttpException('Catalog item is out of stock.');
}
@@ -324,9 +325,13 @@ class CatalogService
->findOrFail($variant->catalog_item_id);
$variant->delete();
if (! $catalogItem->variants()->exists()) {
$sellableVariants = $catalogItem->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id');
if (! (clone $sellableVariants)->exists()) {
$this->delete($catalogItem);
} elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) {
} elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) {
$catalogItem->update(['precio' => $minimumPrice]);
}
@@ -399,7 +404,11 @@ class CatalogService
]);
}
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
if ($variantId !== null && ! $componentItem->variants()
->whereKey($variantId)
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->exists()) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
__('api.catalog.component_variant_invalid'),

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Models\BundleComponent;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use Illuminate\Support\Collection;
class VariantReplacementService
{
/** @return Collection<int, Variant> */
public function replaceEventDate(EventDate $source, EventDate $destination): Collection
{
$variants = Variant::query()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->where(function ($query) use ($source): void {
$query->where('event_date_id', $source->getKey())
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $source->getKey()));
})
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
->orderBy('id')
->lockForUpdate()
->get();
return $variants->map(function (Variant $variant) use ($source, $destination): Variant {
$destinationDateIds = $variant->selectedEventDates()
->pluck('id')
->map(fn ($id): int => (int) $id === (int) $source->getKey()
? (int) $destination->getKey()
: (int) $id)
->unique()
->sort()
->values();
$replacement = $this->findEquivalent($variant, $destinationDateIds)
?? $this->cloneWithDates($variant, $destinationDateIds);
$variant->update([
'replaced_by_variant_id' => $replacement->getKey(),
'sales_disabled_at' => now(),
]);
BundleComponent::query()
->where('component_variant_id', $variant->getKey())
->update(['component_variant_id' => $replacement->getKey()]);
return $replacement;
})->values();
}
public function disableForSuspension(EventDate $eventDate): void
{
Variant::query()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->where(function ($query) use ($eventDate): void {
$query->where('event_date_id', $eventDate->getKey())
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $eventDate->getKey()));
})
->update(['sales_disabled_at' => now()]);
}
/** @param Collection<int, int> $eventDateIds */
private function findEquivalent(Variant $source, Collection $eventDateIds): ?Variant
{
$definitionSignature = $this->definitionSignature($source);
$dateSignature = $eventDateIds->map(fn ($id): int => (int) $id)->sort()->values()->all();
return Variant::query()
->where('catalog_item_id', $source->catalog_item_id)
->whereKeyNot($source->getKey())
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['eventDates', 'eventDate', 'definitions'])
->orderBy('id')
->lockForUpdate()
->get()
->first(fn (Variant $candidate): bool => $this->definitionSignature($candidate) === $definitionSignature
&& $candidate->selectedEventDates()
->pluck('id')
->map(fn ($id): int => (int) $id)
->sort()
->values()
->all() === $dateSignature
);
}
/** @param Collection<int, int> $eventDateIds */
private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant
{
$replacement = $source->replicate([
'event_date_id',
'replaced_by_variant_id',
'sales_disabled_at',
]);
$replacement->event_date_id = $eventDateIds->count() === 1
? $eventDateIds->first()
: null;
$replacement->save();
$replacement->eventDates()->sync($eventDateIds->all());
$replacement->definitions()->createMany(
$source->definitions
->map(fn ($definition): array => [
'item_attribute_id' => $definition->item_attribute_id,
'value' => $definition->value,
])
->all(),
);
$attachments = $source->allAttachments
->mapWithKeys(fn ($attachment): array => [
$attachment->getKey() => [
'orden' => $attachment->pivot->orden,
'is_enabled' => $attachment->pivot->is_enabled,
],
])
->all();
$replacement->allAttachments()->sync($attachments);
return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']);
}
/** @return list<string> */
private function definitionSignature(Variant $variant): array
{
return $variant->definitions
->map(fn ($definition): string => $definition->item_attribute_id.'\0'.$definition->value)
->sort()
->values()
->all();
}
}

View File

@@ -52,6 +52,7 @@ class EventController extends Controller
$request->user()->tenant()->firstOrFail(),
$eventDate,
$request->validated(),
$request->user(),
)
);
}
@@ -62,6 +63,7 @@ class EventController extends Controller
$this->eventService->suspendDateForTenant(
$request->user()->tenant()->firstOrFail(),
$eventDate,
$request->user(),
)
);
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Event\Enums;
enum EventDateChangeType: string
{
case Rescheduled = 'rescheduled';
case Suspended = 'suspended';
}

View File

@@ -86,6 +86,18 @@ class EventDate extends Model
return $this->hasMany(self::class, 'rescheduled_to_event_date_id');
}
/** @return HasMany<EventDateChange, $this> */
public function changeHistory(): HasMany
{
return $this->hasMany(EventDateChange::class, 'source_event_date_id');
}
/** @return HasMany<EventDateChange, $this> */
public function destinationChangeHistory(): HasMany
{
return $this->hasMany(EventDateChange::class, 'destination_event_date_id');
}
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'tenant_code',
'change_type',
'source_event_date_id',
'destination_event_date_id',
'created_by_user_id',
'previous_date',
'new_date',
])]
class EventDateChange extends Model
{
public $timestamps = false;
protected function casts(): array
{
return [
'change_type' => EventDateChangeType::class,
'source_event_date_id' => 'integer',
'destination_event_date_id' => 'integer',
'created_by_user_id' => 'integer',
'previous_date' => 'date:Y-m-d',
'new_date' => 'date:Y-m-d',
'created_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<EventDate, $this> */
public function sourceEventDate(): BelongsTo
{
return $this->belongsTo(EventDate::class, 'source_event_date_id');
}
/** @return BelongsTo<EventDate, $this> */
public function destinationEventDate(): BelongsTo
{
return $this->belongsTo(EventDate::class, 'destination_event_date_id');
}
/** @return BelongsTo<User, $this> */
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed();
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin EventDateChange */
class EventDateChangeResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'type' => $this->change_type->value,
'source_event_date_id' => $this->source_event_date_id,
'destination_event_date_id' => $this->destination_event_date_id,
'previous_date' => $this->previous_date->format('Y-m-d'),
'new_date' => $this->new_date?->format('Y-m-d'),
'occurred_at' => $this->created_at->toISOString(),
];
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Support\Collection;
class EventDateNoticeFormatter
{
public function __construct(private readonly EventDateTextFormatter $dateTextFormatter) {}
/**
* @param Collection<int, EventDateChange> $changes
* @return list<array{
* type: string,
* title: string,
* message: list<array{text: string, bold: bool}>
* }>
*/
public function format(Collection $changes): array
{
return collect([
$this->suspensionNotice(
$changes->where('change_type', EventDateChangeType::Suspended)
),
$this->rescheduleNotice(
$changes->where('change_type', EventDateChangeType::Rescheduled)
),
])->filter()->values()->all();
}
/**
* @param Collection<int, EventDateChange> $changes
* @return array{type: string, title: string, message: list<array{text: string, bold: bool}>}|null
*/
private function suspensionNotice(Collection $changes): ?array
{
$dates = $this->formatDates($changes, 'previous_date');
if ($dates === null) {
return null;
}
$plural = $changes->count() > 1;
return [
'type' => EventDateChangeType::Suspended->value,
'title' => $plural ? 'FECHAS CANCELADAS!' : 'FECHA CANCELADA!',
'message' => [
['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false],
['text' => $dates, 'bold' => true],
['text' => $plural ? ' han sido canceladas.' : ' ha sido cancelada.', 'bold' => false],
],
];
}
/**
* @param Collection<int, EventDateChange> $changes
* @return array{type: string, title: string, message: list<array{text: string, bold: bool}>}|null
*/
private function rescheduleNotice(Collection $changes): ?array
{
$changes = $changes->whereNotNull('new_date');
$sourceDates = $this->formatDates($changes, 'previous_date');
$destinationDates = $this->formatDates($changes, 'new_date');
if ($sourceDates === null || $destinationDates === null) {
return null;
}
$plural = $changes->count() > 1;
$message = [
['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false],
['text' => $sourceDates, 'bold' => true],
[
'text' => $plural ? ' han sido reprogramadas para el ' : ' ha sido reprogramada para el ',
'bold' => false,
],
['text' => $destinationDates, 'bold' => true],
];
if ($plural) {
$message[] = ['text' => ', ', 'bold' => false];
$message[] = ['text' => 'respectivamente', 'bold' => true];
}
$message[] = ['text' => '.', 'bold' => false];
return [
'type' => EventDateChangeType::Rescheduled->value,
'title' => $plural ? 'FECHAS REPROGRAMADAS!' : 'FECHA REPROGRAMADA!',
'message' => $message,
];
}
/**
* @param Collection<int, EventDateChange> $changes
*/
private function formatDates(Collection $changes, string $attribute): ?string
{
return $this->dateTextFormatter->formatForSentence(
$changes
->pluck($attribute)
->filter()
->map(fn ($date): string => $date->format('Y-m-d'))
);
}
}

View File

@@ -25,6 +25,21 @@ class EventDateTextFormatter
/** @param iterable<string> $dates */
public function format(iterable $dates): ?string
{
return $this->formatWithOptions($dates, false, false);
}
/** @param iterable<string> $dates */
public function formatForSentence(iterable $dates): ?string
{
return $this->formatWithOptions($dates, true, true);
}
/** @param iterable<string> $dates */
private function formatWithOptions(
iterable $dates,
bool $padDays,
bool $includeYearPreposition,
): ?string {
$normalizedDates = collect($dates)
->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date))
->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
@@ -37,12 +52,14 @@ class EventDateTextFormatter
$years = $normalizedDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y'))
->map(function ($yearDates, string $year): string {
->map(function ($yearDates, string $year) use ($padDays, $includeYearPreposition): string {
$months = $yearDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('n'))
->map(function ($monthDates, string $month): string {
->map(function ($monthDates, string $month) use ($padDays): string {
$days = $monthDates
->map(fn (DateTimeImmutable $date): string => (string) ((int) $date->format('j')))
->map(fn (DateTimeImmutable $date): string => $padDays
? $date->format('d')
: (string) ((int) $date->format('j')))
->values()
->all();
@@ -51,7 +68,7 @@ class EventDateTextFormatter
->values()
->all();
return $this->join($months).' '.$year;
return $this->join($months).($includeYearPreposition ? ' de ' : ' ').$year;
})
->values()
->all();

View File

@@ -2,10 +2,14 @@
namespace App\Domains\Event\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\VariantReplacementService;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection;
@@ -23,6 +27,7 @@ class EventService
public function __construct(
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
private readonly VariantReplacementService $variantReplacementService,
) {}
public function forTenant(Tenant $tenant): Tenant
@@ -73,9 +78,13 @@ class EventService
}
/** @param array{date: string} $data */
public function rescheduleDateForTenant(Tenant $tenant, EventDate $eventDate, array $data): EventDate
{
return DB::transaction(function () use ($tenant, $eventDate, $data): EventDate {
public function rescheduleDateForTenant(
Tenant $tenant,
EventDate $eventDate,
array $data,
?User $createdBy = null,
): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate {
$source = $this->lockedDateForTenant($tenant, $eventDate);
if ($source->suspended_at !== null) {
@@ -109,7 +118,8 @@ class EventService
]);
}
if ($this->effectiveEventDateResolver->resolve($destination) === null) {
$effectiveDestination = $this->effectiveEventDateResolver->resolve($destination);
if ($effectiveDestination === null) {
throw ValidationException::withMessages([
'date' => ['La fecha de destino no es utilizable.'],
]);
@@ -120,6 +130,17 @@ class EventService
$this->affectedDateIds($tenant, $source),
);
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
$this->variantReplacementService->replaceEventDate($source, $effectiveDestination);
EventDateChange::query()->create([
'tenant_code' => $tenant->codigo,
'change_type' => EventDateChangeType::Rescheduled,
'source_event_date_id' => $source->getKey(),
'destination_event_date_id' => $destination->getKey(),
'created_by_user_id' => $createdBy?->getKey(),
'previous_date' => $source->date->format('Y-m-d'),
'new_date' => $destination->date->format('Y-m-d'),
]);
EventDateRescheduled::dispatch(
$tenant->codigo,
@@ -134,9 +155,12 @@ class EventService
});
}
public function suspendDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate
{
return DB::transaction(function () use ($tenant, $eventDate): EventDate {
public function suspendDateForTenant(
Tenant $tenant,
EventDate $eventDate,
?User $createdBy = null,
): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate {
$date = $this->lockedDateForTenant($tenant, $eventDate);
if ($date->rescheduled_to_event_date_id !== null) {
@@ -154,8 +178,19 @@ class EventService
$this->affectedDateIds($tenant, $date),
);
$date->update(['suspended_at' => now()]);
$this->variantReplacementService->disableForSuspension($date);
$this->disableTicketsWithoutUsableDates($tenant, $date);
EventDateChange::query()->create([
'tenant_code' => $tenant->codigo,
'change_type' => EventDateChangeType::Suspended,
'source_event_date_id' => $date->getKey(),
'destination_event_date_id' => null,
'created_by_user_id' => $createdBy?->getKey(),
'previous_date' => $date->date->format('Y-m-d'),
'new_date' => null,
]);
EventDateSuspended::dispatch(
$tenant->codigo,
$date->getKey(),

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\EntryFormResource;
use App\Domains\Forms\Services\EntryFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class EntryFormController extends Controller
{
public function __construct(protected EntryFormService $entryFormService) {}
public function __invoke(Request $request): EntryFormResource
{
return EntryFormResource::make(
$this->entryFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Forms\Resources;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EntryFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'event_dates' => $this->resource['event_dates']->map(
fn (EventDate $eventDate): array => [
'id' => $eventDate->id,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'date' => $eventDate->date->format('Y-m-d'),
]
)->values(),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
class EntryFormService
{
/** @return array{event_dates: Collection<int, EventDate>} */
public function get(Tenant $tenant): array
{
return [
'event_dates' => $tenant->eventDates()
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->with('validityTime')
->get(),
];
}
}

View File

@@ -27,7 +27,11 @@ class FoodFormService
->keyBy('codigo');
return [
'event_dates' => $tenant->eventDates()->with('validityTime')->get(),
'event_dates' => $tenant->eventDates()
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->with('validityTime')
->get(),
'schedules' => $attributes->get('horario')?->options ?? new Collection,
'services' => $attributes->get('servicio')?->options ?? new Collection,
];

View File

@@ -1,5 +1,6 @@
<?php
use App\Domains\Forms\Controllers\AdminApp\EntryFormController;
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
use App\Domains\Forms\Controllers\AdminApp\FoodFormController;
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
@@ -22,6 +23,10 @@ Route::prefix('v1/adminapp/forms')
'fiesta-futbol-infantil/ticket',
TicketFormController::class
);
Route::get(
'fiesta-futbol-infantil/entry',
EntryFormController::class
);
Route::get(
'fiesta-futbol-infantil/merchandise',
MerchandiseFormController::class

View File

@@ -28,7 +28,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'discount_total',
'tax_total',
'total',
'refunded_amount',
])]
class PurchaseItem extends Model
{
@@ -49,7 +48,6 @@ class PurchaseItem extends Model
'discount_total' => 'decimal:2',
'tax_total' => 'decimal:2',
'total' => 'decimal:2',
'refunded_amount' => 'decimal:2',
];
}

View File

@@ -25,7 +25,6 @@ class PurchaseItemResource extends JsonResource
'quantity' => (int) $this->cantidad,
'unit_price' => $this->formatMoney($this->precio_unitario),
'line_total' => $this->formatMoney($this->total),
'refunded_amount' => $this->formatMoney($this->refunded_amount),
'source_catalog_item_id' => $this->source_catalog_item_id,
'source_variant_id' => $this->source_variant_id,
'item_details' => [

View File

@@ -41,6 +41,17 @@ class CatalogSelectionResolver
]);
}
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.catalog_item_id" => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem;
}
@@ -70,6 +81,12 @@ class CatalogSelectionResolver
throw new NotFoundHttpException('Variant not found for catalog item.');
}
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.variant_id" => [__('api.cart.variant_unavailable')],
]);
}
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation(
'inventory',

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Services\CartVariantReplacementService;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
@@ -29,6 +30,7 @@ class StartCheckoutService
private readonly InsufficientStockMessageBuilder $stockMessages,
private readonly PurchaseResponseLoader $responses,
private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly CartVariantReplacementService $variantReplacements,
) {}
/** @param array<string, mixed> $purchaseData */
@@ -250,6 +252,7 @@ class StartCheckoutService
int $cartId,
): Purchase {
$cart = $this->resolveCart($tenant, $userId, $cartId);
$this->variantReplacements->replaceHistoricalVariants($cart);
$cartItems = $cart->items()->lockForUpdate()->get();
if ($cartItems->isEmpty()) {

View File

@@ -2,20 +2,20 @@
namespace App\Domains\Purchase\Services;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Builder;
class PurchaseRefundSummaryService
{
public function totalForTenant(Tenant $tenant): string
{
$total = PurchaseItem::query()
$total = TicketRefund::query()
->whereHas(
'purchase',
'purchaseItem.purchase',
fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo)
)
->sum('refunded_amount');
->sum('amount');
return number_format((float) $total, 2, '.', '');
}

View File

@@ -22,7 +22,6 @@ class SaleDetailResource extends JsonResource
'quantity' => (int) $item->cantidad,
'unit_price' => $this->formatMoney($item->precio_unitario),
'total' => $this->formatMoney($item->total),
'refunded_amount' => $this->formatMoney($item->refunded_amount),
])->values(),
'total' => $this->formatMoney($this->total),
];

View File

@@ -9,6 +9,7 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Client\Models\Client;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu;
use App\Domains\Tenant\Enums\CartEditingPolicy;
@@ -229,6 +230,14 @@ class Tenant extends Model
->orderBy('time_start');
}
/** @return HasMany<EventDateChange, $this> */
public function eventDateChanges(): HasMany
{
return $this->hasMany(EventDateChange::class, 'tenant_code', 'codigo')
->orderBy('created_at')
->orderBy('id');
}
/**
* @return HasMany<Category, $this>
*/

View File

@@ -6,6 +6,8 @@ use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Models\AttachmentCrop;
use App\Domains\Catalog\Models\Category;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Resources\EventDateChangeResource;
use App\Domains\Event\Services\EventDateNoticeFormatter;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
@@ -61,6 +63,14 @@ class TenantResource extends JsonResource
'time_start' => $eventDate->time_start,
'time_end' => $eventDate->time_end,
])->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(
'websiteExtras',

View File

@@ -32,7 +32,6 @@ class AdminAppTicketRowService
?? $ticket->sourceCatalogItem?->nombre
?? $ticket->name,
'amount' => $purchaseItem?->precio_unitario,
'refunded_amount' => $refund?->amount ?? $purchaseItem?->refunded_amount,
'refund_type' => $refund?->type,
'refund_type_label' => $refund?->typeLabel(),
'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido,

View File

@@ -142,7 +142,7 @@ class AdminAppTicketService
$unitPrice = (float) $purchaseItem->precio_unitario;
$itemTotal = (float) $purchaseItem->total;
$itemRefundedAmount = (float) ($purchaseItem->refunded_amount ?? 0);
$itemRefundedAmount = $this->refundedAmountForPurchaseItem($purchaseItem);
$remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2));
$total = null;
@@ -201,7 +201,10 @@ class AdminAppTicketService
}
$refundAmount = $this->refundAmount($purchaseItem, $tenant, $refundType);
$refundedAmount = round((float) $purchaseItem->refunded_amount + $refundAmount, 2);
$refundedAmount = round(
$this->refundedAmountForPurchaseItem($purchaseItem) + $refundAmount,
2,
);
if ($refundedAmount > (float) $purchaseItem->total) {
throw ValidationException::withMessages([
@@ -220,14 +223,17 @@ class AdminAppTicketService
'amount' => number_format($refundAmount, 2, '.', ''),
]);
$purchaseItem->update([
'refunded_amount' => number_format($refundedAmount, 2, '.', ''),
]);
return $ticket->refresh()->load(self::RELATIONS);
});
}
private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float
{
return round((float) TicketRefund::query()
->where('purchase_item_id', $purchaseItem->id)
->sum('amount'), 2);
}
private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void
{
$isAllowed = match ($refundType) {

View File

@@ -52,8 +52,7 @@
"npx concurrently -c \"#93c5fd,#c4b5fd,#a7f3d0,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=emails,default --tries=1 --timeout=0\" \"php artisan schedule:work\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,scheduler,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
"@php vendor/phpunit/phpunit/phpunit"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",

View File

@@ -0,0 +1,34 @@
<?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('event_date_reschedules', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->foreignId('source_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete();
$table->foreignId('destination_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete();
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->date('previous_date');
$table->date('new_date');
$table->timestamp('created_at')->useCurrent();
$table->foreign('tenant_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->index(['tenant_code', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('event_date_reschedules');
}
};

View File

@@ -0,0 +1,86 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$requiresForeignKeyRecreation = in_array(DB::getDriverName(), ['mysql', 'mariadb'], true);
Schema::table('variantes', function (Blueprint $table) use ($requiresForeignKeyRecreation): void {
if ($requiresForeignKeyRecreation) {
$table->dropForeign(['inventory_id']);
}
$table->dropUnique('variantes_inventory_id_unique');
$table->index('inventory_id');
if ($requiresForeignKeyRecreation) {
$table->foreign('inventory_id')->references('id')->on('inventories')->restrictOnDelete();
}
$table->foreignId('replaced_by_variant_id')
->nullable()
->after('inventory_id')
->constrained('variantes')
->nullOnDelete();
$table->timestamp('sales_disabled_at')
->nullable()
->after('replaced_by_variant_id');
$table->index(
['sales_disabled_at', 'replaced_by_variant_id'],
'variants_sellable_index',
);
});
}
public function down(): void
{
$requiresForeignKeyRecreation = in_array(DB::getDriverName(), ['mysql', 'mariadb'], true);
DB::table('variantes')
->orderBy('id')
->get()
->groupBy('inventory_id')
->each(function ($variants): void {
$variants->skip(1)->each(function (object $variant): void {
$inventory = DB::table('inventories')->where('id', $variant->inventory_id)->first();
if ($inventory === null) {
return;
}
$inventoryId = DB::table('inventories')->insertGetId([
'sold_units' => $inventory->sold_units,
'reserved_stock' => 0,
'real_stock' => $inventory->real_stock,
]);
DB::table('variantes')->where('id', $variant->id)->update([
'inventory_id' => $inventoryId,
]);
});
});
Schema::table('variantes', function (Blueprint $table) use ($requiresForeignKeyRecreation): void {
$table->dropIndex('variants_sellable_index');
$table->dropConstrainedForeignId('replaced_by_variant_id');
$table->dropColumn('sales_disabled_at');
if ($requiresForeignKeyRecreation) {
$table->dropForeign(['inventory_id']);
}
$table->dropIndex(['inventory_id']);
$table->unique('inventory_id');
if ($requiresForeignKeyRecreation) {
$table->foreign('inventory_id')->references('id')->on('inventories')->restrictOnDelete();
}
});
}
};

View File

@@ -0,0 +1,115 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$this->createEventDateChangesTable();
DB::table('event_date_changes')->insertUsing(
[
'tenant_code',
'change_type',
'source_event_date_id',
'destination_event_date_id',
'created_by_user_id',
'previous_date',
'new_date',
'created_at',
],
DB::table('event_date_reschedules')->select([
'tenant_code',
DB::raw("'rescheduled'"),
'source_event_date_id',
'destination_event_date_id',
'created_by_user_id',
'previous_date',
'new_date',
'created_at',
]),
);
Schema::drop('event_date_reschedules');
}
public function down(): void
{
$this->createEventDateReschedulesTable();
DB::table('event_date_reschedules')->insertUsing(
[
'tenant_code',
'source_event_date_id',
'destination_event_date_id',
'created_by_user_id',
'previous_date',
'new_date',
'created_at',
],
DB::table('event_date_changes')
->where('change_type', 'rescheduled')
->whereNotNull('destination_event_date_id')
->whereNotNull('new_date')
->select([
'tenant_code',
'source_event_date_id',
'destination_event_date_id',
'created_by_user_id',
'previous_date',
'new_date',
'created_at',
]),
);
Schema::drop('event_date_changes');
}
private function createEventDateChangesTable(): void
{
Schema::create('event_date_changes', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->string('change_type', 16);
$table->foreignId('source_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete();
$table->foreignId('destination_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete();
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->date('previous_date');
$table->date('new_date')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->foreign('tenant_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->index(['tenant_code', 'created_at']);
$table->index(['source_event_date_id', 'change_type']);
});
}
private function createEventDateReschedulesTable(): void
{
Schema::create('event_date_reschedules', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->foreignId('source_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete();
$table->foreignId('destination_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete();
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->date('previous_date');
$table->date('new_date');
$table->timestamp('created_at')->useCurrent();
$table->foreign('tenant_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->index(['tenant_code', 'created_at']);
});
}
};

View File

@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$mismatchedItem = DB::table('compra_items as purchase_items')
->leftJoin('ticket_refunds as refunds', 'refunds.purchase_item_id', '=', 'purchase_items.id')
->where('purchase_items.refunded_amount', '>', 0)
->groupBy('purchase_items.id', 'purchase_items.refunded_amount')
->selectRaw(
'purchase_items.id, purchase_items.refunded_amount, COALESCE(SUM(refunds.amount), 0) as refund_total'
)
->get()
->first(fn (object $item): bool => abs(
(float) $item->refunded_amount - (float) $item->refund_total
) > 0.005);
if ($mismatchedItem !== null) {
throw new RuntimeException(
"No se puede eliminar compra_items.refunded_amount: el ítem {$mismatchedItem->id} "
.'contiene un importe histórico que no está respaldado por ticket_refunds.'
);
}
Schema::table('compra_items', function (Blueprint $table): void {
$table->dropColumn('refunded_amount');
});
}
public function down(): void
{
Schema::table('compra_items', function (Blueprint $table): void {
$table->decimal('refunded_amount', 10, 2)->default(0)->after('total');
});
DB::table('ticket_refunds')
->selectRaw('purchase_item_id, SUM(amount) as refund_total')
->groupBy('purchase_item_id')
->orderBy('purchase_item_id')
->eachById(function (object $refund): void {
DB::table('compra_items')
->where('id', $refund->purchase_item_id)
->update(['refunded_amount' => $refund->refund_total]);
}, column: 'purchase_item_id');
}
};

View File

@@ -34,7 +34,10 @@ return [
'max_quantity' => 'You can add a maximum of :max.',
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
'empty_bundle' => 'The bundle has no components.',
'bundle_component_unavailable' => 'The bundle contains a variant that is no longer available for sale.',
'variant_required' => 'You must select a variant for this item.',
'variant_unavailable' => 'The selected variant was replaced or is no longer available for sale.',
'cart_variant_unavailable' => 'The cart contains a replaced variant or one that is no longer available for sale.',
'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.',
],
'purchase' => [

View File

@@ -34,7 +34,10 @@ return [
'max_quantity' => 'El máximo que se puede agregar es :max.',
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
'empty_bundle' => 'El bundle no tiene componentes.',
'bundle_component_unavailable' => 'El bundle contiene una variante que ya no está disponible para la venta.',
'variant_required' => 'Debe seleccionar una variante para este ítem.',
'variant_unavailable' => 'La variante seleccionada fue reemplazada o ya no está disponible para la venta.',
'cart_variant_unavailable' => 'El carrito contiene una variante reemplazada o que ya no está disponible para la venta.',
'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.',
],
'purchase' => [

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
bootstrap="tests/bootstrap.php"
colors="true"
>
<testsuites>
@@ -19,7 +19,9 @@
</source>
<php>
<env name="APP_ENV" value="testing" force="true"/>
<env name="DB_DATABASE" value="shopit_test" force="true"/>
<env name="DB_CONNECTION" value="sqlite" force="true"/>
<env name="DB_DATABASE" value=":memory:" force="true"/>
<env name="DB_URL" value="null" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="APP_CONFIG_CACHE" value="bootstrap/cache/phpunit-config.php"/>
<env name="APP_EVENTS_CACHE" value="bootstrap/cache/phpunit-events.php"/>

View File

@@ -200,10 +200,12 @@ class CatalogSchemaTest extends TestCase
]);
}
public function test_variants_can_override_catalog_item_use_dates(): void
public function test_variants_support_event_dates_and_commercial_replacements(): void
{
$this->assertTrue(Schema::hasColumns('variantes', [
'event_date_id',
'replaced_by_variant_id',
'sales_disabled_at',
]));
}

View File

@@ -11,6 +11,7 @@ use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Purchase\Services\Checkout\CatalogSelectionResolver;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Enums\ValidityTimeType;
@@ -20,6 +21,7 @@ use Database\Seeders\SocialMediaSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
@@ -247,6 +249,7 @@ class AdminAppEventControllerTest extends TestCase
'time_end' => '20:00',
]);
$variant = $this->createVariant($tenant, $original->id);
$variant->inventory()->update(['real_stock' => 5]);
$ticket = $this->createTicket($tenant, $admin, $variant);
Sanctum::actingAs($admin);
@@ -267,11 +270,60 @@ class AdminAppEventControllerTest extends TestCase
});
$this->assertDatabaseCount('event_dates', 2);
$this->assertDatabaseHas('event_date_changes', [
'tenant_code' => $tenant->codigo,
'change_type' => 'rescheduled',
'source_event_date_id' => $original->id,
'destination_event_date_id' => $destination->id,
'created_by_user_id' => $admin->id,
'previous_date' => '2027-10-09',
'new_date' => '2027-10-20',
]);
$variant->refresh();
$replacement = $variant->replacement()->firstOrFail();
$this->assertSame($original->id, $variant->event_date_id);
$this->assertSame($destination->id, $replacement->event_date_id);
$this->assertSame($variant->inventory_id, $replacement->inventory_id);
$this->assertNotNull($variant->sales_disabled_at);
$this->assertSame($replacement->id, $variant->replaced_by_variant_id);
$this->assertSame(
[$replacement->id],
$variant->catalogItem->fresh(['variants.inventory'])->visibleVariants()->modelKeys(),
);
$this->assertSame($variant->id, $ticket->fresh()->source_variant_id);
try {
app(CatalogSelectionResolver::class)->resolve(
$tenant,
$variant->catalog_item_id,
$variant->id,
'direct_items.0',
);
$this->fail('The historical variant should not be sellable.');
} catch (ValidationException $exception) {
$this->assertArrayHasKey('direct_items.0.variant_id', $exception->errors());
}
$this->assertSame('20 de Octubre 2027', $tenant->fresh()->event_date_text);
$this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F')
->assertOk()
->assertJsonCount(1, 'data.event.dates')
->assertJsonPath('data.event.dates.0.id', $destination->id)
->assertJsonCount(1, 'data.event.date_changes')
->assertJsonPath('data.event.date_changes.0.type', 'rescheduled')
->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');
$this->assertSame(
'2027-10-20 11:00:00',
@@ -287,11 +339,35 @@ class AdminAppEventControllerTest extends TestCase
])->assertOk()->assertJsonPath('data.status', 'rescheduled');
$this->assertDatabaseCount('event_dates', 3);
$this->assertDatabaseCount('event_date_changes', 2);
$this->assertDatabaseHas('event_date_changes', [
'tenant_code' => $tenant->codigo,
'change_type' => 'rescheduled',
'source_event_date_id' => $destination->id,
'created_by_user_id' => $admin->id,
'previous_date' => '2027-10-20',
'new_date' => '2027-10-25',
]);
$replacement->refresh();
$latestReplacement = $replacement->replacement()->firstOrFail();
$this->assertSame($replacement->inventory_id, $latestReplacement->inventory_id);
$this->assertSame('2027-10-25', $latestReplacement->eventDate->date->format('Y-m-d'));
$this->assertFalse($replacement->isSellable());
$this->assertTrue($latestReplacement->isSellable());
$this->assertSame('25 de Octubre 2027', $tenant->fresh()->event_date_text);
$this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F')
->assertOk()
->assertJsonCount(1, 'data.event.dates')
->assertJsonPath('data.event.dates.0.date', '2027-10-25')
->assertJsonCount(2, 'data.event.date_changes')
->assertJsonPath('data.event.date_changes.1.type', 'rescheduled')
->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');
$this->assertSame(
'2027-10-25 11:00:00',
@@ -299,6 +375,41 @@ class AdminAppEventControllerTest extends TestCase
);
}
public function test_rescheduling_reuses_an_equivalent_destination_variant(): void
{
Event::fake([EventDateRescheduled::class]);
$tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme');
$original = $tenant->eventDates()->create([
'date' => '2027-10-09',
'time_start' => '09:00',
'time_end' => '18:30',
]);
$destination = $tenant->eventDates()->create([
'date' => '2027-10-20',
'time_start' => '09:00',
'time_end' => '18:30',
]);
$historicalVariant = $this->createVariant($tenant, $original->id);
$destinationVariant = Variant::query()->create([
'catalog_item_id' => $historicalVariant->catalog_item_id,
'inventory_id' => Inventory::query()->create(['real_stock' => 5])->id,
'event_date_id' => $destination->id,
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson("/api/v1/adminapp/tenant/event-dates/{$original->id}/reschedule", [
'date' => '2027-10-20',
])->assertOk();
$this->assertSame(2, Variant::query()->count());
$this->assertSame(
$destinationVariant->id,
$historicalVariant->fresh()->replaced_by_variant_id,
);
$this->assertSame($original->id, $historicalVariant->fresh()->event_date_id);
$this->assertTrue($destinationVariant->fresh()->isSellable());
}
public function test_suspending_disables_only_tickets_without_another_usable_date(): void
{
Event::fake([EventDateSuspended::class]);
@@ -333,13 +444,41 @@ class AdminAppEventControllerTest extends TestCase
&& $event->purchaseTickets === [];
});
$this->assertDatabaseHas('event_date_changes', [
'tenant_code' => $tenant->codigo,
'change_type' => 'suspended',
'source_event_date_id' => $suspendedDate->id,
'destination_event_date_id' => null,
'created_by_user_id' => $admin->id,
'previous_date' => '2027-10-09',
'new_date' => null,
]);
$this->postJson("/api/v1/adminapp/tenant/event-dates/{$suspendedDate->id}/suspend")
->assertOk();
$this->assertDatabaseCount('event_date_changes', 1);
$this->assertNotNull($singleDateTicket->fresh()->disabled_at);
$this->assertNull($multipleDateTicket->fresh()->disabled_at);
$this->assertNotNull($singleDateVariant->fresh()->sales_disabled_at);
$this->assertNotNull($multipleDateVariant->fresh()->sales_disabled_at);
$this->assertSame('10 de Octubre 2027', $tenant->fresh()->event_date_text);
$this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F')
->assertOk()
->assertJsonCount(1, 'data.event.dates')
->assertJsonPath('data.event.dates.0.id', $otherDate->id)
->assertJsonCount(1, 'data.event.date_changes')
->assertJsonPath('data.event.date_changes.0.type', 'suspended')
->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');
$this->assertSame(
'2027-10-10 09:00:00',

View File

@@ -0,0 +1,82 @@
<?php
namespace Tests\Feature\Forms;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppEntryFormControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
}
public function test_authentication_is_required(): void
{
$this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/entry')
->assertUnauthorized();
}
public function test_it_returns_only_selectable_event_dates_for_the_tenant(): void
{
$tenant = $this->createTenant('fiesta');
$otherTenant = $this->createTenant('other');
$available = $this->createEventDate($tenant, '2026-10-09');
$rescheduled = $this->createEventDate($tenant, '2026-10-10');
$replacement = $this->createEventDate($tenant, '2026-10-11');
$rescheduled->update(['rescheduled_to_event_date_id' => $replacement->id]);
$this->createEventDate($tenant, '2026-10-12', ['suspended_at' => now()]);
$this->createEventDate($otherTenant, '2026-10-13');
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]));
$this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/entry')
->assertOk()
->assertJsonCount(2, 'data.event_dates')
->assertJsonFragment(['id' => $available->id, 'date' => '2026-10-09'])
->assertJsonFragment(['id' => $replacement->id, 'date' => '2026-10-11'])
->assertJsonMissing(['date' => '2026-10-10'])
->assertJsonMissing(['date' => '2026-10-12'])
->assertJsonMissing(['date' => '2026-10-13']);
}
private function createTenant(string $code): Tenant
{
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
'website_type_code' => 'onticket',
]);
}
/** @param array<string, mixed> $overrides */
private function createEventDate(
Tenant $tenant,
string $date,
array $overrides = []
): EventDate {
return $tenant->eventDates()->create([
'date' => $date,
'time_start' => '00:00',
'time_end' => '23:59',
...$overrides,
]);
}
}

View File

@@ -67,6 +67,51 @@ class AdminAppFoodFormControllerTest extends TestCase
->assertJsonPath('data.services.0.value', 'Comedor');
}
public function test_it_excludes_rescheduled_and_suspended_event_dates(): void
{
$tenant = Tenant::query()->create([
'codigo' => 'fiesta',
'nombre' => 'Fiesta',
'dominio' => 'fiesta.test',
'website_type_code' => 'onticket',
]);
$available = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$rescheduled = $tenant->eventDates()->create([
'date' => '2026-10-10',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$replacement = $tenant->eventDates()->create([
'date' => '2026-10-11',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$rescheduled->update(['rescheduled_to_event_date_id' => $replacement->id]);
$tenant->eventDates()->create([
'date' => '2026-10-12',
'time_start' => '00:00',
'time_end' => '23:59',
'suspended_at' => now(),
]);
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]));
$this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/food')
->assertOk()
->assertJsonCount(2, 'data.event_dates')
->assertJsonFragment(['id' => $available->id, 'date' => '2026-10-09'])
->assertJsonFragment(['id' => $replacement->id, 'date' => '2026-10-11'])
->assertJsonMissing(['date' => '2026-10-10'])
->assertJsonMissing(['date' => '2026-10-12']);
}
/** @param array<int, array{value: string, label: string, sort_order: int}> $options */
private function createAttribute(Tenant $tenant, string $code, array $options): void
{

View File

@@ -72,6 +72,79 @@ class StorePurchaseTest extends TestCase
$this->travelBack();
}
public function test_checkout_replaces_a_historical_cart_variant_without_duplicating_stock(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$historicalVariant = $this->createVariantForTenant('sonder', 10, '50.00');
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo,
'user_id' => $user->id,
'status' => Cart::STATUS_ACTIVE,
]);
$cart->addItem($historicalVariant->catalog_item_id, $historicalVariant->id, 2);
$replacement = Variant::query()->create([
'catalog_item_id' => $historicalVariant->catalog_item_id,
'inventory_id' => $historicalVariant->inventory_id,
'precio' => '50.00',
]);
$historicalVariant->update([
'replaced_by_variant_id' => $replacement->id,
'sales_disabled_at' => now(),
]);
$purchase = app(CheckoutService::class)->startCheckout($tenant, $user->id, [
'cart_id' => $cart->id,
]);
$this->assertDatabaseHas('carrito_items', [
'cart_id' => $cart->id,
'variant_id' => $replacement->id,
'cantidad' => 2,
]);
$this->assertDatabaseHas('compra_items', [
'compra_id' => $purchase->id,
'source_variant_id' => $replacement->id,
'cantidad' => 2,
]);
$this->assertDatabaseHas('inventories', [
'id' => $historicalVariant->inventory_id,
'real_stock' => 10,
'reserved_stock' => 2,
]);
$this->assertSame(8, $historicalVariant->catalogItem->fresh()->availableStock());
}
public function test_direct_checkout_rejects_a_historical_variant(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$historicalVariant = $this->createVariantForTenant('sonder', 10, '50.00');
$replacement = Variant::query()->create([
'catalog_item_id' => $historicalVariant->catalog_item_id,
'inventory_id' => $historicalVariant->inventory_id,
'precio' => '50.00',
]);
$historicalVariant->update([
'replaced_by_variant_id' => $replacement->id,
'sales_disabled_at' => now(),
]);
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'direct_items' => [[
'catalog_item_id' => $historicalVariant->catalog_item_id,
'variant_id' => $historicalVariant->id,
'cantidad' => 1,
]],
])
->assertUnprocessable()
->assertJsonValidationErrors('direct_items.0.variant_id');
$this->assertDatabaseCount('compras', 0);
}
public function test_checkout_cannot_replace_an_overdue_cart_reservation(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');

View File

@@ -20,6 +20,7 @@ use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketRefund;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
@@ -131,7 +132,8 @@ class AdminAppSaleControllerTest extends TestCase
public function test_sales_list_uses_purchase_item_snapshots_for_every_status(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
@@ -155,7 +157,7 @@ class AdminAppSaleControllerTest extends TestCase
'status' => Purchase::STATUS_CREATED,
'total' => '30000.00',
]);
PurchaseItem::query()->create([
$createdPurchaseItem = PurchaseItem::query()->create([
'compra_id' => $createdPurchase->id,
'source_catalog_item_id' => $catalogItem->id,
'nombre' => $catalogItem->nombre,
@@ -163,7 +165,20 @@ class AdminAppSaleControllerTest extends TestCase
'cantidad' => 3,
'precio_unitario' => '10000.00',
'total' => '30000.00',
'refunded_amount' => '1250.00',
]);
$createdRefundTicket = Ticket::query()->create([
'tenant_code' => $tenant->codigo,
'ticket' => 'created-purchase-refund',
'user_id' => $admin->id,
'source_purchase_item_id' => $createdPurchaseItem->id,
'refunded_at' => now(),
]);
TicketRefund::query()->create([
'ticket_id' => $createdRefundTicket->id,
'purchase_item_id' => $createdPurchaseItem->id,
'created_by_user_id' => $admin->id,
'type' => TicketRefund::TYPE_PARTIAL,
'amount' => '1250.00',
]);
$pendingCart = Cart::query()->create([
@@ -196,7 +211,7 @@ class AdminAppSaleControllerTest extends TestCase
'status' => Purchase::STATUS_PAID,
'total' => '20000.00',
]);
PurchaseItem::query()->create([
$paidPurchaseItem = PurchaseItem::query()->create([
'compra_id' => $paidPurchase->id,
'source_catalog_item_id' => $catalogItem->id,
'nombre' => $catalogItem->nombre,
@@ -204,7 +219,20 @@ class AdminAppSaleControllerTest extends TestCase
'cantidad' => 2,
'precio_unitario' => '10000.00',
'total' => '20000.00',
'refunded_amount' => '2500.00',
]);
$paidRefundTicket = Ticket::query()->create([
'tenant_code' => $tenant->codigo,
'ticket' => 'paid-purchase-refund',
'user_id' => $admin->id,
'source_purchase_item_id' => $paidPurchaseItem->id,
'refunded_at' => now(),
]);
TicketRefund::query()->create([
'ticket_id' => $paidRefundTicket->id,
'purchase_item_id' => $paidPurchaseItem->id,
'created_by_user_id' => $admin->id,
'type' => TicketRefund::TYPE_PARTIAL,
'amount' => '2500.00',
]);
$supersededPurchase = Purchase::query()->create([

View File

@@ -194,14 +194,13 @@ class AdminAppTicketControllerTest extends TestCase
->assertJsonPath('data.id', $ticket->id)
->assertJsonPath('data.status', Ticket::STATUS_REFUNDED)
->assertJsonPath('data.status_label', 'Reembolso total')
->assertJsonPath('data.refunded_amount', '100.00')
->assertJsonMissingPath('data.refunded_amount')
->assertJsonPath('data.refund.type', TicketRefund::TYPE_TOTAL)
->assertJsonPath('data.refund.type_label', 'Reembolso total')
->assertJsonPath('data.refund.amount', '100.00')
->assertJsonPath('data.refund.created_by', $admin->nombre_apellido);
$this->assertNotNull($ticket->fresh()->refunded_at);
$this->assertSame('100.00', $purchaseItem->fresh()->refunded_amount);
$this->assertDatabaseHas('ticket_refunds', [
'ticket_id' => $ticket->id,
'purchase_item_id' => $purchaseItem->id,
@@ -230,11 +229,10 @@ class AdminAppTicketControllerTest extends TestCase
->assertOk()
->assertJsonPath('data.status', Ticket::STATUS_REFUNDED)
->assertJsonPath('data.status_label', 'Reembolso parcial')
->assertJsonPath('data.refunded_amount', '25.50')
->assertJsonMissingPath('data.refunded_amount')
->assertJsonPath('data.refund.type', TicketRefund::TYPE_PARTIAL)
->assertJsonPath('data.refund.amount', '25.50');
$this->assertSame('25.50', $purchaseItem->fresh()->refunded_amount);
$this->assertDatabaseHas('ticket_refunds', [
'ticket_id' => $ticket->id,
'type' => TicketRefund::TYPE_PARTIAL,
@@ -270,7 +268,10 @@ class AdminAppTicketControllerTest extends TestCase
'refund_type' => TicketRefund::TYPE_TOTAL,
])->assertOk()->assertJsonPath('data.status_label', 'Reembolso total');
$this->assertSame('125.00', $purchaseItem->fresh()->refunded_amount);
$this->assertSame(
'125.00',
number_format((float) $purchaseItem->ticketRefunds()->sum('amount'), 2, '.', ''),
);
$this->assertDatabaseHas('ticket_refunds', [
'ticket_id' => $partialTicket->id,
'type' => TicketRefund::TYPE_PARTIAL,
@@ -298,7 +299,7 @@ class AdminAppTicketControllerTest extends TestCase
->assertJsonValidationErrors('refund_type');
$this->assertNull($ticket->fresh()->refunded_at);
$this->assertSame('0.00', $purchaseItem->fresh()->refunded_amount);
$this->assertSame(0, $purchaseItem->ticketRefunds()->count());
}
public function test_it_validates_the_refund_type(): void
@@ -369,8 +370,18 @@ class AdminAppTicketControllerTest extends TestCase
Sanctum::actingAs($admin);
[$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00');
// Simulate 70 already refunded out of 100 on the item (remaining is 30)
$purchaseItem->update(['refunded_amount' => '70.00']);
// Simulate 70 already refunded out of 100 on the item (remaining is 30).
$previousTicket = $this->createTicket($tenant, $admin, [
'source_purchase_item_id' => $purchaseItem->id,
'refunded_at' => now(),
]);
TicketRefund::query()->create([
'ticket_id' => $previousTicket->id,
'purchase_item_id' => $purchaseItem->id,
'created_by_user_id' => $admin->id,
'type' => TicketRefund::TYPE_PARTIAL,
'amount' => '70.00',
]);
$this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund")
->assertOk()
@@ -599,7 +610,8 @@ class AdminAppTicketControllerTest extends TestCase
$this->createTicket($tenant, $admin)->update(['used_at' => now()]);
$this->createTicket($tenant, $admin);
$this->createTicket($tenant, $admin)->update(['cancelled_at' => now()]);
$this->createTicket($tenant, $admin)->update(['refunded_at' => now()]);
$refundedTicket = $this->createTicket($tenant, $admin);
$refundedTicket->update(['refunded_at' => now()]);
$this->createTicket($tenant, $admin)->update(['disabled_at' => now()]);
$this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]);
@@ -614,7 +626,7 @@ class AdminAppTicketControllerTest extends TestCase
'status' => Purchase::STATUS_PAID,
'total' => '1000.00',
]);
PurchaseItem::query()->create([
$purchaseItem = PurchaseItem::query()->create([
'compra_id' => $purchase->id,
'source_catalog_item_id' => $catalogItem->id,
'nombre' => 'Entrada',
@@ -622,14 +634,21 @@ class AdminAppTicketControllerTest extends TestCase
'cantidad' => 1,
'precio_unitario' => '1000.00',
'total' => '1000.00',
'refunded_amount' => '250.00',
]);
$refundedTicket->update(['source_purchase_item_id' => $purchaseItem->id]);
TicketRefund::query()->create([
'ticket_id' => $refundedTicket->id,
'purchase_item_id' => $purchaseItem->id,
'created_by_user_id' => $admin->id,
'type' => TicketRefund::TYPE_PARTIAL,
'amount' => '250.00',
]);
$otherPurchase = Purchase::query()->create([
'tenant_codigo' => $otherTenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '2000.00',
]);
PurchaseItem::query()->create([
$otherPurchaseItem = PurchaseItem::query()->create([
'compra_id' => $otherPurchase->id,
'source_catalog_item_id' => $catalogItem->id,
'nombre' => 'Otra entrada',
@@ -637,7 +656,17 @@ class AdminAppTicketControllerTest extends TestCase
'cantidad' => 1,
'precio_unitario' => '2000.00',
'total' => '2000.00',
'refunded_amount' => '2000.00',
]);
$otherRefundedTicket = $this->createTicket($otherTenant, $otherUser, [
'source_purchase_item_id' => $otherPurchaseItem->id,
'refunded_at' => now(),
]);
TicketRefund::query()->create([
'ticket_id' => $otherRefundedTicket->id,
'purchase_item_id' => $otherPurchaseItem->id,
'created_by_user_id' => $otherUser->id,
'type' => TicketRefund::TYPE_TOTAL,
'amount' => '2000.00',
]);
$this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match')
@@ -722,7 +751,7 @@ class AdminAppTicketControllerTest extends TestCase
->assertJsonPath('data.0.order_number', $purchase->id)
->assertJsonPath('data.0.product', 'Remera')
->assertJsonPath('data.0.amount', '8000.00')
->assertJsonPath('data.0.refunded_amount', '0.00')
->assertJsonMissingPath('data.0.refunded_amount')
->assertJsonPath('data.0.status', Ticket::STATUS_USED)
->assertJsonPath('data.0.scanned_by', $admin->nombre_apellido)
->assertJsonPath('data.0.variant_properties.0.code', 'size')

17
tests/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Database isolation
Run the suite with `composer test` or `vendor/bin/phpunit`. Both use
`tests/bootstrap.php`, which forces SQLite `:memory:` in all environment sources.
Tests never need a MySQL test database or the local database credentials.
`Tests\TestCase` rejects cached configuration and validates the default connection
before application providers boot. Its connection factory also rejects persistent
databases, URLs, and alternate endpoints for named or dynamically built connections.
Tests that need Laravel must extend this base class. Do not bypass these guards to
make a failing test pass; adapt database-specific tests to SQLite or use a separately
designed disposable database workflow.
`php tests/verify-database-safety.php` checks the guard and application wiring without
running test setup, queries, migrations, or opening PDO connections.
The suite does not validate MySQL-specific behavior when using SQLite.

View File

@@ -0,0 +1,31 @@
<?php
namespace Tests\Support;
use Illuminate\Database\Connectors\ConnectionFactory;
use RuntimeException;
final class InMemoryConnectionFactory extends ConnectionFactory
{
public static function assertSafe(array $config): void
{
// Reject URLs and alternate endpoints rather than trusting a database name.
if (($config['driver'] ?? null) !== 'sqlite'
|| ($config['database'] ?? null) !== ':memory:'
|| ! empty($config['url'])
|| array_key_exists('read', $config)
|| array_key_exists('write', $config)
|| array_key_exists('direct', $config)) {
throw new RuntimeException(
'Unsafe test connection. Tests may only use SQLite :memory: without URLs or alternate endpoints.'
);
}
}
public function make(array $config, $name = null)
{
self::assertSafe($config);
return parent::make($config, $name);
}
}

View File

@@ -3,8 +3,11 @@
namespace Tests;
use Illuminate\Foundation\Application;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Bootstrap\LoadConfiguration;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use RuntimeException;
use Tests\Support\InMemoryConnectionFactory;
abstract class TestCase extends BaseTestCase
{
@@ -13,19 +16,28 @@ abstract class TestCase extends BaseTestCase
*/
public function createApplication(): Application
{
$app = parent::createApplication();
$app = require dirname(__DIR__).'/bootstrap/app.php';
$this->traitsUsedByTest = class_uses_recursive(static::class);
$database = (string) $app['config']->get(
'database.connections.'.$app['config']->get('database.default').'.database'
);
if (! preg_match('/^shopit_(?:test|testing)(?:_\d+)?$/', $database)) {
throw new RuntimeException(sprintf(
'Refusing to run tests against database [%s]. Use [shopit_test] or [shopit_testing].',
$database !== '' ? $database : '(empty)'
));
if ($app->configurationIsCached()) {
throw new RuntimeException('Tests refuse cached configuration. Remove the test config cache before retrying.');
}
// Validate before providers boot or RefreshDatabase can run migrations.
$app->afterBootstrapping(LoadConfiguration::class, function (Application $app): void {
if (! $app->environment('testing')) {
throw new RuntimeException('Tests require APP_ENV=testing.');
}
InMemoryConnectionFactory::assertSafe((array) $app['config']->get(
'database.connections.'.$app['config']->get('database.default')
));
});
// Also guard named/dynamic connections and changes made by individual tests.
$app->extend('db.factory', fn () => new InMemoryConnectionFactory($app));
$app->make(Kernel::class)->bootstrap();
return $app;
}
}

View File

@@ -309,7 +309,7 @@ class CatalogModelsTest extends TestCase
$this->assertSame(2, $inventory->sold_units);
$this->assertSame(7, $inventory->availableStock());
$this->assertInstanceOf(CatalogItem::class, $inventory->catalogItem()->getRelated());
$this->assertInstanceOf(Variant::class, $inventory->variant()->getRelated());
$this->assertInstanceOf(Variant::class, $inventory->variants()->getRelated());
}
public function test_catalog_item_aggregates_variant_inventory(): void

View File

@@ -0,0 +1,97 @@
<?php
namespace Tests\Unit\Event;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Event\Services\EventDateNoticeFormatter;
use App\Domains\Event\Services\EventDateTextFormatter;
use Tests\TestCase;
class EventDateNoticeFormatterTest extends TestCase
{
public function test_it_formats_singular_suspension_and_reschedule_notices(): void
{
$formatter = new EventDateNoticeFormatter(new EventDateTextFormatter);
$notices = $formatter->format(collect([
$this->change(EventDateChangeType::Suspended, '2026-10-09'),
$this->change(EventDateChangeType::Rescheduled, '2026-10-10', '2026-10-13'),
]));
$this->assertSame([
[
'type' => 'suspended',
'title' => 'FECHA CANCELADA!',
'message' => [
['text' => 'La fecha del ', 'bold' => false],
['text' => '09 de Octubre de 2026', 'bold' => true],
['text' => ' ha sido cancelada.', 'bold' => false],
],
],
[
'type' => 'rescheduled',
'title' => 'FECHA REPROGRAMADA!',
'message' => [
['text' => 'La fecha del ', 'bold' => false],
['text' => '10 de Octubre de 2026', 'bold' => true],
['text' => ' ha sido reprogramada para el ', 'bold' => false],
['text' => '13 de Octubre de 2026', 'bold' => true],
['text' => '.', 'bold' => false],
],
],
], $notices);
}
public function test_it_formats_suspension_and_reschedule_notices_for_the_storefront(): void
{
$formatter = new EventDateNoticeFormatter(new EventDateTextFormatter);
$notices = $formatter->format(collect([
$this->change(EventDateChangeType::Rescheduled, '2026-10-09', '2026-10-13'),
$this->change(EventDateChangeType::Suspended, '2026-10-09'),
$this->change(EventDateChangeType::Suspended, '2026-10-10'),
$this->change(EventDateChangeType::Rescheduled, '2026-10-10', '2026-10-14'),
]));
$this->assertSame([
[
'type' => 'suspended',
'title' => 'FECHAS CANCELADAS!',
'message' => [
['text' => 'Las fechas del ', 'bold' => false],
['text' => '09 y 10 de Octubre de 2026', 'bold' => true],
['text' => ' han sido canceladas.', 'bold' => false],
],
],
[
'type' => 'rescheduled',
'title' => 'FECHAS REPROGRAMADAS!',
'message' => [
['text' => 'Las fechas del ', 'bold' => false],
['text' => '09 y 10 de Octubre de 2026', 'bold' => true],
['text' => ' han sido reprogramadas para el ', 'bold' => false],
['text' => '13 y 14 de Octubre de 2026', 'bold' => true],
['text' => ', ', 'bold' => false],
['text' => 'respectivamente', 'bold' => true],
['text' => '.', 'bold' => false],
],
],
], $notices);
}
private function change(
EventDateChangeType $type,
string $previousDate,
?string $newDate = null,
): EventDateChange {
$change = new EventDateChange;
$change->setRawAttributes([
'change_type' => $type->value,
'previous_date' => $previousDate,
'new_date' => $newDate,
]);
return $change;
}
}

View File

@@ -35,4 +35,12 @@ class EventDateTextFormatterTest extends TestCase
],
];
}
public function test_it_formats_dates_for_use_inside_sentences(): void
{
$this->assertSame(
'09 y 10 de Octubre de 2026',
(new EventDateTextFormatter)->formatForSentence(['2026-10-10', '2026-10-09'])
);
}
}

16
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
// Set every environment source before Laravel or Dotenv can read the local .env.
foreach ([
'APP_ENV' => 'testing',
'DB_CONNECTION' => 'sqlite',
'DB_DATABASE' => ':memory:',
'DB_URL' => 'null',
'APP_CONFIG_CACHE' => __DIR__.'/../bootstrap/cache/phpunit-config.php',
] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
require __DIR__.'/../vendor/autoload.php';

View File

@@ -0,0 +1,80 @@
<?php
// Standalone safety check: no test lifecycle, migrations, queries, or PDO connections.
putenv('DB_CONNECTION=mysql');
$_ENV['DB_DATABASE'] = 'shopit';
$_SERVER['DB_URL'] = 'mysql://localhost/shopit';
require __DIR__.'/bootstrap.php';
use Illuminate\Container\Container;
use Tests\Support\InMemoryConnectionFactory;
$safe = ['driver' => 'sqlite', 'database' => ':memory:'];
$unsafe = [
[],
['driver' => 'mysql', 'database' => 'shopit'],
['driver' => 'mysql', 'database' => 'shopit_test'],
['driver' => 'sqlite', 'database' => 'database/database.sqlite'],
['driver' => 'sqlite', 'database' => 'shopit_test'],
array_merge($safe, ['url' => 'mysql://localhost/shopit']),
array_merge($safe, ['read' => ['database' => 'shopit']]),
array_merge($safe, ['write' => ['database' => 'shopit']]),
array_merge($safe, ['direct' => ['database' => 'shopit']]),
];
$factory = new InMemoryConnectionFactory(new Container);
foreach ($unsafe as $config) {
try {
$factory->make($config);
} catch (RuntimeException) {
continue;
}
throw new RuntimeException('Unsafe connection was accepted.');
}
$connection = $factory->make($safe);
if (! $connection->getRawPdo() instanceof Closure) {
throw new RuntimeException('Verification must not open a PDO connection.');
}
$case = new class('safetyCheck') extends Tests\TestCase {};
$app = $case->createApplication();
if (! $app['db.factory'] instanceof InMemoryConnectionFactory
|| $app['config']->get('database.default') !== 'sqlite'
|| ! $app['db']->connection()->getRawPdo() instanceof Closure) {
throw new RuntimeException('Application database isolation is not active.');
}
foreach (['mysql', 'pgsql', 'mariadb', 'sqlsrv'] as $name) {
try {
$app['db']->connection($name);
} catch (RuntimeException) {
continue;
}
throw new RuntimeException('A persistent application connection was accepted.');
}
// Include URL overrides resolved by Laravel and dynamically built connections.
foreach ([
['driver' => 'mysql', 'database' => 'shopit'],
array_merge($safe, ['url' => 'mysql://localhost/shopit']),
array_merge($safe, ['url' => 'sqlite:///database/database.sqlite']),
] as $config) {
$app['config']->set('database.connections.unsafe', $config);
foreach ([
fn () => $app['db']->connection('unsafe'),
fn () => $app['db']->build($config),
] as $connect) {
try {
$connect();
} catch (RuntimeException) {
continue;
}
throw new LogicException('A dynamically configured persistent connection was accepted.');
}
}
echo "Database safety verified: unsafe connections rejected; no PDO connections or migrations executed.\n";