From 93c99afb130536e9af65eff113611f825cc6722a Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 19 Aug 2026 15:23:22 -0300 Subject: [PATCH] feat(telepagos): enhance webhook handling and add TenantTransactionResetService --- .../Auth/Services/AdminCredentialVerifier.php | 22 +++ .../Services/TelepagosWebhookService.php | 33 ++-- .../Purchase/Models/TelepagosPayment.php | 2 + .../TenantTransactionResetService.php | 145 ++++++++++++++++++ ...d_nullable_on_telepagos_payments_table.php | 22 +++ ...rchase_ids_to_telepagos_payments_table.php | 22 +++ routes/console.php | 63 ++++++++ .../Integration/TelepagosWebhookTest.php | 79 ++++++++++ 8 files changed, 375 insertions(+), 13 deletions(-) create mode 100644 app/Domains/Auth/Services/AdminCredentialVerifier.php create mode 100644 app/Domains/Purchase/Services/TenantTransactionResetService.php create mode 100644 database/migrations/2026_08_19_040000_make_compra_id_nullable_on_telepagos_payments_table.php create mode 100644 database/migrations/2026_08_19_050000_add_matched_purchase_ids_to_telepagos_payments_table.php diff --git a/app/Domains/Auth/Services/AdminCredentialVerifier.php b/app/Domains/Auth/Services/AdminCredentialVerifier.php new file mode 100644 index 0000000..0d5a2f8 --- /dev/null +++ b/app/Domains/Auth/Services/AdminCredentialVerifier.php @@ -0,0 +1,22 @@ +where('email', mb_strtolower(trim($email))) + ->where('rol_codigo', RoleCode::Admin->value) + ->first(); + + return $admin !== null + && ! $admin->locked_until?->isFuture() + && Hash::check($password, $admin->getAuthPassword()); + } +} diff --git a/app/Domains/Integration/Services/TelepagosWebhookService.php b/app/Domains/Integration/Services/TelepagosWebhookService.php index e74e4bf..73327a3 100644 --- a/app/Domains/Integration/Services/TelepagosWebhookService.php +++ b/app/Domains/Integration/Services/TelepagosWebhookService.php @@ -39,6 +39,20 @@ class TelepagosWebhookService $amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0); $operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null; + $paymentData = [ + 'compra_id' => null, + 'matched_purchase_ids' => null, + 'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null, + 'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null, + 'amount' => $amount, + 'concept' => $details['data']['concept'] ?? $details['concept'] ?? null, + 'operation' => $details['data']['operation'] ?? $details['operation'] ?? null, + 'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null, + 'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null, + 'qr_order_id' => $qrOrderId, + 'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null, + ]; + $transferenciaOperationIds = [1, 3, 11]; $qrOperationIds = [31, 37, 47]; @@ -68,12 +82,16 @@ class TelepagosWebhookService ->where('payment_method', 'transfer') ->where('total', $amount) ->latest() - ->limit(2) ->get(); + $paymentData['matched_purchase_ids'] = $purchases->pluck('id')->all(); $compra = $purchases->count() === 1 ? $purchases->first() : null; if (! $compra) { + if ($purchases->count() > 1) { + TelepagosPayment::create($paymentData); + } + Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, @@ -163,18 +181,7 @@ class TelepagosWebhookService return; } - $paymentData = [ - 'compra_id' => $compra->id, - 'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null, - 'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null, - 'amount' => $amount, - 'concept' => $details['data']['concept'] ?? $details['concept'] ?? null, - 'operation' => $details['data']['operation'] ?? $details['operation'] ?? null, - 'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null, - 'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null, - 'qr_order_id' => $qrOrderId, - 'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null, - ]; + $paymentData['compra_id'] = $compra->id; DB::transaction(function () use ($compra, $paymentData) { TelepagosPayment::create($paymentData); diff --git a/app/Domains/Purchase/Models/TelepagosPayment.php b/app/Domains/Purchase/Models/TelepagosPayment.php index 20f4729..7bfb028 100644 --- a/app/Domains/Purchase/Models/TelepagosPayment.php +++ b/app/Domains/Purchase/Models/TelepagosPayment.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; #[Fillable([ 'compra_id', + 'matched_purchase_ids', 'cuit_buyer', 'cvu_buyer', 'amount', @@ -29,6 +30,7 @@ class TelepagosPayment extends Model { return [ 'compra_id' => 'integer', + 'matched_purchase_ids' => 'array', 'amount' => 'decimal:2', ]; } diff --git a/app/Domains/Purchase/Services/TenantTransactionResetService.php b/app/Domains/Purchase/Services/TenantTransactionResetService.php new file mode 100644 index 0000000..5b5880c --- /dev/null +++ b/app/Domains/Purchase/Services/TenantTransactionResetService.php @@ -0,0 +1,145 @@ + */ + public function preview(string $tenantCode): array + { + $this->ensureTenantExists($tenantCode); + $scope = $this->scope($tenantCode); + + return [ + 'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(), + 'purchases' => $scope['purchase_ids']->count(), + 'purchase_items' => DB::table('compra_items')->whereIn('compra_id', $scope['purchase_ids'])->count(), + 'telepagos_payments' => DB::table('telepagos_payments')->whereIn('compra_id', $scope['purchase_ids'])->count(), + 'telepagos_qr' => DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count(), + 'carts' => $scope['cart_ids']->count(), + 'cart_items' => $scope['cart_item_ids']->count(), + 'tickets' => DB::table('tickets')->where('tenant_code', $tenantCode)->count(), + 'stock_reservations' => $this->reservationQuery($scope)->count(), + 'purchase_changes' => DB::table('value_changes') + ->where('tenant_code', $tenantCode) + ->where('trackable_type', Purchase::class) + ->count(), + 'inventories' => $scope['inventory_ids']->count(), + ]; + } + + /** @return array */ + public function reset(string $tenantCode): array + { + $this->ensureTenantExists($tenantCode); + + return DB::transaction(function () use ($tenantCode): array { + $scope = $this->scope($tenantCode); + $purchaseItems = DB::table('compra_items')->whereIn('compra_id', $scope['purchase_ids'])->count(); + $cartItems = $scope['cart_item_ids']->count(); + $telepagosPayments = DB::table('telepagos_payments')->whereIn('compra_id', $scope['purchase_ids'])->count(); + $telepagosQr = DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count(); + $summary = [ + 'stock_reservations_deleted' => $this->reservationQuery($scope)->delete(), + 'tickets_deleted' => DB::table('tickets')->where('tenant_code', $tenantCode)->delete(), + 'purchase_changes_deleted' => DB::table('value_changes') + ->where('tenant_code', $tenantCode) + ->where('trackable_type', Purchase::class) + ->delete(), + 'purchases_deleted' => DB::table('compras')->whereIn('id', $scope['purchase_ids'])->delete(), + 'purchase_items_deleted' => $purchaseItems, + 'telepagos_payments_deleted' => $telepagosPayments, + 'telepagos_qr_deleted' => $telepagosQr, + 'carts_deleted' => DB::table('carritos')->whereIn('id', $scope['cart_ids'])->delete(), + 'cart_items_deleted' => $cartItems, + 'inventories_reset' => $scope['inventory_ids']->count(), + 'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(), + ]; + + DB::table('inventories') + ->whereIn('id', $scope['inventory_ids']) + ->update([ + 'real_stock' => DB::raw('real_stock + sold_units'), + 'reserved_stock' => 0, + 'sold_units' => 0, + ]); + + return $summary; + }); + } + + private function ensureTenantExists(string $tenantCode): void + { + if (! DB::table('tenants')->where('codigo', $tenantCode)->exists()) { + throw new InvalidArgumentException("El tenant {$tenantCode} no existe."); + } + } + + /** + * @return array{ + * purchase_ids: Collection, + * cart_ids: Collection, + * cart_item_ids: Collection, + * inventory_ids: Collection + * } + */ + private function scope(string $tenantCode): array + { + $catalogItemIds = DB::table('catalog_items') + ->where('tenant_code', $tenantCode) + ->pluck('id'); + $purchaseIds = DB::table('compras') + ->where('tenant_codigo', $tenantCode) + ->pluck('id'); + $cartIds = DB::table('carritos') + ->where('tenant_codigo', $tenantCode) + ->pluck('id'); + $cartItemIds = DB::table('carrito_items') + ->whereIn('cart_id', $cartIds) + ->pluck('id'); + $inventoryIds = DB::table('variantes') + ->whereIn('catalog_item_id', $catalogItemIds) + ->whereNotNull('inventory_id') + ->pluck('inventory_id') + ->merge( + DB::table('catalog_items') + ->whereIn('id', $catalogItemIds) + ->whereNotNull('inventory_id') + ->pluck('inventory_id'), + ) + ->map(fn ($id): int => (int) $id) + ->unique() + ->values(); + + return [ + 'purchase_ids' => $purchaseIds->map(fn ($id): int => (int) $id), + 'cart_ids' => $cartIds->map(fn ($id): int => (int) $id), + 'cart_item_ids' => $cartItemIds->map(fn ($id): int => (int) $id), + 'inventory_ids' => $inventoryIds, + ]; + } + + /** + * @param array{ + * purchase_ids: Collection, + * cart_ids: Collection, + * cart_item_ids: Collection, + * inventory_ids: Collection + * } $scope + */ + private function reservationQuery(array $scope): Builder + { + return DB::table('stock_reservations') + ->where(function (Builder $query) use ($scope): void { + $query->whereIn('inventory_id', $scope['inventory_ids']) + ->orWhereIn('purchase_id', $scope['purchase_ids']) + ->orWhereIn('cart_item_id', $scope['cart_item_ids']); + }); + } +} diff --git a/database/migrations/2026_08_19_040000_make_compra_id_nullable_on_telepagos_payments_table.php b/database/migrations/2026_08_19_040000_make_compra_id_nullable_on_telepagos_payments_table.php new file mode 100644 index 0000000..d2d5667 --- /dev/null +++ b/database/migrations/2026_08_19_040000_make_compra_id_nullable_on_telepagos_payments_table.php @@ -0,0 +1,22 @@ +foreignId('compra_id')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('telepagos_payments', function (Blueprint $table) { + $table->foreignId('compra_id')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_08_19_050000_add_matched_purchase_ids_to_telepagos_payments_table.php b/database/migrations/2026_08_19_050000_add_matched_purchase_ids_to_telepagos_payments_table.php new file mode 100644 index 0000000..9a05157 --- /dev/null +++ b/database/migrations/2026_08_19_050000_add_matched_purchase_ids_to_telepagos_payments_table.php @@ -0,0 +1,22 @@ +json('matched_purchase_ids')->nullable()->after('compra_id'); + }); + } + + public function down(): void + { + Schema::table('telepagos_payments', function (Blueprint $table) { + $table->dropColumn('matched_purchase_ids'); + }); + } +}; diff --git a/routes/console.php b/routes/console.php index ffb9927..fd48c29 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,8 @@ everyMinute() ->withoutOverlapping(); + +Artisan::command( + 'tenants:reset-transactions + {tenant : Código del tenant que se limpiará} + {--dry-run : Mostrar el alcance sin modificar datos} + {--force : Omitir la confirmación interactiva}', + function ( + TenantTransactionResetService $resetService, + AdminCredentialVerifier $adminCredentialVerifier, + ): int { + $tenantCode = (string) $this->argument('tenant'); + + try { + $preview = $resetService->preview($tenantCode); + } catch (InvalidArgumentException $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->warn("Se eliminarán los datos transaccionales de: {$tenantCode}"); + $this->table( + ['Dato', 'Cantidad'], + collect($preview)->map(fn (int $count, string $label): array => [$label, $count])->values(), + ); + $this->info('Los usuarios, el catálogo, los eventos y la configuración se conservarán.'); + + if ($this->option('dry-run')) { + $this->comment('Vista previa finalizada; no se modificaron datos.'); + + return self::SUCCESS; + } + + if (! $this->option('force') && ! $this->confirm('¿Confirmás esta limpieza irreversible?')) { + $this->comment('Operación cancelada.'); + + return self::SUCCESS; + } + + $this->newLine(); + $this->warn('Autorización administrativa requerida.'); + $adminEmail = (string) $this->ask('Email del administrador'); + $adminPassword = (string) $this->secret('Contraseña del administrador'); + + if (! $adminCredentialVerifier->verify($adminEmail, $adminPassword)) { + $this->error('Las credenciales no son válidas o el usuario no posee el rol admin.'); + + return self::FAILURE; + } + + $summary = $resetService->reset($tenantCode); + + $this->info('Limpieza completada correctamente.'); + $this->table( + ['Resultado', 'Cantidad'], + collect($summary)->map(fn (int $count, string $label): array => [$label, $count])->values(), + ); + + return self::SUCCESS; + }, +)->purpose('Delete tenant sales, carts, tickets and reservations while preserving users and catalog'); diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index c17fdf5..a28a448 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -14,6 +14,7 @@ use App\Domains\Catalog\Models\Variant; use App\Domains\Integration\Models\ClientIntegration; use App\Domains\Integration\Models\Integration; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\TelepagosPayment; use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -261,6 +262,84 @@ class TelepagosWebhookTest extends TestCase ]); } + public function test_transfer_webhook_records_unmatched_payment_when_multiple_purchases_match(): void + { + $tenant = $this->createTenant('ambiguous', 'Ambiguous', 'ambiguous.com.ar'); + $this->configureTelepagosIntegration($tenant); + + $variant = $this->createVariantForTenant('ambiguous', 10, '50.00'); + $firstPurchase = $this->createPendingTransferPurchase( + $tenant, + User::factory()->create()->id, + $variant->id, + 1, + '12345678', + ); + $secondPurchase = $this->createPendingTransferPurchase( + $tenant, + User::factory()->create()->id, + $variant->id, + 1, + '12345678', + ); + $thirdPurchase = $this->createPendingTransferPurchase( + $tenant, + User::factory()->create()->id, + $variant->id, + 1, + '12345678', + ); + + Http::fake([ + 'https://api.telepagos.com.ar/v2/auth/token' => Http::response([ + 'status' => 'ok', + 'token' => 'test-token', + 'expires_at' => now()->addHour()->toIso8601String(), + ]), + 'https://api.telepagos.com.ar/v2/payment/cashin/ambiguous' => Http::response([ + 'status' => 'ok', + 'data' => [ + 'amount' => 50, + 'operation_id' => 1, + 'transaction_id' => 'tx-ambiguous', + 'buyer' => [ + 'cuit' => '20123456789', + ], + ], + ]), + ]); + + $this->postJson('/api/webhooks/telepagos/ambiguous', [ + 'id' => 'ambiguous', + ])->assertOk()->assertJsonPath('status', 'success'); + + $this->assertDatabaseHas('telepagos_payments', [ + 'compra_id' => null, + 'amount' => 50, + 'operation_id' => 1, + 'transaction_id' => 'tx-ambiguous', + ]); + $payment = TelepagosPayment::query() + ->where('transaction_id', 'tx-ambiguous') + ->firstOrFail(); + $this->assertEqualsCanonicalizing( + [$firstPurchase->id, $secondPurchase->id, $thirdPurchase->id], + $payment->matched_purchase_ids, + ); + $this->assertDatabaseHas('compras', [ + 'id' => $firstPurchase->id, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + ]); + $this->assertDatabaseHas('compras', [ + 'id' => $secondPurchase->id, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + ]); + $this->assertDatabaseHas('compras', [ + 'id' => $thirdPurchase->id, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + ]); + } + public function test_webhook_confirms_a_purchase_with_tickets_enabled(): void { $tenant = $this->createTenant('expired-ticket', 'Expired Ticket', 'expired-ticket.com.ar');