feat: subscriptions
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user