From 29e85589b0633d2e22bd67309bf1adbf51027a03 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Wed, 12 Aug 2026 16:00:11 +0200 Subject: [PATCH] feat: revenue cat --- .env.example | 11 +- .env.prod.example | 9 +- app/Console/Commands/CreateUser.php | 3 - app/Enums/BillingProductPurpose.php | 28 -- app/Enums/PaymentTransactionStatus.php | 36 -- app/Enums/PaymentTransactionType.php | 28 -- .../BillingProductResource.php | 78 ---- .../Pages/CreateBillingProduct.php | 17 - .../Pages/EditBillingProduct.php | 26 -- .../Pages/ListBillingProducts.php | 19 - .../Schemas/BillingProductForm.php | 68 ---- .../Tables/BillingProductsTable.php | 71 ---- .../Pages/ListPaymentTransactions.php | 11 - .../Pages/ViewPaymentTransaction.php | 11 - .../PaymentTransactionResource.php | 61 --- .../Schemas/PaymentTransactionInfolist.php | 111 ------ .../Tables/PaymentTransactionsTable.php | 84 ---- .../Resources/Users/Schemas/UserForm.php | 2 - .../Resources/Users/Schemas/UserInfolist.php | 4 +- .../Resources/Users/Tables/UsersTable.php | 4 +- app/Http/Controllers/AuthController.php | 15 +- app/Http/Controllers/BillingController.php | 138 ------- app/Http/Controllers/RevenueCatController.php | 103 +++++ app/Http/Resources/BillingProductResource.php | 25 -- app/Http/Resources/UserResource.php | 8 +- app/Listeners/HandleStripeWebhook.php | 367 ------------------ app/Mail/PaymentInvoiceMail.php | 64 --- app/Models/BillingProduct.php | 52 --- app/Models/PaymentTransaction.php | 61 --- app/Models/RevenueCatWebhookEvent.php | 31 ++ app/Models/User.php | 30 +- app/Providers/AppServiceProvider.php | 4 - app/Services/PaymentInvoiceEmailer.php | 42 -- app/Services/PaymentTransactionRecorder.php | 96 ----- app/Services/RevenueCatService.php | 151 +++++++ app/Services/StripeBillingProductSyncer.php | 96 ----- composer.json | 1 - composer.lock | 328 +--------------- config/billing.php | 8 +- ...replace_stripe_billing_with_revenuecat.php | 141 +++++++ database/seeders/LegalDocumentSeeder.php | 6 +- lang/en/admin.php | 73 +--- lang/fr/admin.php | 73 +--- .../views/emails/payment-invoice.blade.php | 79 ---- routes/api.php | 12 +- routes/web.php | 2 - tests/Feature/BillingProductsTest.php | 39 -- tests/Feature/CertificationTest.php | 68 ++-- tests/Feature/CreateUserCommandTest.php | 2 +- tests/Feature/EmailVerificationTest.php | 7 +- tests/Feature/MealImageAnalysisTest.php | 18 +- tests/Feature/NotificationDeliveryTest.php | 44 ++- tests/Feature/PaymentInvoiceMailTest.php | 45 --- tests/Feature/RevenueCatBillingTest.php | 132 +++++++ tests/Feature/SubscriptionAccessTest.php | 23 +- 55 files changed, 703 insertions(+), 2363 deletions(-) delete mode 100644 app/Enums/BillingProductPurpose.php delete mode 100644 app/Enums/PaymentTransactionStatus.php delete mode 100644 app/Enums/PaymentTransactionType.php delete mode 100644 app/Filament/Resources/BillingProducts/BillingProductResource.php delete mode 100644 app/Filament/Resources/BillingProducts/Pages/CreateBillingProduct.php delete mode 100644 app/Filament/Resources/BillingProducts/Pages/EditBillingProduct.php delete mode 100644 app/Filament/Resources/BillingProducts/Pages/ListBillingProducts.php delete mode 100644 app/Filament/Resources/BillingProducts/Schemas/BillingProductForm.php delete mode 100644 app/Filament/Resources/BillingProducts/Tables/BillingProductsTable.php delete mode 100644 app/Filament/Resources/PaymentTransactions/Pages/ListPaymentTransactions.php delete mode 100644 app/Filament/Resources/PaymentTransactions/Pages/ViewPaymentTransaction.php delete mode 100644 app/Filament/Resources/PaymentTransactions/PaymentTransactionResource.php delete mode 100644 app/Filament/Resources/PaymentTransactions/Schemas/PaymentTransactionInfolist.php delete mode 100644 app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php delete mode 100644 app/Http/Controllers/BillingController.php create mode 100644 app/Http/Controllers/RevenueCatController.php delete mode 100644 app/Http/Resources/BillingProductResource.php delete mode 100644 app/Listeners/HandleStripeWebhook.php delete mode 100644 app/Mail/PaymentInvoiceMail.php delete mode 100644 app/Models/BillingProduct.php delete mode 100644 app/Models/PaymentTransaction.php create mode 100644 app/Models/RevenueCatWebhookEvent.php delete mode 100644 app/Services/PaymentInvoiceEmailer.php delete mode 100644 app/Services/PaymentTransactionRecorder.php create mode 100644 app/Services/RevenueCatService.php delete mode 100644 app/Services/StripeBillingProductSyncer.php create mode 100644 database/migrations/2026_08_12_123833_replace_stripe_billing_with_revenuecat.php delete mode 100644 resources/views/emails/payment-invoice.blade.php delete mode 100644 tests/Feature/BillingProductsTest.php delete mode 100644 tests/Feature/PaymentInvoiceMailTest.php create mode 100644 tests/Feature/RevenueCatBillingTest.php diff --git a/.env.example b/.env.example index d89cb03..20b390e 100644 --- a/.env.example +++ b/.env.example @@ -84,9 +84,8 @@ MEILISEARCH_KEY=masterKey VITE_APP_NAME="${APP_NAME}" -#Stripe -STRIPE_KEY=your-stripe-key -STRIPE_SECRET=your-stripe-secret -STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret -CASHIER_CURRENCY=eur -BILLING_TRIAL_DAYS=7 +# RevenueCat (secret server key and exact webhook Authorization header) +REVENUECAT_SECRET_KEY= +REVENUECAT_WEBHOOK_AUTHORIZATION= +REVENUECAT_SUBSCRIPTION_ENTITLEMENT="bowli Pro" +REVENUECAT_CERTIFICATION_ENTITLEMENT=certification diff --git a/.env.prod.example b/.env.prod.example index b25fcf5..63b73ad 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -70,11 +70,10 @@ STRAVA_CLIENT_ID= STRAVA_CLIENT_SECRET= STRAVA_SCOPE=read,activity:read -STRIPE_KEY= -STRIPE_SECRET= -STRIPE_WEBHOOK_SECRET= -CASHIER_CURRENCY=eur -BILLING_TRIAL_DAYS=7 +REVENUECAT_SECRET_KEY= +REVENUECAT_WEBHOOK_AUTHORIZATION= +REVENUECAT_SUBSCRIPTION_ENTITLEMENT="bowli Pro" +REVENUECAT_CERTIFICATION_ENTITLEMENT=certification HEALTH_SECRET_TOKEN= HORIZON_HEARTBEAT_URL= diff --git a/app/Console/Commands/CreateUser.php b/app/Console/Commands/CreateUser.php index 148fca5..a7ac427 100644 --- a/app/Console/Commands/CreateUser.php +++ b/app/Console/Commands/CreateUser.php @@ -78,8 +78,6 @@ class CreateUser extends Command return self::FAILURE; } - $trialDays = max(0, (int) config('billing.trial_days', 7)); - $user = User::query()->create([ 'name' => $name, 'email' => $email, @@ -87,7 +85,6 @@ class CreateUser extends Command 'locale' => $locale, 'password' => Hash::make($password), 'role' => UserRole::from($role), - 'trial_ends_at' => $trialDays > 0 ? now()->addDays($trialDays) : null, ]); $this->components->info("User {$user->email} created with ID {$user->getKey()} and role {$user->role->value}."); diff --git a/app/Enums/BillingProductPurpose.php b/app/Enums/BillingProductPurpose.php deleted file mode 100644 index 1e68b86..0000000 --- a/app/Enums/BillingProductPurpose.php +++ /dev/null @@ -1,28 +0,0 @@ - __('enums.billing_product_purpose.subscription'), - self::CERTIFICATION => __('enums.billing_product_purpose.certification'), - }; - } - - public function getColor(): string - { - return match ($this) { - self::SUBSCRIPTION => 'info', - self::CERTIFICATION => 'success', - }; - } -} diff --git a/app/Enums/PaymentTransactionStatus.php b/app/Enums/PaymentTransactionStatus.php deleted file mode 100644 index 668b1cf..0000000 --- a/app/Enums/PaymentTransactionStatus.php +++ /dev/null @@ -1,36 +0,0 @@ - __('enums.payment_transaction_status.pending'), - self::PAID => __('enums.payment_transaction_status.paid'), - self::FAILED => __('enums.payment_transaction_status.failed'), - self::IGNORED => __('enums.payment_transaction_status.ignored'), - self::ERROR => __('enums.payment_transaction_status.error'), - }; - } - - public function getColor(): string - { - return match ($this) { - self::PENDING => 'gray', - self::PAID => 'success', - self::FAILED, self::ERROR => 'danger', - self::IGNORED => 'warning', - }; - } -} diff --git a/app/Enums/PaymentTransactionType.php b/app/Enums/PaymentTransactionType.php deleted file mode 100644 index 301f8e9..0000000 --- a/app/Enums/PaymentTransactionType.php +++ /dev/null @@ -1,28 +0,0 @@ - __('enums.payment_transaction_type.checkout'), - self::SUBSCRIPTION_INVOICE => __('enums.payment_transaction_type.subscription_invoice'), - }; - } - - public function getColor(): string - { - return match ($this) { - self::CHECKOUT => 'success', - self::SUBSCRIPTION_INVOICE => 'info', - }; - } -} diff --git a/app/Filament/Resources/BillingProducts/BillingProductResource.php b/app/Filament/Resources/BillingProducts/BillingProductResource.php deleted file mode 100644 index 9eb5ffe..0000000 --- a/app/Filament/Resources/BillingProducts/BillingProductResource.php +++ /dev/null @@ -1,78 +0,0 @@ -label(__('admin.billing_products.actions.sync_stripe')) - ->icon(Heroicon::OutlinedArrowPath) - ->visible(fn (BillingProduct $record): bool => blank($record->stripe_price_id) && $record->purpose !== null) - ->requiresConfirmation() - ->action(function (BillingProduct $record): void { - app(StripeBillingProductSyncer::class)->sync($record); - - Notification::make() - ->title(__('admin.billing_products.actions.sync_stripe_success')) - ->success() - ->send(); - }); - } - - public static function getPages(): array - { - return [ - 'index' => ListBillingProducts::route('/'), - 'create' => CreateBillingProduct::route('/create'), - 'edit' => EditBillingProduct::route('/{record}/edit'), - ]; - } -} diff --git a/app/Filament/Resources/BillingProducts/Pages/CreateBillingProduct.php b/app/Filament/Resources/BillingProducts/Pages/CreateBillingProduct.php deleted file mode 100644 index 31ad0ee..0000000 --- a/app/Filament/Resources/BillingProducts/Pages/CreateBillingProduct.php +++ /dev/null @@ -1,17 +0,0 @@ -sync($this->record); - } -} diff --git a/app/Filament/Resources/BillingProducts/Pages/EditBillingProduct.php b/app/Filament/Resources/BillingProducts/Pages/EditBillingProduct.php deleted file mode 100644 index 0351fc8..0000000 --- a/app/Filament/Resources/BillingProducts/Pages/EditBillingProduct.php +++ /dev/null @@ -1,26 +0,0 @@ -sync($this->record); - } -} diff --git a/app/Filament/Resources/BillingProducts/Pages/ListBillingProducts.php b/app/Filament/Resources/BillingProducts/Pages/ListBillingProducts.php deleted file mode 100644 index 9945963..0000000 --- a/app/Filament/Resources/BillingProducts/Pages/ListBillingProducts.php +++ /dev/null @@ -1,19 +0,0 @@ -components([ - Section::make(__('admin.billing_products.sections.product')) - ->columns(2) - ->schema([ - TextInput::make('name') - ->label(__('admin.billing_products.fields.name')) - ->required() - ->maxLength(255), - Select::make('purpose') - ->label(__('admin.billing_products.fields.purpose')) - ->options(BillingProductPurpose::class) - ->unique(ignoreRecord: true) - ->required(), - TextInput::make('amount') - ->label(__('admin.billing_products.fields.amount')) - ->helperText(__('admin.billing_products.helpers.amount')) - ->integer() - ->minValue(1) - ->required(), - TextInput::make('currency') - ->label(__('admin.billing_products.fields.currency')) - ->default('eur') - ->required() - ->maxLength(3), - TextInput::make('sort_order') - ->label(__('admin.billing_products.fields.sort_order')) - ->integer() - ->default(0) - ->required(), - Textarea::make('description') - ->label(__('admin.billing_products.fields.description')) - ->rows(3) - ->columnSpanFull(), - Toggle::make('is_active') - ->label(__('admin.billing_products.fields.is_active')) - ->default(true), - ]), - Section::make(__('admin.billing_products.sections.stripe')) - ->columns(2) - ->schema([ - TextInput::make('stripe_product_id') - ->label(__('admin.billing_products.fields.stripe_product_id')) - ->maxLength(255), - TextInput::make('stripe_price_id') - ->label(__('admin.billing_products.fields.stripe_price_id')) - ->unique(ignoreRecord: true) - ->maxLength(255), - ]), - ]); - } -} diff --git a/app/Filament/Resources/BillingProducts/Tables/BillingProductsTable.php b/app/Filament/Resources/BillingProducts/Tables/BillingProductsTable.php deleted file mode 100644 index e0766f2..0000000 --- a/app/Filament/Resources/BillingProducts/Tables/BillingProductsTable.php +++ /dev/null @@ -1,71 +0,0 @@ -defaultSort('sort_order') - ->columns([ - TextColumn::make('name') - ->label(__('admin.billing_products.fields.name')) - ->searchable() - ->sortable(), - TextColumn::make('purpose') - ->label(__('admin.billing_products.fields.purpose')) - ->badge() - ->sortable(), - TextColumn::make('amount') - ->label(__('admin.billing_products.fields.amount')) - ->money(fn ($record): string => $record->currency, divideBy: 100) - ->sortable(), - TextColumn::make('stripe_price_id') - ->label(__('admin.billing_products.fields.stripe_price_id')) - ->copyable() - ->searchable() - ->toggleable(), - IconColumn::make('is_active') - ->label(__('admin.billing_products.fields.is_active')) - ->boolean() - ->sortable(), - TextColumn::make('sort_order') - ->label(__('admin.billing_products.fields.sort_order')) - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('created_at') - ->label(__('admin.billing_products.fields.created_at')) - ->dateTime() - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), - ]) - ->filters([ - SelectFilter::make('purpose') - ->label(__('admin.billing_products.fields.purpose')) - ->options(BillingProductPurpose::class), - TernaryFilter::make('is_active') - ->label(__('admin.billing_products.fields.is_active')), - ]) - ->recordActions([ - BillingProductResource::syncStripeAction(), - EditAction::make(), - ]) - ->toolbarActions([ - BulkActionGroup::make([ - DeleteBulkAction::make(), - ]), - ]); - } -} diff --git a/app/Filament/Resources/PaymentTransactions/Pages/ListPaymentTransactions.php b/app/Filament/Resources/PaymentTransactions/Pages/ListPaymentTransactions.php deleted file mode 100644 index ecdf574..0000000 --- a/app/Filament/Resources/PaymentTransactions/Pages/ListPaymentTransactions.php +++ /dev/null @@ -1,11 +0,0 @@ - ListPaymentTransactions::route('/'), - 'view' => ViewPaymentTransaction::route('/{record}'), - ]; - } -} diff --git a/app/Filament/Resources/PaymentTransactions/Schemas/PaymentTransactionInfolist.php b/app/Filament/Resources/PaymentTransactions/Schemas/PaymentTransactionInfolist.php deleted file mode 100644 index d0b680a..0000000 --- a/app/Filament/Resources/PaymentTransactions/Schemas/PaymentTransactionInfolist.php +++ /dev/null @@ -1,111 +0,0 @@ -components([ - Section::make(__('admin.payment_transactions.sections.summary')) - ->columns(3) - ->schema([ - TextEntry::make('id') - ->label(__('admin.payment_transactions.fields.id')) - ->copyable(), - TextEntry::make('status') - ->label(__('admin.payment_transactions.fields.status')) - ->badge(), - TextEntry::make('type') - ->label(__('admin.payment_transactions.fields.type')) - ->badge(), - TextEntry::make('user.email') - ->label(__('admin.payment_transactions.fields.user')) - ->placeholder(__('admin.payment_transactions.placeholders.empty')) - ->copyable(), - TextEntry::make('billingProduct.name') - ->label(__('admin.payment_transactions.fields.billing_product')) - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextEntry::make('amount') - ->label(__('admin.payment_transactions.fields.amount')) - ->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100) - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextEntry::make('processed_at') - ->label(__('admin.payment_transactions.fields.processed_at')) - ->dateTime() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextEntry::make('invoice_email_sent_at') - ->label(__('admin.payment_transactions.fields.invoice_email_sent_at')) - ->dateTime() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - ]), - Section::make(__('admin.payment_transactions.sections.stripe')) - ->columns(2) - ->schema([ - TextEntry::make('stripe_event_type') - ->label(__('admin.payment_transactions.fields.stripe_event_type')) - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextEntry::make('stripe_event_id') - ->label(__('admin.payment_transactions.fields.stripe_event_id')) - ->copyable() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextEntry::make('stripe_customer_id') - ->label(__('admin.payment_transactions.fields.stripe_customer_id')) - ->copyable() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextEntry::make('stripe_checkout_session_id') - ->label(__('admin.payment_transactions.fields.stripe_checkout_session_id')) - ->copyable() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextEntry::make('stripe_invoice_id') - ->label(__('admin.payment_transactions.fields.stripe_invoice_id')) - ->copyable() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextEntry::make('stripe_payment_intent_id') - ->label(__('admin.payment_transactions.fields.stripe_payment_intent_id')) - ->copyable() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextEntry::make('stripe_subscription_id') - ->label(__('admin.payment_transactions.fields.stripe_subscription_id')) - ->copyable() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextEntry::make('stripe_price_id') - ->label(__('admin.payment_transactions.fields.stripe_price_id')) - ->copyable() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextEntry::make('invoice_url') - ->label(__('admin.payment_transactions.fields.invoice_url')) - ->copyable() - ->url(fn ($state): ?string => $state) - ->openUrlInNewTab() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextEntry::make('invoice_pdf_url') - ->label(__('admin.payment_transactions.fields.invoice_pdf_url')) - ->copyable() - ->url(fn ($state): ?string => $state) - ->openUrlInNewTab() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - ]), - Section::make(__('admin.payment_transactions.sections.error')) - ->schema([ - TextEntry::make('error_message') - ->label(__('admin.payment_transactions.fields.error_message')) - ->placeholder(__('admin.payment_transactions.placeholders.empty')) - ->columnSpanFull(), - ]), - Section::make(__('admin.payment_transactions.sections.payload')) - ->schema([ - TextEntry::make('payload') - ->label(__('admin.payment_transactions.fields.payload')) - ->formatStateUsing(fn (mixed $state): string => json_encode($state ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}') - ->fontFamily('mono') - ->columnSpanFull(), - ]), - ]); - } -} diff --git a/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php b/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php deleted file mode 100644 index cab1c64..0000000 --- a/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php +++ /dev/null @@ -1,84 +0,0 @@ -defaultSort('created_at', 'desc') - ->columns([ - TextColumn::make('created_at') - ->label(__('admin.payment_transactions.fields.created_at')) - ->dateTime() - ->sortable(), - TextColumn::make('status') - ->label(__('admin.payment_transactions.fields.status')) - ->badge() - ->sortable(), - TextColumn::make('type') - ->label(__('admin.payment_transactions.fields.type')) - ->badge() - ->sortable(), - TextColumn::make('user.email') - ->label(__('admin.payment_transactions.fields.user')) - ->searchable() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextColumn::make('billingProduct.name') - ->label(__('admin.payment_transactions.fields.billing_product')) - ->searchable() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextColumn::make('amount') - ->label(__('admin.payment_transactions.fields.amount')) - ->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100) - ->sortable() - ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextColumn::make('stripe_event_type') - ->label(__('admin.payment_transactions.fields.stripe_event_type')) - ->toggleable(), - TextColumn::make('stripe_checkout_session_id') - ->label(__('admin.payment_transactions.fields.stripe_checkout_session_id_short')) - ->copyable() - ->searchable() - ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('stripe_invoice_id') - ->label(__('admin.payment_transactions.fields.stripe_invoice_id_short')) - ->copyable() - ->searchable() - ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('stripe_price_id') - ->label(__('admin.payment_transactions.fields.stripe_price_id')) - ->copyable() - ->searchable() - ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('error_message') - ->label(__('admin.payment_transactions.fields.error_message')) - ->limit(60) - ->toggleable(), - TextColumn::make('invoice_email_sent_at') - ->label(__('admin.payment_transactions.fields.invoice_email_sent_at')) - ->dateTime() - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), - ]) - ->filters([ - SelectFilter::make('status') - ->label(__('admin.payment_transactions.fields.status')) - ->options(PaymentTransactionStatus::class), - SelectFilter::make('type') - ->label(__('admin.payment_transactions.fields.type')) - ->options(PaymentTransactionType::class), - ]) - ->recordActions([ - ViewAction::make(), - ]); - } -} diff --git a/app/Filament/Resources/Users/Schemas/UserForm.php b/app/Filament/Resources/Users/Schemas/UserForm.php index c189b7f..43ccdd2 100644 --- a/app/Filament/Resources/Users/Schemas/UserForm.php +++ b/app/Filament/Resources/Users/Schemas/UserForm.php @@ -63,8 +63,6 @@ class UserForm ->autocomplete('new-password'), DateTimePicker::make('email_verified_at') ->label(__('admin.users.fields.email_verified_at')), - DateTimePicker::make('trial_ends_at') - ->label(__('admin.users.fields.trial_ends_at')), ]), Section::make(__('admin.users.sections.profile')) ->columns(2) diff --git a/app/Filament/Resources/Users/Schemas/UserInfolist.php b/app/Filament/Resources/Users/Schemas/UserInfolist.php index 41e3e1c..52a9703 100644 --- a/app/Filament/Resources/Users/Schemas/UserInfolist.php +++ b/app/Filament/Resources/Users/Schemas/UserInfolist.php @@ -39,8 +39,8 @@ class UserInfolist ->label(__('admin.users.fields.certification_purchased_at')) ->dateTime() ->placeholder(__('admin.users.placeholders.empty')), - TextEntry::make('trial_ends_at') - ->label(__('admin.users.fields.trial_ends_at')) + TextEntry::make('subscription_expires_at') + ->label(__('admin.users.fields.subscription_expires_at')) ->dateTime() ->placeholder(__('admin.users.placeholders.empty')), TextEntry::make('suspended_at') diff --git a/app/Filament/Resources/Users/Tables/UsersTable.php b/app/Filament/Resources/Users/Tables/UsersTable.php index 790a5fa..e0b5e35 100644 --- a/app/Filament/Resources/Users/Tables/UsersTable.php +++ b/app/Filament/Resources/Users/Tables/UsersTable.php @@ -51,8 +51,8 @@ class UsersTable ->sortable() ->placeholder(__('admin.users.placeholders.not_verified')) ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('trial_ends_at') - ->label(__('admin.users.fields.trial_ends_at')) + TextColumn::make('subscription_expires_at') + ->label(__('admin.users.fields.subscription_expires_at')) ->dateTime() ->sortable() ->placeholder(__('admin.users.placeholders.empty')) diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index d3631e3..66e1d08 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -11,6 +11,7 @@ use App\Http\Requests\UpdatePasswordRequest; use App\Http\Requests\UpdateUserRequest; use App\Http\Resources\UserResource; use App\Models\User; +use App\Services\RevenueCatService; use Illuminate\Auth\Events\PasswordReset; use Illuminate\Auth\Events\Verified; use Illuminate\Http\JsonResponse; @@ -302,7 +303,7 @@ class AuthController extends Controller ]); } - public function destroy(Request $request): JsonResponse + public function destroy(Request $request, RevenueCatService $revenueCat): JsonResponse { $user = $request->user(); $identityVerificationRequest = $user->identityVerificationRequest()->first(); @@ -312,6 +313,12 @@ class AuthController extends Controller ->values() ->all(); + try { + $revenueCat->deleteCustomer($user); + } catch (Throwable $exception) { + report($exception); + } + DB::transaction(function () use ($user): void { $user->tokens()->delete(); $user->notifications()->delete(); @@ -361,12 +368,6 @@ class AuthController extends Controller $userAttributes['password'] = Hash::make($data['password']); $userAttributes['avatar_url'] = $avatarPath; $userAttributes['terms_accepted_at'] = now(); - $trialDays = max(0, (int) config('billing.trial_days', 7)); - - if ($trialDays > 0) { - $userAttributes['trial_ends_at'] = now()->addDays($trialDays); - } - $user = User::create($userAttributes); try { diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php deleted file mode 100644 index 810920c..0000000 --- a/app/Http/Controllers/BillingController.php +++ /dev/null @@ -1,138 +0,0 @@ -active() - ->whereNotNull('purpose') - ->whereNotNull('stripe_price_id') - ->orderBy('sort_order') - ->orderBy('amount') - ->get(); - - return BillingProductResource::collection($products); - } - - public function checkout( - Request $request, - BillingProduct $billingProduct, - PaymentTransactionRecorder $transactions, - ): JsonResponse { - abort_unless($billingProduct->is_active, 404); - abort_unless($billingProduct->purpose !== null, 404); - abort_unless(filled($billingProduct->stripe_price_id), 404); - - $user = $request->user(); - $this->ensureStripeCustomer($user); - - $metadata = [ - 'billing_product_id' => $billingProduct->getKey(), - 'billing_product_purpose' => $billingProduct->purpose->value, - ]; - $sessionOptions = [ - 'success_url' => $this->redirectUrl('success'), - 'cancel_url' => $this->redirectUrl('cancel'), - 'metadata' => $metadata, - 'client_reference_id' => $billingProduct->getKey(), - ]; - - if ($billingProduct->purpose === BillingProductPurpose::SUBSCRIPTION) { - if ($user->subscribed('default')) { - return response()->json([ - 'message' => __('api.billing.active_subscription_exists'), - ], 409); - } - - $checkout = $user - ->newSubscription('default', $billingProduct->stripe_price_id) - ->withMetadata($metadata) - ->checkout($sessionOptions); - } else { - if ($user->hasPurchasedCertification() || $user->account_verified_at !== null) { - return response()->json([ - 'message' => __('api.billing.certification_already_purchased'), - ], 409); - } - - $sessionOptions['invoice_creation'] = [ - 'enabled' => true, - 'invoice_data' => [ - 'metadata' => $metadata, - ], - ]; - - $checkout = $user->checkout([ - $billingProduct->stripe_price_id => 1, - ], $sessionOptions); - } - - $session = $checkout->asStripeCheckoutSession(); - - $transactions->recordCheckoutSession( - user: $user, - product: $billingProduct, - stripeCheckoutSessionId: $session->id, - stripeCustomerId: $session->customer, - amount: $session->amount_total, - currency: $session->currency, - ); - - return response()->json([ - 'id' => $session->id, - 'url' => $session->url, - ]); - } - - public function portal(Request $request): JsonResponse - { - $user = $request->user(); - $this->ensureStripeCustomer($user); - - if (! $user->subscribed('default')) { - return response()->json([ - 'message' => __('api.billing.no_active_subscription'), - ], 409); - } - - $portalUrl = $user->billingPortalUrl($this->redirectUrl('portal')); - - return response()->json([ - 'url' => $portalUrl, - ]); - } - - public function redirectToMobileApp(string $status): RedirectResponse - { - abort_unless(in_array($status, ['success', 'cancel', 'portal'], true), 404); - - return redirect()->away(MobileDeepLink::to("billing/{$status}")); - } - - private function ensureStripeCustomer(mixed $user): void - { - if (! is_object($user) || ! method_exists($user, 'createOrGetStripeCustomer')) { - return; - } - - $user->createOrGetStripeCustomer(); - } - - private function redirectUrl(string $status): string - { - return route('billing.web-return', ['status' => $status]); - } -} diff --git a/app/Http/Controllers/RevenueCatController.php b/app/Http/Controllers/RevenueCatController.php new file mode 100644 index 0000000..7b536e3 --- /dev/null +++ b/app/Http/Controllers/RevenueCatController.php @@ -0,0 +1,103 @@ +sync($request->user())); + } + + public function webhook(Request $request, RevenueCatService $revenueCat): JsonResponse + { + $expectedAuthorization = trim((string) config('billing.revenuecat.webhook_authorization')); + $providedAuthorization = (string) $request->header('Authorization'); + + abort_if( + $expectedAuthorization === '' || ! hash_equals($expectedAuthorization, $providedAuthorization), + 401, + ); + + $event = $request->input('event'); + abort_unless(is_array($event), 422); + + $eventId = $this->stringValue($event['id'] ?? null); + $eventType = $this->stringValue($event['type'] ?? null); + abort_unless($eventId && $eventType, 422); + + $webhookEvent = RevenueCatWebhookEvent::query()->firstOrCreate([ + 'event_id' => $eventId, + ], [ + 'type' => $eventType, + 'app_user_id' => $this->stringValue($event['app_user_id'] ?? null), + 'product_id' => $this->stringValue($event['product_id'] ?? null), + 'entitlement_ids' => $this->stringList($event['entitlement_ids'] ?? []), + 'environment' => $this->stringValue($event['environment'] ?? null), + 'payload' => $event, + ]); + + if ($webhookEvent->processed_at !== null) { + return response()->json(['received' => true]); + } + + $user = $this->userFromEvent($event); + + if ($user) { + $revenueCat->sync($user); + + if ($eventType === 'BILLING_ISSUE') { + $user->notify( + (new PaymentFailedNotification)->locale($user->preferredLocale()), + ); + } + } + + $webhookEvent->forceFill(['processed_at' => now()])->save(); + + return response()->json(['received' => true]); + } + + /** + * @param array $event + */ + private function userFromEvent(array $event): ?User + { + $identifiers = collect([ + $event['app_user_id'] ?? null, + $event['original_app_user_id'] ?? null, + ]) + ->merge(Arr::wrap($event['aliases'] ?? [])) + ->merge(Arr::wrap($event['transferred_to'] ?? [])) + ->filter(fn (mixed $identifier): bool => is_string($identifier) && $identifier !== '') + ->unique() + ->values(); + + return User::query()->whereIn('id', $identifiers)->first(); + } + + /** + * @return list + */ + private function stringList(mixed $value): array + { + return collect(Arr::wrap($value)) + ->filter(fn (mixed $item): bool => is_string($item) && $item !== '') + ->values() + ->all(); + } + + private function stringValue(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/app/Http/Resources/BillingProductResource.php b/app/Http/Resources/BillingProductResource.php deleted file mode 100644 index 5407048..0000000 --- a/app/Http/Resources/BillingProductResource.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'name' => $this->name, - 'description' => $this->description, - 'purpose' => $this->purpose, - 'amount' => $this->amount, - 'currency' => $this->currency, - 'formattedAmount' => $this->formattedAmount(), - ]; - } -} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index e80c203..b7cf7c5 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -30,8 +30,12 @@ class UserResource extends JsonResource 'certificationPurchased' => $this->hasPurchasedCertification(), 'canAnalyzeMeals' => $this->canAnalyzeMeals(), 'analysisAccessLevel' => $this->analysisAccessLevel(), - 'trialEndsAt' => $this->trial_ends_at?->toISOString(), - 'hasActiveSubscription' => $this->subscribed('default'), + 'trialEndsAt' => $this->analysisAccessLevel() === 'trial' + ? $this->subscription_expires_at?->toISOString() + : null, + 'subscriptionExpiresAt' => $this->subscription_expires_at?->toISOString(), + 'subscriptionProductId' => $this->subscription_product_id, + 'hasActiveSubscription' => $this->hasActiveSubscription(), 'suspendedAt' => $this->suspended_at?->toISOString(), 'physicalActivityLevel' => $this->physical_activity_level, 'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(), diff --git a/app/Listeners/HandleStripeWebhook.php b/app/Listeners/HandleStripeWebhook.php deleted file mode 100644 index cfddec1..0000000 --- a/app/Listeners/HandleStripeWebhook.php +++ /dev/null @@ -1,367 +0,0 @@ -} $event - */ - public function handle(object $event): void - { - $payload = $event->payload ?? []; - - match ($payload['type'] ?? null) { - 'checkout.session.completed' => $this->handleCheckoutSessionCompleted($payload), - 'checkout.session.expired' => $this->handleCheckoutSessionExpired($payload), - 'invoice.paid', 'invoice.payment_succeeded' => $this->handleInvoicePaid($payload), - 'invoice.payment_failed' => $this->handleInvoicePaymentFailed($payload), - default => null, - }; - } - - /** - * @param array $payload - */ - private function handleCheckoutSessionCompleted(array $payload): void - { - $session = $payload['data']['object'] ?? []; - $sessionId = $this->stringValue($session['id'] ?? null); - - if (! $sessionId) { - return; - } - - $product = $this->productFromMetadata($session['metadata'] ?? null); - $user = $this->userFromCustomer($session['customer'] ?? null); - $paymentStatus = $this->stringValue($session['payment_status'] ?? null); - $expectedMode = $product?->purpose === BillingProductPurpose::SUBSCRIPTION - ? 'subscription' - : 'payment'; - $isPaid = $paymentStatus === 'paid' - || ($expectedMode === 'subscription' && $paymentStatus === 'no_payment_required'); - - if (! $product || ! $user || ($session['mode'] ?? null) !== $expectedMode || ! $isPaid) { - $this->transactions->recordCheckoutSession( - user: $user, - product: $product, - stripeCheckoutSessionId: $sessionId, - status: $product && $user ? PaymentTransactionStatus::IGNORED : PaymentTransactionStatus::ERROR, - stripeEventId: $this->stringValue($payload['id'] ?? null), - stripeEventType: $this->stringValue($payload['type'] ?? null), - stripeCustomerId: $this->stringValue($session['customer'] ?? null), - stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), - amount: $this->intValue($session['amount_total'] ?? null), - currency: $this->stringValue($session['currency'] ?? null), - errorMessage: 'Checkout session does not match an eligible paid billing product.', - payload: $session, - ); - - return; - } - - if ($product->purpose === BillingProductPurpose::CERTIFICATION - && $user->certification_purchased_at === null) { - $user->forceFill([ - 'certification_purchased_at' => now(), - ])->save(); - } - - $invoice = $this->retrieveInvoice($this->stringValue($session['invoice'] ?? null)); - $transaction = $this->transactions->recordCheckoutSession( - user: $user, - product: $product, - stripeCheckoutSessionId: $sessionId, - status: PaymentTransactionStatus::PAID, - stripeEventId: $this->stringValue($payload['id'] ?? null), - stripeEventType: $this->stringValue($payload['type'] ?? null), - stripeCustomerId: $this->stringValue($session['customer'] ?? null), - stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), - amount: $this->intValue($session['amount_total'] ?? null), - currency: $this->stringValue($session['currency'] ?? null), - invoiceUrl: $invoice ? $this->stringValue($invoice['hosted_invoice_url'] ?? null) : null, - invoicePdfUrl: $invoice ? $this->stringValue($invoice['invoice_pdf'] ?? null) : null, - payload: $session, - ); - - $this->invoiceEmails->sendIfAvailable($transaction); - } - - /** - * @param array $payload - */ - private function handleCheckoutSessionExpired(array $payload): void - { - $session = $payload['data']['object'] ?? []; - $sessionId = $this->stringValue($session['id'] ?? null); - - if (! $sessionId) { - return; - } - - $transaction = $this->transactions->recordCheckoutSession( - user: $this->userFromCustomer($session['customer'] ?? null), - product: $this->productFromMetadata($session['metadata'] ?? null), - stripeCheckoutSessionId: $sessionId, - status: PaymentTransactionStatus::FAILED, - stripeEventId: $this->stringValue($payload['id'] ?? null), - stripeEventType: $this->stringValue($payload['type'] ?? null), - stripeCustomerId: $this->stringValue($session['customer'] ?? null), - stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), - amount: $this->intValue($session['amount_total'] ?? null), - currency: $this->stringValue($session['currency'] ?? null), - errorMessage: 'Checkout session expired before payment.', - payload: $session, - ); - - $this->notifyPaymentFailure($transaction); - } - - /** - * @param array $payload - */ - private function handleInvoicePaid(array $payload): void - { - $invoice = $payload['data']['object'] ?? []; - $invoiceId = $this->stringValue($invoice['id'] ?? null); - - if (! $invoiceId) { - return; - } - - $matchedProduct = false; - - foreach (($invoice['lines']['data'] ?? []) as $line) { - $priceId = $this->linePriceId($line); - $product = $priceId - ? BillingProduct::query() - ->where('purpose', BillingProductPurpose::SUBSCRIPTION) - ->where('stripe_price_id', $priceId) - ->first() - : null; - - if (! $product) { - continue; - } - - $matchedProduct = true; - $transaction = $this->transactions->recordInvoice( - user: $this->userFromCustomer($invoice['customer'] ?? null), - product: $product, - stripeInvoiceId: $invoiceId, - stripePriceId: $priceId, - status: PaymentTransactionStatus::PAID, - stripeEventId: $this->stringValue($payload['id'] ?? null), - stripeEventType: $this->stringValue($payload['type'] ?? null), - stripeCustomerId: $this->stringValue($invoice['customer'] ?? null), - stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null), - stripeSubscriptionId: $this->invoiceSubscriptionId($invoice), - amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null), - currency: $this->stringValue($invoice['currency'] ?? null), - invoiceUrl: $this->stringValue($invoice['hosted_invoice_url'] ?? null), - invoicePdfUrl: $this->stringValue($invoice['invoice_pdf'] ?? null), - payload: $invoice, - ); - - $this->invoiceEmails->sendIfAvailable($transaction); - } - - if (! $matchedProduct) { - $this->transactions->recordInvoice( - user: $this->userFromCustomer($invoice['customer'] ?? null), - product: null, - stripeInvoiceId: $invoiceId, - stripePriceId: null, - status: PaymentTransactionStatus::IGNORED, - stripeEventId: $this->stringValue($payload['id'] ?? null), - stripeEventType: $this->stringValue($payload['type'] ?? null), - stripeCustomerId: $this->stringValue($invoice['customer'] ?? null), - stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null), - stripeSubscriptionId: $this->invoiceSubscriptionId($invoice), - amount: $this->intValue($invoice['amount_paid'] ?? null), - currency: $this->stringValue($invoice['currency'] ?? null), - errorMessage: 'Invoice does not contain the active subscription product.', - payload: $invoice, - ); - } - } - - /** - * @param array $payload - */ - private function handleInvoicePaymentFailed(array $payload): void - { - $invoice = $payload['data']['object'] ?? []; - $invoiceId = $this->stringValue($invoice['id'] ?? null); - - if (! $invoiceId) { - return; - } - - $lines = $invoice['lines']['data'] ?? [null]; - $notificationUser = null; - $shouldNotify = false; - - foreach ($lines ?: [null] as $line) { - $priceId = is_array($line) ? $this->linePriceId($line) : null; - $product = $priceId - ? BillingProduct::query()->where('stripe_price_id', $priceId)->first() - : null; - - $transaction = $this->transactions->recordInvoice( - user: $this->userFromCustomer($invoice['customer'] ?? null), - product: $product, - stripeInvoiceId: $invoiceId, - stripePriceId: $priceId, - status: PaymentTransactionStatus::FAILED, - stripeEventId: $this->stringValue($payload['id'] ?? null), - stripeEventType: $this->stringValue($payload['type'] ?? null), - stripeCustomerId: $this->stringValue($invoice['customer'] ?? null), - stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null), - stripeSubscriptionId: $this->invoiceSubscriptionId($invoice), - amount: $this->intValue( - is_array($line) - ? ($line['amount'] ?? $invoice['amount_due'] ?? null) - : ($invoice['amount_due'] ?? null) - ), - currency: $this->stringValue($invoice['currency'] ?? null), - errorMessage: 'Invoice payment failed.', - payload: $invoice, - ); - - if ($this->shouldNotifyPaymentFailure($transaction)) { - $notificationUser ??= $transaction->user; - $shouldNotify = true; - } - } - - if ($shouldNotify && $notificationUser) { - $notificationUser->notify( - (new PaymentFailedNotification) - ->locale($notificationUser->preferredLocale()), - ); - } - } - - private function notifyPaymentFailure(PaymentTransaction $transaction): void - { - if (! $this->shouldNotifyPaymentFailure($transaction)) { - return; - } - - $user = $transaction->user; - - if (! $user) { - return; - } - - $user->notify( - (new PaymentFailedNotification) - ->locale($user->preferredLocale()), - ); - } - - private function shouldNotifyPaymentFailure(PaymentTransaction $transaction): bool - { - return $transaction->wasRecentlyCreated - || $transaction->wasChanged('status') - || $transaction->wasChanged('user_id'); - } - - private function userFromCustomer(mixed $customer): ?User - { - if (! is_string($customer) || $customer === '') { - return null; - } - - return User::query()->where('stripe_id', $customer)->first(); - } - - private function productFromMetadata(mixed $metadata): ?BillingProduct - { - if (! is_array($metadata) - || ! is_string($metadata['billing_product_id'] ?? null) - || ! is_string($metadata['billing_product_purpose'] ?? null)) { - return null; - } - - $purpose = BillingProductPurpose::tryFrom($metadata['billing_product_purpose']); - - if (! $purpose) { - return null; - } - - return BillingProduct::query() - ->whereKey($metadata['billing_product_id']) - ->where('purpose', $purpose) - ->first(); - } - - /** - * @param array $line - */ - private function linePriceId(array $line): ?string - { - return $this->stringValue( - $line['price']['id'] - ?? $line['pricing']['price_details']['price'] - ?? null - ); - } - - /** - * @param array $invoice - */ - private function invoiceSubscriptionId(array $invoice): ?string - { - return $this->stringValue( - $invoice['subscription'] - ?? $invoice['parent']['subscription_details']['subscription'] - ?? null - ); - } - - private function stringValue(mixed $value): ?string - { - return is_string($value) && $value !== '' ? $value : null; - } - - private function intValue(mixed $value): ?int - { - return is_numeric($value) ? (int) $value : null; - } - - /** - * @return array|null - */ - private function retrieveInvoice(?string $invoiceId): ?array - { - if (! $invoiceId) { - return null; - } - - try { - return Cashier::stripe() - ->invoices - ->retrieve($invoiceId) - ->toArray(); - } catch (\Throwable) { - return null; - } - } -} diff --git a/app/Mail/PaymentInvoiceMail.php b/app/Mail/PaymentInvoiceMail.php deleted file mode 100644 index a70101b..0000000 --- a/app/Mail/PaymentInvoiceMail.php +++ /dev/null @@ -1,64 +0,0 @@ -mailLocale()), - ); - } - - public function content(): Content - { - return new Content( - view: 'emails.payment-invoice', - with: [ - 'amount' => $this->formattedAmount(), - 'invoicePdfUrl' => $this->transaction->invoice_pdf_url, - 'invoiceUrl' => $this->transaction->invoice_url, - 'locale' => $this->mailLocale(), - 'productName' => $this->transaction->billingProduct?->name, - 'userName' => $this->user->name, - ], - ); - } - - public function attachments(): array - { - return []; - } - - private function formattedAmount(): string - { - if ($this->transaction->amount === null) { - return trans('mail.payment_invoice.unknown_amount', [], $this->mailLocale()); - } - - return number_format($this->transaction->amount / 100, 2, ',', ' ') - .' ' - .strtoupper($this->transaction->currency ?: 'eur'); - } - - private function mailLocale(): string - { - return $this->user->preferredLocale() ?? 'en'; - } -} diff --git a/app/Models/BillingProduct.php b/app/Models/BillingProduct.php deleted file mode 100644 index 39d1120..0000000 --- a/app/Models/BillingProduct.php +++ /dev/null @@ -1,52 +0,0 @@ -hasMany(PaymentTransaction::class); - } - - public function scopeActive(Builder $query): Builder - { - return $query->where('is_active', true); - } - - public function formattedAmount(): string - { - return number_format($this->amount / 100, 2, ',', ' ').' '.strtoupper($this->currency); - } - - protected function casts(): array - { - return [ - 'purpose' => BillingProductPurpose::class, - 'amount' => 'integer', - 'is_active' => 'boolean', - 'sort_order' => 'integer', - ]; - } -} diff --git a/app/Models/PaymentTransaction.php b/app/Models/PaymentTransaction.php deleted file mode 100644 index 9ee0257..0000000 --- a/app/Models/PaymentTransaction.php +++ /dev/null @@ -1,61 +0,0 @@ - */ - use HasFactory, HasUlids; - - protected $fillable = [ - 'user_id', - 'billing_product_id', - 'type', - 'status', - 'stripe_event_id', - 'stripe_event_type', - 'stripe_customer_id', - 'stripe_checkout_session_id', - 'stripe_invoice_id', - 'stripe_payment_intent_id', - 'stripe_subscription_id', - 'stripe_price_id', - 'amount', - 'currency', - 'invoice_url', - 'invoice_pdf_url', - 'invoice_email_sent_at', - 'error_message', - 'payload', - 'processed_at', - ]; - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - public function billingProduct(): BelongsTo - { - return $this->belongsTo(BillingProduct::class); - } - - protected function casts(): array - { - return [ - 'type' => PaymentTransactionType::class, - 'status' => PaymentTransactionStatus::class, - 'amount' => 'integer', - 'invoice_email_sent_at' => 'datetime', - 'payload' => 'array', - 'processed_at' => 'datetime', - ]; - } -} diff --git a/app/Models/RevenueCatWebhookEvent.php b/app/Models/RevenueCatWebhookEvent.php new file mode 100644 index 0000000..1be0030 --- /dev/null +++ b/app/Models/RevenueCatWebhookEvent.php @@ -0,0 +1,31 @@ + 'array', + 'payload' => 'array', + 'processed_at' => 'datetime', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 0d7521b..2df04a4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -25,13 +25,12 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Storage; -use Laravel\Cashier\Billable; use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocalePreference, MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ - use Billable, HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes; + use HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes; protected $attributes = [ 'social_notifications_enabled' => true, @@ -55,7 +54,10 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale 'bio', 'account_verified_at', 'certification_purchased_at', - 'trial_ends_at', + 'subscription_product_id', + 'subscription_store', + 'subscription_expires_at', + 'subscription_is_trial', 'daily_calorie_goal', 'daily_protein_goal', 'daily_carbs_goal', @@ -109,7 +111,8 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale 'social_notifications_enabled' => 'boolean', 'engagement_reminders_enabled' => 'boolean', 'suspended_at' => 'datetime', - 'trial_ends_at' => 'datetime', + 'subscription_expires_at' => 'datetime', + 'subscription_is_trial' => 'boolean', ]; } @@ -133,11 +136,6 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale return $this->hasMany(AiUsage::class); } - public function paymentTransactions(): HasMany - { - return $this->hasMany(PaymentTransaction::class); - } - public function identityVerificationRequest(): HasOne { return $this->hasOne(IdentityVerificationRequest::class); @@ -229,16 +227,22 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale public function canAnalyzeMeals(): bool { - return $this->subscribed('default') || $this->onGenericTrial(); + return $this->hasActiveSubscription(); } public function analysisAccessLevel(): string { - if ($this->subscribed('default')) { - return 'subscribed'; + if (! $this->hasActiveSubscription()) { + return 'free'; } - return $this->onGenericTrial() ? 'trial' : 'free'; + return $this->subscription_is_trial ? 'trial' : 'subscribed'; + } + + public function hasActiveSubscription(): bool + { + return $this->subscription_product_id !== null + && $this->subscription_expires_at?->isFuture() === true; } public function hasPurchasedCertification(): bool diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 32616d1..d82b42a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,7 +3,6 @@ namespace App\Providers; use App\Broadcasting\ExpoPushChannel; -use App\Listeners\HandleStripeWebhook; use App\Mail\ResetPasswordMail; use App\Mail\VerifyAccount; use App\Models\User; @@ -14,7 +13,6 @@ use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\RateLimiter; @@ -60,8 +58,6 @@ class AppServiceProvider extends ServiceProvider Notification::extend('expo', fn ($app) => $app->make(ExpoPushChannel::class)); - Event::listen(\Laravel\Cashier\Events\WebhookReceived::class, HandleStripeWebhook::class); - VerifyEmail::createUrlUsing(function (User $notifiable): string { $relativeUrl = URL::temporarySignedRoute( 'verification.verify', diff --git a/app/Services/PaymentInvoiceEmailer.php b/app/Services/PaymentInvoiceEmailer.php deleted file mode 100644 index 0a8d754..0000000 --- a/app/Services/PaymentInvoiceEmailer.php +++ /dev/null @@ -1,42 +0,0 @@ -invoice_email_sent_at !== null) { - return; - } - - if (! $transaction->user || ! $transaction->user->email) { - return; - } - - if (! $transaction->invoice_url && ! $transaction->invoice_pdf_url) { - return; - } - - try { - Mail::to($transaction->user->email)->send( - new PaymentInvoiceMail($transaction->user, $transaction->loadMissing('billingProduct')) - ); - } catch (Throwable $exception) { - $transaction->forceFill([ - 'error_message' => 'Invoice email failed: '.$exception->getMessage(), - ])->save(); - - return; - } - - $transaction->forceFill([ - 'invoice_email_sent_at' => now(), - ])->save(); - } -} diff --git a/app/Services/PaymentTransactionRecorder.php b/app/Services/PaymentTransactionRecorder.php deleted file mode 100644 index d0cab72..0000000 --- a/app/Services/PaymentTransactionRecorder.php +++ /dev/null @@ -1,96 +0,0 @@ -|null $payload - */ - public function recordCheckoutSession( - ?User $user, - ?BillingProduct $product, - string $stripeCheckoutSessionId, - PaymentTransactionStatus $status = PaymentTransactionStatus::PENDING, - ?string $stripeEventId = null, - ?string $stripeEventType = null, - ?string $stripeCustomerId = null, - ?string $stripePaymentIntentId = null, - ?int $amount = null, - ?string $currency = null, - ?string $invoiceUrl = null, - ?string $invoicePdfUrl = null, - ?string $errorMessage = null, - ?array $payload = null, - ): PaymentTransaction { - return PaymentTransaction::query()->updateOrCreate([ - 'stripe_checkout_session_id' => $stripeCheckoutSessionId, - ], [ - 'user_id' => $user?->getKey(), - 'billing_product_id' => $product?->getKey(), - 'type' => PaymentTransactionType::CHECKOUT, - 'status' => $status, - 'stripe_event_id' => $stripeEventId, - 'stripe_event_type' => $stripeEventType, - 'stripe_customer_id' => $stripeCustomerId, - 'stripe_payment_intent_id' => $stripePaymentIntentId, - 'amount' => $amount, - 'currency' => $currency, - 'invoice_url' => $invoiceUrl, - 'invoice_pdf_url' => $invoicePdfUrl, - 'error_message' => $errorMessage, - 'payload' => $payload, - 'processed_at' => $status === PaymentTransactionStatus::PENDING ? null : now(), - ]); - } - - /** - * @param array|null $payload - */ - public function recordInvoice( - ?User $user, - ?BillingProduct $product, - string $stripeInvoiceId, - ?string $stripePriceId, - PaymentTransactionStatus $status, - ?string $stripeEventId = null, - ?string $stripeEventType = null, - ?string $stripeCustomerId = null, - ?string $stripePaymentIntentId = null, - ?string $stripeSubscriptionId = null, - ?int $amount = null, - ?string $currency = null, - ?string $invoiceUrl = null, - ?string $invoicePdfUrl = null, - ?string $errorMessage = null, - ?array $payload = null, - ): PaymentTransaction { - return PaymentTransaction::query()->updateOrCreate([ - 'stripe_invoice_id' => $stripeInvoiceId, - 'stripe_price_id' => $stripePriceId, - ], [ - 'user_id' => $user?->getKey(), - 'billing_product_id' => $product?->getKey(), - 'type' => PaymentTransactionType::SUBSCRIPTION_INVOICE, - 'status' => $status, - 'stripe_event_id' => $stripeEventId, - 'stripe_event_type' => $stripeEventType, - 'stripe_customer_id' => $stripeCustomerId, - 'stripe_payment_intent_id' => $stripePaymentIntentId, - 'stripe_subscription_id' => $stripeSubscriptionId, - 'amount' => $amount, - 'currency' => $currency, - 'invoice_url' => $invoiceUrl, - 'invoice_pdf_url' => $invoicePdfUrl, - 'error_message' => $errorMessage, - 'payload' => $payload, - 'processed_at' => now(), - ]); - } -} diff --git a/app/Services/RevenueCatService.php b/app/Services/RevenueCatService.php new file mode 100644 index 0000000..6b409fe --- /dev/null +++ b/app/Services/RevenueCatService.php @@ -0,0 +1,151 @@ +client() + ->get('subscribers/'.rawurlencode((string) $user->getKey())) + ->throw() + ->json(); + + $subscriber = Arr::get($response, 'subscriber', []); + + if (! is_array($subscriber)) { + throw new RuntimeException('RevenueCat returned an invalid subscriber payload.'); + } + + $subscription = $this->entitlement( + $subscriber, + (string) config('billing.revenuecat.subscription_entitlement'), + ); + $certification = $this->entitlement( + $subscriber, + (string) config('billing.revenuecat.certification_entitlement'), + ); + + $subscriptionProductId = $this->stringValue($subscription['product_identifier'] ?? null); + $subscriptionDetails = $subscriptionProductId + ? Arr::get($subscriber, "subscriptions.{$subscriptionProductId}", []) + : []; + + $user->forceFill([ + 'subscription_product_id' => $this->entitlementIsActive($subscription) + ? $subscriptionProductId + : null, + 'subscription_store' => $this->entitlementIsActive($subscription) + ? $this->stringValue(is_array($subscriptionDetails) ? ($subscriptionDetails['store'] ?? null) : null) + : null, + 'subscription_expires_at' => $this->entitlementIsActive($subscription) + ? $this->effectiveExpiration($subscription) + : null, + 'subscription_is_trial' => $this->entitlementIsActive($subscription) + && is_array($subscriptionDetails) + && strtoupper((string) ($subscriptionDetails['period_type'] ?? '')) === 'TRIAL', + 'certification_purchased_at' => $this->entitlementIsActive($certification) + ? $this->dateValue($certification['purchase_date'] ?? null) ?? now() + : null, + ])->save(); + + return $user->fresh(); + } + + public function deleteCustomer(User $user): void + { + if (trim((string) config('billing.revenuecat.secret_key')) === '') { + return; + } + + $response = $this->client() + ->delete('subscribers/'.rawurlencode((string) $user->getKey())); + + if (! $response->notFound()) { + $response->throw(); + } + } + + private function client(): PendingRequest + { + $secretKey = trim((string) config('billing.revenuecat.secret_key')); + + if ($secretKey === '') { + throw new RuntimeException('REVENUECAT_SECRET_KEY is not configured.'); + } + + return Http::baseUrl(rtrim((string) config('billing.revenuecat.api_url'), '/')) + ->withToken($secretKey) + ->acceptJson() + ->connectTimeout(5) + ->timeout(12) + ->retry([200, 500], throw: false); + } + + /** + * @param array $subscriber + * @return array + */ + private function entitlement(array $subscriber, string $identifier): array + { + $entitlement = Arr::get($subscriber, "entitlements.{$identifier}", []); + + return is_array($entitlement) ? $entitlement : []; + } + + /** + * @param array $entitlement + */ + private function entitlementIsActive(array $entitlement): bool + { + if ($entitlement === []) { + return false; + } + + $expiration = $this->effectiveExpiration($entitlement); + + return $expiration === null || $expiration->isFuture(); + } + + /** + * @param array $entitlement + */ + private function effectiveExpiration(array $entitlement): ?CarbonImmutable + { + $expiration = $this->dateValue($entitlement['expires_date'] ?? null); + $gracePeriodExpiration = $this->dateValue($entitlement['grace_period_expires_date'] ?? null); + + if ($expiration === null) { + return $gracePeriodExpiration; + } + + if ($gracePeriodExpiration === null) { + return $expiration; + } + + return $gracePeriodExpiration->greaterThan($expiration) + ? $gracePeriodExpiration + : $expiration; + } + + private function dateValue(mixed $value): ?CarbonImmutable + { + if (! is_string($value) || $value === '') { + return null; + } + + return CarbonImmutable::parse($value); + } + + private function stringValue(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/app/Services/StripeBillingProductSyncer.php b/app/Services/StripeBillingProductSyncer.php deleted file mode 100644 index 872e7d3..0000000 --- a/app/Services/StripeBillingProductSyncer.php +++ /dev/null @@ -1,96 +0,0 @@ -purpose === null, - \InvalidArgumentException::class, - 'A billing product purpose is required before syncing with Stripe.', - ); - - $stripe = Cashier::stripe(); - $stripeProductId = $product->stripe_product_id; - - if (! $stripeProductId) { - $stripeProduct = $stripe->products->create([ - 'name' => $product->name, - 'description' => $product->description, - 'metadata' => [ - 'billing_product_id' => $product->getKey(), - 'billing_product_purpose' => $product->purpose?->value, - ], - ]); - - $stripeProductId = $stripeProduct->id; - } else { - $stripe->products->update($stripeProductId, [ - 'name' => $product->name, - 'description' => $product->description, - 'active' => (bool) $product->is_active, - 'metadata' => [ - 'billing_product_id' => $product->getKey(), - 'billing_product_purpose' => $product->purpose?->value, - ], - ]); - } - - $needsNewPrice = false; - - if (! $product->stripe_price_id) { - $needsNewPrice = true; - } elseif ($product->wasChanged(['amount', 'currency', 'purpose'])) { - $needsNewPrice = true; - } - - $stripePriceId = $product->stripe_price_id; - - if ($needsNewPrice) { - $priceData = [ - 'product' => $stripeProductId, - 'unit_amount' => $product->amount, - 'currency' => strtolower($product->currency), - 'metadata' => [ - 'billing_product_id' => $product->getKey(), - 'billing_product_purpose' => $product->purpose?->value, - ], - ]; - - if ($product->purpose === BillingProductPurpose::SUBSCRIPTION) { - $priceData['recurring'] = [ - 'interval' => 'month', - ]; - } - - $stripePrice = $stripe->prices->create($priceData); - $stripePriceId = $stripePrice->id; - - // Archive the old price if it exists - if ($product->stripe_price_id && $product->stripe_price_id !== $stripePriceId) { - try { - $stripe->prices->update($product->stripe_price_id, [ - 'active' => false, - ]); - } catch (\Exception $e) { - // Ignore error if price not found - } - } - } - - if ($product->stripe_product_id !== $stripeProductId || $product->stripe_price_id !== $stripePriceId) { - $product->forceFill([ - 'stripe_product_id' => $stripeProductId, - 'stripe_price_id' => $stripePriceId, - ])->saveQuietly(); - } - - return $product; - } -} diff --git a/composer.json b/composer.json index 9a3f303..83b6fc7 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,6 @@ "filament/widgets": "^5.3", "http-interop/http-factory-guzzle": "^1.2", "laravel/ai": "^0.6.8", - "laravel/cashier": "^16.5", "laravel/framework": "^12.0", "laravel/horizon": "^5.45", "laravel/octane": "^2.17", diff --git a/composer.lock b/composer.lock index f06b558..ad4a172 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "90f8a628941b90402c49fc7d951c3329", + "content-hash": "df1fb81f35dace6e7c3dd49b05ade882", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2694,95 +2694,6 @@ }, "time": "2026-05-11T10:46:54+00:00" }, - { - "name": "laravel/cashier", - "version": "v16.5.3", - "source": { - "type": "git", - "url": "https://github.com/laravel/cashier-stripe.git", - "reference": "b6bcd6b4d79acead34d00a5a528c904d67c5e08a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/b6bcd6b4d79acead34d00a5a528c904d67c5e08a", - "reference": "b6bcd6b4d79acead34d00a5a528c904d67c5e08a", - "shasum": "" - }, - "require": { - "ext-json": "*", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", - "illuminate/database": "^10.0|^11.0|^12.0|^13.0", - "illuminate/http": "^10.0|^11.0|^12.0|^13.0", - "illuminate/log": "^10.0|^11.0|^12.0|^13.0", - "illuminate/notifications": "^10.0|^11.0|^12.0|^13.0", - "illuminate/pagination": "^10.0|^11.0|^12.0|^13.0", - "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "illuminate/view": "^10.0|^11.0|^12.0|^13.0", - "moneyphp/money": "^4.0", - "nesbot/carbon": "^2.0|^3.0", - "php": "^8.1", - "stripe/stripe-php": "^17.3.0", - "symfony/console": "^6.0|^7.0|^8.0", - "symfony/http-kernel": "^6.0|^7.0|^8.0", - "symfony/polyfill-intl-icu": "^1.22.1", - "symfony/polyfill-php84": "^1.32" - }, - "require-dev": { - "dompdf/dompdf": "^2.0|^3.0", - "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", - "phpstan/phpstan": "^1.10", - "spatie/laravel-ray": "^1.40" - }, - "suggest": { - "dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^2.0|^3.0).", - "ext-intl": "Allows for more locales besides the default \"en\" when formatting money values.", - "spatie/laravel-pdf": "Required when generating and downloading invoice PDF's using Cashier's LaravelPdfInvoiceRenderer." - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Cashier\\CashierServiceProvider" - ] - }, - "branch-alias": { - "dev-master": "16.x-dev" - } - }, - "autoload": { - "psr-4": { - "Laravel\\Cashier\\": "src/", - "Laravel\\Cashier\\Database\\Factories\\": "database/factories/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - }, - { - "name": "Dries Vints", - "email": "dries@laravel.com" - } - ], - "description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.", - "keywords": [ - "billing", - "laravel", - "stripe" - ], - "support": { - "issues": "https://github.com/laravel/cashier/issues", - "source": "https://github.com/laravel/cashier" - }, - "time": "2026-05-05T21:18:35+00:00" - }, { "name": "laravel/framework", "version": "v12.53.0", @@ -4574,96 +4485,6 @@ }, "time": "2025-09-18T10:15:45+00:00" }, - { - "name": "moneyphp/money", - "version": "v4.9.0", - "source": { - "type": "git", - "url": "https://github.com/moneyphp/money.git", - "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/moneyphp/money/zipball/d49ee625c6ba79b9d7a228ce153b02fc1032152b", - "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b", - "shasum": "" - }, - "require": { - "ext-bcmath": "*", - "ext-filter": "*", - "ext-json": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" - }, - "require-dev": { - "cache/taggable-cache": "^1.1.0", - "doctrine/coding-standard": "^12.0", - "doctrine/instantiator": "^1.5.0 || ^2.0", - "ext-gmp": "*", - "ext-intl": "*", - "florianv/exchanger": "^2.8.1", - "florianv/swap": "^4.3.0", - "moneyphp/crypto-currencies": "^1.1.0", - "moneyphp/iso-currencies": "^3.4", - "php-http/message": "^1.16.0", - "php-http/mock-client": "^1.6.0", - "phpbench/phpbench": "^1.2.5", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1.9", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5.9", - "psr/cache": "^1.0.1 || ^2.0 || ^3.0", - "ticketswap/phpstan-error-formatter": "^1.1" - }, - "suggest": { - "ext-gmp": "Calculate without integer limits", - "ext-intl": "Format Money objects with intl", - "florianv/exchanger": "Exchange rates library for PHP", - "florianv/swap": "Exchange rates library for PHP", - "psr/cache-implementation": "Used for Currency caching" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Money\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mathias Verraes", - "email": "mathias@verraes.net", - "homepage": "http://verraes.net" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com" - }, - { - "name": "Frederik Bosch", - "email": "f.bosch@genkgo.nl" - } - ], - "description": "PHP implementation of Fowler's Money pattern", - "homepage": "http://moneyphp.org", - "keywords": [ - "Value Object", - "money", - "vo" - ], - "support": { - "issues": "https://github.com/moneyphp/money/issues", - "source": "https://github.com/moneyphp/money/tree/v4.9.0" - }, - "time": "2026-05-04T20:23:15+00:00" - }, { "name": "monolog/monolog", "version": "3.10.0", @@ -7827,65 +7648,6 @@ ], "time": "2026-01-12T07:42:22+00:00" }, - { - "name": "stripe/stripe-php", - "version": "v17.6.0", - "source": { - "type": "git", - "url": "https://github.com/stripe/stripe-php.git", - "reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16", - "reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-json": "*", - "ext-mbstring": "*", - "php": ">=5.6.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "3.72.0", - "phpstan/phpstan": "^1.2", - "phpunit/phpunit": "^5.7 || ^9.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, - "autoload": { - "psr-4": { - "Stripe\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Stripe and contributors", - "homepage": "https://github.com/stripe/stripe-php/contributors" - } - ], - "description": "Stripe PHP Library", - "homepage": "https://stripe.com/", - "keywords": [ - "api", - "payment processing", - "stripe" - ], - "support": { - "issues": "https://github.com/stripe/stripe-php/issues", - "source": "https://github.com/stripe/stripe-php/tree/v17.6.0" - }, - "time": "2025-08-27T19:32:42+00:00" - }, { "name": "symfony/clock", "version": "v7.4.0", @@ -9196,94 +8958,6 @@ ], "time": "2025-06-27T09:58:17+00:00" }, - { - "name": "symfony/polyfill-intl-icu", - "version": "v1.38.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-intl-icu.git", - "reference": "445c90e341fccda10311019cf82ff73bb7343945" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/445c90e341fccda10311019cf82ff73bb7343945", - "reference": "445c90e341fccda10311019cf82ff73bb7343945", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "suggest": { - "ext-intl": "For best performance and support of other locales than \"en\"" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Intl\\Icu\\": "" - }, - "classmap": [ - "Resources/stubs" - ], - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for intl's ICU-related data and classes", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "icu", - "intl", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.38.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-05-25T11:52:53+00:00" - }, { "name": "symfony/polyfill-intl-idn", "version": "v1.37.0", diff --git a/config/billing.php b/config/billing.php index 960b605..258fafa 100644 --- a/config/billing.php +++ b/config/billing.php @@ -1,7 +1,13 @@ (int) env('BILLING_TRIAL_DAYS', 7), + 'revenuecat' => [ + 'api_url' => env('REVENUECAT_API_URL', 'https://api.revenuecat.com/v1'), + 'secret_key' => env('REVENUECAT_SECRET_KEY'), + 'webhook_authorization' => env('REVENUECAT_WEBHOOK_AUTHORIZATION'), + 'subscription_entitlement' => env('REVENUECAT_SUBSCRIPTION_ENTITLEMENT', 'bowli Pro'), + 'certification_entitlement' => env('REVENUECAT_CERTIFICATION_ENTITLEMENT', 'certification'), + ], 'identity_verification' => [ 'disk' => 'identity_documents', diff --git a/database/migrations/2026_08_12_123833_replace_stripe_billing_with_revenuecat.php b/database/migrations/2026_08_12_123833_replace_stripe_billing_with_revenuecat.php new file mode 100644 index 0000000..7954faf --- /dev/null +++ b/database/migrations/2026_08_12_123833_replace_stripe_billing_with_revenuecat.php @@ -0,0 +1,141 @@ +string('subscription_product_id')->nullable()->index(); + $table->string('subscription_store')->nullable(); + $table->timestampTz('subscription_expires_at')->nullable()->index(); + $table->boolean('subscription_is_trial')->default(false); + }); + + Schema::create('revenue_cat_webhook_events', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->string('event_id')->unique(); + $table->string('type')->index(); + $table->string('app_user_id')->nullable()->index(); + $table->string('product_id')->nullable(); + $table->json('entitlement_ids')->nullable(); + $table->string('environment')->nullable(); + $table->json('payload'); + $table->timestampTz('processed_at')->nullable(); + $table->timestamps(); + }); + + Schema::dropIfExists('subscription_items'); + Schema::dropIfExists('subscriptions'); + Schema::dropIfExists('payment_transactions'); + Schema::dropIfExists('billing_products'); + + Schema::table('users', function (Blueprint $table): void { + $table->dropIndex(['stripe_id']); + $table->dropColumn([ + 'stripe_id', + 'pm_type', + 'pm_last_four', + 'trial_ends_at', + ]); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('revenue_cat_webhook_events'); + + Schema::table('users', function (Blueprint $table): void { + $table->dropIndex(['subscription_product_id']); + $table->dropIndex(['subscription_expires_at']); + $table->dropColumn([ + 'subscription_product_id', + 'subscription_store', + 'subscription_expires_at', + 'subscription_is_trial', + ]); + $table->string('stripe_id')->nullable()->index(); + $table->string('pm_type')->nullable(); + $table->string('pm_last_four', 4)->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + }); + + Schema::create('billing_products', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('purpose', 32)->unique(); + $table->unsignedInteger('amount'); + $table->string('currency', 3)->default('eur'); + $table->string('stripe_product_id')->nullable()->index(); + $table->string('stripe_price_id')->nullable()->unique(); + $table->boolean('is_active')->default(true)->index(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + $table->index(['purpose', 'is_active', 'sort_order']); + }); + + Schema::create('payment_transactions', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignUlid('billing_product_id')->nullable()->constrained('billing_products')->nullOnDelete(); + $table->string('type', 64); + $table->string('status', 64); + $table->string('stripe_event_id')->nullable()->index(); + $table->string('stripe_event_type')->nullable()->index(); + $table->string('stripe_customer_id')->nullable()->index(); + $table->string('stripe_checkout_session_id')->nullable()->index(); + $table->string('stripe_invoice_id')->nullable()->index(); + $table->string('stripe_payment_intent_id')->nullable()->index(); + $table->string('stripe_subscription_id')->nullable()->index(); + $table->string('stripe_price_id')->nullable()->index(); + $table->unsignedInteger('amount')->nullable(); + $table->string('currency', 3)->nullable(); + $table->string('invoice_url', 2048)->nullable(); + $table->string('invoice_pdf_url', 2048)->nullable(); + $table->timestamp('invoice_email_sent_at')->nullable(); + $table->text('error_message')->nullable(); + $table->json('payload')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + $table->index(['status', 'created_at']); + $table->index(['type', 'status', 'created_at']); + }); + + Schema::create('subscriptions', function (Blueprint $table): void { + $table->id(); + $table->foreignUlid('user_id'); + $table->string('type'); + $table->string('stripe_id')->unique(); + $table->string('stripe_status'); + $table->string('stripe_price')->nullable(); + $table->integer('quantity')->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + $table->index(['user_id', 'stripe_status']); + }); + + Schema::create('subscription_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('subscription_id'); + $table->string('stripe_id')->unique(); + $table->string('stripe_product'); + $table->string('stripe_price'); + $table->string('meter_id')->nullable(); + $table->integer('quantity')->nullable(); + $table->string('meter_event_name')->nullable(); + $table->timestamps(); + $table->index(['subscription_id', 'stripe_price']); + }); + } +}; diff --git a/database/seeders/LegalDocumentSeeder.php b/database/seeders/LegalDocumentSeeder.php index d4e20d2..d4042c1 100644 --- a/database/seeders/LegalDocumentSeeder.php +++ b/database/seeders/LegalDocumentSeeder.php @@ -64,7 +64,7 @@ class LegalDocumentSeeder extends Seeder [ 'title' => 'Paiements et abonnements', 'body' => [ - 'Stripe traite les paiements, les abonnements, les factures et les informations de moyen de paiement. Bowli conserve uniquement les identifiants et statuts nécessaires au suivi de la transaction et de tes droits d’accès.', + 'Apple ou Google traite le paiement selon ton appareil. RevenueCat synchronise les achats, abonnements et droits d’accès sans recevoir les informations complètes de ton moyen de paiement.', ], ], [ @@ -118,8 +118,8 @@ class LegalDocumentSeeder extends Seeder [ 'title' => 'Essai, abonnement et certification', 'body' => [ - 'La publication de plats reste accessible sans abonnement. L’analyse automatique des photos est accessible pendant la période d’essai indiquée dans l’application, puis avec l’unique formule d’abonnement active.', - 'La certification est un achat unique distinct de l’abonnement. Le paiement donne le droit de soumettre une demande, mais le badge n’est accordé qu’après vérification d’une pièce d’identité valide. En cas de refus, une nouvelle pièce peut être envoyée sans nouvel achat.', + 'La publication de plats reste accessible sans abonnement. L’analyse automatique des photos est accessible avec un abonnement mensuel ou annuel. Une éventuelle période d’essai concerne uniquement ces abonnements et sa durée est affichée avant validation par le store.', + 'La certification ne comporte aucune période d’essai. C’est un achat unique distinct de l’abonnement : le paiement donne le droit de soumettre une demande, mais le badge n’est accordé qu’après vérification d’une pièce d’identité valide. En cas de refus, une nouvelle pièce peut être envoyée sans nouvel achat.', ], ], [ diff --git a/lang/en/admin.php b/lang/en/admin.php index b47d5fc..2688c55 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -29,7 +29,7 @@ return [ 'email_verified_at' => 'Email verified at', 'account_verified_at' => 'Account verified at', 'certification_purchased_at' => 'Certification purchased at', - 'trial_ends_at' => 'Trial ends at', + 'subscription_expires_at' => 'Subscription valid until', 'suspended_at' => 'Suspended at', 'suspended_by' => 'Suspended by', 'avatar' => 'Avatar', @@ -69,36 +69,6 @@ return [ 'empty' => '-', ], ], - 'billing_products' => [ - 'navigation' => [ - 'label' => 'Offers', - 'singular' => 'Offer', - 'plural' => 'Offers', - ], - 'sections' => [ - 'product' => 'Product', - 'stripe' => 'Stripe', - ], - 'fields' => [ - 'name' => 'Name', - 'description' => 'Description', - 'purpose' => 'Offer', - 'amount' => 'Price', - 'currency' => 'Currency', - 'stripe_product_id' => 'Stripe product ID', - 'stripe_price_id' => 'Stripe price ID', - 'is_active' => 'Active', - 'sort_order' => 'Order', - 'created_at' => 'Created at', - ], - 'helpers' => [ - 'amount' => 'Amount in cents, for example 999 for €9.99.', - ], - 'actions' => [ - 'sync_stripe' => 'Create in Stripe', - 'sync_stripe_success' => 'Stripe product synchronized.', - ], - ], 'identity_verification_requests' => [ 'navigation' => [ 'label' => 'Identity verifications', @@ -128,47 +98,6 @@ return [ 'empty' => '-', ], ], - 'payment_transactions' => [ - 'navigation' => [ - 'label' => 'Payments', - 'singular' => 'Payment', - 'plural' => 'Payments', - ], - 'sections' => [ - 'summary' => 'Summary', - 'stripe' => 'Stripe', - 'error' => 'Error', - 'payload' => 'Stripe payload', - ], - 'fields' => [ - 'id' => 'ID', - 'user' => 'User', - 'billing_product' => 'Product', - 'type' => 'Type', - 'status' => 'Status', - 'amount' => 'Amount', - 'invoice_url' => 'Invoice URL', - 'invoice_pdf_url' => 'Invoice PDF', - 'invoice_email_sent_at' => 'Invoice email sent at', - 'stripe_event_id' => 'Stripe event ID', - 'stripe_event_type' => 'Event', - 'stripe_customer_id' => 'Stripe customer ID', - 'stripe_checkout_session_id' => 'Stripe checkout session ID', - 'stripe_checkout_session_id_short' => 'Checkout session', - 'stripe_invoice_id' => 'Stripe invoice ID', - 'stripe_invoice_id_short' => 'Invoice', - 'stripe_payment_intent_id' => 'Stripe payment intent ID', - 'stripe_subscription_id' => 'Stripe subscription ID', - 'stripe_price_id' => 'Stripe price ID', - 'error_message' => 'Message', - 'payload' => 'Payload', - 'processed_at' => 'Processed at', - 'created_at' => 'Created at', - ], - 'placeholders' => [ - 'empty' => '-', - ], - ], 'moderation_cases' => [ 'navigation' => [ 'label' => 'Moderation', diff --git a/lang/fr/admin.php b/lang/fr/admin.php index bafdced..c17a1a7 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -29,7 +29,7 @@ return [ 'email_verified_at' => 'Email vérifié le', 'account_verified_at' => 'Compte vérifié le', 'certification_purchased_at' => 'Certification achetée le', - 'trial_ends_at' => 'Fin de l’essai', + 'subscription_expires_at' => 'Abonnement valable jusqu’au', 'suspended_at' => 'Suspendu le', 'suspended_by' => 'Suspendu par', 'avatar' => 'Avatar', @@ -69,36 +69,6 @@ return [ 'empty' => '-', ], ], - 'billing_products' => [ - 'navigation' => [ - 'label' => 'Offres', - 'singular' => 'Offre', - 'plural' => 'Offres', - ], - 'sections' => [ - 'product' => 'Produit', - 'stripe' => 'Stripe', - ], - 'fields' => [ - 'name' => 'Nom', - 'description' => 'Description', - 'purpose' => 'Offre', - 'amount' => 'Prix', - 'currency' => 'Devise', - 'stripe_product_id' => 'Stripe product ID', - 'stripe_price_id' => 'Stripe price ID', - 'is_active' => 'Actif', - 'sort_order' => 'Ordre', - 'created_at' => 'Créé le', - ], - 'helpers' => [ - 'amount' => 'Montant en centimes, par exemple 999 pour 9,99 €.', - ], - 'actions' => [ - 'sync_stripe' => 'Créer dans Stripe', - 'sync_stripe_success' => 'Produit Stripe synchronisé.', - ], - ], 'identity_verification_requests' => [ 'navigation' => [ 'label' => 'Vérifications d’identité', @@ -128,47 +98,6 @@ return [ 'empty' => '-', ], ], - 'payment_transactions' => [ - 'navigation' => [ - 'label' => 'Paiements', - 'singular' => 'Paiement', - 'plural' => 'Paiements', - ], - 'sections' => [ - 'summary' => 'Résumé', - 'stripe' => 'Stripe', - 'error' => 'Erreur', - 'payload' => 'Payload Stripe', - ], - 'fields' => [ - 'id' => 'ID', - 'user' => 'Utilisateur', - 'billing_product' => 'Produit', - 'type' => 'Type', - 'status' => 'Statut', - 'amount' => 'Montant', - 'invoice_url' => 'URL facture', - 'invoice_pdf_url' => 'PDF facture', - 'invoice_email_sent_at' => 'Email facture envoyé le', - 'stripe_event_id' => 'Stripe event ID', - 'stripe_event_type' => 'Événement', - 'stripe_customer_id' => 'Stripe customer ID', - 'stripe_checkout_session_id' => 'Stripe checkout session ID', - 'stripe_checkout_session_id_short' => 'Checkout session', - 'stripe_invoice_id' => 'Stripe invoice ID', - 'stripe_invoice_id_short' => 'Invoice', - 'stripe_payment_intent_id' => 'Stripe payment intent ID', - 'stripe_subscription_id' => 'Stripe subscription ID', - 'stripe_price_id' => 'Stripe price ID', - 'error_message' => 'Message', - 'payload' => 'Payload', - 'processed_at' => 'Traité le', - 'created_at' => 'Créé le', - ], - 'placeholders' => [ - 'empty' => '-', - ], - ], 'moderation_cases' => [ 'navigation' => [ 'label' => 'Modération', diff --git a/resources/views/emails/payment-invoice.blade.php b/resources/views/emails/payment-invoice.blade.php deleted file mode 100644 index 45e0ec3..0000000 --- a/resources/views/emails/payment-invoice.blade.php +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - {{ trans('mail.payment_invoice.subject', [], $locale) }} - - - - - - -
- - - - -
-

- Bowli -

- -

- {{ trans('mail.payment_invoice.title', [], $locale) }} -

- -

- {{ trans('mail.payment_invoice.greeting', ['name' => $userName ?: trans('mail.payment_invoice.default_name', [], $locale)], $locale) }} -

- -

- {{ trans('mail.payment_invoice.intro', [], $locale) }} -

- - - - - - - - - - -
- {{ trans('mail.payment_invoice.product', [], $locale) }} - - {{ $productName ?: trans('mail.payment_invoice.default_product', [], $locale) }} -
- {{ trans('mail.payment_invoice.amount', [], $locale) }} - - {{ $amount }} -
- - @if ($invoiceUrl) - - - - -
- - {{ trans('mail.payment_invoice.action', [], $locale) }} - -
- @endif - - @if ($invoicePdfUrl) -

- {{ trans('mail.payment_invoice.pdf_link', [], $locale) }} - {{ $invoicePdfUrl }} -

- @endif - -

- {{ trans('mail.payment_invoice.footer', [], $locale) }} -

-
-
- - diff --git a/routes/api.php b/routes/api.php index df19a4a..6260b1e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,7 +1,6 @@ group(function Route::patch('notification-preferences', [NotificationPreferenceController::class, 'update'])->middleware('throttle:account-action')->name('notification-preferences.update'); }); +Route::post('webhooks/revenuecat', [RevenueCatController::class, 'webhook']) + ->middleware('throttle:60,1') + ->name('revenuecat.webhook'); + // Billing Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->prefix('billing')->group(function (): void { - Route::get('products', [BillingController::class, 'products'])->name('billing.products'); - Route::post('products/{billingProduct}/checkout', [BillingController::class, 'checkout'])->middleware('throttle:account-action')->name('billing.checkout'); - Route::post('portal', [BillingController::class, 'portal'])->middleware('throttle:account-action')->name('billing.portal'); + Route::post('sync', [RevenueCatController::class, 'sync']) + ->middleware('throttle:account-action') + ->name('billing.sync'); }); Route::middleware(['auth:sanctum', 'verified', 'not_suspended']) diff --git a/routes/web.php b/routes/web.php index b521c75..31fd33f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,6 @@ middleware('throttle:6,1') ->name('password.web-update'); Route::get('strava/callback', [StravaController::class, 'redirectToMobileApp'])->name('strava.web-callback'); -Route::get('billing/return/{status}', [BillingController::class, 'redirectToMobileApp'])->name('billing.web-return'); diff --git a/tests/Feature/BillingProductsTest.php b/tests/Feature/BillingProductsTest.php deleted file mode 100644 index b83d077..0000000 --- a/tests/Feature/BillingProductsTest.php +++ /dev/null @@ -1,39 +0,0 @@ -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); -}); diff --git a/tests/Feature/CertificationTest.php b/tests/Feature/CertificationTest.php index 1d94ad7..9fcc901 100644 --- a/tests/Feature/CertificationTest.php +++ b/tests/Feature/CertificationTest.php @@ -1,20 +1,16 @@ 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(); }); diff --git a/tests/Feature/CreateUserCommandTest.php b/tests/Feature/CreateUserCommandTest.php index 68ba9a8..ca4ad90 100644 --- a/tests/Feature/CreateUserCommandTest.php +++ b/tests/Feature/CreateUserCommandTest.php @@ -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(); }); diff --git a/tests/Feature/EmailVerificationTest.php b/tests/Feature/EmailVerificationTest.php index 605f0a3..cd38716 100644 --- a/tests/Feature/EmailVerificationTest.php +++ b/tests/Feature/EmailVerificationTest.php @@ -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', [ diff --git a/tests/Feature/MealImageAnalysisTest.php b/tests/Feature/MealImageAnalysisTest.php index 69e3a47..36d1e74 100644 --- a/tests/Feature/MealImageAnalysisTest.php +++ b/tests/Feature/MealImageAnalysisTest.php @@ -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 diff --git a/tests/Feature/NotificationDeliveryTest.php b/tests/Feature/NotificationDeliveryTest.php index c50c301..2c908db 100644 --- a/tests/Feature/NotificationDeliveryTest.php +++ b/tests/Feature/NotificationDeliveryTest.php @@ -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); }); diff --git a/tests/Feature/PaymentInvoiceMailTest.php b/tests/Feature/PaymentInvoiceMailTest.php deleted file mode 100644 index a9d159e..0000000 --- a/tests/Feature/PaymentInvoiceMailTest.php +++ /dev/null @@ -1,45 +0,0 @@ -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'); -}); diff --git a/tests/Feature/RevenueCatBillingTest.php b/tests/Feature/RevenueCatBillingTest.php new file mode 100644 index 0000000..4d26aa2 --- /dev/null +++ b/tests/Feature/RevenueCatBillingTest.php @@ -0,0 +1,132 @@ +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(); +}); diff --git a/tests/Feature/SubscriptionAccessTest.php b/tests/Feature/SubscriptionAccessTest.php index 5be61b3..d93a39d 100644 --- a/tests/Feature/SubscriptionAccessTest.php +++ b/tests/Feature/SubscriptionAccessTest.php @@ -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()