From 7b2a7418844dfc773bf2b210d40ab03c980ea1e2 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 7 Jul 2026 10:16:28 -0300 Subject: [PATCH] feat: add total field to purchases and update related logic for payment processing --- .../Services/TelepagosIntegrationService.php | 2 +- .../Services/TelepagosWebhookService.php | 66 +++-- .../Controllers/PurchaseController.php | 6 +- app/Domains/Purchase/Models/Purchase.php | 21 +- .../Purchase/Resources/PurchaseResource.php | 20 +- .../Purchase/Services/CheckoutService.php | 5 + ...7_07_100000_add_total_to_compras_table.php | 59 +++++ .../Integration/TelepagosWebhookTest.php | 246 ++++++++++++++++++ tests/Feature/Purchase/StorePurchaseTest.php | 8 +- 9 files changed, 377 insertions(+), 56 deletions(-) create mode 100644 database/migrations/2026_07_07_100000_add_total_to_compras_table.php create mode 100644 tests/Feature/Integration/TelepagosWebhookTest.php diff --git a/app/Domains/Integration/Services/TelepagosIntegrationService.php b/app/Domains/Integration/Services/TelepagosIntegrationService.php index 27014ee..cb7dc2b 100644 --- a/app/Domains/Integration/Services/TelepagosIntegrationService.php +++ b/app/Domains/Integration/Services/TelepagosIntegrationService.php @@ -158,7 +158,7 @@ class TelepagosIntegrationService extends BaseIntegrationService * @return array * @throws Exception */ - public function getCashinDetails(int $cashinId): array + public function getCashinDetails(string $cashinId): array { $response = $this->sendRequest('get', "/v2/payment/cashin/{$cashinId}"); diff --git a/app/Domains/Integration/Services/TelepagosWebhookService.php b/app/Domains/Integration/Services/TelepagosWebhookService.php index dc0b67d..53058cb 100644 --- a/app/Domains/Integration/Services/TelepagosWebhookService.php +++ b/app/Domains/Integration/Services/TelepagosWebhookService.php @@ -2,10 +2,10 @@ namespace App\Domains\Integration\Services; -use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\TelepagosPayment; use App\Domains\Purchase\Models\TelepagosQr; +use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; use Exception; use Illuminate\Support\Facades\Log; @@ -27,18 +27,15 @@ class TelepagosWebhookService public function handleWebhook(string $tenantCodigo, string $cashinId): void { $tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail(); - + $telepagosService = new TelepagosIntegrationService(); - $telepagosService->forTenant($tenant); + $telepagosService->forTenant($tenant->codigo); try { $details = $telepagosService->getCashinDetails($cashinId); - - // Extract the qr_order_id from the response - // The exact path depends on Telepagos response structure, assuming it's inside 'data' - // Need to handle potential nested structures based on actual API + $qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null; - $amount = $details['data']['amount'] ?? $details['amount'] ?? 0; + $amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0); $operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null; $transferenciaOperationIds = [1, 3, 11]; @@ -46,59 +43,51 @@ class TelepagosWebhookService $compra = null; - if (in_array((int)$operationId, $transferenciaOperationIds, true)) { + if (in_array((int) $operationId, $transferenciaOperationIds, true)) { $cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null; - - if (!$cuit) { + + if (! $cuit) { Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}"); return; } - - // Extraer el DNI del cuit (sacando los primeros 2 y el último dígito) + $dni = substr($cuit, 2, -1); - - // Buscar la compra pendiente, con método transferencia, más reciente - $compras = Purchase::where('tenant_codigo', $tenantCodigo) + + $compra = Purchase::where('tenant_codigo', $tenantCodigo) ->where('dni', $dni) - ->where('status', 'pendiente') - ->where('payment_method', 'transferencia') + ->where('status', 'pending') + ->where('payment_method', 'transfer') + ->where('total', $amount) ->latest() - ->get(); - - foreach ($compras as $c) { - if ($c->getTotalAmount() == $amount) { - $compra = $c; - break; - } - } - - if (!$compra) { + ->first(); + + if (! $compra) { Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}"); return; } - } elseif (in_array((int)$operationId, $qrOperationIds, true)) { - if (!$qrOrderId) { + } elseif (in_array((int) $operationId, $qrOperationIds, true)) { + if (! $qrOrderId) { Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}"); return; } $telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first(); - if (!$telepagosQr) { + if (! $telepagosQr) { Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}"); return; } $compra = $telepagosQr->compra; - if (!$compra) { + if (! $compra) { Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}"); return; } - - $totalAmount = $compra->getTotalAmount(); - if ($amount != $totalAmount) { + $totalAmount = $this->normalizeAmount($compra->getTotalAmount()); + + if ($amount !== $totalAmount) { Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}"); return; } @@ -107,7 +96,6 @@ class TelepagosWebhookService return; } - // Create the TelepagosPayment and finalize the purchase in a transaction $paymentData = [ 'compra_id' => $compra->id, 'cuit_buyer' => $details['data']['cuit_buyer'] ?? $details['cuit_buyer'] ?? null, @@ -128,10 +116,14 @@ class TelepagosWebhookService }); Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}"); - } catch (Exception $e) { Log::error("Telepagos webhook error: " . $e->getMessage()); throw $e; } } + + protected function normalizeAmount(mixed $amount): string + { + return number_format((float) $amount, 2, '.', ''); + } } diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index b30669e..a471256 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -55,9 +55,11 @@ class PurchaseController extends Controller { $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); $method = $request->validated('method'); + $totalAmount = $compra->calculateCurrentTotalAmount(); $compra->update([ 'payment_method' => $method, + 'total' => $totalAmount, ]); if ($method === 'transfer') { @@ -89,9 +91,9 @@ class PurchaseController extends Controller $telepagosService->forTenant($tenant->codigo); try { - Log::info("Generating QR for purchase ID: {$compra->id}, amount: {$compra->getTotalAmount()}"); + Log::info("Generating QR for purchase ID: {$compra->id}, amount: {$totalAmount}"); $qrResponse = $telepagosService->generateQr( - $compra->getTotalAmount(), + $totalAmount, 'Compra', "Compra #{$compra->id}" ); diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index ce3c87b..2ff9026 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'status', 'payment_status', 'payment_method', + 'total', 'dni', 'telefono', 'nombre_apellido', @@ -34,6 +35,7 @@ class Purchase extends Model return [ 'cart_id' => 'integer', 'user_id' => 'integer', + 'total' => 'decimal:2', ]; } @@ -87,13 +89,26 @@ class Purchase extends Model public function getTotalAmount(): float { + if ($this->total !== null) { + return (float) $this->total; + } + + return $this->calculateCurrentTotalAmount(); + } + + public function calculateCurrentTotalAmount(): float + { + if ($this->relationLoaded('items') && $this->getRelation('items')->isNotEmpty()) { + return (float) $this->getRelation('items')->sum('total'); + } + if ($this->items()->exists()) { return (float) $this->items()->sum('total'); } $cart = $this->relationLoaded('cart') ? $this->getRelation('cart') - : $this->cart()->first(); + : $this->cart()->with('items.variant.product')->first(); if (! $cart) { return 0.0; @@ -105,8 +120,8 @@ class Purchase extends Model public function finalize(): void { $this->update([ - 'status' => 'pagado', // Or the equivalent final status in your system - 'payment_status' => 'paid', + 'status' => 'paid', + 'payment_status' => 'approved', ]); } } diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index 2bcaff8..43317de 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -19,15 +19,19 @@ class PurchaseResource extends JsonResource ? $this->resource->getRelation('items') : collect(); - $subtotal = $items->reduce( - fn (float $carry, $item): float => $carry + ((float) $item->precio_unitario * $item->cantidad), - 0.0, - ); + $subtotal = $items->isNotEmpty() + ? $items->reduce( + fn (float $carry, $item): float => $carry + ((float) $item->precio_unitario * $item->cantidad), + 0.0, + ) + : (float) ($this->total ?? 0); - $total = $items->reduce( - fn (float $carry, $item): float => $carry + (float) $item->total, - 0.0, - ); + $total = $items->isNotEmpty() + ? $items->reduce( + fn (float $carry, $item): float => $carry + (float) $item->total, + 0.0, + ) + : (float) ($this->total ?? 0); return [ 'id' => $this->id, diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index de92752..0af9dfd 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -29,6 +29,10 @@ class CheckoutService ]); } + $cartItems->load('variant.product'); + $cart->setRelation('items', $cartItems); + $totalAmount = $cart->getTotalAmount(); + /** @var Purchase $purchase */ $purchase = Purchase::query()->updateOrCreate( [ @@ -41,6 +45,7 @@ class CheckoutService ...$purchaseData, 'payment_status' => 'pending', 'payment_method' => null, + 'total' => $totalAmount, ] ); diff --git a/database/migrations/2026_07_07_100000_add_total_to_compras_table.php b/database/migrations/2026_07_07_100000_add_total_to_compras_table.php new file mode 100644 index 0000000..4821a04 --- /dev/null +++ b/database/migrations/2026_07_07_100000_add_total_to_compras_table.php @@ -0,0 +1,59 @@ +decimal('total', 10, 2)->default(0)->after('payment_method'); + }); + + DB::table('compras') + ->orderBy('id') + ->chunkById(100, function ($compras): void { + foreach ($compras as $compra) { + $itemsQuery = DB::table('compra_items') + ->where('compra_id', $compra->id); + $hasItems = $itemsQuery->exists(); + $itemsTotal = $itemsQuery->sum('total'); + + $total = (float) $itemsTotal; + + if (! $hasItems && $compra->cart_id !== null) { + $cartTotal = DB::table('carrito_items as carrito_item') + ->join('productos_variantes as variante', 'variante.id', '=', 'carrito_item.producto_variante_id') + ->join('productos as producto', 'producto.id', '=', 'variante.producto_id') + ->where('carrito_item.cart_id', $compra->cart_id) + ->selectRaw('COALESCE(SUM(producto.precio * carrito_item.cantidad), 0) as total') + ->value('total'); + + $total = (float) ($cartTotal ?? 0); + } + + DB::table('compras') + ->where('id', $compra->id) + ->update([ + 'total' => number_format($total, 2, '.', ''), + ]); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('compras', function (Blueprint $table) { + $table->dropColumn('total'); + }); + } +}; diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php new file mode 100644 index 0000000..68b07b3 --- /dev/null +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -0,0 +1,246 @@ + 'base64:' . base64_encode(random_bytes(32))]); + Cache::flush(); + } + + public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void + { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $this->configureTelepagosIntegration($tenant); + + $matchingUser = User::factory()->create([ + 'email' => 'buyer@example.com', + ]); + $newerUser = User::factory()->create([ + 'email' => 'buyer-2@example.com', + ]); + + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + + $matchingPurchase = $this->createPendingTransferPurchase( + $tenant, + $matchingUser->id, + $variant->id, + 1, + '12345678' + ); + + $newerPurchase = $this->createPendingTransferPurchase( + $tenant, + $newerUser->id, + $variant->id, + 2, + '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/6351' => Http::response([ + 'status' => 'ok', + 'data' => [ + 'amount' => 50, + 'operation_id' => 1, + 'transaction_id' => 'tx-123', + 'buyer' => [ + 'cuit' => '20123456789', + ], + ], + ]), + ]); + + $this->postJson('/api/webhooks/telepagos/sonder', [ + 'id' => '6351', + ])->assertOk()->assertJsonPath('status', 'success'); + + $this->assertDatabaseHas('compras', [ + 'id' => $matchingPurchase->id, + 'status' => 'paid', + 'payment_status' => 'approved', + 'payment_method' => 'transfer', + 'total' => 50, + ]); + + $this->assertDatabaseHas('compras', [ + 'id' => $newerPurchase->id, + 'status' => 'pending', + 'payment_status' => 'pending', + 'payment_method' => 'transfer', + 'total' => 100, + ]); + + $this->assertDatabaseHas('telepagos_payments', [ + 'compra_id' => $matchingPurchase->id, + 'amount' => 50, + 'operation_id' => 1, + 'transaction_id' => 'tx-123', + ]); + + $this->assertDatabaseMissing('telepagos_payments', [ + 'compra_id' => $newerPurchase->id, + ]); + + $this->assertDatabaseHas('compra_items', [ + 'compra_id' => $matchingPurchase->id, + 'producto_variante_id' => $variant->id, + 'cantidad' => 1, + 'total' => 50, + ]); + + $this->assertDatabaseMissing('compra_items', [ + 'compra_id' => $newerPurchase->id, + ]); + + $this->assertSoftDeleted('carritos', [ + 'id' => $matchingPurchase->cart_id, + ]); + } + + private function createPendingTransferPurchase( + Tenant $tenant, + int $userId, + int $variantId, + int $quantity, + string $dni, + ): Purchase { + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $userId, + 'status' => 'active', + ]); + + $cart->addItem($variantId, $quantity); + + /** @var CheckoutService $checkoutService */ + $checkoutService = app(CheckoutService::class); + + $purchase = $checkoutService->startCheckout($tenant, $userId, [ + 'cart_id' => $cart->id, + 'dni' => $dni, + 'telefono' => '+54 9 341 555-4321', + 'nombre_apellido' => 'Juan Perez', + 'email' => 'juan.perez@example.com', + ]); + + $purchase->update([ + 'payment_method' => 'transfer', + ]); + + return $purchase->fresh(); + } + + private function configureTelepagosIntegration(Tenant $tenant): void + { + Integration::create([ + 'integration_code' => 'telepagos_homo', + 'name' => 'Telepagos', + 'url' => 'https://api.telepagos.com.ar', + 'integration_data_schema' => [ + 'username' => 'required|string', + 'password' => 'required|string', + ], + ]); + + TenantIntegration::create([ + 'tenant_code' => $tenant->codigo, + 'integration_code' => 'telepagos_homo', + 'integration_data' => [ + 'username' => 'user123', + 'password' => 'pass123', + ], + ]); + } + + private function createVariantForTenant( + string $tenantCode, + int $stock, + string $price, + string $slugPrefix = 'shirt', + ): ProductVariant { + $category = \App\Domains\Catalog\Models\Category::query()->create([ + 'tenant_code' => $tenantCode, + 'nombre' => "{$slugPrefix} category {$tenantCode}", + ]); + + $product = Product::query()->create([ + 'tenant_codigo' => $tenantCode, + 'categoria_id' => $category->id, + 'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(), + 'nombre' => ucfirst($slugPrefix)." {$tenantCode}", + 'descripcion' => 'Test product', + 'precio' => $price, + ]); + + return ProductVariant::query()->create([ + 'producto_id' => $product->id, + 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), + 'nombre' => ucfirst($slugPrefix).' Variant', + 'stock' => $stock, + 'descripcion' => 'Test variant', + 'precio' => $price, + ])->load('product'); + } + + private function createTenant(string $codigo, string $nombre, string $dominio): Tenant + { + $hdrKey = (string) \Illuminate\Support\Str::uuid(); + $ftrKey = (string) \Illuminate\Support\Str::uuid(); + + $headerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + 'key' => $hdrKey, + 'path' => 'tenants/' . $hdrKey . '.png', + 'filename' => 'logo_header.png', + 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $footerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + 'key' => $ftrKey, + 'path' => 'tenants/' . $ftrKey . '.png', + 'filename' => 'logo_footer.png', + 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + + return Tenant::create([ + 'codigo' => $codigo, + 'nombre' => $nombre, + 'dominio' => $dominio, + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'success_color' => '#28a745', + 'header_bg_color' => '#444444', + 'footer_bg_color' => '#444444', + 'header_logo_id' => $headerAttachment->id, + 'footer_logo_id' => $footerAttachment->id, + ]); + } +} diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 05b4f56..9bddbf6 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -75,7 +75,8 @@ class StorePurchaseTest extends TestCase $response->assertJsonPath('data.email', 'juan.perez@example.com'); $response->assertJsonPath('data.tenant_codigo', 'sonder'); $response->assertJsonPath('data.items', []); - $response->assertJsonPath('data.total', '0.00'); + $response->assertJsonPath('data.subtotal', '100.00'); + $response->assertJsonPath('data.total', '100.00'); $purchaseId = $response->json('data.id'); @@ -88,6 +89,7 @@ class StorePurchaseTest extends TestCase 'telefono' => '+54 9 341 555-4321', 'nombre_apellido' => 'Juan Perez', 'email' => 'juan.perez@example.com', + 'total' => 100, ]); $this->assertDatabaseMissing('compra_items', [ 'compra_id' => $purchaseId, @@ -97,10 +99,6 @@ class StorePurchaseTest extends TestCase 'status' => 'active', 'user_id' => $user->id, ]); - $this->assertDatabaseMissing('carritos', [ - 'id' => $cartId, - 'deleted_at' => null, - ]); $this->assertDatabaseHas('carrito_items', [ 'cart_id' => $cartId, 'producto_variante_id' => $variant->id,