diff --git a/app/Domains/Integration/Services/TelepagosWebhookService.php b/app/Domains/Integration/Services/TelepagosWebhookService.php index 3b1c438..008faf5 100644 --- a/app/Domains/Integration/Services/TelepagosWebhookService.php +++ b/app/Domains/Integration/Services/TelepagosWebhookService.php @@ -57,13 +57,9 @@ class TelepagosWebhookService ->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(); @@ -97,7 +93,6 @@ class TelepagosWebhookService 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"); diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 14e70b0..5439563 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -15,6 +15,7 @@ use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -124,14 +125,28 @@ class PurchaseController extends Controller $purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni')); } - $updated = Purchase::query() - ->whereKey($compra->getKey()) - ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) - ->where(function ($query): void { - $query->whereNull('expires_at') - ->orWhere('expires_at', '>', now()); - }) - ->update($purchaseUpdate); + $updated = DB::transaction(function () use ($compra, $purchaseUpdate): bool { + /** @var Purchase|null $purchase */ + $purchase = Purchase::query() + ->whereKey($compra->getKey()) + ->lockForUpdate() + ->first(); + + if ( + $purchase === null + || ! in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true) + || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) + ) { + return false; + } + + $purchase->update($purchaseUpdate); + + return true; + }); if ($updated === 0) { throw ValidationException::withMessages([ diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index dd5e85b..ff1b79d 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Models; use App\Domains\Auth\Models\User; use App\Domains\Cart\Models\Cart; +use App\Domains\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; @@ -31,14 +32,12 @@ use Illuminate\Support\Facades\DB; ])] class Purchase extends Model { - use HasFactory; + use HasFactory, LogsValueChanges; public const STATUS_CREATED = 'created'; public const STATUS_PENDING_PAYMENT = 'pending_payment'; - public const STATUS_IN_REVIEW = 'in_review'; - public const STATUS_PAID = 'paid'; public const STATUS_CANCELLED = 'cancelled'; @@ -49,6 +48,11 @@ class Purchase extends Model protected $table = 'compras'; + /** @var array */ + protected array $loggedAttributes = [ + 'status', + ]; + protected function casts(): array { return [ diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 6d8a06a..90431c8 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -70,7 +70,6 @@ class CheckoutService if (in_array($purchase->status, [ Purchase::STATUS_PAID, - Purchase::STATUS_IN_REVIEW, Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED, Purchase::STATUS_EXPIRED, @@ -96,7 +95,6 @@ class CheckoutService ->findOrFail($purchase->getKey()); if (in_array($purchase->status, [ - Purchase::STATUS_IN_REVIEW, Purchase::STATUS_PAID, ], true)) { return $this->loadPurchase($purchase); @@ -112,7 +110,6 @@ class CheckoutService } $purchase->update([ - 'status' => Purchase::STATUS_IN_REVIEW, 'expires_at' => null, ]); diff --git a/database/migrations/2026_08_03_000300_migrate_in_review_purchases_to_pending_payment.php b/database/migrations/2026_08_03_000300_migrate_in_review_purchases_to_pending_payment.php new file mode 100644 index 0000000..f5d3841 --- /dev/null +++ b/database/migrations/2026_08_03_000300_migrate_in_review_purchases_to_pending_payment.php @@ -0,0 +1,19 @@ +where('status', 'in_review') + ->update(['status' => 'pending_payment']); + } + + public function down(): void + { + // This cleanup cannot be reversed without changing legitimate pending purchases. + } +}; diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index 9d4a822..ba3bcb5 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_purchase_in_review_by_dni_and_total_amount(): void + 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); @@ -122,8 +122,6 @@ class TelepagosWebhookTest extends TestCase '12345678' ); - $matchingPurchase->update(['status' => Purchase::STATUS_IN_REVIEW]); - $newerPurchase = $this->createPendingTransferPurchase( $tenant, $newerUser->id, diff --git a/tests/Feature/Logging/LogsValueChangesTest.php b/tests/Feature/Logging/LogsValueChangesTest.php index 94f891d..4951219 100644 --- a/tests/Feature/Logging/LogsValueChangesTest.php +++ b/tests/Feature/Logging/LogsValueChangesTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Logging; use App\Domains\Logging\Enums\ValueChangeActorType; use App\Domains\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Logging\Models\ValueChange; +use App\Domains\Purchase\Models\Purchase; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; @@ -37,6 +38,12 @@ class LogsValueChangesTest extends TestCase $table->timestamps(); }); + Schema::create('compras', function (Blueprint $table): void { + $table->id(); + $table->string('status')->default(Purchase::STATUS_CREATED); + $table->timestamps(); + }); + $migration = require database_path('migrations/2026_08_03_000200_create_value_changes_table.php'); $migration->up(); } @@ -103,6 +110,27 @@ class LogsValueChangesTest extends TestCase $this->assertDatabaseCount('value_changes', 0); } + + public function test_purchase_logs_its_status_changes(): void + { + $purchase = Purchase::query()->create([ + 'status' => Purchase::STATUS_CREATED, + ]); + + $purchase->update([ + 'status' => Purchase::STATUS_PENDING_PAYMENT, + ]); + + $this->assertDatabaseHas('value_changes', [ + 'trackable_type' => $purchase->getMorphClass(), + 'trackable_id' => $purchase->id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_CREATED, + 'new_value' => Purchase::STATUS_PENDING_PAYMENT, + 'actor_type' => ValueChangeActorType::System->value, + 'user_id' => null, + ]); + } } #[Fillable(['name', 'price', 'description'])] diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 918e122..bd9be50 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -472,9 +472,18 @@ class StorePurchaseTest extends TestCase 'status' => Purchase::STATUS_PENDING_PAYMENT, 'payment_method' => 'transfer', ]); + $this->assertDatabaseHas('value_changes', [ + 'trackable_type' => (new Purchase)->getMorphClass(), + 'trackable_id' => $purchaseId, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_CREATED, + 'new_value' => Purchase::STATUS_PENDING_PAYMENT, + 'actor_type' => 'user', + 'user_id' => $user->id, + ]); } - public function test_it_submits_a_pending_purchase_for_review_idempotently(): void + public function test_it_keeps_a_submitted_purchase_pending_payment_idempotently(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); @@ -492,24 +501,24 @@ class StorePurchaseTest extends TestCase $this->actingAs($user, 'sanctum') ->postJson($url) ->assertOk() - ->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW) + ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT) ->assertJsonPath('data.expires_at', null); $this->assertDatabaseHas('compras', [ 'id' => $purchase->id, - 'status' => Purchase::STATUS_IN_REVIEW, + 'status' => Purchase::STATUS_PENDING_PAYMENT, 'expires_at' => null, ]); $this->actingAs($user, 'sanctum') ->postJson($url) ->assertOk() - ->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW); + ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT); $this->actingAs($user, 'sanctum') ->postJson("/api/tenants/sonder/compras/{$purchase->id}/complete") ->assertOk() - ->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW); + ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT); } public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void