diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index 61eee11..fa2b22a 100644 --- a/ShopIt_API_Postman_Collection.json +++ b/ShopIt_API_Postman_Collection.json @@ -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", diff --git a/app/Domains/Integration/Services/TelepagosWebhookService.php b/app/Domains/Integration/Services/TelepagosWebhookService.php index 85663d8..6252690 100644 --- a/app/Domains/Integration/Services/TelepagosWebhookService.php +++ b/app/Domains/Integration/Services/TelepagosWebhookService.php @@ -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}"); diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index eda7762..434d61d 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -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) { diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index 2ff9026..7a18740 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -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, ]); } } diff --git a/app/Domains/Purchase/Requests/StorePurchaseRequest.php b/app/Domains/Purchase/Requests/StorePurchaseRequest.php index c9f07da..3926f5b 100644 --- a/app/Domains/Purchase/Requests/StorePurchaseRequest.php +++ b/app/Domains/Purchase/Requests/StorePurchaseRequest.php @@ -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'], diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index 43317de..fdc32fc 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -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, diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 0af9dfd..2ba9638 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -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']); }); diff --git a/app/Domains/Purchase/routes/api.php b/app/Domains/Purchase/routes/api.php index ef77959..c5b55d3 100644 --- a/app/Domains/Purchase/routes/api.php +++ b/app/Domains/Purchase/routes/api.php @@ -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']); }); diff --git a/database/migrations/2026_07_07_110000_refactor_purchase_statuses.php b/database/migrations/2026_07_07_110000_refactor_purchase_statuses.php new file mode 100644 index 0000000..b66ad93 --- /dev/null +++ b/database/migrations/2026_07_07_110000_refactor_purchase_statuses.php @@ -0,0 +1,83 @@ +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(); + }); + } +}; diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index 68b07b3..fd4bc9c 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -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(); } diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 9bddbf6..e963c52 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -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');