feat: subscriptions
CI / 🧪 Tests Laravel (push) Successful in 2m48s
CI / 🐳 Build & Push Image (push) Successful in 3m0s

This commit is contained in:
2026-08-07 11:53:46 +02:00
parent 99cb0e63e6
commit 2ece4b6e3e
51 changed files with 1489 additions and 254 deletions
+216
View File
@@ -0,0 +1,216 @@
<?php
use App\Actions\ReviewIdentityVerification;
use App\Enums\BillingProductPurpose;
use App\Enums\CreditProductType;
use App\Enums\IdentityVerificationStatus;
use App\Enums\PaymentTransactionStatus;
use App\Enums\UserRole;
use App\Filament\Resources\IdentityVerificationRequests\Pages\ListIdentityVerificationRequests;
use App\Listeners\GrantCreditsFromStripeWebhook;
use App\Models\CreditProduct;
use App\Models\IdentityVerificationRequest;
use App\Models\PaymentTransaction;
use App\Models\User;
use App\Policies\IdentityVerificationRequestPolicy;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('requires the lifetime purchase before accepting an identity document', function () {
Storage::fake('identity_documents');
Sanctum::actingAs(User::factory()->create());
$this->post('/api/certification/identity-document', [
'document' => UploadedFile::fake()->image('identity.jpg'),
])->assertForbidden();
expect(IdentityVerificationRequest::query()->count())->toBe(0);
});
it('stores identity documents privately without exposing their path', function () {
Storage::fake('identity_documents');
$user = User::factory()->create([
'certification_purchased_at' => now(),
]);
Sanctum::actingAs($user);
$response = $this->post('/api/certification/identity-document', [
'document' => UploadedFile::fake()->image('my-passport.jpg'),
]);
$response
->assertOk()
->assertJsonPath('data.entitlementPurchased', true)
->assertJsonPath('data.canSubmitIdentity', false)
->assertJsonPath('data.verification.status', IdentityVerificationStatus::PENDING->value)
->assertJsonMissingPath('data.verification.documentPath');
$verificationRequest = IdentityVerificationRequest::query()->sole();
Storage::disk('identity_documents')->assertExists($verificationRequest->document_path);
expect($verificationRequest->document_path)->toStartWith('submissions/')
->not->toContain('my-passport')
->and(Storage::disk('identity_documents')->getVisibility($verificationRequest->document_path))
->toBe('private');
});
it('prevents replacing a document while it is under review', function () {
Storage::fake('identity_documents');
$user = User::factory()->create([
'certification_purchased_at' => now(),
]);
IdentityVerificationRequest::factory()->for($user)->create();
Sanctum::actingAs($user);
$this
->withHeader('Accept', 'application/json')
->post('/api/certification/identity-document', [
'document' => UploadedFile::fake()->image('identity.jpg'),
])
->assertConflict()
->assertJsonPath('message', __('api.certification.already_pending'));
});
it('approves a certification and removes the reviewed identity document', function () {
Storage::fake('identity_documents');
$user = User::factory()->create([
'certification_purchased_at' => now(),
]);
$admin = User::factory()->create([
'role' => UserRole::ADMIN,
]);
$verificationRequest = IdentityVerificationRequest::factory()->for($user)->create();
Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity');
app(ReviewIdentityVerification::class)->execute(
$verificationRequest,
IdentityVerificationStatus::APPROVED,
$admin,
);
Storage::disk('identity_documents')->assertMissing($verificationRequest->document_path);
expect($user->fresh()->account_verified_at)->not->toBeNull()
->and($verificationRequest->fresh()->status)->toBe(IdentityVerificationStatus::APPROVED)
->and($verificationRequest->fresh()->document_path)->toBeNull()
->and($verificationRequest->fresh()->document_deleted_at)->not->toBeNull();
});
it('allows a new submission after rejection without another purchase', function () {
Storage::fake('identity_documents');
$user = User::factory()->create([
'certification_purchased_at' => now(),
]);
$admin = User::factory()->create([
'role' => UserRole::ADMIN,
]);
$verificationRequest = IdentityVerificationRequest::factory()->for($user)->create();
Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity');
app(ReviewIdentityVerification::class)->execute(
$verificationRequest,
IdentityVerificationStatus::REJECTED,
$admin,
'Le document est illisible.',
);
Sanctum::actingAs($user);
$this->post('/api/certification/identity-document', [
'document' => UploadedFile::fake()->image('identity-new.jpg'),
])
->assertOk()
->assertJsonPath('data.verification.status', IdentityVerificationStatus::PENDING->value);
expect($verificationRequest->fresh()->rejection_reason)->toBeNull()
->and($verificationRequest->fresh()->document_path)->not->toBeNull();
});
it('restricts identity review records to administrators', function () {
$policy = new IdentityVerificationRequestPolicy;
$request = IdentityVerificationRequest::factory()->make();
$admin = User::factory()->make(['role' => UserRole::ADMIN]);
$moderator = User::factory()->make(['role' => UserRole::MODERATOR]);
expect($policy->viewAny($admin))->toBeTrue()
->and($policy->view($admin, $request))->toBeTrue()
->and($policy->viewAny($moderator))->toBeFalse()
->and($policy->view($moderator, $request))->toBeFalse();
});
it('lets an administrator approve the identity request from filament', function () {
app()->setLocale('fr');
Filament::setCurrentPanel(Filament::getPanel('dashboard'));
Storage::fake('identity_documents');
$admin = User::factory()->create([
'role' => UserRole::ADMIN,
]);
$user = User::factory()->create([
'certification_purchased_at' => now(),
]);
$verificationRequest = IdentityVerificationRequest::factory()->for($user)->create();
Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity');
$this->actingAs($admin);
Livewire::test(ListIdentityVerificationRequests::class)
->assertCanSeeTableRecords([$verificationRequest])
->callTableAction('approve', $verificationRequest);
expect($verificationRequest->fresh()->status)->toBe(IdentityVerificationStatus::APPROVED)
->and($user->fresh()->account_verified_at)->not->toBeNull();
});
it('grants lifetime certification entitlement from the paid Stripe webhook once', function () {
$user = User::factory()->create([
'stripe_id' => 'cus_certification',
]);
$product = CreditProduct::create([
'name' => 'Certification',
'type' => CreditProductType::ONE_TIME,
'purpose' => BillingProductPurpose::CERTIFICATION,
'credits' => 0,
'amount' => 1999,
'currency' => 'eur',
'stripe_price_id' => 'price_certification',
]);
$event = (object) [
'payload' => [
'id' => 'evt_certification',
'type' => 'checkout.session.completed',
'data' => [
'object' => [
'id' => 'cs_certification',
'mode' => 'payment',
'payment_status' => 'paid',
'customer' => $user->stripe_id,
'payment_intent' => 'pi_certification',
'amount_total' => 1999,
'currency' => 'eur',
'metadata' => [
'credit_product_id' => $product->getKey(),
'billing_product_purpose' => BillingProductPurpose::CERTIFICATION->value,
],
],
],
],
];
$listener = app(GrantCreditsFromStripeWebhook::class);
$listener->handle($event);
$firstPurchaseDate = $user->fresh()->certification_purchased_at;
$listener->handle($event);
expect($firstPurchaseDate)->not->toBeNull()
->and($user->fresh()->certification_purchased_at?->equalTo($firstPurchaseDate))->toBeTrue()
->and(PaymentTransaction::query()->count())->toBe(1)
->and(PaymentTransaction::query()->sole()->status)->toBe(PaymentTransactionStatus::PAID);
});