feat: revenue cat
This commit is contained in:
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\BillingProductPurpose;
|
||||
use App\Models\BillingProduct;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('lists the subscription and certification offers for the mobile app', function () {
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
BillingProduct::create([
|
||||
'name' => 'Certification',
|
||||
'purpose' => BillingProductPurpose::CERTIFICATION,
|
||||
'amount' => 1999,
|
||||
'currency' => 'eur',
|
||||
'stripe_price_id' => 'price_certification',
|
||||
'sort_order' => 20,
|
||||
]);
|
||||
BillingProduct::create([
|
||||
'name' => 'Bowli Plus',
|
||||
'purpose' => BillingProductPurpose::SUBSCRIPTION,
|
||||
'amount' => 999,
|
||||
'currency' => 'eur',
|
||||
'stripe_price_id' => 'price_subscription',
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
$this
|
||||
->getJson('/api/billing/products')
|
||||
->assertOk()
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('data.0.name', 'Bowli Plus')
|
||||
->assertJsonPath('data.0.purpose', BillingProductPurpose::SUBSCRIPTION->value)
|
||||
->assertJsonPath('data.0.formattedAmount', '9,99 EUR')
|
||||
->assertJsonPath('data.1.name', 'Certification')
|
||||
->assertJsonPath('data.1.purpose', BillingProductPurpose::CERTIFICATION->value);
|
||||
});
|
||||
@@ -1,20 +1,16 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\ReviewIdentityVerification;
|
||||
use App\Enums\BillingProductPurpose;
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use App\Enums\PaymentTransactionStatus;
|
||||
use App\Enums\UserRole;
|
||||
use App\Filament\Resources\IdentityVerificationRequests\Pages\ListIdentityVerificationRequests;
|
||||
use App\Listeners\HandleStripeWebhook;
|
||||
use App\Models\BillingProduct;
|
||||
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\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Livewire\Livewire;
|
||||
@@ -168,46 +164,34 @@ it('lets an administrator approve the identity request from filament', function
|
||||
->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 = BillingProduct::create([
|
||||
'name' => 'Certification',
|
||||
'purpose' => BillingProductPurpose::CERTIFICATION,
|
||||
'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' => [
|
||||
'billing_product_id' => $product->getKey(),
|
||||
'billing_product_purpose' => BillingProductPurpose::CERTIFICATION->value,
|
||||
it('grants the lifetime certification entitlement from RevenueCat without a trial', function () {
|
||||
config()->set('billing.revenuecat.secret_key', 'rc-secret');
|
||||
config()->set('billing.revenuecat.certification_entitlement', 'certification');
|
||||
$user = User::factory()->create();
|
||||
$purchaseDate = now()->subDay()->startOfSecond();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
Http::fake([
|
||||
"api.revenuecat.com/v1/subscribers/{$user->id}" => Http::response([
|
||||
'subscriber' => [
|
||||
'entitlements' => [
|
||||
'certification' => [
|
||||
'expires_date' => null,
|
||||
'grace_period_expires_date' => null,
|
||||
'product_identifier' => 'lifetime',
|
||||
'purchase_date' => $purchaseDate->toISOString(),
|
||||
],
|
||||
],
|
||||
'subscriptions' => [],
|
||||
],
|
||||
],
|
||||
];
|
||||
]),
|
||||
]);
|
||||
|
||||
$listener = app(HandleStripeWebhook::class);
|
||||
$listener->handle($event);
|
||||
$firstPurchaseDate = $user->fresh()->certification_purchased_at;
|
||||
$listener->handle($event);
|
||||
$this->postJson('/api/billing/sync')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.certificationPurchased', true)
|
||||
->assertJsonPath('data.analysisAccessLevel', 'free');
|
||||
|
||||
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);
|
||||
expect($user->fresh()->certification_purchased_at?->equalTo($purchaseDate))->toBeTrue()
|
||||
->and($user->fresh()->subscription_is_trial)->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ it('creates a verified administrator with the application command', function ()
|
||||
->and($user->role)->toBe(UserRole::ADMIN)
|
||||
->and($user->locale)->toBe('fr')
|
||||
->and($user->email_verified_at)->not->toBeNull()
|
||||
->and($user->trial_ends_at)->not->toBeNull()
|
||||
->and($user->subscription_expires_at)->toBeNull()
|
||||
->and(Hash::check('Strong-password1', $user->password))->toBeTrue();
|
||||
});
|
||||
|
||||
|
||||
@@ -33,8 +33,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.analysisAccessLevel', 'free')
|
||||
->assertJsonPath('user.canAnalyzeMeals', false)
|
||||
->assertJsonPath('user.physicalActivityLevel', PhysicalActivityLevel::LIGHTLY_ACTIVE->value)
|
||||
->assertJsonPath('user.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value)
|
||||
->assertJsonPath('user.pacePreference', PacePreference::NORMAL->value)
|
||||
@@ -45,8 +45,7 @@ it('sends a verification email when a user registers', function () {
|
||||
$user = User::where('email', 'leon@example.com')->firstOrFail();
|
||||
|
||||
expect($user->hasVerifiedEmail())->toBeFalse()
|
||||
->and($user->trial_ends_at)->not->toBeNull()
|
||||
->and($user->trial_ends_at->isFuture())->toBeTrue()
|
||||
->and($user->subscription_expires_at)->toBeNull()
|
||||
->and($user->terms_accepted_at)->not->toBeNull();
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
|
||||
@@ -27,7 +27,9 @@ function fakeMealAnalysisImage(): UploadedFile
|
||||
|
||||
it('analyzes a meal image into a draft', function () {
|
||||
$user = User::factory()->create([
|
||||
'trial_ends_at' => now()->addDay(),
|
||||
'subscription_expires_at' => now()->addDay(),
|
||||
'subscription_is_trial' => true,
|
||||
'subscription_product_id' => 'monthly',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
@@ -113,7 +115,9 @@ it('analyzes a meal image into a draft', function () {
|
||||
|
||||
it('records a failed meal image analysis attempt', function () {
|
||||
$user = User::factory()->create([
|
||||
'trial_ends_at' => now()->addDay(),
|
||||
'subscription_expires_at' => now()->addDay(),
|
||||
'subscription_is_trial' => true,
|
||||
'subscription_product_id' => 'monthly',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
@@ -147,9 +151,11 @@ it('records a failed meal image analysis attempt', function () {
|
||||
|
||||
});
|
||||
|
||||
it('requires a trial or subscription to analyze a meal image', function () {
|
||||
it('requires an active subscription to analyze a meal image', function () {
|
||||
$user = User::factory()->create([
|
||||
'trial_ends_at' => now()->subDay(),
|
||||
'subscription_expires_at' => now()->subDay(),
|
||||
'subscription_is_trial' => true,
|
||||
'subscription_product_id' => 'monthly',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
@@ -182,7 +188,9 @@ it('requires authentication to analyze a meal image', function () {
|
||||
|
||||
it('validates the analyzed image', function () {
|
||||
Sanctum::actingAs(User::factory()->create([
|
||||
'trial_ends_at' => now()->addDay(),
|
||||
'subscription_expires_at' => now()->addDay(),
|
||||
'subscription_is_trial' => true,
|
||||
'subscription_product_id' => 'monthly',
|
||||
]));
|
||||
|
||||
$this
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use App\Actions\ReviewIdentityVerification;
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use App\Enums\UserRole;
|
||||
use App\Listeners\HandleStripeWebhook;
|
||||
use App\Models\IdentityVerificationRequest;
|
||||
use App\Models\User;
|
||||
use App\Notifications\CertificationReviewedNotification;
|
||||
@@ -11,6 +10,7 @@ use App\Notifications\EngagementReminderNotification;
|
||||
use App\Notifications\NewFollowerNotification;
|
||||
use App\Notifications\PaymentFailedNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
@@ -68,28 +68,36 @@ it('notifies the user when certification is reviewed', function () {
|
||||
Notification::assertSentTo($user, CertificationReviewedNotification::class);
|
||||
});
|
||||
|
||||
it('notifies a user only once when a failed invoice webhook is replayed', function () {
|
||||
it('notifies a user only once when a RevenueCat billing issue is replayed', function () {
|
||||
Notification::fake();
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_failed_payment']);
|
||||
$event = (object) [
|
||||
'payload' => [
|
||||
'id' => 'evt_failed_payment',
|
||||
'type' => 'invoice.payment_failed',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'in_failed_payment',
|
||||
'customer' => 'cus_failed_payment',
|
||||
'amount_due' => 999,
|
||||
'currency' => 'eur',
|
||||
'lines' => ['data' => []],
|
||||
],
|
||||
config()->set('billing.revenuecat.secret_key', 'rc-secret');
|
||||
config()->set('billing.revenuecat.webhook_authorization', 'Bearer rc-webhook');
|
||||
$user = User::factory()->create();
|
||||
Http::fake([
|
||||
"api.revenuecat.com/v1/subscribers/{$user->id}" => Http::response([
|
||||
'subscriber' => [
|
||||
'entitlements' => [],
|
||||
'subscriptions' => [],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
$event = [
|
||||
'event' => [
|
||||
'id' => 'rc-billing-issue',
|
||||
'type' => 'BILLING_ISSUE',
|
||||
'app_user_id' => $user->id,
|
||||
'product_id' => 'monthly',
|
||||
'entitlement_ids' => ['bowli Pro'],
|
||||
'environment' => 'PRODUCTION',
|
||||
],
|
||||
];
|
||||
|
||||
$listener = app(HandleStripeWebhook::class);
|
||||
$listener->handle($event);
|
||||
$listener->handle($event);
|
||||
$this->withHeader('Authorization', 'Bearer rc-webhook')
|
||||
->postJson('/api/webhooks/revenuecat', $event)
|
||||
->assertOk();
|
||||
$this->withHeader('Authorization', 'Bearer rc-webhook')
|
||||
->postJson('/api/webhooks/revenuecat', $event)
|
||||
->assertOk();
|
||||
|
||||
Notification::assertSentToTimes($user, PaymentFailedNotification::class, 1);
|
||||
});
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\BillingProductPurpose;
|
||||
use App\Enums\PaymentTransactionStatus;
|
||||
use App\Enums\PaymentTransactionType;
|
||||
use App\Mail\PaymentInvoiceMail;
|
||||
use App\Models\BillingProduct;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders invoice emails without the retired credit system', function () {
|
||||
$user = User::factory()->create([
|
||||
'locale' => 'fr',
|
||||
]);
|
||||
$product = BillingProduct::query()->create([
|
||||
'name' => 'Bowli Plus',
|
||||
'purpose' => BillingProductPurpose::SUBSCRIPTION,
|
||||
'amount' => 999,
|
||||
'currency' => 'eur',
|
||||
]);
|
||||
$transaction = PaymentTransaction::query()->create([
|
||||
'user_id' => $user->getKey(),
|
||||
'billing_product_id' => $product->getKey(),
|
||||
'type' => PaymentTransactionType::SUBSCRIPTION_INVOICE,
|
||||
'status' => PaymentTransactionStatus::PAID,
|
||||
'amount' => 999,
|
||||
'currency' => 'eur',
|
||||
'stripe_invoice_id' => 'in_bowli',
|
||||
'invoice_url' => 'https://invoice.stripe.test/in_bowli',
|
||||
]);
|
||||
|
||||
$mail = new PaymentInvoiceMail(
|
||||
$user,
|
||||
$transaction->load('billingProduct'),
|
||||
);
|
||||
|
||||
$mail
|
||||
->assertSeeInHtml('Bowli Plus')
|
||||
->assertSeeInHtml('9,99 EUR')
|
||||
->assertDontSeeInHtml('Crédits')
|
||||
->assertDontSeeInHtml('Credits');
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('billing.revenuecat.secret_key', 'rc-secret');
|
||||
config()->set('billing.revenuecat.subscription_entitlement', 'bowli Pro');
|
||||
config()->set('billing.revenuecat.certification_entitlement', 'certification');
|
||||
});
|
||||
|
||||
it('synchronizes a monthly subscription trial from RevenueCat', function () {
|
||||
$user = User::factory()->create();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
Http::fake([
|
||||
"api.revenuecat.com/v1/subscribers/{$user->id}" => Http::response([
|
||||
'subscriber' => [
|
||||
'entitlements' => [
|
||||
'bowli Pro' => [
|
||||
'expires_date' => now()->addDays(7)->toISOString(),
|
||||
'grace_period_expires_date' => null,
|
||||
'product_identifier' => 'monthly',
|
||||
'purchase_date' => now()->toISOString(),
|
||||
],
|
||||
],
|
||||
'subscriptions' => [
|
||||
'monthly' => [
|
||||
'period_type' => 'trial',
|
||||
'store' => 'app_store',
|
||||
],
|
||||
],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/billing/sync')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.hasActiveSubscription', true)
|
||||
->assertJsonPath('data.analysisAccessLevel', 'trial')
|
||||
->assertJsonPath('data.subscriptionProductId', 'monthly')
|
||||
->assertJsonPath('data.certificationPurchased', false);
|
||||
|
||||
expect($user->fresh()->subscription_is_trial)->toBeTrue()
|
||||
->and($user->fresh()->subscription_store)->toBe('app_store');
|
||||
|
||||
Http::assertSent(fn ($request): bool => $request->hasHeader('Authorization', 'Bearer rc-secret'));
|
||||
});
|
||||
|
||||
it('synchronizes annual access and the lifetime certification independently', function () {
|
||||
$user = User::factory()->create();
|
||||
Sanctum::actingAs($user);
|
||||
$certificationPurchasedAt = now()->subDay()->startOfSecond();
|
||||
|
||||
Http::fake([
|
||||
"api.revenuecat.com/v1/subscribers/{$user->id}" => Http::response([
|
||||
'subscriber' => [
|
||||
'entitlements' => [
|
||||
'bowli Pro' => [
|
||||
'expires_date' => now()->addYear()->toISOString(),
|
||||
'grace_period_expires_date' => null,
|
||||
'product_identifier' => 'yearly',
|
||||
'purchase_date' => now()->toISOString(),
|
||||
],
|
||||
'certification' => [
|
||||
'expires_date' => null,
|
||||
'grace_period_expires_date' => null,
|
||||
'product_identifier' => 'lifetime',
|
||||
'purchase_date' => $certificationPurchasedAt->toISOString(),
|
||||
],
|
||||
],
|
||||
'subscriptions' => [
|
||||
'yearly' => [
|
||||
'period_type' => 'normal',
|
||||
'store' => 'play_store',
|
||||
],
|
||||
],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/billing/sync')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.analysisAccessLevel', 'subscribed')
|
||||
->assertJsonPath('data.certificationPurchased', true);
|
||||
|
||||
$user->refresh();
|
||||
|
||||
expect($user->subscription_product_id)->toBe('yearly')
|
||||
->and($user->subscription_is_trial)->toBeFalse()
|
||||
->and($user->certification_purchased_at?->equalTo($certificationPurchasedAt))->toBeTrue();
|
||||
});
|
||||
|
||||
it('removes access when RevenueCat returns expired entitlements', function () {
|
||||
$user = User::factory()->create([
|
||||
'certification_purchased_at' => now()->subMonth(),
|
||||
'subscription_expires_at' => now()->addMonth(),
|
||||
'subscription_is_trial' => true,
|
||||
'subscription_product_id' => 'monthly',
|
||||
'subscription_store' => 'app_store',
|
||||
]);
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
Http::fake([
|
||||
"api.revenuecat.com/v1/subscribers/{$user->id}" => Http::response([
|
||||
'subscriber' => [
|
||||
'entitlements' => [
|
||||
'bowli Pro' => [
|
||||
'expires_date' => now()->subDay()->toISOString(),
|
||||
'grace_period_expires_date' => null,
|
||||
'product_identifier' => 'monthly',
|
||||
],
|
||||
],
|
||||
'subscriptions' => [],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/billing/sync')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.analysisAccessLevel', 'free')
|
||||
->assertJsonPath('data.hasActiveSubscription', false)
|
||||
->assertJsonPath('data.certificationPurchased', false);
|
||||
|
||||
expect($user->fresh()->subscription_product_id)->toBeNull()
|
||||
->and($user->fresh()->subscription_is_trial)->toBeFalse()
|
||||
->and($user->fresh()->certification_purchased_at)->toBeNull();
|
||||
});
|
||||
@@ -5,9 +5,11 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('grants analysis access during the generic trial', function () {
|
||||
it('grants analysis access during a RevenueCat subscription trial', function () {
|
||||
$user = User::factory()->create([
|
||||
'trial_ends_at' => now()->addDay(),
|
||||
'subscription_expires_at' => now()->addDay(),
|
||||
'subscription_is_trial' => true,
|
||||
'subscription_product_id' => 'monthly',
|
||||
]);
|
||||
|
||||
expect($user->canAnalyzeMeals())->toBeTrue()
|
||||
@@ -16,23 +18,20 @@ it('grants analysis access during the generic trial', function () {
|
||||
|
||||
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',
|
||||
'subscription_expires_at' => now()->addYear(),
|
||||
'subscription_is_trial' => false,
|
||||
'subscription_product_id' => 'yearly',
|
||||
]);
|
||||
|
||||
expect($user->canAnalyzeMeals())->toBeTrue()
|
||||
->and($user->analysisAccessLevel())->toBe('subscribed');
|
||||
});
|
||||
|
||||
it('keeps publishing accounts free without analysis access after the trial', function () {
|
||||
it('keeps publishing accounts free without a subscription', function () {
|
||||
$user = User::factory()->create([
|
||||
'trial_ends_at' => now()->subDay(),
|
||||
'subscription_expires_at' => now()->subDay(),
|
||||
'subscription_is_trial' => true,
|
||||
'subscription_product_id' => 'monthly',
|
||||
]);
|
||||
|
||||
expect($user->canAnalyzeMeals())->toBeFalse()
|
||||
|
||||
Reference in New Issue
Block a user