feat: refactor purchase status handling and add finalize endpoint for purchases

This commit is contained in:
ncoronel 2026-07-07 15:33:29 -03:00
parent 786b4eb6a9
commit c276ccd311
11 changed files with 220 additions and 27 deletions

View File

@ -1367,7 +1367,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"status\": \"pending\",\n \"payment_status\": \"pending\",\n \"payment_method\": \"cash\",\n \"items\": [\n {\n \"producto_variante_id\": {{product_variant_id}},\n \"cantidad\": 1\n }\n ]\n}"
"raw": "{\n \"cart_id\": {{cart_id}},\n \"dni\": \"12345678\",\n \"telefono\": \"+54 9 341 555-4321\",\n \"nombre_apellido\": \"Juan Perez\",\n \"email\": \"juan.perez@example.com\"\n}"
},
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/compras",

View File

@ -55,7 +55,7 @@ class TelepagosWebhookService
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
->where('dni', $dni)
->where('status', 'pending')
->where('status', Purchase::STATUS_PENDING_PAYMENT)
->where('payment_method', 'transfer')
->where('total', $amount)
->latest()
@ -85,6 +85,11 @@ class TelepagosWebhookService
return;
}
if ($compra->status !== Purchase::STATUS_PENDING_PAYMENT) {
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
return;
}
$totalAmount = $this->normalizeAmount($compra->getTotalAmount());
if ($amount !== $totalAmount) {
@ -112,7 +117,7 @@ class TelepagosWebhookService
\Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) {
TelepagosPayment::create($paymentData);
$this->checkoutService->confirmPurchase($compra);
$compra->finalize();
$compra->markAsPaid();
});
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");

View File

@ -2,17 +2,15 @@
namespace App\Domains\Purchase\Controllers;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Requests\StorePurchaseRequest;
use App\Domains\Purchase\Resources\PurchaseResource;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Domains\Purchase\Services\CheckoutService;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class PurchaseController extends Controller
@ -122,6 +120,15 @@ class PurchaseController extends Controller
], 400);
}
public function finalize(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
{
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->finalizePurchase($compra)
);
}
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
{
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {

View File

@ -16,7 +16,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'tenant_codigo',
'user_id',
'status',
'payment_status',
'payment_method',
'total',
'dni',
@ -28,6 +27,12 @@ class Purchase extends Model
{
use HasFactory;
public const STATUS_CREATED = 'created';
public const STATUS_PENDING_PAYMENT = 'pending_payment';
public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_REJECTED = 'rejected';
protected $table = 'compras';
protected function casts(): array
@ -117,11 +122,17 @@ class Purchase extends Model
return $cart->getTotalAmount();
}
public function finalize(): void
public function markAsPendingPayment(): void
{
$this->update([
'status' => 'paid',
'payment_status' => 'approved',
'status' => self::STATUS_PENDING_PAYMENT,
]);
}
public function markAsPaid(): void
{
$this->update([
'status' => self::STATUS_PAID,
]);
}
}

View File

@ -3,7 +3,6 @@
namespace App\Domains\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePurchaseRequest extends FormRequest
{
@ -18,7 +17,6 @@ class StorePurchaseRequest extends FormRequest
public function rules(): array
{
return [
'status' => ['sometimes', 'string', Rule::in(['pending', 'paid', 'cancelled'])],
'cart_id' => ['required', 'integer', 'exists:carritos,id'],
'dni' => ['required', 'string'],
'telefono' => ['required', 'string'],

View File

@ -39,7 +39,6 @@ class PurchaseResource extends JsonResource
'tenant_codigo' => $this->tenant_codigo,
'user_id' => $this->user_id,
'status' => $this->status,
'payment_status' => $this->payment_status,
'payment_method' => $this->payment_method,
'dni' => $this->dni,
'telefono' => $this->telefono,

View File

@ -33,21 +33,62 @@ class CheckoutService
$cart->setRelation('items', $cartItems);
$totalAmount = $cart->getTotalAmount();
/** @var Purchase $purchase */
$purchase = Purchase::query()->updateOrCreate(
[
/** @var Purchase|null $purchase */
$purchase = Purchase::query()
->where('cart_id', $cart->getKey())
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $userId)
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->latest('id')
->first();
if ($purchase === null) {
/** @var Purchase $purchase */
$purchase = Purchase::query()->create([
...$purchaseData,
'cart_id' => $cart->getKey(),
'status' => 'pending',
'tenant_codigo' => $tenant->codigo,
'user_id' => $userId,
],
[
...$purchaseData,
'payment_status' => 'pending',
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'total' => $totalAmount,
]
);
]);
} else {
$purchase->fill([
...$purchaseData,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'total' => $totalAmount,
]);
$purchase->save();
}
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']);
});
}
public function finalizePurchase(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if ($purchase->payment_method === null) {
throw ValidationException::withMessages([
'payment_method' => 'The purchase payment method must be selected before finalizing.',
]);
}
if (in_array($purchase->status, [Purchase::STATUS_PAID, Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED], true)) {
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']);
}
$purchase->update([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => $purchase->calculateCurrentTotalAmount(),
]);
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']);
});

View File

@ -6,4 +6,5 @@ use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->middleware(['auth:sanctum', \App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class])->group(function (): void {
Route::apiResource('compras', PurchaseController::class)->only(['index', 'store', 'show']);
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
Route::post('compras/{compra}/finalize', [PurchaseController::class, 'finalize']);
});

View File

@ -0,0 +1,83 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
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('status')->default('created')->change();
});
if (Schema::hasColumn('compras', 'payment_status')) {
DB::table('compras')
->where('payment_status', 'approved')
->update(['status' => 'paid']);
DB::table('compras')
->where('payment_status', 'rejected')
->where('status', '!=', 'cancelled')
->update(['status' => 'rejected']);
}
DB::table('compras')
->where('status', 'pending')
->whereNull('payment_method')
->update(['status' => 'created']);
DB::table('compras')
->where('status', 'pending')
->whereNotNull('payment_method')
->update(['status' => 'pending_payment']);
if (Schema::hasColumn('compras', 'payment_status')) {
Schema::table('compras', function (Blueprint $table) {
$table->dropColumn('payment_status');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasColumn('compras', 'payment_status')) {
Schema::table('compras', function (Blueprint $table) {
$table->string('payment_status')->default('pending')->after('status');
});
}
DB::table('compras')
->where('status', 'paid')
->update([
'status' => 'paid',
'payment_status' => 'approved',
]);
DB::table('compras')
->where('status', 'rejected')
->update([
'status' => 'pending',
'payment_status' => 'rejected',
]);
DB::table('compras')
->whereIn('status', ['created', 'pending_payment'])
->update([
'status' => 'pending',
'payment_status' => 'pending',
]);
Schema::table('compras', function (Blueprint $table) {
$table->string('status')->default('pending')->change();
});
}
};

View File

@ -83,16 +83,14 @@ class TelepagosWebhookTest extends TestCase
$this->assertDatabaseHas('compras', [
'id' => $matchingPurchase->id,
'status' => 'paid',
'payment_status' => 'approved',
'status' => Purchase::STATUS_PAID,
'payment_method' => 'transfer',
'total' => 50,
]);
$this->assertDatabaseHas('compras', [
'id' => $newerPurchase->id,
'status' => 'pending',
'payment_status' => 'pending',
'status' => Purchase::STATUS_PENDING_PAYMENT,
'payment_method' => 'transfer',
'total' => 100,
]);
@ -154,6 +152,8 @@ class TelepagosWebhookTest extends TestCase
'payment_method' => 'transfer',
]);
$checkoutService->finalizePurchase($purchase);
return $purchase->fresh();
}

View File

@ -6,6 +6,7 @@ use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -74,6 +75,7 @@ class StorePurchaseTest extends TestCase
$response->assertJsonPath('data.nombre_apellido', 'Juan Perez');
$response->assertJsonPath('data.email', 'juan.perez@example.com');
$response->assertJsonPath('data.tenant_codigo', 'sonder');
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
$response->assertJsonPath('data.items', []);
$response->assertJsonPath('data.subtotal', '100.00');
$response->assertJsonPath('data.total', '100.00');
@ -89,6 +91,7 @@ class StorePurchaseTest extends TestCase
'telefono' => '+54 9 341 555-4321',
'nombre_apellido' => 'Juan Perez',
'email' => 'juan.perez@example.com',
'status' => Purchase::STATUS_CREATED,
'total' => 100,
]);
$this->assertDatabaseMissing('compra_items', [
@ -111,6 +114,51 @@ class StorePurchaseTest extends TestCase
]);
}
public function test_it_moves_a_created_purchase_to_pending_payment_when_finalized(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create([
'email' => 'buyer@example.com',
]);
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$cartId = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 2,
])
->assertOk()
->json('data.id');
$purchaseId = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras', [
'cart_id' => $cartId,
'dni' => '987654321',
'telefono' => '+54 9 341 555-4321',
'nombre_apellido' => 'Juan Perez',
'email' => 'juan.perez@example.com',
])
->assertCreated()
->json('data.id');
Purchase::query()
->whereKey($purchaseId)
->update([
'payment_method' => 'transfer',
]);
$this->actingAs($user, 'sanctum')
->postJson("/api/tenants/sonder/compras/{$purchaseId}/finalize")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT);
$this->assertDatabaseHas('compras', [
'id' => $purchaseId,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'payment_method' => 'transfer',
]);
}
public function test_it_rejects_a_cart_from_another_user(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');