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
+17 -15
View File
@@ -1,5 +1,6 @@
<?php
use App\Enums\BillingProductPurpose;
use App\Enums\CreditProductType;
use App\Models\CreditProduct;
use App\Models\User;
@@ -8,44 +9,45 @@ use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
it('lists active credit products ordered for the mobile app', function () {
it('lists the subscription and certification offers for the mobile app', function () {
Sanctum::actingAs(User::factory()->create());
CreditProduct::create([
'name' => 'Pack 10',
'name' => 'Certification',
'type' => CreditProductType::ONE_TIME,
'credits' => 10,
'amount' => 499,
'purpose' => BillingProductPurpose::CERTIFICATION,
'credits' => 0,
'amount' => 1999,
'currency' => 'eur',
'stripe_price_id' => 'price_pack_10',
'stripe_price_id' => 'price_certification',
'sort_order' => 20,
]);
CreditProduct::create([
'name' => 'Mensuel 30',
'name' => 'Bowli Plus',
'type' => CreditProductType::MONTHLY,
'credits' => 30,
'purpose' => BillingProductPurpose::SUBSCRIPTION,
'credits' => 0,
'amount' => 999,
'currency' => 'eur',
'stripe_price_id' => 'price_monthly_30',
'stripe_price_id' => 'price_subscription',
'sort_order' => 10,
]);
CreditProduct::create([
'name' => 'Inactive',
'name' => 'Ancien pack',
'type' => CreditProductType::ONE_TIME,
'credits' => 5,
'amount' => 299,
'currency' => 'eur',
'stripe_price_id' => 'price_inactive',
'is_active' => false,
'stripe_price_id' => 'price_legacy',
]);
$this
->getJson('/api/billing/products')
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.name', 'Mensuel 30')
->assertJsonPath('data.0.type', CreditProductType::MONTHLY->value)
->assertJsonPath('data.0.credits', 30)
->assertJsonPath('data.0.name', 'Bowli Plus')
->assertJsonPath('data.0.purpose', BillingProductPurpose::SUBSCRIPTION->value)
->assertJsonPath('data.0.formattedAmount', '9,99 EUR')
->assertJsonPath('data.1.name', 'Pack 10');
->assertJsonPath('data.1.name', 'Certification')
->assertJsonPath('data.1.purpose', BillingProductPurpose::CERTIFICATION->value);
});
+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);
});
+5 -1
View File
@@ -32,6 +32,8 @@ it('sends a verification email when a user registers', function () {
->assertJsonPath('user.email', 'leon@example.com')
->assertJsonPath('user.locale', 'fr')
->assertJsonPath('user.emailVerified', false)
->assertJsonPath('user.analysisAccessLevel', 'trial')
->assertJsonPath('user.canAnalyzeMeals', true)
->assertJsonPath('user.physicalActivityLevel', PhysicalActivityLevel::LIGHTLY_ACTIVE->value)
->assertJsonPath('user.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value)
->assertJsonPath('user.pacePreference', PacePreference::NORMAL->value)
@@ -41,7 +43,9 @@ it('sends a verification email when a user registers', function () {
$user = User::where('email', 'leon@example.com')->firstOrFail();
expect($user->hasVerifiedEmail())->toBeFalse();
expect($user->hasVerifiedEmail())->toBeFalse()
->and($user->trial_ends_at)->not->toBeNull()
->and($user->trial_ends_at->isFuture())->toBeTrue();
$this->assertDatabaseHas('users', [
'id' => $user->id,
+13 -21
View File
@@ -3,11 +3,9 @@
use App\Ai\Agents\MealImageAnalyzer;
use App\Enums\AiUsageStatus;
use App\Enums\AiUsageType;
use App\Enums\CreditLedgerEntryType;
use App\Enums\IngredientUnit;
use App\Enums\MealPostType;
use App\Models\AiUsage;
use App\Models\CreditLedgerEntry;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
@@ -28,7 +26,9 @@ function fakeMealAnalysisImage(): UploadedFile
}
it('analyzes a meal image into a draft', function () {
$user = User::factory()->create();
$user = User::factory()->create([
'trial_ends_at' => now()->addDay(),
]);
Sanctum::actingAs($user);
@@ -66,7 +66,6 @@ it('analyzes a meal image into a draft', function () {
$response
->assertOk()
->assertJsonPath('credits.balance', 2)
->assertJsonPath('data.title', 'Bowl saumon avocat')
->assertJsonPath('data.type', MealPostType::LUNCH->value)
->assertJsonPath('data.calories', 612)
@@ -110,18 +109,12 @@ it('analyzes a meal image into a draft', function () {
],
]);
$user->refresh();
$ledgerEntry = CreditLedgerEntry::query()->sole();
expect($user->ai_credits_balance)->toBe(2)
->and($ledgerEntry->user_id)->toBe($user->id)
->and($ledgerEntry->type)->toBe(CreditLedgerEntryType::USAGE)
->and($ledgerEntry->credits)->toBe(-1)
->and($ledgerEntry->balance_after)->toBe(2);
});
it('records a failed meal image analysis attempt', function () {
$user = User::factory()->create();
$user = User::factory()->create([
'trial_ends_at' => now()->addDay(),
]);
Sanctum::actingAs($user);
Exceptions::fake();
@@ -152,12 +145,11 @@ it('records a failed meal image analysis attempt', function () {
->and($usage->output)->toBeNull()
->and($usage->error_message)->toContain('Provider unavailable');
expect($user->fresh()->ai_credits_balance)->toBe(3);
});
it('requires available credits to analyze a meal image', function () {
it('requires a trial or subscription to analyze a meal image', function () {
$user = User::factory()->create([
'ai_credits_balance' => 0,
'trial_ends_at' => now()->subDay(),
]);
Sanctum::actingAs($user);
@@ -174,11 +166,9 @@ it('requires available credits to analyze a meal image', function () {
'image' => fakeMealAnalysisImage(),
])
->assertStatus(402)
->assertJsonPath('message', __('api.meal_image_analysis.insufficient_credits'))
->assertJsonPath('credits.balance', 0);
->assertJsonPath('message', __('api.meal_image_analysis.subscription_required'));
expect(AiUsage::query()->count())->toBe(0)
->and(CreditLedgerEntry::query()->count())->toBe(0);
expect(AiUsage::query()->count())->toBe(0);
});
it('requires authentication to analyze a meal image', function () {
@@ -191,7 +181,9 @@ it('requires authentication to analyze a meal image', function () {
});
it('validates the analyzed image', function () {
Sanctum::actingAs(User::factory()->create());
Sanctum::actingAs(User::factory()->create([
'trial_ends_at' => now()->addDay(),
]));
$this
->postJson('/api/meal-posts/analyze-image')
+40
View File
@@ -0,0 +1,40 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('grants analysis access during the generic trial', function () {
$user = User::factory()->create([
'trial_ends_at' => now()->addDay(),
]);
expect($user->canAnalyzeMeals())->toBeTrue()
->and($user->analysisAccessLevel())->toBe('trial');
});
it('grants analysis access to active subscribers', function () {
$user = User::factory()->create([
'trial_ends_at' => now()->subDay(),
]);
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_active',
'stripe_status' => 'active',
'stripe_price' => 'price_subscription',
]);
expect($user->canAnalyzeMeals())->toBeTrue()
->and($user->analysisAccessLevel())->toBe('subscribed');
});
it('keeps publishing accounts free without analysis access after the trial', function () {
$user = User::factory()->create([
'trial_ends_at' => now()->subDay(),
]);
expect($user->canAnalyzeMeals())->toBeFalse()
->and($user->analysisAccessLevel())->toBe('free');
});