From 2ece4b6e3ec7bca933de5dbdc5b2b60e6b71ad74 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Fri, 7 Aug 2026 11:53:46 +0200 Subject: [PATCH] feat: subscriptions --- .env.example | 1 + .env.prod.example | 1 + app/Actions/ReviewIdentityVerification.php | 84 ++++++ app/Actions/SubmitIdentityVerification.php | 85 ++++++ app/Enums/BillingProductPurpose.php | 28 ++ app/Enums/IdentityVerificationStatus.php | 31 +++ .../CreditProducts/CreditProductResource.php | 2 +- .../Schemas/CreditProductForm.php | 19 +- .../Tables/CreditProductsTable.php | 16 +- .../IdentityVerificationRequestResource.php | 61 +++++ .../ListIdentityVerificationRequests.php | 16 ++ .../Pages/ViewIdentityVerificationRequest.php | 20 ++ .../IdentityVerificationRequestInfolist.php | 55 ++++ .../IdentityVerificationRequestsTable.php | 98 +++++++ .../Resources/Users/Schemas/UserForm.php | 4 +- .../Resources/Users/Schemas/UserInfolist.php | 8 + .../Resources/Users/Tables/UsersTable.php | 6 + app/Http/Controllers/AuthController.php | 11 + app/Http/Controllers/BillingController.php | 15 +- .../Controllers/CertificationController.php | 44 +++ .../MealImageAnalysisController.php | 14 +- .../StoreIdentityVerificationRequest.php | 37 +++ app/Http/Resources/CreditProductResource.php | 3 +- .../IdentityVerificationResource.php | 19 ++ app/Http/Resources/UserResource.php | 5 +- .../GrantCreditsFromStripeWebhook.php | 255 +++++++----------- app/Models/CreditProduct.php | 3 + app/Models/IdentityVerificationRequest.php | 73 +++++ app/Models/User.php | 27 ++ .../IdentityVerificationRequestPolicy.php | 44 +++ app/Services/StripeCreditProductSyncer.php | 21 +- config/billing.php | 10 + config/filesystems.php | 20 ++ .../IdentityVerificationRequestFactory.php | 31 +++ ...e_identity_verification_requests_table.php | 38 +++ ...d_billing_access_fields_to_users_table.php | 31 +++ ...8_add_purpose_to_credit_products_table.php | 28 ++ database/seeders/LegalDocumentSeeder.php | 26 +- docker-compose.prod.yml | 1 + lang/en/admin.php | 38 ++- lang/en/api.php | 10 +- lang/en/enums.php | 9 + lang/fr/admin.php | 38 ++- lang/fr/api.php | 10 +- lang/fr/enums.php | 9 + routes/api.php | 10 + tests/Feature/BillingProductsTest.php | 32 +-- tests/Feature/CertificationTest.php | 216 +++++++++++++++ tests/Feature/EmailVerificationTest.php | 6 +- tests/Feature/MealImageAnalysisTest.php | 34 +-- tests/Feature/SubscriptionAccessTest.php | 40 +++ 51 files changed, 1489 insertions(+), 254 deletions(-) create mode 100644 app/Actions/ReviewIdentityVerification.php create mode 100644 app/Actions/SubmitIdentityVerification.php create mode 100644 app/Enums/BillingProductPurpose.php create mode 100644 app/Enums/IdentityVerificationStatus.php create mode 100644 app/Filament/Resources/IdentityVerificationRequests/IdentityVerificationRequestResource.php create mode 100644 app/Filament/Resources/IdentityVerificationRequests/Pages/ListIdentityVerificationRequests.php create mode 100644 app/Filament/Resources/IdentityVerificationRequests/Pages/ViewIdentityVerificationRequest.php create mode 100644 app/Filament/Resources/IdentityVerificationRequests/Schemas/IdentityVerificationRequestInfolist.php create mode 100644 app/Filament/Resources/IdentityVerificationRequests/Tables/IdentityVerificationRequestsTable.php create mode 100644 app/Http/Controllers/CertificationController.php create mode 100644 app/Http/Requests/StoreIdentityVerificationRequest.php create mode 100644 app/Http/Resources/IdentityVerificationResource.php create mode 100644 app/Models/IdentityVerificationRequest.php create mode 100644 app/Policies/IdentityVerificationRequestPolicy.php create mode 100644 config/billing.php create mode 100644 database/factories/IdentityVerificationRequestFactory.php create mode 100644 database/migrations/2026_08_07_092015_create_identity_verification_requests_table.php create mode 100644 database/migrations/2026_08_07_092017_add_billing_access_fields_to_users_table.php create mode 100644 database/migrations/2026_08_07_092018_add_purpose_to_credit_products_table.php create mode 100644 tests/Feature/CertificationTest.php create mode 100644 tests/Feature/SubscriptionAccessTest.php diff --git a/.env.example b/.env.example index 46f9036..35e51c5 100644 --- a/.env.example +++ b/.env.example @@ -90,3 +90,4 @@ STRIPE_KEY=your-stripe-key STRIPE_SECRET=your-stripe-secret STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret CASHIER_CURRENCY=eur +BILLING_TRIAL_DAYS=7 diff --git a/.env.prod.example b/.env.prod.example index b937baf..445822d 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -71,6 +71,7 @@ STRIPE_KEY= STRIPE_SECRET= STRIPE_WEBHOOK_SECRET= CASHIER_CURRENCY=eur +BILLING_TRIAL_DAYS=7 HEALTH_SECRET_TOKEN= HORIZON_HEARTBEAT_URL= diff --git a/app/Actions/ReviewIdentityVerification.php b/app/Actions/ReviewIdentityVerification.php new file mode 100644 index 0000000..4622b74 --- /dev/null +++ b/app/Actions/ReviewIdentityVerification.php @@ -0,0 +1,84 @@ +isAdmin(), 403); + abort_unless( + in_array($status, [IdentityVerificationStatus::APPROVED, IdentityVerificationStatus::REJECTED], true), + 422, + ); + abort_if( + $status === IdentityVerificationStatus::REJECTED && blank($rejectionReason), + 422, + __('api.certification.rejection_reason_required'), + ); + + $reviewedRequest = DB::transaction(function () use ( + $rejectionReason, + $reviewer, + $status, + $verificationRequest, + ): IdentityVerificationRequest { + $lockedRequest = IdentityVerificationRequest::query() + ->whereKey($verificationRequest->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + abort_unless($lockedRequest->status === IdentityVerificationStatus::PENDING, 409); + + $lockedRequest->update([ + 'status' => $status, + 'reviewed_at' => now(), + 'reviewed_by' => $reviewer->getKey(), + 'rejection_reason' => $status === IdentityVerificationStatus::REJECTED + ? trim((string) $rejectionReason) + : null, + ]); + + if ($status === IdentityVerificationStatus::APPROVED) { + $lockedRequest->user()->update([ + 'account_verified_at' => now(), + ]); + } + + return $lockedRequest->fresh(['user', 'reviewer']); + }); + + $this->deleteReviewedDocument($reviewedRequest); + + return $reviewedRequest->fresh(['user', 'reviewer']); + } + + private function deleteReviewedDocument(IdentityVerificationRequest $verificationRequest): void + { + if (! $verificationRequest->hasDocument()) { + return; + } + + try { + Storage::disk($verificationRequest->document_disk) + ->delete($verificationRequest->document_path); + + $verificationRequest->update([ + 'document_path' => null, + 'document_deleted_at' => now(), + ]); + } catch (\Throwable $exception) { + report($exception); + } + } +} diff --git a/app/Actions/SubmitIdentityVerification.php b/app/Actions/SubmitIdentityVerification.php new file mode 100644 index 0000000..4587cb0 --- /dev/null +++ b/app/Actions/SubmitIdentityVerification.php @@ -0,0 +1,85 @@ +getKey(), 30) + ->block(5, fn (): IdentityVerificationRequest => $this->store($user, $document)); + } + + private function store(User $user, UploadedFile $document): IdentityVerificationRequest + { + $existingRequest = $user->identityVerificationRequest()->first(); + + if ($existingRequest?->status === IdentityVerificationStatus::PENDING) { + throw new ConflictHttpException(__('api.certification.already_pending')); + } + + $disk = (string) config('billing.identity_verification.disk', 'identity_documents'); + $path = null; + + try { + $path = Storage::disk($disk)->putFileAs( + 'submissions', + $document, + Str::ulid().'.'.$this->extensionFor($document), + ['visibility' => 'private'], + ); + + if (! is_string($path) || $path === '') { + throw new \RuntimeException('The identity document could not be stored.'); + } + + $verificationRequest = IdentityVerificationRequest::query()->updateOrCreate( + ['user_id' => $user->getKey()], + [ + 'status' => IdentityVerificationStatus::PENDING, + 'document_disk' => $disk, + 'document_path' => $path, + 'document_mime_type' => $document->getMimeType(), + 'document_size' => $document->getSize(), + 'submitted_at' => now(), + 'reviewed_at' => null, + 'reviewed_by' => null, + 'rejection_reason' => null, + 'document_deleted_at' => null, + ], + ); + } catch (Throwable $exception) { + if ($path) { + Storage::disk($disk)->delete($path); + } + + throw $exception; + } + + if ($existingRequest?->hasDocument() && $existingRequest->document_path !== $path) { + Storage::disk($existingRequest->document_disk)->delete($existingRequest->document_path); + } + + return $verificationRequest; + } + + private function extensionFor(UploadedFile $document): string + { + return match ($document->getMimeType()) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'application/pdf' => 'pdf', + default => throw new \InvalidArgumentException('Unsupported identity document type.'), + }; + } +} diff --git a/app/Enums/BillingProductPurpose.php b/app/Enums/BillingProductPurpose.php new file mode 100644 index 0000000..1e68b86 --- /dev/null +++ b/app/Enums/BillingProductPurpose.php @@ -0,0 +1,28 @@ + __('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/IdentityVerificationStatus.php b/app/Enums/IdentityVerificationStatus.php new file mode 100644 index 0000000..d2b35ce --- /dev/null +++ b/app/Enums/IdentityVerificationStatus.php @@ -0,0 +1,31 @@ + __('enums.identity_verification_status.pending'), + self::APPROVED => __('enums.identity_verification_status.approved'), + self::REJECTED => __('enums.identity_verification_status.rejected'), + }; + } + + public function getColor(): string + { + return match ($this) { + self::PENDING => 'warning', + self::APPROVED => 'success', + self::REJECTED => 'danger', + }; + } +} diff --git a/app/Filament/Resources/CreditProducts/CreditProductResource.php b/app/Filament/Resources/CreditProducts/CreditProductResource.php index ebcead5..376d915 100644 --- a/app/Filament/Resources/CreditProducts/CreditProductResource.php +++ b/app/Filament/Resources/CreditProducts/CreditProductResource.php @@ -55,7 +55,7 @@ class CreditProductResource extends Resource return Action::make('syncStripe') ->label(__('admin.credit_products.actions.sync_stripe')) ->icon(Heroicon::OutlinedArrowPath) - ->visible(fn (CreditProduct $record): bool => blank($record->stripe_price_id)) + ->visible(fn (CreditProduct $record): bool => blank($record->stripe_price_id) && $record->purpose !== null) ->requiresConfirmation() ->action(function (CreditProduct $record): void { app(StripeCreditProductSyncer::class)->sync($record); diff --git a/app/Filament/Resources/CreditProducts/Schemas/CreditProductForm.php b/app/Filament/Resources/CreditProducts/Schemas/CreditProductForm.php index 7799b73..5b0e73d 100644 --- a/app/Filament/Resources/CreditProducts/Schemas/CreditProductForm.php +++ b/app/Filament/Resources/CreditProducts/Schemas/CreditProductForm.php @@ -2,7 +2,9 @@ namespace App\Filament\Resources\CreditProducts\Schemas; +use App\Enums\BillingProductPurpose; use App\Enums\CreditProductType; +use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; @@ -23,16 +25,15 @@ class CreditProductForm ->label(__('admin.credit_products.fields.name')) ->required() ->maxLength(255), - Select::make('type') - ->label(__('admin.credit_products.fields.type')) - ->options(CreditProductType::class) - ->default(CreditProductType::ONE_TIME->value) - ->required(), - TextInput::make('credits') - ->label(__('admin.credit_products.fields.credits')) - ->integer() - ->minValue(1) + Select::make('purpose') + ->label(__('admin.credit_products.fields.purpose')) + ->options(BillingProductPurpose::class) + ->unique(ignoreRecord: true) ->required(), + Hidden::make('type') + ->default(CreditProductType::ONE_TIME->value), + Hidden::make('credits') + ->default(0), TextInput::make('amount') ->label(__('admin.credit_products.fields.amount')) ->helperText(__('admin.credit_products.helpers.amount')) diff --git a/app/Filament/Resources/CreditProducts/Tables/CreditProductsTable.php b/app/Filament/Resources/CreditProducts/Tables/CreditProductsTable.php index ed44126..fc45fa2 100644 --- a/app/Filament/Resources/CreditProducts/Tables/CreditProductsTable.php +++ b/app/Filament/Resources/CreditProducts/Tables/CreditProductsTable.php @@ -2,7 +2,7 @@ namespace App\Filament\Resources\CreditProducts\Tables; -use App\Enums\CreditProductType; +use App\Enums\BillingProductPurpose; use App\Filament\Resources\CreditProducts\CreditProductResource; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; @@ -24,14 +24,10 @@ class CreditProductsTable ->label(__('admin.credit_products.fields.name')) ->searchable() ->sortable(), - TextColumn::make('type') - ->label(__('admin.credit_products.fields.type')) + TextColumn::make('purpose') + ->label(__('admin.credit_products.fields.purpose')) ->badge() ->sortable(), - TextColumn::make('credits') - ->label(__('admin.credit_products.fields.credits')) - ->numeric(decimalPlaces: 0) - ->sortable(), TextColumn::make('amount') ->label(__('admin.credit_products.fields.amount')) ->money(fn ($record): string => $record->currency, divideBy: 100) @@ -56,9 +52,9 @@ class CreditProductsTable ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ - SelectFilter::make('type') - ->label(__('admin.credit_products.fields.type')) - ->options(CreditProductType::class), + SelectFilter::make('purpose') + ->label(__('admin.credit_products.fields.purpose')) + ->options(BillingProductPurpose::class), TernaryFilter::make('is_active') ->label(__('admin.credit_products.fields.is_active')), ]) diff --git a/app/Filament/Resources/IdentityVerificationRequests/IdentityVerificationRequestResource.php b/app/Filament/Resources/IdentityVerificationRequests/IdentityVerificationRequestResource.php new file mode 100644 index 0000000..be15173 --- /dev/null +++ b/app/Filament/Resources/IdentityVerificationRequests/IdentityVerificationRequestResource.php @@ -0,0 +1,61 @@ + ListIdentityVerificationRequests::route('/'), + 'view' => ViewIdentityVerificationRequest::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/IdentityVerificationRequests/Pages/ListIdentityVerificationRequests.php b/app/Filament/Resources/IdentityVerificationRequests/Pages/ListIdentityVerificationRequests.php new file mode 100644 index 0000000..15387af --- /dev/null +++ b/app/Filament/Resources/IdentityVerificationRequests/Pages/ListIdentityVerificationRequests.php @@ -0,0 +1,16 @@ +components([ + Section::make(__('admin.identity_verification_requests.sections.request')) + ->columns(3) + ->schema([ + TextEntry::make('id') + ->label(__('admin.identity_verification_requests.fields.id')) + ->copyable(), + TextEntry::make('status') + ->label(__('admin.identity_verification_requests.fields.status')) + ->badge(), + TextEntry::make('user.email') + ->label(__('admin.identity_verification_requests.fields.user')) + ->copyable(), + TextEntry::make('submitted_at') + ->label(__('admin.identity_verification_requests.fields.submitted_at')) + ->dateTime(), + TextEntry::make('reviewed_at') + ->label(__('admin.identity_verification_requests.fields.reviewed_at')) + ->dateTime() + ->placeholder(__('admin.identity_verification_requests.placeholders.empty')), + TextEntry::make('reviewer.email') + ->label(__('admin.identity_verification_requests.fields.reviewed_by')) + ->placeholder(__('admin.identity_verification_requests.placeholders.empty')), + TextEntry::make('document') + ->label(__('admin.identity_verification_requests.fields.document')) + ->state(__('admin.identity_verification_requests.actions.open_document')) + ->url(fn (IdentityVerificationRequest $record): ?string => $record->temporaryDocumentUrl()) + ->openUrlInNewTab() + ->visible(fn (IdentityVerificationRequest $record): bool => $record->hasDocument()), + TextEntry::make('document_deleted_at') + ->label(__('admin.identity_verification_requests.fields.document_deleted_at')) + ->dateTime() + ->placeholder(__('admin.identity_verification_requests.placeholders.empty')), + TextEntry::make('rejection_reason') + ->label(__('admin.identity_verification_requests.fields.rejection_reason')) + ->placeholder(__('admin.identity_verification_requests.placeholders.empty')) + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/IdentityVerificationRequests/Tables/IdentityVerificationRequestsTable.php b/app/Filament/Resources/IdentityVerificationRequests/Tables/IdentityVerificationRequestsTable.php new file mode 100644 index 0000000..5b57cd1 --- /dev/null +++ b/app/Filament/Resources/IdentityVerificationRequests/Tables/IdentityVerificationRequestsTable.php @@ -0,0 +1,98 @@ +defaultSort('submitted_at', 'desc') + ->columns([ + TextColumn::make('submitted_at') + ->label(__('admin.identity_verification_requests.fields.submitted_at')) + ->dateTime() + ->sortable(), + TextColumn::make('status') + ->label(__('admin.identity_verification_requests.fields.status')) + ->badge() + ->sortable(), + TextColumn::make('user.email') + ->label(__('admin.identity_verification_requests.fields.user')) + ->searchable(), + TextColumn::make('reviewed_at') + ->label(__('admin.identity_verification_requests.fields.reviewed_at')) + ->dateTime() + ->sortable() + ->placeholder(__('admin.identity_verification_requests.placeholders.empty')), + ]) + ->filters([ + SelectFilter::make('status') + ->label(__('admin.identity_verification_requests.fields.status')) + ->options(IdentityVerificationStatus::class), + ]) + ->recordActions([ + ViewAction::make(), + self::approveAction(), + self::rejectAction(), + ]); + } + + public static function approveAction(): Action + { + return Action::make('approve') + ->label(__('admin.identity_verification_requests.actions.approve')) + ->color('success') + ->requiresConfirmation() + ->visible(fn (IdentityVerificationRequest $record): bool => $record->status === IdentityVerificationStatus::PENDING) + ->action(function (IdentityVerificationRequest $record): void { + app(ReviewIdentityVerification::class)->execute( + $record, + IdentityVerificationStatus::APPROVED, + self::reviewer(), + ); + }); + } + + public static function rejectAction(): Action + { + return Action::make('reject') + ->label(__('admin.identity_verification_requests.actions.reject')) + ->color('danger') + ->schema([ + Textarea::make('reason') + ->label(__('admin.identity_verification_requests.fields.rejection_reason')) + ->required() + ->maxLength(2000), + ]) + ->visible(fn (IdentityVerificationRequest $record): bool => $record->status === IdentityVerificationStatus::PENDING) + ->action(function (array $data, IdentityVerificationRequest $record): void { + app(ReviewIdentityVerification::class)->execute( + $record, + IdentityVerificationStatus::REJECTED, + self::reviewer(), + $data['reason'], + ); + }); + } + + private static function reviewer(): User + { + $reviewer = auth()->user(); + + abort_unless($reviewer instanceof User, 403); + + return $reviewer; + } +} diff --git a/app/Filament/Resources/Users/Schemas/UserForm.php b/app/Filament/Resources/Users/Schemas/UserForm.php index 3e39072..c189b7f 100644 --- a/app/Filament/Resources/Users/Schemas/UserForm.php +++ b/app/Filament/Resources/Users/Schemas/UserForm.php @@ -63,8 +63,8 @@ class UserForm ->autocomplete('new-password'), DateTimePicker::make('email_verified_at') ->label(__('admin.users.fields.email_verified_at')), - DateTimePicker::make('account_verified_at') - ->label(__('admin.users.fields.account_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 d863a5c..41e3e1c 100644 --- a/app/Filament/Resources/Users/Schemas/UserInfolist.php +++ b/app/Filament/Resources/Users/Schemas/UserInfolist.php @@ -35,6 +35,14 @@ class UserInfolist ->label(__('admin.users.fields.account_verified_at')) ->dateTime() ->placeholder(__('admin.users.placeholders.not_verified')), + TextEntry::make('certification_purchased_at') + ->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')) + ->dateTime() + ->placeholder(__('admin.users.placeholders.empty')), TextEntry::make('suspended_at') ->label(__('admin.users.fields.suspended_at')) ->dateTime() diff --git a/app/Filament/Resources/Users/Tables/UsersTable.php b/app/Filament/Resources/Users/Tables/UsersTable.php index 6baf5ba..790a5fa 100644 --- a/app/Filament/Resources/Users/Tables/UsersTable.php +++ b/app/Filament/Resources/Users/Tables/UsersTable.php @@ -51,6 +51,12 @@ class UsersTable ->sortable() ->placeholder(__('admin.users.placeholders.not_verified')) ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('trial_ends_at') + ->label(__('admin.users.fields.trial_ends_at')) + ->dateTime() + ->sortable() + ->placeholder(__('admin.users.placeholders.empty')) + ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('suspended_at') ->label(__('admin.users.fields.suspended_at')) ->dateTime() diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 4082681..dc64b96 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -305,6 +305,7 @@ class AuthController extends Controller public function destroy(Request $request): JsonResponse { $user = $request->user(); + $identityVerificationRequest = $user->identityVerificationRequest()->first(); $storagePaths = collect([$user->avatar_url]) ->merge($user->mealPosts()->withTrashed()->pluck('image_url')) ->filter(fn (?string $path): bool => $this->isPublicStoragePath($path)) @@ -330,6 +331,11 @@ class AuthController extends Controller Storage::disk()->delete($storagePaths); } + if ($identityVerificationRequest?->hasDocument()) { + Storage::disk($identityVerificationRequest->document_disk) + ->delete($identityVerificationRequest->document_path); + } + if ($request->hasSession()) { Auth::guard('web')->logout(); $request->session()->invalidate(); @@ -354,6 +360,11 @@ class AuthController extends Controller $userAttributes['email'] = strtolower($data['email']); $userAttributes['password'] = Hash::make($data['password']); $userAttributes['avatar_url'] = $avatarPath; + $trialDays = max(0, (int) config('billing.trial_days', 7)); + + if ($trialDays > 0) { + $userAttributes['trial_ends_at'] = now()->addDays($trialDays); + } $user = User::create($userAttributes); diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php index 21418b5..55f146f 100644 --- a/app/Http/Controllers/BillingController.php +++ b/app/Http/Controllers/BillingController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers; -use App\Enums\CreditProductType; +use App\Enums\BillingProductPurpose; use App\Http\Resources\CreditProductResource; use App\Models\CreditProduct; use App\Services\MobileDeepLink; @@ -18,6 +18,7 @@ class BillingController extends Controller { $products = CreditProduct::query() ->active() + ->whereNotNull('purpose') ->whereNotNull('stripe_price_id') ->orderBy('sort_order') ->orderBy('amount') @@ -32,6 +33,7 @@ class BillingController extends Controller PaymentTransactionRecorder $transactions, ): JsonResponse { abort_unless($creditProduct->is_active, 404); + abort_unless($creditProduct->purpose !== null, 404); abort_unless(filled($creditProduct->stripe_price_id), 404); $user = $request->user(); @@ -39,8 +41,7 @@ class BillingController extends Controller $metadata = [ 'credit_product_id' => $creditProduct->getKey(), - 'credit_product_type' => $creditProduct->type->value, - 'credits' => (string) $creditProduct->credits, + 'billing_product_purpose' => $creditProduct->purpose->value, ]; $sessionOptions = [ 'success_url' => $this->redirectUrl('success'), @@ -49,7 +50,7 @@ class BillingController extends Controller 'client_reference_id' => $creditProduct->getKey(), ]; - if ($creditProduct->type === CreditProductType::MONTHLY) { + if ($creditProduct->purpose === BillingProductPurpose::SUBSCRIPTION) { if ($user->subscribed('default')) { return response()->json([ 'message' => __('api.billing.active_subscription_exists'), @@ -61,6 +62,12 @@ class BillingController extends Controller ->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' => [ diff --git a/app/Http/Controllers/CertificationController.php b/app/Http/Controllers/CertificationController.php new file mode 100644 index 0000000..c9daa64 --- /dev/null +++ b/app/Http/Controllers/CertificationController.php @@ -0,0 +1,44 @@ +user(); + $verificationRequest = $user->identityVerificationRequest()->first(); + + return response()->json([ + 'data' => [ + 'entitlementPurchased' => $user->hasPurchasedCertification(), + 'accountVerified' => $user->account_verified_at !== null, + 'canSubmitIdentity' => $user->hasPurchasedCertification() + && $user->account_verified_at === null + && $verificationRequest?->status !== IdentityVerificationStatus::PENDING, + 'verification' => $verificationRequest + ? (new IdentityVerificationResource($verificationRequest))->resolve($request) + : null, + ], + ]); + } + + public function store( + StoreIdentityVerificationRequest $request, + SubmitIdentityVerification $submitIdentityVerification, + ): JsonResponse { + $submitIdentityVerification->execute( + $request->user(), + $request->file('document'), + ); + + return $this->show($request); + } +} diff --git a/app/Http/Controllers/MealImageAnalysisController.php b/app/Http/Controllers/MealImageAnalysisController.php index 8fd2eca..365c7c0 100644 --- a/app/Http/Controllers/MealImageAnalysisController.php +++ b/app/Http/Controllers/MealImageAnalysisController.php @@ -7,7 +7,6 @@ use App\Enums\AiUsageType; use App\Enums\IngredientUnit; use App\Enums\MealPostType; use App\Http\Requests\MealImageAnalysisRequest; -use App\Services\AiCreditService; use App\Services\AiUsageRecorder; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Http\JsonResponse; @@ -20,19 +19,15 @@ class MealImageAnalysisController extends Controller { public function store( MealImageAnalysisRequest $request, - AiCreditService $aiCredits, AiUsageRecorder $aiUsageRecorder, ): JsonResponse { $user = $request->user(); $image = $request->file('image'); $input = $this->analysisInput($image); - if (! $aiCredits->consume($user)) { + if (! $user->canAnalyzeMeals()) { return response()->json([ - 'message' => __('api.meal_image_analysis.insufficient_credits'), - 'credits' => [ - 'balance' => $user->ai_credits_balance, - ], + 'message' => __('api.meal_image_analysis.subscription_required'), ], 402); } @@ -45,8 +40,6 @@ class MealImageAnalysisController extends Controller } catch (Throwable $exception) { report($exception); - $aiCredits->refund($user); - $aiUsageRecorder->failed( user: $user, type: AiUsageType::MEAL_IMAGE_ANALYSIS, @@ -79,9 +72,6 @@ class MealImageAnalysisController extends Controller return response()->json([ 'data' => $draft, - 'credits' => [ - 'balance' => $user->fresh()->ai_credits_balance, - ], ]); } diff --git a/app/Http/Requests/StoreIdentityVerificationRequest.php b/app/Http/Requests/StoreIdentityVerificationRequest.php new file mode 100644 index 0000000..338b834 --- /dev/null +++ b/app/Http/Requests/StoreIdentityVerificationRequest.php @@ -0,0 +1,37 @@ +user(); + + return $user?->hasPurchasedCertification() === true + && $user->account_verified_at === null; + } + + public function rules(): array + { + return [ + 'document' => [ + 'required', + File::types(['jpg', 'jpeg', 'png', 'pdf'])->max('10mb'), + 'mimetypes:image/jpeg,image/png,application/pdf', + ], + ]; + } + + public function messages(): array + { + return [ + 'document.required' => __('api.certification.document_required'), + 'document.mimetypes' => __('api.certification.document_type'), + 'document.max' => __('api.certification.document_max'), + ]; + } +} diff --git a/app/Http/Resources/CreditProductResource.php b/app/Http/Resources/CreditProductResource.php index aad5ed0..d9f46de 100644 --- a/app/Http/Resources/CreditProductResource.php +++ b/app/Http/Resources/CreditProductResource.php @@ -16,8 +16,7 @@ class CreditProductResource extends JsonResource 'id' => $this->id, 'name' => $this->name, 'description' => $this->description, - 'type' => $this->type, - 'credits' => $this->credits, + 'purpose' => $this->purpose, 'amount' => $this->amount, 'currency' => $this->currency, 'formattedAmount' => $this->formattedAmount(), diff --git a/app/Http/Resources/IdentityVerificationResource.php b/app/Http/Resources/IdentityVerificationResource.php new file mode 100644 index 0000000..904c841 --- /dev/null +++ b/app/Http/Resources/IdentityVerificationResource.php @@ -0,0 +1,19 @@ + $this->status, + 'submittedAt' => $this->submitted_at?->toISOString(), + 'reviewedAt' => $this->reviewed_at?->toISOString(), + 'rejectionReason' => $this->rejection_reason, + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 7153565..e80c203 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -27,7 +27,10 @@ class UserResource extends JsonResource 'bio' => $this->bio, 'role' => $this->role, 'accountVerified' => $this->account_verified_at !== null, - 'aiCreditsBalance' => (int) $this->ai_credits_balance, + 'certificationPurchased' => $this->hasPurchasedCertification(), + 'canAnalyzeMeals' => $this->canAnalyzeMeals(), + 'analysisAccessLevel' => $this->analysisAccessLevel(), + 'trialEndsAt' => $this->trial_ends_at?->toISOString(), 'hasActiveSubscription' => $this->subscribed('default'), 'suspendedAt' => $this->suspended_at?->toISOString(), 'physicalActivityLevel' => $this->physical_activity_level, diff --git a/app/Listeners/GrantCreditsFromStripeWebhook.php b/app/Listeners/GrantCreditsFromStripeWebhook.php index 9565abb..865fa1c 100644 --- a/app/Listeners/GrantCreditsFromStripeWebhook.php +++ b/app/Listeners/GrantCreditsFromStripeWebhook.php @@ -2,12 +2,10 @@ namespace App\Listeners; -use App\Enums\CreditLedgerEntryType; -use App\Enums\CreditProductType; -use App\Models\CreditLedgerEntry; +use App\Enums\BillingProductPurpose; +use App\Enums\PaymentTransactionStatus; use App\Models\CreditProduct; use App\Models\User; -use App\Services\AiCreditService; use App\Services\PaymentInvoiceEmailer; use App\Services\PaymentTransactionRecorder; use Laravel\Cashier\Cashier; @@ -15,7 +13,6 @@ use Laravel\Cashier\Cashier; class GrantCreditsFromStripeWebhook { public function __construct( - private AiCreditService $credits, private PaymentInvoiceEmailer $invoiceEmails, private PaymentTransactionRecorder $transactions, ) {} @@ -30,8 +27,7 @@ class GrantCreditsFromStripeWebhook match ($payload['type'] ?? null) { 'checkout.session.completed' => $this->handleCheckoutSessionCompleted($payload), 'checkout.session.expired' => $this->handleCheckoutSessionExpired($payload), - 'invoice.paid' => $this->handleInvoicePaid($payload), - 'invoice.payment_succeeded' => $this->handleInvoicePaid($payload), + 'invoice.paid', 'invoice.payment_succeeded' => $this->handleInvoicePaid($payload), 'invoice.payment_failed' => $this->handleInvoicePaymentFailed($payload), default => null, }; @@ -43,87 +39,60 @@ class GrantCreditsFromStripeWebhook private function handleCheckoutSessionCompleted(array $payload): void { $session = $payload['data']['object'] ?? []; - $eventId = $this->stringValue($payload['id'] ?? null); - $eventType = $this->stringValue($payload['type'] ?? null); $sessionId = $this->stringValue($session['id'] ?? null); if (! $sessionId) { return; } - if (($session['mode'] ?? null) !== 'payment' || ($session['payment_status'] ?? null) !== 'paid') { - $this->transactions->recordCheckoutSession( - user: $this->userFromCustomer($session['customer'] ?? null), - product: $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME), - stripeCheckoutSessionId: $sessionId, - status: \App\Enums\PaymentTransactionStatus::IGNORED, - stripeEventId: $eventId, - stripeEventType: $eventType, - 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 is not a paid one-time payment.', - payload: $session, - ); - - return; - } - - $product = $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME); + $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) { + if (! $product || ! $user || ($session['mode'] ?? null) !== $expectedMode || ! $isPaid) { $this->transactions->recordCheckoutSession( user: $user, product: $product, stripeCheckoutSessionId: $sessionId, - status: \App\Enums\PaymentTransactionStatus::ERROR, - stripeEventId: $eventId, - stripeEventType: $eventType, + 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: $product - ? 'Stripe customer does not match any user.' - : 'Credit product was not found from checkout metadata.', + errorMessage: 'Checkout session does not match an eligible paid billing product.', payload: $session, ); return; } - $source = 'stripe_checkout_session:'.$sessionId; - $entry = $this->credits->grant( - user: $user, - credits: $product->credits, - type: CreditLedgerEntryType::PURCHASE, - product: $product, - source: $source, - stripeCheckoutSessionId: $sessionId, - stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), - metadata: [ - 'stripe_event_id' => $eventId, - ], - ); + if ($product->purpose === BillingProductPurpose::CERTIFICATION + && $user->certification_purchased_at === null) { + $user->forceFill([ + 'certification_purchased_at' => now(), + ])->save(); + } - $entry ??= CreditLedgerEntry::query()->where('source', $source)->first(); $invoice = $this->retrieveInvoice($this->stringValue($session['invoice'] ?? null)); - $transaction = $this->transactions->recordCheckoutSession( user: $user, product: $product, stripeCheckoutSessionId: $sessionId, - status: \App\Enums\PaymentTransactionStatus::CREDITED, - stripeEventId: $eventId, - stripeEventType: $eventType, + 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), - creditsGranted: $entry ? $product->credits : 0, - ledgerEntry: $entry, + creditsExpected: 0, invoiceUrl: $invoice ? $this->stringValue($invoice['hosted_invoice_url'] ?? null) : null, invoicePdfUrl: $invoice ? $this->stringValue($invoice['invoice_pdf'] ?? null) : null, payload: $session, @@ -146,15 +115,16 @@ class GrantCreditsFromStripeWebhook $this->transactions->recordCheckoutSession( user: $this->userFromCustomer($session['customer'] ?? null), - product: $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME), + product: $this->productFromMetadata($session['metadata'] ?? null), stripeCheckoutSessionId: $sessionId, - status: \App\Enums\PaymentTransactionStatus::FAILED, + 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), + creditsExpected: 0, errorMessage: 'Checkout session expired before payment.', payload: $session, ); @@ -167,105 +137,41 @@ class GrantCreditsFromStripeWebhook { $invoice = $payload['data']['object'] ?? []; $invoiceId = $this->stringValue($invoice['id'] ?? null); - $eventId = $this->stringValue($payload['id'] ?? null); - $eventType = $this->stringValue($payload['type'] ?? null); if (! $invoiceId) { return; } - $user = $this->userFromCustomer($invoice['customer'] ?? null); - - if (! $user) { - $this->transactions->recordInvoice( - user: null, - product: null, - stripeInvoiceId: $invoiceId, - stripePriceId: null, - status: \App\Enums\PaymentTransactionStatus::ERROR, - stripeEventId: $eventId, - stripeEventType: $eventType, - stripeCustomerId: $this->stringValue($invoice['customer'] ?? null), - stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null), - stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null), - amount: $this->intValue($invoice['amount_paid'] ?? null), - currency: $this->stringValue($invoice['currency'] ?? null), - errorMessage: 'Stripe customer does not match any user.', - payload: $invoice, - ); - - return; - } - - $matchedLine = false; + $matchedProduct = false; foreach (($invoice['lines']['data'] ?? []) as $line) { - $priceId = $line['price']['id'] ?? null; - - if (! $priceId) { - continue; - } - - $product = CreditProduct::query() - ->where('type', CreditProductType::MONTHLY) - ->where('stripe_price_id', $priceId) - ->first(); + $priceId = $this->linePriceId($line); + $product = $priceId + ? CreditProduct::query() + ->where('purpose', BillingProductPurpose::SUBSCRIPTION) + ->where('stripe_price_id', $priceId) + ->first() + : null; if (! $product) { - $this->transactions->recordInvoice( - user: $user, - product: null, - stripeInvoiceId: $invoiceId, - stripePriceId: $this->stringValue($priceId), - status: \App\Enums\PaymentTransactionStatus::ERROR, - stripeEventId: $eventId, - stripeEventType: $eventType, - stripeCustomerId: $this->stringValue($invoice['customer'] ?? null), - stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null), - stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null), - amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null), - currency: $this->stringValue($invoice['currency'] ?? null), - errorMessage: 'No monthly credit product found for Stripe price.', - payload: $invoice, - ); - continue; } - $matchedLine = true; - $source = 'stripe_invoice:'.$invoiceId.':price:'.$priceId; - $entry = $this->credits->grant( - user: $user, - credits: $product->credits, - type: CreditLedgerEntryType::SUBSCRIPTION_RENEWAL, - product: $product, - source: $source, - stripeInvoiceId: $invoiceId, - stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null), - stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null), - metadata: [ - 'billing_reason' => $invoice['billing_reason'] ?? null, - 'stripe_event_id' => $eventId, - ], - ); - - $entry ??= CreditLedgerEntry::query()->where('source', $source)->first(); - + $matchedProduct = true; $transaction = $this->transactions->recordInvoice( - user: $user, + user: $this->userFromCustomer($invoice['customer'] ?? null), product: $product, stripeInvoiceId: $invoiceId, - stripePriceId: $this->stringValue($priceId), - status: \App\Enums\PaymentTransactionStatus::CREDITED, - stripeEventId: $eventId, - stripeEventType: $eventType, + 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->stringValue($invoice['subscription'] ?? null), + stripeSubscriptionId: $this->invoiceSubscriptionId($invoice), amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null), currency: $this->stringValue($invoice['currency'] ?? null), - creditsGranted: $entry ? $product->credits : 0, - ledgerEntry: $entry, + creditsExpected: 0, invoiceUrl: $this->stringValue($invoice['hosted_invoice_url'] ?? null), invoicePdfUrl: $this->stringValue($invoice['invoice_pdf'] ?? null), payload: $invoice, @@ -274,21 +180,22 @@ class GrantCreditsFromStripeWebhook $this->invoiceEmails->sendIfAvailable($transaction); } - if (! $matchedLine) { + if (! $matchedProduct) { $this->transactions->recordInvoice( - user: $user, + user: $this->userFromCustomer($invoice['customer'] ?? null), product: null, stripeInvoiceId: $invoiceId, stripePriceId: null, - status: \App\Enums\PaymentTransactionStatus::IGNORED, - stripeEventId: $eventId, - stripeEventType: $eventType, + 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->stringValue($invoice['subscription'] ?? null), + stripeSubscriptionId: $this->invoiceSubscriptionId($invoice), amount: $this->intValue($invoice['amount_paid'] ?? null), currency: $this->stringValue($invoice['currency'] ?? null), - errorMessage: 'Invoice does not contain a monthly credit product line.', + creditsExpected: 0, + errorMessage: 'Invoice does not contain the active subscription product.', payload: $invoice, ); } @@ -306,11 +213,10 @@ class GrantCreditsFromStripeWebhook return; } - foreach (($invoice['lines']['data'] ?? [null]) as $line) { - $priceId = is_array($line) ? $this->stringValue($line['price']['id'] ?? null) : null; - $amount = is_array($line) - ? $this->intValue($line['amount'] ?? $invoice['amount_due'] ?? null) - : $this->intValue($invoice['amount_due'] ?? null); + $lines = $invoice['lines']['data'] ?? [null]; + + foreach ($lines ?: [null] as $line) { + $priceId = is_array($line) ? $this->linePriceId($line) : null; $product = $priceId ? CreditProduct::query()->where('stripe_price_id', $priceId)->first() : null; @@ -320,14 +226,19 @@ class GrantCreditsFromStripeWebhook product: $product, stripeInvoiceId: $invoiceId, stripePriceId: $priceId, - status: \App\Enums\PaymentTransactionStatus::FAILED, + 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->stringValue($invoice['subscription'] ?? null), - amount: $amount, + 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), + creditsExpected: 0, errorMessage: 'Invoice payment failed.', payload: $invoice, ); @@ -343,18 +254,50 @@ class GrantCreditsFromStripeWebhook return User::query()->where('stripe_id', $customer)->first(); } - private function productFromMetadata(mixed $metadata, CreditProductType $type): ?CreditProduct + private function productFromMetadata(mixed $metadata): ?CreditProduct { - if (! is_array($metadata) || ! is_string($metadata['credit_product_id'] ?? null)) { + if (! is_array($metadata) + || ! is_string($metadata['credit_product_id'] ?? null) + || ! is_string($metadata['billing_product_purpose'] ?? null)) { + return null; + } + + $purpose = BillingProductPurpose::tryFrom($metadata['billing_product_purpose']); + + if (! $purpose) { return null; } return CreditProduct::query() ->whereKey($metadata['credit_product_id']) - ->where('type', $type) + ->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; diff --git a/app/Models/CreditProduct.php b/app/Models/CreditProduct.php index b6f3b3b..2703aab 100644 --- a/app/Models/CreditProduct.php +++ b/app/Models/CreditProduct.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\BillingProductPurpose; use App\Enums\CreditProductType; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; @@ -18,6 +19,7 @@ class CreditProduct extends Model 'name', 'description', 'type', + 'purpose', 'credits', 'amount', 'currency', @@ -51,6 +53,7 @@ class CreditProduct extends Model { return [ 'type' => CreditProductType::class, + 'purpose' => BillingProductPurpose::class, 'credits' => 'integer', 'amount' => 'integer', 'is_active' => 'boolean', diff --git a/app/Models/IdentityVerificationRequest.php b/app/Models/IdentityVerificationRequest.php new file mode 100644 index 0000000..4052642 --- /dev/null +++ b/app/Models/IdentityVerificationRequest.php @@ -0,0 +1,73 @@ + */ + use HasFactory, HasUlids; + + protected $fillable = [ + 'user_id', + 'status', + 'document_disk', + 'document_path', + 'document_mime_type', + 'document_size', + 'submitted_at', + 'reviewed_at', + 'reviewed_by', + 'rejection_reason', + 'document_deleted_at', + ]; + + protected $hidden = [ + 'document_disk', + 'document_path', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by'); + } + + public function hasDocument(): bool + { + return filled($this->document_path) && $this->document_deleted_at === null; + } + + public function temporaryDocumentUrl(): ?string + { + if (! $this->hasDocument()) { + return null; + } + + return Storage::disk($this->document_disk)->temporaryUrl( + $this->document_path, + now()->addMinutes((int) config('billing.identity_verification.temporary_url_minutes', 5)), + ); + } + + protected function casts(): array + { + return [ + 'status' => IdentityVerificationStatus::class, + 'submitted_at' => 'datetime', + 'reviewed_at' => 'datetime', + 'document_deleted_at' => 'datetime', + 'document_size' => 'integer', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 2209064..f7de7ac 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -47,6 +47,8 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale 'height', 'bio', 'account_verified_at', + 'certification_purchased_at', + 'trial_ends_at', 'daily_calorie_goal', 'daily_protein_goal', 'daily_carbs_goal', @@ -82,6 +84,7 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale return [ 'email_verified_at' => 'datetime', 'account_verified_at' => 'datetime', + 'certification_purchased_at' => 'datetime', 'password' => 'hashed', 'daily_calorie_goal' => 'integer', 'daily_protein_goal' => 'float', @@ -129,6 +132,11 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale return $this->hasMany(PaymentTransaction::class); } + public function identityVerificationRequest(): HasOne + { + return $this->hasOne(IdentityVerificationRequest::class); + } + public function workouts(): HasMany { return $this->hasMany(WorkoutSessions::class); @@ -188,6 +196,25 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale return $this->suspended_at !== null; } + public function canAnalyzeMeals(): bool + { + return $this->subscribed('default') || $this->onGenericTrial(); + } + + public function analysisAccessLevel(): string + { + if ($this->subscribed('default')) { + return 'subscribed'; + } + + return $this->onGenericTrial() ? 'trial' : 'free'; + } + + public function hasPurchasedCertification(): bool + { + return $this->certification_purchased_at !== null; + } + public function routeNotificationForExpoPush(): array { return $this->deviceTokens() diff --git a/app/Policies/IdentityVerificationRequestPolicy.php b/app/Policies/IdentityVerificationRequestPolicy.php new file mode 100644 index 0000000..896be2e --- /dev/null +++ b/app/Policies/IdentityVerificationRequestPolicy.php @@ -0,0 +1,44 @@ +isAdmin(); + } + + public function view(User $user, IdentityVerificationRequest $identityVerificationRequest): bool + { + return $user->isAdmin(); + } + + public function create(User $user): bool + { + return false; + } + + public function update(User $user, IdentityVerificationRequest $identityVerificationRequest): bool + { + return $user->isAdmin(); + } + + public function delete(User $user, IdentityVerificationRequest $identityVerificationRequest): bool + { + return false; + } + + public function restore(User $user, IdentityVerificationRequest $identityVerificationRequest): bool + { + return false; + } + + public function forceDelete(User $user, IdentityVerificationRequest $identityVerificationRequest): bool + { + return false; + } +} diff --git a/app/Services/StripeCreditProductSyncer.php b/app/Services/StripeCreditProductSyncer.php index b132c04..40436eb 100644 --- a/app/Services/StripeCreditProductSyncer.php +++ b/app/Services/StripeCreditProductSyncer.php @@ -2,7 +2,7 @@ namespace App\Services; -use App\Enums\CreditProductType; +use App\Enums\BillingProductPurpose; use App\Models\CreditProduct; use Laravel\Cashier\Cashier; @@ -10,6 +10,12 @@ class StripeCreditProductSyncer { public function sync(CreditProduct $product): CreditProduct { + throw_if( + $product->purpose === null, + \InvalidArgumentException::class, + 'A billing product purpose is required before syncing with Stripe.', + ); + $stripe = Cashier::stripe(); $stripeProductId = $product->stripe_product_id; @@ -19,8 +25,7 @@ class StripeCreditProductSyncer 'description' => $product->description, 'metadata' => [ 'credit_product_id' => $product->getKey(), - 'credits' => (string) $product->credits, - 'type' => $product->type->value, + 'billing_product_purpose' => $product->purpose?->value, ], ]); @@ -32,8 +37,7 @@ class StripeCreditProductSyncer 'active' => (bool) $product->is_active, 'metadata' => [ 'credit_product_id' => $product->getKey(), - 'credits' => (string) $product->credits, - 'type' => $product->type->value, + 'billing_product_purpose' => $product->purpose?->value, ], ]); } @@ -42,7 +46,7 @@ class StripeCreditProductSyncer if (! $product->stripe_price_id) { $needsNewPrice = true; - } elseif ($product->wasChanged(['amount', 'currency', 'type', 'credits'])) { + } elseif ($product->wasChanged(['amount', 'currency', 'purpose'])) { $needsNewPrice = true; } @@ -55,12 +59,11 @@ class StripeCreditProductSyncer 'currency' => strtolower($product->currency), 'metadata' => [ 'credit_product_id' => $product->getKey(), - 'credits' => (string) $product->credits, - 'type' => $product->type->value, + 'billing_product_purpose' => $product->purpose?->value, ], ]; - if ($product->type === CreditProductType::MONTHLY) { + if ($product->purpose === BillingProductPurpose::SUBSCRIPTION) { $priceData['recurring'] = [ 'interval' => 'month', ]; diff --git a/config/billing.php b/config/billing.php new file mode 100644 index 0000000..960b605 --- /dev/null +++ b/config/billing.php @@ -0,0 +1,10 @@ + (int) env('BILLING_TRIAL_DAYS', 7), + + 'identity_verification' => [ + 'disk' => 'identity_documents', + 'temporary_url_minutes' => 5, + ], +]; diff --git a/config/filesystems.php b/config/filesystems.php index 4f4fc57..f4093bc 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -1,5 +1,10 @@ false, ], + 'identity_documents' => [ + 'driver' => $identityDocumentsDriver, + 'root' => $identityDocumentsRoot, + 'serve' => true, + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'visibility' => 'private', + 'throw' => true, + 'report' => true, + ], + ], /* diff --git a/database/factories/IdentityVerificationRequestFactory.php b/database/factories/IdentityVerificationRequestFactory.php new file mode 100644 index 0000000..2b314e6 --- /dev/null +++ b/database/factories/IdentityVerificationRequestFactory.php @@ -0,0 +1,31 @@ + + */ +class IdentityVerificationRequestFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'status' => IdentityVerificationStatus::PENDING, + 'document_disk' => 'identity_documents', + 'document_path' => 'submissions/'.fake()->uuid().'.jpg', + 'document_mime_type' => 'image/jpeg', + 'document_size' => fake()->numberBetween(100_000, 2_000_000), + 'submitted_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_08_07_092015_create_identity_verification_requests_table.php b/database/migrations/2026_08_07_092015_create_identity_verification_requests_table.php new file mode 100644 index 0000000..ee68ff8 --- /dev/null +++ b/database/migrations/2026_08_07_092015_create_identity_verification_requests_table.php @@ -0,0 +1,38 @@ +ulid('id')->primary(); + $table->foreignUlid('user_id')->unique()->constrained('users')->cascadeOnDelete(); + $table->string('status', 32)->default('pending')->index(); + $table->string('document_disk'); + $table->string('document_path', 2048)->nullable(); + $table->string('document_mime_type', 128); + $table->unsignedBigInteger('document_size'); + $table->timestampTz('submitted_at')->index(); + $table->timestampTz('reviewed_at')->nullable(); + $table->foreignUlid('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('rejection_reason')->nullable(); + $table->timestampTz('document_deleted_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('identity_verification_requests'); + } +}; diff --git a/database/migrations/2026_08_07_092017_add_billing_access_fields_to_users_table.php b/database/migrations/2026_08_07_092017_add_billing_access_fields_to_users_table.php new file mode 100644 index 0000000..9763590 --- /dev/null +++ b/database/migrations/2026_08_07_092017_add_billing_access_fields_to_users_table.php @@ -0,0 +1,31 @@ +timestampTz('certification_purchased_at') + ->nullable() + ->after('account_verified_at') + ->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('certification_purchased_at'); + }); + } +}; diff --git a/database/migrations/2026_08_07_092018_add_purpose_to_credit_products_table.php b/database/migrations/2026_08_07_092018_add_purpose_to_credit_products_table.php new file mode 100644 index 0000000..b22b26f --- /dev/null +++ b/database/migrations/2026_08_07_092018_add_purpose_to_credit_products_table.php @@ -0,0 +1,28 @@ +string('purpose', 32)->nullable()->after('type')->unique(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('credit_products', function (Blueprint $table): void { + $table->dropColumn('purpose'); + }); + } +}; diff --git a/database/seeders/LegalDocumentSeeder.php b/database/seeders/LegalDocumentSeeder.php index b845dec..d4e20d2 100644 --- a/database/seeders/LegalDocumentSeeder.php +++ b/database/seeders/LegalDocumentSeeder.php @@ -14,7 +14,7 @@ class LegalDocumentSeeder extends Seeder public function run(): void { LegalDocument::query()->updateOrCreate( - ['slug' => 'privacy-policy', 'version' => 1], + ['slug' => 'privacy-policy', 'version' => 2], [ 'type' => LegalDocumentType::PRIVACY_POLICY, 'title' => [ @@ -51,7 +51,20 @@ class LegalDocumentSeeder extends Seeder 'title' => 'Notifications et appareil', 'body' => [ 'Si tu actives les notifications, Bowli enregistre un token Expo Push pour envoyer des notifications liées à ton compte.', - 'La caméra et la bibliothèque photo ne sont utilisées que lorsque tu choisis d’ajouter une image à un repas ou à ton profil.', + 'La caméra et la bibliothèque photo ne sont utilisées que lorsque tu choisis d’ajouter une image à un repas, à ton profil ou à une demande de certification.', + ], + ], + [ + 'title' => 'Certification et pièce d’identité', + 'body' => [ + 'Si tu achètes la certification et demandes le badge, Bowli traite la photo de ta pièce d’identité uniquement pour vérifier que ton profil correspond à une personne réelle.', + 'Le document est stocké dans un espace privé chiffré, accessible uniquement aux administrateurs autorisés par un lien temporaire, puis supprimé après l’acceptation ou le refus. Le statut de la vérification et sa date sont conservés avec ton compte.', + ], + ], + [ + '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.', ], ], [ @@ -69,7 +82,7 @@ class LegalDocumentSeeder extends Seeder ); LegalDocument::query()->updateOrCreate( - ['slug' => 'terms-of-service', 'version' => 1], + ['slug' => 'terms-of-service', 'version' => 2], [ 'type' => LegalDocumentType::TERMS, 'title' => [ @@ -102,6 +115,13 @@ class LegalDocumentSeeder extends Seeder 'Tu t’engages à ne pas publier de contenu illégal, trompeur, haineux, violent, discriminatoire, attentatoire à la vie privée ou aux droits de tiers.', ], ], + [ + '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.', + ], + ], [ 'title' => 'Modération et disponibilité', 'body' => [ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 102925f..c5e291f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -6,6 +6,7 @@ x-app-environment: &app-environment APP_ENV: production APP_DEBUG: "false" OCTANE_SERVER: frankenphp + OCTANE_HTTPS: "true" OCTANE_PORT: "80" OCTANE_ADMIN_PORT: "2019" OCTANE_MAX_REQUESTS: "500" diff --git a/lang/en/admin.php b/lang/en/admin.php index 075a74e..325edff 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -28,6 +28,8 @@ return [ 'role' => 'Role', 'email_verified_at' => 'Email verified at', 'account_verified_at' => 'Account verified at', + 'certification_purchased_at' => 'Certification purchased at', + 'trial_ends_at' => 'Trial ends at', 'suspended_at' => 'Suspended at', 'suspended_by' => 'Suspended by', 'avatar' => 'Avatar', @@ -69,9 +71,9 @@ return [ ], 'credit_products' => [ 'navigation' => [ - 'label' => 'Credit products', - 'singular' => 'Credit product', - 'plural' => 'Credit products', + 'label' => 'Offers', + 'singular' => 'Offer', + 'plural' => 'Offers', ], 'sections' => [ 'product' => 'Product', @@ -81,6 +83,7 @@ return [ 'name' => 'Name', 'description' => 'Description', 'type' => 'Type', + 'purpose' => 'Offer', 'credits' => 'Credits', 'amount' => 'Price', 'currency' => 'Currency', @@ -98,6 +101,35 @@ return [ 'sync_stripe_success' => 'Stripe product synchronized.', ], ], + 'identity_verification_requests' => [ + 'navigation' => [ + 'label' => 'Identity verifications', + 'singular' => 'Identity verification', + 'plural' => 'Identity verifications', + ], + 'sections' => [ + 'request' => 'Certification request', + ], + 'fields' => [ + 'id' => 'ID', + 'status' => 'Status', + 'user' => 'User', + 'submitted_at' => 'Submitted at', + 'reviewed_at' => 'Reviewed at', + 'reviewed_by' => 'Reviewed by', + 'document' => 'Identity document', + 'document_deleted_at' => 'Document deleted at', + 'rejection_reason' => 'Rejection reason', + ], + 'actions' => [ + 'open_document' => 'Open for 5 minutes', + 'approve' => 'Approve', + 'reject' => 'Reject', + ], + 'placeholders' => [ + 'empty' => '-', + ], + ], 'payment_transactions' => [ 'navigation' => [ 'label' => 'Payments', diff --git a/lang/en/api.php b/lang/en/api.php index 82e3bf1..7c2673b 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -26,7 +26,7 @@ return [ ], 'meal_image_analysis' => [ 'failed' => 'Unable to analyze the image right now.', - 'insufficient_credits' => 'You do not have any analysis credits left.', + 'subscription_required' => 'Analysis is available during the trial or with a subscription.', ], 'legal_documents' => [ 'not_found' => 'Legal document not found.', @@ -34,6 +34,14 @@ return [ 'billing' => [ 'active_subscription_exists' => 'You already have an active subscription.', 'no_active_subscription' => 'There is no active subscription to manage.', + 'certification_already_purchased' => 'You have already purchased lifetime certification.', + ], + 'certification' => [ + 'document_required' => 'Add a photo or PDF of your identity document.', + 'document_type' => 'The document must be a JPG, PNG, or PDF file.', + 'document_max' => 'The document may not be larger than 10 MB.', + 'already_pending' => 'Your identity document is already being reviewed.', + 'rejection_reason_required' => 'A rejection reason is required.', ], 'notifications' => [ 'test_title' => 'Test', diff --git a/lang/en/enums.php b/lang/en/enums.php index ba8a1b3..28f6d0e 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -53,6 +53,15 @@ return [ 'monthly' => 'Monthly', 'one_time' => 'Credit pack', ], + 'billing_product_purpose' => [ + 'subscription' => 'Analysis subscription', + 'certification' => 'Lifetime certification', + ], + 'identity_verification_status' => [ + 'pending' => 'Pending review', + 'approved' => 'Approved', + 'rejected' => 'Rejected', + ], 'credit_ledger_entry_type' => [ 'free_grant' => 'Free credits', 'purchase' => 'Purchase', diff --git a/lang/fr/admin.php b/lang/fr/admin.php index 39b115f..bde332b 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -28,6 +28,8 @@ return [ 'role' => 'Rôle', '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', 'suspended_at' => 'Suspendu le', 'suspended_by' => 'Suspendu par', 'avatar' => 'Avatar', @@ -69,9 +71,9 @@ return [ ], 'credit_products' => [ 'navigation' => [ - 'label' => 'Produits de crédits', - 'singular' => 'Produit de crédits', - 'plural' => 'Produits de crédits', + 'label' => 'Offres', + 'singular' => 'Offre', + 'plural' => 'Offres', ], 'sections' => [ 'product' => 'Produit', @@ -81,6 +83,7 @@ return [ 'name' => 'Nom', 'description' => 'Description', 'type' => 'Type', + 'purpose' => 'Offre', 'credits' => 'Crédits', 'amount' => 'Prix', 'currency' => 'Devise', @@ -98,6 +101,35 @@ return [ 'sync_stripe_success' => 'Produit Stripe synchronisé.', ], ], + 'identity_verification_requests' => [ + 'navigation' => [ + 'label' => 'Vérifications d’identité', + 'singular' => 'Vérification d’identité', + 'plural' => 'Vérifications d’identité', + ], + 'sections' => [ + 'request' => 'Demande de certification', + ], + 'fields' => [ + 'id' => 'ID', + 'status' => 'Statut', + 'user' => 'Utilisateur', + 'submitted_at' => 'Envoyée le', + 'reviewed_at' => 'Traitée le', + 'reviewed_by' => 'Traitée par', + 'document' => 'Pièce d’identité', + 'document_deleted_at' => 'Document supprimé le', + 'rejection_reason' => 'Motif du refus', + ], + 'actions' => [ + 'open_document' => 'Ouvrir pendant 5 minutes', + 'approve' => 'Approuver', + 'reject' => 'Refuser', + ], + 'placeholders' => [ + 'empty' => '-', + ], + ], 'payment_transactions' => [ 'navigation' => [ 'label' => 'Paiements', diff --git a/lang/fr/api.php b/lang/fr/api.php index 7d4c35b..1619a5f 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -26,7 +26,7 @@ return [ ], 'meal_image_analysis' => [ 'failed' => "Impossible d'analyser l'image pour le moment.", - 'insufficient_credits' => "Tu n'as plus de crédits d'analyse.", + 'subscription_required' => "L'analyse est disponible pendant l'essai ou avec l'abonnement.", ], 'legal_documents' => [ 'not_found' => 'Document légal introuvable.', @@ -34,6 +34,14 @@ return [ 'billing' => [ 'active_subscription_exists' => 'Tu as déjà un abonnement en cours.', 'no_active_subscription' => 'Aucun abonnement actif à gérer.', + 'certification_already_purchased' => 'Tu as déjà acheté la certification à vie.', + ], + 'certification' => [ + 'document_required' => "Ajoute une photo ou un PDF de ta pièce d'identité.", + 'document_type' => 'Le document doit être un fichier JPG, PNG ou PDF.', + 'document_max' => 'Le document ne doit pas dépasser 10 Mo.', + 'already_pending' => 'Ta pièce d’identité est déjà en cours de vérification.', + 'rejection_reason_required' => 'Un motif de refus est obligatoire.', ], 'notifications' => [ 'test_title' => 'Test', diff --git a/lang/fr/enums.php b/lang/fr/enums.php index 3879f1e..751e25a 100644 --- a/lang/fr/enums.php +++ b/lang/fr/enums.php @@ -53,6 +53,15 @@ return [ 'monthly' => 'Mensuel', 'one_time' => 'Pack de crédits', ], + 'billing_product_purpose' => [ + 'subscription' => 'Abonnement analyse', + 'certification' => 'Certification à vie', + ], + 'identity_verification_status' => [ + 'pending' => 'À vérifier', + 'approved' => 'Approuvée', + 'rejected' => 'Refusée', + ], 'credit_ledger_entry_type' => [ 'free_grant' => 'Crédits offerts', 'purchase' => 'Achat', diff --git a/routes/api.php b/routes/api.php index 4b092b5..7491dac 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\BillingController; +use App\Http\Controllers\CertificationController; use App\Http\Controllers\DeviceTokenController; use App\Http\Controllers\FollowController; use App\Http\Controllers\LegalDocumentController; @@ -65,6 +66,15 @@ Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->prefix('billin Route::post('portal', [BillingController::class, 'portal'])->middleware('throttle:account-action')->name('billing.portal'); }); +Route::middleware(['auth:sanctum', 'verified', 'not_suspended']) + ->prefix('certification') + ->group(function (): void { + Route::get('/', [CertificationController::class, 'show'])->name('certification.show'); + Route::post('identity-document', [CertificationController::class, 'store']) + ->middleware('throttle:account-action') + ->name('certification.identity.store'); + }); + // Workouts Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { Route::get('workouts/calendar', [WorkoutSessionController::class, 'calendar'])->name('workouts.calendar'); diff --git a/tests/Feature/BillingProductsTest.php b/tests/Feature/BillingProductsTest.php index 0ba24a7..a2250c2 100644 --- a/tests/Feature/BillingProductsTest.php +++ b/tests/Feature/BillingProductsTest.php @@ -1,5 +1,6 @@ create()); CreditProduct::create([ - 'name' => 'Pack 10', + 'name' => 'Certification', 'type' => CreditProductType::ONE_TIME, - 'credits' => 10, - 'amount' => 499, + 'purpose' => BillingProductPurpose::CERTIFICATION, + 'credits' => 0, + 'amount' => 1999, 'currency' => 'eur', - 'stripe_price_id' => 'price_pack_10', + 'stripe_price_id' => 'price_certification', 'sort_order' => 20, ]); CreditProduct::create([ - 'name' => 'Mensuel 30', + 'name' => 'Bowli Plus', 'type' => CreditProductType::MONTHLY, - 'credits' => 30, + 'purpose' => BillingProductPurpose::SUBSCRIPTION, + 'credits' => 0, 'amount' => 999, 'currency' => 'eur', - 'stripe_price_id' => 'price_monthly_30', + 'stripe_price_id' => 'price_subscription', 'sort_order' => 10, ]); CreditProduct::create([ - 'name' => 'Inactive', + 'name' => 'Ancien pack', 'type' => CreditProductType::ONE_TIME, 'credits' => 5, 'amount' => 299, 'currency' => 'eur', - 'stripe_price_id' => 'price_inactive', - 'is_active' => false, + 'stripe_price_id' => 'price_legacy', ]); $this ->getJson('/api/billing/products') ->assertOk() ->assertJsonCount(2, 'data') - ->assertJsonPath('data.0.name', 'Mensuel 30') - ->assertJsonPath('data.0.type', CreditProductType::MONTHLY->value) - ->assertJsonPath('data.0.credits', 30) + ->assertJsonPath('data.0.name', 'Bowli Plus') + ->assertJsonPath('data.0.purpose', BillingProductPurpose::SUBSCRIPTION->value) ->assertJsonPath('data.0.formattedAmount', '9,99 EUR') - ->assertJsonPath('data.1.name', 'Pack 10'); + ->assertJsonPath('data.1.name', 'Certification') + ->assertJsonPath('data.1.purpose', BillingProductPurpose::CERTIFICATION->value); }); diff --git a/tests/Feature/CertificationTest.php b/tests/Feature/CertificationTest.php new file mode 100644 index 0000000..4fcf08e --- /dev/null +++ b/tests/Feature/CertificationTest.php @@ -0,0 +1,216 @@ +create()); + + $this->post('/api/certification/identity-document', [ + 'document' => UploadedFile::fake()->image('identity.jpg'), + ])->assertForbidden(); + + expect(IdentityVerificationRequest::query()->count())->toBe(0); +}); + +it('stores identity documents privately without exposing their path', function () { + Storage::fake('identity_documents'); + $user = User::factory()->create([ + 'certification_purchased_at' => now(), + ]); + Sanctum::actingAs($user); + + $response = $this->post('/api/certification/identity-document', [ + 'document' => UploadedFile::fake()->image('my-passport.jpg'), + ]); + + $response + ->assertOk() + ->assertJsonPath('data.entitlementPurchased', true) + ->assertJsonPath('data.canSubmitIdentity', false) + ->assertJsonPath('data.verification.status', IdentityVerificationStatus::PENDING->value) + ->assertJsonMissingPath('data.verification.documentPath'); + + $verificationRequest = IdentityVerificationRequest::query()->sole(); + + Storage::disk('identity_documents')->assertExists($verificationRequest->document_path); + + expect($verificationRequest->document_path)->toStartWith('submissions/') + ->not->toContain('my-passport') + ->and(Storage::disk('identity_documents')->getVisibility($verificationRequest->document_path)) + ->toBe('private'); +}); + +it('prevents replacing a document while it is under review', function () { + Storage::fake('identity_documents'); + $user = User::factory()->create([ + 'certification_purchased_at' => now(), + ]); + IdentityVerificationRequest::factory()->for($user)->create(); + Sanctum::actingAs($user); + + $this + ->withHeader('Accept', 'application/json') + ->post('/api/certification/identity-document', [ + 'document' => UploadedFile::fake()->image('identity.jpg'), + ]) + ->assertConflict() + ->assertJsonPath('message', __('api.certification.already_pending')); +}); + +it('approves a certification and removes the reviewed identity document', function () { + Storage::fake('identity_documents'); + $user = User::factory()->create([ + 'certification_purchased_at' => now(), + ]); + $admin = User::factory()->create([ + 'role' => UserRole::ADMIN, + ]); + $verificationRequest = IdentityVerificationRequest::factory()->for($user)->create(); + Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity'); + + app(ReviewIdentityVerification::class)->execute( + $verificationRequest, + IdentityVerificationStatus::APPROVED, + $admin, + ); + + Storage::disk('identity_documents')->assertMissing($verificationRequest->document_path); + + expect($user->fresh()->account_verified_at)->not->toBeNull() + ->and($verificationRequest->fresh()->status)->toBe(IdentityVerificationStatus::APPROVED) + ->and($verificationRequest->fresh()->document_path)->toBeNull() + ->and($verificationRequest->fresh()->document_deleted_at)->not->toBeNull(); +}); + +it('allows a new submission after rejection without another purchase', function () { + Storage::fake('identity_documents'); + $user = User::factory()->create([ + 'certification_purchased_at' => now(), + ]); + $admin = User::factory()->create([ + 'role' => UserRole::ADMIN, + ]); + $verificationRequest = IdentityVerificationRequest::factory()->for($user)->create(); + Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity'); + + app(ReviewIdentityVerification::class)->execute( + $verificationRequest, + IdentityVerificationStatus::REJECTED, + $admin, + 'Le document est illisible.', + ); + + Sanctum::actingAs($user); + + $this->post('/api/certification/identity-document', [ + 'document' => UploadedFile::fake()->image('identity-new.jpg'), + ]) + ->assertOk() + ->assertJsonPath('data.verification.status', IdentityVerificationStatus::PENDING->value); + + expect($verificationRequest->fresh()->rejection_reason)->toBeNull() + ->and($verificationRequest->fresh()->document_path)->not->toBeNull(); +}); + +it('restricts identity review records to administrators', function () { + $policy = new IdentityVerificationRequestPolicy; + $request = IdentityVerificationRequest::factory()->make(); + $admin = User::factory()->make(['role' => UserRole::ADMIN]); + $moderator = User::factory()->make(['role' => UserRole::MODERATOR]); + + expect($policy->viewAny($admin))->toBeTrue() + ->and($policy->view($admin, $request))->toBeTrue() + ->and($policy->viewAny($moderator))->toBeFalse() + ->and($policy->view($moderator, $request))->toBeFalse(); +}); + +it('lets an administrator approve the identity request from filament', function () { + app()->setLocale('fr'); + Filament::setCurrentPanel(Filament::getPanel('dashboard')); + Storage::fake('identity_documents'); + + $admin = User::factory()->create([ + 'role' => UserRole::ADMIN, + ]); + $user = User::factory()->create([ + 'certification_purchased_at' => now(), + ]); + $verificationRequest = IdentityVerificationRequest::factory()->for($user)->create(); + Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity'); + + $this->actingAs($admin); + + Livewire::test(ListIdentityVerificationRequests::class) + ->assertCanSeeTableRecords([$verificationRequest]) + ->callTableAction('approve', $verificationRequest); + + expect($verificationRequest->fresh()->status)->toBe(IdentityVerificationStatus::APPROVED) + ->and($user->fresh()->account_verified_at)->not->toBeNull(); +}); + +it('grants lifetime certification entitlement from the paid Stripe webhook once', function () { + $user = User::factory()->create([ + 'stripe_id' => 'cus_certification', + ]); + $product = CreditProduct::create([ + 'name' => 'Certification', + 'type' => CreditProductType::ONE_TIME, + 'purpose' => BillingProductPurpose::CERTIFICATION, + 'credits' => 0, + 'amount' => 1999, + 'currency' => 'eur', + 'stripe_price_id' => 'price_certification', + ]); + $event = (object) [ + 'payload' => [ + 'id' => 'evt_certification', + 'type' => 'checkout.session.completed', + 'data' => [ + 'object' => [ + 'id' => 'cs_certification', + 'mode' => 'payment', + 'payment_status' => 'paid', + 'customer' => $user->stripe_id, + 'payment_intent' => 'pi_certification', + 'amount_total' => 1999, + 'currency' => 'eur', + 'metadata' => [ + 'credit_product_id' => $product->getKey(), + 'billing_product_purpose' => BillingProductPurpose::CERTIFICATION->value, + ], + ], + ], + ], + ]; + + $listener = app(GrantCreditsFromStripeWebhook::class); + $listener->handle($event); + $firstPurchaseDate = $user->fresh()->certification_purchased_at; + $listener->handle($event); + + expect($firstPurchaseDate)->not->toBeNull() + ->and($user->fresh()->certification_purchased_at?->equalTo($firstPurchaseDate))->toBeTrue() + ->and(PaymentTransaction::query()->count())->toBe(1) + ->and(PaymentTransaction::query()->sole()->status)->toBe(PaymentTransactionStatus::PAID); +}); diff --git a/tests/Feature/EmailVerificationTest.php b/tests/Feature/EmailVerificationTest.php index 399b7cd..6675fd7 100644 --- a/tests/Feature/EmailVerificationTest.php +++ b/tests/Feature/EmailVerificationTest.php @@ -32,6 +32,8 @@ it('sends a verification email when a user registers', function () { ->assertJsonPath('user.email', 'leon@example.com') ->assertJsonPath('user.locale', 'fr') ->assertJsonPath('user.emailVerified', false) + ->assertJsonPath('user.analysisAccessLevel', 'trial') + ->assertJsonPath('user.canAnalyzeMeals', true) ->assertJsonPath('user.physicalActivityLevel', PhysicalActivityLevel::LIGHTLY_ACTIVE->value) ->assertJsonPath('user.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value) ->assertJsonPath('user.pacePreference', PacePreference::NORMAL->value) @@ -41,7 +43,9 @@ it('sends a verification email when a user registers', function () { $user = User::where('email', 'leon@example.com')->firstOrFail(); - expect($user->hasVerifiedEmail())->toBeFalse(); + expect($user->hasVerifiedEmail())->toBeFalse() + ->and($user->trial_ends_at)->not->toBeNull() + ->and($user->trial_ends_at->isFuture())->toBeTrue(); $this->assertDatabaseHas('users', [ 'id' => $user->id, diff --git a/tests/Feature/MealImageAnalysisTest.php b/tests/Feature/MealImageAnalysisTest.php index 64d4158..69e3a47 100644 --- a/tests/Feature/MealImageAnalysisTest.php +++ b/tests/Feature/MealImageAnalysisTest.php @@ -3,11 +3,9 @@ use App\Ai\Agents\MealImageAnalyzer; use App\Enums\AiUsageStatus; use App\Enums\AiUsageType; -use App\Enums\CreditLedgerEntryType; use App\Enums\IngredientUnit; use App\Enums\MealPostType; use App\Models\AiUsage; -use App\Models\CreditLedgerEntry; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; @@ -28,7 +26,9 @@ function fakeMealAnalysisImage(): UploadedFile } it('analyzes a meal image into a draft', function () { - $user = User::factory()->create(); + $user = User::factory()->create([ + 'trial_ends_at' => now()->addDay(), + ]); Sanctum::actingAs($user); @@ -66,7 +66,6 @@ it('analyzes a meal image into a draft', function () { $response ->assertOk() - ->assertJsonPath('credits.balance', 2) ->assertJsonPath('data.title', 'Bowl saumon avocat') ->assertJsonPath('data.type', MealPostType::LUNCH->value) ->assertJsonPath('data.calories', 612) @@ -110,18 +109,12 @@ it('analyzes a meal image into a draft', function () { ], ]); - $user->refresh(); - $ledgerEntry = CreditLedgerEntry::query()->sole(); - - expect($user->ai_credits_balance)->toBe(2) - ->and($ledgerEntry->user_id)->toBe($user->id) - ->and($ledgerEntry->type)->toBe(CreditLedgerEntryType::USAGE) - ->and($ledgerEntry->credits)->toBe(-1) - ->and($ledgerEntry->balance_after)->toBe(2); }); it('records a failed meal image analysis attempt', function () { - $user = User::factory()->create(); + $user = User::factory()->create([ + 'trial_ends_at' => now()->addDay(), + ]); Sanctum::actingAs($user); Exceptions::fake(); @@ -152,12 +145,11 @@ it('records a failed meal image analysis attempt', function () { ->and($usage->output)->toBeNull() ->and($usage->error_message)->toContain('Provider unavailable'); - expect($user->fresh()->ai_credits_balance)->toBe(3); }); -it('requires available credits to analyze a meal image', function () { +it('requires a trial or subscription to analyze a meal image', function () { $user = User::factory()->create([ - 'ai_credits_balance' => 0, + 'trial_ends_at' => now()->subDay(), ]); Sanctum::actingAs($user); @@ -174,11 +166,9 @@ it('requires available credits to analyze a meal image', function () { 'image' => fakeMealAnalysisImage(), ]) ->assertStatus(402) - ->assertJsonPath('message', __('api.meal_image_analysis.insufficient_credits')) - ->assertJsonPath('credits.balance', 0); + ->assertJsonPath('message', __('api.meal_image_analysis.subscription_required')); - expect(AiUsage::query()->count())->toBe(0) - ->and(CreditLedgerEntry::query()->count())->toBe(0); + expect(AiUsage::query()->count())->toBe(0); }); it('requires authentication to analyze a meal image', function () { @@ -191,7 +181,9 @@ it('requires authentication to analyze a meal image', function () { }); it('validates the analyzed image', function () { - Sanctum::actingAs(User::factory()->create()); + Sanctum::actingAs(User::factory()->create([ + 'trial_ends_at' => now()->addDay(), + ])); $this ->postJson('/api/meal-posts/analyze-image') diff --git a/tests/Feature/SubscriptionAccessTest.php b/tests/Feature/SubscriptionAccessTest.php new file mode 100644 index 0000000..5be61b3 --- /dev/null +++ b/tests/Feature/SubscriptionAccessTest.php @@ -0,0 +1,40 @@ +create([ + 'trial_ends_at' => now()->addDay(), + ]); + + expect($user->canAnalyzeMeals())->toBeTrue() + ->and($user->analysisAccessLevel())->toBe('trial'); +}); + +it('grants analysis access to active subscribers', function () { + $user = User::factory()->create([ + 'trial_ends_at' => now()->subDay(), + ]); + + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_active', + 'stripe_status' => 'active', + 'stripe_price' => 'price_subscription', + ]); + + expect($user->canAnalyzeMeals())->toBeTrue() + ->and($user->analysisAccessLevel())->toBe('subscribed'); +}); + +it('keeps publishing accounts free without analysis access after the trial', function () { + $user = User::factory()->create([ + 'trial_ends_at' => now()->subDay(), + ]); + + expect($user->canAnalyzeMeals())->toBeFalse() + ->and($user->analysisAccessLevel())->toBe('free'); +});