diff --git a/app/Domains/Integration/Services/TelepagosWebhookService.php b/app/Domains/Integration/Services/TelepagosWebhookService.php index 0ff5002..3b1c438 100644 --- a/app/Domains/Integration/Services/TelepagosWebhookService.php +++ b/app/Domains/Integration/Services/TelepagosWebhookService.php @@ -8,6 +8,7 @@ use App\Domains\Purchase\Models\TelepagosQr; use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; use Exception; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class TelepagosWebhookService @@ -19,16 +20,13 @@ class TelepagosWebhookService /** * Handle the Telepagos webhook notification. * - * @param string $tenantCodigo - * @param string $cashinId - * @return void * @throws Exception */ public function handleWebhook(string $tenantCodigo, string $cashinId): void { $tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail(); - $telepagosService = new TelepagosIntegrationService(); + $telepagosService = new TelepagosIntegrationService; $telepagosService->forTenant($tenant->codigo); try { @@ -48,26 +46,36 @@ class TelepagosWebhookService if (! $cuit) { Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}"); + return; } $dni = substr($cuit, 2, -1); $compra = Purchase::where('tenant_codigo', $tenantCodigo) - ->where('transfer_payer_dni', $dni) - ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) + ->where('transfer_payer_dni', $dni) + ->whereIn('status', [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_IN_REVIEW, + ]) ->where('payment_method', 'transfer') ->where('total', $amount) + ->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [ + Purchase::STATUS_IN_REVIEW, + ]) ->latest() ->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) { Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}"); + return; } @@ -75,6 +83,7 @@ class TelepagosWebhookService if (! $telepagosQr) { Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}"); + return; } @@ -82,11 +91,16 @@ class TelepagosWebhookService if (! $compra) { Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}"); + return; } - if ($compra->status !== Purchase::STATUS_PENDING_PAYMENT) { + if (! in_array($compra->status, [ + Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_IN_REVIEW, + ], true)) { Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation"); + return; } @@ -94,10 +108,12 @@ class TelepagosWebhookService if ($amount !== $totalAmount) { Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}"); + return; } } else { Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}"); + return; } @@ -114,7 +130,7 @@ class TelepagosWebhookService 'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null, ]; - \Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) { + DB::transaction(function () use ($compra, $paymentData) { TelepagosPayment::create($paymentData); $this->checkoutService->confirmPurchase($compra); $compra->markAsPaid(); @@ -122,7 +138,7 @@ class TelepagosWebhookService Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}"); } catch (Exception $e) { - Log::error("Telepagos webhook error: " . $e->getMessage()); + Log::error('Telepagos webhook error: '.$e->getMessage()); throw $e; } } diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index ef5611f..14e70b0 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -223,6 +223,19 @@ class PurchaseController extends Controller ); } + public function submitForReview( + Request $request, + Tenant $tenant, + Purchase $compra, + CheckoutService $checkoutService, + ): PurchaseResource { + $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); + + return PurchaseResource::make( + $checkoutService->submitForReview($compra), + ); + } + public function cancel(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource { $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index 57a8d73..dd5e85b 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -37,6 +37,8 @@ class Purchase extends Model public const STATUS_PENDING_PAYMENT = 'pending_payment'; + public const STATUS_IN_REVIEW = 'in_review'; + public const STATUS_PAID = 'paid'; public const STATUS_CANCELLED = 'cancelled'; diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index a5127c8..6d8a06a 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -70,6 +70,7 @@ class CheckoutService if (in_array($purchase->status, [ Purchase::STATUS_PAID, + Purchase::STATUS_IN_REVIEW, Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED, Purchase::STATUS_EXPIRED, @@ -86,6 +87,39 @@ class CheckoutService }); } + public function submitForReview(Purchase $purchase): Purchase + { + return DB::transaction(function () use ($purchase): Purchase { + /** @var Purchase $purchase */ + $purchase = Purchase::query() + ->lockForUpdate() + ->findOrFail($purchase->getKey()); + + if (in_array($purchase->status, [ + Purchase::STATUS_IN_REVIEW, + Purchase::STATUS_PAID, + ], true)) { + return $this->loadPurchase($purchase); + } + + if ( + $purchase->status !== Purchase::STATUS_PENDING_PAYMENT + || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) + ) { + throw ValidationException::withMessages([ + 'purchase' => __('api.purchase.not_available_for_review'), + ]); + } + + $purchase->update([ + 'status' => Purchase::STATUS_IN_REVIEW, + 'expires_at' => null, + ]); + + return $this->loadPurchase($purchase); + }); + } + /** * @param array $customerData */ diff --git a/app/Domains/Purchase/routes/api.php b/app/Domains/Purchase/routes/api.php index 7d5bf10..41cc919 100644 --- a/app/Domains/Purchase/routes/api.php +++ b/app/Domains/Purchase/routes/api.php @@ -12,5 +12,6 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']); Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']); Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']); + Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']); Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']); }); diff --git a/lang/en/api.php b/lang/en/api.php index 4f491f3..99f6efd 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -46,6 +46,7 @@ return [ 'catalog_item_wrong_tenant' => 'One or more catalog items do not belong to the tenant.', 'inactive_cart' => 'The selected cart is no longer active.', 'not_available_for_payment' => 'The purchase is no longer available for payment.', + 'not_available_for_review' => 'The purchase is no longer available for review.', ], 'ticket' => [ 'not_available' => 'One or more tickets are not available.', diff --git a/lang/es/api.php b/lang/es/api.php index 19a3c67..5978123 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -46,6 +46,7 @@ return [ 'catalog_item_wrong_tenant' => 'Uno o más productos no pertenecen al tenant.', 'inactive_cart' => 'El carrito seleccionado ya no está activo.', 'not_available_for_payment' => 'La compra ya no está disponible para el pago.', + 'not_available_for_review' => "La compra ya no est\u{00E1} disponible para revisi\u{00F3}n.", ], 'ticket' => [ 'not_available' => 'Uno o más tickets no están disponibles.', diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index ba3bcb5..9d4a822 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -100,7 +100,7 @@ class TelepagosWebhookTest extends TestCase ]); } - public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void + public function test_transfer_webhook_matches_purchase_in_review_by_dni_and_total_amount(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $this->configureTelepagosIntegration($tenant); @@ -122,6 +122,8 @@ class TelepagosWebhookTest extends TestCase '12345678' ); + $matchingPurchase->update(['status' => Purchase::STATUS_IN_REVIEW]); + $newerPurchase = $this->createPendingTransferPurchase( $tenant, $newerUser->id, diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 9d13eb7..918e122 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -474,6 +474,62 @@ class StorePurchaseTest extends TestCase ]); } + public function test_it_submits_a_pending_purchase_for_review_idempotently(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + + $purchase->update([ + 'payment_method' => 'transfer', + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'expires_at' => now()->addMinutes(30), + ]); + + $url = "/api/tenants/sonder/compras/{$purchase->id}/review"; + + $this->actingAs($user, 'sanctum') + ->postJson($url) + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW) + ->assertJsonPath('data.expires_at', null); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'status' => Purchase::STATUS_IN_REVIEW, + 'expires_at' => null, + ]); + + $this->actingAs($user, 'sanctum') + ->postJson($url) + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$purchase->id}/complete") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW); + } + + public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$purchase->id}/review") + ->assertUnprocessable() + ->assertJsonValidationErrors(['purchase']); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'status' => Purchase::STATUS_CREATED, + ]); + } + public function test_it_expires_an_abandoned_purchase_and_restores_its_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');