feat(integrations): resolve payment context from events

This commit is contained in:
2026-09-28 19:14:23 +00:00
parent 7996d53e12
commit 3f9b773367
7 changed files with 173 additions and 42 deletions

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Commerce\Purchase\Controllers; namespace App\Domains\Commerce\Purchase\Controllers;
use App\Shared\Integration\Services\TelepagosIntegrationService;
use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Requests\PaymentIntentRequest; use App\Domains\Commerce\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Commerce\Purchase\Requests\StartCheckoutRequest; 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\Commerce\Purchase\Services\PurchaseStateGuard;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Shared\Integration\Services\TelepagosIntegrationService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -151,11 +151,14 @@ class PurchaseController extends Controller
} }
$compra->refresh(); $compra->refresh();
$compra->loadMissing('event');
$totalAmount = (float) $compra->total; $totalAmount = (float) $compra->total;
if ($method === 'transfer') { if ($method === 'transfer') {
$telepagosService = new TelepagosIntegrationService; $telepagosService = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo); $compra->event
? $telepagosService->forEvent($compra->event)
: $telepagosService->forTenant($tenant->codigo);
try { try {
$accountInfo = $telepagosService->getAccountInfo(); $accountInfo = $telepagosService->getAccountInfo();
@@ -191,7 +194,9 @@ class PurchaseController extends Controller
if ($method === 'qr') { if ($method === 'qr') {
$telepagosService = new TelepagosIntegrationService; $telepagosService = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo); $compra->event
? $telepagosService->forEvent($compra->event)
: $telepagosService->forTenant($tenant->codigo);
try { try {
Log::channel('telepagos')->info('Generating Telepagos QR.', [ Log::channel('telepagos')->info('Generating Telepagos QR.', [

View File

@@ -3,11 +3,12 @@
namespace App\Shared\Integration\Services; namespace App\Shared\Integration\Services;
use App\Domains\Core\Client\Models\Client; 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\ClientIntegration;
use App\Shared\Integration\Models\Integration; use App\Shared\Integration\Models\Integration;
use App\Shared\Integration\Models\IntegrationInstance; use App\Shared\Integration\Models\IntegrationInstance;
use App\Shared\Integration\Models\AdminWebsiteTypeIntegration;
use App\Domains\Core\Tenant\Models\Tenant;
use Exception; use Exception;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@@ -86,6 +87,17 @@ abstract class BaseIntegrationService
return $this; 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. * Load the integration definition and its effective instance configuration.
* *

View File

@@ -3,8 +3,9 @@
namespace App\Shared\Integration\Services; namespace App\Shared\Integration\Services;
use App\Domains\Core\Client\Models\Client; 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\AdminWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use Exception; use Exception;
use Illuminate\Contracts\Mail\Factory as MailFactory; use Illuminate\Contracts\Mail\Factory as MailFactory;
use Illuminate\Contracts\Mail\Mailer; use Illuminate\Contracts\Mail\Mailer;
@@ -39,14 +40,7 @@ class MailService extends BaseIntegrationService
public function forTenant(string $tenantCode): self public function forTenant(string $tenantCode): self
{ {
parent::forTenant($tenantCode); parent::forTenant($tenantCode);
$this->configureMailer();
if ($this->integrationInstance) {
$this->mailer = $this->resolveMailer();
$this->usesInstanceMailer = true;
} else {
$this->mailer = $this->mailFactory->mailer();
$this->usesInstanceMailer = false;
}
return $this; return $this;
} }
@@ -55,16 +49,30 @@ class MailService extends BaseIntegrationService
{ {
parent::forClient($client); parent::forClient($client);
$this->tenant = $this->clientContext?->tenants()->first(); $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) { if ($this->integrationInstance) {
$this->mailer = $this->resolveMailer(); $this->mailer = $this->resolveMailer();
$this->usesInstanceMailer = true; $this->usesInstanceMailer = true;
} else {
$this->mailer = $this->mailFactory->mailer(); return;
$this->usesInstanceMailer = false;
} }
return $this; $this->mailer = $this->mailFactory->mailer();
$this->usesInstanceMailer = false;
} }
public function getHeaders(): array public function getHeaders(): array
@@ -83,7 +91,7 @@ class MailService extends BaseIntegrationService
array $attachments = [], array $attachments = [],
): void { ): void {
if (! $this->mailer || ! $this->tenant) { 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; $brand ??= $this->tenant;
@@ -160,7 +168,7 @@ class MailService extends BaseIntegrationService
public function onSetup(): void public function onSetup(): void
{ {
if (! $this->mailer || ! $this->clientContext) { 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'); $recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS');

View File

@@ -46,7 +46,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
public function getToken(): string public function getToken(): string
{ {
if (! $this->integrationInstance) { 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(); $cacheKey = $this->integrationInstance->tokenCacheKey();

View File

@@ -2,12 +2,12 @@
namespace App\Shared\Integration\Services; 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\Purchase;
use App\Domains\Commerce\Purchase\Models\TelepagosPayment; use App\Domains\Commerce\Purchase\Models\TelepagosPayment;
use App\Domains\Commerce\Purchase\Models\TelepagosQr; use App\Domains\Commerce\Purchase\Models\TelepagosQr;
use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\CheckoutService;
use App\Domains\Commerce\Purchase\Services\DniDistanceService; use App\Domains\Commerce\Purchase\Services\DniDistanceService;
use App\Domains\Core\Client\Models\Client;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@@ -75,9 +75,30 @@ class TelepagosWebhookService
$dni = substr($cuit, 2, -1); $dni = substr($cuit, 2, -1);
$tenantCodes = $client->tenants()->pluck('codigo');
$eligiblePurchases = Purchase::query() $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', [ ->whereIn('status', [
Purchase::STATUS_CREATED, Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_PENDING_PAYMENT,
@@ -170,7 +191,7 @@ class TelepagosWebhookService
return; 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.', [ Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [
'client_code' => $client->code, 'client_code' => $client->code,
'cashin_id' => $cashinId, 'cashin_id' => $cashinId,
@@ -244,6 +265,16 @@ class TelepagosWebhookService
return number_format((float) $amount, 2, '.', ''); 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<Purchase> $eligiblePurchases * @param Builder<Purchase> $eligiblePurchases
* @return Collection<int, Purchase> * @return Collection<int, Purchase>

View File

@@ -2,16 +2,16 @@
namespace App\Shared\Notification\Services; 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\ResetPasswordAttempt;
use App\Domains\Core\Auth\Models\User; 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\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Services\TicketPdfService; use App\Domains\Ticketing\Ticket\Services\TicketPdfService;
use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
use App\Shared\Integration\Services\MailService;
use App\Shared\Notification\Events\PasswordResetRequested;
use Closure; use Closure;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -158,7 +158,7 @@ class NotificationMailService
$context = ['purchase_id' => $purchaseId]; $context = ['purchase_id' => $purchaseId];
$purchase = Purchase::query() $purchase = Purchase::query()
->with(['tenant', 'user', 'items']) ->with(['event', 'tenant', 'user', 'items'])
->find($purchaseId); ->find($purchaseId);
if ($purchase === null) { if ($purchase === null) {
@@ -197,8 +197,7 @@ class NotificationMailService
'mime' => 'application/pdf', 'mime' => 'application/pdf',
]]; ]];
$this->mailService $this->mailForPurchase($purchase)
->forTenant($purchase->tenant_codigo)
->send( ->send(
$recipient, $recipient,
"Compra confirmada - Compra #{$purchase->getKey()}", "Compra confirmada - Compra #{$purchase->getKey()}",
@@ -286,7 +285,6 @@ class NotificationMailService
$context, $context,
$recipient, $recipient,
function () use ( function () use (
$tenantCode,
$purchase, $purchase,
$recipient, $recipient,
$previousDate, $previousDate,
@@ -295,8 +293,7 @@ class NotificationMailService
): array { ): array {
$brand = $purchase->tenant; $brand = $purchase->tenant;
$this->mailService $this->mailForPurchase($purchase)
->forTenant($tenantCode)
->send( ->send(
$recipient, $recipient,
"Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}", "Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}",
@@ -387,7 +384,6 @@ class NotificationMailService
$context, $context,
$recipient, $recipient,
function () use ( function () use (
$tenantCode,
$purchase, $purchase,
$recipient, $recipient,
$date, $date,
@@ -396,8 +392,7 @@ class NotificationMailService
): array { ): array {
$brand = $purchase->tenant; $brand = $purchase->tenant;
$this->mailService $this->mailForPurchase($purchase)
->forTenant($tenantCode)
->send( ->send(
$recipient, $recipient,
"Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}", "Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}",
@@ -421,10 +416,17 @@ class NotificationMailService
{ {
return Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenantCode) ->where('tenant_codigo', $tenantCode)
->with(['tenant', 'user']) ->with(['event', 'tenant', 'user'])
->find($purchaseId); ->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 private function recipientFor(Purchase $purchase): string
{ {
return (string) ($purchase->email ?: $purchase->user?->email); return (string) ($purchase->email ?: $purchase->user?->email);

View File

@@ -2,20 +2,22 @@
namespace Tests\Feature\Integration; 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\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\Variant; 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\Purchase;
use App\Domains\Commerce\Purchase\Models\TelepagosPayment; use App\Domains\Commerce\Purchase\Models\TelepagosPayment;
use App\Domains\Commerce\Purchase\Services\CheckoutService; 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\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\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; 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 public function test_transfer_webhook_buys_unlimited_inventory_without_reducing_real_stock(): void
{ {
$tenant = $this->createTenant('unlimited', 'Unlimited', 'unlimited.com.ar'); $tenant = $this->createTenant('unlimited', 'Unlimited', 'unlimited.com.ar');