feat: Remove 'in_review' status from purchase workflow; implement logging for status changes and update related tests

This commit is contained in:
2026-08-03 17:04:39 -03:00
parent c4b317d5fc
commit 96f26e2e9d
8 changed files with 92 additions and 27 deletions

View File

@@ -57,13 +57,9 @@ class TelepagosWebhookService
->whereIn('status', [ ->whereIn('status', [
Purchase::STATUS_CREATED, Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
]) ])
->where('payment_method', 'transfer') ->where('payment_method', 'transfer')
->where('total', $amount) ->where('total', $amount)
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [
Purchase::STATUS_IN_REVIEW,
])
->latest() ->latest()
->first(); ->first();
@@ -97,7 +93,6 @@ class TelepagosWebhookService
if (! in_array($compra->status, [ if (! in_array($compra->status, [
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
], true)) { ], true)) {
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation"); Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");

View File

@@ -15,6 +15,7 @@ use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 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')); $purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'));
} }
$updated = Purchase::query() $updated = DB::transaction(function () use ($compra, $purchaseUpdate): bool {
->whereKey($compra->getKey()) /** @var Purchase|null $purchase */
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) $purchase = Purchase::query()
->where(function ($query): void { ->whereKey($compra->getKey())
$query->whereNull('expires_at') ->lockForUpdate()
->orWhere('expires_at', '>', now()); ->first();
})
->update($purchaseUpdate); 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) { if ($updated === 0) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Models;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\Cart;
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
@@ -31,14 +32,12 @@ use Illuminate\Support\Facades\DB;
])] ])]
class Purchase extends Model class Purchase extends Model
{ {
use HasFactory; use HasFactory, LogsValueChanges;
public const STATUS_CREATED = 'created'; public const STATUS_CREATED = 'created';
public const STATUS_PENDING_PAYMENT = 'pending_payment'; public const STATUS_PENDING_PAYMENT = 'pending_payment';
public const STATUS_IN_REVIEW = 'in_review';
public const STATUS_PAID = 'paid'; public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled'; public const STATUS_CANCELLED = 'cancelled';
@@ -49,6 +48,11 @@ class Purchase extends Model
protected $table = 'compras'; protected $table = 'compras';
/** @var array<int, string> */
protected array $loggedAttributes = [
'status',
];
protected function casts(): array protected function casts(): array
{ {
return [ return [

View File

@@ -70,7 +70,6 @@ class CheckoutService
if (in_array($purchase->status, [ if (in_array($purchase->status, [
Purchase::STATUS_PAID, Purchase::STATUS_PAID,
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_CANCELLED, Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED, Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED, Purchase::STATUS_EXPIRED,
@@ -96,7 +95,6 @@ class CheckoutService
->findOrFail($purchase->getKey()); ->findOrFail($purchase->getKey());
if (in_array($purchase->status, [ if (in_array($purchase->status, [
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_PAID, Purchase::STATUS_PAID,
], true)) { ], true)) {
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);
@@ -112,7 +110,6 @@ class CheckoutService
} }
$purchase->update([ $purchase->update([
'status' => Purchase::STATUS_IN_REVIEW,
'expires_at' => null, 'expires_at' => null,
]); ]);

View File

@@ -0,0 +1,19 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('compras')
->where('status', 'in_review')
->update(['status' => 'pending_payment']);
}
public function down(): void
{
// This cleanup cannot be reversed without changing legitimate pending purchases.
}
};

View File

@@ -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'); $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$this->configureTelepagosIntegration($tenant); $this->configureTelepagosIntegration($tenant);
@@ -122,8 +122,6 @@ class TelepagosWebhookTest extends TestCase
'12345678' '12345678'
); );
$matchingPurchase->update(['status' => Purchase::STATUS_IN_REVIEW]);
$newerPurchase = $this->createPendingTransferPurchase( $newerPurchase = $this->createPendingTransferPurchase(
$tenant, $tenant,
$newerUser->id, $newerUser->id,

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature\Logging;
use App\Domains\Logging\Enums\ValueChangeActorType; use App\Domains\Logging\Enums\ValueChangeActorType;
use App\Domains\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Logging\Models\ValueChange; use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
@@ -37,6 +38,12 @@ class LogsValueChangesTest extends TestCase
$table->timestamps(); $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 = require database_path('migrations/2026_08_03_000200_create_value_changes_table.php');
$migration->up(); $migration->up();
} }
@@ -103,6 +110,27 @@ class LogsValueChangesTest extends TestCase
$this->assertDatabaseCount('value_changes', 0); $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'])] #[Fillable(['name', 'price', 'description'])]

View File

@@ -472,9 +472,18 @@ class StorePurchaseTest extends TestCase
'status' => Purchase::STATUS_PENDING_PAYMENT, 'status' => Purchase::STATUS_PENDING_PAYMENT,
'payment_method' => 'transfer', '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'); $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create(); $user = User::factory()->create();
@@ -492,24 +501,24 @@ class StorePurchaseTest extends TestCase
$this->actingAs($user, 'sanctum') $this->actingAs($user, 'sanctum')
->postJson($url) ->postJson($url)
->assertOk() ->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW) ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
->assertJsonPath('data.expires_at', null); ->assertJsonPath('data.expires_at', null);
$this->assertDatabaseHas('compras', [ $this->assertDatabaseHas('compras', [
'id' => $purchase->id, 'id' => $purchase->id,
'status' => Purchase::STATUS_IN_REVIEW, 'status' => Purchase::STATUS_PENDING_PAYMENT,
'expires_at' => null, 'expires_at' => null,
]); ]);
$this->actingAs($user, 'sanctum') $this->actingAs($user, 'sanctum')
->postJson($url) ->postJson($url)
->assertOk() ->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW); ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT);
$this->actingAs($user, 'sanctum') $this->actingAs($user, 'sanctum')
->postJson("/api/tenants/sonder/compras/{$purchase->id}/complete") ->postJson("/api/tenants/sonder/compras/{$purchase->id}/complete")
->assertOk() ->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 public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void