From 471a94158793ed9fdd058d449d64d79f4e423fcf Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 10 Sep 2026 15:29:35 -0300 Subject: [PATCH 01/63] feat(tenant): persist ticket refund configuration --- app/Domains/Tenant/Models/Tenant.php | 9 ++++++ ..._refund_configuration_to_tenants_table.php | 28 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 database/migrations/2026_09_10_000000_add_ticket_refund_configuration_to_tenants_table.php diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index c6229b9..3376c0b 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -52,6 +52,9 @@ use Illuminate\Support\Facades\Schema; 'checkout_editing_policy', 'display_cart_item_images', 'scanner_category_validation_enabled', + 'allow_ticket_total_refund', + 'allow_ticket_partial_refund', + 'ticket_partial_refund_percentage', 'event_title', 'event_location', 'event_date_text', @@ -72,6 +75,9 @@ class Tenant extends Model 'checkout_editing_policy' => CartEditingPolicy::Disabled->value, 'display_cart_item_images' => true, 'scanner_category_validation_enabled' => true, + 'allow_ticket_total_refund' => false, + 'allow_ticket_partial_refund' => false, + 'ticket_partial_refund_percentage' => 0, ]; public function getRouteKeyName(): string @@ -124,6 +130,9 @@ class Tenant extends Model 'checkout_editing_policy' => CartEditingPolicy::class, 'display_cart_item_images' => 'boolean', 'scanner_category_validation_enabled' => 'boolean', + 'allow_ticket_total_refund' => 'boolean', + 'allow_ticket_partial_refund' => 'boolean', + 'ticket_partial_refund_percentage' => 'decimal:2', ]; } diff --git a/database/migrations/2026_09_10_000000_add_ticket_refund_configuration_to_tenants_table.php b/database/migrations/2026_09_10_000000_add_ticket_refund_configuration_to_tenants_table.php new file mode 100644 index 0000000..3f8b55d --- /dev/null +++ b/database/migrations/2026_09_10_000000_add_ticket_refund_configuration_to_tenants_table.php @@ -0,0 +1,28 @@ +boolean('allow_ticket_total_refund')->default(false); + $table->boolean('allow_ticket_partial_refund')->default(false); + $table->decimal('ticket_partial_refund_percentage', 4, 2)->default(0); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn([ + 'allow_ticket_total_refund', + 'allow_ticket_partial_refund', + 'ticket_partial_refund_percentage', + ]); + }); + } +}; -- 2.49.1 From 156498525922d04019e23ecc97f5332704665830 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 10 Sep 2026 15:29:41 -0300 Subject: [PATCH 02/63] feat(tenant): expose ticket refund configuration --- .../Tenant/Requests/StoreTenantRequest.php | 9 ++ .../Tenant/Requests/UpdateTenantRequest.php | 9 ++ .../Tenant/Resources/TenantResource.php | 3 + .../Tenant/TenantRefundConfigurationTest.php | 100 ++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 tests/Feature/Tenant/TenantRefundConfigurationTest.php diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index ee37d92..c7bfef5 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -117,6 +117,15 @@ class StoreTenantRequest extends FormRequest ], 'display_cart_item_images' => ['sometimes', 'boolean'], 'scanner_category_validation_enabled' => ['sometimes', 'boolean'], + 'allow_ticket_total_refund' => ['sometimes', 'boolean'], + 'allow_ticket_partial_refund' => ['sometimes', 'boolean'], + 'ticket_partial_refund_percentage' => [ + 'sometimes', + 'numeric', + 'decimal:0,2', + 'min:0', + 'max:99.99', + ], 'website_type_code' => [ 'required_with:extras', 'sometimes', diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index e7a06bc..8c11cef 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -138,6 +138,15 @@ class UpdateTenantRequest extends FormRequest ], 'display_cart_item_images' => ['sometimes', 'boolean'], 'scanner_category_validation_enabled' => ['sometimes', 'boolean'], + 'allow_ticket_total_refund' => ['sometimes', 'boolean'], + 'allow_ticket_partial_refund' => ['sometimes', 'boolean'], + 'ticket_partial_refund_percentage' => [ + 'sometimes', + 'numeric', + 'decimal:0,2', + 'min:0', + 'max:99.99', + ], ]; } } diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 1c3b14b..f268f7a 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -82,6 +82,9 @@ class TenantResource extends JsonResource 'checkout_editing_policy' => CartEditingPolicyResource::make($this->checkout_editing_policy), 'display_cart_item_images' => $this->display_cart_item_images, 'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled, + 'allow_ticket_total_refund' => $this->allow_ticket_total_refund, + 'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund, + 'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage, 'social_media' => $this->whenLoaded( 'socialMedia', fn () => $this->socialMedia diff --git a/tests/Feature/Tenant/TenantRefundConfigurationTest.php b/tests/Feature/Tenant/TenantRefundConfigurationTest.php new file mode 100644 index 0000000..17c5608 --- /dev/null +++ b/tests/Feature/Tenant/TenantRefundConfigurationTest.php @@ -0,0 +1,100 @@ +createTenant('refund-defaults'); + + $this->assertFalse($tenant->allow_ticket_total_refund); + $this->assertFalse($tenant->allow_ticket_partial_refund); + $this->assertSame('0.00', $tenant->ticket_partial_refund_percentage); + + $this->getJson("/api/tenants/{$tenant->codigo}") + ->assertOk() + ->assertJsonPath('data.allow_ticket_total_refund', false) + ->assertJsonPath('data.allow_ticket_partial_refund', false) + ->assertJsonPath('data.ticket_partial_refund_percentage', '0.00'); + } + + public function test_refund_configuration_can_be_updated_and_validates_its_precision(): void + { + $tenant = $this->createTenant('refund-update'); + + $this->putJson("/api/tenants/{$tenant->codigo}", [ + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.50, + ]) + ->assertOk() + ->assertJsonPath('data.allow_ticket_total_refund', true) + ->assertJsonPath('data.allow_ticket_partial_refund', true) + ->assertJsonPath('data.ticket_partial_refund_percentage', '25.50'); + + $this->assertDatabaseHas('tenants', [ + 'id' => $tenant->id, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.50, + ]); + + $this->putJson("/api/tenants/{$tenant->codigo}", [ + 'ticket_partial_refund_percentage' => 100, + ])->assertUnprocessable() + ->assertJsonValidationErrors('ticket_partial_refund_percentage'); + + $this->putJson("/api/tenants/{$tenant->codigo}", [ + 'ticket_partial_refund_percentage' => 12.345, + ])->assertUnprocessable() + ->assertJsonValidationErrors('ticket_partial_refund_percentage'); + } + + private function createTenant(string $code): Tenant + { + $attachmentIds = collect(['header', 'footer'])->map(function (string $name): int { + $key = (string) Str::uuid(); + + return Attachment::query()->create([ + 'key' => $key, + 'path' => "tenants/{$key}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ])->id; + }); + + $clientId = DB::table('clients')->insertGetId([ + 'code' => $code, + 'name' => Str::headline($code), + ]); + + $tenantId = DB::table('tenants')->insertGetId([ + 'client_id' => $clientId, + 'codigo' => $code, + 'nombre' => Str::headline($code), + 'dominio' => "{$code}.test", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $attachmentIds[0], + 'footer_logo_id' => $attachmentIds[1], + ]); + + return Tenant::query()->findOrFail($tenantId); + } +} -- 2.49.1 From 18f739b7123a5286034396a71f11678c776d196c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 10 Sep 2026 16:10:08 -0300 Subject: [PATCH 03/63] feat(ticket): add refundable terminal states --- app/Domains/Purchase/Models/PurchaseItem.php | 2 + app/Domains/Ticket/Models/Ticket.php | 94 ++++++++++++++++++- ..._and_refunded_amount_to_purchase_items.php | 36 +++++++ .../Feature/Logging/LogsValueChangesTest.php | 31 ++++++ tests/Unit/Ticket/TicketTest.php | 48 ++++++++++ 5 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 database/migrations/2026_09_10_010000_add_state_timestamps_to_tickets_and_refunded_amount_to_purchase_items.php diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index f15bf6b..ee4e9f5 100644 --- a/app/Domains/Purchase/Models/PurchaseItem.php +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -27,6 +27,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'discount_total', 'tax_total', 'total', + 'refunded_amount', ])] class PurchaseItem extends Model { @@ -47,6 +48,7 @@ class PurchaseItem extends Model 'discount_total' => 'decimal:2', 'tax_total' => 'decimal:2', 'total' => 'decimal:2', + 'refunded_amount' => 'decimal:2', ]; } diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index 1df25cc..895e4e1 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -5,6 +5,7 @@ namespace App\Domains\Ticket\Models; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; +use App\Domains\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Services\ResolvedTicketValidity; @@ -26,12 +27,15 @@ use Illuminate\Support\Collection; 'source_catalog_item_id', 'source_variant_id', 'used_at', + 'disabled_at', + 'cancelled_at', + 'refunded_at', 'scanner_user_id', 'user_id', ])] class Ticket extends Model { - use HasFactory; + use HasFactory, LogsValueChanges; private ?ResolvedTicketValidity $resolvedValidity = null; @@ -41,8 +45,22 @@ class Ticket extends Model public const STATUS_USED = 'used'; + public const STATUS_DISABLED = 'disabled'; + + public const STATUS_CANCELLED = 'cancelled'; + + public const STATUS_REFUNDED = 'refunded'; + public $timestamps = false; + /** @var list */ + protected array $loggedAttributes = [ + 'used_at', + 'disabled_at', + 'cancelled_at', + 'refunded_at', + ]; + protected $appends = [ 'name', 'description', @@ -59,11 +77,50 @@ class Ticket extends Model 'source_variant_id' => 'integer', 'source_purchase_item_id' => 'integer', 'used_at' => 'datetime', + 'disabled_at' => 'datetime', + 'cancelled_at' => 'datetime', + 'refunded_at' => 'datetime', 'scanner_user_id' => 'integer', 'user_id' => 'integer', ]; } + /** @return list */ + public static function statuses(): array + { + return array_keys(self::statusLabels()); + } + + /** @return array */ + public static function statusLabels(): array + { + return [ + self::STATUS_ACTIVE => 'Activo', + self::STATUS_USED => 'Usado', + self::STATUS_EXPIRED => 'Vencido', + self::STATUS_DISABLED => 'Inhabilitado', + self::STATUS_CANCELLED => 'Cancelado', + self::STATUS_REFUNDED => 'Reembolsado', + ]; + } + + /** @return list */ + public static function statusOptions(): array + { + return collect(self::statusLabels()) + ->map(fn (string $label, string $status): array => [ + 'value' => $status, + 'label' => $label, + ]) + ->values() + ->all(); + } + + public static function statusLabel(string $status): string + { + return self::statusLabels()[$status] ?? $status; + } + /** @return BelongsTo */ public function tenant(): BelongsTo { @@ -108,7 +165,7 @@ class Ticket extends Model public function isValid(): bool { - if ($this->used_at !== null) { + if ($this->hasTerminalStatus() || $this->used_at !== null) { return false; } @@ -122,7 +179,9 @@ class Ticket extends Model public function getIsExpiredAttribute(): bool { - return $this->used_at === null && $this->resolvedValidity()->isExpired(); + return ! $this->hasTerminalStatus() + && $this->used_at === null + && $this->resolvedValidity()->isExpired(); } public function getIsUsedAttribute(): bool @@ -132,6 +191,18 @@ class Ticket extends Model public function getStatusAttribute(): string { + if ($this->refunded_at !== null) { + return self::STATUS_REFUNDED; + } + + if ($this->cancelled_at !== null) { + return self::STATUS_CANCELLED; + } + + if ($this->disabled_at !== null) { + return self::STATUS_DISABLED; + } + if ($this->is_used) { return self::STATUS_USED; } @@ -143,6 +214,23 @@ class Ticket extends Model return self::STATUS_ACTIVE; } + public function getStatusLabelAttribute(): string + { + return self::statusLabel($this->status); + } + + protected function valueChangeTenantCode(): string + { + return $this->tenant_code; + } + + private function hasTerminalStatus(): bool + { + return $this->disabled_at !== null + || $this->cancelled_at !== null + || $this->refunded_at !== null; + } + public function getNameAttribute(): string { return app(TicketPresentationResolver::class)->name($this); diff --git a/database/migrations/2026_09_10_010000_add_state_timestamps_to_tickets_and_refunded_amount_to_purchase_items.php b/database/migrations/2026_09_10_010000_add_state_timestamps_to_tickets_and_refunded_amount_to_purchase_items.php new file mode 100644 index 0000000..5c087b0 --- /dev/null +++ b/database/migrations/2026_09_10_010000_add_state_timestamps_to_tickets_and_refunded_amount_to_purchase_items.php @@ -0,0 +1,36 @@ +dateTime('disabled_at')->nullable()->after('used_at'); + $table->dateTime('cancelled_at')->nullable()->after('disabled_at'); + $table->dateTime('refunded_at')->nullable()->after('cancelled_at'); + }); + + Schema::table('compra_items', function (Blueprint $table): void { + $table->decimal('refunded_amount', 10, 2)->default(0)->after('total'); + }); + } + + public function down(): void + { + Schema::table('compra_items', function (Blueprint $table): void { + $table->dropColumn('refunded_amount'); + }); + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropColumn([ + 'disabled_at', + 'cancelled_at', + 'refunded_at', + ]); + }); + } +}; diff --git a/tests/Feature/Logging/LogsValueChangesTest.php b/tests/Feature/Logging/LogsValueChangesTest.php index bd4dc8c..157f7fb 100644 --- a/tests/Feature/Logging/LogsValueChangesTest.php +++ b/tests/Feature/Logging/LogsValueChangesTest.php @@ -6,6 +6,7 @@ use App\Domains\Logging\Enums\ValueChangeActorType; use App\Domains\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Logging\Models\ValueChange; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; @@ -52,6 +53,16 @@ class LogsValueChangesTest extends TestCase $table->timestamps(); }); + Schema::create('tickets', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->uuid('ticket'); + $table->dateTime('used_at')->nullable(); + $table->dateTime('disabled_at')->nullable(); + $table->dateTime('cancelled_at')->nullable(); + $table->dateTime('refunded_at')->nullable(); + }); + $migration = require database_path('migrations/2026_08_03_000200_create_value_changes_table.php'); $migration->up(); $tenantMigration = require database_path('migrations/2026_08_04_000000_add_tenant_code_to_value_changes_table.php'); @@ -144,6 +155,26 @@ class LogsValueChangesTest extends TestCase 'user_id' => null, ]); } + + public function test_ticket_logs_its_status_changes(): void + { + $ticket = Ticket::query()->create([ + 'tenant_code' => 'test', + 'ticket' => '794606d5-5f69-458d-9de7-03494757d626', + ]); + + $ticket->update(['disabled_at' => now()]); + + $this->assertDatabaseHas('value_changes', [ + 'tenant_code' => 'test', + 'trackable_type' => $ticket->getMorphClass(), + 'trackable_id' => $ticket->id, + 'attribute' => 'disabled_at', + 'old_value' => null, + 'actor_type' => ValueChangeActorType::System->value, + 'user_id' => null, + ]); + } } #[Fillable(['name', 'price', 'description'])] diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php index 4f4c099..c4f24e1 100644 --- a/tests/Unit/Ticket/TicketTest.php +++ b/tests/Unit/Ticket/TicketTest.php @@ -31,6 +31,9 @@ class TicketTest extends TestCase 'source_catalog_item_id' => '20', 'source_variant_id' => '30', 'used_at' => null, + 'disabled_at' => '2026-09-10 10:00:00', + 'cancelled_at' => null, + 'refunded_at' => null, 'scanner_user_id' => '15', 'user_id' => '10', ]); @@ -40,6 +43,9 @@ class TicketTest extends TestCase $this->assertSame(20, $ticket->source_catalog_item_id); $this->assertSame(30, $ticket->source_variant_id); $this->assertNull($ticket->used_at); + $this->assertSame('2026-09-10 10:00:00', $ticket->disabled_at->format('Y-m-d H:i:s')); + $this->assertNull($ticket->cancelled_at); + $this->assertNull($ticket->refunded_at); $this->assertSame(15, $ticket->scanner_user_id); $this->assertSame(10, $ticket->user_id); $this->assertInstanceOf(Tenant::class, $ticket->tenant()->getRelated()); @@ -154,6 +160,48 @@ class TicketTest extends TestCase $this->assertSame(Ticket::STATUS_USED, $ticket->status); } + public function test_persisted_terminal_statuses_make_the_ticket_invalid(): void + { + foreach ([ + 'disabled_at' => Ticket::STATUS_DISABLED, + 'cancelled_at' => Ticket::STATUS_CANCELLED, + 'refunded_at' => Ticket::STATUS_REFUNDED, + ] as $timestamp => $status) { + $ticket = new Ticket([$timestamp => now()]); + + $this->assertSame($status, $ticket->status); + $this->assertFalse($ticket->is_valid); + $this->assertFalse($ticket->is_expired); + } + } + + public function test_it_exposes_every_supported_status(): void + { + $this->assertSame([ + Ticket::STATUS_ACTIVE, + Ticket::STATUS_USED, + Ticket::STATUS_EXPIRED, + Ticket::STATUS_DISABLED, + Ticket::STATUS_CANCELLED, + Ticket::STATUS_REFUNDED, + ], Ticket::statuses()); + + $this->assertSame('Inhabilitado', Ticket::statusLabel(Ticket::STATUS_DISABLED)); + $this->assertSame('Cancelado', Ticket::statusLabel(Ticket::STATUS_CANCELLED)); + $this->assertSame('Reembolsado', Ticket::statusLabel(Ticket::STATUS_REFUNDED)); + } + + public function test_refunded_has_priority_when_multiple_state_timestamps_exist(): void + { + $ticket = new Ticket([ + 'disabled_at' => now()->subHours(2), + 'cancelled_at' => now()->subHour(), + 'refunded_at' => now(), + ]); + + $this->assertSame(Ticket::STATUS_REFUNDED, $ticket->status); + } + public function test_all_validity_times_in_the_same_group_must_be_active(): void { Carbon::setTestNow('2026-08-20 13:00:00'); -- 2.49.1 From f19bca64d0516fb0e84021b8014573ea798461e7 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 10 Sep 2026 16:10:17 -0300 Subject: [PATCH 04/63] feat(admin): expose ticket refund states --- .../Services/TicketFilterFormService.php | 6 +--- .../Forms/Services/TicketFormService.php | 6 +--- .../Resources/PurchaseItemResource.php | 1 + .../Resources/AdminApp/SaleDetailResource.php | 1 + .../Resources/AdminApp/SaleTicketResource.php | 1 + .../Requests/AdminAppTicketIndexRequest.php | 6 +--- .../Ticket/Resources/TicketResource.php | 2 ++ .../Services/AdminAppTicketRowService.php | 7 ++--- .../Ticket/Services/AdminAppTicketService.php | 30 ++++++++++++++++++- ...AdminAppTicketFilterFormControllerTest.php | 3 ++ .../AdminAppTicketFormControllerTest.php | 3 ++ .../Sale/AdminAppSaleControllerTest.php | 2 ++ .../Ticket/AdminAppTicketControllerTest.php | 24 +++++++++++++++ .../Ticket/ScannerTicketControllerTest.php | 13 ++++++++ 14 files changed, 84 insertions(+), 21 deletions(-) diff --git a/app/Domains/Forms/Services/TicketFilterFormService.php b/app/Domains/Forms/Services/TicketFilterFormService.php index 72ccc65..7574a92 100644 --- a/app/Domains/Forms/Services/TicketFilterFormService.php +++ b/app/Domains/Forms/Services/TicketFilterFormService.php @@ -124,11 +124,7 @@ class TicketFilterFormService 'required' => false, 'default' => null, 'placeholder' => 'Estado', - 'options' => [ - ['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'], - ['value' => Ticket::STATUS_USED, 'label' => 'Usado'], - ['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'], - ], + 'options' => Ticket::statusOptions(), ], ]; } diff --git a/app/Domains/Forms/Services/TicketFormService.php b/app/Domains/Forms/Services/TicketFormService.php index b9436d2..ff50bb2 100644 --- a/app/Domains/Forms/Services/TicketFormService.php +++ b/app/Domains/Forms/Services/TicketFormService.php @@ -236,11 +236,7 @@ class TicketFormService ?: $left['label'] <=> $right['label']); return [ - 'statuses' => [ - ['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'], - ['value' => Ticket::STATUS_USED, 'label' => 'Usado'], - ['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'], - ], + 'statuses' => Ticket::statusOptions(), 'categories' => array_values(array_map( fn (array $category): array => [ 'value' => $category['value'], diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index 1bd67d5..f83ecf6 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -25,6 +25,7 @@ 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' => [ diff --git a/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php b/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php index d389d1a..08f1736 100644 --- a/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php +++ b/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php @@ -22,6 +22,7 @@ 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), ]; diff --git a/app/Domains/Sale/Resources/AdminApp/SaleTicketResource.php b/app/Domains/Sale/Resources/AdminApp/SaleTicketResource.php index 5070b1a..33ba09b 100644 --- a/app/Domains/Sale/Resources/AdminApp/SaleTicketResource.php +++ b/app/Domains/Sale/Resources/AdminApp/SaleTicketResource.php @@ -17,6 +17,7 @@ class SaleTicketResource extends JsonResource 'id' => $this->id, 'expires_at' => $this->getEffectiveExpiresAt(), 'status' => $this->status, + 'status_label' => $this->status_label, ]; } } diff --git a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php index 5a80395..90d8e35 100644 --- a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php +++ b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php @@ -32,11 +32,7 @@ class AdminAppTicketIndexRequest extends FormRequest 'status' => [ 'sometimes', 'nullable', - Rule::in([ - Ticket::STATUS_ACTIVE, - Ticket::STATUS_USED, - Ticket::STATUS_EXPIRED, - ]), + Rule::in(Ticket::statuses()), ], 'page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], diff --git a/app/Domains/Ticket/Resources/TicketResource.php b/app/Domains/Ticket/Resources/TicketResource.php index 8e4c0e8..d45a3eb 100644 --- a/app/Domains/Ticket/Resources/TicketResource.php +++ b/app/Domains/Ticket/Resources/TicketResource.php @@ -16,6 +16,8 @@ class TicketResource extends JsonResource 'id' => $this->id, 'tenant_code' => $this->tenant_code, 'ticket' => $this->ticket, + 'status' => $this->status, + 'status_label' => $this->status_label, 'name' => $this->name, 'description' => $this->description, 'client' => $this->user?->nombre_apellido, diff --git a/app/Domains/Ticket/Services/AdminAppTicketRowService.php b/app/Domains/Ticket/Services/AdminAppTicketRowService.php index e29b879..31b5a7c 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketRowService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketRowService.php @@ -31,6 +31,7 @@ class AdminAppTicketRowService ?? $ticket->sourceCatalogItem?->nombre ?? $ticket->name, 'amount' => $purchaseItem?->precio_unitario, + 'refunded_amount' => $purchaseItem?->refunded_amount, 'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido, 'status' => $ticket->status, 'scanned_by' => $ticket->scannerUser?->nombre_apellido, @@ -95,11 +96,7 @@ class AdminAppTicketRowService return match ($type) { 'order_number' => '#'.$value, 'currency' => '$'.number_format((float) $value, 2, ',', '.'), - 'status' => match ((string) $value) { - Ticket::STATUS_USED => 'Usado', - Ticket::STATUS_EXPIRED => 'Vencido', - default => 'Activo', - }, + 'status' => Ticket::statusLabel((string) $value), default => (string) $value, }; } diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index cc41319..fe5c020 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -255,13 +255,41 @@ class AdminAppTicketService } if ($status === Ticket::STATUS_USED) { - $query->whereNotNull('used_at'); + $query + ->whereNotNull('used_at') + ->whereNull('disabled_at') + ->whereNull('cancelled_at') + ->whereNull('refunded_at'); + + return; + } + + $timestampColumn = match ($status) { + Ticket::STATUS_DISABLED => 'disabled_at', + Ticket::STATUS_CANCELLED => 'cancelled_at', + Ticket::STATUS_REFUNDED => 'refunded_at', + default => null, + }; + + if ($timestampColumn !== null) { + $query->whereNotNull($timestampColumn); + + if ($status === Ticket::STATUS_DISABLED) { + $query->whereNull('cancelled_at')->whereNull('refunded_at'); + } + + if ($status === Ticket::STATUS_CANCELLED) { + $query->whereNull('refunded_at'); + } return; } $matchingIds = (clone $query) ->whereNull('used_at') + ->whereNull('disabled_at') + ->whereNull('cancelled_at') + ->whereNull('refunded_at') ->with(TicketValidityResolver::RELATIONS) ->get() ->filter(fn (Ticket $ticket): bool => $ticket->status === $status) diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php index 43d19da..6e0659c 100644 --- a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -78,6 +78,9 @@ class AdminAppTicketFilterFormControllerTest extends TestCase ['value' => 'active', 'label' => 'Activo'], ['value' => 'used', 'label' => 'Usado'], ['value' => 'expired', 'label' => 'Vencido'], + ['value' => 'disabled', 'label' => 'Inhabilitado'], + ['value' => 'cancelled', 'label' => 'Cancelado'], + ['value' => 'refunded', 'label' => 'Reembolsado'], ], ], ], diff --git a/tests/Feature/Forms/AdminAppTicketFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFormControllerTest.php index 5bb4ef3..78c97d7 100644 --- a/tests/Feature/Forms/AdminAppTicketFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFormControllerTest.php @@ -63,6 +63,9 @@ class AdminAppTicketFormControllerTest extends TestCase ['value' => 'active', 'label' => 'Activo'], ['value' => 'used', 'label' => 'Usado'], ['value' => 'expired', 'label' => 'Vencido'], + ['value' => 'disabled', 'label' => 'Inhabilitado'], + ['value' => 'cancelled', 'label' => 'Cancelado'], + ['value' => 'refunded', 'label' => 'Reembolsado'], ], 'categories' => [ [ diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index 1cc0d7f..cb74909 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -507,12 +507,14 @@ class AdminAppSaleControllerTest extends TestCase 'id' => $firstTicket->id, 'expires_at' => null, 'status' => Ticket::STATUS_ACTIVE, + 'status_label' => 'Activo', ], [ 'product' => 'Abono general', 'id' => $usedTicket->id, 'expires_at' => null, 'status' => Ticket::STATUS_USED, + 'status_label' => 'Usado', ], ], ]); diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 0ac1a67..f894627 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -72,6 +72,7 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.tenant_code', $tenant->codigo) ->assertJsonPath('data.0.values.id', $ticket->id) ->assertJsonPath('data.0.values.status', Ticket::STATUS_ACTIVE) + ->assertJsonPath('data.0.status_label', 'Activo') ->assertJsonMissingPath('data.0.values.ticket') ->assertJsonPath('data.0.values.date', '-') ->assertJsonPath('data.0.values.size', '-') @@ -335,6 +336,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') ->assertJsonPath('data.0.status', Ticket::STATUS_USED) ->assertJsonPath('data.0.scanned_by', $admin->nombre_apellido) ->assertJsonPath('data.0.variant_properties.0.code', 'size') @@ -498,6 +500,28 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.id', $active->id); } + public function test_it_filters_persisted_ticket_statuses(): void + { + $tenant = $this->createTenant('ticket-statuses'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + foreach ([ + Ticket::STATUS_DISABLED => 'disabled_at', + Ticket::STATUS_CANCELLED => 'cancelled_at', + Ticket::STATUS_REFUNDED => 'refunded_at', + ] as $status => $timestamp) { + $ticket = $this->createTicket($tenant, $admin, [$timestamp => now()]); + + $this->getJson('/api/v1/adminapp/tenant/tickets?status='.$status) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.status', $status); + } + } + public function test_it_downloads_filtered_ticket_reports(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); diff --git a/tests/Feature/Ticket/ScannerTicketControllerTest.php b/tests/Feature/Ticket/ScannerTicketControllerTest.php index aa6fd8a..71b7a22 100644 --- a/tests/Feature/Ticket/ScannerTicketControllerTest.php +++ b/tests/Feature/Ticket/ScannerTicketControllerTest.php @@ -272,6 +272,11 @@ class ScannerTicketControllerTest extends TestCase ->assertJsonPath('data.ticket.scanner_user_id', $this->scanner->id) ->assertJsonPath('data.ticket.is_valid', false) ->assertJsonPath('data.ticket.is_used', true) + ->assertJsonPath('data.ticket.status', Ticket::STATUS_USED) + ->assertJsonPath('data.ticket.status_label', 'Usado') + ->assertJsonMissingPath('data.ticket.disabled_at') + ->assertJsonMissingPath('data.ticket.cancelled_at') + ->assertJsonMissingPath('data.ticket.refunded_at') ->assertJsonPath('data.ticket.client', $this->ticketOwner->nombre_apellido) ->assertJsonPath('data.client.id', $this->ticketOwner->id) ->assertJsonPath('data.client.nombre_apellido', $this->ticketOwner->nombre_apellido); @@ -280,6 +285,14 @@ class ScannerTicketControllerTest extends TestCase 'id' => $ticket->id, 'scanner_user_id' => $this->scanner->id, ]); + $this->assertDatabaseHas('value_changes', [ + 'tenant_code' => $this->tenant->codigo, + 'trackable_type' => $ticket->getMorphClass(), + 'trackable_id' => $ticket->id, + 'attribute' => 'used_at', + 'old_value' => null, + 'user_id' => $this->scanner->id, + ]); $this->assertNotNull($ticket->fresh()->used_at); $scanAttempt = ScanAttempt::query()->sole(); -- 2.49.1 From de259f4286e3d52e6797f41779d672b074cb49b1 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 10 Sep 2026 16:20:35 -0300 Subject: [PATCH 05/63] feat(ticket): implement terminal status management and validation --- app/Domains/Ticket/Models/Ticket.php | 93 ++++++++++++++++++- .../Feature/Logging/LogsValueChangesTest.php | 14 +++ tests/Unit/Ticket/TicketTest.php | 31 +++++++ 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index 895e4e1..899196f 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Collection; +use Illuminate\Validation\ValidationException; #[Fillable([ 'tenant_code', @@ -121,6 +122,13 @@ class Ticket extends Model return self::statusLabels()[$status] ?? $status; } + protected static function booted(): void + { + static::saving(function (self $ticket): void { + $ticket->ensureTerminalStatusTransitionIsAllowed(); + }); + } + /** @return BelongsTo */ public function tenant(): BelongsTo { @@ -219,6 +227,21 @@ class Ticket extends Model return self::statusLabel($this->status); } + public function markAsDisabled(): void + { + $this->markAsTerminalStatus(self::STATUS_DISABLED); + } + + public function markAsCancelled(): void + { + $this->markAsTerminalStatus(self::STATUS_CANCELLED); + } + + public function markAsRefunded(): void + { + $this->markAsTerminalStatus(self::STATUS_REFUNDED); + } + protected function valueChangeTenantCode(): string { return $this->tenant_code; @@ -226,9 +249,73 @@ class Ticket extends Model private function hasTerminalStatus(): bool { - return $this->disabled_at !== null - || $this->cancelled_at !== null - || $this->refunded_at !== null; + return $this->terminalStatus() !== null; + } + + private function markAsTerminalStatus(string $status): void + { + $currentStatus = $this->terminalStatus(); + + if ($currentStatus === $status) { + return; + } + + if ($currentStatus !== null) { + $this->throwTerminalStatusTransitionException(); + } + + $this->ensureTerminalStatusTransitionIsAllowed($status); + + $this->{self::terminalStatusTimestampColumn($status)} = now(); + } + + private function ensureTerminalStatusTransitionIsAllowed(?string $targetStatus = null): void + { + $currentStatus = $this->terminalStatusFromAttributes($this->getRawOriginal()); + $nextStatus = $targetStatus ?? $this->terminalStatus(); + + if ($currentStatus === null || $nextStatus === null || $currentStatus === $nextStatus) { + return; + } + + $this->throwTerminalStatusTransitionException(); + } + + private function throwTerminalStatusTransitionException(): never + { + throw ValidationException::withMessages([ + 'status' => 'No se puede cambiar un ticket con estado terminal a otro estado terminal.', + ]); + } + + private function terminalStatus(): ?string + { + return $this->terminalStatusFromAttributes($this->getAttributes()); + } + + /** @param array $attributes */ + private function terminalStatusFromAttributes(array $attributes): ?string + { + foreach ([ + self::STATUS_REFUNDED, + self::STATUS_CANCELLED, + self::STATUS_DISABLED, + ] as $status) { + if (($attributes[self::terminalStatusTimestampColumn($status)] ?? null) !== null) { + return $status; + } + } + + return null; + } + + private static function terminalStatusTimestampColumn(string $status): string + { + return match ($status) { + self::STATUS_DISABLED => 'disabled_at', + self::STATUS_CANCELLED => 'cancelled_at', + self::STATUS_REFUNDED => 'refunded_at', + }; } public function getNameAttribute(): string diff --git a/tests/Feature/Logging/LogsValueChangesTest.php b/tests/Feature/Logging/LogsValueChangesTest.php index 157f7fb..11d6d64 100644 --- a/tests/Feature/Logging/LogsValueChangesTest.php +++ b/tests/Feature/Logging/LogsValueChangesTest.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Schema; +use Illuminate\Validation\ValidationException; use Tests\TestCase; class LogsValueChangesTest extends TestCase @@ -175,6 +176,19 @@ class LogsValueChangesTest extends TestCase 'user_id' => null, ]); } + + public function test_ticket_cannot_transition_between_terminal_statuses(): void + { + $ticket = Ticket::query()->create([ + 'tenant_code' => 'test', + 'ticket' => '794606d5-5f69-458d-9de7-03494757d626', + ]); + + $ticket->update(['disabled_at' => now()]); + + $this->expectException(ValidationException::class); + $ticket->update(['cancelled_at' => now()]); + } } #[Fillable(['name', 'price', 'description'])] diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php index c4f24e1..a71541a 100644 --- a/tests/Unit/Ticket/TicketTest.php +++ b/tests/Unit/Ticket/TicketTest.php @@ -13,6 +13,7 @@ use App\Domains\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticket\Services\ResolvedValidityGroup; use App\Domains\Ticket\Services\TicketValidityResolver; use Illuminate\Support\Carbon; +use Illuminate\Validation\ValidationException; use Tests\TestCase; class TicketTest extends TestCase @@ -202,6 +203,36 @@ class TicketTest extends TestCase $this->assertSame(Ticket::STATUS_REFUNDED, $ticket->status); } + public function test_it_marks_tickets_with_terminal_statuses(): void + { + Carbon::setTestNow('2026-09-10 12:00:00'); + + $disabled = new Ticket; + $disabled->markAsDisabled(); + + $cancelled = new Ticket; + $cancelled->markAsCancelled(); + + $refunded = new Ticket; + $refunded->markAsRefunded(); + + $this->assertSame(Ticket::STATUS_DISABLED, $disabled->status); + $this->assertSame('2026-09-10 12:00:00', $disabled->disabled_at->format('Y-m-d H:i:s')); + $this->assertSame(Ticket::STATUS_CANCELLED, $cancelled->status); + $this->assertSame('2026-09-10 12:00:00', $cancelled->cancelled_at->format('Y-m-d H:i:s')); + $this->assertSame(Ticket::STATUS_REFUNDED, $refunded->status); + $this->assertSame('2026-09-10 12:00:00', $refunded->refunded_at->format('Y-m-d H:i:s')); + } + + public function test_it_does_not_allow_a_transition_between_terminal_statuses(): void + { + $ticket = new Ticket; + $ticket->markAsDisabled(); + + $this->expectException(ValidationException::class); + $ticket->markAsRefunded(); + } + public function test_all_validity_times_in_the_same_group_must_be_active(): void { Carbon::setTestNow('2026-08-20 13:00:00'); -- 2.49.1 From beb5d18b2994a686802ba4478e62b43f6bf5e4a4 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 10 Sep 2026 16:28:45 -0300 Subject: [PATCH 06/63] feat(ticket): add cancel ticket functionality and corresponding tests --- .../Controllers/AdminApp/TicketController.php | 9 ++++++ .../Ticket/Services/AdminAppTicketService.php | 12 +++++++ app/Domains/Ticket/routes/adminapp.php | 4 +++ .../Ticket/AdminAppTicketControllerTest.php | 32 +++++++++++++++++++ 4 files changed, 57 insertions(+) diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php index 00194f4..35e174e 100644 --- a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -5,10 +5,12 @@ namespace App\Domains\Ticket\Controllers\AdminApp; use App\Domains\Ticket\Requests\AdminAppTicketExportRequest; use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest; use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection; +use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketResource; use App\Domains\Ticket\Services\AdminAppTicketExcelService; use App\Domains\Ticket\Services\AdminAppTicketPdfService; use App\Domains\Ticket\Services\AdminAppTicketService; use App\Http\Controllers\Controller; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -29,6 +31,13 @@ class TicketController extends Controller ); } + public function cancel(Request $request, int $ticket): AdminAppTicketResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket)); + } + public function downloadPdf(AdminAppTicketExportRequest $request): Response { $tenant = $request->user()->tenant()->firstOrFail(); diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index fe5c020..ebf8f9f 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -75,6 +75,18 @@ class AdminAppTicketService return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters); } + public function cancel(Tenant $tenant, int $ticketId): Ticket + { + $ticket = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->findOrFail($ticketId); + + $ticket->markAsCancelled(); + $ticket->save(); + + return $ticket->refresh()->load(self::RELATIONS); + } + /** * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters * @return Builder diff --git a/app/Domains/Ticket/routes/adminapp.php b/app/Domains/Ticket/routes/adminapp.php index f2602cc..0393ee4 100644 --- a/app/Domains/Ticket/routes/adminapp.php +++ b/app/Domains/Ticket/routes/adminapp.php @@ -9,6 +9,10 @@ Route::prefix('v1/adminapp/tenant') Route::get('tickets', [TicketController::class, 'index']) ->middleware('tenant.menu:adminapp.tickets') ->name('adminapp.tickets.index'); + Route::post('tickets/{ticket}/cancel', [TicketController::class, 'cancel']) + ->whereNumber('ticket') + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.tickets.cancel'); Route::get('tickets/pdf', [TicketController::class, 'downloadPdf']) ->middleware('tenant.menu:adminapp.tickets') ->name('adminapp.tickets.pdf'); diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index f894627..b8bc401 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -80,6 +80,38 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('meta.total', 1); } + public function test_it_cancels_a_ticket_from_the_authenticated_tenant(): void + { + $tenant = $this->createTenant('ticket-cancellation'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + $ticket = $this->createTicket($tenant, $admin); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/cancel") + ->assertOk() + ->assertJsonPath('data.id', $ticket->id) + ->assertJsonPath('data.status', Ticket::STATUS_CANCELLED) + ->assertJsonPath('data.status_label', 'Cancelado'); + + $this->assertNotNull($ticket->fresh()->cancelled_at); + } + + public function test_it_does_not_cancel_a_ticket_with_another_terminal_status(): void + { + $tenant = $this->createTenant('ticket-cancellation'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + $ticket = $this->createTicket($tenant, $admin, ['disabled_at' => now()]); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/cancel") + ->assertUnprocessable() + ->assertJsonValidationErrors('status'); + + $this->assertNull($ticket->fresh()->cancelled_at); + } + public function test_it_searches_by_id_and_does_not_search_by_uuid(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); -- 2.49.1 From 210c854fee3437ca6440249cb8dc9517b389c4b6 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 10 Sep 2026 17:04:34 -0300 Subject: [PATCH 07/63] feat(tenant): add allow_refund and allow_partial_refund methods --- app/Domains/Tenant/Models/Tenant.php | 27 ++++++++++++ .../Tenant/TenantRefundConfigurationTest.php | 42 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index 3376c0b..7c6938f 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -112,6 +112,33 @@ class Tenant extends Model return $this->scanner_category_validation_enabled; } + public function allow_refund(): bool + { + return (bool) $this->allow_ticket_total_refund || $this->allow_partial_refund(); + } + + public function allow_partial_refund(): bool + { + return (bool) $this->allow_ticket_partial_refund + && $this->ticket_partial_refund_percentage !== null + && (float) $this->ticket_partial_refund_percentage > 0; + } + + public function allowRefund(): bool + { + return $this->allow_refund(); + } + + public function allowPartialRefund(): bool + { + return $this->allow_partial_refund(); + } + + public function getAllowRefundAttribute(): bool + { + return $this->allow_refund(); + } + /** * Get the attributes that should be cast. * diff --git a/tests/Feature/Tenant/TenantRefundConfigurationTest.php b/tests/Feature/Tenant/TenantRefundConfigurationTest.php index 17c5608..b50304a 100644 --- a/tests/Feature/Tenant/TenantRefundConfigurationTest.php +++ b/tests/Feature/Tenant/TenantRefundConfigurationTest.php @@ -61,6 +61,48 @@ class TenantRefundConfigurationTest extends TestCase ->assertJsonValidationErrors('ticket_partial_refund_percentage'); } + public function test_tenant_allow_refund_logic(): void + { + $tenant = new Tenant([ + 'allow_ticket_total_refund' => false, + 'allow_ticket_partial_refund' => false, + 'ticket_partial_refund_percentage' => 0, + ]); + + $this->assertFalse($tenant->allow_refund()); + $this->assertFalse($tenant->allowRefund()); + $this->assertFalse($tenant->allow_refund); + $this->assertFalse($tenant->allow_partial_refund()); + $this->assertFalse($tenant->allowPartialRefund()); + + // Partial refund enabled but percentage is 0 / unset + $tenant->allow_ticket_partial_refund = true; + $tenant->ticket_partial_refund_percentage = 0; + $this->assertFalse($tenant->allow_refund()); + $this->assertFalse($tenant->allow_partial_refund()); + + // Partial refund enabled and percentage is set + $tenant->ticket_partial_refund_percentage = 25.50; + $this->assertTrue($tenant->allow_refund()); + $this->assertTrue($tenant->allowRefund()); + $this->assertTrue($tenant->allow_refund); + $this->assertTrue($tenant->allow_partial_refund()); + $this->assertTrue($tenant->allowPartialRefund()); + + // Total refund enabled, partial refund disabled + $tenant->allow_ticket_partial_refund = false; + $tenant->allow_ticket_total_refund = true; + $tenant->ticket_partial_refund_percentage = 0; + $this->assertTrue($tenant->allow_refund()); + $this->assertFalse($tenant->allow_partial_refund()); + + // Total refund enabled and partial refund enabled with percentage + $tenant->allow_ticket_partial_refund = true; + $tenant->ticket_partial_refund_percentage = 50.00; + $this->assertTrue($tenant->allow_refund()); + $this->assertTrue($tenant->allow_partial_refund()); + } + private function createTenant(string $code): Tenant { $attachmentIds = collect(['header', 'footer'])->map(function (string $name): int { -- 2.49.1 From 5d00dc439e143e48e96dd0a1b4080e08121f6801 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 10 Sep 2026 17:05:57 -0300 Subject: [PATCH 08/63] feat(ticket): implement refund functionality and expose allow_refund flag --- .../Controllers/AdminApp/TicketController.php | 10 ++ app/Domains/Ticket/Models/Ticket.php | 15 ++ .../Requests/AdminAppTicketRefundRequest.php | 22 +++ .../AdminApp/AdminAppTicketResource.php | 1 + .../Services/AdminAppTicketRowService.php | 1 + .../Ticket/Services/AdminAppTicketService.php | 67 ++++++++ app/Domains/Ticket/routes/adminapp.php | 4 + .../Ticket/AdminAppTicketControllerTest.php | 153 ++++++++++++++++++ 8 files changed, 273 insertions(+) create mode 100644 app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php index 35e174e..6b63322 100644 --- a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -4,6 +4,7 @@ namespace App\Domains\Ticket\Controllers\AdminApp; use App\Domains\Ticket\Requests\AdminAppTicketExportRequest; use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest; +use App\Domains\Ticket\Requests\AdminAppTicketRefundRequest; use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection; use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketResource; use App\Domains\Ticket\Services\AdminAppTicketExcelService; @@ -38,6 +39,15 @@ class TicketController extends Controller return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket)); } + public function refund(AdminAppTicketRefundRequest $request, int $ticket): AdminAppTicketResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return new AdminAppTicketResource( + $this->ticketService->refund($tenant, $ticket, $request->validated('refund_type')) + ); + } + public function downloadPdf(AdminAppTicketExportRequest $request): Response { $tenant = $request->user()->tenant()->firstOrFail(); diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index 899196f..f93fac1 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -135,6 +135,21 @@ class Ticket extends Model return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); } + public function allow_refund(): bool + { + return $this->tenant?->allow_refund() ?? false; + } + + public function allowRefund(): bool + { + return $this->allow_refund(); + } + + public function getAllowRefundAttribute(): bool + { + return $this->allow_refund(); + } + /** @return BelongsTo */ public function user(): BelongsTo { diff --git a/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php new file mode 100644 index 0000000..c542516 --- /dev/null +++ b/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php @@ -0,0 +1,22 @@ +> */ + public function rules(): array + { + return [ + 'refund_type' => ['required', 'string', Rule::in(['partial', 'total'])], + ]; + } +} diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php index feda27c..bef2479 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -19,6 +19,7 @@ class AdminAppTicketResource extends TicketResource return [ ...parent::toArray($request), ...$details, + 'allow_refund' => $this->resource->allow_refund(), 'values' => $rowService->values($this->resource, $details), ]; } diff --git a/app/Domains/Ticket/Services/AdminAppTicketRowService.php b/app/Domains/Ticket/Services/AdminAppTicketRowService.php index 31b5a7c..457acfc 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketRowService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketRowService.php @@ -36,6 +36,7 @@ class AdminAppTicketRowService 'status' => $ticket->status, 'scanned_by' => $ticket->scannerUser?->nombre_apellido, 'variant_properties' => $this->variantProperties($ticket), + 'allow_refund' => $ticket->allow_refund(), ]; } diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index ebf8f9f..4f54759 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -9,12 +9,15 @@ use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class AdminAppTicketService { private const RELATIONS = [ ...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, + 'tenant', 'user', 'scannerUser', 'sourceCatalogItem.category', @@ -87,6 +90,70 @@ class AdminAppTicketService return $ticket->refresh()->load(self::RELATIONS); } + public function refund(Tenant $tenant, int $ticketId, string $refundType): Ticket + { + $this->ensureRefundIsAllowed($tenant, $refundType); + + return DB::transaction(function () use ($tenant, $ticketId, $refundType): Ticket { + $ticket = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->lockForUpdate() + ->findOrFail($ticketId); + + $purchaseItem = PurchaseItem::query() + ->lockForUpdate() + ->find($ticket->source_purchase_item_id); + + if ($purchaseItem === null) { + throw ValidationException::withMessages([ + 'ticket' => 'El ticket no tiene un ítem de compra asociado para reembolsar.', + ]); + } + + $refundAmount = $this->refundAmount($purchaseItem, $tenant, $refundType); + $refundedAmount = round((float) $purchaseItem->refunded_amount + $refundAmount, 2); + + if ($refundedAmount > (float) $purchaseItem->total) { + throw ValidationException::withMessages([ + 'refund_type' => 'El importe reembolsado no puede superar el total del ítem de compra.', + ]); + } + + $ticket->markAsRefunded(); + $ticket->save(); + + $purchaseItem->update([ + 'refunded_amount' => number_format($refundedAmount, 2, '.', ''), + ]); + + return $ticket->refresh()->load(self::RELATIONS); + }); + } + + private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void + { + $isAllowed = match ($refundType) { + 'partial' => $tenant->allow_partial_refund(), + 'total' => (bool) $tenant->allow_ticket_total_refund, + }; + + if (! $isAllowed) { + throw ValidationException::withMessages([ + 'refund_type' => 'El tipo de reembolso solicitado no está habilitado para este tenant.', + ]); + } + } + + private function refundAmount(PurchaseItem $purchaseItem, Tenant $tenant, string $refundType): float + { + $ticketAmount = (float) $purchaseItem->precio_unitario; + + return match ($refundType) { + 'partial' => round($ticketAmount * (float) $tenant->ticket_partial_refund_percentage / 100, 2), + 'total' => $ticketAmount, + }; + } + /** * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters * @return Builder diff --git a/app/Domains/Ticket/routes/adminapp.php b/app/Domains/Ticket/routes/adminapp.php index 0393ee4..d01e8d1 100644 --- a/app/Domains/Ticket/routes/adminapp.php +++ b/app/Domains/Ticket/routes/adminapp.php @@ -13,6 +13,10 @@ Route::prefix('v1/adminapp/tenant') ->whereNumber('ticket') ->middleware('tenant.menu:adminapp.tickets') ->name('adminapp.tickets.cancel'); + Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund']) + ->whereNumber('ticket') + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.tickets.refund'); Route::get('tickets/pdf', [TicketController::class, 'downloadPdf']) ->middleware('tenant.menu:adminapp.tickets') ->name('adminapp.tickets.pdf'); diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index b8bc401..a959bba 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -73,6 +73,7 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.values.id', $ticket->id) ->assertJsonPath('data.0.values.status', Ticket::STATUS_ACTIVE) ->assertJsonPath('data.0.status_label', 'Activo') + ->assertJsonPath('data.0.allow_refund', false) ->assertJsonMissingPath('data.0.values.ticket') ->assertJsonPath('data.0.values.date', '-') ->assertJsonPath('data.0.values.size', '-') @@ -80,6 +81,45 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('meta.total', 1); } + public function test_it_exposes_allow_refund_flag_in_tickets_list(): void + { + $tenant = $this->createTenant('ticket-allow-refund'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $this->createTicket($tenant, $admin); + + // Default: neither total nor partial refund allowed + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.allow_refund', false); + + // Total refund allowed + $tenant->update(['allow_ticket_total_refund' => true]); + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.allow_refund', true); + + // Partial refund allowed with percentage set + $tenant->update([ + 'allow_ticket_total_refund' => false, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 20.00, + ]); + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.allow_refund', true); + + // Partial refund enabled but percentage is 0 + $tenant->update([ + 'ticket_partial_refund_percentage' => 0, + ]); + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.allow_refund', false); + } + public function test_it_cancels_a_ticket_from_the_authenticated_tenant(): void { $tenant = $this->createTenant('ticket-cancellation'); @@ -112,6 +152,82 @@ class AdminAppTicketControllerTest extends TestCase $this->assertNull($ticket->fresh()->cancelled_at); } + public function test_it_totally_refunds_a_ticket_when_the_tenant_allows_it(): void + { + $tenant = $this->createTenant('ticket-total-refund'); + $tenant->update(['allow_ticket_total_refund' => true]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ + 'refund_type' => 'total', + ]) + ->assertOk() + ->assertJsonPath('data.id', $ticket->id) + ->assertJsonPath('data.status', Ticket::STATUS_REFUNDED) + ->assertJsonPath('data.refunded_amount', '100.00'); + + $this->assertNotNull($ticket->fresh()->refunded_at); + $this->assertSame('100.00', $purchaseItem->fresh()->refunded_amount); + } + + public function test_it_partially_refunds_a_ticket_using_the_tenant_percentage(): void + { + $tenant = $this->createTenant('ticket-partial-refund'); + $tenant->update([ + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.50, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ + 'refund_type' => 'partial', + ]) + ->assertOk() + ->assertJsonPath('data.status', Ticket::STATUS_REFUNDED) + ->assertJsonPath('data.refunded_amount', '25.50'); + + $this->assertSame('25.50', $purchaseItem->fresh()->refunded_amount); + } + + public function test_it_does_not_refund_a_ticket_when_the_requested_refund_type_is_disabled(): void + { + $tenant = $this->createTenant('ticket-refund-disabled'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ + 'refund_type' => 'total', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('refund_type'); + + $this->assertNull($ticket->fresh()->refunded_at); + $this->assertSame('0.00', $purchaseItem->fresh()->refunded_amount); + } + + public function test_it_validates_the_refund_type(): void + { + $tenant = $this->createTenant('ticket-refund-validation'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ + 'refund_type' => 'invalid', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('refund_type'); + } + public function test_it_searches_by_id_and_does_not_search_by_uuid(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); @@ -659,6 +775,43 @@ class AdminAppTicketControllerTest extends TestCase ]); } + /** @return array{Ticket, PurchaseItem} */ + private function createRefundableTicket(Tenant $tenant, User $admin, string $amount): array + { + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'ticket-reembolsable-'.Str::uuid(), + 'nombre' => 'Ticket reembolsable', + 'precio' => $amount, + ]); + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $admin->id, + 'status' => Purchase::STATUS_PAID, + 'nombre_apellido' => $admin->nombre_apellido, + 'total' => $amount, + ]); + $purchaseItem = PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => 'Ticket reembolsable', + 'descripcion' => '', + 'slug' => 'ticket-reembolsable', + 'item_nombre' => 'Ticket reembolsable', + 'cantidad' => 1, + 'precio_unitario' => $amount, + 'total' => $amount, + ]); + + return [ + $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $purchaseItem->id, + 'source_catalog_item_id' => $catalogItem->id, + ]), + $purchaseItem, + ]; + } + private function grantTicketsMenu(Tenant $tenant): void { $menu = Menu::query()->create([ -- 2.49.1 From 2ddb046c26543623bf05504fd16319293a9d993a Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 11 Sep 2026 09:25:19 -0300 Subject: [PATCH 09/63] feat(ticket): add action capability helpers and expose them in admin resource --- app/Domains/Ticket/Models/Ticket.php | 45 +++++++++++++++++++ .../AdminApp/AdminAppTicketResource.php | 3 ++ .../Ticket/AdminAppTicketControllerTest.php | 15 +++++-- tests/Unit/Ticket/TicketTest.php | 18 ++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index f93fac1..ee151aa 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -150,6 +150,51 @@ class Ticket extends Model return $this->allow_refund(); } + public function is_active(): bool + { + return $this->status === self::STATUS_ACTIVE; + } + + public function isActive(): bool + { + return $this->is_active(); + } + + public function getIsActiveAttribute(): bool + { + return $this->is_active(); + } + + public function can_cancel(): bool + { + return $this->is_active(); + } + + public function canCancel(): bool + { + return $this->can_cancel(); + } + + public function getCanCancelAttribute(): bool + { + return $this->can_cancel(); + } + + public function can_refund(): bool + { + return $this->is_active() && $this->allow_refund(); + } + + public function canRefund(): bool + { + return $this->can_refund(); + } + + public function getCanRefundAttribute(): bool + { + return $this->can_refund(); + } + /** @return BelongsTo */ public function user(): BelongsTo { diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php index bef2479..cae2bd1 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -20,6 +20,9 @@ class AdminAppTicketResource extends TicketResource ...parent::toArray($request), ...$details, 'allow_refund' => $this->resource->allow_refund(), + 'is_active' => $this->resource->is_active(), + 'can_cancel' => $this->resource->can_cancel(), + 'can_refund' => $this->resource->can_refund(), 'values' => $rowService->values($this->resource, $details), ]; } diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index a959bba..284ea5f 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -74,6 +74,9 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.values.status', Ticket::STATUS_ACTIVE) ->assertJsonPath('data.0.status_label', 'Activo') ->assertJsonPath('data.0.allow_refund', false) + ->assertJsonPath('data.0.is_active', true) + ->assertJsonPath('data.0.can_cancel', true) + ->assertJsonPath('data.0.can_refund', false) ->assertJsonMissingPath('data.0.values.ticket') ->assertJsonPath('data.0.values.date', '-') ->assertJsonPath('data.0.values.size', '-') @@ -93,13 +96,15 @@ class AdminAppTicketControllerTest extends TestCase // Default: neither total nor partial refund allowed $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() - ->assertJsonPath('data.0.allow_refund', false); + ->assertJsonPath('data.0.allow_refund', false) + ->assertJsonPath('data.0.can_refund', false); // Total refund allowed $tenant->update(['allow_ticket_total_refund' => true]); $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() - ->assertJsonPath('data.0.allow_refund', true); + ->assertJsonPath('data.0.allow_refund', true) + ->assertJsonPath('data.0.can_refund', true); // Partial refund allowed with percentage set $tenant->update([ @@ -109,7 +114,8 @@ class AdminAppTicketControllerTest extends TestCase ]); $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() - ->assertJsonPath('data.0.allow_refund', true); + ->assertJsonPath('data.0.allow_refund', true) + ->assertJsonPath('data.0.can_refund', true); // Partial refund enabled but percentage is 0 $tenant->update([ @@ -117,7 +123,8 @@ class AdminAppTicketControllerTest extends TestCase ]); $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() - ->assertJsonPath('data.0.allow_refund', false); + ->assertJsonPath('data.0.allow_refund', false) + ->assertJsonPath('data.0.can_refund', false); } public function test_it_cancels_a_ticket_from_the_authenticated_tenant(): void diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php index a71541a..d473104 100644 --- a/tests/Unit/Ticket/TicketTest.php +++ b/tests/Unit/Ticket/TicketTest.php @@ -64,6 +64,24 @@ class TicketTest extends TestCase $this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status); } + public function test_it_exposes_admin_action_capabilities(): void + { + $tenant = new Tenant([ + 'allow_ticket_total_refund' => true, + ]); + $active = (new Ticket)->setRelation('tenant', $tenant); + + $this->assertTrue($active->is_active()); + $this->assertTrue($active->can_cancel()); + $this->assertTrue($active->can_refund()); + + $active->used_at = now(); + + $this->assertFalse($active->is_active()); + $this->assertFalse($active->can_cancel()); + $this->assertFalse($active->can_refund()); + } + public function test_fixed_window_controls_ticket_validity(): void { Carbon::setTestNow('2026-07-21 10:00:00'); -- 2.49.1 From 6384c0046dd2bd1907ec28dfa299b4697eca340d Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 11 Sep 2026 09:25:32 -0300 Subject: [PATCH 10/63] feat(ticket): validate ticket capability status and use transaction in cancel and refund --- .../Ticket/Services/AdminAppTicketService.php | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 4f54759..55d3b80 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -80,14 +80,23 @@ class AdminAppTicketService public function cancel(Tenant $tenant, int $ticketId): Ticket { - $ticket = Ticket::query() - ->where('tenant_code', $tenant->codigo) - ->findOrFail($ticketId); + return DB::transaction(function () use ($tenant, $ticketId): Ticket { + $ticket = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->lockForUpdate() + ->findOrFail($ticketId); - $ticket->markAsCancelled(); - $ticket->save(); + if (! $ticket->can_cancel()) { + throw ValidationException::withMessages([ + 'status' => 'El ticket debe estar activo para poder cancelarlo.', + ]); + } - return $ticket->refresh()->load(self::RELATIONS); + $ticket->markAsCancelled(); + $ticket->save(); + + return $ticket->refresh()->load(self::RELATIONS); + }); } public function refund(Tenant $tenant, int $ticketId, string $refundType): Ticket @@ -100,6 +109,18 @@ class AdminAppTicketService ->lockForUpdate() ->findOrFail($ticketId); + if (! $ticket->can_refund()) { + if ($ticket->status !== Ticket::STATUS_ACTIVE) { + throw ValidationException::withMessages([ + 'status' => 'El ticket debe estar activo para poder reembolsarlo.', + ]); + } + + throw ValidationException::withMessages([ + 'refund' => 'El reembolso no está disponible para este ticket.', + ]); + } + $purchaseItem = PurchaseItem::query() ->lockForUpdate() ->find($ticket->source_purchase_item_id); -- 2.49.1 From 4abb6c67fd601043c18b5c9d282adf25c347a4c4 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 11 Sep 2026 10:43:03 -0300 Subject: [PATCH 11/63] feat(ticket): add refund calculation functionality and corresponding tests --- .../Controllers/AdminApp/TicketController.php | 10 ++ ...dminAppTicketRefundCalculationResource.php | 26 +++++ .../Ticket/Services/AdminAppTicketService.php | 95 +++++++++++++++++- app/Domains/Ticket/routes/adminapp.php | 4 + .../Ticket/AdminAppTicketControllerTest.php | 99 +++++++++++++++++++ 5 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 app/Domains/Ticket/Resources/AdminApp/AdminAppTicketRefundCalculationResource.php diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php index 6b63322..7e2c4b3 100644 --- a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -6,6 +6,7 @@ use App\Domains\Ticket\Requests\AdminAppTicketExportRequest; use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest; use App\Domains\Ticket\Requests\AdminAppTicketRefundRequest; use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection; +use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketRefundCalculationResource; use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketResource; use App\Domains\Ticket\Services\AdminAppTicketExcelService; use App\Domains\Ticket\Services\AdminAppTicketPdfService; @@ -39,6 +40,15 @@ class TicketController extends Controller return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket)); } + public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return new AdminAppTicketRefundCalculationResource( + $this->ticketService->calculateRefund($tenant, $ticket) + ); + } + public function refund(AdminAppTicketRefundRequest $request, int $ticket): AdminAppTicketResource { $tenant = $request->user()->tenant()->firstOrFail(); diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketRefundCalculationResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketRefundCalculationResource.php new file mode 100644 index 0000000..40ebac4 --- /dev/null +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketRefundCalculationResource.php @@ -0,0 +1,26 @@ + $this->resource['total'], + 'partial' => $this->resource['partial'], + ]; + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 55d3b80..3717500 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -45,20 +45,29 @@ class AdminAppTicketService ->get(); $matchingTickets = $this->sortTickets($matchingTickets, $tenant, $filters); $tickets = $this->paginate($matchingTickets, $filters); - $scannedTickets = $matchingTickets->whereNotNull('used_at')->count(); + $scannedTickets = $matchingTickets + ->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_USED) + ->count(); + $activeTickets = $matchingTickets + ->filter(fn (Ticket $ticket): bool => $ticket->is_active()) + ->count(); + $totalTickets = $activeTickets + $scannedTickets; } else { $tickets = (clone $query) ->with(self::RELATIONS) ->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id')) ->paginateFromRequest() ->withQueryString(); - $scannedTickets = $countQuery->whereNotNull('used_at')->count(); + + $counts = $this->calculateTicketCounts($countQuery); + $scannedTickets = $counts['scanned']; + $totalTickets = $counts['total']; } return new AdminAppTicketResult( tickets: $tickets, scannedTickets: $scannedTickets, - totalTickets: $tickets->total(), + totalTickets: $totalTickets, ); } @@ -99,6 +108,57 @@ class AdminAppTicketService }); } + /** + * @return array{ + * total: string|null, + * partial: string|null, + * } + */ + public function calculateRefund(Tenant $tenant, int $ticketId): array + { + $ticket = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->findOrFail($ticketId); + + if (! $ticket->can_refund()) { + throw ValidationException::withMessages([ + 'refund' => 'El reembolso no está disponible para este ticket.', + ]); + } + + $purchaseItem = PurchaseItem::query() + ->find($ticket->source_purchase_item_id); + + if ($purchaseItem === null) { + throw ValidationException::withMessages([ + 'ticket' => 'El ticket no tiene un ítem de compra asociado para reembolsar.', + ]); + } + + $unitPrice = (float) $purchaseItem->precio_unitario; + $itemTotal = (float) $purchaseItem->total; + $itemRefundedAmount = (float) ($purchaseItem->refunded_amount ?? 0); + $remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2)); + + $total = null; + if ($tenant->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) { + $total = number_format($unitPrice, 2, '.', ''); + } + + $partial = null; + if ($tenant->allow_partial_refund()) { + $partialAmount = $this->refundAmount($purchaseItem, $tenant, 'partial'); + if ($partialAmount <= $remainingItemAmount) { + $partial = number_format($partialAmount, 2, '.', ''); + } + } + + return [ + 'total' => $total, + 'partial' => $partial, + ]; + } + public function refund(Tenant $tenant, int $ticketId, string $refundType): Ticket { $this->ensureRefundIsAllowed($tenant, $refundType); @@ -398,6 +458,35 @@ class AdminAppTicketService $query->whereIn('tickets.id', $matchingIds); } + /** + * @param Builder $countQuery + * @return array{scanned: int, total: int} + */ + private function calculateTicketCounts(Builder $countQuery): array + { + $scannedTickets = (clone $countQuery) + ->whereNotNull('used_at') + ->whereNull('disabled_at') + ->whereNull('cancelled_at') + ->whereNull('refunded_at') + ->count(); + + $activeTickets = (clone $countQuery) + ->whereNull('used_at') + ->whereNull('disabled_at') + ->whereNull('cancelled_at') + ->whereNull('refunded_at') + ->with(TicketValidityResolver::RELATIONS) + ->get() + ->filter(fn (Ticket $ticket): bool => $ticket->is_active()) + ->count(); + + return [ + 'scanned' => $scannedTickets, + 'total' => $activeTickets + $scannedTickets, + ]; + } + private function normalizedCategory(string $category): string { return mb_strtolower(trim($category)); diff --git a/app/Domains/Ticket/routes/adminapp.php b/app/Domains/Ticket/routes/adminapp.php index d01e8d1..66abe50 100644 --- a/app/Domains/Ticket/routes/adminapp.php +++ b/app/Domains/Ticket/routes/adminapp.php @@ -13,6 +13,10 @@ Route::prefix('v1/adminapp/tenant') ->whereNumber('ticket') ->middleware('tenant.menu:adminapp.tickets') ->name('adminapp.tickets.cancel'); + Route::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund']) + ->whereNumber('ticket') + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.tickets.calculate-refund'); Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund']) ->whereNumber('ticket') ->middleware('tenant.menu:adminapp.tickets') diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 284ea5f..f7b5ee4 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -235,6 +235,101 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonValidationErrors('refund_type'); } + public function test_it_calculates_total_and_partial_refund_for_a_ticket(): void + { + $tenant = $this->createTenant('ticket-calc-both'); + $tenant->update([ + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 30.00, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") + ->assertOk() + ->assertJsonPath('data.total', '100.00') + ->assertJsonPath('data.partial', '30.00'); + } + + public function test_it_calculates_only_total_when_partial_is_disabled(): void + { + $tenant = $this->createTenant('ticket-calc-total'); + $tenant->update([ + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => false, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket] = $this->createRefundableTicket($tenant, $admin, '150.00'); + + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") + ->assertOk() + ->assertJsonPath('data.total', '150.00') + ->assertJsonPath('data.partial', null); + } + + public function test_it_returns_null_when_remaining_item_balance_is_insufficient(): void + { + $tenant = $this->createTenant('ticket-calc-insufficient'); + $tenant->update([ + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 50.00, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + // Simulate 70 already refunded out of 100 on the item (remaining is 30) + $purchaseItem->update(['refunded_amount' => '70.00']); + + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") + ->assertOk() + // Total is 100, which exceeds remaining 30 -> total is null + ->assertJsonPath('data.total', null) + // Partial is 50, which exceeds remaining 30 -> partial is null + ->assertJsonPath('data.partial', null); + } + + public function test_it_fails_calculating_refund_if_ticket_is_not_active(): void + { + $tenant = $this->createTenant('ticket-calc-inactive'); + $tenant->update(['allow_ticket_total_refund' => true]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + $ticket->update(['cancelled_at' => now()]); + + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") + ->assertUnprocessable() + ->assertJsonValidationErrors('refund'); + } + + public function test_it_cannot_calculate_refund_for_another_tenants_ticket(): void + { + $tenantA = $this->createTenant('ticket-calc-a'); + $tenantB = $this->createTenant('ticket-calc-b'); + $tenantA->update(['allow_ticket_total_refund' => true]); + $tenantB->update(['allow_ticket_total_refund' => true]); + + $adminA = $this->createAdminAppUser($tenantA); + $adminB = $this->createAdminAppUser($tenantB); + $this->grantTicketsMenu($tenantA); + + [$ticketB] = $this->createRefundableTicket($tenantB, $adminB, '100.00'); + + Sanctum::actingAs($adminA); + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticketB->id}/refund") + ->assertNotFound(); + } + public function test_it_searches_by_id_and_does_not_search_by_uuid(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); @@ -410,6 +505,9 @@ 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()]); + $this->createTicket($tenant, $admin)->update(['disabled_at' => now()]); $this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]); $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') @@ -420,6 +518,7 @@ class AdminAppTicketControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() + ->assertJsonCount(5, 'data') ->assertJsonPath('scanned_tickets', 1) ->assertJsonPath('total_tickets', 2); } -- 2.49.1 From d5fdae9a24325371c1451e17ff3a58699709a43c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 11 Sep 2026 12:28:29 -0300 Subject: [PATCH 12/63] feat(event): add rescheduling and cancellation state to event dates --- app/Domains/Event/Enums/EventDateStatus.php | 12 +++++ app/Domains/Event/Models/EventDate.php | 45 ++++++++++++++++++- ..._add_rescheduling_state_to_event_dates.php | 30 +++++++++++++ tests/Unit/Event/EventModelsTest.php | 33 ++++++++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 app/Domains/Event/Enums/EventDateStatus.php create mode 100644 database/migrations/2026_09_11_000000_add_rescheduling_state_to_event_dates.php diff --git a/app/Domains/Event/Enums/EventDateStatus.php b/app/Domains/Event/Enums/EventDateStatus.php new file mode 100644 index 0000000..591b7c2 --- /dev/null +++ b/app/Domains/Event/Enums/EventDateStatus.php @@ -0,0 +1,12 @@ + $eventDate->syncValidityTime()); @@ -52,6 +57,8 @@ class EventDate extends Model return [ 'date' => 'date:Y-m-d', 'validity_time_id' => 'integer', + 'rescheduled_to_event_date_id' => 'integer', + 'cancelled_at' => 'datetime', ]; } @@ -67,6 +74,18 @@ class EventDate extends Model return $this->belongsTo(ValidityTime::class); } + /** @return BelongsTo */ + public function rescheduledTo(): BelongsTo + { + return $this->belongsTo(self::class, 'rescheduled_to_event_date_id'); + } + + /** @return HasMany */ + public function rescheduledFrom(): HasMany + { + return $this->hasMany(self::class, 'rescheduled_to_event_date_id'); + } + /** @return HasMany */ public function variants(): HasMany { @@ -94,6 +113,27 @@ class EventDate extends Model return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end); } + public function getStatusAttribute(): EventDateStatus + { + if ($this->rescheduled_to_event_date_id !== null) { + return EventDateStatus::Rescheduled; + } + + if ($this->cancelled_at !== null) { + return EventDateStatus::Cancelled; + } + + if (now()->lt($this->startsAt())) { + return EventDateStatus::Scheduled; + } + + if (now()->lt($this->endsAt())) { + return EventDateStatus::InProgress; + } + + return EventDateStatus::Completed; + } + private function syncTenantDateText(): void { $tenant = $this->tenant()->first(); @@ -104,7 +144,10 @@ class EventDate extends Model $tenant->update([ 'event_date_text' => app(EventDateTextFormatter::class)->format( - $tenant->eventDates()->pluck('date') + $tenant->eventDates() + ->whereNull('rescheduled_to_event_date_id') + ->whereNull('cancelled_at') + ->pluck('date') ), ]); } diff --git a/database/migrations/2026_09_11_000000_add_rescheduling_state_to_event_dates.php b/database/migrations/2026_09_11_000000_add_rescheduling_state_to_event_dates.php new file mode 100644 index 0000000..54c999f --- /dev/null +++ b/database/migrations/2026_09_11_000000_add_rescheduling_state_to_event_dates.php @@ -0,0 +1,30 @@ +foreignId('rescheduled_to_event_date_id') + ->nullable() + ->after('validity_time_id') + ->constrained('event_dates') + ->restrictOnDelete(); + $table->dateTime('cancelled_at') + ->nullable() + ->after('rescheduled_to_event_date_id'); + }); + } + + public function down(): void + { + Schema::table('event_dates', function (Blueprint $table): void { + $table->dropConstrainedForeignId('rescheduled_to_event_date_id'); + $table->dropColumn('cancelled_at'); + }); + } +}; diff --git a/tests/Unit/Event/EventModelsTest.php b/tests/Unit/Event/EventModelsTest.php index 0650596..459aa8e 100644 --- a/tests/Unit/Event/EventModelsTest.php +++ b/tests/Unit/Event/EventModelsTest.php @@ -3,9 +3,11 @@ namespace Tests\Unit\Event; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Enums\EventDateStatus; use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\ValidityTime; +use Illuminate\Support\Carbon; use Tests\TestCase; class EventModelsTest extends TestCase @@ -30,6 +32,37 @@ class EventModelsTest extends TestCase $this->assertInstanceOf(ValidityTime::class, $eventDate->validityTime()->getRelated()); $this->assertInstanceOf(EventDate::class, (new ValidityTime)->eventDate()->getRelated()); $this->assertInstanceOf(Variant::class, $eventDate->variants()->getRelated()); + $this->assertInstanceOf(EventDate::class, $eventDate->rescheduledTo()->getRelated()); + $this->assertInstanceOf(EventDate::class, $eventDate->rescheduledFrom()->getRelated()); + } + + public function test_event_date_status_is_computed_in_business_priority_order(): void + { + Carbon::setTestNow('2026-10-09 10:00:00'); + + try { + $eventDate = new EventDate([ + 'date' => '2026-10-09', + 'time_start' => '09:00:00', + 'time_end' => '18:30:00', + ]); + + $this->assertSame(EventDateStatus::InProgress, $eventDate->status); + + $eventDate->date = '2026-10-10'; + $this->assertSame(EventDateStatus::Scheduled, $eventDate->status); + + $eventDate->date = '2026-10-08'; + $this->assertSame(EventDateStatus::Completed, $eventDate->status); + + $eventDate->cancelled_at = now(); + $this->assertSame(EventDateStatus::Cancelled, $eventDate->status); + + $eventDate->rescheduled_to_event_date_id = 123; + $this->assertSame(EventDateStatus::Rescheduled, $eventDate->status); + } finally { + Carbon::setTestNow(); + } } public function test_tenant_has_many_event_dates(): void -- 2.49.1 From 106cf017dcc19a926a1143474e84ba3e8e6c26bf Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 11 Sep 2026 12:28:37 -0300 Subject: [PATCH 13/63] feat(ticket): resolve effective event dates and handle reschedule chains --- .../Services/EffectiveEventDateResolver.php | 37 +++++++++++++++++++ .../Services/TicketValidityResolver.php | 23 +++++++++++- .../Ticket/TicketGeneratorServiceTest.php | 4 +- 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 app/Domains/Event/Services/EffectiveEventDateResolver.php diff --git a/app/Domains/Event/Services/EffectiveEventDateResolver.php b/app/Domains/Event/Services/EffectiveEventDateResolver.php new file mode 100644 index 0000000..4606da0 --- /dev/null +++ b/app/Domains/Event/Services/EffectiveEventDateResolver.php @@ -0,0 +1,37 @@ +getKey() === null + ? 'object:'.spl_object_id($current) + : 'key:'.$current->getKey(); + + if (isset($visited[$identity])) { + return null; + } + + $visited[$identity] = true; + + if ($current->rescheduled_to_event_date_id === null) { + return $current->cancelled_at === null ? $current : null; + } + + $current->loadMissing('rescheduledTo'); + $current = $current->rescheduledTo; + + if ($current === null) { + return null; + } + } + } +} diff --git a/app/Domains/Ticket/Services/TicketValidityResolver.php b/app/Domains/Ticket/Services/TicketValidityResolver.php index 0c0c34d..69714d5 100644 --- a/app/Domains/Ticket/Services/TicketValidityResolver.php +++ b/app/Domains/Ticket/Services/TicketValidityResolver.php @@ -4,6 +4,8 @@ namespace App\Domains\Ticket\Services; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\VariantDefinition; +use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Services\EffectiveEventDateResolver; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Support\Collection; @@ -17,6 +19,14 @@ use Illuminate\Support\Collection; */ class TicketValidityResolver { + private readonly EffectiveEventDateResolver $effectiveEventDateResolver; + + public function __construct(?EffectiveEventDateResolver $effectiveEventDateResolver = null) + { + $this->effectiveEventDateResolver = $effectiveEventDateResolver + ?? new EffectiveEventDateResolver; + } + /** Relaciones necesarias para resolver tickets sin consultas N+1. */ public const RELATIONS = [ 'sourceVariant.eventDates.validityTime', @@ -56,7 +66,18 @@ class TicketValidityResolver ]); $dimensions = collect(); - $eventDates = $variant->selectedEventDates(); + $selectedEventDates = $variant->selectedEventDates(); + $eventDates = $selectedEventDates + ->map(fn (EventDate $eventDate): ?EventDate => $this->effectiveEventDateResolver->resolve($eventDate)) + ->filter() + ->unique(fn (EventDate $eventDate): int => $eventDate->getKey() ?? spl_object_id($eventDate)) + ->values(); + + if ($selectedEventDates->isNotEmpty() && $eventDates->isEmpty()) { + return ResolvedTicketValidity::unresolvable(); + } + + $eventDates->each->loadMissing('validityTime'); if ($eventDates->contains(fn ($eventDate): bool => $eventDate->validityTime === null)) { return ResolvedTicketValidity::unresolvable(); diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index b1415cc..5edbf37 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -471,7 +471,9 @@ class TicketGeneratorServiceTest extends TestCase $this->assertCount(2, $ticket->resolvedValidityGroups()); $this->assertTrue($ticket->resolvedValidityGroups()->every( fn ($group): bool => $group->validityTimes->count() === 2 - && $group->validityTimes->contains($lunch) + && $group->validityTimes->contains( + fn (ValidityTime $validityTime): bool => $validityTime->is($lunch) + ) )); $this->assertEqualsCanonicalizing( $dates->pluck('validity_time_id')->all(), -- 2.49.1 From 96df431d6042cde8bd6869b017355d6df17e364d Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 11 Sep 2026 12:28:51 -0300 Subject: [PATCH 14/63] feat(event): implement admin date creation, rescheduling, and cancellation endpoints --- .../Controllers/AdminApp/EventController.php | 37 +++ .../Requests/RescheduleEventDateRequest.php | 21 ++ .../Event/Requests/StoreEventDateRequest.php | 23 ++ .../Event/Requests/UpdateEventRequest.php | 5 - .../Event/Resources/EventDateResource.php | 28 +++ app/Domains/Event/Resources/EventResource.php | 10 +- app/Domains/Event/Services/EventService.php | 202 +++++++++++++-- app/Domains/Event/routes/adminapp.php | 3 + .../Event/AdminAppEventControllerTest.php | 231 +++++++++++++----- 9 files changed, 462 insertions(+), 98 deletions(-) create mode 100644 app/Domains/Event/Requests/RescheduleEventDateRequest.php create mode 100644 app/Domains/Event/Requests/StoreEventDateRequest.php create mode 100644 app/Domains/Event/Resources/EventDateResource.php diff --git a/app/Domains/Event/Controllers/AdminApp/EventController.php b/app/Domains/Event/Controllers/AdminApp/EventController.php index b6036d9..eb626b5 100644 --- a/app/Domains/Event/Controllers/AdminApp/EventController.php +++ b/app/Domains/Event/Controllers/AdminApp/EventController.php @@ -2,7 +2,11 @@ namespace App\Domains\Event\Controllers\AdminApp; +use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Requests\RescheduleEventDateRequest; +use App\Domains\Event\Requests\StoreEventDateRequest; use App\Domains\Event\Requests\UpdateEventRequest; +use App\Domains\Event\Resources\EventDateResource; use App\Domains\Event\Resources\EventResource; use App\Domains\Event\Services\EventService; use App\Http\Controllers\Controller; @@ -28,4 +32,37 @@ class EventController extends Controller ) ); } + + public function storeDate(StoreEventDateRequest $request): EventDateResource + { + return EventDateResource::make( + $this->eventService->createDateForTenant( + $request->user()->tenant()->firstOrFail(), + $request->validated(), + ) + ); + } + + public function rescheduleDate( + RescheduleEventDateRequest $request, + EventDate $eventDate, + ): EventDateResource { + return EventDateResource::make( + $this->eventService->rescheduleDateForTenant( + $request->user()->tenant()->firstOrFail(), + $eventDate, + $request->validated(), + ) + ); + } + + public function cancelDate(Request $request, EventDate $eventDate): EventDateResource + { + return EventDateResource::make( + $this->eventService->cancelDateForTenant( + $request->user()->tenant()->firstOrFail(), + $eventDate, + ) + ); + } } diff --git a/app/Domains/Event/Requests/RescheduleEventDateRequest.php b/app/Domains/Event/Requests/RescheduleEventDateRequest.php new file mode 100644 index 0000000..3573c8a --- /dev/null +++ b/app/Domains/Event/Requests/RescheduleEventDateRequest.php @@ -0,0 +1,21 @@ + */ + public function rules(): array + { + return [ + 'date' => ['required', 'date_format:Y-m-d'], + ]; + } +} diff --git a/app/Domains/Event/Requests/StoreEventDateRequest.php b/app/Domains/Event/Requests/StoreEventDateRequest.php new file mode 100644 index 0000000..74bbe0e --- /dev/null +++ b/app/Domains/Event/Requests/StoreEventDateRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'date' => ['required', 'date_format:Y-m-d'], + 'start_time' => ['required', 'date_format:H:i'], + 'end_time' => ['required', 'date_format:H:i'], + ]; + } +} diff --git a/app/Domains/Event/Requests/UpdateEventRequest.php b/app/Domains/Event/Requests/UpdateEventRequest.php index 987dccb..adf383d 100644 --- a/app/Domains/Event/Requests/UpdateEventRequest.php +++ b/app/Domains/Event/Requests/UpdateEventRequest.php @@ -19,11 +19,6 @@ class UpdateEventRequest extends FormRequest return [ 'title' => ['required', 'string', 'max:255'], 'location' => ['required', 'string', 'max:255'], - 'dates' => ['required', 'array', 'min:1'], - 'dates.*' => ['required', 'array:date,start_time,end_time'], - 'dates.*.date' => ['required', 'date_format:Y-m-d', 'distinct'], - 'dates.*.start_time' => ['required', 'date_format:H:i'], - 'dates.*.end_time' => ['required', 'date_format:H:i'], 'social_media' => ['sometimes', 'array'], 'social_media.*' => ['required', 'array:code,url,orden'], 'social_media.*.code' => [ diff --git a/app/Domains/Event/Resources/EventDateResource.php b/app/Domains/Event/Resources/EventDateResource.php new file mode 100644 index 0000000..4268940 --- /dev/null +++ b/app/Domains/Event/Resources/EventDateResource.php @@ -0,0 +1,28 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'validity_time_id' => $this->validity_time_id, + 'validity_time' => ValidityTimeResource::make($this->whenLoaded('validityTime')), + 'date' => $this->date->format('Y-m-d'), + 'start_time' => substr($this->time_start, 0, 5), + 'end_time' => substr($this->time_end, 0, 5), + 'status' => $this->status->value, + 'rescheduled_to_event_date_id' => $this->rescheduled_to_event_date_id, + 'cancelled_at' => $this->cancelled_at?->toISOString(), + ]; + } +} diff --git a/app/Domains/Event/Resources/EventResource.php b/app/Domains/Event/Resources/EventResource.php index 1dd4735..feed495 100644 --- a/app/Domains/Event/Resources/EventResource.php +++ b/app/Domains/Event/Resources/EventResource.php @@ -3,7 +3,6 @@ namespace App\Domains\Event\Resources; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -19,14 +18,7 @@ class EventResource extends JsonResource 'id' => $this->id, 'title' => $this->event_title, 'location' => $this->event_location, - 'dates' => $this->eventDates->map(fn ($eventDate): array => [ - 'id' => $eventDate->id, - 'validity_time_id' => $eventDate->validity_time_id, - 'validity_time' => ValidityTimeResource::make($eventDate->validityTime), - 'date' => $eventDate->date->format('Y-m-d'), - 'start_time' => substr($eventDate->time_start, 0, 5), - 'end_time' => substr($eventDate->time_end, 0, 5), - ])->values(), + 'dates' => EventDateResource::collection($this->eventDates), 'social_media' => $this->socialMedia->map(fn ($item): array => [ 'code' => $item->code, 'url' => $item->pivot->url, diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index 3425eae..fc7bb40 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -2,7 +2,10 @@ namespace App\Domains\Event\Services; +use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Models\Ticket; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -14,6 +17,10 @@ class EventService 'facebook_url' => 'facebook', ]; + public function __construct( + private readonly EffectiveEventDateResolver $effectiveEventDateResolver, + ) {} + public function forTenant(Tenant $tenant): Tenant { return $tenant->load(['eventDates.validityTime', 'socialMedia']); @@ -29,7 +36,6 @@ class EventService 'event_location' => $data['location'], ]); - $this->syncDates($tenant, $data['dates']); if (array_key_exists('social_media', $data)) { $this->syncSocialMedia($tenant, $data['social_media']); } else { @@ -40,41 +46,185 @@ class EventService }); } - /** @param array $dates */ - private function syncDates(Tenant $tenant, array $dates): void + /** @param array{date: string, start_time: string, end_time: string} $data */ + public function createDateForTenant(Tenant $tenant, array $data): EventDate { - $existingDates = $tenant->eventDates()->get()->values(); + return DB::transaction(function () use ($tenant, $data): EventDate { + $attributes = $this->dateAttributes($data); - foreach (array_values($dates) as $index => $date) { - $attributes = [ - 'date' => $date['date'], - 'time_start' => $date['start_time'], - 'time_end' => $date['end_time'], - ]; + if ($tenant->eventDates()->where($attributes)->exists()) { + throw ValidationException::withMessages([ + 'date' => ['La fecha y el horario ya existen.'], + ]); + } - $existingDate = $existingDates->get($index); + return $tenant->eventDates()->create($attributes)->load('validityTime'); + }); + } - if ($existingDate) { - $existingDate->update($attributes); - } else { - $tenant->eventDates()->create($attributes); + /** @param array{date: string} $data */ + public function rescheduleDateForTenant(Tenant $tenant, EventDate $eventDate, array $data): EventDate + { + return DB::transaction(function () use ($tenant, $eventDate, $data): EventDate { + $source = $this->lockedDateForTenant($tenant, $eventDate); + + if ($source->cancelled_at !== null) { + throw ValidationException::withMessages([ + 'event_date' => ['No se puede reprogramar una fecha cancelada.'], + ]); + } + + if ($source->rescheduled_to_event_date_id !== null) { + throw ValidationException::withMessages([ + 'event_date' => ['La fecha ya fue reprogramada.'], + ]); + } + + $destination = $tenant->eventDates() + ->whereDate('date', $data['date']) + ->lockForUpdate() + ->first(); + + if ($destination === null) { + $destination = $tenant->eventDates()->create([ + 'date' => $data['date'], + 'time_start' => $source->time_start, + 'time_end' => $source->time_end, + ]); + } + + if ($destination->is($source) || $this->chainContains($destination, $source)) { + throw ValidationException::withMessages([ + 'date' => ['La reprogramación generaría una referencia circular.'], + ]); + } + + if ($this->effectiveEventDateResolver->resolve($destination) === null) { + throw ValidationException::withMessages([ + 'date' => ['La fecha de destino no es utilizable.'], + ]); + } + + $source->update(['rescheduled_to_event_date_id' => $destination->getKey()]); + + return $source->fresh(['validityTime', 'rescheduledTo.validityTime']); + }); + } + + public function cancelDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate + { + return DB::transaction(function () use ($tenant, $eventDate): EventDate { + $date = $this->lockedDateForTenant($tenant, $eventDate); + + if ($date->rescheduled_to_event_date_id !== null) { + throw ValidationException::withMessages([ + 'event_date' => ['No se puede cancelar una fecha que ya fue reprogramada.'], + ]); + } + + if ($date->cancelled_at !== null) { + return $date->load('validityTime'); + } + + $date->update(['cancelled_at' => now()]); + $this->disableTicketsWithoutUsableDates($tenant, $date); + + return $date->fresh('validityTime'); + }); + } + + private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate + { + return $tenant->eventDates() + ->whereKey($eventDate->getKey()) + ->lockForUpdate() + ->firstOrFail(); + } + + private function chainContains(EventDate $start, EventDate $expected): bool + { + $current = $start; + $visited = []; + + while ($current->rescheduled_to_event_date_id !== null) { + if ($current->is($expected)) { + return true; + } + + if (isset($visited[$current->getKey()])) { + return true; + } + + $visited[$current->getKey()] = true; + $current = $current->rescheduledTo()->lockForUpdate()->first(); + + if ($current === null) { + return false; } } - $datesToDelete = $existingDates->slice(count($dates)); + return $current->is($expected); + } - if ($datesToDelete->contains(fn ($eventDate): bool => $eventDate - ->selectedByVariants() - ->whereHas('sourceTickets') - ->exists() - || $eventDate->variants()->whereHas('sourceTickets')->exists())) { - throw ValidationException::withMessages([ - 'dates' => ['No se puede eliminar una fecha utilizada por tickets generados.'], - ]); + private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $cancelledDate): void + { + $affectedDateIds = collect([$cancelledDate->getKey()]); + $frontier = $affectedDateIds; + + while ($frontier->isNotEmpty()) { + $predecessors = $tenant->eventDates() + ->whereIn('rescheduled_to_event_date_id', $frontier) + ->pluck('id') + ->diff($affectedDateIds) + ->values(); + $affectedDateIds = $affectedDateIds->merge($predecessors)->unique()->values(); + $frontier = $predecessors; } - $datesToDelete->each->delete(); - $tenant->unsetRelation('eventDates'); + $variants = Variant::withTrashed() + ->where(function ($query) use ($affectedDateIds): void { + $query->whereIn('event_date_id', $affectedDateIds) + ->orWhereHas('eventDates', fn ($eventDates) => $eventDates + ->whereIn('event_dates.id', $affectedDateIds)); + }) + ->with(['eventDates', 'eventDate']) + ->get(); + + foreach ($variants as $variant) { + $hasUsableDate = $variant->selectedEventDates()->contains( + fn (EventDate $candidate): bool => $this->effectiveEventDateResolver->resolve($candidate) !== null + ); + + if ($hasUsableDate) { + continue; + } + + Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->where('source_variant_id', $variant->getKey()) + ->whereNull('disabled_at') + ->whereNull('cancelled_at') + ->whereNull('refunded_at') + ->lockForUpdate() + ->get() + ->each(function (Ticket $ticket): void { + $ticket->markAsDisabled(); + $ticket->save(); + }); + } + } + + /** + * @param array{date: string, start_time: string, end_time: string} $data + * @return array{date: string, time_start: string, time_end: string} + */ + private function dateAttributes(array $data): array + { + return [ + 'date' => $data['date'], + 'time_start' => $data['start_time'].':00', + 'time_end' => $data['end_time'].':00', + ]; } /** @param array $contact */ diff --git a/app/Domains/Event/routes/adminapp.php b/app/Domains/Event/routes/adminapp.php index 75ae9ea..c437381 100644 --- a/app/Domains/Event/routes/adminapp.php +++ b/app/Domains/Event/routes/adminapp.php @@ -8,4 +8,7 @@ Route::prefix('v1/adminapp/tenant') ->group(function (): void { Route::get('event', [EventController::class, 'show']); Route::put('event', [EventController::class, 'update']); + Route::post('event-dates', [EventController::class, 'storeDate']); + Route::post('event-dates/{eventDate}/reschedule', [EventController::class, 'rescheduleDate']); + Route::post('event-dates/{eventDate}/cancel', [EventController::class, 'cancelDate']); }); diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index cdf9a98..53ca2aa 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -6,12 +6,17 @@ use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; +use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\Variant; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Enums\ValidityTimeType; +use App\Domains\Ticket\Models\Ticket; use Database\Seeders\AuthorizationSeeder; use Database\Seeders\SocialMediaSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -34,9 +39,10 @@ class AdminAppEventControllerTest extends TestCase { $this->getJson('/api/v1/adminapp/tenant/event')->assertUnauthorized(); $this->putJson('/api/v1/adminapp/tenant/event', $this->eventPayload())->assertUnauthorized(); + $this->postJson('/api/v1/adminapp/tenant/event-dates', $this->datePayload())->assertUnauthorized(); } - public function test_an_adminapp_user_can_create_the_active_event_and_contact_information(): void + public function test_an_adminapp_user_can_update_event_and_contact_information_without_synchronizing_dates(): void { $tenant = $this->createTenant('acme'); Sanctum::actingAs($this->createAdminAppUser($tenant)); @@ -45,10 +51,7 @@ class AdminAppEventControllerTest extends TestCase ->assertOk() ->assertJsonPath('data.title', 'Festival Acme') ->assertJsonPath('data.location', 'Predio Ferial, Rosario') - ->assertJsonPath('data.dates.0.date', '2026-10-09') - ->assertJsonPath('data.dates.0.validity_time.type', 'fixed_window') - ->assertJsonPath('data.dates.0.start_time', '09:00') - ->assertJsonPath('data.dates.0.end_time', '18:30') + ->assertJsonCount(0, 'data.dates') ->assertJsonPath('data.contact.whatsapp_url', 'https://wa.me/5493415550101') ->assertJsonPath('data.contact.instagram_url', 'https://instagram.com/acme') ->assertJsonPath('data.contact.facebook_url', null); @@ -58,25 +61,9 @@ class AdminAppEventControllerTest extends TestCase 'id' => $tenant->id, 'event_title' => 'Festival Acme', 'event_location' => 'Predio Ferial, Rosario', - 'event_date_text' => '9 de Octubre 2026', + 'event_date_text' => null, ]); - $this->assertDatabaseHas('event_dates', [ - 'tenant_code' => $tenant->codigo, - 'date' => '2026-10-09', - 'time_start' => '09:00:00', - 'time_end' => '18:30:00', - ]); - $eventDate = $tenant->eventDates()->with('validityTime')->sole(); - $this->assertSame($eventDate->validity_time_id, $response->json('data.dates.0.validity_time_id')); - $this->assertSame(ValidityTimeType::FixedWindow, $eventDate->validityTime->type); - $this->assertSame( - '2026-10-09 09:00:00', - $eventDate->validityTime->fixed_starts_at->format('Y-m-d H:i:s'), - ); - $this->assertSame( - '2026-10-09 18:30:00', - $eventDate->validityTime->fixed_expires_at->format('Y-m-d H:i:s'), - ); + $this->assertDatabaseCount('event_dates', 0); $this->assertDatabaseHas('tenant_social_media', [ 'tenant_code' => $tenant->codigo, 'social_media_code' => 'whatsapp', @@ -109,7 +96,7 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonCount(0, 'data.dates'); } - public function test_updating_reuses_the_active_event_and_synchronizes_dates_and_contact(): void + public function test_updating_event_does_not_change_or_delete_existing_dates(): void { $tenant = $this->createTenant('acme'); $eventTenant = $this->createActiveEvent($tenant, 'Old Event'); @@ -124,7 +111,6 @@ class AdminAppEventControllerTest extends TestCase 'time_end' => '12:00', ]); $firstValidityTimeId = $firstDate->validity_time_id; - $removedValidityTimeId = $removedDate->validity_time_id; $tenant->socialMedia()->attach('facebook', [ 'url' => 'https://facebook.com/old', 'orden' => 2, @@ -136,12 +122,6 @@ class AdminAppEventControllerTest extends TestCase Sanctum::actingAs($this->createAdminAppUser($tenant)); $payload = $this->eventPayload(); - $payload['dates'] = [[ - 'date' => '2026-11-15', - 'start_time' => '10:00', - 'end_time' => '20:00', - ]]; - $this->putJson('/api/v1/adminapp/tenant/event', $payload) ->assertOk() ->assertJsonPath('data.id', $tenant->id) @@ -151,17 +131,16 @@ class AdminAppEventControllerTest extends TestCase $this->assertDatabaseHas('event_dates', [ 'id' => $firstDate->id, 'validity_time_id' => $firstValidityTimeId, - 'date' => '2026-11-15', + 'date' => '2026-10-01', ]); $this->assertDatabaseHas('validity_times', [ 'id' => $firstValidityTimeId, 'type' => ValidityTimeType::FixedWindow->value, - 'fixed_starts_at' => '2026-11-15 10:00:00', - 'fixed_expires_at' => '2026-11-15 20:00:00', + 'fixed_starts_at' => '2026-10-01 08:00:00', + 'fixed_expires_at' => '2026-10-01 12:00:00', ]); - $this->assertDatabaseMissing('event_dates', ['id' => $removedDate->id]); - $this->assertDatabaseMissing('validity_times', ['id' => $removedValidityTimeId]); - $this->assertSame('15 de Noviembre 2026', $tenant->fresh()->event_date_text); + $this->assertDatabaseHas('event_dates', ['id' => $removedDate->id]); + $this->assertSame('1 y 2 de Octubre 2026', $tenant->fresh()->event_date_text); $this->assertDatabaseMissing('tenant_social_media', [ 'tenant_code' => $tenant->codigo, 'social_media_code' => 'facebook', @@ -173,20 +152,17 @@ class AdminAppEventControllerTest extends TestCase ]); } - public function test_updating_event_dates_recalculates_the_tenant_date_text(): void + public function test_dates_are_created_independently_and_recalculate_the_tenant_date_text(): void { $tenant = $this->createTenant('acme'); Sanctum::actingAs($this->createAdminAppUser($tenant)); - $payload = $this->eventPayload(); - $payload['dates'] = collect([9, 10, 11, 12]) - ->map(fn (int $day): array => [ + foreach ([9, 10, 11, 12] as $day) { + $this->postJson('/api/v1/adminapp/tenant/event-dates', [ 'date' => sprintf('2026-10-%02d', $day), 'start_time' => '09:00', 'end_time' => '18:30', - ]) - ->all(); - - $this->putJson('/api/v1/adminapp/tenant/event', $payload)->assertOk(); + ])->assertCreated()->assertJsonPath('data.status', 'scheduled'); + } $this->assertSame( '9, 10, 11 y 12 de Octubre 2026', @@ -194,7 +170,115 @@ class AdminAppEventControllerTest extends TestCase ); } - public function test_update_validates_event_dates_and_contact_urls(): void + public function test_rescheduling_reuses_an_existing_date_and_tickets_resolve_its_validity(): void + { + $tenant = $this->createTenant('acme'); + $admin = $this->createAdminAppUser($tenant); + $original = $tenant->eventDates()->create([ + 'date' => '2027-10-09', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $destination = $tenant->eventDates()->create([ + 'date' => '2027-10-20', + 'time_start' => '11:00', + 'time_end' => '20:00', + ]); + $variant = $this->createVariant($tenant, $original->id); + $ticket = $this->createTicket($tenant, $admin, $variant); + Sanctum::actingAs($admin); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$original->id}/reschedule", [ + 'date' => '2027-10-20', + ]) + ->assertOk() + ->assertJsonPath('data.status', 'rescheduled') + ->assertJsonPath('data.rescheduled_to_event_date_id', $destination->id); + + $this->assertDatabaseCount('event_dates', 2); + $this->assertSame( + '2027-10-20 11:00:00', + $ticket->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), + ); + $this->assertDatabaseHas('validity_times', [ + 'id' => $original->validity_time_id, + 'fixed_starts_at' => '2027-10-09 09:00:00', + ]); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$destination->id}/reschedule", [ + 'date' => '2027-10-25', + ])->assertOk()->assertJsonPath('data.status', 'rescheduled'); + + $this->assertDatabaseCount('event_dates', 3); + $this->assertSame( + '2027-10-25 11:00:00', + $ticket->fresh()->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), + ); + } + + public function test_cancelling_disables_only_tickets_without_another_usable_date(): void + { + $tenant = $this->createTenant('acme'); + $admin = $this->createAdminAppUser($tenant); + $cancelledDate = $tenant->eventDates()->create([ + 'date' => '2027-10-09', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $otherDate = $tenant->eventDates()->create([ + 'date' => '2027-10-10', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $singleDateVariant = $this->createVariant($tenant, $cancelledDate->id); + $multipleDateVariant = $this->createVariant($tenant); + $multipleDateVariant->eventDates()->sync([$cancelledDate->id, $otherDate->id]); + $singleDateTicket = $this->createTicket($tenant, $admin, $singleDateVariant); + $multipleDateTicket = $this->createTicket($tenant, $admin, $multipleDateVariant); + Sanctum::actingAs($admin); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$cancelledDate->id}/cancel") + ->assertOk() + ->assertJsonPath('data.status', 'cancelled'); + + $this->assertNotNull($singleDateTicket->fresh()->disabled_at); + $this->assertNull($multipleDateTicket->fresh()->disabled_at); + $this->assertSame( + '2027-10-10 09:00:00', + $multipleDateTicket->fresh()->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), + ); + } + + public function test_cancelling_a_reschedule_destination_disables_tickets_from_predecessor_dates(): void + { + $tenant = $this->createTenant('acme'); + $admin = $this->createAdminAppUser($tenant); + $original = $tenant->eventDates()->create([ + 'date' => '2027-10-09', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $destination = $tenant->eventDates()->create([ + 'date' => '2027-10-20', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $original->update(['rescheduled_to_event_date_id' => $destination->id]); + $ticket = $this->createTicket( + $tenant, + $admin, + $this->createVariant($tenant, $original->id), + ); + Sanctum::actingAs($admin); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$destination->id}/cancel") + ->assertOk(); + + $this->assertNotNull($ticket->fresh()->disabled_at); + $this->assertFalse($ticket->fresh()->resolvedValidity()->isResolvable); + } + + public function test_update_and_date_creation_validate_their_own_payloads(): void { $tenant = $this->createTenant('acme'); Sanctum::actingAs($this->createAdminAppUser($tenant)); @@ -202,10 +286,6 @@ class AdminAppEventControllerTest extends TestCase $this->putJson('/api/v1/adminapp/tenant/event', [ 'title' => '', 'location' => '', - 'dates' => [ - ['date' => '09/10/2026', 'start_time' => '9am', 'end_time' => '18:00'], - ['date' => '09/10/2026', 'start_time' => '09:00', 'end_time' => '18:00'], - ], 'contact' => [ 'whatsapp_url' => 'not-a-url', 'instagram_url' => null, @@ -216,12 +296,15 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonValidationErrors([ 'title', 'location', - 'dates.0.date', - 'dates.0.start_time', - 'dates.1.date', 'contact.whatsapp_url', ]); + $this->postJson('/api/v1/adminapp/tenant/event-dates', [ + 'date' => '09/10/2026', + 'start_time' => '9am', + 'end_time' => '18:00', + ])->assertUnprocessable()->assertJsonValidationErrors(['date', 'start_time']); + $this->assertNull($tenant->fresh()->event_title); } @@ -273,11 +356,6 @@ class AdminAppEventControllerTest extends TestCase return [ 'title' => 'Festival Acme', 'location' => 'Predio Ferial, Rosario', - 'dates' => [[ - 'date' => '2026-10-09', - 'start_time' => '09:00', - 'end_time' => '18:30', - ]], 'contact' => [ 'whatsapp_url' => 'https://wa.me/5493415550101', 'instagram_url' => 'https://instagram.com/acme', @@ -286,6 +364,43 @@ class AdminAppEventControllerTest extends TestCase ]; } + /** @return array{date: string, start_time: string, end_time: string} */ + private function datePayload(): array + { + return [ + 'date' => '2026-10-09', + 'start_time' => '09:00', + 'end_time' => '18:30', + ]; + } + + private function createVariant(Tenant $tenant, ?int $eventDateId = null): Variant + { + $item = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'item-'.Str::uuid(), + 'nombre' => 'Entrada', + 'precio' => '1000.00', + ]); + + return Variant::query()->create([ + 'catalog_item_id' => $item->id, + 'inventory_id' => Inventory::query()->create()->id, + 'event_date_id' => $eventDateId, + ]); + } + + private function createTicket(Tenant $tenant, User $user, Variant $variant): Ticket + { + return Ticket::query()->create([ + 'tenant_code' => $tenant->codigo, + 'ticket' => (string) Str::uuid(), + 'user_id' => $user->id, + 'source_catalog_item_id' => $variant->catalog_item_id, + 'source_variant_id' => $variant->id, + ]); + } + private function createTenant(string $code): Tenant { $headerLogo = $this->createAttachment("{$code}-header"); -- 2.49.1 From 1880fc8147e6ad136e20f195c765e546669aebe5 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 11 Sep 2026 15:57:17 -0300 Subject: [PATCH 15/63] feat(event): suspend event dates instead of cancelling --- .../Controllers/AdminApp/EventController.php | 4 ++-- app/Domains/Event/Enums/EventDateStatus.php | 2 +- app/Domains/Event/Models/EventDate.php | 10 ++++----- .../Event/Resources/EventDateResource.php | 2 +- .../Services/EffectiveEventDateResolver.php | 2 +- app/Domains/Event/Services/EventService.php | 16 +++++++------- app/Domains/Event/routes/adminapp.php | 2 +- ...lled_at_to_suspended_at_on_event_dates.php | 22 +++++++++++++++++++ .../Event/AdminAppEventControllerTest.php | 17 +++++++------- tests/Unit/Event/EventModelsTest.php | 4 ++-- 10 files changed, 52 insertions(+), 29 deletions(-) create mode 100644 database/migrations/2026_09_11_020000_rename_cancelled_at_to_suspended_at_on_event_dates.php diff --git a/app/Domains/Event/Controllers/AdminApp/EventController.php b/app/Domains/Event/Controllers/AdminApp/EventController.php index eb626b5..24acf49 100644 --- a/app/Domains/Event/Controllers/AdminApp/EventController.php +++ b/app/Domains/Event/Controllers/AdminApp/EventController.php @@ -56,10 +56,10 @@ class EventController extends Controller ); } - public function cancelDate(Request $request, EventDate $eventDate): EventDateResource + public function suspendDate(Request $request, EventDate $eventDate): EventDateResource { return EventDateResource::make( - $this->eventService->cancelDateForTenant( + $this->eventService->suspendDateForTenant( $request->user()->tenant()->firstOrFail(), $eventDate, ) diff --git a/app/Domains/Event/Enums/EventDateStatus.php b/app/Domains/Event/Enums/EventDateStatus.php index 591b7c2..c06578b 100644 --- a/app/Domains/Event/Enums/EventDateStatus.php +++ b/app/Domains/Event/Enums/EventDateStatus.php @@ -5,7 +5,7 @@ namespace App\Domains\Event\Enums; enum EventDateStatus: string { case Rescheduled = 'rescheduled'; - case Cancelled = 'cancelled'; + case Suspended = 'suspended'; case Scheduled = 'scheduled'; case InProgress = 'in_progress'; case Completed = 'completed'; diff --git a/app/Domains/Event/Models/EventDate.php b/app/Domains/Event/Models/EventDate.php index 88b99de..2dc3309 100644 --- a/app/Domains/Event/Models/EventDate.php +++ b/app/Domains/Event/Models/EventDate.php @@ -23,7 +23,7 @@ use Illuminate\Support\Carbon; 'time_start', 'time_end', 'rescheduled_to_event_date_id', - 'cancelled_at', + 'suspended_at', ])] class EventDate extends Model { @@ -58,7 +58,7 @@ class EventDate extends Model 'date' => 'date:Y-m-d', 'validity_time_id' => 'integer', 'rescheduled_to_event_date_id' => 'integer', - 'cancelled_at' => 'datetime', + 'suspended_at' => 'datetime', ]; } @@ -119,8 +119,8 @@ class EventDate extends Model return EventDateStatus::Rescheduled; } - if ($this->cancelled_at !== null) { - return EventDateStatus::Cancelled; + if ($this->suspended_at !== null) { + return EventDateStatus::Suspended; } if (now()->lt($this->startsAt())) { @@ -146,7 +146,7 @@ class EventDate extends Model 'event_date_text' => app(EventDateTextFormatter::class)->format( $tenant->eventDates() ->whereNull('rescheduled_to_event_date_id') - ->whereNull('cancelled_at') + ->whereNull('suspended_at') ->pluck('date') ), ]); diff --git a/app/Domains/Event/Resources/EventDateResource.php b/app/Domains/Event/Resources/EventDateResource.php index 4268940..0c15cca 100644 --- a/app/Domains/Event/Resources/EventDateResource.php +++ b/app/Domains/Event/Resources/EventDateResource.php @@ -22,7 +22,7 @@ class EventDateResource extends JsonResource 'end_time' => substr($this->time_end, 0, 5), 'status' => $this->status->value, 'rescheduled_to_event_date_id' => $this->rescheduled_to_event_date_id, - 'cancelled_at' => $this->cancelled_at?->toISOString(), + 'suspended_at' => $this->suspended_at?->toISOString(), ]; } } diff --git a/app/Domains/Event/Services/EffectiveEventDateResolver.php b/app/Domains/Event/Services/EffectiveEventDateResolver.php index 4606da0..7a50244 100644 --- a/app/Domains/Event/Services/EffectiveEventDateResolver.php +++ b/app/Domains/Event/Services/EffectiveEventDateResolver.php @@ -23,7 +23,7 @@ class EffectiveEventDateResolver $visited[$identity] = true; if ($current->rescheduled_to_event_date_id === null) { - return $current->cancelled_at === null ? $current : null; + return $current->suspended_at === null ? $current : null; } $current->loadMissing('rescheduledTo'); diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index fc7bb40..7561e59 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -68,9 +68,9 @@ class EventService return DB::transaction(function () use ($tenant, $eventDate, $data): EventDate { $source = $this->lockedDateForTenant($tenant, $eventDate); - if ($source->cancelled_at !== null) { + if ($source->suspended_at !== null) { throw ValidationException::withMessages([ - 'event_date' => ['No se puede reprogramar una fecha cancelada.'], + 'event_date' => ['No se puede reprogramar una fecha suspendida.'], ]); } @@ -111,22 +111,22 @@ class EventService }); } - public function cancelDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate + public function suspendDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate { return DB::transaction(function () use ($tenant, $eventDate): EventDate { $date = $this->lockedDateForTenant($tenant, $eventDate); if ($date->rescheduled_to_event_date_id !== null) { throw ValidationException::withMessages([ - 'event_date' => ['No se puede cancelar una fecha que ya fue reprogramada.'], + 'event_date' => ['No se puede suspender una fecha que ya fue reprogramada.'], ]); } - if ($date->cancelled_at !== null) { + if ($date->suspended_at !== null) { return $date->load('validityTime'); } - $date->update(['cancelled_at' => now()]); + $date->update(['suspended_at' => now()]); $this->disableTicketsWithoutUsableDates($tenant, $date); return $date->fresh('validityTime'); @@ -166,9 +166,9 @@ class EventService return $current->is($expected); } - private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $cancelledDate): void + private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void { - $affectedDateIds = collect([$cancelledDate->getKey()]); + $affectedDateIds = collect([$suspendedDate->getKey()]); $frontier = $affectedDateIds; while ($frontier->isNotEmpty()) { diff --git a/app/Domains/Event/routes/adminapp.php b/app/Domains/Event/routes/adminapp.php index c437381..529e0b6 100644 --- a/app/Domains/Event/routes/adminapp.php +++ b/app/Domains/Event/routes/adminapp.php @@ -10,5 +10,5 @@ Route::prefix('v1/adminapp/tenant') Route::put('event', [EventController::class, 'update']); Route::post('event-dates', [EventController::class, 'storeDate']); Route::post('event-dates/{eventDate}/reschedule', [EventController::class, 'rescheduleDate']); - Route::post('event-dates/{eventDate}/cancel', [EventController::class, 'cancelDate']); + Route::post('event-dates/{eventDate}/suspend', [EventController::class, 'suspendDate']); }); diff --git a/database/migrations/2026_09_11_020000_rename_cancelled_at_to_suspended_at_on_event_dates.php b/database/migrations/2026_09_11_020000_rename_cancelled_at_to_suspended_at_on_event_dates.php new file mode 100644 index 0000000..feec1d2 --- /dev/null +++ b/database/migrations/2026_09_11_020000_rename_cancelled_at_to_suspended_at_on_event_dates.php @@ -0,0 +1,22 @@ +renameColumn('cancelled_at', 'suspended_at'); + }); + } + + public function down(): void + { + Schema::table('event_dates', function (Blueprint $table): void { + $table->renameColumn('suspended_at', 'cancelled_at'); + }); + } +}; diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 53ca2aa..e9d78bc 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -216,11 +216,11 @@ class AdminAppEventControllerTest extends TestCase ); } - public function test_cancelling_disables_only_tickets_without_another_usable_date(): void + public function test_suspending_disables_only_tickets_without_another_usable_date(): void { $tenant = $this->createTenant('acme'); $admin = $this->createAdminAppUser($tenant); - $cancelledDate = $tenant->eventDates()->create([ + $suspendedDate = $tenant->eventDates()->create([ 'date' => '2027-10-09', 'time_start' => '09:00', 'time_end' => '18:30', @@ -230,16 +230,17 @@ class AdminAppEventControllerTest extends TestCase 'time_start' => '09:00', 'time_end' => '18:30', ]); - $singleDateVariant = $this->createVariant($tenant, $cancelledDate->id); + $singleDateVariant = $this->createVariant($tenant, $suspendedDate->id); $multipleDateVariant = $this->createVariant($tenant); - $multipleDateVariant->eventDates()->sync([$cancelledDate->id, $otherDate->id]); + $multipleDateVariant->eventDates()->sync([$suspendedDate->id, $otherDate->id]); $singleDateTicket = $this->createTicket($tenant, $admin, $singleDateVariant); $multipleDateTicket = $this->createTicket($tenant, $admin, $multipleDateVariant); Sanctum::actingAs($admin); - $this->postJson("/api/v1/adminapp/tenant/event-dates/{$cancelledDate->id}/cancel") + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$suspendedDate->id}/suspend") ->assertOk() - ->assertJsonPath('data.status', 'cancelled'); + ->assertJsonPath('data.status', 'suspended') + ->assertJsonPath('data.suspended_at', fn ($value) => is_string($value)); $this->assertNotNull($singleDateTicket->fresh()->disabled_at); $this->assertNull($multipleDateTicket->fresh()->disabled_at); @@ -249,7 +250,7 @@ class AdminAppEventControllerTest extends TestCase ); } - public function test_cancelling_a_reschedule_destination_disables_tickets_from_predecessor_dates(): void + public function test_suspending_a_reschedule_destination_disables_tickets_from_predecessor_dates(): void { $tenant = $this->createTenant('acme'); $admin = $this->createAdminAppUser($tenant); @@ -271,7 +272,7 @@ class AdminAppEventControllerTest extends TestCase ); Sanctum::actingAs($admin); - $this->postJson("/api/v1/adminapp/tenant/event-dates/{$destination->id}/cancel") + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$destination->id}/suspend") ->assertOk(); $this->assertNotNull($ticket->fresh()->disabled_at); diff --git a/tests/Unit/Event/EventModelsTest.php b/tests/Unit/Event/EventModelsTest.php index 459aa8e..d05a04b 100644 --- a/tests/Unit/Event/EventModelsTest.php +++ b/tests/Unit/Event/EventModelsTest.php @@ -55,8 +55,8 @@ class EventModelsTest extends TestCase $eventDate->date = '2026-10-08'; $this->assertSame(EventDateStatus::Completed, $eventDate->status); - $eventDate->cancelled_at = now(); - $this->assertSame(EventDateStatus::Cancelled, $eventDate->status); + $eventDate->suspended_at = now(); + $this->assertSame(EventDateStatus::Suspended, $eventDate->status); $eventDate->rescheduled_to_event_date_id = 123; $this->assertSame(EventDateStatus::Rescheduled, $eventDate->status); -- 2.49.1 From b4da6e3747da0d2987afd49b656220c9fb8c792d Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 11 Sep 2026 16:21:37 -0300 Subject: [PATCH 16/63] feat(event): omit rescheduled and suspended dates from tenant resource --- .../Tenant/Resources/TenantResource.php | 17 +++++++++----- .../Event/AdminAppEventControllerTest.php | 22 +++++++++++++++++-- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index f268f7a..b005f40 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -5,6 +5,7 @@ namespace App\Domains\Tenant\Resources; use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\AttachmentCrop; use App\Domains\Catalog\Models\Category; +use App\Domains\Event\Models\EventDate; use App\Domains\Menu\Models\Menu; use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; @@ -50,12 +51,16 @@ class TenantResource extends JsonResource : [ 'title' => $this->event_title, 'location' => $this->event_location, - 'dates' => $this->eventDates->map(fn ($eventDate): array => [ - 'id' => $eventDate->id, - 'date' => $eventDate->date->format('Y-m-d'), - 'time_start' => $eventDate->time_start, - 'time_end' => $eventDate->time_end, - ])->values(), + 'dates' => $this->eventDates + ->filter(fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null + && $eventDate->suspended_at === null + ) + ->map(fn (EventDate $eventDate): array => [ + 'id' => $eventDate->id, + 'date' => $eventDate->date->format('Y-m-d'), + 'time_start' => $eventDate->time_start, + 'time_end' => $eventDate->time_end, + ])->values(), ]), 'extras' => $this->whenLoaded( 'websiteExtras', diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index e9d78bc..5cfe5b0 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -172,7 +172,7 @@ class AdminAppEventControllerTest extends TestCase public function test_rescheduling_reuses_an_existing_date_and_tickets_resolve_its_validity(): void { - $tenant = $this->createTenant('acme'); + $tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme'); $admin = $this->createAdminAppUser($tenant); $original = $tenant->eventDates()->create([ 'date' => '2027-10-09', @@ -196,6 +196,12 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonPath('data.rescheduled_to_event_date_id', $destination->id); $this->assertDatabaseCount('event_dates', 2); + $this->assertSame('20 de Octubre 2027', $tenant->fresh()->event_date_text); + $this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F') + ->assertOk() + ->assertJsonCount(1, 'data.event.dates') + ->assertJsonPath('data.event.dates.0.id', $destination->id) + ->assertJsonPath('data.event_date_text', '20 de Octubre 2027'); $this->assertSame( '2027-10-20 11:00:00', $ticket->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), @@ -210,6 +216,12 @@ class AdminAppEventControllerTest extends TestCase ])->assertOk()->assertJsonPath('data.status', 'rescheduled'); $this->assertDatabaseCount('event_dates', 3); + $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') + ->assertJsonPath('data.event_date_text', '25 de Octubre 2027'); $this->assertSame( '2027-10-25 11:00:00', $ticket->fresh()->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), @@ -218,7 +230,7 @@ class AdminAppEventControllerTest extends TestCase public function test_suspending_disables_only_tickets_without_another_usable_date(): void { - $tenant = $this->createTenant('acme'); + $tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme'); $admin = $this->createAdminAppUser($tenant); $suspendedDate = $tenant->eventDates()->create([ 'date' => '2027-10-09', @@ -244,6 +256,12 @@ class AdminAppEventControllerTest extends TestCase $this->assertNotNull($singleDateTicket->fresh()->disabled_at); $this->assertNull($multipleDateTicket->fresh()->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) + ->assertJsonPath('data.event_date_text', '10 de Octubre 2027'); $this->assertSame( '2027-10-10 09:00:00', $multipleDateTicket->fresh()->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), -- 2.49.1 From c0c19c9c0196b295a52e753a242e298c237f524d Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 11 Sep 2026 16:22:27 -0300 Subject: [PATCH 17/63] feat(tenant): add master toggle for ticket refunds --- app/Domains/Tenant/Models/Tenant.php | 9 +++- .../Tenant/Requests/StoreTenantRequest.php | 1 + .../Tenant/Requests/UpdateTenantRequest.php | 1 + .../Tenant/Resources/TenantResource.php | 1 + .../Ticket/Services/AdminAppTicketService.php | 8 ++-- ..._refund_master_toggle_to_tenants_table.php | 34 +++++++++++++++ .../Tenant/TenantRefundConfigurationTest.php | 17 ++++++++ .../Ticket/AdminAppTicketControllerTest.php | 41 ++++++++++++++++--- tests/Unit/Ticket/TicketTest.php | 1 + 9 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 database/migrations/2026_09_11_010000_add_ticket_refund_master_toggle_to_tenants_table.php diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index 7c6938f..e575def 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -52,6 +52,7 @@ use Illuminate\Support\Facades\Schema; 'checkout_editing_policy', 'display_cart_item_images', 'scanner_category_validation_enabled', + 'allow_ticket_refund', 'allow_ticket_total_refund', 'allow_ticket_partial_refund', 'ticket_partial_refund_percentage', @@ -75,6 +76,7 @@ class Tenant extends Model 'checkout_editing_policy' => CartEditingPolicy::Disabled->value, 'display_cart_item_images' => true, 'scanner_category_validation_enabled' => true, + 'allow_ticket_refund' => false, 'allow_ticket_total_refund' => false, 'allow_ticket_partial_refund' => false, 'ticket_partial_refund_percentage' => 0, @@ -114,12 +116,14 @@ class Tenant extends Model public function allow_refund(): bool { - return (bool) $this->allow_ticket_total_refund || $this->allow_partial_refund(); + return (bool) $this->allow_ticket_refund + && ((bool) $this->allow_ticket_total_refund || $this->allow_partial_refund()); } public function allow_partial_refund(): bool { - return (bool) $this->allow_ticket_partial_refund + return (bool) $this->allow_ticket_refund + && (bool) $this->allow_ticket_partial_refund && $this->ticket_partial_refund_percentage !== null && (float) $this->ticket_partial_refund_percentage > 0; } @@ -157,6 +161,7 @@ class Tenant extends Model 'checkout_editing_policy' => CartEditingPolicy::class, 'display_cart_item_images' => 'boolean', 'scanner_category_validation_enabled' => 'boolean', + 'allow_ticket_refund' => 'boolean', 'allow_ticket_total_refund' => 'boolean', 'allow_ticket_partial_refund' => 'boolean', 'ticket_partial_refund_percentage' => 'decimal:2', diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index c7bfef5..2dc435b 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -117,6 +117,7 @@ class StoreTenantRequest extends FormRequest ], 'display_cart_item_images' => ['sometimes', 'boolean'], 'scanner_category_validation_enabled' => ['sometimes', 'boolean'], + 'allow_ticket_refund' => ['sometimes', 'boolean'], 'allow_ticket_total_refund' => ['sometimes', 'boolean'], 'allow_ticket_partial_refund' => ['sometimes', 'boolean'], 'ticket_partial_refund_percentage' => [ diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index 8c11cef..4acc110 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -138,6 +138,7 @@ class UpdateTenantRequest extends FormRequest ], 'display_cart_item_images' => ['sometimes', 'boolean'], 'scanner_category_validation_enabled' => ['sometimes', 'boolean'], + 'allow_ticket_refund' => ['sometimes', 'boolean'], 'allow_ticket_total_refund' => ['sometimes', 'boolean'], 'allow_ticket_partial_refund' => ['sometimes', 'boolean'], 'ticket_partial_refund_percentage' => [ diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index b005f40..1b94e48 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -87,6 +87,7 @@ class TenantResource extends JsonResource 'checkout_editing_policy' => CartEditingPolicyResource::make($this->checkout_editing_policy), 'display_cart_item_images' => $this->display_cart_item_images, 'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled, + 'allow_ticket_refund' => $this->allow_ticket_refund, 'allow_ticket_total_refund' => $this->allow_ticket_total_refund, 'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund, 'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage, diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 3717500..46a1cde 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -141,12 +141,12 @@ class AdminAppTicketService $remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2)); $total = null; - if ($tenant->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) { + if ($tenant->allow_refund() && $tenant->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) { $total = number_format($unitPrice, 2, '.', ''); } $partial = null; - if ($tenant->allow_partial_refund()) { + if ($tenant->allow_refund() && $tenant->allow_partial_refund()) { $partialAmount = $this->refundAmount($purchaseItem, $tenant, 'partial'); if ($partialAmount <= $remainingItemAmount) { $partial = number_format($partialAmount, 2, '.', ''); @@ -214,8 +214,8 @@ class AdminAppTicketService private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void { $isAllowed = match ($refundType) { - 'partial' => $tenant->allow_partial_refund(), - 'total' => (bool) $tenant->allow_ticket_total_refund, + 'partial' => $tenant->allow_refund() && $tenant->allow_partial_refund(), + 'total' => $tenant->allow_refund() && (bool) $tenant->allow_ticket_total_refund, }; if (! $isAllowed) { diff --git a/database/migrations/2026_09_11_010000_add_ticket_refund_master_toggle_to_tenants_table.php b/database/migrations/2026_09_11_010000_add_ticket_refund_master_toggle_to_tenants_table.php new file mode 100644 index 0000000..e831d5e --- /dev/null +++ b/database/migrations/2026_09_11_010000_add_ticket_refund_master_toggle_to_tenants_table.php @@ -0,0 +1,34 @@ +boolean('allow_ticket_refund') + ->default(false) + ->after('scanner_category_validation_enabled'); + }); + + DB::table('tenants') + ->where('allow_ticket_total_refund', true) + ->orWhere(function ($query): void { + $query + ->where('allow_ticket_partial_refund', true) + ->where('ticket_partial_refund_percentage', '>', 0); + }) + ->update(['allow_ticket_refund' => true]); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn('allow_ticket_refund'); + }); + } +}; diff --git a/tests/Feature/Tenant/TenantRefundConfigurationTest.php b/tests/Feature/Tenant/TenantRefundConfigurationTest.php index b50304a..6091bbb 100644 --- a/tests/Feature/Tenant/TenantRefundConfigurationTest.php +++ b/tests/Feature/Tenant/TenantRefundConfigurationTest.php @@ -18,12 +18,14 @@ class TenantRefundConfigurationTest extends TestCase { $tenant = $this->createTenant('refund-defaults'); + $this->assertFalse($tenant->allow_ticket_refund); $this->assertFalse($tenant->allow_ticket_total_refund); $this->assertFalse($tenant->allow_ticket_partial_refund); $this->assertSame('0.00', $tenant->ticket_partial_refund_percentage); $this->getJson("/api/tenants/{$tenant->codigo}") ->assertOk() + ->assertJsonPath('data.allow_ticket_refund', false) ->assertJsonPath('data.allow_ticket_total_refund', false) ->assertJsonPath('data.allow_ticket_partial_refund', false) ->assertJsonPath('data.ticket_partial_refund_percentage', '0.00'); @@ -34,17 +36,20 @@ class TenantRefundConfigurationTest extends TestCase $tenant = $this->createTenant('refund-update'); $this->putJson("/api/tenants/{$tenant->codigo}", [ + 'allow_ticket_refund' => true, 'allow_ticket_total_refund' => true, 'allow_ticket_partial_refund' => true, 'ticket_partial_refund_percentage' => 25.50, ]) ->assertOk() + ->assertJsonPath('data.allow_ticket_refund', true) ->assertJsonPath('data.allow_ticket_total_refund', true) ->assertJsonPath('data.allow_ticket_partial_refund', true) ->assertJsonPath('data.ticket_partial_refund_percentage', '25.50'); $this->assertDatabaseHas('tenants', [ 'id' => $tenant->id, + 'allow_ticket_refund' => true, 'allow_ticket_total_refund' => true, 'allow_ticket_partial_refund' => true, 'ticket_partial_refund_percentage' => 25.50, @@ -64,6 +69,7 @@ class TenantRefundConfigurationTest extends TestCase public function test_tenant_allow_refund_logic(): void { $tenant = new Tenant([ + 'allow_ticket_refund' => false, 'allow_ticket_total_refund' => false, 'allow_ticket_partial_refund' => false, 'ticket_partial_refund_percentage' => 0, @@ -83,6 +89,10 @@ class TenantRefundConfigurationTest extends TestCase // Partial refund enabled and percentage is set $tenant->ticket_partial_refund_percentage = 25.50; + $this->assertFalse($tenant->allow_refund()); + $this->assertFalse($tenant->allow_partial_refund()); + + $tenant->allow_ticket_refund = true; $this->assertTrue($tenant->allow_refund()); $this->assertTrue($tenant->allowRefund()); $this->assertTrue($tenant->allow_refund); @@ -101,6 +111,13 @@ class TenantRefundConfigurationTest extends TestCase $tenant->ticket_partial_refund_percentage = 50.00; $this->assertTrue($tenant->allow_refund()); $this->assertTrue($tenant->allow_partial_refund()); + + $tenant->allow_ticket_refund = false; + $this->assertFalse($tenant->allow_refund()); + $this->assertFalse($tenant->allow_partial_refund()); + $this->assertTrue($tenant->allow_ticket_total_refund); + $this->assertTrue($tenant->allow_ticket_partial_refund); + $this->assertSame('50.00', $tenant->ticket_partial_refund_percentage); } private function createTenant(string $code): Tenant diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index f7b5ee4..a933388 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -100,7 +100,10 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.can_refund', false); // Total refund allowed - $tenant->update(['allow_ticket_total_refund' => true]); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() ->assertJsonPath('data.0.allow_refund', true) @@ -108,6 +111,7 @@ class AdminAppTicketControllerTest extends TestCase // Partial refund allowed with percentage set $tenant->update([ + 'allow_ticket_refund' => true, 'allow_ticket_total_refund' => false, 'allow_ticket_partial_refund' => true, 'ticket_partial_refund_percentage' => 20.00, @@ -117,6 +121,17 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.allow_refund', true) ->assertJsonPath('data.0.can_refund', true); + // Master toggle disabled while preserving the partial refund preference + $tenant->update(['allow_ticket_refund' => false]); + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.allow_refund', false) + ->assertJsonPath('data.0.can_refund', false); + $this->assertTrue($tenant->fresh()->allow_ticket_partial_refund); + $this->assertSame('20.00', $tenant->fresh()->ticket_partial_refund_percentage); + + $tenant->update(['allow_ticket_refund' => true]); + // Partial refund enabled but percentage is 0 $tenant->update([ 'ticket_partial_refund_percentage' => 0, @@ -162,7 +177,10 @@ class AdminAppTicketControllerTest extends TestCase public function test_it_totally_refunds_a_ticket_when_the_tenant_allows_it(): void { $tenant = $this->createTenant('ticket-total-refund'); - $tenant->update(['allow_ticket_total_refund' => true]); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); $admin = $this->createAdminAppUser($tenant); $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); @@ -184,6 +202,7 @@ class AdminAppTicketControllerTest extends TestCase { $tenant = $this->createTenant('ticket-partial-refund'); $tenant->update([ + 'allow_ticket_refund' => true, 'allow_ticket_partial_refund' => true, 'ticket_partial_refund_percentage' => 25.50, ]); @@ -239,6 +258,7 @@ class AdminAppTicketControllerTest extends TestCase { $tenant = $this->createTenant('ticket-calc-both'); $tenant->update([ + 'allow_ticket_refund' => true, 'allow_ticket_total_refund' => true, 'allow_ticket_partial_refund' => true, 'ticket_partial_refund_percentage' => 30.00, @@ -258,6 +278,7 @@ class AdminAppTicketControllerTest extends TestCase { $tenant = $this->createTenant('ticket-calc-total'); $tenant->update([ + 'allow_ticket_refund' => true, 'allow_ticket_total_refund' => true, 'allow_ticket_partial_refund' => false, ]); @@ -276,6 +297,7 @@ class AdminAppTicketControllerTest extends TestCase { $tenant = $this->createTenant('ticket-calc-insufficient'); $tenant->update([ + 'allow_ticket_refund' => true, 'allow_ticket_total_refund' => true, 'allow_ticket_partial_refund' => true, 'ticket_partial_refund_percentage' => 50.00, @@ -299,7 +321,10 @@ class AdminAppTicketControllerTest extends TestCase public function test_it_fails_calculating_refund_if_ticket_is_not_active(): void { $tenant = $this->createTenant('ticket-calc-inactive'); - $tenant->update(['allow_ticket_total_refund' => true]); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); $admin = $this->createAdminAppUser($tenant); $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); @@ -316,8 +341,14 @@ class AdminAppTicketControllerTest extends TestCase { $tenantA = $this->createTenant('ticket-calc-a'); $tenantB = $this->createTenant('ticket-calc-b'); - $tenantA->update(['allow_ticket_total_refund' => true]); - $tenantB->update(['allow_ticket_total_refund' => true]); + $tenantA->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); + $tenantB->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + ]); $adminA = $this->createAdminAppUser($tenantA); $adminB = $this->createAdminAppUser($tenantB); diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php index d473104..dc0b663 100644 --- a/tests/Unit/Ticket/TicketTest.php +++ b/tests/Unit/Ticket/TicketTest.php @@ -67,6 +67,7 @@ class TicketTest extends TestCase public function test_it_exposes_admin_action_capabilities(): void { $tenant = new Tenant([ + 'allow_ticket_refund' => true, 'allow_ticket_total_refund' => true, ]); $active = (new Ticket)->setRelation('tenant', $tenant); -- 2.49.1 From 4bb4f526e47579b704e7ef439961d8566daad8da Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 11 Sep 2026 16:22:46 -0300 Subject: [PATCH 18/63] feat(event): support ticket refund configuration in event endpoints --- .../Event/Requests/UpdateEventRequest.php | 45 ++++++++++++++ app/Domains/Event/Resources/EventResource.php | 4 ++ app/Domains/Event/Services/EventService.php | 6 ++ .../Event/AdminAppEventControllerTest.php | 62 +++++++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/app/Domains/Event/Requests/UpdateEventRequest.php b/app/Domains/Event/Requests/UpdateEventRequest.php index adf383d..6867b03 100644 --- a/app/Domains/Event/Requests/UpdateEventRequest.php +++ b/app/Domains/Event/Requests/UpdateEventRequest.php @@ -33,6 +33,16 @@ class UpdateEventRequest extends FormRequest 'contact.whatsapp_url' => ['nullable', 'url', 'max:2048'], 'contact.instagram_url' => ['nullable', 'url', 'max:2048'], 'contact.facebook_url' => ['nullable', 'url', 'max:2048'], + 'allow_ticket_refund' => ['sometimes', 'boolean'], + 'allow_ticket_total_refund' => ['sometimes', 'boolean'], + 'allow_ticket_partial_refund' => ['sometimes', 'boolean'], + 'ticket_partial_refund_percentage' => [ + 'sometimes', + 'numeric', + 'decimal:0,2', + 'min:0', + 'max:99.99', + ], ]; } @@ -49,6 +59,41 @@ class UpdateEventRequest extends FormRequest 'The social media field is required.' ); } + + if (! array_key_exists('allow_ticket_refund', $input)) { + return; + } + + foreach ([ + 'allow_ticket_total_refund', + 'allow_ticket_partial_refund', + 'ticket_partial_refund_percentage', + ] as $field) { + if (! array_key_exists($field, $input)) { + $validator->errors()->add($field, 'El campo es obligatorio.'); + } + } + + $totalEnabled = $this->boolean('allow_ticket_total_refund'); + $partialEnabled = $this->boolean('allow_ticket_partial_refund'); + + $refundEnabled = $this->boolean('allow_ticket_refund'); + + if ($refundEnabled && ! $totalEnabled && ! $partialEnabled) { + $validator->errors()->add( + 'allow_ticket_refund', + 'Seleccioná al menos un tipo de reembolso.' + ); + } + + if ($refundEnabled + && $partialEnabled + && (float) ($input['ticket_partial_refund_percentage'] ?? 0) <= 0) { + $validator->errors()->add( + 'ticket_partial_refund_percentage', + 'Ingresá un porcentaje mayor que cero para el reembolso parcial.' + ); + } }, ]; } diff --git a/app/Domains/Event/Resources/EventResource.php b/app/Domains/Event/Resources/EventResource.php index feed495..207cf42 100644 --- a/app/Domains/Event/Resources/EventResource.php +++ b/app/Domains/Event/Resources/EventResource.php @@ -18,6 +18,10 @@ class EventResource extends JsonResource 'id' => $this->id, 'title' => $this->event_title, 'location' => $this->event_location, + 'allow_ticket_refund' => $this->allow_ticket_refund, + 'allow_ticket_total_refund' => $this->allow_ticket_total_refund, + 'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund, + 'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage, 'dates' => EventDateResource::collection($this->eventDates), 'social_media' => $this->socialMedia->map(fn ($item): array => [ 'code' => $item->code, diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index 7561e59..c3b19c8 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -34,6 +34,12 @@ class EventService $tenant->update([ 'event_title' => $data['title'], 'event_location' => $data['location'], + ...array_intersect_key($data, array_flip([ + 'allow_ticket_refund', + 'allow_ticket_total_refund', + 'allow_ticket_partial_refund', + 'ticket_partial_refund_percentage', + ])), ]); if (array_key_exists('social_media', $data)) { diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 5cfe5b0..ebf2591 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -51,6 +51,10 @@ class AdminAppEventControllerTest extends TestCase ->assertOk() ->assertJsonPath('data.title', 'Festival Acme') ->assertJsonPath('data.location', 'Predio Ferial, Rosario') + ->assertJsonPath('data.allow_ticket_refund', true) + ->assertJsonPath('data.allow_ticket_total_refund', true) + ->assertJsonPath('data.allow_ticket_partial_refund', true) + ->assertJsonPath('data.ticket_partial_refund_percentage', '25.50') ->assertJsonCount(0, 'data.dates') ->assertJsonPath('data.contact.whatsapp_url', 'https://wa.me/5493415550101') ->assertJsonPath('data.contact.instagram_url', 'https://instagram.com/acme') @@ -62,6 +66,10 @@ class AdminAppEventControllerTest extends TestCase 'event_title' => 'Festival Acme', 'event_location' => 'Predio Ferial, Rosario', 'event_date_text' => null, + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.50, ]); $this->assertDatabaseCount('event_dates', 0); $this->assertDatabaseHas('tenant_social_media', [ @@ -71,6 +79,56 @@ class AdminAppEventControllerTest extends TestCase ]); } + public function test_disabling_refunds_preserves_the_configured_types_and_percentage(): void + { + $tenant = $this->createTenant('acme'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 35.50, + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $payload = $this->eventPayload(); + $payload['allow_ticket_refund'] = false; + $payload['ticket_partial_refund_percentage'] = 35.50; + + $this->putJson('/api/v1/adminapp/tenant/event', $payload) + ->assertOk() + ->assertJsonPath('data.allow_ticket_refund', false) + ->assertJsonPath('data.allow_ticket_total_refund', true) + ->assertJsonPath('data.allow_ticket_partial_refund', true) + ->assertJsonPath('data.ticket_partial_refund_percentage', '35.50'); + + $tenant->refresh(); + $this->assertFalse($tenant->allow_refund()); + $this->assertTrue($tenant->allow_ticket_total_refund); + $this->assertTrue($tenant->allow_ticket_partial_refund); + $this->assertSame('35.50', $tenant->ticket_partial_refund_percentage); + } + + public function test_enabled_refunds_require_a_type_and_a_valid_partial_percentage(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $payload = $this->eventPayload(); + $payload['allow_ticket_total_refund'] = false; + $payload['allow_ticket_partial_refund'] = false; + + $this->putJson('/api/v1/adminapp/tenant/event', $payload) + ->assertUnprocessable() + ->assertJsonValidationErrors('allow_ticket_refund'); + + $payload['allow_ticket_partial_refund'] = true; + $payload['ticket_partial_refund_percentage'] = 0; + + $this->putJson('/api/v1/adminapp/tenant/event', $payload) + ->assertUnprocessable() + ->assertJsonValidationErrors('ticket_partial_refund_percentage'); + } + public function test_an_adminapp_user_can_read_only_its_tenant_active_event(): void { $tenant = $this->createTenant('acme'); @@ -375,6 +433,10 @@ class AdminAppEventControllerTest extends TestCase return [ 'title' => 'Festival Acme', 'location' => 'Predio Ferial, Rosario', + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.50, 'contact' => [ 'whatsapp_url' => 'https://wa.me/5493415550101', 'instagram_url' => 'https://instagram.com/acme', -- 2.49.1 From 4f7ede10723dca04a527b0c91456d2f4f6458f7c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 11 Sep 2026 16:23:23 -0300 Subject: [PATCH 19/63] feat(purchase,sale,ticket): expose refunded total summary in admin sales and tickets --- .../Services/PurchaseRefundSummaryService.php | 22 ++++++++++ .../Controllers/AdminApp/SaleController.php | 1 + .../Sale/Services/AdminAppSaleService.php | 7 +++ .../AdminApp/AdminAppTicketCollection.php | 6 ++- .../Ticket/Services/AdminAppTicketResult.php | 1 + .../Ticket/Services/AdminAppTicketService.php | 3 ++ .../Sale/AdminAppSaleControllerTest.php | 5 ++- .../Ticket/AdminAppTicketControllerTest.php | 43 ++++++++++++++++++- 8 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 app/Domains/Purchase/Services/PurchaseRefundSummaryService.php diff --git a/app/Domains/Purchase/Services/PurchaseRefundSummaryService.php b/app/Domains/Purchase/Services/PurchaseRefundSummaryService.php new file mode 100644 index 0000000..9546810 --- /dev/null +++ b/app/Domains/Purchase/Services/PurchaseRefundSummaryService.php @@ -0,0 +1,22 @@ +whereHas( + 'purchase', + fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo) + ) + ->sum('refunded_amount'); + + return number_format((float) $total, 2, '.', ''); + } +} diff --git a/app/Domains/Sale/Controllers/AdminApp/SaleController.php b/app/Domains/Sale/Controllers/AdminApp/SaleController.php index 383f700..71fe5f6 100644 --- a/app/Domains/Sale/Controllers/AdminApp/SaleController.php +++ b/app/Domains/Sale/Controllers/AdminApp/SaleController.php @@ -35,6 +35,7 @@ class SaleController extends Controller $this->saleService->sales($tenant, $request->validated()) )->additional([ 'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant), + 'refunded_total' => $this->saleService->refundedTotal($tenant), ]); } diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index eecd5fc..d329a96 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -5,6 +5,7 @@ namespace App\Domains\Sale\Services; use App\Domains\Logging\Models\ValueChange; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; +use App\Domains\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Services\TicketPresentationResolver; @@ -17,6 +18,7 @@ class AdminAppSaleService { public function __construct( protected CheckoutService $checkoutService, + protected PurchaseRefundSummaryService $refundSummaryService, ) {} public function confirmedSalesTotal(Tenant $tenant): string @@ -29,6 +31,11 @@ class AdminAppSaleService return number_format((float) $total, 2, '.', ''); } + public function refundedTotal(Tenant $tenant): string + { + return $this->refundSummaryService->totalForTenant($tenant); + } + /** * @param array{ * q?: string|null, diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php index 3af312c..3b7c0e6 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php @@ -15,20 +15,24 @@ class AdminAppTicketCollection extends ResourceCollection private readonly int $totalTickets; + private readonly string $refundedTotal; + public function __construct(AdminAppTicketResult $result) { parent::__construct($result->tickets); $this->scannedTickets = $result->scannedTickets; $this->totalTickets = $result->totalTickets; + $this->refundedTotal = $result->refundedTotal; } - /** @return array{scanned_tickets: int, total_tickets: int} */ + /** @return array{scanned_tickets: int, total_tickets: int, refunded_total: string} */ public function with(Request $request): array { return [ 'scanned_tickets' => $this->scannedTickets, 'total_tickets' => $this->totalTickets, + 'refunded_total' => $this->refundedTotal, ]; } } diff --git a/app/Domains/Ticket/Services/AdminAppTicketResult.php b/app/Domains/Ticket/Services/AdminAppTicketResult.php index e9f65f4..bce92bc 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketResult.php +++ b/app/Domains/Ticket/Services/AdminAppTicketResult.php @@ -12,5 +12,6 @@ final readonly class AdminAppTicketResult public LengthAwarePaginator $tickets, public int $scannedTickets, public int $totalTickets, + public string $refundedTotal, ) {} } diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 46a1cde..64eb532 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -4,6 +4,7 @@ namespace App\Domains\Ticket\Services; use App\Domains\Auth\Models\User; use App\Domains\Purchase\Models\PurchaseItem; +use App\Domains\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Builder; @@ -27,6 +28,7 @@ class AdminAppTicketService public function __construct( private readonly AdminAppTicketColumnService $columnService, private readonly AdminAppTicketRowService $rowService, + private readonly PurchaseRefundSummaryService $refundSummaryService, ) {} /** @@ -68,6 +70,7 @@ class AdminAppTicketService tickets: $tickets, scannedTickets: $scannedTickets, totalTickets: $totalTickets, + refundedTotal: $this->refundSummaryService->totalForTenant($tenant), ); } diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index cb74909..81b21e9 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -163,6 +163,7 @@ class AdminAppSaleControllerTest extends TestCase 'cantidad' => 3, 'precio_unitario' => '10000.00', 'total' => '30000.00', + 'refunded_amount' => '1250.00', ]); $pendingCart = Cart::query()->create([ @@ -203,6 +204,7 @@ class AdminAppSaleControllerTest extends TestCase 'cantidad' => 2, 'precio_unitario' => '10000.00', 'total' => '20000.00', + 'refunded_amount' => '2500.00', ]); $supersededPurchase = Purchase::query()->create([ @@ -237,7 +239,8 @@ class AdminAppSaleControllerTest extends TestCase ->assertJsonPath('data.2.status_label', 'Confirmado') ->assertJsonPath('data.3.id', $supersededPurchase->id) ->assertJsonPath('data.3.admin_status', Purchase::ADMIN_STATUS_CANCELLED) - ->assertJsonPath('data.3.status_label', 'Anulado'); + ->assertJsonPath('data.3.status_label', 'Anulado') + ->assertJsonPath('refunded_total', '3750.00'); $this->getJson('/api/v1/adminapp/tenant/sales?status='.Purchase::STATUS_SUPERSEDED) ->assertUnprocessable(); diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index a933388..1887e4e 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -541,17 +541,56 @@ class AdminAppTicketControllerTest extends TestCase $this->createTicket($tenant, $admin)->update(['disabled_at' => now()]); $this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'refund-summary-item', + 'nombre' => 'Entrada', + 'precio' => '1000.00', + ]); + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PAID, + 'total' => '1000.00', + ]); + PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => 'Entrada', + 'item_nombre' => 'Entrada', + 'cantidad' => 1, + 'precio_unitario' => '1000.00', + 'total' => '1000.00', + 'refunded_amount' => '250.00', + ]); + $otherPurchase = Purchase::query()->create([ + 'tenant_codigo' => $otherTenant->codigo, + 'status' => Purchase::STATUS_PAID, + 'total' => '2000.00', + ]); + PurchaseItem::query()->create([ + 'compra_id' => $otherPurchase->id, + 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => 'Otra entrada', + 'item_nombre' => 'Otra entrada', + 'cantidad' => 1, + 'precio_unitario' => '2000.00', + 'total' => '2000.00', + 'refunded_amount' => '2000.00', + ]); + $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') ->assertOk() ->assertJsonCount(0, 'data') ->assertJsonPath('scanned_tickets', 0) - ->assertJsonPath('total_tickets', 0); + ->assertJsonPath('total_tickets', 0) + ->assertJsonPath('refunded_total', '250.00'); $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() ->assertJsonCount(5, 'data') ->assertJsonPath('scanned_tickets', 1) - ->assertJsonPath('total_tickets', 2); + ->assertJsonPath('total_tickets', 2) + ->assertJsonPath('refunded_total', '250.00'); } public function test_it_returns_structured_variant_properties(): void -- 2.49.1 From 205d77bc0b0635f7a962d7f632aea45a38b77884 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 08:53:45 -0300 Subject: [PATCH 20/63] feat(event): dispatch date change notifications --- .../Event/Events/EventDateRescheduled.php | 22 +++++++ .../Event/Events/EventDateSuspended.php | 20 +++++++ .../AffectedEventDatePurchaseResolver.php | 57 +++++++++++++++++++ app/Domains/Event/Services/EventService.php | 40 ++++++++++++- .../Event/AdminAppEventControllerTest.php | 21 +++++++ 5 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 app/Domains/Event/Events/EventDateRescheduled.php create mode 100644 app/Domains/Event/Events/EventDateSuspended.php create mode 100644 app/Domains/Event/Services/AffectedEventDatePurchaseResolver.php diff --git a/app/Domains/Event/Events/EventDateRescheduled.php b/app/Domains/Event/Events/EventDateRescheduled.php new file mode 100644 index 0000000..d0c5271 --- /dev/null +++ b/app/Domains/Event/Events/EventDateRescheduled.php @@ -0,0 +1,22 @@ +}> $purchaseTickets + */ + public function __construct( + public readonly string $tenantCode, + public readonly int $sourceEventDateId, + public readonly int $destinationEventDateId, + public readonly string $previousDate, + public readonly string $newDate, + public readonly array $purchaseTickets, + ) {} +} diff --git a/app/Domains/Event/Events/EventDateSuspended.php b/app/Domains/Event/Events/EventDateSuspended.php new file mode 100644 index 0000000..fe99365 --- /dev/null +++ b/app/Domains/Event/Events/EventDateSuspended.php @@ -0,0 +1,20 @@ +}> $purchaseTickets + */ + public function __construct( + public readonly string $tenantCode, + public readonly int $eventDateId, + public readonly string $date, + public readonly array $purchaseTickets, + ) {} +} diff --git a/app/Domains/Event/Services/AffectedEventDatePurchaseResolver.php b/app/Domains/Event/Services/AffectedEventDatePurchaseResolver.php new file mode 100644 index 0000000..23b7e04 --- /dev/null +++ b/app/Domains/Event/Services/AffectedEventDatePurchaseResolver.php @@ -0,0 +1,57 @@ +|list $eventDateIds + * @return list}> + */ + public function resolve(Tenant $tenant, Collection|array $eventDateIds): array + { + $eventDateIds = collect($eventDateIds)->map(fn (mixed $id): int => (int) $id)->unique()->values(); + + if ($eventDateIds->isEmpty()) { + return []; + } + + /** @var Collection $tickets */ + $tickets = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->whereHas('sourcePurchaseItem.purchase', fn (Builder $query) => $query + ->where('status', Purchase::STATUS_PAID)) + ->whereHas('sourceVariant', function (Builder $query) use ($eventDateIds): void { + $query->whereIn('event_date_id', $eventDateIds) + ->orWhereHas('eventDates', fn (Builder $eventDates) => $eventDates + ->whereIn('event_dates.id', $eventDateIds)); + }) + ->with([ + ...TicketValidityResolver::RELATIONS, + 'sourcePurchaseItem.purchase', + ]) + ->get() + ->filter(fn (Ticket $ticket): bool => $ticket->is_active()) + ->values(); + + return $tickets + ->groupBy(fn (Ticket $ticket): int => (int) $ticket->sourcePurchaseItem->purchase->getKey()) + ->map(function (Collection $purchaseTickets): array { + return [ + 'purchase_id' => (int) $purchaseTickets->first()->sourcePurchaseItem->purchase->getKey(), + 'ticket_ids' => $purchaseTickets->modelKeys(), + ]; + }) + ->values() + ->all(); + } +} diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index c3b19c8..1cafb91 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -3,9 +3,12 @@ namespace App\Domains\Event\Services; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Events\EventDateRescheduled; +use App\Domains\Event\Events\EventDateSuspended; use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -19,6 +22,7 @@ class EventService public function __construct( private readonly EffectiveEventDateResolver $effectiveEventDateResolver, + private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver, ) {} public function forTenant(Tenant $tenant): Tenant @@ -111,8 +115,21 @@ class EventService ]); } + $purchaseTickets = $this->affectedPurchaseResolver->resolve( + $tenant, + $this->affectedDateIds($tenant, $source), + ); $source->update(['rescheduled_to_event_date_id' => $destination->getKey()]); + EventDateRescheduled::dispatch( + $tenant->codigo, + $source->getKey(), + $destination->getKey(), + $source->date->format('d/m/Y'), + $destination->date->format('d/m/Y'), + $purchaseTickets, + ); + return $source->fresh(['validityTime', 'rescheduledTo.validityTime']); }); } @@ -132,9 +149,20 @@ class EventService return $date->load('validityTime'); } + $purchaseTickets = $this->affectedPurchaseResolver->resolve( + $tenant, + $this->affectedDateIds($tenant, $date), + ); $date->update(['suspended_at' => now()]); $this->disableTicketsWithoutUsableDates($tenant, $date); + EventDateSuspended::dispatch( + $tenant->codigo, + $date->getKey(), + $date->date->format('d/m/Y'), + $purchaseTickets, + ); + return $date->fresh('validityTime'); }); } @@ -172,9 +200,10 @@ class EventService return $current->is($expected); } - private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void + /** @return Collection */ + private function affectedDateIds(Tenant $tenant, EventDate $eventDate): Collection { - $affectedDateIds = collect([$suspendedDate->getKey()]); + $affectedDateIds = collect([$eventDate->getKey()]); $frontier = $affectedDateIds; while ($frontier->isNotEmpty()) { @@ -187,6 +216,13 @@ class EventService $frontier = $predecessors; } + return $affectedDateIds; + } + + private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void + { + $affectedDateIds = $this->affectedDateIds($tenant, $suspendedDate); + $variants = Variant::withTrashed() ->where(function ($query) use ($affectedDateIds): void { $query->whereIn('event_date_id', $affectedDateIds) diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index ebf2591..f4116fc 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -9,6 +9,8 @@ use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\CatalogItem; 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\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Enums\ValidityTimeType; @@ -16,6 +18,7 @@ use App\Domains\Ticket\Models\Ticket; use Database\Seeders\AuthorizationSeeder; use Database\Seeders\SocialMediaSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -230,6 +233,7 @@ class AdminAppEventControllerTest extends TestCase public function test_rescheduling_reuses_an_existing_date_and_tickets_resolve_its_validity(): void { + Event::fake([EventDateRescheduled::class]); $tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme'); $admin = $this->createAdminAppUser($tenant); $original = $tenant->eventDates()->create([ @@ -253,6 +257,15 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonPath('data.status', 'rescheduled') ->assertJsonPath('data.rescheduled_to_event_date_id', $destination->id); + Event::assertDispatched(EventDateRescheduled::class, function (EventDateRescheduled $event) use ($tenant, $original, $destination): bool { + return $event->tenantCode === $tenant->codigo + && $event->sourceEventDateId === $original->id + && $event->destinationEventDateId === $destination->id + && $event->previousDate === '09/10/2027' + && $event->newDate === '20/10/2027' + && $event->purchaseTickets === []; + }); + $this->assertDatabaseCount('event_dates', 2); $this->assertSame('20 de Octubre 2027', $tenant->fresh()->event_date_text); $this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F') @@ -288,6 +301,7 @@ class AdminAppEventControllerTest extends TestCase public function test_suspending_disables_only_tickets_without_another_usable_date(): void { + Event::fake([EventDateSuspended::class]); $tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme'); $admin = $this->createAdminAppUser($tenant); $suspendedDate = $tenant->eventDates()->create([ @@ -312,6 +326,13 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonPath('data.status', 'suspended') ->assertJsonPath('data.suspended_at', fn ($value) => is_string($value)); + Event::assertDispatched(EventDateSuspended::class, function (EventDateSuspended $event) use ($tenant, $suspendedDate): bool { + return $event->tenantCode === $tenant->codigo + && $event->eventDateId === $suspendedDate->id + && $event->date === '09/10/2027' + && $event->purchaseTickets === []; + }); + $this->assertNotNull($singleDateTicket->fresh()->disabled_at); $this->assertNull($multipleDateTicket->fresh()->disabled_at); $this->assertSame('10 de Octubre 2027', $tenant->fresh()->event_date_text); -- 2.49.1 From 756f4dad0a9fceb88767a81e21247cd0722acb3c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 08:53:51 -0300 Subject: [PATCH 21/63] feat(notification): email event date changes --- .../SendEventDateRescheduledEmails.php | 32 ++ .../SendEventDateSuspendedEmails.php | 30 ++ .../Models/EventDateNotificationDelivery.php | 28 ++ .../Services/NotificationMailService.php | 273 ++++++++++++++++++ app/Providers/AppServiceProvider.php | 6 + ...ent_date_notification_deliveries_table.php | 28 ++ .../event-date-rescheduled.blade.php | 14 + .../event-date-suspended.blade.php | 23 ++ .../NotificationMailServiceTest.php | 125 ++++++++ .../QueuedNotificationListenerTest.php | 42 +++ 10 files changed, 601 insertions(+) create mode 100644 app/Domains/Notification/Listeners/SendEventDateRescheduledEmails.php create mode 100644 app/Domains/Notification/Listeners/SendEventDateSuspendedEmails.php create mode 100644 app/Domains/Notification/Models/EventDateNotificationDelivery.php create mode 100644 database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php create mode 100644 resources/views/mail/notifications/event-date-rescheduled.blade.php create mode 100644 resources/views/mail/notifications/event-date-suspended.blade.php diff --git a/app/Domains/Notification/Listeners/SendEventDateRescheduledEmails.php b/app/Domains/Notification/Listeners/SendEventDateRescheduledEmails.php new file mode 100644 index 0000000..b119489 --- /dev/null +++ b/app/Domains/Notification/Listeners/SendEventDateRescheduledEmails.php @@ -0,0 +1,32 @@ + */ + public array $backoff = [30, 120, 300]; + + public function handle(EventDateRescheduled $event): void + { + app(NotificationMailService::class)->sendEventDateRescheduled( + $event->tenantCode, + $event->sourceEventDateId, + $event->destinationEventDateId, + $event->previousDate, + $event->newDate, + $event->purchaseTickets, + ); + } +} diff --git a/app/Domains/Notification/Listeners/SendEventDateSuspendedEmails.php b/app/Domains/Notification/Listeners/SendEventDateSuspendedEmails.php new file mode 100644 index 0000000..93af492 --- /dev/null +++ b/app/Domains/Notification/Listeners/SendEventDateSuspendedEmails.php @@ -0,0 +1,30 @@ + */ + public array $backoff = [30, 120, 300]; + + public function handle(EventDateSuspended $event): void + { + app(NotificationMailService::class)->sendEventDateSuspended( + $event->tenantCode, + $event->eventDateId, + $event->date, + $event->purchaseTickets, + ); + } +} diff --git a/app/Domains/Notification/Models/EventDateNotificationDelivery.php b/app/Domains/Notification/Models/EventDateNotificationDelivery.php new file mode 100644 index 0000000..33bd028 --- /dev/null +++ b/app/Domains/Notification/Models/EventDateNotificationDelivery.php @@ -0,0 +1,28 @@ + 'integer', + 'destination_event_date_id' => 'integer', + 'purchase_id' => 'integer', + 'sent_at' => 'datetime', + ]; + } +} diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index d053032..efbdabb 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -6,11 +6,13 @@ use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\User; use App\Domains\Integration\Services\MailService; use App\Domains\Notification\Events\PasswordResetRequested; +use App\Domains\Notification\Models\EventDateNotificationDelivery; use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Services\TicketPdfService; use App\Domains\Ticket\Services\TicketPresentationResolver; +use App\Domains\Ticket\Services\TicketValidityResolver; use Closure; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; @@ -184,6 +186,277 @@ class NotificationMailService }); } + /** + * @param list}> $purchaseTickets + */ + public function sendEventDateRescheduled( + string $tenantCode, + int $sourceEventDateId, + int $destinationEventDateId, + string $previousDate, + string $newDate, + array $purchaseTickets, + ): void { + foreach ($purchaseTickets as $purchaseTicketGroup) { + $purchaseId = $purchaseTicketGroup['purchase_id']; + $ticketIds = $purchaseTicketGroup['ticket_ids']; + $context = [ + 'tenant_code' => $tenantCode, + 'event_date_id' => $sourceEventDateId, + 'destination_event_date_id' => $destinationEventDateId, + 'purchase_id' => $purchaseId, + 'ticket_ids' => $ticketIds, + ]; + $purchase = $this->eventDateNotificationPurchase($tenantCode, $purchaseId); + + if ($purchase === null || $purchase->status !== Purchase::STATUS_PAID) { + $this->logSkipped('event_date_rescheduled', array_merge($context, [ + 'reason' => 'purchase_not_paid_or_not_found', + ])); + + continue; + } + + /** @var Collection $tickets */ + $tickets = $purchase->tickets() + ->where('tenant_code', $tenantCode) + ->whereKey($ticketIds) + ->with([ + ...TicketPresentationResolver::RELATIONS, + ...TicketValidityResolver::RELATIONS, + ]) + ->get() + ->filter(fn (Ticket $ticket): bool => $ticket->is_active()) + ->values(); + + if ($tickets->isEmpty()) { + $this->logSkipped('event_date_rescheduled', array_merge($context, [ + 'reason' => 'no_longer_active_tickets', + ])); + + continue; + } + + $recipient = $this->recipientFor($purchase); + if ($recipient === '') { + $this->logSkipped('event_date_rescheduled', array_merge($context, [ + 'reason' => 'missing_recipient', + ])); + + continue; + } + + $deliveryKey = "event-date-rescheduled:{$sourceEventDateId}:{$destinationEventDateId}:{$purchaseId}"; + if (! $this->claimDelivery( + $deliveryKey, + 'event_date_rescheduled', + $tenantCode, + $sourceEventDateId, + $destinationEventDateId, + $purchaseId, + )) { + $this->logSkipped('event_date_rescheduled', array_merge($context, [ + 'reason' => 'already_sent', + ])); + + continue; + } + + try { + $this->sendLogged('event_date_rescheduled', $context, function () use ( + $tenantCode, + $purchase, + $recipient, + $previousDate, + $newDate, + $tickets, + ): array { + $brand = $purchase->tenant->websiteType ?? $purchase->tenant; + + $this->mailService + ->forTenant($tenantCode) + ->send( + $recipient, + "Tu evento fue reprogramado - Compra #{$purchase->getKey()}", + view('mail.notifications.event-date-rescheduled', compact( + 'purchase', 'previousDate', 'newDate', 'tickets' + ))->render(), + $brand, + ); + + return ['ticket_count' => $tickets->count()]; + }); + $this->markDeliverySent($deliveryKey); + } catch (Throwable $exception) { + $this->releaseDelivery($deliveryKey); + + throw $exception; + } + } + } + + /** + * @param list}> $purchaseTickets + */ + public function sendEventDateSuspended( + string $tenantCode, + int $eventDateId, + string $date, + array $purchaseTickets, + ): void { + foreach ($purchaseTickets as $purchaseTicketGroup) { + $purchaseId = $purchaseTicketGroup['purchase_id']; + $ticketIds = $purchaseTicketGroup['ticket_ids']; + $context = [ + 'tenant_code' => $tenantCode, + 'event_date_id' => $eventDateId, + 'purchase_id' => $purchaseId, + 'ticket_ids' => $ticketIds, + ]; + $purchase = $this->eventDateNotificationPurchase($tenantCode, $purchaseId); + + if ($purchase === null || $purchase->status !== Purchase::STATUS_PAID) { + $this->logSkipped('event_date_suspended', array_merge($context, [ + 'reason' => 'purchase_not_paid_or_not_found', + ])); + + continue; + } + + /** @var Collection $tickets */ + $tickets = $purchase->tickets() + ->where('tenant_code', $tenantCode) + ->whereKey($ticketIds) + ->with([ + ...TicketPresentationResolver::RELATIONS, + ...TicketValidityResolver::RELATIONS, + ]) + ->get() + ->filter(fn (Ticket $ticket): bool => in_array($ticket->status, [ + Ticket::STATUS_ACTIVE, + Ticket::STATUS_DISABLED, + ], true)) + ->values(); + + if ($tickets->isEmpty()) { + $this->logSkipped('event_date_suspended', array_merge($context, [ + 'reason' => 'no_longer_relevant_tickets', + ])); + + continue; + } + + $recipient = $this->recipientFor($purchase); + if ($recipient === '') { + $this->logSkipped('event_date_suspended', array_merge($context, [ + 'reason' => 'missing_recipient', + ])); + + continue; + } + + $deliveryKey = "event-date-suspended:{$eventDateId}:{$purchaseId}"; + if (! $this->claimDelivery( + $deliveryKey, + 'event_date_suspended', + $tenantCode, + $eventDateId, + null, + $purchaseId, + )) { + $this->logSkipped('event_date_suspended', array_merge($context, [ + 'reason' => 'already_sent', + ])); + + continue; + } + + $disabledTickets = $tickets + ->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_DISABLED) + ->values(); + $activeTickets = $tickets + ->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_ACTIVE) + ->values(); + + try { + $this->sendLogged('event_date_suspended', $context, function () use ( + $tenantCode, + $purchase, + $recipient, + $date, + $disabledTickets, + $activeTickets, + ): array { + $brand = $purchase->tenant->websiteType ?? $purchase->tenant; + + $this->mailService + ->forTenant($tenantCode) + ->send( + $recipient, + "Una fecha de tu evento fue suspendida - Compra #{$purchase->getKey()}", + view('mail.notifications.event-date-suspended', compact( + 'purchase', 'date', 'disabledTickets', 'activeTickets' + ))->render(), + $brand, + ); + + return [ + 'ticket_count' => $disabledTickets->count() + $activeTickets->count(), + 'disabled_ticket_ids' => $disabledTickets->modelKeys(), + 'active_ticket_ids' => $activeTickets->modelKeys(), + ]; + }); + $this->markDeliverySent($deliveryKey); + } catch (Throwable $exception) { + $this->releaseDelivery($deliveryKey); + + throw $exception; + } + } + } + + private function eventDateNotificationPurchase(string $tenantCode, int $purchaseId): ?Purchase + { + return Purchase::query() + ->where('tenant_codigo', $tenantCode) + ->with(['tenant.websiteType', 'user']) + ->find($purchaseId); + } + + private function claimDelivery( + string $deliveryKey, + string $type, + string $tenantCode, + int $eventDateId, + ?int $destinationEventDateId, + int $purchaseId, + ): bool { + return EventDateNotificationDelivery::query()->insertOrIgnore([ + 'notification_key' => $deliveryKey, + 'notification_type' => $type, + 'tenant_code' => $tenantCode, + 'event_date_id' => $eventDateId, + 'destination_event_date_id' => $destinationEventDateId, + 'purchase_id' => $purchaseId, + 'created_at' => now(), + 'updated_at' => now(), + ]) === 1; + } + + private function markDeliverySent(string $deliveryKey): void + { + EventDateNotificationDelivery::query() + ->where('notification_key', $deliveryKey) + ->update(['sent_at' => now()]); + } + + private function releaseDelivery(string $deliveryKey): void + { + EventDateNotificationDelivery::query() + ->where('notification_key', $deliveryKey) + ->delete(); + } + private function recipientFor(Purchase $purchase): string { return (string) ($purchase->email ?: $purchase->user?->email); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e687726..d528318 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,10 +2,14 @@ namespace App\Providers; +use App\Domains\Event\Events\EventDateRescheduled; +use App\Domains\Event\Events\EventDateSuspended; use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Policies\IntegrationPolicy; use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Notification\Events\UserRegistered; +use App\Domains\Notification\Listeners\SendEventDateRescheduledEmails; +use App\Domains\Notification\Listeners\SendEventDateSuspendedEmails; use App\Domains\Notification\Listeners\SendPasswordResetEmail; use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail; use App\Domains\Notification\Listeners\SendWelcomeEmail; @@ -40,6 +44,8 @@ class AppServiceProvider extends ServiceProvider ); Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class); Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class); + Event::listen(EventDateRescheduled::class, SendEventDateRescheduledEmails::class); + Event::listen(EventDateSuspended::class, SendEventDateSuspendedEmails::class); Event::listen(UserRegistered::class, SendWelcomeEmail::class); Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class); diff --git a/database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php b/database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php new file mode 100644 index 0000000..e8070ad --- /dev/null +++ b/database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('notification_key')->unique(); + $table->string('notification_type'); + $table->string('tenant_code'); + $table->unsignedBigInteger('event_date_id'); + $table->unsignedBigInteger('destination_event_date_id')->nullable(); + $table->unsignedBigInteger('purchase_id'); + $table->timestamp('sent_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_date_notification_deliveries'); + } +}; diff --git a/resources/views/mail/notifications/event-date-rescheduled.blade.php b/resources/views/mail/notifications/event-date-rescheduled.blade.php new file mode 100644 index 0000000..af33d18 --- /dev/null +++ b/resources/views/mail/notifications/event-date-rescheduled.blade.php @@ -0,0 +1,14 @@ +

Tu evento fue reprogramado

+

Te informamos que la fecha de tu evento cambió.

+

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

+

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

+

Tickets afectados

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

Compra #{{ $purchase->id }}

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

Actualización sobre tu evento

+

La fecha {{ $date }} fue suspendida.

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

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

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

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

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

Estos tickets conservan otras fechas disponibles:

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

Compra #{{ $purchase->id }}

diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index eef0c1c..f305d8d 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -270,6 +270,131 @@ class NotificationMailServiceTest extends TestCase }); } + public function test_it_sends_one_rescheduling_email_per_purchase_and_does_not_duplicate_it(): void + { + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'user_id' => $this->user->id, + 'status' => Purchase::STATUS_PAID, + 'payment_method' => 'transfer', + 'total' => 25, + 'email' => 'checkout@example.com', + ]); + $purchaseItem = $purchase->items()->create([ + 'source_catalog_item_id' => $this->catalogItem()->id, + 'nombre' => 'Entrada', + 'item_nombre' => 'Entrada general', + 'variant_attributes' => [], + 'cantidad' => 2, + 'precio_unitario' => 25, + 'total' => 25, + ]); + $firstTicket = $this->ticketFor($purchaseItem->id); + $secondTicket = $this->ticketFor($purchaseItem->id); + $purchaseTickets = [[ + 'purchase_id' => $purchase->id, + 'ticket_ids' => [$firstTicket->id, $secondTicket->id], + ]]; + + app(NotificationMailService::class)->sendEventDateRescheduled( + $this->tenant->codigo, + 10, + 20, + '09/10/2027', + '20/10/2027', + $purchaseTickets, + ); + app(NotificationMailService::class)->sendEventDateRescheduled( + $this->tenant->codigo, + 10, + 20, + '09/10/2027', + '20/10/2027', + $purchaseTickets, + ); + + Mail::assertSent(Mailable::class, 1); + Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $firstTicket, $secondTicket): bool { + $mail->assertTo('checkout@example.com'); + + return $mail->subject === "Tu evento fue reprogramado - Compra #{$purchase->id}" + && str_contains($mail->render(), '09/10/2027') + && str_contains($mail->render(), '20/10/2027') + && str_contains($mail->render(), '#'.$firstTicket->id) + && str_contains($mail->render(), '#'.$secondTicket->id); + }); + $this->assertDatabaseHas('event_date_notification_deliveries', [ + 'notification_key' => "event-date-rescheduled:10:20:{$purchase->id}", + 'purchase_id' => $purchase->id, + ]); + } + + public function test_suspension_email_uses_the_account_email_and_separates_disabled_tickets(): void + { + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'user_id' => $this->user->id, + 'status' => Purchase::STATUS_PAID, + 'payment_method' => 'transfer', + 'total' => 25, + 'email' => null, + ]); + $purchaseItem = $purchase->items()->create([ + 'source_catalog_item_id' => $this->catalogItem()->id, + 'nombre' => 'Entrada', + 'item_nombre' => 'Entrada general', + 'variant_attributes' => [], + 'cantidad' => 2, + 'precio_unitario' => 25, + 'total' => 25, + ]); + $disabledTicket = $this->ticketFor($purchaseItem->id); + $disabledTicket->markAsDisabled(); + $disabledTicket->save(); + $activeTicket = $this->ticketFor($purchaseItem->id); + + app(NotificationMailService::class)->sendEventDateSuspended( + $this->tenant->codigo, + 10, + '09/10/2027', + [[ + 'purchase_id' => $purchase->id, + 'ticket_ids' => [$disabledTicket->id, $activeTicket->id], + ]], + ); + + Mail::assertSent(Mailable::class, function (Mailable $mail) use ($disabledTicket, $activeTicket): bool { + $mail->assertTo('ada@example.com'); + + return str_contains($mail->render(), 'quedaron inhabilitados') + && str_contains($mail->render(), 'conservan otras fechas disponibles') + && str_contains($mail->render(), '#'.$disabledTicket->id) + && str_contains($mail->render(), '#'.$activeTicket->id); + }); + } + + private function ticketFor(int $purchaseItemId): Ticket + { + return Ticket::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'ticket' => fake()->uuid(), + 'source_purchase_item_id' => $purchaseItemId, + 'user_id' => $this->user->id, + ]); + } + + private function catalogItem(): CatalogItem + { + return CatalogItem::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => fake()->unique()->slug(), + 'nombre' => 'Entrada', + 'descripcion' => 'Entrada general', + 'precio' => 25, + 'has_tickets' => true, + ]); + } + private function useWebsiteTypeBranding(): void { $websiteType = WebsiteType::query()->create([ diff --git a/tests/Feature/Notification/QueuedNotificationListenerTest.php b/tests/Feature/Notification/QueuedNotificationListenerTest.php index fbf446f..f9234ce 100644 --- a/tests/Feature/Notification/QueuedNotificationListenerTest.php +++ b/tests/Feature/Notification/QueuedNotificationListenerTest.php @@ -2,6 +2,10 @@ namespace Tests\Feature\Notification; +use App\Domains\Event\Events\EventDateRescheduled; +use App\Domains\Event\Events\EventDateSuspended; +use App\Domains\Notification\Listeners\SendEventDateRescheduledEmails; +use App\Domains\Notification\Listeners\SendEventDateSuspendedEmails; use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail; use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Purchase\Events\PurchasePaid; @@ -27,4 +31,42 @@ class QueuedNotificationListenerTest extends TestCase $this->assertSame(123, $purchasePaid->purchaseId); } + + public function test_rescheduled_date_email_listener_delegates_the_captured_purchase_tickets(): void + { + $mailService = Mockery::mock(NotificationMailService::class); + $mailService->shouldReceive('sendEventDateRescheduled') + ->once() + ->with('acme', 10, 20, '2027-10-09', '2027-10-20', [[ + 'purchase_id' => 123, + 'ticket_ids' => [456, 789], + ]]); + $this->app->instance(NotificationMailService::class, $mailService); + + (new SendEventDateRescheduledEmails)->handle(new EventDateRescheduled( + 'acme', 10, 20, '2027-10-09', '2027-10-20', [[ + 'purchase_id' => 123, + 'ticket_ids' => [456, 789], + ]] + )); + } + + public function test_suspended_date_email_listener_delegates_the_captured_purchase_tickets(): void + { + $mailService = Mockery::mock(NotificationMailService::class); + $mailService->shouldReceive('sendEventDateSuspended') + ->once() + ->with('acme', 10, '2027-10-09', [[ + 'purchase_id' => 123, + 'ticket_ids' => [456], + ]]); + $this->app->instance(NotificationMailService::class, $mailService); + + (new SendEventDateSuspendedEmails)->handle(new EventDateSuspended( + 'acme', 10, '2027-10-09', [[ + 'purchase_id' => 123, + 'ticket_ids' => [456], + ]] + )); + } } -- 2.49.1 From 9c1415319908024873b0b32b3cf35ab6b36c8d4c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 09:41:40 -0300 Subject: [PATCH 22/63] feat(notification): implement idempotent email delivery system and update related services --- .env.example | 1 + .../Notification/Models/EmailDelivery.php | 44 ++ .../Models/EventDateNotificationDelivery.php | 28 -- .../IdempotentEmailDeliveryService.php | 112 +++++ .../Services/NotificationMailService.php | 408 +++++++++--------- .../Notification/documentacion/README.md | 23 +- config/mail.php | 2 + ...4_000000_create_email_deliveries_table.php | 34 ++ ...ent_date_notification_deliveries_table.php | 28 -- .../IdempotentEmailDeliveryServiceTest.php | 136 ++++++ .../NotificationMailServiceTest.php | 53 ++- .../NotificationMailServiceLoggingTest.php | 2 + 12 files changed, 604 insertions(+), 267 deletions(-) create mode 100644 app/Domains/Notification/Models/EmailDelivery.php delete mode 100644 app/Domains/Notification/Models/EventDateNotificationDelivery.php create mode 100644 app/Domains/Notification/Services/IdempotentEmailDeliveryService.php create mode 100644 database/migrations/2026_09_14_000000_create_email_deliveries_table.php delete mode 100644 database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php create mode 100644 tests/Feature/Notification/IdempotentEmailDeliveryServiceTest.php diff --git a/.env.example b/.env.example index a82d56d..fd77644 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,7 @@ COMMANDS_LOG_LEVEL=info COMMANDS_LOG_DAYS=30 EMAILS_LOG_LEVEL=info EMAILS_LOG_DAYS=30 +EMAIL_DELIVERY_LEASE_SECONDS=300 DB_CONNECTION=mysql DB_HOST=127.0.0.1 diff --git a/app/Domains/Notification/Models/EmailDelivery.php b/app/Domains/Notification/Models/EmailDelivery.php new file mode 100644 index 0000000..8b16a6d --- /dev/null +++ b/app/Domains/Notification/Models/EmailDelivery.php @@ -0,0 +1,44 @@ + 'integer', + 'context' => 'array', + 'claimed_at' => 'datetime', + 'lease_expires_at' => 'datetime', + 'sent_at' => 'datetime', + 'failed_at' => 'datetime', + ]; + } +} diff --git a/app/Domains/Notification/Models/EventDateNotificationDelivery.php b/app/Domains/Notification/Models/EventDateNotificationDelivery.php deleted file mode 100644 index 33bd028..0000000 --- a/app/Domains/Notification/Models/EventDateNotificationDelivery.php +++ /dev/null @@ -1,28 +0,0 @@ - 'integer', - 'destination_event_date_id' => 'integer', - 'purchase_id' => 'integer', - 'sent_at' => 'datetime', - ]; - } -} diff --git a/app/Domains/Notification/Services/IdempotentEmailDeliveryService.php b/app/Domains/Notification/Services/IdempotentEmailDeliveryService.php new file mode 100644 index 0000000..48e8c37 --- /dev/null +++ b/app/Domains/Notification/Services/IdempotentEmailDeliveryService.php @@ -0,0 +1,112 @@ + $context + * @param Closure(): void $send + */ + public function sendOnce( + string $key, + string $type, + ?string $tenantCode, + array $context, + string $recipient, + Closure $send, + ): bool { + $now = now(); + + EmailDelivery::query()->insertOrIgnore([ + 'idempotency_key' => $key, + 'email_type' => $type, + 'tenant_code' => $tenantCode, + 'status' => EmailDelivery::STATUS_PENDING, + 'attempts' => 0, + 'context' => json_encode($context, JSON_THROW_ON_ERROR), + 'recipient_fingerprint' => $this->recipientFingerprint($recipient), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $claimToken = (string) Str::uuid(); + $leaseExpiresAt = $now->copy()->addSeconds( + max(1, (int) config('mail.delivery_lease_seconds', 300)), + ); + + $claimed = EmailDelivery::query() + ->where('idempotency_key', $key) + ->where(function ($query) use ($now): void { + $query->whereIn('status', [ + EmailDelivery::STATUS_PENDING, + EmailDelivery::STATUS_FAILED, + ])->orWhere(function ($query) use ($now): void { + $query->where('status', EmailDelivery::STATUS_PROCESSING) + ->where('lease_expires_at', '<=', $now); + }); + }) + ->update([ + 'status' => EmailDelivery::STATUS_PROCESSING, + 'attempts' => new Expression('attempts + 1'), + 'context' => json_encode($context, JSON_THROW_ON_ERROR), + 'recipient_fingerprint' => $this->recipientFingerprint($recipient), + 'claim_token' => $claimToken, + 'claimed_at' => $now, + 'lease_expires_at' => $leaseExpiresAt, + 'failed_at' => null, + 'last_error' => null, + 'updated_at' => $now, + ]) === 1; + + if (! $claimed) { + return false; + } + + try { + $send(); + + EmailDelivery::query() + ->where('idempotency_key', $key) + ->where('claim_token', $claimToken) + ->update([ + 'status' => EmailDelivery::STATUS_SENT, + 'claim_token' => null, + 'lease_expires_at' => null, + 'sent_at' => now(), + 'updated_at' => now(), + ]); + } catch (Throwable $exception) { + EmailDelivery::query() + ->where('idempotency_key', $key) + ->where('claim_token', $claimToken) + ->update([ + 'status' => EmailDelivery::STATUS_FAILED, + 'claim_token' => null, + 'lease_expires_at' => null, + 'failed_at' => now(), + 'last_error' => Str::limit($exception::class, 2000, ''), + 'updated_at' => now(), + ]); + + throw $exception; + } + + return true; + } + + private function recipientFingerprint(string $recipient): string + { + return hash_hmac( + 'sha256', + mb_strtolower(trim($recipient)), + (string) config('app.key'), + ); + } +} diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index efbdabb..057810a 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -6,7 +6,6 @@ use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\User; use App\Domains\Integration\Services\MailService; use App\Domains\Notification\Events\PasswordResetRequested; -use App\Domains\Notification\Models\EventDateNotificationDelivery; use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; @@ -23,32 +22,42 @@ class NotificationMailService public function __construct( private readonly MailService $mailService, private readonly TicketPdfService $ticketPdfService, + private readonly IdempotentEmailDeliveryService $emailDeliveryService, ) {} public function sendWelcome(int $userId, string $tenantCode): void { - $this->sendLogged('welcome', [ + $context = [ 'user_id' => $userId, 'tenant_code' => $tenantCode, - ], function () use ($userId, $tenantCode): array { - $tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail(); - $user = User::query()->findOrFail($userId); - $brand = $tenant->websiteType ?? $tenant; - $tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path; + ]; + $tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail(); + $user = User::query()->findOrFail($userId); - $this->mailService - ->forTenant($tenantCode) - ->send( - $user->email, - "Bienvenido a {$brand->nombre}", - view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(), - $brand, - ); + $this->sendIdempotently( + "welcome:{$tenantCode}:{$userId}", + 'welcome', + $tenantCode, + $context, + $user->email, + function () use ($user, $tenant, $tenantCode): array { + $brand = $tenant->websiteType ?? $tenant; + $tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path; - return [ - 'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type', - ]; - }); + $this->mailService + ->forTenant($tenantCode) + ->send( + $user->email, + "Bienvenido a {$brand->nombre}", + view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(), + $brand, + ); + + return [ + 'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type', + ]; + }, + ); } public function sendPasswordResetCode( @@ -62,128 +71,149 @@ class NotificationMailService 'channel' => $channel, ]; - $this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array { - $tenant = Tenant::query() - ->with('websiteType') - ->where('codigo', $tenantCode) - ->firstOrFail(); - $attempt = ResetPasswordAttempt::query() - ->with('user') - ->findOrFail($attemptId); + $tenant = Tenant::query() + ->with('websiteType') + ->where('codigo', $tenantCode) + ->firstOrFail(); + $attempt = ResetPasswordAttempt::query() + ->with('user') + ->findOrFail($attemptId); - if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) { - $this->logSkipped('password_reset', array_merge($context, [ - 'reason' => 'attempt_not_pending', - 'attempt_status' => $attempt->status, - 'user_id' => $attempt->user_id, - ])); - - return null; - } - - $recoveryDomain = match ($channel) { - PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio, - PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain, - default => $tenant->dominio, - }; - $recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT - && $tenant->base_path !== '/' - ? $tenant->base_path - : ''; - $recoveryQuery = ['email' => $attempt->user->email]; - if ( - $channel === PasswordResetRequested::CHANNEL_SCANNER - && $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED - ) { - $recoveryQuery['code'] = $attempt->codigo; - } - $recoveryUrl = $recoveryDomain === null - ? null - : 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery); - $brand = $tenant->websiteType ?? $tenant; - - [$subject, $template] = match ($attempt->reason) { - ResetPasswordAttempt::REASON_STAFF_CREATED => [ - 'Tu cuenta de escáner está lista', 'scanner-created', - ], - ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [ - 'Tu cuenta de administrador está lista', 'administrator-created', - ], - ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [ - 'Desbloqueá tu cuenta', 'account-locked', - ], - default => ['Código para recuperar tu contraseña', 'password-reset'], - }; - - $this->mailService - ->forTenant($tenantCode) - ->send( - $attempt->user->email, - "{$subject} - {$brand->nombre}", - view("mail.notifications.{$template}", [ - 'attempt' => $attempt, - 'recoveryUrl' => $recoveryUrl, - 'brand' => $brand, - ])->render(), - $brand, - ); - - return [ + if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) { + $this->logSkipped('password_reset', array_merge($context, [ + 'reason' => 'attempt_not_pending', + 'attempt_status' => $attempt->status, 'user_id' => $attempt->user_id, - 'recovery_domain_available' => $recoveryDomain !== null, - ]; - }); + ])); + + return; + } + + $this->sendIdempotently( + "password-reset:{$attemptId}", + 'password_reset', + $tenantCode, + $context, + $attempt->user->email, + function () use ($attempt, $tenant, $tenantCode, $channel): array { + $recoveryDomain = match ($channel) { + PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio, + PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain, + default => $tenant->dominio, + }; + $recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT + && $tenant->base_path !== '/' + ? $tenant->base_path + : ''; + $recoveryQuery = ['email' => $attempt->user->email]; + if ( + $channel === PasswordResetRequested::CHANNEL_SCANNER + && $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED + ) { + $recoveryQuery['code'] = $attempt->codigo; + } + $recoveryUrl = $recoveryDomain === null + ? null + : 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery); + $brand = $tenant->websiteType ?? $tenant; + + [$subject, $template] = match ($attempt->reason) { + ResetPasswordAttempt::REASON_STAFF_CREATED => [ + 'Tu cuenta de escáner está lista', 'scanner-created', + ], + ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [ + 'Tu cuenta de administrador está lista', 'administrator-created', + ], + ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [ + 'Desbloqueá tu cuenta', 'account-locked', + ], + default => ['Código para recuperar tu contraseña', 'password-reset'], + }; + + $this->mailService + ->forTenant($tenantCode) + ->send( + $attempt->user->email, + "{$subject} - {$brand->nombre}", + view("mail.notifications.{$template}", [ + 'attempt' => $attempt, + 'recoveryUrl' => $recoveryUrl, + 'brand' => $brand, + ])->render(), + $brand, + ); + + return [ + 'user_id' => $attempt->user_id, + 'recovery_domain_available' => $recoveryDomain !== null, + ]; + }, + ); } public function sendPurchaseConfirmed(int $purchaseId): void { $context = ['purchase_id' => $purchaseId]; - $this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array { - $purchase = Purchase::query() - ->with(['tenant', 'user', 'items']) - ->find($purchaseId); + $purchase = Purchase::query() + ->with(['tenant', 'user', 'items']) + ->find($purchaseId); - if ($purchase === null) { - $this->logSkipped('purchase_confirmed', array_merge($context, [ - 'reason' => 'purchase_not_found', - 'missing_model' => Purchase::class, - ])); + if ($purchase === null) { + $this->logSkipped('purchase_confirmed', array_merge($context, [ + 'reason' => 'purchase_not_found', + 'missing_model' => Purchase::class, + ])); - return null; - } + return; + } - /** @var Collection $tickets */ - $tickets = $purchase->tickets() - ->where('tenant_code', $purchase->tenant_codigo) - ->with(TicketPresentationResolver::RELATIONS) - ->get(); - $attachments = $tickets->isEmpty() - ? [] - : [[ - 'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets), - 'name' => $this->ticketPdfService->filename($tickets), - 'mime' => 'application/pdf', - ]]; + $recipient = $this->recipientFor($purchase); + if ($recipient === '') { + $this->logSkipped('purchase_confirmed', array_merge($context, ['reason' => 'missing_recipient'])); - $this->mailService - ->forTenant($purchase->tenant_codigo) - ->send( - $this->recipientFor($purchase), - "Compra confirmada - Compra #{$purchase->getKey()}", - view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(), - attachments: $attachments, - ); + return; + } - return [ - 'tenant_code' => $purchase->tenant_codigo, - 'user_id' => $purchase->user_id, - 'purchase_status' => $purchase->status, - 'purchase_item_count' => $purchase->items->count(), - 'ticket_count' => $tickets->count(), - 'ticket_ids' => $tickets->modelKeys(), - ]; - }); + $this->sendIdempotently( + "purchase-confirmed:{$purchaseId}", + 'purchase_confirmed', + $purchase->tenant_codigo, + $context, + $recipient, + function () use ($purchase, $recipient): array { + /** @var Collection $tickets */ + $tickets = $purchase->tickets() + ->where('tenant_code', $purchase->tenant_codigo) + ->with(TicketPresentationResolver::RELATIONS) + ->get(); + $attachments = $tickets->isEmpty() + ? [] + : [[ + 'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets), + 'name' => $this->ticketPdfService->filename($tickets), + 'mime' => 'application/pdf', + ]]; + + $this->mailService + ->forTenant($purchase->tenant_codigo) + ->send( + $recipient, + "Compra confirmada - Compra #{$purchase->getKey()}", + view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(), + attachments: $attachments, + ); + + return [ + 'tenant_code' => $purchase->tenant_codigo, + 'user_id' => $purchase->user_id, + 'purchase_status' => $purchase->status, + 'purchase_item_count' => $purchase->items->count(), + 'ticket_count' => $tickets->count(), + 'ticket_ids' => $tickets->modelKeys(), + ]; + }, + ); } /** @@ -247,23 +277,13 @@ class NotificationMailService } $deliveryKey = "event-date-rescheduled:{$sourceEventDateId}:{$destinationEventDateId}:{$purchaseId}"; - if (! $this->claimDelivery( + $this->sendIdempotently( $deliveryKey, 'event_date_rescheduled', $tenantCode, - $sourceEventDateId, - $destinationEventDateId, - $purchaseId, - )) { - $this->logSkipped('event_date_rescheduled', array_merge($context, [ - 'reason' => 'already_sent', - ])); - - continue; - } - - try { - $this->sendLogged('event_date_rescheduled', $context, function () use ( + $context, + $recipient, + function () use ( $tenantCode, $purchase, $recipient, @@ -285,13 +305,8 @@ class NotificationMailService ); return ['ticket_count' => $tickets->count()]; - }); - $this->markDeliverySent($deliveryKey); - } catch (Throwable $exception) { - $this->releaseDelivery($deliveryKey); - - throw $exception; - } + }, + ); } } @@ -356,21 +371,6 @@ class NotificationMailService } $deliveryKey = "event-date-suspended:{$eventDateId}:{$purchaseId}"; - if (! $this->claimDelivery( - $deliveryKey, - 'event_date_suspended', - $tenantCode, - $eventDateId, - null, - $purchaseId, - )) { - $this->logSkipped('event_date_suspended', array_merge($context, [ - 'reason' => 'already_sent', - ])); - - continue; - } - $disabledTickets = $tickets ->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_DISABLED) ->values(); @@ -378,8 +378,13 @@ class NotificationMailService ->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_ACTIVE) ->values(); - try { - $this->sendLogged('event_date_suspended', $context, function () use ( + $this->sendIdempotently( + $deliveryKey, + 'event_date_suspended', + $tenantCode, + $context, + $recipient, + function () use ( $tenantCode, $purchase, $recipient, @@ -405,13 +410,8 @@ class NotificationMailService 'disabled_ticket_ids' => $disabledTickets->modelKeys(), 'active_ticket_ids' => $activeTickets->modelKeys(), ]; - }); - $this->markDeliverySent($deliveryKey); - } catch (Throwable $exception) { - $this->releaseDelivery($deliveryKey); - - throw $exception; - } + }, + ); } } @@ -423,45 +423,39 @@ class NotificationMailService ->find($purchaseId); } - private function claimDelivery( - string $deliveryKey, - string $type, - string $tenantCode, - int $eventDateId, - ?int $destinationEventDateId, - int $purchaseId, - ): bool { - return EventDateNotificationDelivery::query()->insertOrIgnore([ - 'notification_key' => $deliveryKey, - 'notification_type' => $type, - 'tenant_code' => $tenantCode, - 'event_date_id' => $eventDateId, - 'destination_event_date_id' => $destinationEventDateId, - 'purchase_id' => $purchaseId, - 'created_at' => now(), - 'updated_at' => now(), - ]) === 1; - } - - private function markDeliverySent(string $deliveryKey): void - { - EventDateNotificationDelivery::query() - ->where('notification_key', $deliveryKey) - ->update(['sent_at' => now()]); - } - - private function releaseDelivery(string $deliveryKey): void - { - EventDateNotificationDelivery::query() - ->where('notification_key', $deliveryKey) - ->delete(); - } - private function recipientFor(Purchase $purchase): string { return (string) ($purchase->email ?: $purchase->user?->email); } + /** + * @param array $context + * @param Closure(): array $send + */ + private function sendIdempotently( + string $key, + string $emailType, + ?string $tenantCode, + array $context, + string $recipient, + Closure $send, + ): void { + $sent = $this->emailDeliveryService->sendOnce( + $key, + $emailType, + $tenantCode, + $context, + $recipient, + function () use ($emailType, $context, $send): void { + $this->sendLogged($emailType, $context, $send); + }, + ); + + if (! $sent) { + $this->logSkipped($emailType, array_merge($context, ['reason' => 'already_claimed'])); + } + } + /** * @param array $context * @param Closure(): (array|null) $send diff --git a/app/Domains/Notification/documentacion/README.md b/app/Domains/Notification/documentacion/README.md index 467c0dd..683402a 100644 --- a/app/Domains/Notification/documentacion/README.md +++ b/app/Domains/Notification/documentacion/README.md @@ -12,7 +12,19 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin ## Componentes -Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`. +Los listeners delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`. + +`IdempotentEmailDeliveryService` coordina los envíos automáticos mediante la tabla +`email_deliveries`. Cada correo utiliza una clave de negocio única: + +- bienvenida: `welcome:{tenant_code}:{user_id}`; +- recuperación: `password-reset:{attempt_id}`; +- compra confirmada: `purchase-confirmed:{purchase_id}`; +- reprogramación: `event-date-rescheduled:{source_event_date_id}:{destination_event_date_id}:{purchase_id}`; +- suspensión: `event-date-suspended:{event_date_id}:{purchase_id}`. + +Los correos de prueba y de validación de una integración SMTP no usan esta capa, +porque su reenvío explícito es parte de su comportamiento esperado. ## API y dependencias @@ -25,3 +37,12 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`, - Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, con fallback al tenant si no tiene uno configurado. - El correo transaccional de compra confirmada usa la identidad visual del tenant y adjunta un único PDF cuando la compra generó tickets. - Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola. +- Una entrega queda en estado `processing` mientras un worker posee su claim. Si + el worker se interrumpe, el claim vence según `EMAIL_DELIVERY_LEASE_SECONDS` y + otro intento puede recuperarlo. +- Los fallos quedan registrados como `failed` y pueden ser retomados por los + reintentos de la cola. Los envíos exitosos permanecen como `sent` y las llamadas + posteriores con la misma clave no vuelven a enviar el correo. +- SMTP no ofrece una confirmación transaccional junto con la base de datos. Una + interrupción ocurrida después de entregar el correo y antes de registrar + `sent` puede producir un duplicado excepcional al recuperar el claim. diff --git a/config/mail.php b/config/mail.php index e32e88d..90fb4f5 100644 --- a/config/mail.php +++ b/config/mail.php @@ -16,6 +16,8 @@ return [ 'default' => env('MAIL_MAILER', 'log'), + 'delivery_lease_seconds' => (int) env('EMAIL_DELIVERY_LEASE_SECONDS', 300), + /* |-------------------------------------------------------------------------- | Mailer Configurations diff --git a/database/migrations/2026_09_14_000000_create_email_deliveries_table.php b/database/migrations/2026_09_14_000000_create_email_deliveries_table.php new file mode 100644 index 0000000..eb0cb98 --- /dev/null +++ b/database/migrations/2026_09_14_000000_create_email_deliveries_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('idempotency_key')->unique(); + $table->string('email_type')->index(); + $table->string('tenant_code')->nullable()->index(); + $table->string('status')->index(); + $table->unsignedInteger('attempts')->default(0); + $table->json('context')->nullable(); + $table->string('recipient_fingerprint', 64)->nullable(); + $table->uuid('claim_token')->nullable()->index(); + $table->timestamp('claimed_at')->nullable(); + $table->timestamp('lease_expires_at')->nullable()->index(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_deliveries'); + } +}; diff --git a/database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php b/database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php deleted file mode 100644 index e8070ad..0000000 --- a/database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php +++ /dev/null @@ -1,28 +0,0 @@ -id(); - $table->string('notification_key')->unique(); - $table->string('notification_type'); - $table->string('tenant_code'); - $table->unsignedBigInteger('event_date_id'); - $table->unsignedBigInteger('destination_event_date_id')->nullable(); - $table->unsignedBigInteger('purchase_id'); - $table->timestamp('sent_at')->nullable(); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('event_date_notification_deliveries'); - } -}; diff --git a/tests/Feature/Notification/IdempotentEmailDeliveryServiceTest.php b/tests/Feature/Notification/IdempotentEmailDeliveryServiceTest.php new file mode 100644 index 0000000..5b666a1 --- /dev/null +++ b/tests/Feature/Notification/IdempotentEmailDeliveryServiceTest.php @@ -0,0 +1,136 @@ +sendOnce( + 'welcome:tenant:10', + 'welcome', + 'tenant', + ['user_id' => 10], + 'ada@example.com', + function () use (&$calls): void { + $calls++; + }, + ); + $second = $service->sendOnce( + 'welcome:tenant:10', + 'welcome', + 'tenant', + ['user_id' => 10], + 'ada@example.com', + function () use (&$calls): void { + $calls++; + }, + ); + + $this->assertTrue($first); + $this->assertFalse($second); + $this->assertSame(1, $calls); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => 'welcome:tenant:10', + 'status' => EmailDelivery::STATUS_SENT, + 'attempts' => 1, + ]); + } + + public function test_it_records_a_failure_and_allows_a_retry(): void + { + $service = app(IdempotentEmailDeliveryService::class); + + try { + $service->sendOnce( + 'purchase-confirmed:20', + 'purchase_confirmed', + 'tenant', + ['purchase_id' => 20], + 'buyer@example.com', + fn () => throw new RuntimeException('Sensitive SMTP detail'), + ); + $this->fail('The delivery exception was not rethrown.'); + } catch (RuntimeException) { + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => 'purchase-confirmed:20', + 'status' => EmailDelivery::STATUS_FAILED, + 'attempts' => 1, + 'last_error' => RuntimeException::class, + ]); + } + + $sent = $service->sendOnce( + 'purchase-confirmed:20', + 'purchase_confirmed', + 'tenant', + ['purchase_id' => 20], + 'buyer@example.com', + static function (): void {}, + ); + + $this->assertTrue($sent); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => 'purchase-confirmed:20', + 'status' => EmailDelivery::STATUS_SENT, + 'attempts' => 2, + 'last_error' => null, + ]); + } + + public function test_it_recovers_an_expired_claim_but_not_an_active_one(): void + { + config(['mail.delivery_lease_seconds' => 300]); + $service = app(IdempotentEmailDeliveryService::class); + $delivery = EmailDelivery::query()->create([ + 'idempotency_key' => 'password-reset:30', + 'email_type' => 'password_reset', + 'tenant_code' => 'tenant', + 'status' => EmailDelivery::STATUS_PROCESSING, + 'attempts' => 1, + 'context' => ['attempt_id' => 30], + 'recipient_fingerprint' => str_repeat('a', 64), + 'claim_token' => fake()->uuid(), + 'claimed_at' => now(), + 'lease_expires_at' => now()->addMinute(), + ]); + + $activeClaim = $service->sendOnce( + $delivery->idempotency_key, + $delivery->email_type, + $delivery->tenant_code, + $delivery->context, + 'ada@example.com', + static function (): void {}, + ); + $this->assertFalse($activeClaim); + + $delivery->update(['lease_expires_at' => now()->subSecond()]); + $expiredClaim = $service->sendOnce( + $delivery->idempotency_key, + $delivery->email_type, + $delivery->tenant_code, + $delivery->context, + 'ada@example.com', + static function (): void {}, + ); + + $this->assertTrue($expiredClaim); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => 'password-reset:30', + 'status' => EmailDelivery::STATUS_SENT, + 'attempts' => 2, + ]); + } +} diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index f305d8d..a642f03 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -14,6 +14,7 @@ use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; +use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Mail\Mailable; use Illuminate\Support\Facades\Mail; @@ -31,6 +32,7 @@ class NotificationMailServiceTest extends TestCase { parent::setUp(); + $this->app->register(DomPdfServiceProvider::class); Mail::fake(); Integration::query()->create([ 'integration_code' => 'email', @@ -75,6 +77,9 @@ class NotificationMailServiceTest extends TestCase $this->useWebsiteTypeBranding(); app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo); + app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo); + + Mail::assertSent(Mailable::class, 1); Mail::assertSent(Mailable::class, function (Mailable $mail): bool { $mail->assertTo('ada@example.com'); @@ -98,6 +103,12 @@ class NotificationMailServiceTest extends TestCase $attempt->id, $this->tenant->codigo, ); + app(NotificationMailService::class)->sendPasswordResetCode( + $attempt->id, + $this->tenant->codigo, + ); + + Mail::assertSent(Mailable::class, 1); Mail::assertSent(Mailable::class, function (Mailable $mail): bool { $mail->assertTo('ada@example.com'); @@ -113,6 +124,27 @@ class NotificationMailServiceTest extends TestCase }); } + public function test_password_reset_idempotency_is_scoped_to_each_attempt(): void + { + $firstAttempt = $this->user->resetPasswordAttempts()->create(['codigo' => '0123']); + $secondAttempt = $this->user->resetPasswordAttempts()->create(['codigo' => '4567']); + $service = app(NotificationMailService::class); + + $service->sendPasswordResetCode($firstAttempt->id, $this->tenant->codigo); + $service->sendPasswordResetCode($firstAttempt->id, $this->tenant->codigo); + $service->sendPasswordResetCode($secondAttempt->id, $this->tenant->codigo); + + Mail::assertSent(Mailable::class, 2); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => "password-reset:{$firstAttempt->id}", + 'status' => 'sent', + ]); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => "password-reset:{$secondAttempt->id}", + 'status' => 'sent', + ]); + } + public function test_it_links_scanner_password_resets_to_the_scanner_domain(): void { $websiteType = WebsiteType::query()->create([ @@ -261,6 +293,9 @@ class NotificationMailServiceTest extends TestCase ]); app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id); + app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id); + + Mail::assertSent(Mailable::class, 1); Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool { return $mail->subject === "Compra confirmada - Compra #{$purchase->id}" @@ -323,9 +358,10 @@ class NotificationMailServiceTest extends TestCase && str_contains($mail->render(), '#'.$firstTicket->id) && str_contains($mail->render(), '#'.$secondTicket->id); }); - $this->assertDatabaseHas('event_date_notification_deliveries', [ - 'notification_key' => "event-date-rescheduled:10:20:{$purchase->id}", - 'purchase_id' => $purchase->id, + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => "event-date-rescheduled:10:20:{$purchase->id}", + 'email_type' => 'event_date_rescheduled', + 'status' => 'sent', ]); } @@ -362,6 +398,17 @@ class NotificationMailServiceTest extends TestCase 'ticket_ids' => [$disabledTicket->id, $activeTicket->id], ]], ); + app(NotificationMailService::class)->sendEventDateSuspended( + $this->tenant->codigo, + 10, + '09/10/2027', + [[ + 'purchase_id' => $purchase->id, + 'ticket_ids' => [$disabledTicket->id, $activeTicket->id], + ]], + ); + + Mail::assertSent(Mailable::class, 1); Mail::assertSent(Mailable::class, function (Mailable $mail) use ($disabledTicket, $activeTicket): bool { $mail->assertTo('ada@example.com'); diff --git a/tests/Unit/Notification/NotificationMailServiceLoggingTest.php b/tests/Unit/Notification/NotificationMailServiceLoggingTest.php index 6733354..9f69677 100644 --- a/tests/Unit/Notification/NotificationMailServiceLoggingTest.php +++ b/tests/Unit/Notification/NotificationMailServiceLoggingTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Notification; use App\Domains\Integration\Services\MailService; +use App\Domains\Notification\Services\IdempotentEmailDeliveryService; use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Ticket\Services\TicketPdfService; @@ -28,6 +29,7 @@ class NotificationMailServiceLoggingTest extends TestCase $this->service = new NotificationMailService( $this->mailService, Mockery::mock(TicketPdfService::class), + Mockery::mock(IdempotentEmailDeliveryService::class), ); } -- 2.49.1 From a62e989bb4efc02428e37218b64edfa3cf5dbecd Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 11:33:20 -0300 Subject: [PATCH 23/63] feat(ticket): enhance ticket name resolution to follow event date reschedules --- .../Services/EffectiveEventDateResolver.php | 10 ++- .../Services/TicketPresentationResolver.php | 14 +++- tests/Feature/Ticket/TicketControllerTest.php | 71 +++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/app/Domains/Event/Services/EffectiveEventDateResolver.php b/app/Domains/Event/Services/EffectiveEventDateResolver.php index 7a50244..f74db64 100644 --- a/app/Domains/Event/Services/EffectiveEventDateResolver.php +++ b/app/Domains/Event/Services/EffectiveEventDateResolver.php @@ -7,6 +7,14 @@ use App\Domains\Event\Models\EventDate; class EffectiveEventDateResolver { public function resolve(EventDate $eventDate): ?EventDate + { + $date = $this->resolveLatest($eventDate); + + return $date !== null && $date->suspended_at === null ? $date : null; + } + + /** Sigue las reprogramaciones para presentación, incluso si el destino está suspendido. */ + public function resolveLatest(EventDate $eventDate): ?EventDate { $current = $eventDate; $visited = []; @@ -23,7 +31,7 @@ class EffectiveEventDateResolver $visited[$identity] = true; if ($current->rescheduled_to_event_date_id === null) { - return $current->suspended_at === null ? $current : null; + return $current; } $current->loadMissing('rescheduledTo'); diff --git a/app/Domains/Ticket/Services/TicketPresentationResolver.php b/app/Domains/Ticket/Services/TicketPresentationResolver.php index 19de892..f2e7681 100644 --- a/app/Domains/Ticket/Services/TicketPresentationResolver.php +++ b/app/Domains/Ticket/Services/TicketPresentationResolver.php @@ -2,10 +2,14 @@ namespace App\Domains\Ticket\Services; +use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Services\EffectiveEventDateResolver; use App\Domains\Ticket\Models\Ticket; class TicketPresentationResolver { + public function __construct(private readonly EffectiveEventDateResolver $effectiveEventDateResolver) {} + /** Relaciones necesarias para calcular nombre y descripción sin consultas N+1. */ public const RELATIONS = [ 'sourceCatalogItem', @@ -30,9 +34,15 @@ class TicketPresentationResolver } $itemAttributes = $variant->catalogItem->itemAttributes; + $eventDateLabels = $variant->selectedEventDates() + ->map(fn (EventDate $date): EventDate => $this->effectiveEventDateResolver->resolveLatest($date) ?? $date) + ->unique(fn (EventDate $date): int => $date->getKey()) + ->map(fn (EventDate $date): string => $date->date->format('d/m/Y')) + ->implode(', '); + $properties = $variant->selectionOptions($itemAttributes) - ->map(function (array $option, string $attributeCode) use ($itemAttributes): ?string { - $labels = collect(array_is_list($option) ? $option : [$option]) + ->map(function (array $option, string $attributeCode) use ($itemAttributes, $eventDateLabels): ?string { + $labels = $attributeCode === 'event_date' ? $eventDateLabels : collect(array_is_list($option) ? $option : [$option]) ->pluck('label') ->filter(fn ($label): bool => is_string($label) && $label !== '') ->implode(', '); diff --git a/tests/Feature/Ticket/TicketControllerTest.php b/tests/Feature/Ticket/TicketControllerTest.php index 588746e..dc9e0a0 100644 --- a/tests/Feature/Ticket/TicketControllerTest.php +++ b/tests/Feature/Ticket/TicketControllerTest.php @@ -5,12 +5,17 @@ namespace Tests\Feature\Ticket; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; +use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; +use App\Domains\Event\Events\EventDateRescheduled; +use App\Domains\Event\Events\EventDateSuspended; use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Services\EventService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Str; use Tests\TestCase; @@ -18,6 +23,72 @@ class TicketControllerTest extends TestCase { use RefreshDatabase; + public function test_ticket_names_follow_reschedules_without_changing_variant_selections(): void + { + Event::fake([EventDateRescheduled::class, EventDateSuspended::class]); + $tenant = $this->createTenant('rescheduled'); + $user = User::factory()->create(); + $ticket = $this->createTicket($tenant, $user, 'COMIDA'); + $item = $ticket->sourceCatalogItem; + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'service', + 'nombre' => 'Servicio', + 'type' => 'string', + ]); + $itemAttribute = $item->itemAttributes()->create([ + 'attribute_id' => $attribute->id, + 'ticket_label' => 'Servicio', + ]); + $source = $tenant->eventDates()->create([ + 'date' => '2026-10-29', + 'time_start' => '08:00:00', + 'time_end' => '10:00:00', + ]); + $variant = $item->variants()->create([ + 'event_date_id' => $source->id, + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->definitions()->create([ + 'item_attribute_id' => $itemAttribute->id, + 'value' => 'COMEDOR', + ]); + $ticket->update(['source_variant_id' => $variant->id]); + + foreach (['29/10/2026', '02/11/2026', '05/11/2026'] as $step => $expectedDate) { + if ($step > 0) { + $previous = $step === 1 ? $source : $tenant->eventDates()->whereDate('date', '2026-11-02')->firstOrFail(); + app(EventService::class)->rescheduleDateForTenant($tenant, $previous, [ + 'date' => $step === 1 ? '2026-11-02' : '2026-11-05', + ]); + } + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/{$tenant->codigo}/tickets") + ->assertOk() + ->assertJsonPath('data.0.name', "COMIDA ({$expectedDate}, Servicio COMEDOR)") + ->assertJsonPath('data.0.ticket', $ticket->ticket); + } + + $destination = $tenant->eventDates()->whereDate('date', '2026-11-05')->firstOrFail(); + $variant->eventDates()->sync([$source->id, $destination->id]); + $this->assertSame('COMIDA (05/11/2026, Servicio COMEDOR)', $ticket->fresh()->name); + $this->assertSame('29/10/2026', $variant->fresh()->selectionOptions()->get('event_date')[0]['label']); + $this->assertSame($source->id, $variant->fresh()->event_date_id); + + app(EventService::class)->suspendDateForTenant($tenant, $destination); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/{$tenant->codigo}/tickets") + ->assertOk() + ->assertJsonPath('data.0.name', 'COMIDA (05/11/2026, Servicio COMEDOR)') + ->assertJsonPath('data.0.status', 'disabled') + ->assertJsonPath('data.0.is_valid', false) + ->assertJsonPath('data.0.starts_at', null) + ->assertJsonPath('data.0.expires_at', null) + ->assertJsonPath('data.0.ticket', $ticket->ticket); + } + public function test_an_authenticated_user_can_list_their_tickets_for_the_tenant(): void { $tenant = $this->createTenant('current'); -- 2.49.1 From adec5d172517e1131c619ac5c55249d783a1d0fa Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 11:57:06 -0300 Subject: [PATCH 24/63] feat(ticket): add refund transaction model --- app/Domains/Purchase/Models/PurchaseItem.php | 7 ++ app/Domains/Ticket/Models/Ticket.php | 15 +++++ app/Domains/Ticket/Models/TicketRefund.php | 66 +++++++++++++++++++ ..._14_010000_create_ticket_refunds_table.php | 28 ++++++++ 4 files changed, 116 insertions(+) create mode 100644 app/Domains/Ticket/Models/TicketRefund.php create mode 100644 database/migrations/2026_09_14_010000_create_ticket_refunds_table.php diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index ee4e9f5..956b6dc 100644 --- a/app/Domains/Purchase/Models/PurchaseItem.php +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -6,6 +6,7 @@ use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketRefund; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -66,6 +67,12 @@ class PurchaseItem extends Model return $this->hasMany(Ticket::class, 'source_purchase_item_id'); } + /** @return HasMany */ + public function ticketRefunds(): HasMany + { + return $this->hasMany(TicketRefund::class); + } + /** @return BelongsTo */ public function imageAttachment(): BelongsTo { diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index ee151aa..31041db 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; @@ -219,6 +220,12 @@ class Ticket extends Model return $this->belongsTo(PurchaseItem::class, 'source_purchase_item_id'); } + /** @return HasOne */ + public function refund(): HasOne + { + return $this->hasOne(TicketRefund::class); + } + /** @return BelongsTo */ public function sourceCatalogItem(): BelongsTo { @@ -284,6 +291,14 @@ class Ticket extends Model public function getStatusLabelAttribute(): string { + if ($this->status === self::STATUS_REFUNDED && $this->relationLoaded('refund')) { + $refund = $this->getRelation('refund'); + + if ($refund instanceof TicketRefund) { + return $refund->typeLabel(); + } + } + return self::statusLabel($this->status); } diff --git a/app/Domains/Ticket/Models/TicketRefund.php b/app/Domains/Ticket/Models/TicketRefund.php new file mode 100644 index 0000000..6af68f6 --- /dev/null +++ b/app/Domains/Ticket/Models/TicketRefund.php @@ -0,0 +1,66 @@ + 'integer', + 'purchase_item_id' => 'integer', + 'created_by_user_id' => 'integer', + 'amount' => 'decimal:2', + ]; + } + + /** @return list */ + public static function types(): array + { + return [self::TYPE_PARTIAL, self::TYPE_TOTAL]; + } + + public function typeLabel(): string + { + return match ($this->type) { + self::TYPE_PARTIAL => 'Reembolso parcial', + self::TYPE_TOTAL => 'Reembolso total', + default => 'Reembolsado', + }; + } + + /** @return BelongsTo */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } + + /** @return BelongsTo */ + public function purchaseItem(): BelongsTo + { + return $this->belongsTo(PurchaseItem::class); + } + + /** @return BelongsTo */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed(); + } +} diff --git a/database/migrations/2026_09_14_010000_create_ticket_refunds_table.php b/database/migrations/2026_09_14_010000_create_ticket_refunds_table.php new file mode 100644 index 0000000..e19a686 --- /dev/null +++ b/database/migrations/2026_09_14_010000_create_ticket_refunds_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('ticket_id')->unique()->constrained('tickets')->cascadeOnDelete(); + $table->foreignId('purchase_item_id')->constrained('compra_items')->restrictOnDelete(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('type', 16); + $table->decimal('amount', 10, 2); + $table->timestamps(); + + $table->index(['purchase_item_id', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_refunds'); + } +}; -- 2.49.1 From caed44fcc8c69042ed5e0becf90800def3bec783 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 11:57:31 -0300 Subject: [PATCH 25/63] feat(ticket): persist and expose refund details --- .../Sale/Services/AdminAppSaleService.php | 2 +- .../Controllers/AdminApp/TicketController.php | 7 +- .../Requests/AdminAppTicketRefundRequest.php | 3 +- .../AdminApp/AdminAppTicketResource.php | 7 ++ .../Ticket/Resources/TicketResource.php | 6 ++ .../Services/AdminAppTicketExcelService.php | 4 +- .../Services/AdminAppTicketRowService.php | 10 ++- .../Ticket/Services/AdminAppTicketService.php | 28 ++++++-- .../Ticket/AdminAppTicketControllerTest.php | 66 ++++++++++++++++++- .../AdminAppTicketExportServiceTest.php | 15 +++++ 10 files changed, 133 insertions(+), 15 deletions(-) diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index d329a96..4003634 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -67,7 +67,7 @@ class AdminAppSaleService { return $this->findForTenant($tenant, $saleId) ->tickets() - ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS]) + ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund']) ->orderBy('id') ->get(); } diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php index 7e2c4b3..d041fe6 100644 --- a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -54,7 +54,12 @@ class TicketController extends Controller $tenant = $request->user()->tenant()->firstOrFail(); return new AdminAppTicketResource( - $this->ticketService->refund($tenant, $ticket, $request->validated('refund_type')) + $this->ticketService->refund( + $tenant, + $ticket, + $request->validated('refund_type'), + $request->user(), + ) ); } diff --git a/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php index c542516..4de9323 100644 --- a/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php +++ b/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php @@ -2,6 +2,7 @@ namespace App\Domains\Ticket\Requests; +use App\Domains\Ticket\Models\TicketRefund; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -16,7 +17,7 @@ class AdminAppTicketRefundRequest extends FormRequest public function rules(): array { return [ - 'refund_type' => ['required', 'string', Rule::in(['partial', 'total'])], + 'refund_type' => ['required', 'string', Rule::in(TicketRefund::types())], ]; } } diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php index cae2bd1..4205f4e 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -23,6 +23,13 @@ class AdminAppTicketResource extends TicketResource 'is_active' => $this->resource->is_active(), 'can_cancel' => $this->resource->can_cancel(), 'can_refund' => $this->resource->can_refund(), + 'refund' => $this->resource->refund === null ? null : [ + 'type' => $this->resource->refund->type, + 'type_label' => $this->resource->refund->typeLabel(), + 'amount' => $this->resource->refund->amount, + 'created_at' => $this->resource->refund->created_at, + 'created_by' => $this->resource->refund->createdBy?->nombre_apellido, + ], 'values' => $rowService->values($this->resource, $details), ]; } diff --git a/app/Domains/Ticket/Resources/TicketResource.php b/app/Domains/Ticket/Resources/TicketResource.php index d45a3eb..16f7531 100644 --- a/app/Domains/Ticket/Resources/TicketResource.php +++ b/app/Domains/Ticket/Resources/TicketResource.php @@ -18,6 +18,12 @@ class TicketResource extends JsonResource 'ticket' => $this->ticket, 'status' => $this->status, 'status_label' => $this->status_label, + 'refund' => $this->whenLoaded('refund', fn (): ?array => $this->refund === null ? null : [ + 'type' => $this->refund->type, + 'type_label' => $this->refund->typeLabel(), + 'amount' => $this->refund->amount, + 'created_at' => $this->refund->created_at, + ]), 'name' => $this->name, 'description' => $this->description, 'client' => $this->user?->nombre_apellido, diff --git a/app/Domains/Ticket/Services/AdminAppTicketExcelService.php b/app/Domains/Ticket/Services/AdminAppTicketExcelService.php index c34496e..d89024f 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketExcelService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketExcelService.php @@ -41,7 +41,9 @@ class AdminAppTicketExcelService $row = $index + 2; foreach ($columns as $columnIndex => $column) { $coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).$row; - $value = $ticket[$column['key']] ?? null; + $value = $column['type'] === 'status' + ? ($ticket['status_label'] ?? $ticket[$column['key']] ?? null) + : ($ticket[$column['key']] ?? null); if ($column['type'] === 'currency' && $value !== null) { $sheet->setCellValue($coordinate, (float) $value); diff --git a/app/Domains/Ticket/Services/AdminAppTicketRowService.php b/app/Domains/Ticket/Services/AdminAppTicketRowService.php index 457acfc..7b8b8a6 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketRowService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketRowService.php @@ -23,6 +23,7 @@ class AdminAppTicketRowService public function details(Ticket $ticket): array { $purchaseItem = $ticket->sourcePurchaseItem; + $refund = $ticket->refund; return [ 'source_purchase_item_id' => $ticket->source_purchase_item_id, @@ -31,7 +32,9 @@ class AdminAppTicketRowService ?? $ticket->sourceCatalogItem?->nombre ?? $ticket->name, 'amount' => $purchaseItem?->precio_unitario, - 'refunded_amount' => $purchaseItem?->refunded_amount, + '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, 'status' => $ticket->status, 'scanned_by' => $ticket->scannerUser?->nombre_apellido, @@ -57,6 +60,7 @@ class AdminAppTicketRowService 'client' => $details['client'] ?? 'Sin nombre', 'id' => $ticket->id, 'status' => $details['status'], + 'status_label' => $ticket->status_label, 'scanned_by' => $details['scanned_by'] ?? '-', ]; } @@ -80,7 +84,9 @@ class AdminAppTicketRowService return $rows->map(fn (array $row): array => collect($columns) ->mapWithKeys(fn (array $column): array => [ $column['key'] => $this->displayValue( - $row[$column['key']] ?? null, + $column['type'] === 'status' + ? ($row['status_label'] ?? $row[$column['key']] ?? null) + : ($row[$column['key']] ?? null), $column['type'], $timeZone, ), diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 64eb532..19cac45 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -7,6 +7,7 @@ use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketRefund; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -23,6 +24,7 @@ class AdminAppTicketService 'scannerUser', 'sourceCatalogItem.category', 'sourcePurchaseItem.purchase', + 'refund.createdBy', ]; public function __construct( @@ -162,11 +164,15 @@ class AdminAppTicketService ]; } - public function refund(Tenant $tenant, int $ticketId, string $refundType): Ticket - { + public function refund( + Tenant $tenant, + int $ticketId, + string $refundType, + ?User $createdBy = null, + ): Ticket { $this->ensureRefundIsAllowed($tenant, $refundType); - return DB::transaction(function () use ($tenant, $ticketId, $refundType): Ticket { + return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): Ticket { $ticket = Ticket::query() ->where('tenant_code', $tenant->codigo) ->lockForUpdate() @@ -206,6 +212,14 @@ class AdminAppTicketService $ticket->markAsRefunded(); $ticket->save(); + TicketRefund::query()->create([ + 'ticket_id' => $ticket->id, + 'purchase_item_id' => $purchaseItem->id, + 'created_by_user_id' => $createdBy?->id, + 'type' => $refundType, + 'amount' => number_format($refundAmount, 2, '.', ''), + ]); + $purchaseItem->update([ 'refunded_amount' => number_format($refundedAmount, 2, '.', ''), ]); @@ -217,8 +231,8 @@ class AdminAppTicketService private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void { $isAllowed = match ($refundType) { - 'partial' => $tenant->allow_refund() && $tenant->allow_partial_refund(), - 'total' => $tenant->allow_refund() && (bool) $tenant->allow_ticket_total_refund, + TicketRefund::TYPE_PARTIAL => $tenant->allow_refund() && $tenant->allow_partial_refund(), + TicketRefund::TYPE_TOTAL => $tenant->allow_refund() && (bool) $tenant->allow_ticket_total_refund, }; if (! $isAllowed) { @@ -233,8 +247,8 @@ class AdminAppTicketService $ticketAmount = (float) $purchaseItem->precio_unitario; return match ($refundType) { - 'partial' => round($ticketAmount * (float) $tenant->ticket_partial_refund_percentage / 100, 2), - 'total' => $ticketAmount, + TicketRefund::TYPE_PARTIAL => round($ticketAmount * (float) $tenant->ticket_partial_refund_percentage / 100, 2), + TicketRefund::TYPE_TOTAL => $ticketAmount, }; } diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 1887e4e..89adb78 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -17,6 +17,7 @@ use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketRefund; use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider; use Database\Seeders\AttributeSeeder; use Database\Seeders\AuthorizationSeeder; @@ -192,10 +193,22 @@ class AdminAppTicketControllerTest extends TestCase ->assertOk() ->assertJsonPath('data.id', $ticket->id) ->assertJsonPath('data.status', Ticket::STATUS_REFUNDED) - ->assertJsonPath('data.refunded_amount', '100.00'); + ->assertJsonPath('data.status_label', 'Reembolso total') + ->assertJsonPath('data.refunded_amount', '100.00') + ->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, + 'created_by_user_id' => $admin->id, + 'type' => TicketRefund::TYPE_TOTAL, + 'amount' => '100.00', + ]); } public function test_it_partially_refunds_a_ticket_using_the_tenant_percentage(): void @@ -216,9 +229,58 @@ class AdminAppTicketControllerTest extends TestCase ]) ->assertOk() ->assertJsonPath('data.status', Ticket::STATUS_REFUNDED) - ->assertJsonPath('data.refunded_amount', '25.50'); + ->assertJsonPath('data.status_label', 'Reembolso parcial') + ->assertJsonPath('data.refunded_amount', '25.50') + ->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, + 'amount' => '25.50', + ]); + } + + public function test_it_records_different_refund_types_for_tickets_from_the_same_purchase_item(): void + { + $tenant = $this->createTenant('ticket-mixed-refunds'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.00, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$partialTicket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + $purchaseItem->update(['cantidad' => 2, 'total' => '200.00']); + $purchaseItem->purchase->update(['total' => '200.00']); + $totalTicket = $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $purchaseItem->id, + 'source_catalog_item_id' => $partialTicket->source_catalog_item_id, + ]); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$partialTicket->id}/refund", [ + 'refund_type' => TicketRefund::TYPE_PARTIAL, + ])->assertOk()->assertJsonPath('data.status_label', 'Reembolso parcial'); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$totalTicket->id}/refund", [ + 'refund_type' => TicketRefund::TYPE_TOTAL, + ])->assertOk()->assertJsonPath('data.status_label', 'Reembolso total'); + + $this->assertSame('125.00', $purchaseItem->fresh()->refunded_amount); + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => $partialTicket->id, + 'type' => TicketRefund::TYPE_PARTIAL, + 'amount' => '25.00', + ]); + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => $totalTicket->id, + 'type' => TicketRefund::TYPE_TOTAL, + 'amount' => '100.00', + ]); } public function test_it_does_not_refund_a_ticket_when_the_requested_refund_type_is_disabled(): void diff --git a/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php index 0bf122e..fd352b5 100644 --- a/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php +++ b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php @@ -98,6 +98,21 @@ class AdminAppTicketExportServiceTest extends TestCase $this->assertStringContainsString('Vianda', $html); } + public function test_it_uses_the_refund_type_label_when_displaying_a_refunded_status(): void + { + $row = $this->row(); + $row['status'] = 'refunded'; + $row['status_label'] = 'Reembolso parcial'; + + $displayRow = (new AdminAppTicketRowService)->displayRows( + collect([$row]), + $this->columnService()->columns($this->tenant()), + 'America/La_Paz', + )->first(); + + $this->assertSame('Reembolso parcial', $displayRow['status']); + } + private function reportService(): AdminAppTicketReportService { $rowService = Mockery::mock(AdminAppTicketRowService::class)->makePartial(); -- 2.49.1 From 67deca095e7e5085b6d2df953c513ef721147a0b Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 12:29:13 -0300 Subject: [PATCH 26/63] refactor(ticket): use refunds as amount source --- app/Domains/Purchase/Models/PurchaseItem.php | 2 - .../Resources/PurchaseItemResource.php | 1 - .../Services/PurchaseRefundSummaryService.php | 8 +-- .../Resources/AdminApp/SaleDetailResource.php | 1 - .../Services/AdminAppTicketRowService.php | 1 - .../Ticket/Services/AdminAppTicketService.php | 18 ++++-- ...ve_refunded_amount_from_purchase_items.php | 22 +++++++ .../Sale/AdminAppSaleControllerTest.php | 38 +++++++++++-- .../Ticket/AdminAppTicketControllerTest.php | 57 ++++++++++++++----- 9 files changed, 114 insertions(+), 34 deletions(-) create mode 100644 database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index 956b6dc..7a6d52b 100644 --- a/app/Domains/Purchase/Models/PurchaseItem.php +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -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', ]; } diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index f83ecf6..1bd67d5 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -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' => [ diff --git a/app/Domains/Purchase/Services/PurchaseRefundSummaryService.php b/app/Domains/Purchase/Services/PurchaseRefundSummaryService.php index 9546810..7b67fbc 100644 --- a/app/Domains/Purchase/Services/PurchaseRefundSummaryService.php +++ b/app/Domains/Purchase/Services/PurchaseRefundSummaryService.php @@ -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, '.', ''); } diff --git a/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php b/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php index 08f1736..d389d1a 100644 --- a/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php +++ b/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php @@ -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), ]; diff --git a/app/Domains/Ticket/Services/AdminAppTicketRowService.php b/app/Domains/Ticket/Services/AdminAppTicketRowService.php index 7b8b8a6..ed19924 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketRowService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketRowService.php @@ -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, diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 19cac45..9960fe3 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -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) { diff --git a/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php b/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php new file mode 100644 index 0000000..f331d8c --- /dev/null +++ b/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php @@ -0,0 +1,22 @@ +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'); + }); + } +}; diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index 81b21e9..f995133 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -20,6 +20,7 @@ use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketRefund; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; @@ -131,7 +132,8 @@ class AdminAppSaleControllerTest extends TestCase public function test_sales_list_uses_purchase_item_snapshots_for_every_status(): void { $tenant = $this->createTenant('acme'); - Sanctum::actingAs($this->createAdminAppUser($tenant)); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); $catalogItem = CatalogItem::query()->create([ 'tenant_code' => $tenant->codigo, @@ -155,7 +157,7 @@ class AdminAppSaleControllerTest extends TestCase 'status' => Purchase::STATUS_CREATED, 'total' => '30000.00', ]); - PurchaseItem::query()->create([ + $createdPurchaseItem = PurchaseItem::query()->create([ 'compra_id' => $createdPurchase->id, 'source_catalog_item_id' => $catalogItem->id, 'nombre' => $catalogItem->nombre, @@ -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([ diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 89adb78..a9ea96e 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -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') -- 2.49.1 From 28ab6c9ae4656668e35a7904a1f4fadaed9b89d6 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 12:31:03 -0300 Subject: [PATCH 27/63] fix(ticket): guard legacy refund amount removal --- ...ve_refunded_amount_from_purchase_items.php | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php b/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php index f331d8c..8c7c7a8 100644 --- a/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php +++ b/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php @@ -2,12 +2,32 @@ 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'); }); @@ -18,5 +38,15 @@ return new class extends Migration 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'); } }; -- 2.49.1 From 289ceba3af37f49009a9334f400baf0385c2e9de Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 12:35:34 -0300 Subject: [PATCH 28/63] fix(tests): isolate databases in memory and reject persistent connections --- AGENTS.md | 6 ++ composer.json | 3 +- phpunit.xml | 6 +- tests/README.md | 17 +++++ tests/Support/InMemoryConnectionFactory.php | 31 ++++++++ tests/TestCase.php | 32 ++++++--- tests/bootstrap.php | 16 +++++ tests/verify-database-safety.php | 80 +++++++++++++++++++++ 8 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/Support/InMemoryConnectionFactory.php create mode 100644 tests/bootstrap.php create mode 100644 tests/verify-database-safety.php diff --git a/AGENTS.md b/AGENTS.md index 011848b..075fcaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # Project Conventions +## Test database safety + +- Tests must use SQLite `:memory:` through `tests/bootstrap.php` and `Tests\TestCase`. +- Never run tests, `migrate:fresh`, `migrate:refresh`, or `db:wipe` against a persistent database, including the developer's `shopit` database. +- Never bypass the connection safety guard to resolve test failures. Use `php tests/verify-database-safety.php` to verify isolation without queries or migrations. + ## Architecture This project uses a domain-oriented structure under `app/Domains`. diff --git a/composer.json b/composer.json index 78ca5d7..d806e5f 100644 --- a/composer.json +++ b/composer.json @@ -52,8 +52,7 @@ "npx concurrently -c \"#93c5fd,#c4b5fd,#a7f3d0,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=emails,default --tries=1 --timeout=0\" \"php artisan schedule:work\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,scheduler,logs,vite --kill-others" ], "test": [ - "@php artisan config:clear --ansi @no_additional_args", - "@php artisan test" + "@php vendor/phpunit/phpunit/phpunit" ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", diff --git a/phpunit.xml b/phpunit.xml index 0bce1ba..9963a5e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ @@ -19,7 +19,9 @@ - + + + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d22d58f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,17 @@ +# Database isolation + +Run the suite with `composer test` or `vendor/bin/phpunit`. Both use +`tests/bootstrap.php`, which forces SQLite `:memory:` in all environment sources. +Tests never need a MySQL test database or the local database credentials. + +`Tests\TestCase` rejects cached configuration and validates the default connection +before application providers boot. Its connection factory also rejects persistent +databases, URLs, and alternate endpoints for named or dynamically built connections. +Tests that need Laravel must extend this base class. Do not bypass these guards to +make a failing test pass; adapt database-specific tests to SQLite or use a separately +designed disposable database workflow. + +`php tests/verify-database-safety.php` checks the guard and application wiring without +running test setup, queries, migrations, or opening PDO connections. + +The suite does not validate MySQL-specific behavior when using SQLite. diff --git a/tests/Support/InMemoryConnectionFactory.php b/tests/Support/InMemoryConnectionFactory.php new file mode 100644 index 0000000..71b7c18 --- /dev/null +++ b/tests/Support/InMemoryConnectionFactory.php @@ -0,0 +1,31 @@ +traitsUsedByTest = class_uses_recursive(static::class); - $database = (string) $app['config']->get( - 'database.connections.'.$app['config']->get('database.default').'.database' - ); - - if (! preg_match('/^shopit_(?:test|testing)(?:_\d+)?$/', $database)) { - throw new RuntimeException(sprintf( - 'Refusing to run tests against database [%s]. Use [shopit_test] or [shopit_testing].', - $database !== '' ? $database : '(empty)' - )); + if ($app->configurationIsCached()) { + throw new RuntimeException('Tests refuse cached configuration. Remove the test config cache before retrying.'); } + // Validate before providers boot or RefreshDatabase can run migrations. + $app->afterBootstrapping(LoadConfiguration::class, function (Application $app): void { + if (! $app->environment('testing')) { + throw new RuntimeException('Tests require APP_ENV=testing.'); + } + + InMemoryConnectionFactory::assertSafe((array) $app['config']->get( + 'database.connections.'.$app['config']->get('database.default') + )); + }); + + // Also guard named/dynamic connections and changes made by individual tests. + $app->extend('db.factory', fn () => new InMemoryConnectionFactory($app)); + $app->make(Kernel::class)->bootstrap(); + return $app; } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..913086d --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,16 @@ + 'testing', + 'DB_CONNECTION' => 'sqlite', + 'DB_DATABASE' => ':memory:', + 'DB_URL' => 'null', + 'APP_CONFIG_CACHE' => __DIR__.'/../bootstrap/cache/phpunit-config.php', +] as $key => $value) { + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; +} + +require __DIR__.'/../vendor/autoload.php'; diff --git a/tests/verify-database-safety.php b/tests/verify-database-safety.php new file mode 100644 index 0000000..2a2e1e8 --- /dev/null +++ b/tests/verify-database-safety.php @@ -0,0 +1,80 @@ + 'sqlite', 'database' => ':memory:']; +$unsafe = [ + [], + ['driver' => 'mysql', 'database' => 'shopit'], + ['driver' => 'mysql', 'database' => 'shopit_test'], + ['driver' => 'sqlite', 'database' => 'database/database.sqlite'], + ['driver' => 'sqlite', 'database' => 'shopit_test'], + array_merge($safe, ['url' => 'mysql://localhost/shopit']), + array_merge($safe, ['read' => ['database' => 'shopit']]), + array_merge($safe, ['write' => ['database' => 'shopit']]), + array_merge($safe, ['direct' => ['database' => 'shopit']]), +]; +$factory = new InMemoryConnectionFactory(new Container); +foreach ($unsafe as $config) { + try { + $factory->make($config); + } catch (RuntimeException) { + continue; + } + + throw new RuntimeException('Unsafe connection was accepted.'); +} + +$connection = $factory->make($safe); +if (! $connection->getRawPdo() instanceof Closure) { + throw new RuntimeException('Verification must not open a PDO connection.'); +} + +$case = new class('safetyCheck') extends Tests\TestCase {}; +$app = $case->createApplication(); +if (! $app['db.factory'] instanceof InMemoryConnectionFactory + || $app['config']->get('database.default') !== 'sqlite' + || ! $app['db']->connection()->getRawPdo() instanceof Closure) { + throw new RuntimeException('Application database isolation is not active.'); +} + +foreach (['mysql', 'pgsql', 'mariadb', 'sqlsrv'] as $name) { + try { + $app['db']->connection($name); + } catch (RuntimeException) { + continue; + } + + throw new RuntimeException('A persistent application connection was accepted.'); +} + +// Include URL overrides resolved by Laravel and dynamically built connections. +foreach ([ + ['driver' => 'mysql', 'database' => 'shopit'], + array_merge($safe, ['url' => 'mysql://localhost/shopit']), + array_merge($safe, ['url' => 'sqlite:///database/database.sqlite']), +] as $config) { + $app['config']->set('database.connections.unsafe', $config); + + foreach ([ + fn () => $app['db']->connection('unsafe'), + fn () => $app['db']->build($config), + ] as $connect) { + try { + $connect(); + } catch (RuntimeException) { + continue; + } + + throw new LogicException('A dynamically configured persistent connection was accepted.'); + } +} + +echo "Database safety verified: unsafe connections rejected; no PDO connections or migrations executed.\n"; -- 2.49.1 From aa14129fe749e472659b80d2958bcfb9eec1a33d Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 14:07:55 -0300 Subject: [PATCH 29/63] feat(catalog): track variant replacements --- app/Domains/Catalog/Models/Inventory.php | 6 +- app/Domains/Catalog/Models/Variant.php | 22 +++++ ...4_030000_add_variant_replacement_state.php | 86 +++++++++++++++++++ tests/Feature/Catalog/CatalogSchemaTest.php | 4 +- tests/Unit/Catalog/CatalogModelsTest.php | 2 +- 5 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 database/migrations/2026_09_14_030000_add_variant_replacement_state.php diff --git a/app/Domains/Catalog/Models/Inventory.php b/app/Domains/Catalog/Models/Inventory.php index 4a1e567..5ff7f6c 100644 --- a/app/Domains/Catalog/Models/Inventory.php +++ b/app/Domains/Catalog/Models/Inventory.php @@ -42,10 +42,10 @@ class Inventory extends Model return $this->hasOne(CatalogItem::class); } - /** @return HasOne */ - public function variant(): HasOne + /** @return HasMany */ + public function variants(): HasMany { - return $this->hasOne(Variant::class); + return $this->hasMany(Variant::class); } /** @return HasMany */ diff --git a/app/Domains/Catalog/Models/Variant.php b/app/Domains/Catalog/Models/Variant.php index 581afc3..522b781 100644 --- a/app/Domains/Catalog/Models/Variant.php +++ b/app/Domains/Catalog/Models/Variant.php @@ -20,6 +20,8 @@ use Illuminate\Support\Str; 'catalog_item_id', 'event_date_id', 'inventory_id', + 'replaced_by_variant_id', + 'sales_disabled_at', 'descripcion', 'precio', ])] @@ -37,6 +39,8 @@ class Variant extends Model 'catalog_item_id' => 'integer', 'event_date_id' => 'integer', 'inventory_id' => 'integer', + 'replaced_by_variant_id' => 'integer', + 'sales_disabled_at' => 'datetime', 'precio' => 'decimal:2', ]; } @@ -76,6 +80,24 @@ class Variant extends Model return $this->belongsTo(Inventory::class); } + /** @return BelongsTo */ + public function replacement(): BelongsTo + { + return $this->belongsTo(self::class, 'replaced_by_variant_id'); + } + + /** @return HasMany */ + public function replacedVariants(): HasMany + { + return $this->hasMany(self::class, 'replaced_by_variant_id'); + } + + public function isSellable(): bool + { + return $this->sales_disabled_at === null + && $this->replaced_by_variant_id === null; + } + /** @return HasMany */ public function definitions(): HasMany { diff --git a/database/migrations/2026_09_14_030000_add_variant_replacement_state.php b/database/migrations/2026_09_14_030000_add_variant_replacement_state.php new file mode 100644 index 0000000..2769c9d --- /dev/null +++ b/database/migrations/2026_09_14_030000_add_variant_replacement_state.php @@ -0,0 +1,86 @@ +dropForeign(['inventory_id']); + } + + $table->dropUnique('variantes_inventory_id_unique'); + $table->index('inventory_id'); + + if ($requiresForeignKeyRecreation) { + $table->foreign('inventory_id')->references('id')->on('inventories')->restrictOnDelete(); + } + + $table->foreignId('replaced_by_variant_id') + ->nullable() + ->after('inventory_id') + ->constrained('variantes') + ->nullOnDelete(); + $table->timestamp('sales_disabled_at') + ->nullable() + ->after('replaced_by_variant_id'); + $table->index( + ['sales_disabled_at', 'replaced_by_variant_id'], + 'variants_sellable_index', + ); + }); + } + + public function down(): void + { + $requiresForeignKeyRecreation = in_array(DB::getDriverName(), ['mysql', 'mariadb'], true); + + DB::table('variantes') + ->orderBy('id') + ->get() + ->groupBy('inventory_id') + ->each(function ($variants): void { + $variants->skip(1)->each(function (object $variant): void { + $inventory = DB::table('inventories')->where('id', $variant->inventory_id)->first(); + + if ($inventory === null) { + return; + } + + $inventoryId = DB::table('inventories')->insertGetId([ + 'sold_units' => $inventory->sold_units, + 'reserved_stock' => 0, + 'real_stock' => $inventory->real_stock, + ]); + + DB::table('variantes')->where('id', $variant->id)->update([ + 'inventory_id' => $inventoryId, + ]); + }); + }); + + Schema::table('variantes', function (Blueprint $table) use ($requiresForeignKeyRecreation): void { + $table->dropIndex('variants_sellable_index'); + $table->dropConstrainedForeignId('replaced_by_variant_id'); + $table->dropColumn('sales_disabled_at'); + + if ($requiresForeignKeyRecreation) { + $table->dropForeign(['inventory_id']); + } + + $table->dropIndex(['inventory_id']); + $table->unique('inventory_id'); + + if ($requiresForeignKeyRecreation) { + $table->foreign('inventory_id')->references('id')->on('inventories')->restrictOnDelete(); + } + }); + } +}; diff --git a/tests/Feature/Catalog/CatalogSchemaTest.php b/tests/Feature/Catalog/CatalogSchemaTest.php index 68b2508..94cb8a2 100644 --- a/tests/Feature/Catalog/CatalogSchemaTest.php +++ b/tests/Feature/Catalog/CatalogSchemaTest.php @@ -200,10 +200,12 @@ class CatalogSchemaTest extends TestCase ]); } - public function test_variants_can_override_catalog_item_use_dates(): void + public function test_variants_support_event_dates_and_commercial_replacements(): void { $this->assertTrue(Schema::hasColumns('variantes', [ 'event_date_id', + 'replaced_by_variant_id', + 'sales_disabled_at', ])); } diff --git a/tests/Unit/Catalog/CatalogModelsTest.php b/tests/Unit/Catalog/CatalogModelsTest.php index 84ef63d..e989b2c 100644 --- a/tests/Unit/Catalog/CatalogModelsTest.php +++ b/tests/Unit/Catalog/CatalogModelsTest.php @@ -309,7 +309,7 @@ class CatalogModelsTest extends TestCase $this->assertSame(2, $inventory->sold_units); $this->assertSame(7, $inventory->availableStock()); $this->assertInstanceOf(CatalogItem::class, $inventory->catalogItem()->getRelated()); - $this->assertInstanceOf(Variant::class, $inventory->variant()->getRelated()); + $this->assertInstanceOf(Variant::class, $inventory->variants()->getRelated()); } public function test_catalog_item_aggregates_variant_inventory(): void -- 2.49.1 From e42bc545a5614589b2a36d04c5b42e9f89c0294e Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 14:09:42 -0300 Subject: [PATCH 30/63] feat(event): replace variants when dates change --- app/Domains/Catalog/Models/CatalogItem.php | 30 +++- .../Services/CatalogInventoryService.php | 11 +- .../Catalog/Services/CatalogService.php | 17 ++- .../Services/VariantReplacementService.php | 137 ++++++++++++++++++ app/Domains/Event/Services/EventService.php | 7 +- 5 files changed, 188 insertions(+), 14 deletions(-) create mode 100644 app/Domains/Catalog/Services/VariantReplacementService.php diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 401d670..74e3b7b 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -179,11 +179,27 @@ class CatalogItem extends Model { return $query->where(function (Builder $query): void { $query - ->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) + ->where(function (Builder $unlimitedQuery): void { + $unlimitedQuery + ->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) + ->where(function (Builder $selectionQuery): void { + $selectionQuery + ->whereDoesntHave('variants') + ->orWhereHas('variants', fn (Builder $variantQuery): Builder => $variantQuery + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id')); + }); + }) ->orWhereHas( - 'variants.inventory', - fn (Builder $inventoryQuery): Builder => $inventoryQuery - ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') + 'variants', + fn (Builder $variantQuery): Builder => $variantQuery + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->whereHas( + 'inventory', + fn (Builder $inventoryQuery): Builder => $inventoryQuery + ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') + ) ) ->orWhere(function (Builder $directItemQuery): void { $directItemQuery @@ -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(); } diff --git a/app/Domains/Catalog/Services/CatalogInventoryService.php b/app/Domains/Catalog/Services/CatalogInventoryService.php index 30e433a..34de52f 100644 --- a/app/Domains/Catalog/Services/CatalogInventoryService.php +++ b/app/Domains/Catalog/Services/CatalogInventoryService.php @@ -63,9 +63,14 @@ class CatalogInventoryService $selection->loadMissing('variants.inventory'); - return $selection->variants->sum( - fn (Variant $variant): int => $variant->inventory->availableStock(), - ); + return $selection->variants + ->filter(fn (Variant $variant): bool => $variant->isSellable()) + ->unique(fn (Variant $variant): string => $variant->inventory_id === null + ? 'object:'.spl_object_id($variant->inventory) + : 'id:'.$variant->inventory_id) + ->sum( + fn (Variant $variant): int => $variant->inventory->availableStock(), + ); } $requirements = $this->inventoryRequirements($selection); diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index 2b46377..4cc8989 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -207,7 +207,8 @@ class CatalogService $visibleVariants = $catalogItem->visibleVariants(); if ($catalogItem->type === CatalogItemType::Standard && ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty()) - && ! $catalogItem->isAvailable()) { + && (($catalogItem->variants->isNotEmpty() && $visibleVariants->isEmpty()) + || ! $catalogItem->isAvailable())) { throw new NotFoundHttpException('Catalog item is out of stock.'); } @@ -324,9 +325,13 @@ class CatalogService ->findOrFail($variant->catalog_item_id); $variant->delete(); - if (! $catalogItem->variants()->exists()) { + $sellableVariants = $catalogItem->variants() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id'); + + if (! (clone $sellableVariants)->exists()) { $this->delete($catalogItem); - } elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) { + } elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) { $catalogItem->update(['precio' => $minimumPrice]); } @@ -399,7 +404,11 @@ class CatalogService ]); } - if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) { + if ($variantId !== null && ! $componentItem->variants() + ->whereKey($variantId) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->exists()) { throw ValidationException::withMessages([ "components.{$index}.variant_id" => [ __('api.catalog.component_variant_invalid'), diff --git a/app/Domains/Catalog/Services/VariantReplacementService.php b/app/Domains/Catalog/Services/VariantReplacementService.php new file mode 100644 index 0000000..b6702ce --- /dev/null +++ b/app/Domains/Catalog/Services/VariantReplacementService.php @@ -0,0 +1,137 @@ + */ + 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 $eventDateIds */ + private function findEquivalent(Variant $source, Collection $eventDateIds): ?Variant + { + $definitionSignature = $this->definitionSignature($source); + $dateSignature = $eventDateIds->map(fn ($id): int => (int) $id)->sort()->values()->all(); + + return Variant::query() + ->where('catalog_item_id', $source->catalog_item_id) + ->whereKeyNot($source->getKey()) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['eventDates', 'eventDate', 'definitions']) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->first(fn (Variant $candidate): bool => $this->definitionSignature($candidate) === $definitionSignature + && $candidate->selectedEventDates() + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->sort() + ->values() + ->all() === $dateSignature + ); + } + + /** @param Collection $eventDateIds */ + private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant + { + $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 */ + private function definitionSignature(Variant $variant): array + { + return $variant->definitions + ->map(fn ($definition): string => $definition->item_attribute_id.'\0'.$definition->value) + ->sort() + ->values() + ->all(); + } +} diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index 1cafb91..0119e15 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -3,6 +3,7 @@ namespace App\Domains\Event\Services; use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Services\VariantReplacementService; use App\Domains\Event\Events\EventDateRescheduled; use App\Domains\Event\Events\EventDateSuspended; use App\Domains\Event\Models\EventDate; @@ -23,6 +24,7 @@ class EventService public function __construct( private readonly EffectiveEventDateResolver $effectiveEventDateResolver, private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver, + private readonly VariantReplacementService $variantReplacementService, ) {} public function forTenant(Tenant $tenant): Tenant @@ -109,7 +111,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 +123,7 @@ class EventService $this->affectedDateIds($tenant, $source), ); $source->update(['rescheduled_to_event_date_id' => $destination->getKey()]); + $this->variantReplacementService->replaceEventDate($source, $effectiveDestination); EventDateRescheduled::dispatch( $tenant->codigo, @@ -154,6 +158,7 @@ class EventService $this->affectedDateIds($tenant, $date), ); $date->update(['suspended_at' => now()]); + $this->variantReplacementService->disableForSuspension($date); $this->disableTicketsWithoutUsableDates($tenant, $date); EventDateSuspended::dispatch( -- 2.49.1 From 2bd3bf9dc1bf1c5d61dccf80a963909449275751 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 14:09:49 -0300 Subject: [PATCH 31/63] feat(checkout): route carts to replacement variants --- app/Domains/Cart/Models/Cart.php | 17 ++++ .../CartVariantReplacementService.php | 99 +++++++++++++++++++ .../Checkout/CatalogSelectionResolver.php | 17 ++++ .../Checkout/StartCheckoutService.php | 3 + lang/en/api.php | 3 + lang/es/api.php | 3 + 6 files changed, 142 insertions(+) create mode 100644 app/Domains/Cart/Services/CartVariantReplacementService.php diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 9c6ea42..afdaa79 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -400,6 +400,17 @@ class Cart extends Model ]); } + if ($catalogItem->bundleComponents() + ->whereNotNull('component_variant_id') + ->whereHas('variant', fn ($query) => $query + ->whereNotNull('sales_disabled_at') + ->orWhereNotNull('replaced_by_variant_id')) + ->exists()) { + throw ValidationException::withMessages([ + 'catalog_item_id' => [__('api.cart.bundle_component_unavailable')], + ]); + } + return $catalogItem; } @@ -430,6 +441,12 @@ class Cart extends Model throw new NotFoundHttpException('Variant not found for catalog item.'); } + if (! $variant->isSellable()) { + throw ValidationException::withMessages([ + 'variant_id' => [__('api.cart.variant_unavailable')], + ]); + } + $inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate); $variant->setRelation('catalogItem', $catalogItem); $variant->setRelation('inventory', $inventory); diff --git a/app/Domains/Cart/Services/CartVariantReplacementService.php b/app/Domains/Cart/Services/CartVariantReplacementService.php new file mode 100644 index 0000000..0dda8eb --- /dev/null +++ b/app/Domains/Cart/Services/CartVariantReplacementService.php @@ -0,0 +1,99 @@ +items() + ->whereNotNull('variant_id') + ->orderBy('id') + ->lockForUpdate() + ->get(); + + foreach ($items as $item) { + $variant = Variant::query()->lockForUpdate()->find($item->variant_id); + + if ($variant === null) { + throw $this->unavailableVariant(); + } + + $replacement = $this->latestReplacement($variant); + + if ($replacement->is($variant)) { + if (! $variant->isSellable()) { + throw $this->unavailableVariant(); + } + + continue; + } + + if (! $replacement->isSellable()) { + throw $this->unavailableVariant(); + } + + /** @var CartItem|null $targetItem */ + $targetItem = $cart->items() + ->whereKeyNot($item->getKey()) + ->where('catalog_item_id', $item->catalog_item_id) + ->where('variant_id', $replacement->getKey()) + ->lockForUpdate() + ->first(); + + $replacementQuantity = $item->cantidad + ($targetItem?->cantidad ?? 0); + if ($replacement->inventory_id !== $variant->inventory_id) { + $available = $this->inventory->availableQuantity($replacement); + + if ($available !== null && $available < $replacementQuantity) { + throw $this->unavailableVariant(); + } + } + + if ($targetItem !== null) { + $targetItem->cantidad += $item->cantidad; + $targetItem->save(); + $item->delete(); + + continue; + } + + $item->update(['variant_id' => $replacement->getKey()]); + } + } + + private function latestReplacement(Variant $variant): Variant + { + $current = $variant; + $visited = []; + + while ($current->replaced_by_variant_id !== null) { + if (isset($visited[$current->getKey()])) { + throw $this->unavailableVariant(); + } + + $visited[$current->getKey()] = true; + $current = Variant::query() + ->lockForUpdate() + ->find($current->replaced_by_variant_id) + ?? throw $this->unavailableVariant(); + } + + return $current; + } + + private function unavailableVariant(): ValidationException + { + return ValidationException::withMessages([ + 'cart_id' => [__('api.cart.cart_variant_unavailable')], + ]); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php index cfa9233..15e09d2 100644 --- a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php +++ b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php @@ -41,6 +41,17 @@ class CatalogSelectionResolver ]); } + if ($catalogItem->bundleComponents() + ->whereNotNull('component_variant_id') + ->whereHas('variant', fn ($query) => $query + ->whereNotNull('sales_disabled_at') + ->orWhereNotNull('replaced_by_variant_id')) + ->exists()) { + throw ValidationException::withMessages([ + "{$fieldPrefix}.catalog_item_id" => [__('api.cart.bundle_component_unavailable')], + ]); + } + return $catalogItem; } @@ -70,6 +81,12 @@ class CatalogSelectionResolver throw new NotFoundHttpException('Variant not found for catalog item.'); } + if (! $variant->isSellable()) { + throw ValidationException::withMessages([ + "{$fieldPrefix}.variant_id" => [__('api.cart.variant_unavailable')], + ]); + } + $variant->setRelation('catalogItem', $catalogItem); $variant->setRelation( 'inventory', diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index b34fd41..71cc159 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; +use App\Domains\Cart\Services\CartVariantReplacementService; use App\Domains\Catalog\Exceptions\StockReservationExpiredException; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; @@ -29,6 +30,7 @@ class StartCheckoutService private readonly InsufficientStockMessageBuilder $stockMessages, private readonly PurchaseResponseLoader $responses, private readonly PurchaseItemSnapshotFactory $snapshots, + private readonly CartVariantReplacementService $variantReplacements, ) {} /** @param array $purchaseData */ @@ -250,6 +252,7 @@ class StartCheckoutService int $cartId, ): Purchase { $cart = $this->resolveCart($tenant, $userId, $cartId); + $this->variantReplacements->replaceHistoricalVariants($cart); $cartItems = $cart->items()->lockForUpdate()->get(); if ($cartItems->isEmpty()) { diff --git a/lang/en/api.php b/lang/en/api.php index 7ecb1f1..304f302 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -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' => [ diff --git a/lang/es/api.php b/lang/es/api.php index 55be341..d4fd6cc 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -34,7 +34,10 @@ return [ 'max_quantity' => 'El máximo que se puede agregar es :max.', 'bundle_variant_forbidden' => 'Un bundle no admite una variante.', 'empty_bundle' => 'El bundle no tiene componentes.', + 'bundle_component_unavailable' => 'El bundle contiene una variante que ya no está disponible para la venta.', 'variant_required' => 'Debe seleccionar una variante para este ítem.', + 'variant_unavailable' => 'La variante seleccionada fue reemplazada o ya no está disponible para la venta.', + 'cart_variant_unavailable' => 'El carrito contiene una variante reemplazada o que ya no está disponible para la venta.', 'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.', ], 'purchase' => [ -- 2.49.1 From b4a7b8043040cf504221e0d7e336acd9beca0ebd Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 14:11:24 -0300 Subject: [PATCH 32/63] test: cover event variant replacements --- .../Event/AdminAppEventControllerTest.php | 71 ++++++++++++++++++ tests/Feature/Purchase/StorePurchaseTest.php | 73 +++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index f4116fc..2ce68c7 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -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,6 +270,31 @@ class AdminAppEventControllerTest extends TestCase }); $this->assertDatabaseCount('event_dates', 2); + $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() @@ -287,6 +315,12 @@ class AdminAppEventControllerTest extends TestCase ])->assertOk()->assertJsonPath('data.status', 'rescheduled'); $this->assertDatabaseCount('event_dates', 3); + $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() @@ -299,6 +333,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]); @@ -335,6 +404,8 @@ class AdminAppEventControllerTest extends TestCase $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() diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index e7fd951..9014f75 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -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'); -- 2.49.1 From 18a0d14fa89e18ffd0558281b81cf180f103a70f Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 14:13:39 -0300 Subject: [PATCH 33/63] feat(event): add date reschedule history --- app/Domains/Event/Models/EventDate.php | 12 ++++ .../Event/Models/EventDateReschedule.php | 58 +++++++++++++++++++ ...00_create_event_date_reschedules_table.php | 34 +++++++++++ 3 files changed, 104 insertions(+) create mode 100644 app/Domains/Event/Models/EventDateReschedule.php create mode 100644 database/migrations/2026_09_14_020000_create_event_date_reschedules_table.php diff --git a/app/Domains/Event/Models/EventDate.php b/app/Domains/Event/Models/EventDate.php index 2dc3309..6833987 100644 --- a/app/Domains/Event/Models/EventDate.php +++ b/app/Domains/Event/Models/EventDate.php @@ -86,6 +86,18 @@ class EventDate extends Model return $this->hasMany(self::class, 'rescheduled_to_event_date_id'); } + /** @return HasMany */ + public function rescheduleHistory(): HasMany + { + return $this->hasMany(EventDateReschedule::class, 'source_event_date_id'); + } + + /** @return HasMany */ + public function destinationRescheduleHistory(): HasMany + { + return $this->hasMany(EventDateReschedule::class, 'destination_event_date_id'); + } + /** @return HasMany */ public function variants(): HasMany { diff --git a/app/Domains/Event/Models/EventDateReschedule.php b/app/Domains/Event/Models/EventDateReschedule.php new file mode 100644 index 0000000..89291be --- /dev/null +++ b/app/Domains/Event/Models/EventDateReschedule.php @@ -0,0 +1,58 @@ + 'integer', + 'destination_event_date_id' => 'integer', + 'created_by_user_id' => 'integer', + 'previous_date' => 'date:Y-m-d', + 'new_date' => 'date:Y-m-d', + 'created_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); + } + + /** @return BelongsTo */ + public function sourceEventDate(): BelongsTo + { + return $this->belongsTo(EventDate::class, 'source_event_date_id'); + } + + /** @return BelongsTo */ + public function destinationEventDate(): BelongsTo + { + return $this->belongsTo(EventDate::class, 'destination_event_date_id'); + } + + /** @return BelongsTo */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed(); + } +} diff --git a/database/migrations/2026_09_14_020000_create_event_date_reschedules_table.php b/database/migrations/2026_09_14_020000_create_event_date_reschedules_table.php new file mode 100644 index 0000000..b01dd6e --- /dev/null +++ b/database/migrations/2026_09_14_020000_create_event_date_reschedules_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('tenant_code'); + $table->foreignId('source_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete(); + $table->foreignId('destination_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->date('previous_date'); + $table->date('new_date'); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('tenant_code') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->index(['tenant_code', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_date_reschedules'); + } +}; -- 2.49.1 From 54827fbc5848d6dec44139725f1baacdde31d8ab Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 14:13:46 -0300 Subject: [PATCH 34/63] feat(event): record date reschedules --- .../Controllers/AdminApp/EventController.php | 1 + app/Domains/Event/Services/EventService.php | 21 ++++++++++++++++--- .../Event/AdminAppEventControllerTest.php | 16 ++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/Domains/Event/Controllers/AdminApp/EventController.php b/app/Domains/Event/Controllers/AdminApp/EventController.php index 24acf49..624320d 100644 --- a/app/Domains/Event/Controllers/AdminApp/EventController.php +++ b/app/Domains/Event/Controllers/AdminApp/EventController.php @@ -52,6 +52,7 @@ class EventController extends Controller $request->user()->tenant()->firstOrFail(), $eventDate, $request->validated(), + $request->user(), ) ); } diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index 0119e15..39d5607 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -2,11 +2,13 @@ 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\Events\EventDateRescheduled; use App\Domains\Event\Events\EventDateSuspended; use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Models\EventDateReschedule; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Support\Collection; @@ -75,9 +77,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) { @@ -125,6 +131,15 @@ class EventService $source->update(['rescheduled_to_event_date_id' => $destination->getKey()]); $this->variantReplacementService->replaceEventDate($source, $effectiveDestination); + EventDateReschedule::query()->create([ + 'tenant_code' => $tenant->codigo, + 'source_event_date_id' => $source->getKey(), + 'destination_event_date_id' => $destination->getKey(), + 'created_by_user_id' => $createdBy?->getKey(), + 'previous_date' => $source->date->format('Y-m-d'), + 'new_date' => $destination->date->format('Y-m-d'), + ]); + EventDateRescheduled::dispatch( $tenant->codigo, $source->getKey(), diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 2ce68c7..a849ea9 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -270,6 +270,14 @@ class AdminAppEventControllerTest extends TestCase }); $this->assertDatabaseCount('event_dates', 2); + $this->assertDatabaseHas('event_date_reschedules', [ + 'tenant_code' => $tenant->codigo, + '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); @@ -315,6 +323,14 @@ class AdminAppEventControllerTest extends TestCase ])->assertOk()->assertJsonPath('data.status', 'rescheduled'); $this->assertDatabaseCount('event_dates', 3); + $this->assertDatabaseCount('event_date_reschedules', 2); + $this->assertDatabaseHas('event_date_reschedules', [ + 'tenant_code' => $tenant->codigo, + '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); -- 2.49.1 From 0d54887602a1089a5b2cc15149020b161e3f01f0 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 14:19:29 -0300 Subject: [PATCH 35/63] feat(event): audit suspended event dates --- .../Controllers/AdminApp/EventController.php | 1 + .../Event/Enums/EventDateChangeType.php | 9 ++ app/Domains/Event/Models/EventDate.php | 12 +- ...DateReschedule.php => EventDateChange.php} | 5 +- app/Domains/Event/Services/EventService.php | 25 +++- ...lize_event_date_reschedules_as_changes.php | 115 ++++++++++++++++++ .../Event/AdminAppEventControllerTest.php | 22 +++- 7 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 app/Domains/Event/Enums/EventDateChangeType.php rename app/Domains/Event/Models/{EventDateReschedule.php => EventDateChange.php} (90%) create mode 100644 database/migrations/2026_09_14_040000_generalize_event_date_reschedules_as_changes.php diff --git a/app/Domains/Event/Controllers/AdminApp/EventController.php b/app/Domains/Event/Controllers/AdminApp/EventController.php index 624320d..bb1a783 100644 --- a/app/Domains/Event/Controllers/AdminApp/EventController.php +++ b/app/Domains/Event/Controllers/AdminApp/EventController.php @@ -63,6 +63,7 @@ class EventController extends Controller $this->eventService->suspendDateForTenant( $request->user()->tenant()->firstOrFail(), $eventDate, + $request->user(), ) ); } diff --git a/app/Domains/Event/Enums/EventDateChangeType.php b/app/Domains/Event/Enums/EventDateChangeType.php new file mode 100644 index 0000000..1dba386 --- /dev/null +++ b/app/Domains/Event/Enums/EventDateChangeType.php @@ -0,0 +1,9 @@ +hasMany(self::class, 'rescheduled_to_event_date_id'); } - /** @return HasMany */ - public function rescheduleHistory(): HasMany + /** @return HasMany */ + public function changeHistory(): HasMany { - return $this->hasMany(EventDateReschedule::class, 'source_event_date_id'); + return $this->hasMany(EventDateChange::class, 'source_event_date_id'); } - /** @return HasMany */ - public function destinationRescheduleHistory(): HasMany + /** @return HasMany */ + public function destinationChangeHistory(): HasMany { - return $this->hasMany(EventDateReschedule::class, 'destination_event_date_id'); + return $this->hasMany(EventDateChange::class, 'destination_event_date_id'); } /** @return HasMany */ diff --git a/app/Domains/Event/Models/EventDateReschedule.php b/app/Domains/Event/Models/EventDateChange.php similarity index 90% rename from app/Domains/Event/Models/EventDateReschedule.php rename to app/Domains/Event/Models/EventDateChange.php index 89291be..635c8d6 100644 --- a/app/Domains/Event/Models/EventDateReschedule.php +++ b/app/Domains/Event/Models/EventDateChange.php @@ -3,6 +3,7 @@ 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; @@ -10,19 +11,21 @@ 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 EventDateReschedule extends Model +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', diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index 39d5607..8ea5d72 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -5,10 +5,11 @@ 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\EventDateReschedule; +use App\Domains\Event\Models\EventDateChange; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Support\Collection; @@ -131,8 +132,9 @@ class EventService $source->update(['rescheduled_to_event_date_id' => $destination->getKey()]); $this->variantReplacementService->replaceEventDate($source, $effectiveDestination); - EventDateReschedule::query()->create([ + 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(), @@ -153,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) { @@ -176,6 +181,16 @@ class EventService $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(), diff --git a/database/migrations/2026_09_14_040000_generalize_event_date_reschedules_as_changes.php b/database/migrations/2026_09_14_040000_generalize_event_date_reschedules_as_changes.php new file mode 100644 index 0000000..294211c --- /dev/null +++ b/database/migrations/2026_09_14_040000_generalize_event_date_reschedules_as_changes.php @@ -0,0 +1,115 @@ +createEventDateChangesTable(); + + DB::table('event_date_changes')->insertUsing( + [ + 'tenant_code', + 'change_type', + 'source_event_date_id', + 'destination_event_date_id', + 'created_by_user_id', + 'previous_date', + 'new_date', + 'created_at', + ], + DB::table('event_date_reschedules')->select([ + 'tenant_code', + DB::raw("'rescheduled'"), + 'source_event_date_id', + 'destination_event_date_id', + 'created_by_user_id', + 'previous_date', + 'new_date', + 'created_at', + ]), + ); + + Schema::drop('event_date_reschedules'); + } + + public function down(): void + { + $this->createEventDateReschedulesTable(); + + DB::table('event_date_reschedules')->insertUsing( + [ + 'tenant_code', + 'source_event_date_id', + 'destination_event_date_id', + 'created_by_user_id', + 'previous_date', + 'new_date', + 'created_at', + ], + DB::table('event_date_changes') + ->where('change_type', 'rescheduled') + ->whereNotNull('destination_event_date_id') + ->whereNotNull('new_date') + ->select([ + 'tenant_code', + 'source_event_date_id', + 'destination_event_date_id', + 'created_by_user_id', + 'previous_date', + 'new_date', + 'created_at', + ]), + ); + + Schema::drop('event_date_changes'); + } + + private function createEventDateChangesTable(): void + { + Schema::create('event_date_changes', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('change_type', 16); + $table->foreignId('source_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete(); + $table->foreignId('destination_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->date('previous_date'); + $table->date('new_date')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('tenant_code') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->index(['tenant_code', 'created_at']); + $table->index(['source_event_date_id', 'change_type']); + }); + } + + private function createEventDateReschedulesTable(): void + { + Schema::create('event_date_reschedules', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->foreignId('source_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete(); + $table->foreignId('destination_event_date_id')->nullable()->constrained('event_dates')->nullOnDelete(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->date('previous_date'); + $table->date('new_date'); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('tenant_code') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->index(['tenant_code', 'created_at']); + }); + } +}; diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index a849ea9..74e050d 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -270,8 +270,9 @@ class AdminAppEventControllerTest extends TestCase }); $this->assertDatabaseCount('event_dates', 2); - $this->assertDatabaseHas('event_date_reschedules', [ + $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, @@ -323,9 +324,10 @@ class AdminAppEventControllerTest extends TestCase ])->assertOk()->assertJsonPath('data.status', 'rescheduled'); $this->assertDatabaseCount('event_dates', 3); - $this->assertDatabaseCount('event_date_reschedules', 2); - $this->assertDatabaseHas('event_date_reschedules', [ + $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', @@ -418,6 +420,20 @@ 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); -- 2.49.1 From 61314d516633fd64cae24c3c469e7329eec674c2 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 14:49:30 -0300 Subject: [PATCH 36/63] feat(storefront): expose event date notices --- .../Services/TenantBootstrapService.php | 1 + .../Resources/EventDateChangeResource.php | 24 ++++ .../Services/EventDateNoticeFormatter.php | 109 ++++++++++++++++++ .../Event/Services/EventDateTextFormatter.php | 25 +++- app/Domains/Tenant/Models/Tenant.php | 9 ++ .../Tenant/Resources/TenantResource.php | 10 ++ .../Event/AdminAppEventControllerTest.php | 36 ++++++ .../Event/EventDateNoticeFormatterTest.php | 97 ++++++++++++++++ .../Unit/Event/EventDateTextFormatterTest.php | 8 ++ 9 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 app/Domains/Event/Resources/EventDateChangeResource.php create mode 100644 app/Domains/Event/Services/EventDateNoticeFormatter.php create mode 100644 tests/Unit/Event/EventDateNoticeFormatterTest.php diff --git a/app/Domains/Bootstrap/Services/TenantBootstrapService.php b/app/Domains/Bootstrap/Services/TenantBootstrapService.php index 0327c4e..b4c6352 100644 --- a/app/Domains/Bootstrap/Services/TenantBootstrapService.php +++ b/app/Domains/Bootstrap/Services/TenantBootstrapService.php @@ -32,6 +32,7 @@ class TenantBootstrapService return $this->tenantInformationService->load( $tenant, [ + 'eventDateChanges', 'menues' => fn ($query) => $query->whereHas( 'roles', fn ($query) => $query->where('codigo', RoleCode::User->value) diff --git a/app/Domains/Event/Resources/EventDateChangeResource.php b/app/Domains/Event/Resources/EventDateChangeResource.php new file mode 100644 index 0000000..025e053 --- /dev/null +++ b/app/Domains/Event/Resources/EventDateChangeResource.php @@ -0,0 +1,24 @@ + */ + public function toArray(Request $request): array + { + return [ + 'type' => $this->change_type->value, + 'source_event_date_id' => $this->source_event_date_id, + 'destination_event_date_id' => $this->destination_event_date_id, + 'previous_date' => $this->previous_date->format('Y-m-d'), + 'new_date' => $this->new_date?->format('Y-m-d'), + 'occurred_at' => $this->created_at->toISOString(), + ]; + } +} diff --git a/app/Domains/Event/Services/EventDateNoticeFormatter.php b/app/Domains/Event/Services/EventDateNoticeFormatter.php new file mode 100644 index 0000000..ee38744 --- /dev/null +++ b/app/Domains/Event/Services/EventDateNoticeFormatter.php @@ -0,0 +1,109 @@ + $changes + * @return list + * }> + */ + public function format(Collection $changes): array + { + return collect([ + $this->suspensionNotice( + $changes->where('change_type', EventDateChangeType::Suspended) + ), + $this->rescheduleNotice( + $changes->where('change_type', EventDateChangeType::Rescheduled) + ), + ])->filter()->values()->all(); + } + + /** + * @param Collection $changes + * @return array{type: string, title: string, message: list}|null + */ + private function suspensionNotice(Collection $changes): ?array + { + $dates = $this->formatDates($changes, 'previous_date'); + + if ($dates === null) { + return null; + } + + $plural = $changes->count() > 1; + + return [ + 'type' => EventDateChangeType::Suspended->value, + 'title' => $plural ? 'FECHAS CANCELADAS!' : 'FECHA CANCELADA!', + 'message' => [ + ['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false], + ['text' => $dates, 'bold' => true], + ['text' => $plural ? ' han sido canceladas.' : ' ha sido cancelada.', 'bold' => false], + ], + ]; + } + + /** + * @param Collection $changes + * @return array{type: string, title: string, message: list}|null + */ + private function rescheduleNotice(Collection $changes): ?array + { + $changes = $changes->whereNotNull('new_date'); + $sourceDates = $this->formatDates($changes, 'previous_date'); + $destinationDates = $this->formatDates($changes, 'new_date'); + + if ($sourceDates === null || $destinationDates === null) { + return null; + } + + $plural = $changes->count() > 1; + $message = [ + ['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false], + ['text' => $sourceDates, 'bold' => true], + [ + 'text' => $plural ? ' han sido reprogramadas para el ' : ' ha sido reprogramada para el ', + 'bold' => false, + ], + ['text' => $destinationDates, 'bold' => true], + ]; + + if ($plural) { + $message[] = ['text' => ', ', 'bold' => false]; + $message[] = ['text' => 'respectivamente', 'bold' => true]; + } + + $message[] = ['text' => '.', 'bold' => false]; + + return [ + 'type' => EventDateChangeType::Rescheduled->value, + 'title' => $plural ? 'FECHAS REPROGRAMADAS!' : 'FECHA REPROGRAMADA!', + 'message' => $message, + ]; + } + + /** + * @param Collection $changes + */ + private function formatDates(Collection $changes, string $attribute): ?string + { + return $this->dateTextFormatter->formatForSentence( + $changes + ->pluck($attribute) + ->filter() + ->map(fn ($date): string => $date->format('Y-m-d')) + ); + } +} diff --git a/app/Domains/Event/Services/EventDateTextFormatter.php b/app/Domains/Event/Services/EventDateTextFormatter.php index 2731390..99d2f31 100644 --- a/app/Domains/Event/Services/EventDateTextFormatter.php +++ b/app/Domains/Event/Services/EventDateTextFormatter.php @@ -25,6 +25,21 @@ class EventDateTextFormatter /** @param iterable $dates */ public function format(iterable $dates): ?string { + return $this->formatWithOptions($dates, false, false); + } + + /** @param iterable $dates */ + public function formatForSentence(iterable $dates): ?string + { + return $this->formatWithOptions($dates, true, true); + } + + /** @param iterable $dates */ + private function formatWithOptions( + iterable $dates, + bool $padDays, + bool $includeYearPreposition, + ): ?string { $normalizedDates = collect($dates) ->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date)) ->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d')) @@ -37,12 +52,14 @@ class EventDateTextFormatter $years = $normalizedDates ->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y')) - ->map(function ($yearDates, string $year): string { + ->map(function ($yearDates, string $year) use ($padDays, $includeYearPreposition): string { $months = $yearDates ->groupBy(fn (DateTimeImmutable $date): string => $date->format('n')) - ->map(function ($monthDates, string $month): string { + ->map(function ($monthDates, string $month) use ($padDays): string { $days = $monthDates - ->map(fn (DateTimeImmutable $date): string => (string) ((int) $date->format('j'))) + ->map(fn (DateTimeImmutable $date): string => $padDays + ? $date->format('d') + : (string) ((int) $date->format('j'))) ->values() ->all(); @@ -51,7 +68,7 @@ class EventDateTextFormatter ->values() ->all(); - return $this->join($months).' '.$year; + return $this->join($months).($includeYearPreposition ? ' de ' : ' ').$year; }) ->values() ->all(); diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index e575def..e119319 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -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 */ + public function eventDateChanges(): HasMany + { + return $this->hasMany(EventDateChange::class, 'tenant_code', 'codigo') + ->orderBy('created_at') + ->orderBy('id'); + } + /** * @return HasMany */ diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 1b94e48..c15e28e 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -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', diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 74e050d..cd6c7c3 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -309,6 +309,21 @@ class AdminAppEventControllerTest extends TestCase ->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', @@ -344,6 +359,15 @@ class AdminAppEventControllerTest extends TestCase ->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', @@ -443,6 +467,18 @@ class AdminAppEventControllerTest extends TestCase ->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', diff --git a/tests/Unit/Event/EventDateNoticeFormatterTest.php b/tests/Unit/Event/EventDateNoticeFormatterTest.php new file mode 100644 index 0000000..af53628 --- /dev/null +++ b/tests/Unit/Event/EventDateNoticeFormatterTest.php @@ -0,0 +1,97 @@ +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; + } +} diff --git a/tests/Unit/Event/EventDateTextFormatterTest.php b/tests/Unit/Event/EventDateTextFormatterTest.php index ca86e70..a5f1f7b 100644 --- a/tests/Unit/Event/EventDateTextFormatterTest.php +++ b/tests/Unit/Event/EventDateTextFormatterTest.php @@ -35,4 +35,12 @@ class EventDateTextFormatterTest extends TestCase ], ]; } + + public function test_it_formats_dates_for_use_inside_sentences(): void + { + $this->assertSame( + '09 y 10 de Octubre de 2026', + (new EventDateTextFormatter)->formatForSentence(['2026-10-10', '2026-10-09']) + ); + } } -- 2.49.1 From 91022c5897af1f78f8519349b2a70c302de595ba Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 15:00:23 -0300 Subject: [PATCH 37/63] fix(forms): hide inactive food event dates --- .../Forms/Services/FoodFormService.php | 6 ++- .../Forms/AdminAppFoodFormControllerTest.php | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/app/Domains/Forms/Services/FoodFormService.php b/app/Domains/Forms/Services/FoodFormService.php index 72f2b07..240ea5d 100644 --- a/app/Domains/Forms/Services/FoodFormService.php +++ b/app/Domains/Forms/Services/FoodFormService.php @@ -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, ]; diff --git a/tests/Feature/Forms/AdminAppFoodFormControllerTest.php b/tests/Feature/Forms/AdminAppFoodFormControllerTest.php index f3e9db3..50f01e1 100644 --- a/tests/Feature/Forms/AdminAppFoodFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppFoodFormControllerTest.php @@ -67,6 +67,51 @@ class AdminAppFoodFormControllerTest extends TestCase ->assertJsonPath('data.services.0.value', 'Comedor'); } + public function test_it_excludes_rescheduled_and_suspended_event_dates(): void + { + $tenant = Tenant::query()->create([ + 'codigo' => 'fiesta', + 'nombre' => 'Fiesta', + 'dominio' => 'fiesta.test', + 'website_type_code' => 'onticket', + ]); + $available = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $rescheduled = $tenant->eventDates()->create([ + 'date' => '2026-10-10', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $replacement = $tenant->eventDates()->create([ + 'date' => '2026-10-11', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $rescheduled->update(['rescheduled_to_event_date_id' => $replacement->id]); + $tenant->eventDates()->create([ + 'date' => '2026-10-12', + 'time_start' => '00:00', + 'time_end' => '23:59', + 'suspended_at' => now(), + ]); + + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ])); + + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/food') + ->assertOk() + ->assertJsonCount(2, 'data.event_dates') + ->assertJsonFragment(['id' => $available->id, 'date' => '2026-10-09']) + ->assertJsonFragment(['id' => $replacement->id, 'date' => '2026-10-11']) + ->assertJsonMissing(['date' => '2026-10-10']) + ->assertJsonMissing(['date' => '2026-10-12']); + } + /** @param array $options */ private function createAttribute(Tenant $tenant, string $code, array $options): void { -- 2.49.1 From 7d45734ad735b86709954e808d0b3f1696e61d10 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 15:00:29 -0300 Subject: [PATCH 38/63] feat(forms): add entry form event dates --- .../AdminApp/EntryFormController.php | 22 +++++ .../Forms/Resources/EntryFormResource.php | 26 ++++++ .../Forms/Services/EntryFormService.php | 22 +++++ app/Domains/Forms/routes/adminapp.php | 5 ++ .../Forms/AdminAppEntryFormControllerTest.php | 82 +++++++++++++++++++ 5 files changed, 157 insertions(+) create mode 100644 app/Domains/Forms/Controllers/AdminApp/EntryFormController.php create mode 100644 app/Domains/Forms/Resources/EntryFormResource.php create mode 100644 app/Domains/Forms/Services/EntryFormService.php create mode 100644 tests/Feature/Forms/AdminAppEntryFormControllerTest.php diff --git a/app/Domains/Forms/Controllers/AdminApp/EntryFormController.php b/app/Domains/Forms/Controllers/AdminApp/EntryFormController.php new file mode 100644 index 0000000..518425e --- /dev/null +++ b/app/Domains/Forms/Controllers/AdminApp/EntryFormController.php @@ -0,0 +1,22 @@ +entryFormService->get( + $request->user('sanctum')->tenant()->firstOrFail() + ) + ); + } +} diff --git a/app/Domains/Forms/Resources/EntryFormResource.php b/app/Domains/Forms/Resources/EntryFormResource.php new file mode 100644 index 0000000..87f2bef --- /dev/null +++ b/app/Domains/Forms/Resources/EntryFormResource.php @@ -0,0 +1,26 @@ + */ + public function toArray(Request $request): array + { + return [ + 'event_dates' => $this->resource['event_dates']->map( + fn (EventDate $eventDate): array => [ + 'id' => $eventDate->id, + 'validity_time_id' => $eventDate->validity_time_id, + 'validity_time' => ValidityTimeResource::make($eventDate->validityTime), + 'date' => $eventDate->date->format('Y-m-d'), + ] + )->values(), + ]; + } +} diff --git a/app/Domains/Forms/Services/EntryFormService.php b/app/Domains/Forms/Services/EntryFormService.php new file mode 100644 index 0000000..33311dc --- /dev/null +++ b/app/Domains/Forms/Services/EntryFormService.php @@ -0,0 +1,22 @@ +} */ + public function get(Tenant $tenant): array + { + return [ + 'event_dates' => $tenant->eventDates() + ->whereNull('rescheduled_to_event_date_id') + ->whereNull('suspended_at') + ->with('validityTime') + ->get(), + ]; + } +} diff --git a/app/Domains/Forms/routes/adminapp.php b/app/Domains/Forms/routes/adminapp.php index 70f9ee3..93c41e5 100644 --- a/app/Domains/Forms/routes/adminapp.php +++ b/app/Domains/Forms/routes/adminapp.php @@ -1,5 +1,6 @@ seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/entry') + ->assertUnauthorized(); + } + + public function test_it_returns_only_selectable_event_dates_for_the_tenant(): void + { + $tenant = $this->createTenant('fiesta'); + $otherTenant = $this->createTenant('other'); + $available = $this->createEventDate($tenant, '2026-10-09'); + $rescheduled = $this->createEventDate($tenant, '2026-10-10'); + $replacement = $this->createEventDate($tenant, '2026-10-11'); + $rescheduled->update(['rescheduled_to_event_date_id' => $replacement->id]); + $this->createEventDate($tenant, '2026-10-12', ['suspended_at' => now()]); + $this->createEventDate($otherTenant, '2026-10-13'); + + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ])); + + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/entry') + ->assertOk() + ->assertJsonCount(2, 'data.event_dates') + ->assertJsonFragment(['id' => $available->id, 'date' => '2026-10-09']) + ->assertJsonFragment(['id' => $replacement->id, 'date' => '2026-10-11']) + ->assertJsonMissing(['date' => '2026-10-10']) + ->assertJsonMissing(['date' => '2026-10-12']) + ->assertJsonMissing(['date' => '2026-10-13']); + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => 'onticket', + ]); + } + + /** @param array $overrides */ + private function createEventDate( + Tenant $tenant, + string $date, + array $overrides = [] + ): EventDate { + return $tenant->eventDates()->create([ + 'date' => $date, + 'time_start' => '00:00', + 'time_end' => '23:59', + ...$overrides, + ]); + } +} -- 2.49.1 From b1e09b71adf593c7cdff441d2887934e9803f477 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 15:13:16 -0300 Subject: [PATCH 39/63] feat(migrations): implement backfill for legacy refunds and add migration tests --- ...ve_refunded_amount_from_purchase_items.php | 78 ++++++++- ...oveRefundedAmountFromPurchaseItemsTest.php | 151 ++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Migrations/RemoveRefundedAmountFromPurchaseItemsTest.php diff --git a/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php b/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php index 8c7c7a8..ffc3ae6 100644 --- a/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php +++ b/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php @@ -9,6 +9,8 @@ return new class extends Migration { public function up(): void { + $this->backfillLegacyRefunds(); + $mismatchedItem = DB::table('compra_items as purchase_items') ->leftJoin('ticket_refunds as refunds', 'refunds.purchase_item_id', '=', 'purchase_items.id') ->where('purchase_items.refunded_amount', '>', 0) @@ -24,7 +26,7 @@ return new class extends Migration 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.' + .'contiene un importe histórico que no se pudo respaldar con ticket_refunds.' ); } @@ -49,4 +51,78 @@ return new class extends Migration ->update(['refunded_amount' => $refund->refund_total]); }, column: 'purchase_item_id'); } + + private function backfillLegacyRefunds(): void + { + $items = DB::table('compra_items as purchase_items') + ->leftJoin('ticket_refunds as refunds', 'refunds.purchase_item_id', '=', 'purchase_items.id') + ->where('purchase_items.refunded_amount', '>', 0) + ->groupBy( + 'purchase_items.id', + 'purchase_items.refunded_amount', + 'purchase_items.precio_unitario', + ) + ->selectRaw( + 'purchase_items.id, purchase_items.refunded_amount, purchase_items.precio_unitario, ' + .'COALESCE(SUM(refunds.amount), 0) as refund_total' + ) + ->orderBy('purchase_items.id') + ->get(); + + foreach ($items as $item) { + $legacyAmountInCents = (int) round( + ((float) $item->refunded_amount - (float) $item->refund_total) * 100 + ); + + if ($legacyAmountInCents <= 0) { + continue; + } + + $tickets = DB::table('tickets as tickets') + ->leftJoin('ticket_refunds as refunds', 'refunds.ticket_id', '=', 'tickets.id') + ->where('tickets.source_purchase_item_id', $item->id) + ->whereNotNull('tickets.refunded_at') + ->whereNull('refunds.id') + ->orderBy('tickets.refunded_at') + ->orderBy('tickets.id') + ->get(['tickets.id', 'tickets.refunded_at']); + + $ticketCount = $tickets->count(); + $unitPriceInCents = (int) round((float) $item->precio_unitario * 100); + + if ($ticketCount === 0 + || $unitPriceInCents <= 0 + || $legacyAmountInCents > $ticketCount * $unitPriceInCents) { + continue; + } + + $baseAmountInCents = intdiv($legacyAmountInCents, $ticketCount); + $remainderInCents = $legacyAmountInCents % $ticketCount; + + if ($baseAmountInCents === 0) { + continue; + } + + $refunds = $tickets->values()->map(function (object $ticket, int $index) use ( + $item, + $baseAmountInCents, + $remainderInCents, + $unitPriceInCents, + ): array { + $amountInCents = $baseAmountInCents + ($index < $remainderInCents ? 1 : 0); + + return [ + 'ticket_id' => $ticket->id, + 'purchase_item_id' => $item->id, + 'created_by_user_id' => null, + 'type' => $amountInCents === $unitPriceInCents ? 'total' : 'partial', + 'amount' => number_format($amountInCents / 100, 2, '.', ''), + 'created_at' => $ticket->refunded_at, + 'updated_at' => $ticket->refunded_at, + ]; + })->all(); + + DB::table('ticket_refunds')->insert($refunds); + } + } }; diff --git a/tests/Feature/Migrations/RemoveRefundedAmountFromPurchaseItemsTest.php b/tests/Feature/Migrations/RemoveRefundedAmountFromPurchaseItemsTest.php new file mode 100644 index 0000000..c26aa95 --- /dev/null +++ b/tests/Feature/Migrations/RemoveRefundedAmountFromPurchaseItemsTest.php @@ -0,0 +1,151 @@ +originalConnection = DB::getDefaultConnection(); + config()->set('database.connections.refund_migration_test', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); + DB::setDefaultConnection('refund_migration_test'); + + Schema::create('compra_items', function (Blueprint $table): void { + $table->id(); + $table->decimal('precio_unitario', 10, 2); + $table->decimal('total', 10, 2); + $table->decimal('refunded_amount', 10, 2)->default(0); + }); + Schema::create('tickets', function (Blueprint $table): void { + $table->id(); + $table->foreignId('source_purchase_item_id')->nullable(); + $table->dateTime('refunded_at')->nullable(); + }); + Schema::create('ticket_refunds', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->unique(); + $table->foreignId('purchase_item_id'); + $table->foreignId('created_by_user_id')->nullable(); + $table->string('type', 16); + $table->decimal('amount', 10, 2); + $table->timestamps(); + }); + } + + protected function tearDown(): void + { + DB::purge('refund_migration_test'); + DB::setDefaultConnection($this->originalConnection); + + parent::tearDown(); + } + + public function test_it_backfills_a_refund_created_before_ticket_refunds_existed(): void + { + DB::table('compra_items')->insert([ + 'id' => 254, + 'precio_unitario' => '100.00', + 'total' => '100.00', + 'refunded_amount' => '40.00', + ]); + DB::table('tickets')->insert([ + 'id' => 501, + 'source_purchase_item_id' => 254, + 'refunded_at' => '2026-09-13 18:30:00', + ]); + + $this->migration()->up(); + + $this->assertFalse(Schema::hasColumn('compra_items', 'refunded_amount')); + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => 501, + 'purchase_item_id' => 254, + 'created_by_user_id' => null, + 'type' => 'partial', + 'amount' => 40, + 'created_at' => '2026-09-13 18:30:00', + ]); + } + + public function test_it_only_backfills_the_amount_not_already_in_ticket_refunds(): void + { + DB::table('compra_items')->insert([ + 'id' => 254, + 'precio_unitario' => '100.00', + 'total' => '200.00', + 'refunded_amount' => '140.00', + ]); + DB::table('tickets')->insert([ + [ + 'id' => 501, + 'source_purchase_item_id' => 254, + 'refunded_at' => '2026-09-13 18:30:00', + ], + [ + 'id' => 502, + 'source_purchase_item_id' => 254, + 'refunded_at' => '2026-09-14 10:00:00', + ], + ]); + DB::table('ticket_refunds')->insert([ + 'ticket_id' => 502, + 'purchase_item_id' => 254, + 'created_by_user_id' => 7, + 'type' => 'total', + 'amount' => '100.00', + 'created_at' => '2026-09-14 10:00:00', + 'updated_at' => '2026-09-14 10:00:00', + ]); + + $this->migration()->up(); + + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => 501, + 'purchase_item_id' => 254, + 'created_by_user_id' => null, + 'type' => 'partial', + 'amount' => 40, + ]); + $this->assertSame(2, DB::table('ticket_refunds')->count()); + } + + public function test_it_still_refuses_to_drop_an_amount_without_a_refunded_ticket(): void + { + DB::table('compra_items')->insert([ + 'id' => 254, + 'precio_unitario' => '100.00', + 'total' => '100.00', + 'refunded_amount' => '40.00', + ]); + + try { + $this->migration()->up(); + $this->fail('The migration should preserve an amount that cannot be backfilled.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('ítem 254', $exception->getMessage()); + $this->assertTrue(Schema::hasColumn('compra_items', 'refunded_amount')); + } + } + + private function migration(): object + { + return require database_path( + 'migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php' + ); + } +} -- 2.49.1 From c27d9628fd7c316774b7431b0df3754a68a8d37e Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 16:48:06 -0300 Subject: [PATCH 40/63] feat(event): filter event dates to exclude rescheduled and suspended entries --- app/Domains/Catalog/Models/Attribute.php | 2 ++ .../CatalogItemDetailControllerTest.php | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/Domains/Catalog/Models/Attribute.php b/app/Domains/Catalog/Models/Attribute.php index 58e1f99..c4c9dee 100644 --- a/app/Domains/Catalog/Models/Attribute.php +++ b/app/Domains/Catalog/Models/Attribute.php @@ -59,6 +59,8 @@ class Attribute extends Model public function eventDates(): HasMany { return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo') + ->whereNull('rescheduled_to_event_date_id') + ->whereNull('suspended_at') ->orderBy('date') ->orderBy('time_start'); } diff --git a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php index 5d8d3a8..b466afb 100644 --- a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php +++ b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php @@ -220,6 +220,18 @@ class CatalogItemDetailControllerTest extends TestCase 'time_start' => '10:00', 'time_end' => '19:00', ]); + $rescheduledEventDate = $tenant->eventDates()->create([ + 'date' => '2026-10-08', + 'time_start' => '10:00', + 'time_end' => '19:00', + 'rescheduled_to_event_date_id' => $unusedEventDate->id, + ]); + $suspendedEventDate = $tenant->eventDates()->create([ + 'date' => '2026-10-11', + 'time_start' => '10:00', + 'time_end' => '19:00', + 'suspended_at' => now(), + ]); $item = $this->createItem($tenant, 'Entry'); $attribute = Attribute::query()->create([ 'tenant_codigo' => $tenant->codigo, @@ -245,6 +257,14 @@ class CatalogItemDetailControllerTest extends TestCase ->assertJsonPath('data.attributes.0.options.0.validity_time.type', 'fixed_window') ->assertJsonPath('data.attributes.0.options.1.id', $unusedEventDate->id) ->assertJsonPath('data.attributes.0.options.1.value', (string) $unusedEventDate->id) + ->assertJsonMissing([ + 'id' => $rescheduledEventDate->id, + 'value' => (string) $rescheduledEventDate->id, + ]) + ->assertJsonMissing([ + 'id' => $suspendedEventDate->id, + 'value' => (string) $suspendedEventDate->id, + ]) ->assertJsonPath('data.variants.0.values.event_date.value', (string) $eventDate->id) ->assertJsonPath( 'data.variants.0.values.event_date.label', -- 2.49.1 From 9fa4da7de2ee443c21eac5c6ad02af7992cc4715 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 16:52:34 -0300 Subject: [PATCH 41/63] feat(variants): add filtering for inactive event dates in visibleVariants method --- app/Domains/Catalog/Models/CatalogItem.php | 11 +++++---- app/Domains/Catalog/Models/Variant.php | 17 ++++++++++++- tests/Unit/Catalog/CatalogModelsTest.php | 28 ++++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 74e3b7b..18309a0 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -221,11 +221,12 @@ class CatalogItem extends Model public function visibleVariants(?int $includedVariantId = null): Collection { return $this->variants - ->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId) - || ($variant->isSellable() && ( - $this->inventory_policy === InventoryPolicy::Unlimited - || ($variant->inventory?->availableStock() ?? 0) > 0 - ))) + ->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates() + && (($includedVariantId !== null && $variant->id === $includedVariantId) + || ($variant->isSellable() && ( + $this->inventory_policy === InventoryPolicy::Unlimited + || ($variant->inventory?->availableStock() ?? 0) > 0 + )))) ->values(); } diff --git a/app/Domains/Catalog/Models/Variant.php b/app/Domains/Catalog/Models/Variant.php index 522b781..0873958 100644 --- a/app/Domains/Catalog/Models/Variant.php +++ b/app/Domains/Catalog/Models/Variant.php @@ -95,7 +95,22 @@ class Variant extends Model public function isSellable(): bool { return $this->sales_disabled_at === null - && $this->replaced_by_variant_id === null; + && $this->replaced_by_variant_id === null + && $this->hasOnlyActiveEventDates(); + } + + public function hasOnlyActiveEventDates(): bool + { + if (! $this->exists + && $this->event_date_id === null + && ! $this->relationLoaded('eventDates')) { + return true; + } + + return $this->selectedEventDates()->every( + fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null + && $eventDate->suspended_at === null, + ); } /** @return HasMany */ diff --git a/tests/Unit/Catalog/CatalogModelsTest.php b/tests/Unit/Catalog/CatalogModelsTest.php index e989b2c..45ca556 100644 --- a/tests/Unit/Catalog/CatalogModelsTest.php +++ b/tests/Unit/Catalog/CatalogModelsTest.php @@ -346,6 +346,34 @@ class CatalogModelsTest extends TestCase $this->assertSame([$unavailable, $available], $item->visibleVariants()->all()); } + public function test_catalog_item_never_exposes_variants_with_inactive_event_dates(): void + { + $activeDate = new EventDate(['date' => '2026-10-10']); + $rescheduledDate = new EventDate([ + 'date' => '2026-09-08', + 'rescheduled_to_event_date_id' => 100, + ]); + $suspendedDate = new EventDate([ + 'date' => '2026-10-08', + 'suspended_at' => now(), + ]); + + $active = (new Variant)->setRelation('eventDates', new EloquentCollection([$activeDate])); + $rescheduled = (new Variant)->setRelation('eventDates', new EloquentCollection([$rescheduledDate])); + $suspended = (new Variant)->setRelation('eventDates', new EloquentCollection([$suspendedDate])); + $active->id = 10; + $rescheduled->id = 20; + $suspended->id = 30; + + $item = new CatalogItem; + $item->inventory_policy = InventoryPolicy::Unlimited; + $item->setRelation('variants', new EloquentCollection([$active, $rescheduled, $suspended])); + + $this->assertSame([$active], $item->visibleVariants()->all()); + $this->assertSame([$active], $item->visibleVariants($rescheduled->id)->all()); + $this->assertSame([$active], $item->visibleVariants($suspended->id)->all()); + } + public function test_catalog_item_prioritizes_its_inventory_over_variants(): void { $item = new CatalogItem; -- 2.49.1 From ff43ba32f35127b7845869b074493a905e3528fb Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 15 Sep 2026 15:03:41 -0300 Subject: [PATCH 42/63] feat(food): add endpoint to update historical stock and separate current and historical food variants --- app/Domains/Event/Models/EventDate.php | 10 +- .../Controllers/FoodController.php | 11 ++ .../UpdateHistoricalFoodStockRequest.php | 24 +++ .../Resources/FoodResource.php | 143 ++++++++++++++++-- .../Services/FoodService.php | 131 +++++++++++++++- .../FiestaFutbolInfantil/routes/api.php | 3 + .../Forms/Services/FoodFormService.php | 9 +- .../FoodControllerTest.php | 104 +++++++++++++ tests/Unit/Event/EventModelsTest.php | 18 +++ 9 files changed, 426 insertions(+), 27 deletions(-) create mode 100644 app/Domains/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php diff --git a/app/Domains/Event/Models/EventDate.php b/app/Domains/Event/Models/EventDate.php index c70df51..81b826a 100644 --- a/app/Domains/Event/Models/EventDate.php +++ b/app/Domains/Event/Models/EventDate.php @@ -122,7 +122,11 @@ class EventDate extends Model public function endsAt(): CarbonInterface { - return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end); + $endsAt = Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end); + + return $endsAt->lessThanOrEqualTo($this->startsAt()) + ? $endsAt->addDay() + : $endsAt; } public function getStatusAttribute(): EventDateStatus @@ -169,10 +173,6 @@ class EventDate extends Model $startsAt = $this->startsAt(); $expiresAt = $this->endsAt(); - if ($expiresAt->lessThanOrEqualTo($startsAt)) { - $expiresAt = $expiresAt->addDay(); - } - $attributes = [ 'type' => ValidityTimeType::FixedWindow, 'start_time' => null, diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php b/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php index 109a836..1923540 100644 --- a/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php +++ b/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php @@ -3,6 +3,7 @@ namespace App\Domains\FiestaFutbolInfantil\Controllers; use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest; +use App\Domains\FiestaFutbolInfantil\Requests\UpdateHistoricalFoodStockRequest; use App\Domains\FiestaFutbolInfantil\Resources\FoodResource; use App\Domains\FiestaFutbolInfantil\Services\FoodService; use App\Http\Controllers\Controller; @@ -32,6 +33,16 @@ class FoodController extends Controller ); } + public function updateHistoricalStock(UpdateHistoricalFoodStockRequest $request): FoodResource + { + return FoodResource::make( + $this->foodService->updateHistoricalStock( + $request->user()->tenant()->firstOrFail(), + $request->validated('variants'), + ) + ); + } + public function destroy(Request $request, int $food): Response { $this->foodService->delete( diff --git a/app/Domains/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php b/app/Domains/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php new file mode 100644 index 0000000..b75832d --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php @@ -0,0 +1,24 @@ + */ + public function rules(): array + { + return [ + 'variants' => ['required', 'array', 'min:1', 'max:500'], + 'variants.*' => ['required', 'array:id,stock'], + 'variants.*.id' => ['required', 'integer', 'distinct'], + 'variants.*.stock' => ['required', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php b/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php index ad1f800..e3e854c 100644 --- a/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php +++ b/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php @@ -3,8 +3,14 @@ namespace App\Domains\FiestaFutbolInfantil\Resources; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Enums\EventDateChangeType; +use App\Domains\Event\Enums\EventDateStatus; +use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Models\EventDateChange; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Collection; /** @mixin CatalogItem */ class FoodResource extends JsonResource @@ -17,27 +23,134 @@ class FoodResource extends JsonResource 'id' => null, 'name' => 'Comida', 'variants' => [], + 'history' => [], ]; } + $currentVariants = $this->variants + ->filter(fn (Variant $variant): bool => $this->isCurrent($variant)); + $historicalVariants = $this->variants + ->filter(fn (Variant $variant): bool => $this->isHistorical($variant)); + return [ 'id' => $this->id, 'name' => $this->nombre, - 'variants' => $this->variants->map(function ($variant): array { - $values = $variant->selectionValues(); - $eventDate = $variant->selectedEventDates()->first(); - - return [ - 'id' => $variant->id, - 'event_date_id' => $eventDate?->id, - 'event_date' => $eventDate?->date?->format('Y-m-d'), - 'schedule' => $values->get('horario'), - 'service' => $values->get('servicio'), - 'description' => $variant->descripcion, - 'stock' => $variant->inventory->real_stock, - 'price' => number_format($variant->getPrice(), 2, '.', ''), - ]; - })->values(), + 'variants' => $currentVariants->map($this->variantData(...))->values(), + 'history' => $this->historyData($historicalVariants), ]; } + + private function isCurrent(Variant $variant): bool + { + if ($variant->sales_disabled_at !== null || $variant->replaced_by_variant_id !== null) { + return false; + } + + $status = $variant->selectedEventDates()->first()?->status; + + return $status === null || in_array( + $status, + [EventDateStatus::Scheduled, EventDateStatus::InProgress], + true, + ); + } + + private function isHistorical(Variant $variant): bool + { + return in_array( + $variant->selectedEventDates()->first()?->status, + [ + EventDateStatus::Rescheduled, + EventDateStatus::Suspended, + EventDateStatus::Completed, + ], + true, + ); + } + + /** @return array */ + private function variantData(Variant $variant): array + { + $values = $variant->selectionValues(); + $eventDate = $variant->selectedEventDates()->first(); + + return [ + 'id' => $variant->id, + 'event_date_id' => $eventDate?->id, + 'event_date' => $eventDate?->date?->format('Y-m-d'), + 'schedule' => $values->get('horario'), + 'service' => $values->get('servicio'), + 'description' => $variant->descripcion, + 'stock' => $variant->inventory->real_stock, + 'price' => number_format($variant->getPrice(), 2, '.', ''), + ]; + } + + /** + * @param Collection $variants + * @return Collection> + */ + private function historyData(Collection $variants): Collection + { + return $variants + ->filter(fn (Variant $variant): bool => $variant->selectedEventDates()->first() !== null) + ->groupBy(fn (Variant $variant): int => (int) $variant->selectedEventDates()->first()->id) + ->map(function (Collection $dateVariants): array { + /** @var EventDate $eventDate */ + $eventDate = $dateVariants->first()->selectedEventDates()->first(); + $status = $this->historicalStatus($eventDate); + $change = $this->changeForStatus($eventDate, $status); + + return [ + 'id' => $eventDate->id, + 'change_id' => $change?->id, + 'status' => $status->value, + 'status_text' => match ($status) { + EventDateStatus::Rescheduled => 'REPROGRAMADA', + EventDateStatus::Suspended => 'CANCELADA', + EventDateStatus::Completed => 'FINALIZADA', + default => '', + }, + 'event_date_id' => $eventDate->id, + 'event_date' => $eventDate->date->format('Y-m-d'), + 'replacement_event_date_id' => $status === EventDateStatus::Rescheduled + ? ($change?->destination_event_date_id + ?? $eventDate->rescheduled_to_event_date_id) + : null, + 'replacement_event_date' => $status === EventDateStatus::Rescheduled + ? ($change?->new_date?->format('Y-m-d') + ?? $eventDate->rescheduledTo?->date?->format('Y-m-d')) + : null, + 'occurred_at' => ($change?->created_at ?? $eventDate->endsAt())->toISOString(), + 'variants' => $dateVariants->map($this->variantData(...))->values(), + ]; + }) + ->sortByDesc('occurred_at') + ->values(); + } + + private function historicalStatus(EventDate $eventDate): EventDateStatus + { + return match ($eventDate->status) { + EventDateStatus::Rescheduled => EventDateStatus::Rescheduled, + EventDateStatus::Suspended => EventDateStatus::Suspended, + default => EventDateStatus::Completed, + }; + } + + private function changeForStatus( + EventDate $eventDate, + EventDateStatus $status, + ): ?EventDateChange { + $changeType = match ($status) { + EventDateStatus::Rescheduled => EventDateChangeType::Rescheduled, + EventDateStatus::Suspended => EventDateChangeType::Suspended, + default => null, + }; + + return $changeType === null + ? null + : $eventDate->changeHistory + ->first(fn (EventDateChange $change): bool => $change->change_type === $changeType); + } } diff --git a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php index 7551b69..06caafb 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php @@ -11,6 +11,8 @@ use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogService; +use App\Domains\Event\Enums\EventDateStatus; +use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -36,8 +38,10 @@ class FoodService ->with([ 'variants.catalogItem', 'variants.inventory', - 'variants.eventDate', - 'variants.eventDates', + 'variants.eventDate.rescheduledTo', + 'variants.eventDate.changeHistory.destinationEventDate', + 'variants.eventDates.rescheduledTo', + 'variants.eventDates.changeHistory.destinationEventDate', 'variants.definitions.itemAttribute.attribute', ]) ->first(); @@ -55,11 +59,16 @@ class FoodService $food->variants()->whereNull('precio')->update(['precio' => $food->precio]); $existingVariants = $food->variants() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') ->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute']) ->lockForUpdate() - ->get(); + ->get() + ->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant)) + ->values(); $resolvedVariants = $this->resolveVariants($variants, $attributes); + $this->validateCurrentEventDates($tenant, $resolvedVariants); $this->validateCombinations($resolvedVariants, $existingVariants); foreach ($resolvedVariants as $index => $data) { @@ -80,7 +89,13 @@ class FoodService } } - $minimumPrice = $food->variants()->min('precio'); + $minimumPrice = $food->variants() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['eventDate', 'eventDates']) + ->get() + ->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant)) + ->min('precio'); if ($minimumPrice !== null) { $food->update(['precio' => $minimumPrice]); } @@ -88,22 +103,81 @@ class FoodService return $food->fresh()->load([ 'variants.catalogItem', 'variants.inventory', - 'variants.eventDate', - 'variants.eventDates', + 'variants.eventDate.rescheduledTo', + 'variants.eventDate.changeHistory.destinationEventDate', + 'variants.eventDates.rescheduledTo', + 'variants.eventDates.changeHistory.destinationEventDate', 'variants.definitions.itemAttribute.attribute', ]); }); } + /** + * @param array $variants + */ + public function updateHistoricalStock(Tenant $tenant, array $variants): CatalogItem + { + return DB::transaction(function () use ($tenant, $variants): CatalogItem { + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->lockForUpdate() + ->firstOrFail(); + $variantIds = collect($variants)->pluck('id')->map(fn ($id): int => (int) $id); + $historicalVariants = $food->variants() + ->whereIn('id', $variantIds) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['inventory', 'eventDate', 'eventDates']) + ->lockForUpdate() + ->get() + ->filter(fn (Variant $variant): bool => $this->hasHistoricalDate($variant)) + ->keyBy('id'); + + foreach ($variants as $index => $data) { + $variant = $historicalVariants->get((int) $data['id']); + if ($variant === null) { + throw ValidationException::withMessages([ + "variants.{$index}.id" => [ + 'La variante no pertenece al historial de Comida.', + ], + ]); + } + + $stock = (int) $data['stock']; + $inventory = Inventory::query() + ->whereKey($variant->inventory_id) + ->lockForUpdate() + ->firstOrFail(); + if ($stock < $inventory->reserved_stock) { + throw ValidationException::withMessages([ + "variants.{$index}.stock" => [ + 'El stock no puede ser menor que la cantidad actualmente reservada.', + ], + ]); + } + + $inventory->update(['real_stock' => $stock]); + } + + return $this->current($tenant) ?? $food; + }); + } + public function delete(Tenant $tenant, int $foodId): void { $variant = Variant::query() ->whereKey($foodId) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['eventDate', 'eventDates']) ->whereHas('catalogItem', fn ($query) => $query ->where('tenant_code', $tenant->codigo) ->where('slug', 'comida')) ->firstOrFail(); + abort_unless($this->hasCurrentDate($variant), 404); + $this->catalogService->deleteVariant($variant); } @@ -225,6 +299,30 @@ class FoodService return $option; } + /** @param array> $variants */ + private function validateCurrentEventDates(Tenant $tenant, array $variants): void + { + $eventDates = EventDate::query() + ->where('tenant_code', $tenant->codigo) + ->whereIn('id', collect($variants)->pluck('event_date_id')->unique()) + ->get() + ->keyBy('id'); + + foreach ($variants as $index => $variant) { + $eventDate = $eventDates->get($variant['event_date_id']); + + if ($eventDate !== null && $this->isCurrentStatus($eventDate->status)) { + continue; + } + + throw ValidationException::withMessages([ + "variants.{$index}.event_date_id" => [ + 'La fecha seleccionada ya no está disponible.', + ], + ]); + } + } + /** * @param array> $incoming * @param Collection $existing @@ -329,4 +427,25 @@ class FoodService mb_strtolower(trim($service)), ]); } + + private function hasCurrentDate(Variant $variant): bool + { + $status = $variant->selectedEventDates()->first()?->status; + + return $status === null || $this->isCurrentStatus($status); + } + + private function hasHistoricalDate(Variant $variant): bool + { + return in_array( + $variant->selectedEventDates()->first()?->status, + [EventDateStatus::Rescheduled, EventDateStatus::Suspended, EventDateStatus::Completed], + true, + ); + } + + private function isCurrentStatus(EventDateStatus $status): bool + { + return in_array($status, [EventDateStatus::Scheduled, EventDateStatus::InProgress], true); + } } diff --git a/app/Domains/FiestaFutbolInfantil/routes/api.php b/app/Domains/FiestaFutbolInfantil/routes/api.php index 39d4a47..242cad0 100644 --- a/app/Domains/FiestaFutbolInfantil/routes/api.php +++ b/app/Domains/FiestaFutbolInfantil/routes/api.php @@ -39,6 +39,9 @@ Route::prefix('v1/adminapp/tenant') Route::post('foods', [FoodController::class, 'store']) ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') ->name('adminapp.fiesta-futbol-infantil.foods.store'); + Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') + ->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update'); Route::delete('foods/{food}', [FoodController::class, 'destroy']) ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') ->name('adminapp.fiesta-futbol-infantil.foods.destroy'); diff --git a/app/Domains/Forms/Services/FoodFormService.php b/app/Domains/Forms/Services/FoodFormService.php index 240ea5d..5ed7636 100644 --- a/app/Domains/Forms/Services/FoodFormService.php +++ b/app/Domains/Forms/Services/FoodFormService.php @@ -4,6 +4,7 @@ namespace App\Domains\Forms\Services; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\AttributeOption; +use App\Domains\Event\Enums\EventDateStatus; use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Collection; @@ -31,7 +32,13 @@ class FoodFormService ->whereNull('rescheduled_to_event_date_id') ->whereNull('suspended_at') ->with('validityTime') - ->get(), + ->get() + ->filter(fn (EventDate $eventDate): bool => in_array( + $eventDate->status, + [EventDateStatus::Scheduled, EventDateStatus::InProgress], + true, + )) + ->values(), 'schedules' => $attributes->get('horario')?->options ?? new Collection, 'services' => $attributes->get('servicio')?->options ?? new Collection, ]; diff --git a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php index f03dac5..d41a7bb 100644 --- a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php @@ -8,12 +8,14 @@ use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Services\EventService; use App\Domains\Menu\Models\Menu; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -32,6 +34,13 @@ class FoodControllerTest extends TestCase ]); } + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + public function test_authentication_is_required(): void { $this->postJson('/api/v1/adminapp/tenant/foods', ['variants' => []]) @@ -142,6 +151,101 @@ class FoodControllerTest extends TestCase ]); } + public function test_it_separates_current_and_historical_food_variants(): void + { + Carbon::setTestNow('2026-09-01 12:00:00'); + [$tenant, $rescheduledDate, $suspendedDate] = $this->configuredTenant(); + $completedDate = $tenant->eventDates()->create([ + 'date' => '2026-10-11', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $activeDate = $tenant->eventDates()->create([ + 'date' => '2026-10-21', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($rescheduledDate->id, 'Almuerzo', 'Comedor', 100, 10000), + $this->variantPayload($suspendedDate->id, 'Cena', 'Vianda', 90, 9000), + $this->variantPayload($completedDate->id, 'Cena', 'Comedor', 80, 8000), + $this->variantPayload($activeDate->id, 'Almuerzo', 'Vianda', 70, 7000), + ], + ])->assertOk(); + + $eventService = app(EventService::class); + $eventService->rescheduleDateForTenant( + $tenant, + $rescheduledDate, + ['date' => '2026-10-20'], + $admin, + ); + $eventService->suspendDateForTenant($tenant, $suspendedDate, $admin); + + Carbon::setTestNow('2026-10-15 12:00:00'); + $response = $this->getJson('/api/v1/adminapp/tenant/foods')->assertOk(); + + $response->assertJsonCount(2, 'data.variants')->assertJsonCount(3, 'data.history'); + $history = collect($response->json('data.history')); + + $rescheduled = $history->firstWhere('status', 'rescheduled'); + $this->assertSame('REPROGRAMADA', $rescheduled['status_text']); + $this->assertSame('2026-10-09', $rescheduled['event_date']); + $this->assertSame('2026-10-20', $rescheduled['replacement_event_date']); + $this->assertCount(1, $rescheduled['variants']); + + $suspended = $history->firstWhere('status', 'suspended'); + $this->assertSame('CANCELADA', $suspended['status_text']); + $this->assertNull($suspended['replacement_event_date']); + + $completed = $history->firstWhere('status', 'completed'); + $this->assertSame('FINALIZADA', $completed['status_text']); + $this->assertSame('2026-10-11', $completed['event_date']); + $this->assertNull($completed['replacement_event_date']); + + } + + public function test_it_updates_only_the_stock_of_historical_food_variants(): void + { + [$tenant, $historicalDate, $activeDate] = $this->configuredTenant(); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $created = $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($historicalDate->id, 'Almuerzo', 'Comedor', 100, 10000), + $this->variantPayload($activeDate->id, 'Cena', 'Vianda', 80, 8000), + ], + ])->assertOk(); + $historicalVariantId = $created->json('data.variants.0.id'); + $activeVariantId = $created->json('data.variants.1.id'); + + app(EventService::class)->suspendDateForTenant($tenant, $historicalDate, $admin); + + $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [ + 'variants' => [['id' => $historicalVariantId, 'stock' => 45]], + ]) + ->assertOk() + ->assertJsonPath('data.history.0.variants.0.id', $historicalVariantId) + ->assertJsonPath('data.history.0.variants.0.stock', 45); + + $historicalInventoryId = Variant::query()->findOrFail($historicalVariantId)->inventory_id; + $this->assertDatabaseHas('inventories', [ + 'id' => $historicalInventoryId, + 'real_stock' => 45, + ]); + + $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [ + 'variants' => [['id' => $activeVariantId, 'stock' => 20]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['variants.0.id']); + } + public function test_it_deletes_food_records_and_removes_the_empty_product(): void { [$tenant, $firstDate, $secondDate] = $this->configuredTenant(); diff --git a/tests/Unit/Event/EventModelsTest.php b/tests/Unit/Event/EventModelsTest.php index d05a04b..cb3673c 100644 --- a/tests/Unit/Event/EventModelsTest.php +++ b/tests/Unit/Event/EventModelsTest.php @@ -65,6 +65,24 @@ class EventModelsTest extends TestCase } } + public function test_an_overnight_event_finishes_on_the_following_day(): void + { + Carbon::setTestNow('2026-10-10 01:00:00'); + + try { + $eventDate = new EventDate([ + 'date' => '2026-10-09', + 'time_start' => '20:00:00', + 'time_end' => '02:00:00', + ]); + + $this->assertSame('2026-10-10 02:00:00', $eventDate->endsAt()->format('Y-m-d H:i:s')); + $this->assertSame(EventDateStatus::InProgress, $eventDate->status); + } finally { + Carbon::setTestNow(); + } + } + public function test_tenant_has_many_event_dates(): void { $tenant = new Tenant; -- 2.49.1 From dcf4383fbf973cc25ae28b533f4254a3894ffcc1 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 15 Sep 2026 15:15:18 -0300 Subject: [PATCH 43/63] feat(variants): implement inventory cloning for historical stock updates and adjust related tests --- .../Services/VariantReplacementService.php | 41 +++++++++++++++++++ .../Controllers/FoodController.php | 2 +- .../Services/FoodService.php | 33 ++++++++++++--- .../Event/AdminAppEventControllerTest.php | 24 +++++++++-- .../FoodControllerTest.php | 22 +++++++++- 5 files changed, 110 insertions(+), 12 deletions(-) diff --git a/app/Domains/Catalog/Services/VariantReplacementService.php b/app/Domains/Catalog/Services/VariantReplacementService.php index b6702ce..0c170aa 100644 --- a/app/Domains/Catalog/Services/VariantReplacementService.php +++ b/app/Domains/Catalog/Services/VariantReplacementService.php @@ -3,6 +3,9 @@ namespace App\Domains\Catalog\Services; use App\Domains\Catalog\Models\BundleComponent; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\StockReservation; +use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Models\Variant; use App\Domains\Event\Models\EventDate; use Illuminate\Support\Collection; @@ -92,11 +95,14 @@ class VariantReplacementService /** @param Collection $eventDateIds */ private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant { + $replacementInventory = $this->cloneInventory($source); $replacement = $source->replicate([ 'event_date_id', + 'inventory_id', 'replaced_by_variant_id', 'sales_disabled_at', ]); + $replacement->inventory_id = $replacementInventory->getKey(); $replacement->event_date_id = $eventDateIds->count() === 1 ? $eventDateIds->first() : null; @@ -125,6 +131,41 @@ class VariantReplacementService return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']); } + private function cloneInventory(Variant $source): Inventory + { + $activeLines = StockReservationLine::query() + ->where('inventory_id', $source->inventory_id) + ->whereHas('reservation', fn ($reservation) => $reservation + ->where('status', StockReservation::STATUS_ACTIVE)) + ->orderBy('id') + ->lockForUpdate() + ->get(); + $sourceInventory = Inventory::query() + ->whereKey($source->inventory_id) + ->lockForUpdate() + ->firstOrFail(); + $reservedStock = (int) $activeLines->sum('quantity'); + + if ($sourceInventory->reserved_stock !== $reservedStock) { + throw new \LogicException('El inventario reservado de la variante es inconsistente.'); + } + + $replacementInventory = Inventory::query()->create([ + 'sold_units' => $sourceInventory->sold_units, + 'reserved_stock' => $reservedStock, + 'real_stock' => $sourceInventory->real_stock, + ]); + + if ($activeLines->isNotEmpty()) { + StockReservationLine::query() + ->whereKey($activeLines->modelKeys()) + ->update(['inventory_id' => $replacementInventory->getKey()]); + } + $sourceInventory->update(['reserved_stock' => 0]); + + return $replacementInventory; + } + /** @return list */ private function definitionSignature(Variant $variant): array { diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php b/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php index 1923540..103bdf0 100644 --- a/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php +++ b/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php @@ -2,8 +2,8 @@ namespace App\Domains\FiestaFutbolInfantil\Controllers; -use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest; use App\Domains\FiestaFutbolInfantil\Requests\UpdateHistoricalFoodStockRequest; +use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest; use App\Domains\FiestaFutbolInfantil\Resources\FoodResource; use App\Domains\FiestaFutbolInfantil\Services\FoodService; use App\Http\Controllers\Controller; diff --git a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php index 06caafb..571d1cd 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php @@ -126,8 +126,6 @@ class FoodService $variantIds = collect($variants)->pluck('id')->map(fn ($id): int => (int) $id); $historicalVariants = $food->variants() ->whereIn('id', $variantIds) - ->whereNull('sales_disabled_at') - ->whereNull('replaced_by_variant_id') ->with(['inventory', 'eventDate', 'eventDates']) ->lockForUpdate() ->get() @@ -145,10 +143,7 @@ class FoodService } $stock = (int) $data['stock']; - $inventory = Inventory::query() - ->whereKey($variant->inventory_id) - ->lockForUpdate() - ->firstOrFail(); + $inventory = $this->inventoryForHistoricalStockUpdate($variant); if ($stock < $inventory->reserved_stock) { throw ValidationException::withMessages([ "variants.{$index}.stock" => [ @@ -164,6 +159,32 @@ class FoodService }); } + private function inventoryForHistoricalStockUpdate(Variant $variant): Inventory + { + $inventory = Inventory::query() + ->whereKey($variant->inventory_id) + ->lockForUpdate() + ->firstOrFail(); + $variantsSharingInventory = Variant::query() + ->where('inventory_id', $inventory->getKey()) + ->orderBy('id') + ->lockForUpdate() + ->get(['id']); + + if ($variantsSharingInventory->count() === 1) { + return $inventory; + } + + $historicalInventory = Inventory::query()->create([ + 'sold_units' => $inventory->sold_units, + 'reserved_stock' => 0, + 'real_stock' => $inventory->real_stock, + ]); + $variant->update(['inventory_id' => $historicalInventory->getKey()]); + + return $historicalInventory; + } + public function delete(Tenant $tenant, int $foodId): void { $variant = Variant::query() diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index cd6c7c3..32cad55 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -8,6 +8,8 @@ use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\StockReservation; +use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Models\Variant; use App\Domains\Event\Events\EventDateRescheduled; use App\Domains\Event\Events\EventDateSuspended; @@ -249,7 +251,17 @@ class AdminAppEventControllerTest extends TestCase 'time_end' => '20:00', ]); $variant = $this->createVariant($tenant, $original->id); - $variant->inventory()->update(['real_stock' => 5]); + $variant->inventory()->update(['real_stock' => 5, 'reserved_stock' => 2]); + $reservation = StockReservation::query()->create([ + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => now()->addHour(), + ]); + $reservationLine = StockReservationLine::query()->create([ + 'stock_reservation_id' => $reservation->id, + 'inventory_id' => $variant->inventory_id, + 'quantity' => 2, + 'tracks_inventory' => true, + ]); $ticket = $this->createTicket($tenant, $admin, $variant); Sanctum::actingAs($admin); @@ -283,7 +295,12 @@ class AdminAppEventControllerTest extends TestCase $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->assertNotSame($variant->inventory_id, $replacement->inventory_id); + $this->assertSame(5, $variant->inventory->fresh()->real_stock); + $this->assertSame(0, $variant->inventory->fresh()->reserved_stock); + $this->assertSame(5, $replacement->inventory->real_stock); + $this->assertSame(2, $replacement->inventory->reserved_stock); + $this->assertSame($replacement->inventory_id, $reservationLine->fresh()->inventory_id); $this->assertNotNull($variant->sales_disabled_at); $this->assertSame($replacement->id, $variant->replaced_by_variant_id); $this->assertSame( @@ -350,7 +367,8 @@ class AdminAppEventControllerTest extends TestCase ]); $replacement->refresh(); $latestReplacement = $replacement->replacement()->firstOrFail(); - $this->assertSame($replacement->inventory_id, $latestReplacement->inventory_id); + $this->assertNotSame($replacement->inventory_id, $latestReplacement->inventory_id); + $this->assertSame(5, $latestReplacement->inventory->real_stock); $this->assertSame('2027-10-25', $latestReplacement->eventDate->date->format('Y-m-d')); $this->assertFalse($replacement->isSellable()); $this->assertTrue($latestReplacement->isSellable()); diff --git a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php index d41a7bb..3bda7f1 100644 --- a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php @@ -7,6 +7,7 @@ use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; +use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Event\Services\EventService; use App\Domains\Menu\Models\Menu; @@ -224,16 +225,33 @@ class FoodControllerTest extends TestCase $historicalVariantId = $created->json('data.variants.0.id'); $activeVariantId = $created->json('data.variants.1.id'); - app(EventService::class)->suspendDateForTenant($tenant, $historicalDate, $admin); + app(EventService::class)->rescheduleDateForTenant( + $tenant, + $historicalDate, + ['date' => $activeDate->date->format('Y-m-d')], + $admin, + ); + $historicalVariant = Variant::query()->findOrFail($historicalVariantId); + $replacementVariant = $historicalVariant->replacement()->firstOrFail(); + $replacementInventoryId = $replacementVariant->inventory_id; - $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [ + // Simula variantes creadas antes de que la reprogramación separara sus inventarios. + $replacementVariant->update(['inventory_id' => $historicalVariant->inventory_id]); + Inventory::query()->whereKey($replacementInventoryId)->delete(); + + $updated = $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [ 'variants' => [['id' => $historicalVariantId, 'stock' => 45]], ]) ->assertOk() ->assertJsonPath('data.history.0.variants.0.id', $historicalVariantId) ->assertJsonPath('data.history.0.variants.0.stock', 45); + $replacement = collect($updated->json('data.variants')) + ->firstWhere('schedule', 'Almuerzo'); + $this->assertSame(100, $replacement['stock']); $historicalInventoryId = Variant::query()->findOrFail($historicalVariantId)->inventory_id; + $currentInventoryId = $replacementVariant->fresh()->inventory_id; + $this->assertNotSame($historicalInventoryId, $currentInventoryId); $this->assertDatabaseHas('inventories', [ 'id' => $historicalInventoryId, 'real_stock' => 45, -- 2.49.1 From 51937ca93f9abed970259aabee5e0689603b43fc Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 15 Sep 2026 16:08:42 -0300 Subject: [PATCH 44/63] feat(event): add EventDateInfoFormatter to format event date messages and implement related tests --- .../Event/Services/EventDateInfoFormatter.php | 37 ++++++++++++++ .../Tenant/Resources/TenantResource.php | 14 +++-- .../Event/AdminAppEventControllerTest.php | 11 +++- .../Unit/Event/EventDateInfoFormatterTest.php | 51 +++++++++++++++++++ 4 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 app/Domains/Event/Services/EventDateInfoFormatter.php create mode 100644 tests/Unit/Event/EventDateInfoFormatterTest.php diff --git a/app/Domains/Event/Services/EventDateInfoFormatter.php b/app/Domains/Event/Services/EventDateInfoFormatter.php new file mode 100644 index 0000000..28b3d2a --- /dev/null +++ b/app/Domains/Event/Services/EventDateInfoFormatter.php @@ -0,0 +1,37 @@ + $changes */ + public function format(EventDate $eventDate, Collection $changes): ?string + { + $messages = collect(); + + if ($eventDate->suspended_at !== null) { + $messages->push('Esta fecha fue cancelada.'); + } + + $sourceDates = $changes + ->where('change_type', EventDateChangeType::Rescheduled) + ->where('destination_event_date_id', $eventDate->getKey()) + ->pluck('previous_date') + ->filter() + ->map(fn ($date): string => $date->format('d/m/Y')) + ->unique() + ->values(); + + if ($sourceDates->isNotEmpty()) { + $verb = $sourceDates->count() === 1 ? 'se reprogramó' : 'se reprogramaron'; + $messages->push("{$sourceDates->join(', ', ' y ')} {$verb} para este día."); + } + + return $messages->isEmpty() ? null : $messages->join(' '); + } +} diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index c15e28e..b68a8b2 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -7,6 +7,7 @@ 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\EventDateInfoFormatter; use App\Domains\Event\Services\EventDateNoticeFormatter; use App\Domains\Menu\Models\Menu; use App\Domains\Tenant\Models\Tenant; @@ -25,6 +26,10 @@ class TenantResource extends JsonResource */ public function toArray(Request $request): array { + $eventDateChanges = $this->relationLoaded('eventDateChanges') + ? $this->eventDateChanges + : collect(); + return [ 'id' => $this->id, 'client_id' => $this->client_id, @@ -54,14 +59,17 @@ class TenantResource extends JsonResource 'title' => $this->event_title, 'location' => $this->event_location, 'dates' => $this->eventDates - ->filter(fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null - && $eventDate->suspended_at === null - ) + ->filter(fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null) ->map(fn (EventDate $eventDate): array => [ 'id' => $eventDate->id, 'date' => $eventDate->date->format('Y-m-d'), 'time_start' => $eventDate->time_start, 'time_end' => $eventDate->time_end, + 'info_text' => app(EventDateInfoFormatter::class)->format( + $eventDate, + $eventDateChanges, + ), + 'isCanceled' => $eventDate->suspended_at !== null, ])->values(), 'date_changes' => $this->whenLoaded( 'eventDateChanges', diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 32cad55..5ffa50d 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -326,6 +326,8 @@ class AdminAppEventControllerTest extends TestCase ->assertOk() ->assertJsonCount(1, 'data.event.dates') ->assertJsonPath('data.event.dates.0.id', $destination->id) + ->assertJsonPath('data.event.dates.0.info_text', '09/10/2027 se reprogramó para este día.') + ->assertJsonPath('data.event.dates.0.isCanceled', false) ->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) @@ -483,8 +485,13 @@ class AdminAppEventControllerTest extends TestCase $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(2, 'data.event.dates') + ->assertJsonPath('data.event.dates.0.id', $suspendedDate->id) + ->assertJsonPath('data.event.dates.0.info_text', 'Esta fecha fue cancelada.') + ->assertJsonPath('data.event.dates.0.isCanceled', true) + ->assertJsonPath('data.event.dates.1.id', $otherDate->id) + ->assertJsonPath('data.event.dates.1.info_text', null) + ->assertJsonPath('data.event.dates.1.isCanceled', false) ->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) diff --git a/tests/Unit/Event/EventDateInfoFormatterTest.php b/tests/Unit/Event/EventDateInfoFormatterTest.php new file mode 100644 index 0000000..40eb789 --- /dev/null +++ b/tests/Unit/Event/EventDateInfoFormatterTest.php @@ -0,0 +1,51 @@ +id = 10; + + $this->assertNull((new EventDateInfoFormatter)->format($date, collect())); + } + + public function test_it_formats_cancellation_and_source_dates_for_a_reschedule_destination(): void + { + $date = new EventDate; + $date->id = 20; + $date->suspended_at = Carbon::parse('2026-09-15'); + + $changes = collect([ + $this->reschedule(1, 20, '2026-10-09'), + $this->reschedule(2, 20, '2026-10-10'), + ]); + + $this->assertSame( + 'Esta fecha fue cancelada. 09/10/2026 y 10/10/2026 se reprogramaron para este día.', + (new EventDateInfoFormatter)->format($date, $changes), + ); + } + + private function reschedule(int $sourceId, int $destinationId, string $previousDate): EventDateChange + { + $change = new EventDateChange; + $change->setRawAttributes([ + 'change_type' => EventDateChangeType::Rescheduled->value, + 'source_event_date_id' => $sourceId, + 'destination_event_date_id' => $destinationId, + 'previous_date' => $previousDate, + ]); + + return $change; + } +} -- 2.49.1 From 24d87623ccad03446ebd6e4c5d72ca7545c97fe4 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 15 Sep 2026 16:28:39 -0300 Subject: [PATCH 45/63] feat(event): implement EventDateGroupingService to group historical dates and update related resources and tests --- .../Event/Resources/EventDateResource.php | 3 + app/Domains/Event/Resources/EventResource.php | 5 +- .../Services/EventDateGroupingService.php | 101 ++++++++++++++++++ .../Event/Services/EventDateInfoFormatter.php | 45 +++++++- .../Event/AdminAppEventControllerTest.php | 46 ++++++++ .../Event/EventDateGroupingServiceTest.php | 78 ++++++++++++++ .../Unit/Event/EventDateInfoFormatterTest.php | 19 ++++ 7 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 app/Domains/Event/Services/EventDateGroupingService.php create mode 100644 tests/Unit/Event/EventDateGroupingServiceTest.php diff --git a/app/Domains/Event/Resources/EventDateResource.php b/app/Domains/Event/Resources/EventDateResource.php index 0c15cca..6ebba0b 100644 --- a/app/Domains/Event/Resources/EventDateResource.php +++ b/app/Domains/Event/Resources/EventDateResource.php @@ -23,6 +23,9 @@ class EventDateResource extends JsonResource 'status' => $this->status->value, 'rescheduled_to_event_date_id' => $this->rescheduled_to_event_date_id, 'suspended_at' => $this->suspended_at?->toISOString(), + 'rescheduled_dates' => EventDateResource::collection( + $this->whenLoaded('adminRescheduledDates') + ), ]; } } diff --git a/app/Domains/Event/Resources/EventResource.php b/app/Domains/Event/Resources/EventResource.php index 207cf42..150dcc7 100644 --- a/app/Domains/Event/Resources/EventResource.php +++ b/app/Domains/Event/Resources/EventResource.php @@ -2,6 +2,7 @@ namespace App\Domains\Event\Resources; +use App\Domains\Event\Services\EventDateGroupingService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -22,7 +23,9 @@ class EventResource extends JsonResource 'allow_ticket_total_refund' => $this->allow_ticket_total_refund, 'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund, 'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage, - 'dates' => EventDateResource::collection($this->eventDates), + 'dates' => EventDateResource::collection( + app(EventDateGroupingService::class)->group($this->eventDates) + ), 'social_media' => $this->socialMedia->map(fn ($item): array => [ 'code' => $item->code, 'url' => $item->pivot->url, diff --git a/app/Domains/Event/Services/EventDateGroupingService.php b/app/Domains/Event/Services/EventDateGroupingService.php new file mode 100644 index 0000000..83dff7c --- /dev/null +++ b/app/Domains/Event/Services/EventDateGroupingService.php @@ -0,0 +1,101 @@ + $dates + * @return Collection + */ + public function group(Collection $dates): Collection + { + $byId = $dates->keyBy(fn (EventDate $date): int => (int) $date->getKey()); + $groups = collect(); + + foreach ($dates as $date) { + $destination = $this->finalDestination($date, $byId); + $key = (int) $destination->getKey(); + + if (! $groups->has($key)) { + $groups->put($key, [ + 'destination' => $destination, + 'rescheduled' => collect(), + ]); + } + + if (! $date->is($destination)) { + $group = $groups->get($key); + $historicalDate = clone $date; + $historicalDate->setAttribute( + 'rescheduled_to_event_date_id', + $destination->getKey(), + ); + $group['rescheduled']->push($historicalDate); + $groups->put($key, $group); + } + } + + return $groups + ->map(function (array $group): EventDate { + /** @var EventDate $destination */ + $destination = clone $group['destination']; + /** @var Collection $rescheduled */ + $rescheduled = $group['rescheduled']; + $destination->setRelation( + 'adminRescheduledDates', + new EloquentCollection($rescheduled->sort($this->dateSorter())->values()->all()), + ); + + return $destination; + }) + ->sort($this->dateSorter()) + ->values(); + } + + /** @param Collection $byId */ + private function finalDestination(EventDate $date, Collection $byId): EventDate + { + $current = $date; + $visited = collect(); + + while ($current->rescheduled_to_event_date_id !== null) { + $currentId = (int) $current->getKey(); + + if ($visited->contains($currentId)) { + break; + } + + $visited->push($currentId); + $destination = $byId->get((int) $current->rescheduled_to_event_date_id); + + if (! $destination instanceof EventDate) { + break; + } + + $current = $destination; + } + + return $current; + } + + /** @return callable(EventDate, EventDate): int */ + private function dateSorter(): callable + { + return fn (EventDate $left, EventDate $right): int => [ + $left->date->format('Y-m-d'), + $left->time_start, + $left->getKey(), + ] <=> [ + $right->date->format('Y-m-d'), + $right->time_start, + $right->getKey(), + ]; + } +} diff --git a/app/Domains/Event/Services/EventDateInfoFormatter.php b/app/Domains/Event/Services/EventDateInfoFormatter.php index 28b3d2a..9442d14 100644 --- a/app/Domains/Event/Services/EventDateInfoFormatter.php +++ b/app/Domains/Event/Services/EventDateInfoFormatter.php @@ -18,9 +18,7 @@ class EventDateInfoFormatter $messages->push('Esta fecha fue cancelada.'); } - $sourceDates = $changes - ->where('change_type', EventDateChangeType::Rescheduled) - ->where('destination_event_date_id', $eventDate->getKey()) + $sourceDates = $this->reschedulesEndingAt($eventDate, $changes) ->pluck('previous_date') ->filter() ->map(fn ($date): string => $date->format('d/m/Y')) @@ -34,4 +32,45 @@ class EventDateInfoFormatter return $messages->isEmpty() ? null : $messages->join(' '); } + + /** + * Includes direct and intermediate reschedules that ultimately end at the + * displayed event date, while preserving the original change order. + * + * @param Collection $changes + * @return Collection + */ + private function reschedulesEndingAt(EventDate $eventDate, Collection $changes): Collection + { + $eventDateId = $eventDate->getKey(); + $reschedules = $changes->where('change_type', EventDateChangeType::Rescheduled); + + if ($eventDateId === null) { + return $reschedules->where('destination_event_date_id', null); + } + + $destinationIds = [(int) $eventDateId => true]; + + do { + $foundAncestor = false; + + foreach ($reschedules as $change) { + $destinationId = $change->destination_event_date_id; + $sourceId = $change->source_event_date_id; + + if ($destinationId === null || $sourceId === null) { + continue; + } + + if (isset($destinationIds[(int) $destinationId]) && ! isset($destinationIds[(int) $sourceId])) { + $destinationIds[(int) $sourceId] = true; + $foundAncestor = true; + } + } + } while ($foundAncestor); + + return $reschedules + ->filter(fn (EventDateChange $change): bool => $change->destination_event_date_id !== null + && isset($destinationIds[(int) $change->destination_event_date_id])); + } } diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 5ffa50d..e5431e9 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -150,6 +150,52 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonMissing(['title' => 'Other Event']); } + public function test_dates_are_grouped_by_their_final_destination_and_ordered_by_active_date(): void + { + $tenant = $this->createActiveEvent($this->createTenant('acme'), 'Acme Event'); + $source13 = $tenant->eventDates()->create([ + 'date' => '2026-10-13', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $source22 = $tenant->eventDates()->create([ + 'date' => '2026-10-22', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $middle24 = $tenant->eventDates()->create([ + 'date' => '2026-10-24', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $active25 = $tenant->eventDates()->create([ + 'date' => '2026-10-25', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $destination30 = $tenant->eventDates()->create([ + 'date' => '2026-10-30', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $source13->update(['rescheduled_to_event_date_id' => $destination30->id]); + $source22->update(['rescheduled_to_event_date_id' => $middle24->id]); + $middle24->update(['rescheduled_to_event_date_id' => $destination30->id]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/event') + ->assertOk() + ->assertJsonCount(2, 'data.dates') + ->assertJsonPath('data.dates.0.id', $active25->id) + ->assertJsonCount(0, 'data.dates.0.rescheduled_dates') + ->assertJsonPath('data.dates.1.id', $destination30->id) + ->assertJsonPath('data.dates.1.rescheduled_dates.0.id', $source13->id) + ->assertJsonPath( + 'data.dates.1.rescheduled_dates.0.rescheduled_to_event_date_id', + $destination30->id, + ) + ->assertJsonPath('data.dates.1.rescheduled_dates.1.id', $source22->id) + ->assertJsonPath( + 'data.dates.1.rescheduled_dates.1.rescheduled_to_event_date_id', + $destination30->id, + ) + ->assertJsonPath('data.dates.1.rescheduled_dates.2.id', $middle24->id) + ->assertJsonPath( + 'data.dates.1.rescheduled_dates.2.rescheduled_to_event_date_id', + $destination30->id, + ); + } + public function test_reading_a_tenant_without_event_configuration_returns_empty_values(): void { $tenant = $this->createTenant('acme'); diff --git a/tests/Unit/Event/EventDateGroupingServiceTest.php b/tests/Unit/Event/EventDateGroupingServiceTest.php new file mode 100644 index 0000000..2662b4c --- /dev/null +++ b/tests/Unit/Event/EventDateGroupingServiceTest.php @@ -0,0 +1,78 @@ +date(1, '2026-10-13', 5); + $source22 = $this->date(2, '2026-10-22', 3); + $middle24 = $this->date(3, '2026-10-24', 5); + $active25 = $this->date(4, '2026-10-25'); + $destination30 = $this->date(5, '2026-10-30'); + + $grouped = (new EventDateGroupingService)->group(new Collection([ + $source13, + $source22, + $middle24, + $active25, + $destination30, + ])); + + $this->assertSame([4, 5], $grouped->map->getKey()->all()); + $this->assertSame( + [1, 2, 3], + $grouped->last()->getRelation('adminRescheduledDates')->modelKeys(), + ); + $this->assertSame( + [5, 5, 5], + $grouped->last()->getRelation('adminRescheduledDates') + ->pluck('rescheduled_to_event_date_id') + ->all(), + ); + + $tenant = new Tenant(['codigo' => 'acme']); + $tenant->id = 1; + $tenant->setRelation('eventDates', new Collection([ + $source13, + $source22, + $middle24, + $active25, + $destination30, + ])); + $tenant->setRelation('socialMedia', new Collection); + $payload = EventResource::make($tenant)->response()->getData(true)['data']; + + $this->assertCount(2, $payload['dates']); + $this->assertSame(4, $payload['dates'][0]['id']); + $this->assertSame([1, 2, 3], array_column($payload['dates'][1]['rescheduled_dates'], 'id')); + $this->assertSame( + [5, 5, 5], + array_column( + $payload['dates'][1]['rescheduled_dates'], + 'rescheduled_to_event_date_id', + ), + ); + } + + private function date(int $id, string $date, ?int $destinationId = null): EventDate + { + $eventDate = new EventDate([ + 'date' => $date, + 'time_start' => '00:00:00', + 'time_end' => '23:59:00', + 'rescheduled_to_event_date_id' => $destinationId, + ]); + $eventDate->id = $id; + + return $eventDate; + } +} diff --git a/tests/Unit/Event/EventDateInfoFormatterTest.php b/tests/Unit/Event/EventDateInfoFormatterTest.php index 40eb789..0742a73 100644 --- a/tests/Unit/Event/EventDateInfoFormatterTest.php +++ b/tests/Unit/Event/EventDateInfoFormatterTest.php @@ -36,6 +36,25 @@ class EventDateInfoFormatterTest extends TestCase ); } + public function test_it_includes_sources_that_reach_the_destination_through_intermediate_dates(): void + { + $date = new EventDate; + $date->id = 30; + + $changes = collect([ + $this->reschedule(13, 24, '2026-10-13'), + $this->reschedule(22, 24, '2026-10-22'), + $this->reschedule(24, 30, '2026-10-24'), + $this->reschedule(29, 30, '2026-10-29'), + $this->reschedule(10, 11, '2026-10-10'), + ]); + + $this->assertSame( + '13/10/2026, 22/10/2026, 24/10/2026 y 29/10/2026 se reprogramaron para este día.', + (new EventDateInfoFormatter)->format($date, $changes), + ); + } + private function reschedule(int $sourceId, int $destinationId, string $previousDate): EventDateChange { $change = new EventDateChange; -- 2.49.1 From 69f2dbe0569c2fc3d3950238e0e96b72b2e6bd35 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 15 Sep 2026 17:06:06 -0300 Subject: [PATCH 46/63] refactor(event): remove date notices from tenant config --- .../Tenant/Resources/TenantResource.php | 10 ----- .../Event/AdminAppEventControllerTest.php | 42 +++---------------- 2 files changed, 6 insertions(+), 46 deletions(-) diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index b68a8b2..da71d67 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -6,9 +6,7 @@ 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\EventDateInfoFormatter; -use App\Domains\Event\Services\EventDateNoticeFormatter; use App\Domains\Menu\Models\Menu; use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; @@ -71,14 +69,6 @@ class TenantResource extends JsonResource ), 'isCanceled' => $eventDate->suspended_at !== null, ])->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', diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index e5431e9..dc53366 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -374,21 +374,8 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonPath('data.event.dates.0.id', $destination->id) ->assertJsonPath('data.event.dates.0.info_text', '09/10/2027 se reprogramó para este día.') ->assertJsonPath('data.event.dates.0.isCanceled', false) - ->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') + ->assertJsonMissingPath('data.event.date_changes') + ->assertJsonMissingPath('data.event.date_notices') ->assertJsonPath('data.event_date_text', '20 de Octubre 2027'); $this->assertSame( '2027-10-20 11:00:00', @@ -425,15 +412,8 @@ class AdminAppEventControllerTest extends TestCase ->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) + ->assertJsonMissingPath('data.event.date_changes') + ->assertJsonMissingPath('data.event.date_notices') ->assertJsonPath('data.event_date_text', '25 de Octubre 2027'); $this->assertSame( '2027-10-25 11:00:00', @@ -538,18 +518,8 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonPath('data.event.dates.1.id', $otherDate->id) ->assertJsonPath('data.event.dates.1.info_text', null) ->assertJsonPath('data.event.dates.1.isCanceled', false) - ->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) + ->assertJsonMissingPath('data.event.date_changes') + ->assertJsonMissingPath('data.event.date_notices') ->assertJsonPath('data.event_date_text', '10 de Octubre 2027'); $this->assertSame( '2027-10-10 09:00:00', -- 2.49.1 From d02ad5fce25813c9a0d9a834b2f71d293b493218 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 15 Sep 2026 17:06:12 -0300 Subject: [PATCH 47/63] feat(event): add per-user date change notices --- app/Domains/Auth/Models/User.php | 7 + .../Controllers/EventDateNoticeController.php | 22 +++ app/Domains/Event/Models/EventDateChange.php | 7 + .../Event/Models/EventDateChangeView.php | 41 +++++ .../Resources/EventDateNoticeResource.php | 20 +++ .../Services/EventDateNoticeFormatter.php | 21 ++- .../Event/Services/EventDateNoticeService.php | 60 ++++++++ app/Domains/Event/documentacion/README.md | 11 +- app/Domains/Event/routes/api.php | 8 + ...ate_user_event_date_change_views_table.php | 27 ++++ .../Event/EventDateNoticeControllerTest.php | 145 ++++++++++++++++++ .../Event/EventDateNoticeFormatterTest.php | 4 + 12 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 app/Domains/Event/Controllers/EventDateNoticeController.php create mode 100644 app/Domains/Event/Models/EventDateChangeView.php create mode 100644 app/Domains/Event/Resources/EventDateNoticeResource.php create mode 100644 app/Domains/Event/Services/EventDateNoticeService.php create mode 100644 database/migrations/2026_09_15_000000_create_user_event_date_change_views_table.php create mode 100644 tests/Feature/Event/EventDateNoticeControllerTest.php diff --git a/app/Domains/Auth/Models/User.php b/app/Domains/Auth/Models/User.php index de30c29..34f3d49 100644 --- a/app/Domains/Auth/Models/User.php +++ b/app/Domains/Auth/Models/User.php @@ -5,6 +5,7 @@ namespace App\Domains\Auth\Models; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Models\Role; use App\Domains\Catalog\Models\Category; +use App\Domains\Event\Models\EventDateChangeView; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\ScanAttempt; use Database\Factories\UserFactory; @@ -53,6 +54,12 @@ class User extends Authenticatable return $this->hasMany(ScanAttempt::class, 'scanner_user_id'); } + /** @return HasMany */ + public function eventDateChangeViews(): HasMany + { + return $this->hasMany(EventDateChangeView::class); + } + /** * @return BelongsTo */ diff --git a/app/Domains/Event/Controllers/EventDateNoticeController.php b/app/Domains/Event/Controllers/EventDateNoticeController.php new file mode 100644 index 0000000..e427663 --- /dev/null +++ b/app/Domains/Event/Controllers/EventDateNoticeController.php @@ -0,0 +1,22 @@ +noticeService->claimFor($request->user(), $tenant) + ); + } +} diff --git a/app/Domains/Event/Models/EventDateChange.php b/app/Domains/Event/Models/EventDateChange.php index 635c8d6..5fcbfab 100644 --- a/app/Domains/Event/Models/EventDateChange.php +++ b/app/Domains/Event/Models/EventDateChange.php @@ -8,6 +8,7 @@ use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'tenant_code', @@ -58,4 +59,10 @@ class EventDateChange extends Model { return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed(); } + + /** @return HasMany */ + public function views(): HasMany + { + return $this->hasMany(EventDateChangeView::class); + } } diff --git a/app/Domains/Event/Models/EventDateChangeView.php b/app/Domains/Event/Models/EventDateChangeView.php new file mode 100644 index 0000000..199418f --- /dev/null +++ b/app/Domains/Event/Models/EventDateChangeView.php @@ -0,0 +1,41 @@ + 'integer', + 'event_date_change_id' => 'integer', + 'display_count' => 'integer', + 'last_displayed_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** @return BelongsTo */ + public function eventDateChange(): BelongsTo + { + return $this->belongsTo(EventDateChange::class); + } +} diff --git a/app/Domains/Event/Resources/EventDateNoticeResource.php b/app/Domains/Event/Resources/EventDateNoticeResource.php new file mode 100644 index 0000000..dd661b8 --- /dev/null +++ b/app/Domains/Event/Resources/EventDateNoticeResource.php @@ -0,0 +1,20 @@ + */ + public function toArray(Request $request): array + { + return [ + 'type' => $this->resource['type'], + 'change_ids' => $this->resource['change_ids'], + 'title' => $this->resource['title'], + 'message' => $this->resource['message'], + ]; + } +} diff --git a/app/Domains/Event/Services/EventDateNoticeFormatter.php b/app/Domains/Event/Services/EventDateNoticeFormatter.php index ee38744..0a4c69d 100644 --- a/app/Domains/Event/Services/EventDateNoticeFormatter.php +++ b/app/Domains/Event/Services/EventDateNoticeFormatter.php @@ -14,6 +14,7 @@ class EventDateNoticeFormatter * @param Collection $changes * @return list, * title: string, * message: list * }> @@ -32,7 +33,7 @@ class EventDateNoticeFormatter /** * @param Collection $changes - * @return array{type: string, title: string, message: list}|null + * @return array{type: string, change_ids: list, title: string, message: list}|null */ private function suspensionNotice(Collection $changes): ?array { @@ -46,6 +47,7 @@ class EventDateNoticeFormatter return [ 'type' => EventDateChangeType::Suspended->value, + 'change_ids' => $this->changeIds($changes), 'title' => $plural ? 'FECHAS CANCELADAS!' : 'FECHA CANCELADA!', 'message' => [ ['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false], @@ -57,7 +59,7 @@ class EventDateNoticeFormatter /** * @param Collection $changes - * @return array{type: string, title: string, message: list}|null + * @return array{type: string, change_ids: list, title: string, message: list}|null */ private function rescheduleNotice(Collection $changes): ?array { @@ -89,6 +91,7 @@ class EventDateNoticeFormatter return [ 'type' => EventDateChangeType::Rescheduled->value, + 'change_ids' => $this->changeIds($changes), 'title' => $plural ? 'FECHAS REPROGRAMADAS!' : 'FECHA REPROGRAMADA!', 'message' => $message, ]; @@ -106,4 +109,18 @@ class EventDateNoticeFormatter ->map(fn ($date): string => $date->format('Y-m-d')) ); } + + /** + * @param Collection $changes + * @return list + */ + private function changeIds(Collection $changes): array + { + return $changes + ->pluck('id') + ->filter(fn ($id): bool => $id !== null) + ->map(fn ($id): int => (int) $id) + ->values() + ->all(); + } } diff --git a/app/Domains/Event/Services/EventDateNoticeService.php b/app/Domains/Event/Services/EventDateNoticeService.php new file mode 100644 index 0000000..239e3fa --- /dev/null +++ b/app/Domains/Event/Services/EventDateNoticeService.php @@ -0,0 +1,60 @@ +, + * title: string, + * message: list + * }> + */ + public function claimFor(User $user, Tenant $tenant): array + { + return DB::transaction(function () use ($user, $tenant): array { + $lockedUser = User::query()->whereKey($user->getKey())->lockForUpdate()->firstOrFail(); + + $changes = EventDateChange::query() + ->where('tenant_code', $tenant->codigo) + ->whereDoesntHave('views', fn ($query) => $query + ->where('user_id', $lockedUser->getKey()) + ->where('display_count', '>=', self::MAX_DISPLAYS)) + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + $notices = $this->formatter->format($changes); + $claimedChangeIds = collect($notices)->pluck('change_ids')->flatten()->unique(); + + foreach ($claimedChangeIds as $changeId) { + $view = EventDateChangeView::query()->firstOrNew([ + 'user_id' => $lockedUser->getKey(), + 'event_date_change_id' => $changeId, + ]); + $view->display_count = min( + self::MAX_DISPLAYS, + ((int) $view->display_count) + 1, + ); + $view->last_displayed_at = now(); + $view->save(); + } + + return $notices; + }); + } +} diff --git a/app/Domains/Event/documentacion/README.md b/app/Domains/Event/documentacion/README.md index 6959a0b..dd46873 100644 --- a/app/Domains/Event/documentacion/README.md +++ b/app/Domains/Event/documentacion/README.md @@ -8,6 +8,7 @@ Administra la configuración temporal de un tenant orientado a eventos y sus fec - `Models/EventDate.php`: fecha del evento con inicio, fin, tenant y variantes asociadas. - `Services/EventService.php`: obtiene y actualiza la configuración de evento del tenant. +- `Services/EventDateNoticeService.php`: reclama y agrupa los cambios pendientes de cada usuario. - `Controllers/AdminApp/EventController.php`: consulta y modificación desde AdminApp. - `UpdateEventRequest`: valida datos y reglas cruzadas de fechas. - `EventResource`: serializa la configuración de salida. @@ -19,10 +20,18 @@ Bajo `/v1/adminapp/tenant/event`, protegidos por `auth:sanctum` y `adminapp.tena - `GET`: obtiene la configuración. - `PUT`: actualiza la configuración. +Para el storefront autenticado: + +- `POST /tenants/{tenant}/event-date-notices/claim`: devuelve hasta un aviso de suspensiones y otro de + reprogramaciones. Cada cambio se muestra como máximo tres veces por usuario. + ## Dependencias Depende de `Tenant`. Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar tickets. ## Consideraciones -El archivo `routes/api.php` no publica operaciones adicionales. Al modificar fechas debe mantenerse la validación de orden y coherencia temporal de `UpdateEventRequest`. +Los avisos se construyen dinámicamente después de excluir los cambios que el usuario ya vio +tres veces. Al reclamar los avisos se incrementa una vez cada cambio incluido, aunque varios +cambios aparezcan agrupados en el mismo mensaje. El reclamo bloquea al usuario durante la +transacción para impedir que pestañas concurrentes superen el máximo. diff --git a/app/Domains/Event/routes/api.php b/app/Domains/Event/routes/api.php index 062e0fe..19a1261 100644 --- a/app/Domains/Event/routes/api.php +++ b/app/Domains/Event/routes/api.php @@ -1,3 +1,11 @@ post( + 'tenants/{tenant:codigo}/event-date-notices/claim', + [EventDateNoticeController::class, 'claim'], +); diff --git a/database/migrations/2026_09_15_000000_create_user_event_date_change_views_table.php b/database/migrations/2026_09_15_000000_create_user_event_date_change_views_table.php new file mode 100644 index 0000000..a1695a4 --- /dev/null +++ b/database/migrations/2026_09_15_000000_create_user_event_date_change_views_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('event_date_change_id')->constrained('event_date_changes')->cascadeOnDelete(); + $table->unsignedTinyInteger('display_count')->default(0); + $table->timestamp('last_displayed_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'event_date_change_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_event_date_change_views'); + } +}; diff --git a/tests/Feature/Event/EventDateNoticeControllerTest.php b/tests/Feature/Event/EventDateNoticeControllerTest.php new file mode 100644 index 0000000..77e3f38 --- /dev/null +++ b/tests/Feature/Event/EventDateNoticeControllerTest.php @@ -0,0 +1,145 @@ +seed(AuthorizationSeeder::class); + } + + public function test_authentication_is_required_to_claim_notices(): void + { + $tenant = $this->createTenant('acme'); + + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertUnauthorized(); + } + + public function test_changes_are_grouped_dynamically_for_each_users_pending_history(): void + { + $tenant = $this->createTenant('acme'); + $userA = $this->createUser($tenant); + + $rescheduled = collect([ + $this->createChange($tenant, EventDateChangeType::Rescheduled, '2027-10-01', '2027-10-11'), + $this->createChange($tenant, EventDateChangeType::Rescheduled, '2027-10-02', '2027-10-12'), + ]); + $suspended = collect([ + $this->createChange($tenant, EventDateChangeType::Suspended, '2027-10-03'), + $this->createChange($tenant, EventDateChangeType::Suspended, '2027-10-04'), + ]); + + Sanctum::actingAs($userA); + + for ($display = 1; $display <= 3; $display++) { + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.type', 'suspended') + ->assertJsonPath('data.0.change_ids', $suspended->modelKeys()) + ->assertJsonPath('data.1.type', 'rescheduled') + ->assertJsonPath('data.1.change_ids', $rescheduled->modelKeys()); + } + + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertOk() + ->assertJsonCount(0, 'data'); + + $latestReschedule = $this->createChange( + $tenant, + EventDateChangeType::Rescheduled, + '2027-10-05', + '2027-10-15', + ); + + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.type', 'rescheduled') + ->assertJsonPath('data.0.change_ids', [$latestReschedule->id]) + ->assertJsonPath('data.0.title', 'FECHA REPROGRAMADA!'); + + $userB = $this->createUser($tenant); + Sanctum::actingAs($userB); + + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.type', 'suspended') + ->assertJsonPath('data.0.change_ids', $suspended->modelKeys()) + ->assertJsonPath('data.1.type', 'rescheduled') + ->assertJsonPath( + 'data.1.change_ids', + [...$rescheduled->modelKeys(), $latestReschedule->id], + ) + ->assertJsonPath('data.1.title', 'FECHAS REPROGRAMADAS!'); + + foreach ([...$rescheduled, ...$suspended] as $change) { + $this->assertDatabaseHas('user_event_date_change_views', [ + 'user_id' => $userA->id, + 'event_date_change_id' => $change->id, + 'display_count' => 3, + ]); + } + + $this->assertDatabaseHas('user_event_date_change_views', [ + 'user_id' => $userA->id, + 'event_date_change_id' => $latestReschedule->id, + 'display_count' => 1, + ]); + $this->assertDatabaseCount('user_event_date_change_views', 9); + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + ]); + } + + private function createUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function createChange( + Tenant $tenant, + EventDateChangeType $type, + string $previousDate, + ?string $newDate = null, + ): EventDateChange { + return EventDateChange::query()->create([ + 'tenant_code' => $tenant->codigo, + 'change_type' => $type, + 'previous_date' => $previousDate, + 'new_date' => $newDate, + ]); + } +} diff --git a/tests/Unit/Event/EventDateNoticeFormatterTest.php b/tests/Unit/Event/EventDateNoticeFormatterTest.php index af53628..8ff0c73 100644 --- a/tests/Unit/Event/EventDateNoticeFormatterTest.php +++ b/tests/Unit/Event/EventDateNoticeFormatterTest.php @@ -22,6 +22,7 @@ class EventDateNoticeFormatterTest extends TestCase $this->assertSame([ [ 'type' => 'suspended', + 'change_ids' => [], 'title' => 'FECHA CANCELADA!', 'message' => [ ['text' => 'La fecha del ', 'bold' => false], @@ -31,6 +32,7 @@ class EventDateNoticeFormatterTest extends TestCase ], [ 'type' => 'rescheduled', + 'change_ids' => [], 'title' => 'FECHA REPROGRAMADA!', 'message' => [ ['text' => 'La fecha del ', 'bold' => false], @@ -57,6 +59,7 @@ class EventDateNoticeFormatterTest extends TestCase $this->assertSame([ [ 'type' => 'suspended', + 'change_ids' => [], 'title' => 'FECHAS CANCELADAS!', 'message' => [ ['text' => 'Las fechas del ', 'bold' => false], @@ -66,6 +69,7 @@ class EventDateNoticeFormatterTest extends TestCase ], [ 'type' => 'rescheduled', + 'change_ids' => [], 'title' => 'FECHAS REPROGRAMADAS!', 'message' => [ ['text' => 'Las fechas del ', 'bold' => false], -- 2.49.1 From 44178d72a506f64f2a503fbe6440c31c262d7182 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 08:56:01 -0300 Subject: [PATCH 48/63] feat(variants): enhance disableForSuspension method to manage variant replacements and inventory merging --- .../Services/VariantReplacementService.php | 86 +++++++++++- storage/framework/lsp-1ab91c4294c8e5da.php | 130 ++++++++++++++++++ .../Event/AdminAppEventControllerTest.php | 19 +++ 3 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 storage/framework/lsp-1ab91c4294c8e5da.php diff --git a/app/Domains/Catalog/Services/VariantReplacementService.php b/app/Domains/Catalog/Services/VariantReplacementService.php index 0c170aa..e4422d3 100644 --- a/app/Domains/Catalog/Services/VariantReplacementService.php +++ b/app/Domains/Catalog/Services/VariantReplacementService.php @@ -56,7 +56,7 @@ class VariantReplacementService public function disableForSuspension(EventDate $eventDate): void { - Variant::query() + $variants = Variant::query() ->whereNull('sales_disabled_at') ->whereNull('replaced_by_variant_id') ->where(function ($query) use ($eventDate): void { @@ -64,7 +64,43 @@ class VariantReplacementService ->orWhereHas('eventDates', fn ($eventDates) => $eventDates ->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 $eventDateIds */ @@ -166,6 +202,52 @@ class VariantReplacementService 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, + ]); + if ($activeLines->isNotEmpty()) { + StockReservationLine::query() + ->whereKey($activeLines->modelKeys()) + ->update(['inventory_id' => $destinationInventory->getKey()]); + } + $sourceInventory->update([ + 'real_stock' => 0, + 'reserved_stock' => 0, + 'sold_units' => 0, + ]); + } + /** @return list */ private function definitionSignature(Variant $variant): array { diff --git a/storage/framework/lsp-1ab91c4294c8e5da.php b/storage/framework/lsp-1ab91c4294c8e5da.php new file mode 100644 index 0000000..f14d849 --- /dev/null +++ b/storage/framework/lsp-1ab91c4294c8e5da.php @@ -0,0 +1,130 @@ +hasDefaultValue()) { +return ['default' => $property->getDefaultValue()]; +} + +if ($parameter?->isDefaultValueAvailable()) { +return ['default' => $parameter->getDefaultValue()]; +} + +return []; +} + +public static function formatDefaultValue(mixed $value): mixed +{ +return match (true) { +is_array($value) => 'array(...)', +$value instanceof UnitEnum => get_class($value) . '::' . $value->name, +$value instanceof Closure => 'Closure', +is_object($value) => get_class($value), +is_string($value) => var_export($value, true), +is_null($value) => 'null', +is_bool($value) => $value ? 'true' : 'false', +default => $value, +}; +} +} + +use Pest\Expectation; +use Pest\TestSuite; + +$pest = new class +{ +public function __construct() +{ +if ($this->isInstalled()) { +$this->boot(); +} +} + +public function isInstalled(): bool +{ +return class_exists(TestSuite::class); +} + +protected function boot(): void +{ +require_once base_path('vendor/pestphp/pest/overrides/Runner/TestSuiteLoader.php'); + +TestSuite::getInstance(base_path(), 'tests'); + +if (file_exists($pestFile = base_path('tests/Pest.php'))) { +require_once $pestFile; +} +} + +public function config(): ?array +{ +if (!$this->isInstalled()) { +return null; +} + +return [ +'uses' => $this->uses(), +'expectations' => $this->expectations(), +]; +} + +protected function uses(): array +{ +if (is_null($instance = TestSuite::getInstance())) { +return []; +} + +$reflection = new ReflectionProperty($instance->tests, 'uses'); +$uses = $reflection->getValue($instance->tests); + +return collect($uses)->map(function (array $use, string $path) { +[$classOrTraits] = $use; + +return [ +'path' => LspHelper::relativePath($path), +'classes' => array_values(array_filter($classOrTraits, fn ($c) => class_exists($c))), +'traits' => array_values(array_filter($classOrTraits, fn ($c) => trait_exists($c))), +]; +})->values()->all(); +} + +protected function expectations(): array +{ +$reflection = new ReflectionProperty(Expectation::class, 'extends'); +$extends = $reflection->getValue(); + +return collect($extends)->map(function (Closure $closure, string $name) { +$parameters = collect((new ReflectionFunction($closure))->getParameters()) +->map(function (ReflectionParameter $param) { +$type = $param->hasType() ? $param->getType() . ' ' : ''; + +$default = $param->isOptional() && $param->isDefaultValueAvailable() +? ' = ' . var_export($param->getDefaultValue(), true) +: ''; + +return $type . '$' . $param->getName() . $default; +}) +->join(', '); + +return compact('name', 'parameters'); +})->values()->all(); +} +}; + +echo json_encode($pest->config()); diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index dc53366..72e27ee 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -474,6 +474,13 @@ class AdminAppEventControllerTest extends TestCase $singleDateVariant = $this->createVariant($tenant, $suspendedDate->id); $multipleDateVariant = $this->createVariant($tenant); $multipleDateVariant->eventDates()->sync([$suspendedDate->id, $otherDate->id]); + $remainingDateVariant = Variant::query()->create([ + 'catalog_item_id' => $multipleDateVariant->catalog_item_id, + 'inventory_id' => Inventory::query()->create()->id, + 'event_date_id' => $otherDate->id, + ]); + $singleDateVariant->inventory->update(['real_stock' => 5]); + $multipleDateVariant->inventory->update(['real_stock' => 5]); $singleDateTicket = $this->createTicket($tenant, $admin, $singleDateVariant); $multipleDateTicket = $this->createTicket($tenant, $admin, $multipleDateVariant); Sanctum::actingAs($admin); @@ -508,6 +515,14 @@ class AdminAppEventControllerTest extends TestCase $this->assertNull($multipleDateTicket->fresh()->disabled_at); $this->assertNotNull($singleDateVariant->fresh()->sales_disabled_at); $this->assertNotNull($multipleDateVariant->fresh()->sales_disabled_at); + $replacement = $multipleDateVariant->fresh()->replacement; + $this->assertNotNull($replacement); + $this->assertSame($remainingDateVariant->id, $replacement->id); + $this->assertTrue($replacement->isSellable()); + $this->assertSame([$otherDate->id], $replacement->selectedEventDates()->pluck('id')->all()); + $this->assertSame(5, $replacement->inventory->fresh()->availableStock()); + $this->assertTrue(CatalogItem::query()->whereKey($multipleDateVariant->catalog_item_id)->whereAvailable()->exists()); + $this->assertFalse(CatalogItem::query()->whereKey($singleDateVariant->catalog_item_id)->whereAvailable()->exists()); $this->assertSame('10 de Octubre 2027', $tenant->fresh()->event_date_text); $this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F') ->assertOk() @@ -525,6 +540,10 @@ class AdminAppEventControllerTest extends TestCase '2027-10-10 09:00:00', $multipleDateTicket->fresh()->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), ); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$otherDate->id}/suspend") + ->assertOk(); + $this->assertFalse(CatalogItem::query()->whereKey($multipleDateVariant->catalog_item_id)->whereAvailable()->exists()); } public function test_suspending_a_reschedule_destination_disables_tickets_from_predecessor_dates(): void -- 2.49.1 From 3a4f6b64d210d4022ba15cab7a5e67868e4d4b77 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 09:05:21 -0300 Subject: [PATCH 49/63] feat(variants): implement restoration of previously suspended variants with usable dates --- .../Services/VariantReplacementService.php | 88 ++++++++++++++----- .../Resources/EntryResource.php | 7 +- .../Services/EntryService.php | 2 + ...re_variants_with_remaining_event_dates.php | 17 ++++ .../Event/AdminAppEventControllerTest.php | 31 +++++++ 5 files changed, 124 insertions(+), 21 deletions(-) create mode 100644 database/migrations/2026_09_16_000000_restore_variants_with_remaining_event_dates.php diff --git a/app/Domains/Catalog/Services/VariantReplacementService.php b/app/Domains/Catalog/Services/VariantReplacementService.php index e4422d3..3070614 100644 --- a/app/Domains/Catalog/Services/VariantReplacementService.php +++ b/app/Domains/Catalog/Services/VariantReplacementService.php @@ -8,7 +8,9 @@ use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Models\Variant; use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Services\EffectiveEventDateResolver; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; class VariantReplacementService { @@ -70,14 +72,7 @@ class VariantReplacementService ->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(); + $remainingDateIds = $this->usableDateIds($variant); if ($remainingDateIds->isEmpty()) { $variant->update(['sales_disabled_at' => now()]); @@ -85,22 +80,75 @@ class VariantReplacementService continue; } - $replacement = $this->findEquivalent($variant, $remainingDateIds); - if ($replacement === null) { - $replacement = $this->cloneWithDates($variant, $remainingDateIds); - } else { - $this->mergeInventoryInto($variant, $replacement); + $this->replaceWithUsableDates($variant, $remainingDateIds); + } + } + + public function restorePreviouslySuspendedVariants(): int + { + return DB::transaction(function (): int { + $variants = Variant::query() + ->whereNotNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['eventDates', 'eventDate', 'definitions', 'allAttachments']) + ->orderBy('id') + ->lockForUpdate() + ->get(); + $restored = 0; + + foreach ($variants as $variant) { + if (! $variant->selectedEventDates()->contains( + fn (EventDate $date): bool => $date->suspended_at !== null, + )) { + continue; + } + + $usableDateIds = $this->usableDateIds($variant); + if ($usableDateIds->isEmpty()) { + continue; + } + + $this->replaceWithUsableDates($variant, $usableDateIds); + $restored++; } - $variant->update([ - 'replaced_by_variant_id' => $replacement->getKey(), - 'sales_disabled_at' => now(), - ]); + return $restored; + }); + } - BundleComponent::query() - ->where('component_variant_id', $variant->getKey()) - ->update(['component_variant_id' => $replacement->getKey()]); + /** @return Collection */ + private function usableDateIds(Variant $variant): Collection + { + $resolver = app(EffectiveEventDateResolver::class); + + return $variant->selectedEventDates() + ->map(fn (EventDate $date): ?EventDate => $resolver->resolve($date)) + ->filter() + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values(); + } + + /** @param Collection $dateIds */ + private function replaceWithUsableDates(Variant $variant, Collection $dateIds): void + { + $replacement = $this->findEquivalent($variant, $dateIds); + if ($replacement === null) { + $replacement = $this->cloneWithDates($variant, $dateIds); + } else { + $this->mergeInventoryInto($variant, $replacement); } + + $variant->update([ + 'replaced_by_variant_id' => $replacement->getKey(), + 'sales_disabled_at' => $variant->sales_disabled_at ?? now(), + ]); + + BundleComponent::query() + ->where('component_variant_id', $variant->getKey()) + ->update(['component_variant_id' => $replacement->getKey()]); } /** @param Collection $eventDateIds */ diff --git a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php index 41e414f..4442bd3 100644 --- a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php +++ b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php @@ -12,7 +12,12 @@ class EntryResource extends JsonResource /** @return array */ public function toArray(Request $request): array { - $variant = $this->variants->sole(); + $variant = $this->variants + ->filter(fn ($candidate): bool => $candidate->sales_disabled_at === null + && $candidate->replaced_by_variant_id === null) + ->sortByDesc('id') + ->first() + ?? $this->variants->sortByDesc('id')->firstOrFail(); return [ 'id' => $this->id, diff --git a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php index 0c44363..97e6999 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php @@ -97,6 +97,8 @@ class EntryService $variants = Variant::query() ->where('catalog_item_id', $catalogItem->id) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') ->lockForUpdate() ->get(); diff --git a/database/migrations/2026_09_16_000000_restore_variants_with_remaining_event_dates.php b/database/migrations/2026_09_16_000000_restore_variants_with_remaining_event_dates.php new file mode 100644 index 0000000..86b903d --- /dev/null +++ b/database/migrations/2026_09_16_000000_restore_variants_with_remaining_event_dates.php @@ -0,0 +1,17 @@ +restorePreviouslySuspendedVariants(); + } + + public function down(): void + { + // Historical variants and their purchases must remain intact. + } +}; diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 72e27ee..ef5b469 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -11,6 +11,7 @@ use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Services\VariantReplacementService; use App\Domains\Event\Events\EventDateRescheduled; use App\Domains\Event\Events\EventDateSuspended; use App\Domains\Purchase\Services\Checkout\CatalogSelectionResolver; @@ -575,6 +576,36 @@ class AdminAppEventControllerTest extends TestCase $this->assertFalse($ticket->fresh()->resolvedValidity()->isResolvable); } + public function test_previous_suspensions_restore_variants_with_usable_dates(): void + { + $tenant = $this->createTenant('acme'); + $suspendedDate = $tenant->eventDates()->create([ + 'date' => '2027-10-09', + 'time_start' => '09:00', + 'time_end' => '18:30', + 'suspended_at' => now(), + ]); + $usableDate = $tenant->eventDates()->create([ + 'date' => '2027-10-10', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $variant = $this->createVariant($tenant); + $variant->eventDates()->sync([$suspendedDate->id, $usableDate->id]); + $variant->inventory->update(['real_stock' => 5]); + $variant->update(['sales_disabled_at' => now()]); + + $service = app(VariantReplacementService::class); + $this->assertSame(1, $service->restorePreviouslySuspendedVariants()); + $this->assertSame(0, $service->restorePreviouslySuspendedVariants()); + + $replacement = $variant->fresh()->replacement; + $this->assertNotNull($replacement); + $this->assertSame([$usableDate->id], $replacement->selectedEventDates()->pluck('id')->all()); + $this->assertSame(5, $replacement->inventory->availableStock()); + $this->assertTrue(CatalogItem::query()->whereKey($variant->catalog_item_id)->whereAvailable()->exists()); + } + public function test_update_and_date_creation_validate_their_own_payloads(): void { $tenant = $this->createTenant('acme'); -- 2.49.1 From 8ad9b3a4e33b4da62dbff1688269a7c76abd7746 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 09:08:43 -0300 Subject: [PATCH 50/63] Revert "feat(variants): implement restoration of previously suspended variants with usable dates" This reverts commit 3a4f6b64d210d4022ba15cab7a5e67868e4d4b77. --- .../Services/VariantReplacementService.php | 88 +++++-------------- .../Resources/EntryResource.php | 7 +- .../Services/EntryService.php | 2 - ...re_variants_with_remaining_event_dates.php | 17 ---- .../Event/AdminAppEventControllerTest.php | 31 ------- 5 files changed, 21 insertions(+), 124 deletions(-) delete mode 100644 database/migrations/2026_09_16_000000_restore_variants_with_remaining_event_dates.php diff --git a/app/Domains/Catalog/Services/VariantReplacementService.php b/app/Domains/Catalog/Services/VariantReplacementService.php index 3070614..e4422d3 100644 --- a/app/Domains/Catalog/Services/VariantReplacementService.php +++ b/app/Domains/Catalog/Services/VariantReplacementService.php @@ -8,9 +8,7 @@ use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Models\Variant; use App\Domains\Event\Models\EventDate; -use App\Domains\Event\Services\EffectiveEventDateResolver; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; class VariantReplacementService { @@ -72,7 +70,14 @@ class VariantReplacementService ->get(); foreach ($variants as $variant) { - $remainingDateIds = $this->usableDateIds($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()]); @@ -80,75 +85,22 @@ class VariantReplacementService continue; } - $this->replaceWithUsableDates($variant, $remainingDateIds); - } - } - - public function restorePreviouslySuspendedVariants(): int - { - return DB::transaction(function (): int { - $variants = Variant::query() - ->whereNotNull('sales_disabled_at') - ->whereNull('replaced_by_variant_id') - ->with(['eventDates', 'eventDate', 'definitions', 'allAttachments']) - ->orderBy('id') - ->lockForUpdate() - ->get(); - $restored = 0; - - foreach ($variants as $variant) { - if (! $variant->selectedEventDates()->contains( - fn (EventDate $date): bool => $date->suspended_at !== null, - )) { - continue; - } - - $usableDateIds = $this->usableDateIds($variant); - if ($usableDateIds->isEmpty()) { - continue; - } - - $this->replaceWithUsableDates($variant, $usableDateIds); - $restored++; + $replacement = $this->findEquivalent($variant, $remainingDateIds); + if ($replacement === null) { + $replacement = $this->cloneWithDates($variant, $remainingDateIds); + } else { + $this->mergeInventoryInto($variant, $replacement); } - return $restored; - }); - } + $variant->update([ + 'replaced_by_variant_id' => $replacement->getKey(), + 'sales_disabled_at' => now(), + ]); - /** @return Collection */ - private function usableDateIds(Variant $variant): Collection - { - $resolver = app(EffectiveEventDateResolver::class); - - return $variant->selectedEventDates() - ->map(fn (EventDate $date): ?EventDate => $resolver->resolve($date)) - ->filter() - ->pluck('id') - ->map(fn ($id): int => (int) $id) - ->unique() - ->sort() - ->values(); - } - - /** @param Collection $dateIds */ - private function replaceWithUsableDates(Variant $variant, Collection $dateIds): void - { - $replacement = $this->findEquivalent($variant, $dateIds); - if ($replacement === null) { - $replacement = $this->cloneWithDates($variant, $dateIds); - } else { - $this->mergeInventoryInto($variant, $replacement); + BundleComponent::query() + ->where('component_variant_id', $variant->getKey()) + ->update(['component_variant_id' => $replacement->getKey()]); } - - $variant->update([ - 'replaced_by_variant_id' => $replacement->getKey(), - 'sales_disabled_at' => $variant->sales_disabled_at ?? now(), - ]); - - BundleComponent::query() - ->where('component_variant_id', $variant->getKey()) - ->update(['component_variant_id' => $replacement->getKey()]); } /** @param Collection $eventDateIds */ diff --git a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php index 4442bd3..41e414f 100644 --- a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php +++ b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php @@ -12,12 +12,7 @@ class EntryResource extends JsonResource /** @return array */ public function toArray(Request $request): array { - $variant = $this->variants - ->filter(fn ($candidate): bool => $candidate->sales_disabled_at === null - && $candidate->replaced_by_variant_id === null) - ->sortByDesc('id') - ->first() - ?? $this->variants->sortByDesc('id')->firstOrFail(); + $variant = $this->variants->sole(); return [ 'id' => $this->id, diff --git a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php index 97e6999..0c44363 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php @@ -97,8 +97,6 @@ class EntryService $variants = Variant::query() ->where('catalog_item_id', $catalogItem->id) - ->whereNull('sales_disabled_at') - ->whereNull('replaced_by_variant_id') ->lockForUpdate() ->get(); diff --git a/database/migrations/2026_09_16_000000_restore_variants_with_remaining_event_dates.php b/database/migrations/2026_09_16_000000_restore_variants_with_remaining_event_dates.php deleted file mode 100644 index 86b903d..0000000 --- a/database/migrations/2026_09_16_000000_restore_variants_with_remaining_event_dates.php +++ /dev/null @@ -1,17 +0,0 @@ -restorePreviouslySuspendedVariants(); - } - - public function down(): void - { - // Historical variants and their purchases must remain intact. - } -}; diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index ef5b469..72e27ee 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -11,7 +11,6 @@ use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Models\Variant; -use App\Domains\Catalog\Services\VariantReplacementService; use App\Domains\Event\Events\EventDateRescheduled; use App\Domains\Event\Events\EventDateSuspended; use App\Domains\Purchase\Services\Checkout\CatalogSelectionResolver; @@ -576,36 +575,6 @@ class AdminAppEventControllerTest extends TestCase $this->assertFalse($ticket->fresh()->resolvedValidity()->isResolvable); } - public function test_previous_suspensions_restore_variants_with_usable_dates(): void - { - $tenant = $this->createTenant('acme'); - $suspendedDate = $tenant->eventDates()->create([ - 'date' => '2027-10-09', - 'time_start' => '09:00', - 'time_end' => '18:30', - 'suspended_at' => now(), - ]); - $usableDate = $tenant->eventDates()->create([ - 'date' => '2027-10-10', - 'time_start' => '09:00', - 'time_end' => '18:30', - ]); - $variant = $this->createVariant($tenant); - $variant->eventDates()->sync([$suspendedDate->id, $usableDate->id]); - $variant->inventory->update(['real_stock' => 5]); - $variant->update(['sales_disabled_at' => now()]); - - $service = app(VariantReplacementService::class); - $this->assertSame(1, $service->restorePreviouslySuspendedVariants()); - $this->assertSame(0, $service->restorePreviouslySuspendedVariants()); - - $replacement = $variant->fresh()->replacement; - $this->assertNotNull($replacement); - $this->assertSame([$usableDate->id], $replacement->selectedEventDates()->pluck('id')->all()); - $this->assertSame(5, $replacement->inventory->availableStock()); - $this->assertTrue(CatalogItem::query()->whereKey($variant->catalog_item_id)->whereAvailable()->exists()); - } - public function test_update_and_date_creation_validate_their_own_payloads(): void { $tenant = $this->createTenant('acme'); -- 2.49.1 From 3d6548ce4a6cd230f2d05b39d174e52b9aed3777 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 09:34:02 -0300 Subject: [PATCH 51/63] feat(entry): refine variant selection logic to exclude replaced variants and add unit tests for replacement behavior --- .../Resources/EntryResource.php | 2 +- .../Services/EntryService.php | 1 + .../EntryControllerTest.php | 61 +++++++++++++++++++ .../EntryResourceTest.php | 45 ++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/FiestaFutbolInfantil/EntryResourceTest.php diff --git a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php index 41e414f..eed2e56 100644 --- a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php +++ b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php @@ -12,7 +12,7 @@ class EntryResource extends JsonResource /** @return array */ public function toArray(Request $request): array { - $variant = $this->variants->sole(); + $variant = $this->variants->whereNull('replaced_by_variant_id')->sole(); return [ 'id' => $this->id, diff --git a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php index 0c44363..eed5231 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php @@ -97,6 +97,7 @@ class EntryService $variants = Variant::query() ->where('catalog_item_id', $catalogItem->id) + ->whereNull('replaced_by_variant_id') ->lockForUpdate() ->get(); diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index 9b084f4..0a1249b 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -7,6 +7,7 @@ use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; +use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Menu\Models\Menu; use App\Domains\Shared\Enums\FieldType; @@ -221,6 +222,66 @@ class EntryControllerTest extends TestCase ]); } + public function test_it_reads_and_edits_the_latest_replacement_even_when_sales_are_disabled(): void + { + $tenant = $this->createFiestaTenant(); + $this->createEventDateAttribute($tenant); + $date = $tenant->eventDates()->create([ + 'date' => '2027-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $otherDate = $tenant->eventDates()->create([ + 'date' => '2027-10-10', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + $entryId = $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [[ + 'title' => 'Abono', + 'event_date_ids' => [$date->id], + 'stock' => 10, + 'price' => 100, + ]], + ])->assertOk()->json('data.0.id'); + + $original = Variant::query()->where('catalog_item_id', $entryId)->sole(); + $intermediate = $original->replicate(); + $intermediate->save(); + $replacement = $original->replicate(); + $replacement->inventory_id = Inventory::query()->create(['real_stock' => 20])->id; + $replacement->event_date_id = $otherDate->id; + $replacement->sales_disabled_at = now(); + $replacement->save(); + $replacement->eventDates()->sync([$otherDate->id]); + $original->update(['replaced_by_variant_id' => $intermediate->id, 'sales_disabled_at' => now()]); + $intermediate->update(['replaced_by_variant_id' => $replacement->id, 'sales_disabled_at' => now()]); + + $this->getJson('/api/v1/adminapp/tenant/entries') + ->assertOk() + ->assertJsonPath('data.0.stock', 20) + ->assertJsonPath('data.0.event_date_ids', [$otherDate->id]); + + $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [[ + 'id' => $entryId, + 'title' => 'Abono editado', + 'event_date_ids' => [$date->id, $otherDate->id], + 'stock' => 30, + 'price' => 200, + ]], + ])->assertOk() + ->assertJsonPath('data.0.stock', 30) + ->assertJsonPath('data.0.event_date_ids', [$date->id, $otherDate->id]); + + $this->assertSame(10, $original->fresh()->inventory->real_stock); + $this->assertSame([$date->id], $original->fresh()->selectedEventDates()->pluck('id')->all()); + $this->assertSame(30, $replacement->fresh()->inventory->real_stock); + $this->assertNotNull($replacement->fresh()->sales_disabled_at); + $this->assertDatabaseCount('variantes', 3); + } + public function test_it_deletes_an_entry_and_its_inventory(): void { $tenant = $this->createFiestaTenant(); diff --git a/tests/Unit/FiestaFutbolInfantil/EntryResourceTest.php b/tests/Unit/FiestaFutbolInfantil/EntryResourceTest.php new file mode 100644 index 0000000..6f71b8a --- /dev/null +++ b/tests/Unit/FiestaFutbolInfantil/EntryResourceTest.php @@ -0,0 +1,45 @@ + 2]); + $intermediate = new Variant(['replaced_by_variant_id' => 3]); + $current = new Variant(['sales_disabled_at' => now()]); + $date = new EventDate; + $date->id = 13; + $current->setRelation('eventDates', collect([$date])); + $current->setRelation('inventory', new Inventory(['real_stock' => 20])); + $entry = new CatalogItem(['nombre' => 'Abono', 'precio' => 100]); + $entry->setRelation('variants', collect([$original, $intermediate, $current])); + + $data = (new EntryResource($entry))->resolve(Request::create('/')); + + $this->assertSame([13], $data['event_date_ids']->all()); + $this->assertSame(20, $data['stock']); + $this->assertSame('Abono', $data['title']); + $this->assertNotNull($current->sales_disabled_at); + } + + public function test_it_does_not_choose_arbitrarily_between_current_variants(): void + { + $entry = new CatalogItem; + $entry->setRelation('variants', collect([new Variant, new Variant])); + + $this->expectException(MultipleItemsFoundException::class); + + (new EntryResource($entry))->resolve(Request::create('/')); + } +} -- 2.49.1 From 69bea10519e57ce4f39baf3f93999113a9d2b33d Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 09:50:16 -0300 Subject: [PATCH 52/63] feat(entry): simplify variant data handling in EntryResource by removing replaced variant logic --- app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php index eed2e56..5fe7fd3 100644 --- a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php +++ b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php @@ -12,14 +12,11 @@ class EntryResource extends JsonResource /** @return array */ public function toArray(Request $request): array { - $variant = $this->variants->whereNull('replaced_by_variant_id')->sole(); - return [ 'id' => $this->id, 'title' => $this->nombre, 'description' => $this->descripcion, - 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), - 'stock' => $variant->inventory->real_stock, + 'variants' => $this->variants->toArray(), 'price' => $this->precio, ]; } -- 2.49.1 From e6b5da366cd028a5ac9f0c139ce86e4b9611e15e Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 09:55:28 -0300 Subject: [PATCH 53/63] feat(entry): filter out replaced variants in EntryResource to improve data accuracy --- app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php index 5fe7fd3..8df1b7b 100644 --- a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php +++ b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php @@ -16,7 +16,10 @@ class EntryResource extends JsonResource 'id' => $this->id, 'title' => $this->nombre, 'description' => $this->descripcion, - 'variants' => $this->variants->toArray(), + 'variants' => $this->variants + ->whereNull('replaced_by_variant_id') + ->values() + ->toArray(), 'price' => $this->precio, ]; } -- 2.49.1 From 708037677a3d50688f72fc8df7b628930664689d Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 10:08:39 -0300 Subject: [PATCH 54/63] feat(entry): add validation for single non-replaced variant in EntryResource and update tests --- .../Resources/EntryResource.php | 22 +++++++++++++++---- .../Services/EntryService.php | 1 + .../EntryResourceTest.php | 4 ++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php index 8df1b7b..9e32742 100644 --- a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php +++ b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php @@ -5,6 +5,7 @@ namespace App\Domains\FiestaFutbolInfantil\Resources; use App\Domains\Catalog\Models\CatalogItem; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Validation\ValidationException; /** @mixin CatalogItem */ class EntryResource extends JsonResource @@ -12,14 +13,27 @@ class EntryResource extends JsonResource /** @return array */ public function toArray(Request $request): array { + $variants = $this->variants->whereNull('replaced_by_variant_id'); + + if ($variants->count() !== 1) { + throw ValidationException::withMessages([ + 'entries' => [sprintf( + 'La entrada %s tiene %d variantes sin reemplazar (IDs: %s). Se esperaba una.', + $this->id, + $variants->count(), + $variants->pluck('id')->implode(', '), + )], + ]); + } + + $variant = $variants->first(); + return [ 'id' => $this->id, 'title' => $this->nombre, 'description' => $this->descripcion, - 'variants' => $this->variants - ->whereNull('replaced_by_variant_id') - ->values() - ->toArray(), + 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), + 'stock' => $variant->inventory->real_stock, 'price' => $this->precio, ]; } diff --git a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php index eed5231..c8811c8 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php @@ -25,6 +25,7 @@ class EntryService ->where('tenant_code', $tenant->codigo) ->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas')) ->with([ + 'variants' => fn ($query) => $query->whereNull('replaced_by_variant_id'), 'variants.inventory', 'variants.eventDate', 'variants.eventDates', diff --git a/tests/Unit/FiestaFutbolInfantil/EntryResourceTest.php b/tests/Unit/FiestaFutbolInfantil/EntryResourceTest.php index 6f71b8a..3bbf8d1 100644 --- a/tests/Unit/FiestaFutbolInfantil/EntryResourceTest.php +++ b/tests/Unit/FiestaFutbolInfantil/EntryResourceTest.php @@ -8,7 +8,7 @@ use App\Domains\Catalog\Models\Variant; use App\Domains\Event\Models\EventDate; use App\Domains\FiestaFutbolInfantil\Resources\EntryResource; use Illuminate\Http\Request; -use Illuminate\Support\MultipleItemsFoundException; +use Illuminate\Validation\ValidationException; use Tests\TestCase; class EntryResourceTest extends TestCase @@ -38,7 +38,7 @@ class EntryResourceTest extends TestCase $entry = new CatalogItem; $entry->setRelation('variants', collect([new Variant, new Variant])); - $this->expectException(MultipleItemsFoundException::class); + $this->expectException(ValidationException::class); (new EntryResource($entry))->resolve(Request::create('/')); } -- 2.49.1 From 9aa0c1f3b93235171684b9405d2d4bbb91da7d37 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 10:14:54 -0300 Subject: [PATCH 55/63] feat(entry): enhance variant filtering in all method to exclude replaced and disabled variants --- app/Domains/FiestaFutbolInfantil/Services/EntryService.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php index c8811c8..26372a2 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php @@ -24,8 +24,13 @@ class EntryService return CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas')) + ->whereHas('variants', fn ($query) => $query + ->whereNull('replaced_by_variant_id') + ->whereNull('sales_disabled_at')) ->with([ - 'variants' => fn ($query) => $query->whereNull('replaced_by_variant_id'), + 'variants' => fn ($query) => $query + ->whereNull('replaced_by_variant_id') + ->whereNull('sales_disabled_at'), 'variants.inventory', 'variants.eventDate', 'variants.eventDates', -- 2.49.1 From f8a329682182a0f6525c2dbafc94d4b1917ceced Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 14:27:35 -0300 Subject: [PATCH 56/63] feat(event): add effective date resolution for rescheduled events and update tests --- app/Domains/Event/Models/EventDate.php | 6 +++ .../Services/NotificationMailService.php | 2 +- .../Services/AdminAppTicketRowService.php | 41 +++++++++-------- .../event-date-rescheduled.blade.php | 4 +- .../NotificationMailServiceTest.php | 7 +-- .../Ticket/AdminAppTicketControllerTest.php | 44 +++++++++++++++++++ tests/Unit/Event/EventModelsTest.php | 17 +++++++ 7 files changed, 96 insertions(+), 25 deletions(-) diff --git a/app/Domains/Event/Models/EventDate.php b/app/Domains/Event/Models/EventDate.php index 81b826a..02add11 100644 --- a/app/Domains/Event/Models/EventDate.php +++ b/app/Domains/Event/Models/EventDate.php @@ -4,6 +4,7 @@ namespace App\Domains\Event\Models; use App\Domains\Catalog\Models\Variant; use App\Domains\Event\Enums\EventDateStatus; +use App\Domains\Event\Services\EffectiveEventDateResolver; use App\Domains\Event\Services\EventDateTextFormatter; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Enums\ValidityTimeType; @@ -80,6 +81,11 @@ class EventDate extends Model return $this->belongsTo(self::class, 'rescheduled_to_event_date_id'); } + public function effectiveDate(): ?self + { + return app(EffectiveEventDateResolver::class)->resolve($this); + } + /** @return HasMany */ public function rescheduledFrom(): HasMany { diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index 057810a..32e32d6 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -297,7 +297,7 @@ class NotificationMailService ->forTenant($tenantCode) ->send( $recipient, - "Tu evento fue reprogramado - Compra #{$purchase->getKey()}", + "Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}", view('mail.notifications.event-date-rescheduled', compact( 'purchase', 'previousDate', 'newDate', 'tickets' ))->render(), diff --git a/app/Domains/Ticket/Services/AdminAppTicketRowService.php b/app/Domains/Ticket/Services/AdminAppTicketRowService.php index ed19924..cf831cf 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketRowService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketRowService.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticket\Services; use App\Domains\Catalog\Models\ItemAttribute; +use App\Domains\Event\Models\EventDate; use App\Domains\Ticket\Models\Ticket; use Illuminate\Support\Collection; @@ -11,12 +12,12 @@ class AdminAppTicketRowService private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; private const CATEGORY_PRESENTATIONS = [ - 'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null], - 'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null], - 'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'date' => null, 'size' => null], - 'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null], - 'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null], - 'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'date' => null, 'size' => 'talle'], + 'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null], + 'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null], + 'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'size' => null], + 'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null], + 'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null], + 'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'size' => 'talle'], ]; /** @return array */ @@ -111,13 +112,14 @@ class AdminAppTicketRowService private function presentation(Ticket $ticket, array $details): array { $sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-'; + $effectiveDates = $this->effectiveEventDateLabels($ticket) ?: '-'; if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) { return [ 'category' => $sourceCategory, 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), 'type' => $this->allPropertyLabels($details) ?: '-', - 'date' => '-', + 'date' => $effectiveDates, 'size' => '-', ]; } @@ -128,7 +130,7 @@ class AdminAppTicketRowService 'category' => $sourceCategory, 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), 'type' => $this->allPropertyLabels($details) ?: '-', - 'date' => '-', + 'date' => $effectiveDates, 'size' => '-', ]; } @@ -141,29 +143,30 @@ class AdminAppTicketRowService 'type' => $configuration['type'] === null ? '-' : ($this->propertyLabels($details, $configuration['type']) ?: '-'), - 'date' => $configuration['date'] === null - ? '-' - : ($this->propertyLabels($details, $configuration['date']) ?: '-'), + 'date' => $effectiveDates, 'size' => $configuration['size'] === null ? '-' : ($this->propertyLabels($details, $configuration['size']) ?: '-'), ]; } + private function effectiveEventDateLabels(Ticket $ticket): string + { + return $ticket->sourceVariant?->selectedEventDates() + ->map(fn (EventDate $date): ?EventDate => $date->effectiveDate()) + ->filter() + ->unique(fn (EventDate $date): int => $date->getKey()) + ->sortBy(fn (EventDate $date): string => $date->date->format('Y-m-d')) + ->map(fn (EventDate $date): string => $date->date->format('d/m')) + ->implode(', ') ?? ''; + } + /** @param array $details */ private function propertyLabels(array $details, string $code): string { $property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code); $labels = collect($property['values'] ?? [])->pluck('label')->filter(); - if ($code === 'event_date') { - $labels = $labels->map(function (string $label): string { - [$day, $month] = array_pad(explode('/', $label), 2, null); - - return $day !== null && $month !== null ? "{$day}/{$month}" : $label; - }); - } - return $labels->implode(', '); } diff --git a/resources/views/mail/notifications/event-date-rescheduled.blade.php b/resources/views/mail/notifications/event-date-rescheduled.blade.php index af33d18..bffd003 100644 --- a/resources/views/mail/notifications/event-date-rescheduled.blade.php +++ b/resources/views/mail/notifications/event-date-rescheduled.blade.php @@ -8,7 +8,7 @@

Tickets afectados

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

Compra #{{ $purchase->id }}

+

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

diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index a642f03..22c1ef0 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -352,11 +352,12 @@ class NotificationMailServiceTest extends TestCase Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $firstTicket, $secondTicket): bool { $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(), '20/10/2027') - && str_contains($mail->render(), '#'.$firstTicket->id) - && str_contains($mail->render(), '#'.$secondTicket->id); + && str_contains($mail->render(), 'N° de Ticket #'.$firstTicket->id) + && str_contains($mail->render(), 'N° de Ticket #'.$secondTicket->id) + && str_contains($mail->render(), 'N° de Orden #'.$purchase->id); }); $this->assertDatabaseHas('email_deliveries', [ 'idempotency_key' => "event-date-rescheduled:10:20:{$purchase->id}", diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index a9ea96e..b0ae3db 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -835,6 +835,50 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('total_tickets', 1); } + public function test_it_shows_the_effective_date_after_multiple_reschedules_and_suspension(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + Sanctum::actingAs($admin); + + $entry = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', 'abono')->firstOrFail(); + $variant = $entry->variants()->whereHas('eventDates')->firstOrFail(); + $original = $variant->selectedEventDates()->firstOrFail(); + $middle = $tenant->eventDates()->create([ + 'date' => '2026-11-01', 'time_start' => '09:00', 'time_end' => '18:00', + ]); + $latest = $tenant->eventDates()->create([ + 'date' => '2026-11-02', 'time_start' => '09:00', 'time_end' => '18:00', + ]); + $original->update(['rescheduled_to_event_date_id' => $middle->id]); + $middle->update(['rescheduled_to_event_date_id' => $latest->id]); + $ticket = $this->createTicket($tenant, $admin, [ + 'source_catalog_item_id' => $entry->id, + 'source_variant_id' => $variant->id, + ]); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.values.date', '10/10, 11/10, 12/10, 02/11'); + + $latest->update(['suspended_at' => now()]); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.values.date', '10/10, 11/10, 12/10'); + + foreach ($variant->selectedEventDates()->skip(1) as $date) { + $date->update(['suspended_at' => now()]); + } + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.values.date', '-'); + } + public function test_it_exposes_and_filters_merchandise_color_and_size(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); diff --git a/tests/Unit/Event/EventModelsTest.php b/tests/Unit/Event/EventModelsTest.php index cb3673c..7dc90d2 100644 --- a/tests/Unit/Event/EventModelsTest.php +++ b/tests/Unit/Event/EventModelsTest.php @@ -89,4 +89,21 @@ class EventModelsTest extends TestCase $this->assertInstanceOf(EventDate::class, $tenant->eventDates()->getRelated()); } + + public function test_effective_date_follows_all_replacements_and_returns_null_when_suspended(): void + { + $original = new EventDate; + $original->setRawAttributes(['id' => 1, 'rescheduled_to_event_date_id' => 2]); + $middle = new EventDate; + $middle->setRawAttributes(['id' => 2, 'rescheduled_to_event_date_id' => 3]); + $latest = new EventDate; + $latest->setRawAttributes(['id' => 3, 'date' => '2026-11-02']); + $original->setRelation('rescheduledTo', $middle); + $middle->setRelation('rescheduledTo', $latest); + + $this->assertSame($latest, $original->effectiveDate()); + + $latest->suspended_at = '2026-11-01 12:00:00'; + $this->assertNull($original->effectiveDate()); + } } -- 2.49.1 From 353fd34db228548f4671e386623417de538573db Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 15:00:28 -0300 Subject: [PATCH 57/63] feat(inventory): add refunded_units to inventory model and update related services --- app/Domains/Catalog/Models/Inventory.php | 3 + .../Services/VariantReplacementService.php | 3 + .../Services/FoodService.php | 1 + .../TenantTransactionResetService.php | 3 +- .../Ticket/Services/AdminAppTicketService.php | 50 +++++++++++++++++ ...0000_add_refunded_units_to_inventories.php | 23 ++++++++ .../Ticket/AdminAppTicketControllerTest.php | 55 +++++++++++++++++++ 7 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php diff --git a/app/Domains/Catalog/Models/Inventory.php b/app/Domains/Catalog/Models/Inventory.php index 5ff7f6c..ad20142 100644 --- a/app/Domains/Catalog/Models/Inventory.php +++ b/app/Domains/Catalog/Models/Inventory.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; #[Fillable([ 'sold_units', + 'refunded_units', 'reserved_stock', 'real_stock', ])] @@ -23,6 +24,7 @@ class Inventory extends Model protected $attributes = [ 'sold_units' => 0, + 'refunded_units' => 0, 'reserved_stock' => 0, 'real_stock' => 0, ]; @@ -31,6 +33,7 @@ class Inventory extends Model { return [ 'sold_units' => 'integer', + 'refunded_units' => 'integer', 'reserved_stock' => 'integer', 'real_stock' => 'integer', ]; diff --git a/app/Domains/Catalog/Services/VariantReplacementService.php b/app/Domains/Catalog/Services/VariantReplacementService.php index e4422d3..1b744da 100644 --- a/app/Domains/Catalog/Services/VariantReplacementService.php +++ b/app/Domains/Catalog/Services/VariantReplacementService.php @@ -188,6 +188,7 @@ class VariantReplacementService $replacementInventory = Inventory::query()->create([ 'sold_units' => $sourceInventory->sold_units, + 'refunded_units' => $sourceInventory->refunded_units, 'reserved_stock' => $reservedStock, 'real_stock' => $sourceInventory->real_stock, ]); @@ -235,6 +236,7 @@ class VariantReplacementService '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() @@ -245,6 +247,7 @@ class VariantReplacementService 'real_stock' => 0, 'reserved_stock' => 0, 'sold_units' => 0, + 'refunded_units' => 0, ]); } diff --git a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php index 571d1cd..d9a4064 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php @@ -177,6 +177,7 @@ class FoodService $historicalInventory = Inventory::query()->create([ 'sold_units' => $inventory->sold_units, + 'refunded_units' => $inventory->refunded_units, 'reserved_stock' => 0, 'real_stock' => $inventory->real_stock, ]); diff --git a/app/Domains/Purchase/Services/TenantTransactionResetService.php b/app/Domains/Purchase/Services/TenantTransactionResetService.php index be6b7c2..cd1d450 100644 --- a/app/Domains/Purchase/Services/TenantTransactionResetService.php +++ b/app/Domains/Purchase/Services/TenantTransactionResetService.php @@ -65,9 +65,10 @@ class TenantTransactionResetService DB::table('inventories') ->whereIn('id', $scope['inventory_ids']) ->update([ - 'real_stock' => DB::raw('real_stock + sold_units'), + 'real_stock' => DB::raw('real_stock + sold_units - refunded_units'), 'reserved_stock' => 0, 'sold_units' => 0, + 'refunded_units' => 0, ]); return $summary; diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 9960fe3..275f614 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -3,6 +3,9 @@ namespace App\Domains\Ticket\Services; use App\Domains\Auth\Models\User; +use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\Variant; use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Tenant\Models\Tenant; @@ -223,10 +226,57 @@ class AdminAppTicketService 'amount' => number_format($refundAmount, 2, '.', ''), ]); + $this->restoreInventory($ticket); + return $ticket->refresh()->load(self::RELATIONS); }); } + private function restoreInventory(Ticket $ticket): 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()) { + return; + } + + $inventoryId = $catalogItem->inventory_id; + if ($ticket->source_variant_id !== null) { + $variant = Variant::withTrashed()->find($ticket->source_variant_id); + if ($variant === null) { + throw ValidationException::withMessages(['ticket' => 'No se encontró la variante del ticket.']); + } + + // A replacement can move the sellable inventory to a newer variant. + $visited = []; + while ($variant->replaced_by_variant_id !== null) { + if (isset($visited[$variant->id])) { + throw new \LogicException('La cadena de reemplazos de variantes es circular.'); + } + $visited[$variant->id] = true; + $variant = Variant::withTrashed()->findOrFail($variant->replaced_by_variant_id); + } + $inventoryId = $variant->inventory_id; + } + + $inventory = Inventory::query()->lockForUpdate()->find($inventoryId); + if ($inventory === null) { + throw ValidationException::withMessages(['ticket' => 'No se encontró el inventario del ticket.']); + } + + if ($catalogItem->inventory_policy === InventoryPolicy::Tracked) { + $inventory->real_stock++; + } + $inventory->refunded_units++; + $inventory->save(); + } + private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float { return round((float) TicketRefund::query() diff --git a/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php b/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php new file mode 100644 index 0000000..0727aab --- /dev/null +++ b/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php @@ -0,0 +1,23 @@ +unsignedBigInteger('refunded_units')->default(0); + }); + + } + + public function down(): void + { + Schema::table('inventories', function (Blueprint $table): void { + $table->dropColumn('refunded_units'); + }); + } +}; diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index b0ae3db..f975205 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -186,6 +186,7 @@ class AdminAppTicketControllerTest extends TestCase $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + $inventory = $ticket->sourceCatalogItem->inventory; $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ 'refund_type' => 'total', @@ -201,6 +202,9 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.refund.created_by', $admin->nombre_apellido); $this->assertNotNull($ticket->fresh()->refunded_at); + $this->assertSame(1, $inventory->fresh()->real_stock); + $this->assertSame(1, $inventory->fresh()->sold_units); + $this->assertSame(1, $inventory->fresh()->refunded_units); $this->assertDatabaseHas('ticket_refunds', [ 'ticket_id' => $ticket->id, 'purchase_item_id' => $purchaseItem->id, @@ -208,6 +212,13 @@ class AdminAppTicketControllerTest extends TestCase 'type' => TicketRefund::TYPE_TOTAL, 'amount' => '100.00', ]); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ + 'refund_type' => 'total', + ])->assertUnprocessable(); + $this->assertSame(1, $inventory->fresh()->real_stock); + $this->assertSame(1, $inventory->fresh()->refunded_units); + $this->assertSame(1, $purchaseItem->ticketRefunds()->count()); } public function test_it_partially_refunds_a_ticket_using_the_tenant_percentage(): void @@ -222,6 +233,7 @@ class AdminAppTicketControllerTest extends TestCase $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + $inventory = $ticket->sourceCatalogItem->inventory; $this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [ 'refund_type' => 'partial', @@ -238,6 +250,47 @@ class AdminAppTicketControllerTest extends TestCase 'type' => TicketRefund::TYPE_PARTIAL, 'amount' => '25.50', ]); + $this->assertSame(1, $inventory->fresh()->real_stock); + $this->assertSame(1, $inventory->fresh()->refunded_units); + } + + public function test_existing_refunds_do_not_increment_the_new_refunded_units_counter(): 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); + + $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(1, $inventory->fresh()->refunded_units); + $this->assertSame(1, $inventory->fresh()->real_stock); } public function test_it_records_different_refund_types_for_tickets_from_the_same_purchase_item(): void @@ -1089,8 +1142,10 @@ class AdminAppTicketControllerTest extends TestCase /** @return array{Ticket, PurchaseItem} */ private function createRefundableTicket(Tenant $tenant, User $admin, string $amount): array { + $inventory = Inventory::query()->create(['sold_units' => 1]); $catalogItem = CatalogItem::query()->create([ 'tenant_code' => $tenant->codigo, + 'inventory_id' => $inventory->id, 'slug' => 'ticket-reembolsable-'.Str::uuid(), 'nombre' => 'Ticket reembolsable', 'precio' => $amount, -- 2.49.1 From a46bf905e84bf6e7cd42afdc943437dd2ecf4ce4 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 15:00:41 -0300 Subject: [PATCH 58/63] feat(refund): enhance refund process by adding backfill for refunded_units and updating inventory restoration logic --- .../Ticket/Services/AdminAppTicketService.php | 6 +- ...0000_add_refunded_units_to_inventories.php | 1 - ...6_09_16_000100_backfill_refunded_units.php | 147 ++++++++++++++++++ .../Ticket/AdminAppTicketControllerTest.php | 21 ++- 4 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_09_16_000100_backfill_refunded_units.php diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 275f614..1c626c9 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -226,13 +226,13 @@ class AdminAppTicketService 'amount' => number_format($refundAmount, 2, '.', ''), ]); - $this->restoreInventory($ticket); + $this->restoreInventory($ticket, $purchaseItem); return $ticket->refresh()->load(self::RELATIONS); }); } - private function restoreInventory(Ticket $ticket): void + private function restoreInventory(Ticket $ticket, PurchaseItem $purchaseItem): void { $catalogItem = $ticket->sourceCatalogItem; if ($catalogItem === null) { @@ -242,7 +242,7 @@ class AdminAppTicketService } // Bundle components need a per-ticket allocation before they can be restored. - if ($catalogItem->isBundle()) { + if ($catalogItem->isBundle() || $purchaseItem->sourceCatalogItem?->isBundle()) { return; } diff --git a/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php b/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php index 0727aab..a512d88 100644 --- a/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php +++ b/database/migrations/2026_09_16_000000_add_refunded_units_to_inventories.php @@ -11,7 +11,6 @@ return new class extends Migration Schema::table('inventories', function (Blueprint $table): void { $table->unsignedBigInteger('refunded_units')->default(0); }); - } public function down(): void diff --git a/database/migrations/2026_09_16_000100_backfill_refunded_units.php b/database/migrations/2026_09_16_000100_backfill_refunded_units.php new file mode 100644 index 0000000..5996107 --- /dev/null +++ b/database/migrations/2026_09_16_000100_backfill_refunded_units.php @@ -0,0 +1,147 @@ +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++; + // Bundle purchases need a component allocation that is outside this backfill. + 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')); + $realStockAdded = array_sum(array_column($counts, 'stock')); + + return [ + 'refunds_seen' => $refundsSeen, + 'refunds_applied' => $refundedUnitsAdded, + 'bundles_skipped' => $bundlesSkipped, + 'inventories_updated' => count($counts), + 'refunded_units_added' => $refundedUnitsAdded, + 'real_stock_added' => $realStockAdded, + ]; + }); + + Log::info('inventory.refunded_units_backfill.completed', $summary); + + // The migrator prints DONE after up() returns. Emit the summary once the + // whole migration run ends so it appears below that line in the console. + if (app()->runningInConsole()) { + $printed = false; + Event::listen(MigrationsEnded::class, static function (MigrationsEnded $event) use ($summary, &$printed): void { + if ($printed || $event->method !== 'up') { + return; + } + $printed = true; + + (new ConsoleOutput)->writeln(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'], + )); + }); + } + } + + /** @param array $variants */ + private function currentVariantInventoryId(int $variantId, array &$variants): ?int + { + $visited = []; + + while (true) { + if (isset($visited[$variantId])) { + throw new RuntimeException("La cadena de reemplazos de la variante {$variantId} es circular."); + } + $visited[$variantId] = true; + + if (! array_key_exists($variantId, $variants)) { + $variants[$variantId] = DB::table('variantes') + ->where('id', $variantId) + ->first(['inventory_id', 'replaced_by_variant_id']); + } + $variant = $variants[$variantId]; + if ($variant === null) { + throw new RuntimeException("No se encontró la variante {$variantId} de un ticket reembolsado."); + } + if ($variant->replaced_by_variant_id === null) { + return $variant->inventory_id === null ? null : (int) $variant->inventory_id; + } + + $variantId = (int) $variant->replaced_by_variant_id; + } + } + + public function down(): void + { + throw new RuntimeException('El backfill de reembolsos históricos no se puede revertir sin reconciliar el stock.'); + } +}; diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index f975205..2933595 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -23,6 +23,7 @@ use Database\Seeders\AttributeSeeder; use Database\Seeders\AuthorizationSeeder; use Database\Seeders\FiestaFutbolInfantilProductSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -254,7 +255,7 @@ class AdminAppTicketControllerTest extends TestCase $this->assertSame(1, $inventory->fresh()->refunded_units); } - public function test_existing_refunds_do_not_increment_the_new_refunded_units_counter(): void + public function test_backfill_restores_existing_refunds_before_new_refunds(): void { $tenant = $this->createTenant('ticket-historical-refund'); $tenant->update([ @@ -280,6 +281,20 @@ class AdminAppTicketControllerTest extends TestCase $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, + ]); + $backfill = require database_path('migrations/2026_09_16_000100_backfill_refunded_units.php'); + $backfill->up(); + + $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, @@ -289,8 +304,8 @@ class AdminAppTicketControllerTest extends TestCase ])->assertOk(); $this->assertSame(2, $inventory->fresh()->sold_units); - $this->assertSame(1, $inventory->fresh()->refunded_units); - $this->assertSame(1, $inventory->fresh()->real_stock); + $this->assertSame(2, $inventory->fresh()->refunded_units); + $this->assertSame(2, $inventory->fresh()->real_stock); } public function test_it_records_different_refund_types_for_tickets_from_the_same_purchase_item(): void -- 2.49.1 From 04e54fefad6d2b47b474d51d09806005ad6c566c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 16:35:51 -0300 Subject: [PATCH 59/63] feat(notification): update ticket and order number formatting in event suspension emails --- .../Notification/Services/NotificationMailService.php | 2 +- .../views/mail/notifications/event-date-suspended.blade.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index 32e32d6..6652a04 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -398,7 +398,7 @@ class NotificationMailService ->forTenant($tenantCode) ->send( $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( 'purchase', 'date', 'disabledTickets', 'activeTickets' ))->render(), diff --git a/resources/views/mail/notifications/event-date-suspended.blade.php b/resources/views/mail/notifications/event-date-suspended.blade.php index 9f69adc..4aabbb4 100644 --- a/resources/views/mail/notifications/event-date-suspended.blade.php +++ b/resources/views/mail/notifications/event-date-suspended.blade.php @@ -5,7 +5,7 @@

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

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

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

@@ -15,9 +15,9 @@

Estos tickets conservan otras fechas disponibles:

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

Compra #{{ $purchase->id }}

+

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

-- 2.49.1 From 1269ebcda09dff3baa854085a26d9e1241693d0b Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 17 Sep 2026 09:38:57 -0300 Subject: [PATCH 60/63] feat(ticket): exclude cancelled status from common ticket filter fields --- app/Domains/Forms/Services/TicketFilterFormService.php | 5 ++++- .../Feature/Forms/AdminAppTicketFilterFormControllerTest.php | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/Domains/Forms/Services/TicketFilterFormService.php b/app/Domains/Forms/Services/TicketFilterFormService.php index 7574a92..2089413 100644 --- a/app/Domains/Forms/Services/TicketFilterFormService.php +++ b/app/Domains/Forms/Services/TicketFilterFormService.php @@ -124,7 +124,10 @@ class TicketFilterFormService 'required' => false, 'default' => null, 'placeholder' => 'Estado', - 'options' => Ticket::statusOptions(), + 'options' => array_values(array_filter( + Ticket::statusOptions(), + fn (array $option): bool => $option['value'] !== Ticket::STATUS_CANCELLED, + )), ], ]; } diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php index 6e0659c..3174d77 100644 --- a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -79,7 +79,6 @@ class AdminAppTicketFilterFormControllerTest extends TestCase ['value' => 'used', 'label' => 'Usado'], ['value' => 'expired', 'label' => 'Vencido'], ['value' => 'disabled', 'label' => 'Inhabilitado'], - ['value' => 'cancelled', 'label' => 'Cancelado'], ['value' => 'refunded', 'label' => 'Reembolsado'], ], ], -- 2.49.1 From de08ee4ac78ad319ac8b204f78263bc510fb08f1 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 17 Sep 2026 10:27:27 -0300 Subject: [PATCH 61/63] feat(cart): implement InvalidateEventDateCartsService to handle cart invalidation for rescheduled event dates --- .../InvalidateEventDateCartsService.php | 56 ++++++ .../Services/StockReservationService.php | 2 + app/Domains/Event/Services/EventService.php | 6 +- .../Event/AdminAppEventControllerTest.php | 38 ++++ .../InvalidateEventDateCartsServiceTest.php | 164 ++++++++++++++++++ 5 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 app/Domains/Cart/Services/InvalidateEventDateCartsService.php create mode 100644 tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php diff --git a/app/Domains/Cart/Services/InvalidateEventDateCartsService.php b/app/Domains/Cart/Services/InvalidateEventDateCartsService.php new file mode 100644 index 0000000..5480113 --- /dev/null +++ b/app/Domains/Cart/Services/InvalidateEventDateCartsService.php @@ -0,0 +1,56 @@ + $eventDateIds */ + public function invalidate(Tenant $tenant, Collection $eventDateIds): void + { + DB::transaction(function () use ($tenant, $eventDateIds): 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, + StockReservationService::REASON_EVENT_DATE_RESCHEDULED, + ); + + // Reuse the existing expired-cart flow: stale mutations receive the + // expiration response and the next GET replaces the whole cart. + $cart->update(['status' => Cart::STATUS_EXPIRED]); + } + }); + } +} diff --git a/app/Domains/Catalog/Services/StockReservationService.php b/app/Domains/Catalog/Services/StockReservationService.php index 96d931b..bd6e0b5 100644 --- a/app/Domains/Catalog/Services/StockReservationService.php +++ b/app/Domains/Catalog/Services/StockReservationService.php @@ -19,6 +19,8 @@ class StockReservationService public const REASON_CART_CHANGED = 'cart_changed'; + public const REASON_EVENT_DATE_RESCHEDULED = 'event_date_rescheduled'; + public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded'; public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled'; diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index 8ea5d72..0662d09 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -3,6 +3,7 @@ namespace App\Domains\Event\Services; use App\Domains\Auth\Models\User; +use App\Domains\Cart\Services\InvalidateEventDateCartsService; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\VariantReplacementService; use App\Domains\Event\Enums\EventDateChangeType; @@ -28,6 +29,7 @@ class EventService private readonly EffectiveEventDateResolver $effectiveEventDateResolver, private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver, private readonly VariantReplacementService $variantReplacementService, + private readonly InvalidateEventDateCartsService $invalidateEventDateCarts, ) {} public function forTenant(Tenant $tenant): Tenant @@ -125,9 +127,11 @@ class EventService ]); } + $affectedDateIds = $this->affectedDateIds($tenant, $source); + $this->invalidateEventDateCarts->invalidate($tenant, $affectedDateIds); $purchaseTickets = $this->affectedPurchaseResolver->resolve( $tenant, - $this->affectedDateIds($tenant, $source), + $affectedDateIds, ); $source->update(['rescheduled_to_event_date_id' => $destination->getKey()]); $this->variantReplacementService->replaceEventDate($source, $effectiveDestination); diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 72e27ee..e07ffe2 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -6,6 +6,7 @@ use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; +use App\Domains\Cart\Models\Cart; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\StockReservation; @@ -421,6 +422,43 @@ class AdminAppEventControllerTest extends TestCase ); } + public function test_rescheduling_invalidates_reserved_carts_before_replacing_variants(): void + { + Event::fake([EventDateRescheduled::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}/reschedule", [ + 'date' => '2027-10-20', + ])->assertOk(); + + $this->assertSame(Cart::STATUS_EXPIRED, $cart->fresh()->status); + $this->assertSame(StockReservation::STATUS_RELEASED, $reservation->fresh()->status); + $this->assertSame(0, $variant->fresh()->replacement->inventory->reserved_stock); + $this->getJson('/api/tenants/acme/cart')->assertOk()->assertJsonCount(0, 'data.items'); + } + public function test_rescheduling_reuses_an_equivalent_destination_variant(): void { Event::fake([EventDateRescheduled::class]); diff --git a/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php b/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php new file mode 100644 index 0000000..a551c4e --- /dev/null +++ b/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php @@ -0,0 +1,164 @@ +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 function test_invalidates_whole_cart_and_releases_all_its_stock_only_once(): 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(); + $this->invalidate(); + + $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' => 'event_date_rescheduled', + ]); + } + + 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(): void + { + app(InvalidateEventDateCartsService::class)->invalidate(new Tenant(['codigo' => 'acme']), collect([1])); + } + + 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; + } +} -- 2.49.1 From df6892c6624790b64e206f86b6eb8c67a3ef1f40 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 17 Sep 2026 11:34:26 -0300 Subject: [PATCH 62/63] feat(checkout): enhance cancellation logic to handle unavailable variants and update cart status --- .../Checkout/ReleaseCheckoutService.php | 23 +++++- tests/Feature/Purchase/StorePurchaseTest.php | 77 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index 70ce711..e64ef79 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -3,6 +3,7 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Cart\Models\Cart; +use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Exceptions\PurchaseExpiredException; @@ -91,7 +92,16 @@ class ReleaseCheckoutService Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, ], true)) { - $this->reservations->returnToCart($purchase, $cart); + if ($this->hasUnavailableVariants($cart)) { + $this->releasePurchaseReservations($purchase, $targetStatus, $cart); + $cart->update([ + 'status' => Cart::STATUS_EXPIRED, + 'current_purchase_id' => null, + 'current_stock_reservation_id' => null, + ]); + } else { + $this->reservations->returnToCart($purchase, $cart); + } $purchase->update(['status' => Purchase::STATUS_CANCELLED]); return $this->loadPurchase($purchase); @@ -115,6 +125,17 @@ class ReleaseCheckoutService }); } + private function hasUnavailableVariants(Cart $cart): bool + { + return $cart->items() + ->whereNotNull('variant_id') + ->with(['variant.eventDate', 'variant.eventDates']) + ->lockForUpdate() + ->get() + ->contains(fn (CartItem $item): bool => $item->variant === null + || ! $item->variant->isSellable()); + } + private function releasePurchaseReservations( Purchase $purchase, string $targetStatus, diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 9014f75..65ec3f6 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -11,7 +11,9 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\StockReservation; +use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Models\EventDate; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\UserPurchaseLimitService; @@ -20,6 +22,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; class StorePurchaseTest extends TestCase @@ -642,6 +645,80 @@ class StorePurchaseTest extends TestCase $this->assertSame(1, $activeCart->items()->count()); } + /** @return array */ + public static function unavailableCancellationCases(): array + { + return [ + 'created with disabled variant' => [Purchase::STATUS_CREATED, 'disabled'], + 'pending payment with replaced variant' => [Purchase::STATUS_PENDING_PAYMENT, 'replaced'], + 'pending payment with suspended date' => [Purchase::STATUS_PENDING_PAYMENT, 'suspended'], + ]; + } + + #[DataProvider('unavailableCancellationCases')] + public function test_cancelling_a_purchase_with_unavailable_variants_invalidates_the_whole_cart( + string $purchaseStatus, + string $change, + ): void { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $otherVariant = $this->createVariantForTenant('sonder', 10, '25.00', 'other'); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, 'user_id' => $user->id, + 'status' => Cart::STATUS_ACTIVE, 'origin' => Cart::ORIGIN_USER, + ]); + $cart->addItem($variant->catalog_item_id, $variant->id, 2); + $cart->addItem($otherVariant->catalog_item_id, $otherVariant->id, 1); + $purchase = app(CheckoutService::class)->startCheckout($tenant, $user->id, ['cart_id' => $cart->id]); + $purchase->update(['status' => $purchaseStatus]); + $reservedInventoryId = $variant->inventory_id; + + if ($change === 'replaced') { + // A reprogramming can move reservation lines away from the original variant. + $replacementInventory = Inventory::query()->create(['real_stock' => 10, 'reserved_stock' => 2]); + $replacement = Variant::query()->create([ + 'catalog_item_id' => $variant->catalog_item_id, 'inventory_id' => $replacementInventory->id, + ]); + StockReservationLine::query()->where('stock_reservation_id', $purchase->stock_reservation_id) + ->where('inventory_id', $variant->inventory_id) + ->update(['inventory_id' => $replacementInventory->id]); + $variant->inventory->update(['reserved_stock' => 0]); + $variant->update(['replaced_by_variant_id' => $replacement->id, 'sales_disabled_at' => now()]); + $reservedInventoryId = $replacementInventory->id; + } elseif ($change === 'suspended') { + $date = EventDate::query()->create([ + 'tenant_code' => $tenant->codigo, 'date' => '2027-10-09', + 'time_start' => '09:00', 'time_end' => '18:00', 'suspended_at' => now(), + ]); + $variant->eventDates()->sync([$date->id]); + } else { + $variant->update(['sales_disabled_at' => now()]); + } + + $this->actingAs($user, 'sanctum'); + for ($attempt = 0; $attempt < 2; $attempt++) { + $this->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") + ->assertOk()->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); + } + + $this->assertDatabaseHas('carritos', [ + 'id' => $cart->id, 'status' => Cart::STATUS_EXPIRED, + 'current_purchase_id' => null, 'current_stock_reservation_id' => null, + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $purchase->stock_reservation_id, 'status' => StockReservation::STATUS_RELEASED, + 'release_reason' => 'purchase_cancelled', + ]); + foreach ([$reservedInventoryId, $otherVariant->inventory_id] as $inventoryId) { + $this->assertDatabaseHas('inventories', [ + 'id' => $inventoryId, 'real_stock' => 10, 'reserved_stock' => 0, + ]); + } + $this->getJson('/api/tenants/sonder/cart')->assertOk()->assertJsonCount(0, 'data.items'); + $this->assertSame(Cart::STATUS_ABANDONED, $cart->fresh()->status); + } + public function test_it_reuses_the_cart_reservation_for_a_new_checkout_and_rejects_a_late_confirmation(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); -- 2.49.1 From 830f65c402774dcfe84887d069a64d08ed6b1d6a Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 17 Sep 2026 11:42:33 -0300 Subject: [PATCH 63/63] feat(refund): add support for event date suspension in cart invalidation process --- .../InvalidateEventDateCartsService.php | 11 +++++---- .../Services/StockReservationService.php | 2 ++ app/Domains/Event/Services/EventService.php | 8 +++++-- .../Event/AdminAppEventControllerTest.php | 24 ++++++++++++++----- .../InvalidateEventDateCartsServiceTest.php | 23 +++++++++++++----- 5 files changed, 50 insertions(+), 18 deletions(-) diff --git a/app/Domains/Cart/Services/InvalidateEventDateCartsService.php b/app/Domains/Cart/Services/InvalidateEventDateCartsService.php index 5480113..0d92748 100644 --- a/app/Domains/Cart/Services/InvalidateEventDateCartsService.php +++ b/app/Domains/Cart/Services/InvalidateEventDateCartsService.php @@ -15,9 +15,12 @@ class InvalidateEventDateCartsService public function __construct(private readonly StockReservationService $reservations) {} /** @param Collection $eventDateIds */ - public function invalidate(Tenant $tenant, Collection $eventDateIds): void - { - DB::transaction(function () use ($tenant, $eventDateIds): void { + 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) @@ -44,7 +47,7 @@ class InvalidateEventDateCartsService $this->reservations->releaseCurrentCartReservation( $cart, - StockReservationService::REASON_EVENT_DATE_RESCHEDULED, + $reason, ); // Reuse the existing expired-cart flow: stale mutations receive the diff --git a/app/Domains/Catalog/Services/StockReservationService.php b/app/Domains/Catalog/Services/StockReservationService.php index bd6e0b5..87228a5 100644 --- a/app/Domains/Catalog/Services/StockReservationService.php +++ b/app/Domains/Catalog/Services/StockReservationService.php @@ -21,6 +21,8 @@ class StockReservationService public const REASON_EVENT_DATE_RESCHEDULED = 'event_date_rescheduled'; + public const REASON_EVENT_DATE_SUSPENDED = 'event_date_suspended'; + public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded'; public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled'; diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index 0662d09..7cecbd1 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -5,6 +5,7 @@ namespace App\Domains\Event\Services; use App\Domains\Auth\Models\User; use App\Domains\Cart\Services\InvalidateEventDateCartsService; use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Catalog\Services\VariantReplacementService; use App\Domains\Event\Enums\EventDateChangeType; use App\Domains\Event\Events\EventDateRescheduled; @@ -177,10 +178,13 @@ class EventService return $date->load('validityTime'); } - $purchaseTickets = $this->affectedPurchaseResolver->resolve( + $affectedDateIds = $this->affectedDateIds($tenant, $date); + $this->invalidateEventDateCarts->invalidate( $tenant, - $this->affectedDateIds($tenant, $date), + $affectedDateIds, + StockReservationService::REASON_EVENT_DATE_SUSPENDED, ); + $purchaseTickets = $this->affectedPurchaseResolver->resolve($tenant, $affectedDateIds); $date->update(['suspended_at' => now()]); $this->variantReplacementService->disableForSuspension($date); $this->disableTicketsWithoutUsableDates($tenant, $date); diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index e07ffe2..5c4f79a 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -26,6 +26,7 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Laravel\Sanctum\Sanctum; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; class AdminAppEventControllerTest extends TestCase @@ -422,9 +423,18 @@ class AdminAppEventControllerTest extends TestCase ); } - public function test_rescheduling_invalidates_reserved_carts_before_replacing_variants(): void + public static function cartInvalidatingDateChanges(): array { - Event::fake([EventDateRescheduled::class]); + 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', @@ -449,13 +459,15 @@ class AdminAppEventControllerTest extends TestCase ]); Sanctum::actingAs($admin); - $this->postJson("/api/v1/adminapp/tenant/event-dates/{$source->id}/reschedule", [ - 'date' => '2027-10-20', - ])->assertOk(); + $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(0, $variant->fresh()->replacement->inventory->reserved_stock); + $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'); } diff --git a/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php b/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php index a551c4e..3d59a4d 100644 --- a/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php +++ b/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php @@ -6,10 +6,12 @@ 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 @@ -82,7 +84,16 @@ class InvalidateEventDateCartsServiceTest extends TestCase DB::table('variant_event_dates')->insert(['variant_id' => 3, 'event_date_id' => 1]); } - public function test_invalidates_whole_cart_and_releases_all_its_stock_only_once(): void + 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([]); @@ -93,8 +104,8 @@ class InvalidateEventDateCartsServiceTest extends TestCase 'quantity' => 2, ]); $reservationId = $cart->current_stock_reservation_id; - $this->invalidate(); - $this->invalidate(); + $this->invalidate($reason); + $this->invalidate($reason); $this->assertSame(Cart::STATUS_EXPIRED, $cart->fresh()->status); $this->assertNull($cart->fresh()->current_stock_reservation_id); @@ -102,7 +113,7 @@ class InvalidateEventDateCartsServiceTest extends TestCase $this->assertSame(20, (int) Inventory::query()->sum('real_stock')); $this->assertDatabaseHas('stock_reservations', [ 'id' => $reservationId, 'status' => StockReservation::STATUS_RELEASED, - 'release_reason' => 'event_date_rescheduled', + 'release_reason' => $reason, ]); } @@ -138,9 +149,9 @@ class InvalidateEventDateCartsServiceTest extends TestCase $this->assertSame(2, (int) Inventory::query()->sum('reserved_stock')); } - private function invalidate(): void + private function invalidate(string $reason = StockReservationService::REASON_EVENT_DATE_RESCHEDULED): void { - app(InvalidateEventDateCartsService::class)->invalidate(new Tenant(['codigo' => 'acme']), collect([1])); + app(InvalidateEventDateCartsService::class)->invalidate(new Tenant(['codigo' => 'acme']), collect([1]), $reason); } private function cart(int $variantId, string $tenantCode = 'acme'): Cart -- 2.49.1