From 7996d53e12857b6bff670bd3db1d40bd439f5b47 Mon Sep 17 00:00:00 2001 From: nahu Date: Mon, 28 Sep 2026 19:13:36 +0000 Subject: [PATCH 1/2] feat(events): associate purchases and integrations with events --- .../Commerce/Purchase/Models/Purchase.php | 13 ++++- .../Purchase/Resources/PurchaseResource.php | 5 +- .../Checkout/PurchaseResponseLoader.php | 1 + .../Checkout/StartCheckoutService.php | 4 ++ app/Domains/Core/Client/Models/Client.php | 9 ++- app/Domains/Ticketing/Event/Models/Event.php | 16 +++++- ...6_09_28_000000_add_client_id_to_events.php | 26 +++++++++ ...6_09_28_000100_add_event_id_to_compras.php | 44 ++++++++++++++ ...sign_arrufo_event_to_pyme_rural_client.php | 42 ++++++++++++++ .../seeders/FiestaTradicionArrufoSeeder.php | 8 +++ .../Migrations/AddClientIdToEventsTest.php | 57 +++++++++++++++++++ .../Migrations/AddEventIdToPurchasesTest.php | 44 ++++++++++++++ ...AssignArrufoEventToPymeRuralClientTest.php | 50 ++++++++++++++++ 13 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_09_28_000000_add_client_id_to_events.php create mode 100644 database/migrations/2026_09_28_000100_add_event_id_to_compras.php create mode 100644 database/migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php create mode 100644 tests/Feature/Migrations/AddClientIdToEventsTest.php create mode 100644 tests/Feature/Migrations/AddEventIdToPurchasesTest.php create mode 100644 tests/Feature/Migrations/AssignArrufoEventToPymeRuralClientTest.php diff --git a/app/Domains/Commerce/Purchase/Models/Purchase.php b/app/Domains/Commerce/Purchase/Models/Purchase.php index 5c75fa7f..68fec2f3 100644 --- a/app/Domains/Commerce/Purchase/Models/Purchase.php +++ b/app/Domains/Commerce/Purchase/Models/Purchase.php @@ -2,13 +2,14 @@ namespace App\Domains\Commerce\Purchase\Models; -use App\Domains\Core\Auth\Models\User; use App\Domains\Commerce\Cart\Models\Cart; use App\Domains\Commerce\Catalog\Models\StockReservation; -use App\Shared\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Commerce\Purchase\Events\PurchasePaid; +use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Ticket\Models\Ticket; +use App\Shared\Logging\Models\Concerns\LogsValueChanges; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -21,6 +22,7 @@ use Illuminate\Support\Facades\DB; #[Fillable([ 'cart_id', 'stock_reservation_id', + 'event_id', 'tenant_codigo', 'user_id', 'status', @@ -149,6 +151,7 @@ class Purchase extends Model return [ 'cart_id' => 'integer', 'stock_reservation_id' => 'integer', + 'event_id' => 'integer', 'user_id' => 'integer', 'total' => 'decimal:2', ]; @@ -162,6 +165,12 @@ class Purchase extends Model return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); } + /** @return BelongsTo */ + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + /** * @return BelongsTo */ diff --git a/app/Domains/Commerce/Purchase/Resources/PurchaseResource.php b/app/Domains/Commerce/Purchase/Resources/PurchaseResource.php index 2e98a2e8..d5624cf5 100644 --- a/app/Domains/Commerce/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Commerce/Purchase/Resources/PurchaseResource.php @@ -36,11 +36,11 @@ class PurchaseResource extends JsonResource === StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT; /** @var Event|null $checkoutEvent */ $checkoutEvent = $usesEventSummary - ? $items + ? ($this->event ?? $items ->map(fn (PurchaseItem $item) => $item->sourceCatalogItem?->event) ->filter() ->unique('id') - ->first() + ->first()) : null; $subtotal = $items->isNotEmpty() @@ -62,6 +62,7 @@ class PurchaseResource extends JsonResource return [ 'id' => $this->id, 'cart_id' => $this->cart_id, + 'event_id' => $this->event_id, 'tenant_codigo' => $this->tenant_codigo, 'user_id' => $this->user_id, 'created_at' => $this->created_at, diff --git a/app/Domains/Commerce/Purchase/Services/Checkout/PurchaseResponseLoader.php b/app/Domains/Commerce/Purchase/Services/Checkout/PurchaseResponseLoader.php index ed82d2d6..88dfb5f3 100644 --- a/app/Domains/Commerce/Purchase/Services/Checkout/PurchaseResponseLoader.php +++ b/app/Domains/Commerce/Purchase/Services/Checkout/PurchaseResponseLoader.php @@ -14,6 +14,7 @@ class PurchaseResponseLoader === StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT; $relations = [ 'tenant', + 'event.attachment', 'items.sourceCatalogItem.event.attachment', 'stockReservation', ]; diff --git a/app/Domains/Commerce/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Commerce/Purchase/Services/Checkout/StartCheckoutService.php index e2a1bf14..e21ab889 100644 --- a/app/Domains/Commerce/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Commerce/Purchase/Services/Checkout/StartCheckoutService.php @@ -206,6 +206,7 @@ class StartCheckoutService fn (array $line): float => $line['selection']->getPrice() * $line['quantity'], ), $cart->getKey(), + $cartItems->first()?->catalogItem?->event_id, ); $cart->update(['current_purchase_id' => $purchase->getKey()]); $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); @@ -273,6 +274,7 @@ class StartCheckoutService $purchaseData, $cart->getTotalAmount(), $cart->getKey(), + $cartItems->first()?->catalogItem?->event_id, ); $cart->update(['current_purchase_id' => $purchase->getKey()]); $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); @@ -410,10 +412,12 @@ class StartCheckoutService array $purchaseData, float $total, ?int $cartId, + ?int $eventId, ): Purchase { return Purchase::query()->create([ ...$purchaseData, 'cart_id' => $cartId, + 'event_id' => $eventId, 'tenant_codigo' => $tenant->codigo, 'user_id' => $userId, 'status' => Purchase::STATUS_CREATED, diff --git a/app/Domains/Core/Client/Models/Client.php b/app/Domains/Core/Client/Models/Client.php index f52054ff..e7381320 100644 --- a/app/Domains/Core/Client/Models/Client.php +++ b/app/Domains/Core/Client/Models/Client.php @@ -2,8 +2,9 @@ namespace App\Domains\Core\Client\Models; -use App\Shared\Integration\Models\ClientIntegration; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; +use App\Shared\Integration\Models\ClientIntegration; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -22,6 +23,12 @@ class Client extends Model return $this->hasMany(Tenant::class); } + /** @return HasMany */ + public function events(): HasMany + { + return $this->hasMany(Event::class); + } + /** @return HasMany */ public function integrations(): HasMany { diff --git a/app/Domains/Ticketing/Event/Models/Event.php b/app/Domains/Ticketing/Event/Models/Event.php index d27905f1..ca3e042d 100644 --- a/app/Domains/Ticketing/Event/Models/Event.php +++ b/app/Domains/Ticketing/Event/Models/Event.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticketing\Event\Models; use App\Domains\Commerce\Catalog\Models\CatalogItem; +use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Tenant\Models\SocialMedia; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; @@ -14,14 +15,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])] +#[Fillable(['client_id', 'tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])] class Event extends Model { use HasFactory; protected function casts(): array { - return ['published_at' => 'datetime', 'exact_location' => 'array']; + return ['client_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array']; } /** @return BelongsTo */ @@ -30,6 +31,17 @@ class Event extends Model return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); } + /** @return BelongsTo */ + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function effectiveClient(): Client + { + return $this->client ?? $this->tenant->client; + } + /** @return BelongsTo */ public function eventCategory(): BelongsTo { diff --git a/database/migrations/2026_09_28_000000_add_client_id_to_events.php b/database/migrations/2026_09_28_000000_add_client_id_to_events.php new file mode 100644 index 00000000..039f6804 --- /dev/null +++ b/database/migrations/2026_09_28_000000_add_client_id_to_events.php @@ -0,0 +1,26 @@ +foreignId('client_id') + ->nullable() + ->after('id') + ->constrained('clients') + ->restrictOnDelete(); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table): void { + $table->dropConstrainedForeignId('client_id'); + }); + } +}; diff --git a/database/migrations/2026_09_28_000100_add_event_id_to_compras.php b/database/migrations/2026_09_28_000100_add_event_id_to_compras.php new file mode 100644 index 00000000..0dad3a5b --- /dev/null +++ b/database/migrations/2026_09_28_000100_add_event_id_to_compras.php @@ -0,0 +1,44 @@ +foreignId('event_id') + ->nullable() + ->after('tenant_codigo') + ->constrained('events') + ->nullOnDelete(); + }); + + DB::table('compras') + ->orderBy('id') + ->each(function (object $purchase): void { + $eventIds = DB::table('compra_items') + ->join('catalog_items', 'catalog_items.id', '=', 'compra_items.source_catalog_item_id') + ->where('compra_items.compra_id', $purchase->id) + ->whereNotNull('catalog_items.event_id') + ->distinct() + ->pluck('catalog_items.event_id'); + + if ($eventIds->count() === 1) { + DB::table('compras') + ->where('id', $purchase->id) + ->update(['event_id' => $eventIds->first()]); + } + }); + } + + public function down(): void + { + Schema::table('compras', function (Blueprint $table): void { + $table->dropConstrainedForeignId('event_id'); + }); + } +}; diff --git a/database/migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php b/database/migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php new file mode 100644 index 00000000..601f22e8 --- /dev/null +++ b/database/migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php @@ -0,0 +1,42 @@ +where('code', 'pyme_rural') + ->value('id'); + + if ($clientId === null) { + return; + } + + DB::table('events') + ->where('tenant_code', 'onticket') + ->where('title', self::EVENT_TITLE) + ->update(['client_id' => $clientId]); + } + + public function down(): void + { + $clientId = DB::table('clients') + ->where('code', 'pyme_rural') + ->value('id'); + + if ($clientId === null) { + return; + } + + DB::table('events') + ->where('tenant_code', 'onticket') + ->where('title', self::EVENT_TITLE) + ->where('client_id', $clientId) + ->update(['client_id' => null]); + } +}; diff --git a/database/seeders/FiestaTradicionArrufoSeeder.php b/database/seeders/FiestaTradicionArrufoSeeder.php index 79b0dbbd..69263cb7 100644 --- a/database/seeders/FiestaTradicionArrufoSeeder.php +++ b/database/seeders/FiestaTradicionArrufoSeeder.php @@ -6,6 +6,7 @@ use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Models\Attribute; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Services\CatalogService; +use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Event\Models\Event; use App\Shared\Attachable\Services\AttachmentService; @@ -35,6 +36,12 @@ class FiestaTradicionArrufoSeeder extends Seeder throw new RuntimeException("Tenant 'onticket' no encontrado."); } + $client = Client::query()->where('code', 'pyme_rural')->first(); + + if ($client === null) { + throw new RuntimeException("Client 'pyme_rural' no encontrado."); + } + Attribute::query()->firstOrCreate( ['tenant_codigo' => $tenant->codigo, 'codigo' => 'event_date'], ['nombre' => 'Fecha', 'type' => FieldType::EventDate, 'is_required' => true], @@ -45,6 +52,7 @@ class FiestaTradicionArrufoSeeder extends Seeder ['published_at' => now()], ); $event->update([ + 'client_id' => $client->id, 'subtitle' => 'Una noche para celebrar nuestras raíces y mantener viva la tradición gaucha.', 'description' => 'La 26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas reunirá a agrupaciones, artesanos, pilcheros y público en general para compartir una jornada dedicada a nuestras costumbres y cultura.' ."\n\n".'Un encuentro para disfrutar de la tradición, la identidad gaucha y el espíritu de camaradería, en el Predio de Doma del Club Unión Deportiva Arrufó.' diff --git a/tests/Feature/Migrations/AddClientIdToEventsTest.php b/tests/Feature/Migrations/AddClientIdToEventsTest.php new file mode 100644 index 00000000..1fdb6e0d --- /dev/null +++ b/tests/Feature/Migrations/AddClientIdToEventsTest.php @@ -0,0 +1,57 @@ +id(); + $table->string('code'); + $table->string('name'); + }); + Schema::create('tenants', function (Blueprint $table): void { + $table->id(); + $table->foreignId('client_id'); + $table->string('codigo'); + }); + Schema::create('events', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('title'); + $table->timestamps(); + }); + + DB::table('clients')->insert([ + ['id' => 10, 'code' => 'tenant-client', 'name' => 'Tenant Client'], + ['id' => 11, 'code' => 'event-client', 'name' => 'Event Client'], + ]); + DB::table('tenants')->insert(['id' => 20, 'client_id' => 10, 'codigo' => 'tenant']); + DB::table('events')->insert([ + 'id' => 30, + 'tenant_code' => 'tenant', + 'title' => 'Event', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $migration = require database_path('migrations/2026_09_28_000000_add_client_id_to_events.php'); + $migration->up(); + + $event = Event::query()->findOrFail(30); + $this->assertSame(10, $event->effectiveClient()->id); + + $event->update(['client_id' => 11]); + + $this->assertSame(11, $event->fresh()->effectiveClient()->id); + $this->assertTrue(Client::query()->findOrFail(11)->events()->whereKey(30)->exists()); + } +} diff --git a/tests/Feature/Migrations/AddEventIdToPurchasesTest.php b/tests/Feature/Migrations/AddEventIdToPurchasesTest.php new file mode 100644 index 00000000..2c6d24e3 --- /dev/null +++ b/tests/Feature/Migrations/AddEventIdToPurchasesTest.php @@ -0,0 +1,44 @@ + $table->id()); + Schema::create('compras', fn (Blueprint $table) => $table->id()); + Schema::create('catalog_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('event_id')->nullable(); + }); + Schema::create('compra_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('compra_id'); + $table->foreignId('source_catalog_item_id'); + }); + + DB::table('events')->insert([['id' => 10], ['id' => 11]]); + DB::table('compras')->insert([['id' => 20], ['id' => 21]]); + DB::table('catalog_items')->insert([ + ['id' => 30, 'event_id' => 10], + ['id' => 31, 'event_id' => 11], + ]); + DB::table('compra_items')->insert([ + ['compra_id' => 20, 'source_catalog_item_id' => 30], + ['compra_id' => 21, 'source_catalog_item_id' => 30], + ['compra_id' => 21, 'source_catalog_item_id' => 31], + ]); + + $migration = require database_path('migrations/2026_09_28_000100_add_event_id_to_compras.php'); + $migration->up(); + + $this->assertDatabaseHas('compras', ['id' => 20, 'event_id' => 10]); + $this->assertDatabaseHas('compras', ['id' => 21, 'event_id' => null]); + } +} diff --git a/tests/Feature/Migrations/AssignArrufoEventToPymeRuralClientTest.php b/tests/Feature/Migrations/AssignArrufoEventToPymeRuralClientTest.php new file mode 100644 index 00000000..91a80fd2 --- /dev/null +++ b/tests/Feature/Migrations/AssignArrufoEventToPymeRuralClientTest.php @@ -0,0 +1,50 @@ +id(); + $table->string('code'); + }); + Schema::create('events', function (Blueprint $table): void { + $table->id(); + $table->foreignId('client_id')->nullable(); + $table->string('tenant_code'); + $table->string('title'); + }); + + DB::table('clients')->insert([ + ['id' => 10, 'code' => 'pyme_rural'], + ['id' => 11, 'code' => 'onticket'], + ]); + DB::table('events')->insert([ + [ + 'id' => 20, + 'client_id' => null, + 'tenant_code' => 'onticket', + 'title' => '26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas', + ], + [ + 'id' => 21, + 'client_id' => 11, + 'tenant_code' => 'onticket', + 'title' => 'Otro evento', + ], + ]); + + $migration = require database_path('migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php'); + $migration->up(); + + $this->assertDatabaseHas('events', ['id' => 20, 'client_id' => 10]); + $this->assertDatabaseHas('events', ['id' => 21, 'client_id' => 11]); + } +} From 3f9b7733671cbde5f4b91c8d0781536091d7c888 Mon Sep 17 00:00:00 2001 From: nahu Date: Mon, 28 Sep 2026 19:14:23 +0000 Subject: [PATCH 2/2] feat(integrations): resolve payment context from events --- .../Controllers/PurchaseController.php | 11 ++- .../Services/BaseIntegrationService.php | 16 +++- .../Integration/Services/MailService.php | 38 +++++---- .../Services/TelepagosIntegrationService.php | 2 +- .../Services/TelepagosWebhookService.php | 39 ++++++++- .../Services/NotificationMailService.php | 28 ++++--- .../Integration/TelepagosWebhookTest.php | 81 ++++++++++++++++++- 7 files changed, 173 insertions(+), 42 deletions(-) diff --git a/app/Domains/Commerce/Purchase/Controllers/PurchaseController.php b/app/Domains/Commerce/Purchase/Controllers/PurchaseController.php index f2036131..19a94804 100644 --- a/app/Domains/Commerce/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Commerce/Purchase/Controllers/PurchaseController.php @@ -2,7 +2,6 @@ namespace App\Domains\Commerce\Purchase\Controllers; -use App\Shared\Integration\Services\TelepagosIntegrationService; use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Requests\PaymentIntentRequest; use App\Domains\Commerce\Purchase\Requests\StartCheckoutRequest; @@ -13,6 +12,7 @@ use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard; use App\Domains\Core\Tenant\Models\Tenant; use App\Http\Controllers\Controller; +use App\Shared\Integration\Services\TelepagosIntegrationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -151,11 +151,14 @@ class PurchaseController extends Controller } $compra->refresh(); + $compra->loadMissing('event'); $totalAmount = (float) $compra->total; if ($method === 'transfer') { $telepagosService = new TelepagosIntegrationService; - $telepagosService->forTenant($tenant->codigo); + $compra->event + ? $telepagosService->forEvent($compra->event) + : $telepagosService->forTenant($tenant->codigo); try { $accountInfo = $telepagosService->getAccountInfo(); @@ -191,7 +194,9 @@ class PurchaseController extends Controller if ($method === 'qr') { $telepagosService = new TelepagosIntegrationService; - $telepagosService->forTenant($tenant->codigo); + $compra->event + ? $telepagosService->forEvent($compra->event) + : $telepagosService->forTenant($tenant->codigo); try { Log::channel('telepagos')->info('Generating Telepagos QR.', [ diff --git a/app/Shared/Integration/Services/BaseIntegrationService.php b/app/Shared/Integration/Services/BaseIntegrationService.php index a0244730..09f16b1b 100644 --- a/app/Shared/Integration/Services/BaseIntegrationService.php +++ b/app/Shared/Integration/Services/BaseIntegrationService.php @@ -3,11 +3,12 @@ namespace App\Shared\Integration\Services; use App\Domains\Core\Client\Models\Client; +use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; +use App\Shared\Integration\Models\AdminWebsiteTypeIntegration; use App\Shared\Integration\Models\ClientIntegration; use App\Shared\Integration\Models\Integration; use App\Shared\Integration\Models\IntegrationInstance; -use App\Shared\Integration\Models\AdminWebsiteTypeIntegration; -use App\Domains\Core\Tenant\Models\Tenant; use Exception; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; @@ -86,6 +87,17 @@ abstract class BaseIntegrationService return $this; } + public function forEvent(Event $event): self + { + $event->loadMissing(['client', 'tenant.client']); + $this->tenant = $event->tenant; + $this->tenantCode = $event->tenant_code; + $this->clientContext = $event->effectiveClient(); + $this->loadIntegration(); + + return $this; + } + /** * Load the integration definition and its effective instance configuration. * diff --git a/app/Shared/Integration/Services/MailService.php b/app/Shared/Integration/Services/MailService.php index 71682e0a..cadffb0b 100644 --- a/app/Shared/Integration/Services/MailService.php +++ b/app/Shared/Integration/Services/MailService.php @@ -3,8 +3,9 @@ namespace App\Shared\Integration\Services; use App\Domains\Core\Client\Models\Client; -use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\AdminWebsiteType; +use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; use Exception; use Illuminate\Contracts\Mail\Factory as MailFactory; use Illuminate\Contracts\Mail\Mailer; @@ -39,14 +40,7 @@ class MailService extends BaseIntegrationService public function forTenant(string $tenantCode): self { parent::forTenant($tenantCode); - - if ($this->integrationInstance) { - $this->mailer = $this->resolveMailer(); - $this->usesInstanceMailer = true; - } else { - $this->mailer = $this->mailFactory->mailer(); - $this->usesInstanceMailer = false; - } + $this->configureMailer(); return $this; } @@ -55,16 +49,30 @@ class MailService extends BaseIntegrationService { parent::forClient($client); $this->tenant = $this->clientContext?->tenants()->first(); + $this->configureMailer(); + return $this; + } + + public function forEvent(Event $event): self + { + parent::forEvent($event); + $this->configureMailer(); + + return $this; + } + + private function configureMailer(): void + { if ($this->integrationInstance) { $this->mailer = $this->resolveMailer(); $this->usesInstanceMailer = true; - } else { - $this->mailer = $this->mailFactory->mailer(); - $this->usesInstanceMailer = false; + + return; } - return $this; + $this->mailer = $this->mailFactory->mailer(); + $this->usesInstanceMailer = false; } public function getHeaders(): array @@ -83,7 +91,7 @@ class MailService extends BaseIntegrationService array $attachments = [], ): void { if (! $this->mailer || ! $this->tenant) { - throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.'); + throw new Exception('MailService no está configurado. Llamá a forTenant(), forClient() o forEvent() primero.'); } $brand ??= $this->tenant; @@ -160,7 +168,7 @@ class MailService extends BaseIntegrationService public function onSetup(): void { if (! $this->mailer || ! $this->clientContext) { - throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.'); + throw new Exception('MailService no está configurado. Llamá a forTenant(), forClient() o forEvent() primero.'); } $recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS'); diff --git a/app/Shared/Integration/Services/TelepagosIntegrationService.php b/app/Shared/Integration/Services/TelepagosIntegrationService.php index 83ac8101..424d02b5 100644 --- a/app/Shared/Integration/Services/TelepagosIntegrationService.php +++ b/app/Shared/Integration/Services/TelepagosIntegrationService.php @@ -46,7 +46,7 @@ class TelepagosIntegrationService extends BaseIntegrationService public function getToken(): string { if (! $this->integrationInstance) { - throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.'); + throw new Exception('Client integration is not loaded. Call forTenant(), forClient(), or forEvent() first.'); } $cacheKey = $this->integrationInstance->tokenCacheKey(); diff --git a/app/Shared/Integration/Services/TelepagosWebhookService.php b/app/Shared/Integration/Services/TelepagosWebhookService.php index 8c120cb7..20699e8a 100644 --- a/app/Shared/Integration/Services/TelepagosWebhookService.php +++ b/app/Shared/Integration/Services/TelepagosWebhookService.php @@ -2,12 +2,12 @@ namespace App\Shared\Integration\Services; -use App\Domains\Core\Client\Models\Client; use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\TelepagosPayment; use App\Domains\Commerce\Purchase\Models\TelepagosQr; use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\DniDistanceService; +use App\Domains\Core\Client\Models\Client; use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -75,9 +75,30 @@ class TelepagosWebhookService $dni = substr($cuit, 2, -1); - $tenantCodes = $client->tenants()->pluck('codigo'); $eligiblePurchases = Purchase::query() - ->whereIn('tenant_codigo', $tenantCodes) + ->where(function (Builder $purchases) use ($client): void { + $purchases + ->whereHas('event', function (Builder $events) use ($client): void { + $events + ->where('client_id', $client->id) + ->orWhere(function (Builder $fallback) use ($client): void { + $fallback + ->whereNull('client_id') + ->whereHas( + 'tenant', + fn (Builder $tenants): Builder => $tenants->where('client_id', $client->id), + ); + }); + }) + ->orWhere(function (Builder $legacy) use ($client): void { + $legacy + ->whereNull('event_id') + ->whereHas( + 'tenant', + fn (Builder $tenants): Builder => $tenants->where('client_id', $client->id), + ); + }); + }) ->whereIn('status', [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, @@ -170,7 +191,7 @@ class TelepagosWebhookService return; } - if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) { + if (! $this->purchaseBelongsToClient($compra, $client)) { Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, @@ -244,6 +265,16 @@ class TelepagosWebhookService return number_format((float) $amount, 2, '.', ''); } + private function purchaseBelongsToClient(Purchase $purchase, Client $client): bool + { + $purchase->loadMissing(['event.client', 'event.tenant.client', 'tenant.client']); + + $effectiveClient = $purchase->event?->effectiveClient() + ?? $purchase->tenant?->client; + + return $effectiveClient?->is($client) ?? false; + } + /** * @param Builder $eligiblePurchases * @return Collection diff --git a/app/Shared/Notification/Services/NotificationMailService.php b/app/Shared/Notification/Services/NotificationMailService.php index 707436d3..00866957 100644 --- a/app/Shared/Notification/Services/NotificationMailService.php +++ b/app/Shared/Notification/Services/NotificationMailService.php @@ -2,16 +2,16 @@ namespace App\Shared\Notification\Services; +use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Core\Auth\Models\ResetPasswordAttempt; use App\Domains\Core\Auth\Models\User; -use App\Shared\Integration\Services\MailService; -use App\Shared\Notification\Events\PasswordResetRequested; -use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Services\TicketPdfService; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; +use App\Shared\Integration\Services\MailService; +use App\Shared\Notification\Events\PasswordResetRequested; use Closure; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; @@ -158,7 +158,7 @@ class NotificationMailService $context = ['purchase_id' => $purchaseId]; $purchase = Purchase::query() - ->with(['tenant', 'user', 'items']) + ->with(['event', 'tenant', 'user', 'items']) ->find($purchaseId); if ($purchase === null) { @@ -197,8 +197,7 @@ class NotificationMailService 'mime' => 'application/pdf', ]]; - $this->mailService - ->forTenant($purchase->tenant_codigo) + $this->mailForPurchase($purchase) ->send( $recipient, "Compra confirmada - Compra #{$purchase->getKey()}", @@ -286,7 +285,6 @@ class NotificationMailService $context, $recipient, function () use ( - $tenantCode, $purchase, $recipient, $previousDate, @@ -295,8 +293,7 @@ class NotificationMailService ): array { $brand = $purchase->tenant; - $this->mailService - ->forTenant($tenantCode) + $this->mailForPurchase($purchase) ->send( $recipient, "Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}", @@ -387,7 +384,6 @@ class NotificationMailService $context, $recipient, function () use ( - $tenantCode, $purchase, $recipient, $date, @@ -396,8 +392,7 @@ class NotificationMailService ): array { $brand = $purchase->tenant; - $this->mailService - ->forTenant($tenantCode) + $this->mailForPurchase($purchase) ->send( $recipient, "Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}", @@ -421,10 +416,17 @@ class NotificationMailService { return Purchase::query() ->where('tenant_codigo', $tenantCode) - ->with(['tenant', 'user']) + ->with(['event', 'tenant', 'user']) ->find($purchaseId); } + private function mailForPurchase(Purchase $purchase): MailService + { + return $purchase->event + ? $this->mailService->forEvent($purchase->event) + : $this->mailService->forTenant($purchase->tenant_codigo); + } + private function recipientFor(Purchase $purchase): string { return (string) ($purchase->email ?: $purchase->user?->email); diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index 17518dce..4c823273 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -2,20 +2,22 @@ namespace Tests\Feature\Integration; -use App\Shared\Attachable\Enums\AttachmentType; -use App\Shared\Attachable\Models\Attachment; -use App\Domains\Core\Auth\Models\User; use App\Domains\Commerce\Cart\Models\Cart; use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\Variant; -use App\Shared\Integration\Models\Integration; use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\TelepagosPayment; use App\Domains\Commerce\Purchase\Services\CheckoutService; +use App\Domains\Core\Auth\Models\User; +use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; +use App\Shared\Attachable\Enums\AttachmentType; +use App\Shared\Attachable\Models\Attachment; +use App\Shared\Integration\Models\Integration; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -227,6 +229,77 @@ class TelepagosWebhookTest extends TestCase ]); } + public function test_transfer_webhook_matches_the_client_configured_on_the_event(): void + { + $tenant = $this->createTenant('multi-event', 'Multi Event', 'multi-event.com.ar'); + $eventClient = Client::query()->create([ + 'code' => 'event-client', + 'name' => 'Event Client', + ]); + Integration::query()->create([ + 'integration_code' => 'telepagos_homo', + 'name' => 'Telepagos', + 'url' => 'https://api.telepagos.com.ar', + 'integration_data_schema' => [ + 'username' => 'required|string', + 'password' => 'required|string', + ], + ]); + $this->createClientIntegration([ + 'client_id' => $eventClient->id, + 'integration_code' => 'telepagos_homo', + 'integration_data' => ['username' => 'event-user', 'password' => 'event-password'], + ]); + $event = Event::query()->create([ + 'client_id' => $eventClient->id, + 'tenant_code' => $tenant->codigo, + 'title' => 'Event with its own client', + ]); + $tenant->update(['active_event_id' => $event->id]); + + $user = User::factory()->create(); + $variant = $this->createVariantForTenant($tenant->codigo, 10, '50.00'); + $purchase = $this->createPendingTransferPurchase( + $tenant, + $user->id, + $variant->id, + 1, + '12345678', + ); + + Http::fake([ + 'https://api.telepagos.com.ar/v2/auth/token' => Http::response([ + 'status' => 'ok', + 'token' => 'event-client-token', + 'expires_at' => now()->addHour()->toIso8601String(), + ]), + 'https://api.telepagos.com.ar/v2/payment/cashin/event-client-payment' => Http::response([ + 'status' => 'ok', + 'data' => [ + 'amount' => 50, + 'operation_id' => 1, + 'transaction_id' => 'tx-event-client', + 'buyer' => ['cuit' => '20123456789'], + ], + ]), + ]); + + $this->postJson('/api/webhooks/telepagos/event-client', [ + 'id' => 'event-client-payment', + ])->assertOk()->assertJsonPath('status', 'success'); + + $this->assertSame($event->id, $purchase->event_id); + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'event_id' => $event->id, + 'status' => Purchase::STATUS_PAID, + ]); + $this->assertDatabaseHas('telepagos_payments', [ + 'compra_id' => $purchase->id, + 'transaction_id' => 'tx-event-client', + ]); + } + public function test_transfer_webhook_buys_unlimited_inventory_without_reducing_real_stock(): void { $tenant = $this->createTenant('unlimited', 'Unlimited', 'unlimited.com.ar');