feat: Remove 'in_review' status from purchase workflow; implement logging for status changes and update related tests
This commit is contained in:
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'])]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user