'base64:'.base64_encode(random_bytes(32))]); Cache::flush(); Queue::fake(); } public function test_transfer_payment_intent_requires_a_valid_transfer_payer_dni(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createPendingTransferPurchase($tenant, $user->id, $variant->id, 1, '12345678'); $this->actingAs($user, 'sanctum') ->postJson("/api/tenants/sonder/compras/{$purchase->id}/payment-intent", [ 'method' => 'transfer', ]) ->assertUnprocessable() ->assertJsonValidationErrors(['transfer_payer_dni']); $this->actingAs($user, 'sanctum') ->postJson("/api/tenants/sonder/compras/{$purchase->id}/payment-intent", [ 'method' => 'transfer', 'transfer_payer_dni' => '12.345.678', ]) ->assertUnprocessable() ->assertJsonValidationErrors(['transfer_payer_dni']); } public function test_transfer_payment_intent_persists_data_without_extending_checkout_expiration(): void { config()->set('purchase.checkout_expiration_minutes', 30); $now = now()->startOfSecond(); $this->travelTo($now); $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $this->configureTelepagosIntegration($tenant); $user = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createPendingTransferPurchase($tenant, $user->id, $variant->id, 1, '12345678'); $purchase->update(['status' => Purchase::STATUS_CREATED]); 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/account/info' => Http::response([ 'status' => 'ok', 'holder' => 'Telepagos Test', 'cvu' => '0000003100000000000001', 'alias' => 'telepagos.test', 'entity' => 'Telepagos S.A.', ]), ]); $this->actingAs($user, 'sanctum') ->postJson("/api/tenants/sonder/compras/{$purchase->id}/payment-intent", [ 'method' => 'transfer', 'transfer_payer_dni' => '23456789', ]) ->assertOk() ->assertJsonPath('transfer_data.alias', 'telepagos.test'); $this->assertDatabaseHas('compras', [ 'id' => $purchase->id, 'dni' => '87654321', 'transfer_payer_dni' => '23456789', 'payment_method' => 'transfer', 'status' => Purchase::STATUS_PENDING_PAYMENT, ]); $this->assertDatabaseHas('stock_reservations', [ 'id' => $purchase->stock_reservation_id, 'status' => 'active', 'expires_at' => $now->copy()->addMinutes(30)->toDateTimeString(), ]); $this->travelBack(); } 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' => Purchase::STATUS_PAID, 'payment_method' => 'transfer', 'total' => 50, ]); $this->assertDatabaseHas('compras', [ 'id' => $newerPurchase->id, 'status' => Purchase::STATUS_PENDING_PAYMENT, '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, 'source_catalog_item_id' => $variant->catalog_item_id, 'source_variant_id' => $variant->id, 'cantidad' => 1, 'total' => 50, ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, 'real_stock' => 9, 'reserved_stock' => 2, 'sold_units' => 1, ]); $this->assertDatabaseHas('compra_items', [ 'compra_id' => $newerPurchase->id, 'cantidad' => 2, ]); $newerReservationId = $newerPurchase->fresh()->stock_reservation_id; $this->assertNotNull($newerReservationId); $this->assertDatabaseHas('stock_reservations', [ 'id' => $newerReservationId, 'status' => 'active', ]); $this->assertDatabaseHas('stock_reservation_lines', [ 'stock_reservation_id' => $newerReservationId, 'inventory_id' => $variant->inventory_id, 'quantity' => 2, ]); $this->assertSoftDeleted('carritos', [ 'id' => $matchingPurchase->cart_id, ]); } public function test_transfer_webhook_buys_unlimited_inventory_without_reducing_real_stock(): void { $tenant = $this->createTenant('unlimited', 'Unlimited', 'unlimited.com.ar'); $this->configureTelepagosIntegration($tenant); $user = User::factory()->create(); $variant = $this->createVariantForTenant( 'unlimited', 0, '50.00', 'service', InventoryPolicy::Unlimited, ); $purchase = $this->createPendingTransferPurchase( $tenant, $user->id, $variant->id, 3, '87654321', ); 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/7000' => Http::response([ 'status' => 'ok', 'data' => [ 'amount' => 150, 'operation_id' => 1, 'transaction_id' => 'tx-unlimited', 'buyer' => ['cuit' => '20876543219'], ], ]), ]); $this->postJson('/api/webhooks/telepagos/unlimited', ['id' => '7000']) ->assertOk() ->assertJsonPath('status', 'success'); $this->assertDatabaseHas('compras', [ 'id' => $purchase->id, 'status' => Purchase::STATUS_PAID, ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, 'real_stock' => 0, 'reserved_stock' => 0, 'sold_units' => 3, ]); } 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->candidates()->pluck('compra_id')->all(), ); $this->assertSame(3, $payment->candidates()->where('match_reason', 'ambiguous_exact_match')->count()); $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_transfer_webhook_records_near_amount_only_with_exact_dni_or_exact_amount_with_different_dni(): void { config(['purchase.transfer_candidate_amount_tolerance_percentage' => 5]); $tenant = $this->createTenant('candidates', 'Candidates', 'candidates.com.ar'); $this->configureTelepagosIntegration($tenant); $nearAmountVariant = $this->createVariantForTenant('candidates', 10, '52.00', 'near'); $exactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'exact'); $distanceTwoExactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'distance-two'); $farExactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'far-exact'); $nearAmountDifferentDniVariant = $this->createVariantForTenant('candidates', 10, '51.00', 'near-other-dni'); $outsideRangeVariant = $this->createVariantForTenant('candidates', 10, '100.00', 'outside'); $nearAmountPurchase = $this->createPendingTransferPurchase( $tenant, User::factory()->create()->id, $nearAmountVariant->id, 1, '12345678', ); $exactAmountDifferentDniPurchase = $this->createPendingTransferPurchase( $tenant, User::factory()->create()->id, $exactAmountVariant->id, 1, '12345687', ); $farExactAmountDifferentDniPurchase = $this->createPendingTransferPurchase( $tenant, User::factory()->create()->id, $farExactAmountVariant->id, 1, '87654321', ); $distanceTwoExactAmountPurchase = $this->createPendingTransferPurchase( $tenant, User::factory()->create()->id, $distanceTwoExactAmountVariant->id, 1, '12345087', ); $nearAmountDifferentDniPurchase = $this->createPendingTransferPurchase( $tenant, User::factory()->create()->id, $nearAmountDifferentDniVariant->id, 1, '87654321', ); $outsideRangePurchase = $this->createPendingTransferPurchase( $tenant, User::factory()->create()->id, $outsideRangeVariant->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/candidates' => Http::response([ 'status' => 'ok', 'data' => [ 'amount' => 50, 'operation_id' => 1, 'transaction_id' => 'tx-candidates', 'buyer' => ['cuit' => '20123456789'], ], ]), ]); $this->postJson('/api/webhooks/telepagos/candidates', ['id' => 'candidates']) ->assertOk() ->assertJsonPath('status', 'success'); $payment = TelepagosPayment::query() ->where('transaction_id', 'tx-candidates') ->firstOrFail(); $this->assertNull($payment->compra_id); $this->assertEqualsCanonicalizing([ $nearAmountPurchase->id, $exactAmountDifferentDniPurchase->id, $distanceTwoExactAmountPurchase->id, ], $payment->candidates()->pluck('compra_id')->all()); $this->assertDatabaseHas('telepagos_payment_candidates', [ 'telepagos_payment_id' => $payment->id, 'compra_id' => $nearAmountPurchase->id, 'dni_matches' => true, 'dni_distance' => 0, 'payment_dni' => '12345678', 'purchase_dni' => '12345678', 'amount_matches' => false, 'payment_amount' => 50, 'purchase_amount' => 52, 'amount_difference' => 2, 'match_reason' => 'exact_dni_near_amount', 'confidence' => 'high', ]); $this->assertDatabaseHas('telepagos_payment_candidates', [ 'telepagos_payment_id' => $payment->id, 'compra_id' => $exactAmountDifferentDniPurchase->id, 'dni_matches' => false, 'dni_distance' => 1, 'payment_dni' => '12345678', 'purchase_dni' => '12345687', 'amount_matches' => true, 'payment_amount' => 50, 'purchase_amount' => 50, 'amount_difference' => 0, 'match_reason' => 'exact_amount_near_dni', 'confidence' => 'medium', ]); $this->assertDatabaseHas('telepagos_payment_candidates', [ 'telepagos_payment_id' => $payment->id, 'compra_id' => $distanceTwoExactAmountPurchase->id, 'dni_matches' => false, 'dni_distance' => 2, 'payment_dni' => '12345678', 'purchase_dni' => '12345087', 'amount_matches' => true, 'match_reason' => 'exact_amount_near_dni', ]); $this->assertDatabaseMissing('telepagos_payment_candidates', [ 'telepagos_payment_id' => $payment->id, 'compra_id' => $farExactAmountDifferentDniPurchase->id, ]); $this->assertDatabaseMissing('telepagos_payment_candidates', [ 'telepagos_payment_id' => $payment->id, 'compra_id' => $nearAmountDifferentDniPurchase->id, ]); $this->assertDatabaseMissing('telepagos_payment_candidates', [ 'telepagos_payment_id' => $payment->id, 'compra_id' => $outsideRangePurchase->id, ]); $this->assertSame(Purchase::STATUS_PENDING_PAYMENT, $nearAmountPurchase->fresh()->status); $this->assertSame(Purchase::STATUS_PENDING_PAYMENT, $exactAmountDifferentDniPurchase->fresh()->status); $higherPriorityPayment = TelepagosPayment::query()->create([ 'cuit_buyer' => '20123456789', 'amount' => 52, 'operation_id' => 1, 'transaction_id' => 'tx-primary-candidate', ]); $higherPriorityPayment->candidates()->create([ 'compra_id' => $nearAmountPurchase->id, 'dni_matches' => true, 'dni_distance' => 0, 'payment_dni' => '12345678', 'purchase_dni' => '12345678', 'amount_matches' => true, 'payment_amount' => 52, 'purchase_amount' => 52, 'amount_difference' => 0, 'match_reason' => 'ambiguous_exact_match', 'confidence' => 'exact', ]); app(CheckoutService::class)->submitForReview($nearAmountPurchase->fresh()); $buyer = User::query()->findOrFail($nearAmountPurchase->user_id); $this->actingAs($buyer, 'sanctum') ->getJson("/api/tenants/candidates/compras/{$nearAmountPurchase->id}") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW) ->assertJsonPath('data.payment_verification.status', 'candidate') ->assertJsonPath('data.payment_verification.candidate_count', 2) ->assertJsonPath('data.payment_verification.primary.reason', 'ambiguous_exact_match') ->assertJsonPath('data.payment_verification.primary.amount_difference', '0.00') ->assertJsonPath('data.payment_verification.primary.confidence', 'exact') ->assertJsonPath('data.payment_verification.reasons.0', 'ambiguous_exact_match') ->assertJsonPath('data.payment_verification.reasons.1', 'exact_dni_near_amount'); } public function test_webhook_confirms_a_purchase_with_tickets_enabled(): void { $tenant = $this->createTenant('expired-ticket', 'Expired Ticket', 'expired-ticket.com.ar'); $this->configureTelepagosIntegration($tenant); $user = User::factory()->create(); $variant = $this->createVariantForTenant('expired-ticket', 1, '50.00'); $variant->catalogItem->update([ 'has_tickets' => true, ]); $purchase = $this->createPendingTransferPurchase( $tenant, $user->id, $variant->id, 1, '87654321', ); 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/7001' => Http::response([ 'status' => 'ok', 'data' => [ 'amount' => 50, 'operation_id' => 1, 'transaction_id' => 'tx-expired-ticket', 'buyer' => ['cuit' => '20876543219'], ], ]), ]); $this->postJson('/api/webhooks/telepagos/expired-ticket', ['id' => '7001']) ->assertOk() ->assertJsonPath('status', 'success'); $this->assertDatabaseHas('compras', [ 'id' => $purchase->id, 'status' => Purchase::STATUS_PAID, ]); $this->assertDatabaseHas('tickets', [ 'source_catalog_item_id' => $variant->catalog_item_id, 'source_variant_id' => $variant->id, 'user_id' => $user->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', ]); $variant = Variant::query()->findOrFail($variantId); $cart->addItem($variant->catalog_item_id, $variant->id, $quantity); /** @var CheckoutService $checkoutService */ $checkoutService = app(CheckoutService::class); $purchase = $checkoutService->startCheckout($tenant, $userId, [ 'cart_id' => $cart->id, 'dni' => '87654321', 'telefono' => '+54 9 341 555-4321', 'nombre_apellido' => 'Juan Perez', 'email' => 'juan.perez@example.com', ]); $purchase->update([ 'payment_method' => 'transfer', 'transfer_payer_dni' => $dni, ]); $checkoutService->completePurchase($purchase); 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', ], ]); ClientIntegration::create([ 'client_id' => $tenant->client_id, 'integration_code' => 'telepagos_homo', 'integration_data' => [ 'username' => 'user123', 'password' => 'pass123', ], ]); } private function createVariantForTenant( string $tenantCode, int $stock, string $price, string $slugPrefix = 'shirt', InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked, ): Variant { $category = Category::query()->create([ 'tenant_code' => $tenantCode, 'nombre' => "{$slugPrefix} category {$tenantCode}", ]); $inventory = Inventory::query()->create(['real_stock' => $stock]); $catalogItem = CatalogItem::query()->create([ 'tenant_code' => $tenantCode, 'category_id' => $category->id, 'slug' => "{$slugPrefix}-{$tenantCode}-".CatalogItem::query()->count(), 'nombre' => ucfirst($slugPrefix)." {$tenantCode}", 'descripcion' => 'Test product', 'precio' => $price, 'inventory_policy' => $inventoryPolicy, ]); return Variant::query()->create([ 'catalog_item_id' => $catalogItem->id, 'inventory_id' => $inventory->id, ])->load(['catalogItem', 'inventory']); } private function createTenant(string $codigo, string $nombre, string $dominio): Tenant { $hdrKey = (string) Str::uuid(); $ftrKey = (string) Str::uuid(); $headerAttachment = Attachment::create([ 'key' => $hdrKey, 'path' => 'tenants/'.$hdrKey.'.png', 'filename' => 'logo_header.png', 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); $footerAttachment = Attachment::create([ 'key' => $ftrKey, 'path' => 'tenants/'.$ftrKey.'.png', 'filename' => 'logo_footer.png', 'type' => 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, ]); } }