From 3f9b7733671cbde5f4b91c8d0781536091d7c888 Mon Sep 17 00:00:00 2001 From: nahu Date: Mon, 28 Sep 2026 19:14:23 +0000 Subject: [PATCH] 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');