feat: add customer details fields to Purchase model and request, implement checkout process with cart clearing

This commit is contained in:
2026-07-03 16:48:31 -03:00
parent 6d066beea3
commit 057e6757dc
8 changed files with 242 additions and 40 deletions

View File

@@ -9,7 +9,11 @@ class TelepagosProvider implements PaymentProviderInterface
{ {
public function process(array $paymentData, array $credentials): array public function process(array $paymentData, array $credentials): array
{ {
// TODO: Implementar lógica de comunicación con Telepagos (usando $paymentData['payment_method']) // Mocked response for development and local testing
throw new BadMethodCallException('Método de pago no implementado para Telepagos.'); return [
'status' => 'approved',
'transaction_id' => 'mock_tp_' . uniqid(),
'payment_method' => $paymentData['payment_method'] ?? 'qr',
];
} }
} }

View File

@@ -16,6 +16,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'status', 'status',
'payment_status', 'payment_status',
'payment_method', 'payment_method',
'dni',
'telefono',
'nombre_apellido',
'email',
])] ])]
class Purchase extends Model class Purchase extends Model
{ {

View File

@@ -20,9 +20,11 @@ class StorePurchaseRequest extends FormRequest
return [ return [
'status' => ['sometimes', 'string', Rule::in(['pending', 'paid', 'cancelled'])], 'status' => ['sometimes', 'string', Rule::in(['pending', 'paid', 'cancelled'])],
'payment_status' => ['sometimes', 'string', Rule::in(['pending', 'approved', 'rejected'])], 'payment_status' => ['sometimes', 'string', Rule::in(['pending', 'approved', 'rejected'])],
'integration_code' => ['required', 'string'], 'payment_method' => ['required', 'string'],
'payment_data' => ['required', 'array'], 'dni' => ['required', 'string'],
'payment_data.payment_method' => ['required', 'string'], 'telefono' => ['required', 'string'],
'nombre_apellido' => ['required', 'string'],
'email' => ['required', 'string', 'email'],
'items' => ['required', 'array', 'min:1'], 'items' => ['required', 'array', 'min:1'],
'items.*.producto_variante_id' => ['required', 'integer', 'exists:productos_variantes,id'], 'items.*.producto_variante_id' => ['required', 'integer', 'exists:productos_variantes,id'],
'items.*.cantidad' => ['required', 'integer', 'min:1'], 'items.*.cantidad' => ['required', 'integer', 'min:1'],

View File

@@ -36,6 +36,10 @@ class PurchaseResource extends JsonResource
'status' => $this->status, 'status' => $this->status,
'payment_status' => $this->payment_status, 'payment_status' => $this->payment_status,
'payment_method' => $this->payment_method, 'payment_method' => $this->payment_method,
'dni' => $this->dni,
'telefono' => $this->telefono,
'nombre_apellido' => $this->nombre_apellido,
'email' => $this->email,
'items' => PurchaseItemResource::collection($items), 'items' => PurchaseItemResource::collection($items),
'subtotal' => $this->formatMoney($subtotal), 'subtotal' => $this->formatMoney($subtotal),
'total' => $this->formatMoney($total), 'total' => $this->formatMoney($total),

View File

@@ -3,8 +3,6 @@
namespace App\Domains\Purchase\Services; namespace App\Domains\Purchase\Services;
use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Integration\Services\PaymentProviderFactory;
use App\Domains\Integration\Services\TenantIntegrationService;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -13,53 +11,36 @@ use Exception;
class CheckoutService class CheckoutService
{ {
protected TenantIntegrationService $tenantIntegrationService; public function processCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
protected PaymentProviderFactory $paymentProviderFactory;
public function __construct(
TenantIntegrationService $tenantIntegrationService,
PaymentProviderFactory $paymentProviderFactory
) {
$this->tenantIntegrationService = $tenantIntegrationService;
$this->paymentProviderFactory = $paymentProviderFactory;
}
public function processCheckout(Tenant $tenant, int $userId, array $purchaseData, string $integrationCode, array $paymentData): Purchase
{ {
// 1. Obtener configuración del tenant // 1. Preparar items de la compra
$tenantIntegration = $this->tenantIntegrationService->getTenantIntegration($tenant->codigo, $integrationCode);
if (!$tenantIntegration) {
throw ValidationException::withMessages([
'integration_code' => "El tenant no tiene configurada la integración: {$integrationCode}",
]);
}
// 2. Instanciar el proveedor de pago
$provider = $this->paymentProviderFactory->make($integrationCode);
// 3. Procesar el pago con las credenciales desencriptadas
$paymentResult = $provider->process($paymentData, $tenantIntegration->integration_data);
// 4. Preparar items de la compra
$items = $purchaseData['items']; $items = $purchaseData['items'];
unset($purchaseData['items']); unset($purchaseData['items']);
$variants = $this->resolveTenantVariants($tenant, $items); $variants = $this->resolveTenantVariants($tenant, $items);
$purchaseItemsPayload = $this->buildPurchaseItemsPayload($items, $variants); $purchaseItemsPayload = $this->buildPurchaseItemsPayload($items, $variants);
// 5. Crear la orden de compra // 2. Crear la orden de compra y vaciar carrito
return DB::transaction(function () use ($tenant, $userId, $purchaseData, $purchaseItemsPayload, $paymentResult): Purchase { return DB::transaction(function () use ($tenant, $userId, $purchaseData, $purchaseItemsPayload): Purchase {
/** @var Purchase $purchase */ /** @var Purchase $purchase */
$purchase = Purchase::query()->create([ $purchase = Purchase::query()->create([
...$purchaseData, ...$purchaseData,
'tenant_codigo' => $tenant->codigo, 'tenant_codigo' => $tenant->codigo,
'user_id' => $userId, 'user_id' => $userId,
// TODO: En un futuro se puede guardar info de $paymentResult en la base de datos (ej: payment_id, status)
]); ]);
$purchase->items()->createMany($purchaseItemsPayload); $purchase->items()->createMany($purchaseItemsPayload);
// Vaciar y eliminar el carrito activo del usuario
$cart = \App\Domains\Cart\Models\Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $userId)
->first();
if ($cart) {
$cart->items()->delete();
$cart->delete();
}
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']); return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']);
}); });
} }

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('compras', function (Blueprint $table) {
$table->string('dni')->nullable()->after('payment_method');
$table->string('telefono')->nullable()->after('dni');
$table->string('nombre_apellido')->nullable()->after('telefono');
$table->string('email')->nullable()->after('nombre_apellido');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('compras', function (Blueprint $table) {
$table->dropColumn(['dni', 'telefono', 'nombre_apellido', 'email']);
});
}
};

View File

@@ -12,7 +12,7 @@ class TelepagosIntegrationSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
Integration::updateOrCreate( $integration = Integration::updateOrCreate(
['integration_code' => 'telepagos'], ['integration_code' => 'telepagos'],
[ [
'name' => 'Telepagos', 'name' => 'Telepagos',
@@ -24,7 +24,7 @@ class TelepagosIntegrationSeeder extends Seeder
] ]
); );
Integration::updateOrCreate( $integrationHomo = Integration::updateOrCreate(
['integration_code' => 'telepagos_homo'], ['integration_code' => 'telepagos_homo'],
[ [
'name' => 'Telepagos Homologación', 'name' => 'Telepagos Homologación',
@@ -35,5 +35,16 @@ class TelepagosIntegrationSeeder extends Seeder
] ]
] ]
); );
// Seed association for sonder tenant
$tenantIntegrationService = app(\App\Domains\Integration\Services\TenantIntegrationService::class);
$tenantIntegrationService->updateOrCreateIntegration(
'sonder',
$integration,
[
'username' => 'sonder_telepagos',
'password' => 'secret123',
]
);
} }
} }

View File

@@ -0,0 +1,165 @@
<?php
namespace Tests\Feature\Purchase;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Integration\Models\Integration;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class StorePurchaseTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_purchase_with_customer_details_and_clears_the_cart(): void
{
// 1. Setup Tenant
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
// 2. Setup Integration
$integration = Integration::create([
'integration_code' => 'telepagos',
'name' => 'Telepagos',
'url' => 'https://api.telepagos.com.ar',
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
]);
$tenantIntegrationService = app(\App\Domains\Integration\Services\TenantIntegrationService::class);
$tenantIntegrationService->updateOrCreateIntegration(
'sonder',
$integration,
[
'username' => 'sonder_telepagos',
'password' => 'secret123',
]
);
// 3. Setup User
$user = User::factory()->create([
'email' => 'buyer@example.com'
]);
// 4. Setup Product & Variant
$category = \App\Domains\Catalog\Models\Category::query()->create([
'tenant_code' => 'sonder',
'nombre' => 'Test Category',
]);
$product = Product::query()->create([
'tenant_codigo' => 'sonder',
'categoria_id' => $category->id,
'slug' => 'test-product',
'nombre' => 'Test Product',
'descripcion' => 'Test',
'precio' => '50.00',
]);
$variant = ProductVariant::query()->create([
'producto_id' => $product->id,
'slug' => 'test-variant',
'nombre' => 'Test Variant',
'stock' => 10,
'precio' => '50.00',
]);
// 5. Add item to user's cart
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 2,
])
->assertOk();
// Check cart exists in DB
$this->assertDatabaseHas('carritos', [
'tenant_codigo' => 'sonder',
'user_id' => $user->id,
]);
// 6. Submit Purchase
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras', [
'dni' => '987654321',
'telefono' => '+54 9 341 555-4321',
'nombre_apellido' => 'Juan Perez',
'email' => 'juan.perez@example.com',
'integration_code' => 'telepagos',
'payment_data' => [
'payment_method' => 'transferencia',
],
'items' => [
[
'producto_variante_id' => $variant->id,
'cantidad' => 2,
]
]
]);
// 7. Assertions
$response->assertCreated();
$response->assertJsonPath('data.dni', '987654321');
$response->assertJsonPath('data.telefono', '+54 9 341 555-4321');
$response->assertJsonPath('data.nombre_apellido', 'Juan Perez');
$response->assertJsonPath('data.email', 'juan.perez@example.com');
$response->assertJsonPath('data.payment_method', 'transferencia');
$response->assertJsonPath('data.tenant_codigo', 'sonder');
// Assert Purchase saved in DB
$this->assertDatabaseHas('compras', [
'tenant_codigo' => 'sonder',
'user_id' => $user->id,
'dni' => '987654321',
'telefono' => '+54 9 341 555-4321',
'nombre_apellido' => 'Juan Perez',
'email' => 'juan.perez@example.com',
'payment_method' => 'transferencia',
]);
// Assert Cart was cleared (deleted)
$this->assertDatabaseMissing('carritos', [
'tenant_codigo' => 'sonder',
'user_id' => $user->id,
]);
}
protected 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,
]);
}
}