feat: subscriptions
CI / 🧪 Tests Laravel (push) Successful in 2m48s
CI / 🐳 Build & Push Image (push) Successful in 3m0s

This commit is contained in:
2026-08-07 11:53:46 +02:00
parent 99cb0e63e6
commit 2ece4b6e3e
51 changed files with 1489 additions and 254 deletions
+1
View File
@@ -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
+1
View File
@@ -71,6 +71,7 @@ STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
CASHIER_CURRENCY=eur
BILLING_TRIAL_DAYS=7
HEALTH_SECRET_TOKEN=
HORIZON_HEARTBEAT_URL=
@@ -0,0 +1,84 @@
<?php
namespace App\Actions;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationRequest;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ReviewIdentityVerification
{
public function execute(
IdentityVerificationRequest $verificationRequest,
IdentityVerificationStatus $status,
User $reviewer,
?string $rejectionReason = null,
): IdentityVerificationRequest {
abort_unless($reviewer->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);
}
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Actions;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationRequest;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Throwable;
class SubmitIdentityVerification
{
public function execute(User $user, UploadedFile $document): IdentityVerificationRequest
{
return Cache::lock('identity-verification-submit:'.$user->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.'),
};
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum BillingProductPurpose: string implements HasColor, HasLabel
{
case SUBSCRIPTION = 'subscription';
case CERTIFICATION = 'certification';
public function getLabel(): string
{
return match ($this) {
self::SUBSCRIPTION => __('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',
};
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum IdentityVerificationStatus: string implements HasColor, HasLabel
{
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
public function getLabel(): string
{
return match ($this) {
self::PENDING => __('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',
};
}
}
@@ -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);
@@ -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'))
@@ -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')),
])
@@ -0,0 +1,61 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests;
use App\Filament\Resources\IdentityVerificationRequests\Pages\ListIdentityVerificationRequests;
use App\Filament\Resources\IdentityVerificationRequests\Pages\ViewIdentityVerificationRequest;
use App\Filament\Resources\IdentityVerificationRequests\Schemas\IdentityVerificationRequestInfolist;
use App\Filament\Resources\IdentityVerificationRequests\Tables\IdentityVerificationRequestsTable;
use App\Models\IdentityVerificationRequest;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class IdentityVerificationRequestResource extends Resource
{
protected static ?string $model = IdentityVerificationRequest::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedIdentification;
protected static ?string $recordTitleAttribute = 'id';
public static function getNavigationLabel(): string
{
return __('admin.identity_verification_requests.navigation.label');
}
public static function getModelLabel(): string
{
return __('admin.identity_verification_requests.navigation.singular');
}
public static function getPluralModelLabel(): string
{
return __('admin.identity_verification_requests.navigation.plural');
}
public static function canCreate(): bool
{
return false;
}
public static function infolist(Schema $schema): Schema
{
return IdentityVerificationRequestInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return IdentityVerificationRequestsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListIdentityVerificationRequests::route('/'),
'view' => ViewIdentityVerificationRequest::route('/{record}'),
];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests\Pages;
use App\Filament\Resources\IdentityVerificationRequests\IdentityVerificationRequestResource;
use Filament\Resources\Pages\ListRecords;
class ListIdentityVerificationRequests extends ListRecords
{
protected static string $resource = IdentityVerificationRequestResource::class;
protected function getHeaderActions(): array
{
return [];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests\Pages;
use App\Filament\Resources\IdentityVerificationRequests\IdentityVerificationRequestResource;
use App\Filament\Resources\IdentityVerificationRequests\Tables\IdentityVerificationRequestsTable;
use Filament\Resources\Pages\ViewRecord;
class ViewIdentityVerificationRequest extends ViewRecord
{
protected static string $resource = IdentityVerificationRequestResource::class;
protected function getHeaderActions(): array
{
return [
IdentityVerificationRequestsTable::approveAction(),
IdentityVerificationRequestsTable::rejectAction(),
];
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests\Schemas;
use App\Models\IdentityVerificationRequest;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class IdentityVerificationRequestInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->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(),
]),
]);
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests\Tables;
use App\Actions\ReviewIdentityVerification;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationRequest;
use App\Models\User;
use Filament\Actions\Action;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class IdentityVerificationRequestsTable
{
public static function configure(Table $table): Table
{
return $table
->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;
}
}
@@ -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)
@@ -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()
@@ -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()
+11
View File
@@ -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);
+11 -4
View File
@@ -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' => [
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Actions\SubmitIdentityVerification;
use App\Enums\IdentityVerificationStatus;
use App\Http\Requests\StoreIdentityVerificationRequest;
use App\Http\Resources\IdentityVerificationResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CertificationController extends Controller
{
public function show(Request $request): JsonResponse
{
$user = $request->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);
}
}
@@ -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,
],
]);
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\File;
class StoreIdentityVerificationRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->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'),
];
}
}
+1 -2
View File
@@ -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(),
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IdentityVerificationResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'status' => $this->status,
'submittedAt' => $this->submitted_at?->toISOString(),
'reviewedAt' => $this->reviewed_at?->toISOString(),
'rejectionReason' => $this->rejection_reason,
];
}
}
+4 -1
View File
@@ -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,
+98 -155
View File
@@ -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)
$priceId = $this->linePriceId($line);
$product = $priceId
? CreditProduct::query()
->where('purpose', BillingProductPurpose::SUBSCRIPTION)
->where('stripe_price_id', $priceId)
->first();
->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<string, mixed> $line
*/
private function linePriceId(array $line): ?string
{
return $this->stringValue(
$line['price']['id']
?? $line['pricing']['price_details']['price']
?? null
);
}
/**
* @param array<string, mixed> $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;
+3
View File
@@ -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',
@@ -0,0 +1,73 @@
<?php
namespace App\Models;
use App\Enums\IdentityVerificationStatus;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class IdentityVerificationRequest extends Model
{
/** @use HasFactory<\Database\Factories\IdentityVerificationRequestFactory> */
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',
];
}
}
+27
View File
@@ -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()
@@ -0,0 +1,44 @@
<?php
namespace App\Policies;
use App\Models\IdentityVerificationRequest;
use App\Models\User;
class IdentityVerificationRequestPolicy
{
public function viewAny(User $user): bool
{
return $user->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;
}
}
+12 -9
View File
@@ -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',
];
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
'trial_days' => (int) env('BILLING_TRIAL_DAYS', 7),
'identity_verification' => [
'disk' => 'identity_documents',
'temporary_url_minutes' => 5,
],
];
+20
View File
@@ -1,5 +1,10 @@
<?php
$identityDocumentsDriver = env('FILESYSTEM_DISK', 'local');
$identityDocumentsRoot = $identityDocumentsDriver === 'local'
? storage_path('app/private/identity-documents')
: 'identity-documents';
return [
/*
@@ -60,6 +65,21 @@ return [
'report' => 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,
],
],
/*
@@ -0,0 +1,31 @@
<?php
namespace Database\Factories;
use App\Enums\IdentityVerificationStatus;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\IdentityVerificationRequest>
*/
class IdentityVerificationRequestFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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(),
];
}
}
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('identity_verification_requests', function (Blueprint $table): void {
$table->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');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->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');
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('credit_products', function (Blueprint $table): void {
$table->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');
});
}
};
+23 -3
View File
@@ -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 dajouter une image à un repas ou à ton profil.',
'La caméra et la bibliothèque photo ne sont utilisées que lorsque tu choisis dajouter une image à un repas, à ton profil ou à une demande de certification.',
],
],
[
'title' => 'Certification et pièce didentité',
'body' => [
'Si tu achètes la certification et demandes le badge, Bowli traite la photo de ta pièce didentité 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 lacceptation 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 daccè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 tengages à 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. Lanalyse automatique des photos est accessible pendant la période dessai indiquée dans lapplication, puis avec lunique formule dabonnement active.',
'La certification est un achat unique distinct de labonnement. Le paiement donne le droit de soumettre une demande, mais le badge nest accordé quaprès vérification dune pièce didentité valide. En cas de refus, une nouvelle pièce peut être envoyée sans nouvel achat.',
],
],
[
'title' => 'Modération et disponibilité',
'body' => [
+1
View File
@@ -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"
+35 -3
View File
@@ -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',
+9 -1
View File
@@ -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',
+9
View File
@@ -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',
+35 -3
View File
@@ -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 lessai',
'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 didentité',
'singular' => 'Vérification didentité',
'plural' => 'Vérifications didentité',
],
'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 didentité',
'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',
+9 -1
View File
@@ -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 didentité est déjà en cours de vérification.',
'rejection_reason_required' => 'Un motif de refus est obligatoire.',
],
'notifications' => [
'test_title' => 'Test',
+9
View File
@@ -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',
+10
View File
@@ -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');
+17 -15
View File
@@ -1,5 +1,6 @@
<?php
use App\Enums\BillingProductPurpose;
use App\Enums\CreditProductType;
use App\Models\CreditProduct;
use App\Models\User;
@@ -8,44 +9,45 @@ use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
it('lists active credit products ordered for the mobile app', function () {
it('lists the subscription and certification offers for the mobile app', function () {
Sanctum::actingAs(User::factory()->create());
CreditProduct::create([
'name' => 'Pack 10',
'name' => 'Certification',
'type' => CreditProductType::ONE_TIME,
'credits' => 10,
'amount' => 499,
'purpose' => BillingProductPurpose::CERTIFICATION,
'credits' => 0,
'amount' => 1999,
'currency' => 'eur',
'stripe_price_id' => 'price_pack_10',
'stripe_price_id' => 'price_certification',
'sort_order' => 20,
]);
CreditProduct::create([
'name' => 'Mensuel 30',
'name' => 'Bowli Plus',
'type' => CreditProductType::MONTHLY,
'credits' => 30,
'purpose' => BillingProductPurpose::SUBSCRIPTION,
'credits' => 0,
'amount' => 999,
'currency' => 'eur',
'stripe_price_id' => 'price_monthly_30',
'stripe_price_id' => 'price_subscription',
'sort_order' => 10,
]);
CreditProduct::create([
'name' => 'Inactive',
'name' => 'Ancien pack',
'type' => CreditProductType::ONE_TIME,
'credits' => 5,
'amount' => 299,
'currency' => 'eur',
'stripe_price_id' => 'price_inactive',
'is_active' => false,
'stripe_price_id' => 'price_legacy',
]);
$this
->getJson('/api/billing/products')
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.name', 'Mensuel 30')
->assertJsonPath('data.0.type', CreditProductType::MONTHLY->value)
->assertJsonPath('data.0.credits', 30)
->assertJsonPath('data.0.name', 'Bowli Plus')
->assertJsonPath('data.0.purpose', BillingProductPurpose::SUBSCRIPTION->value)
->assertJsonPath('data.0.formattedAmount', '9,99 EUR')
->assertJsonPath('data.1.name', 'Pack 10');
->assertJsonPath('data.1.name', 'Certification')
->assertJsonPath('data.1.purpose', BillingProductPurpose::CERTIFICATION->value);
});
+216
View File
@@ -0,0 +1,216 @@
<?php
use App\Actions\ReviewIdentityVerification;
use App\Enums\BillingProductPurpose;
use App\Enums\CreditProductType;
use App\Enums\IdentityVerificationStatus;
use App\Enums\PaymentTransactionStatus;
use App\Enums\UserRole;
use App\Filament\Resources\IdentityVerificationRequests\Pages\ListIdentityVerificationRequests;
use App\Listeners\GrantCreditsFromStripeWebhook;
use App\Models\CreditProduct;
use App\Models\IdentityVerificationRequest;
use App\Models\PaymentTransaction;
use App\Models\User;
use App\Policies\IdentityVerificationRequestPolicy;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('requires the lifetime purchase before accepting an identity document', function () {
Storage::fake('identity_documents');
Sanctum::actingAs(User::factory()->create());
$this->post('/api/certification/identity-document', [
'document' => UploadedFile::fake()->image('identity.jpg'),
])->assertForbidden();
expect(IdentityVerificationRequest::query()->count())->toBe(0);
});
it('stores identity documents privately without exposing their path', function () {
Storage::fake('identity_documents');
$user = User::factory()->create([
'certification_purchased_at' => now(),
]);
Sanctum::actingAs($user);
$response = $this->post('/api/certification/identity-document', [
'document' => UploadedFile::fake()->image('my-passport.jpg'),
]);
$response
->assertOk()
->assertJsonPath('data.entitlementPurchased', true)
->assertJsonPath('data.canSubmitIdentity', false)
->assertJsonPath('data.verification.status', IdentityVerificationStatus::PENDING->value)
->assertJsonMissingPath('data.verification.documentPath');
$verificationRequest = IdentityVerificationRequest::query()->sole();
Storage::disk('identity_documents')->assertExists($verificationRequest->document_path);
expect($verificationRequest->document_path)->toStartWith('submissions/')
->not->toContain('my-passport')
->and(Storage::disk('identity_documents')->getVisibility($verificationRequest->document_path))
->toBe('private');
});
it('prevents replacing a document while it is under review', function () {
Storage::fake('identity_documents');
$user = User::factory()->create([
'certification_purchased_at' => now(),
]);
IdentityVerificationRequest::factory()->for($user)->create();
Sanctum::actingAs($user);
$this
->withHeader('Accept', 'application/json')
->post('/api/certification/identity-document', [
'document' => UploadedFile::fake()->image('identity.jpg'),
])
->assertConflict()
->assertJsonPath('message', __('api.certification.already_pending'));
});
it('approves a certification and removes the reviewed identity document', function () {
Storage::fake('identity_documents');
$user = User::factory()->create([
'certification_purchased_at' => now(),
]);
$admin = User::factory()->create([
'role' => UserRole::ADMIN,
]);
$verificationRequest = IdentityVerificationRequest::factory()->for($user)->create();
Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity');
app(ReviewIdentityVerification::class)->execute(
$verificationRequest,
IdentityVerificationStatus::APPROVED,
$admin,
);
Storage::disk('identity_documents')->assertMissing($verificationRequest->document_path);
expect($user->fresh()->account_verified_at)->not->toBeNull()
->and($verificationRequest->fresh()->status)->toBe(IdentityVerificationStatus::APPROVED)
->and($verificationRequest->fresh()->document_path)->toBeNull()
->and($verificationRequest->fresh()->document_deleted_at)->not->toBeNull();
});
it('allows a new submission after rejection without another purchase', function () {
Storage::fake('identity_documents');
$user = User::factory()->create([
'certification_purchased_at' => now(),
]);
$admin = User::factory()->create([
'role' => UserRole::ADMIN,
]);
$verificationRequest = IdentityVerificationRequest::factory()->for($user)->create();
Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity');
app(ReviewIdentityVerification::class)->execute(
$verificationRequest,
IdentityVerificationStatus::REJECTED,
$admin,
'Le document est illisible.',
);
Sanctum::actingAs($user);
$this->post('/api/certification/identity-document', [
'document' => UploadedFile::fake()->image('identity-new.jpg'),
])
->assertOk()
->assertJsonPath('data.verification.status', IdentityVerificationStatus::PENDING->value);
expect($verificationRequest->fresh()->rejection_reason)->toBeNull()
->and($verificationRequest->fresh()->document_path)->not->toBeNull();
});
it('restricts identity review records to administrators', function () {
$policy = new IdentityVerificationRequestPolicy;
$request = IdentityVerificationRequest::factory()->make();
$admin = User::factory()->make(['role' => UserRole::ADMIN]);
$moderator = User::factory()->make(['role' => UserRole::MODERATOR]);
expect($policy->viewAny($admin))->toBeTrue()
->and($policy->view($admin, $request))->toBeTrue()
->and($policy->viewAny($moderator))->toBeFalse()
->and($policy->view($moderator, $request))->toBeFalse();
});
it('lets an administrator approve the identity request from filament', function () {
app()->setLocale('fr');
Filament::setCurrentPanel(Filament::getPanel('dashboard'));
Storage::fake('identity_documents');
$admin = User::factory()->create([
'role' => UserRole::ADMIN,
]);
$user = User::factory()->create([
'certification_purchased_at' => now(),
]);
$verificationRequest = IdentityVerificationRequest::factory()->for($user)->create();
Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity');
$this->actingAs($admin);
Livewire::test(ListIdentityVerificationRequests::class)
->assertCanSeeTableRecords([$verificationRequest])
->callTableAction('approve', $verificationRequest);
expect($verificationRequest->fresh()->status)->toBe(IdentityVerificationStatus::APPROVED)
->and($user->fresh()->account_verified_at)->not->toBeNull();
});
it('grants lifetime certification entitlement from the paid Stripe webhook once', function () {
$user = User::factory()->create([
'stripe_id' => 'cus_certification',
]);
$product = CreditProduct::create([
'name' => 'Certification',
'type' => CreditProductType::ONE_TIME,
'purpose' => BillingProductPurpose::CERTIFICATION,
'credits' => 0,
'amount' => 1999,
'currency' => 'eur',
'stripe_price_id' => 'price_certification',
]);
$event = (object) [
'payload' => [
'id' => 'evt_certification',
'type' => 'checkout.session.completed',
'data' => [
'object' => [
'id' => 'cs_certification',
'mode' => 'payment',
'payment_status' => 'paid',
'customer' => $user->stripe_id,
'payment_intent' => 'pi_certification',
'amount_total' => 1999,
'currency' => 'eur',
'metadata' => [
'credit_product_id' => $product->getKey(),
'billing_product_purpose' => BillingProductPurpose::CERTIFICATION->value,
],
],
],
],
];
$listener = app(GrantCreditsFromStripeWebhook::class);
$listener->handle($event);
$firstPurchaseDate = $user->fresh()->certification_purchased_at;
$listener->handle($event);
expect($firstPurchaseDate)->not->toBeNull()
->and($user->fresh()->certification_purchased_at?->equalTo($firstPurchaseDate))->toBeTrue()
->and(PaymentTransaction::query()->count())->toBe(1)
->and(PaymentTransaction::query()->sole()->status)->toBe(PaymentTransactionStatus::PAID);
});
+5 -1
View File
@@ -32,6 +32,8 @@ it('sends a verification email when a user registers', function () {
->assertJsonPath('user.email', 'leon@example.com')
->assertJsonPath('user.locale', 'fr')
->assertJsonPath('user.emailVerified', false)
->assertJsonPath('user.analysisAccessLevel', 'trial')
->assertJsonPath('user.canAnalyzeMeals', true)
->assertJsonPath('user.physicalActivityLevel', PhysicalActivityLevel::LIGHTLY_ACTIVE->value)
->assertJsonPath('user.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value)
->assertJsonPath('user.pacePreference', PacePreference::NORMAL->value)
@@ -41,7 +43,9 @@ it('sends a verification email when a user registers', function () {
$user = User::where('email', 'leon@example.com')->firstOrFail();
expect($user->hasVerifiedEmail())->toBeFalse();
expect($user->hasVerifiedEmail())->toBeFalse()
->and($user->trial_ends_at)->not->toBeNull()
->and($user->trial_ends_at->isFuture())->toBeTrue();
$this->assertDatabaseHas('users', [
'id' => $user->id,
+13 -21
View File
@@ -3,11 +3,9 @@
use App\Ai\Agents\MealImageAnalyzer;
use App\Enums\AiUsageStatus;
use App\Enums\AiUsageType;
use App\Enums\CreditLedgerEntryType;
use App\Enums\IngredientUnit;
use App\Enums\MealPostType;
use App\Models\AiUsage;
use App\Models\CreditLedgerEntry;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
@@ -28,7 +26,9 @@ function fakeMealAnalysisImage(): UploadedFile
}
it('analyzes a meal image into a draft', function () {
$user = User::factory()->create();
$user = User::factory()->create([
'trial_ends_at' => now()->addDay(),
]);
Sanctum::actingAs($user);
@@ -66,7 +66,6 @@ it('analyzes a meal image into a draft', function () {
$response
->assertOk()
->assertJsonPath('credits.balance', 2)
->assertJsonPath('data.title', 'Bowl saumon avocat')
->assertJsonPath('data.type', MealPostType::LUNCH->value)
->assertJsonPath('data.calories', 612)
@@ -110,18 +109,12 @@ it('analyzes a meal image into a draft', function () {
],
]);
$user->refresh();
$ledgerEntry = CreditLedgerEntry::query()->sole();
expect($user->ai_credits_balance)->toBe(2)
->and($ledgerEntry->user_id)->toBe($user->id)
->and($ledgerEntry->type)->toBe(CreditLedgerEntryType::USAGE)
->and($ledgerEntry->credits)->toBe(-1)
->and($ledgerEntry->balance_after)->toBe(2);
});
it('records a failed meal image analysis attempt', function () {
$user = User::factory()->create();
$user = User::factory()->create([
'trial_ends_at' => now()->addDay(),
]);
Sanctum::actingAs($user);
Exceptions::fake();
@@ -152,12 +145,11 @@ it('records a failed meal image analysis attempt', function () {
->and($usage->output)->toBeNull()
->and($usage->error_message)->toContain('Provider unavailable');
expect($user->fresh()->ai_credits_balance)->toBe(3);
});
it('requires available credits to analyze a meal image', function () {
it('requires a trial or subscription to analyze a meal image', function () {
$user = User::factory()->create([
'ai_credits_balance' => 0,
'trial_ends_at' => now()->subDay(),
]);
Sanctum::actingAs($user);
@@ -174,11 +166,9 @@ it('requires available credits to analyze a meal image', function () {
'image' => fakeMealAnalysisImage(),
])
->assertStatus(402)
->assertJsonPath('message', __('api.meal_image_analysis.insufficient_credits'))
->assertJsonPath('credits.balance', 0);
->assertJsonPath('message', __('api.meal_image_analysis.subscription_required'));
expect(AiUsage::query()->count())->toBe(0)
->and(CreditLedgerEntry::query()->count())->toBe(0);
expect(AiUsage::query()->count())->toBe(0);
});
it('requires authentication to analyze a meal image', function () {
@@ -191,7 +181,9 @@ it('requires authentication to analyze a meal image', function () {
});
it('validates the analyzed image', function () {
Sanctum::actingAs(User::factory()->create());
Sanctum::actingAs(User::factory()->create([
'trial_ends_at' => now()->addDay(),
]));
$this
->postJson('/api/meal-posts/analyze-image')
+40
View File
@@ -0,0 +1,40 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('grants analysis access during the generic trial', function () {
$user = User::factory()->create([
'trial_ends_at' => now()->addDay(),
]);
expect($user->canAnalyzeMeals())->toBeTrue()
->and($user->analysisAccessLevel())->toBe('trial');
});
it('grants analysis access to active subscribers', function () {
$user = User::factory()->create([
'trial_ends_at' => now()->subDay(),
]);
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_active',
'stripe_status' => 'active',
'stripe_price' => 'price_subscription',
]);
expect($user->canAnalyzeMeals())->toBeTrue()
->and($user->analysisAccessLevel())->toBe('subscribed');
});
it('keeps publishing accounts free without analysis access after the trial', function () {
$user = User::factory()->create([
'trial_ends_at' => now()->subDay(),
]);
expect($user->canAnalyzeMeals())->toBeFalse()
->and($user->analysisAccessLevel())->toBe('free');
});