diff --git a/app/Enums/CreditLedgerEntryType.php b/app/Enums/CreditLedgerEntryType.php new file mode 100644 index 0000000..0b2b9a3 --- /dev/null +++ b/app/Enums/CreditLedgerEntryType.php @@ -0,0 +1,38 @@ + __('enums.credit_ledger_entry_type.free_grant'), + self::PURCHASE => __('enums.credit_ledger_entry_type.purchase'), + self::SUBSCRIPTION_RENEWAL => __('enums.credit_ledger_entry_type.subscription_renewal'), + self::USAGE => __('enums.credit_ledger_entry_type.usage'), + self::REFUND => __('enums.credit_ledger_entry_type.refund'), + self::ADJUSTMENT => __('enums.credit_ledger_entry_type.adjustment'), + }; + } + + public function getColor(): string + { + return match ($this) { + self::FREE_GRANT, self::PURCHASE, self::SUBSCRIPTION_RENEWAL => 'success', + self::USAGE => 'warning', + self::REFUND => 'info', + self::ADJUSTMENT => 'gray', + }; + } +} diff --git a/app/Enums/CreditProductType.php b/app/Enums/CreditProductType.php new file mode 100644 index 0000000..e532cd4 --- /dev/null +++ b/app/Enums/CreditProductType.php @@ -0,0 +1,28 @@ + __('enums.credit_product_type.monthly'), + self::ONE_TIME => __('enums.credit_product_type.one_time'), + }; + } + + public function getColor(): string + { + return match ($this) { + self::MONTHLY => 'info', + self::ONE_TIME => 'success', + }; + } +} diff --git a/app/Filament/Resources/CreditProducts/CreditProductResource.php b/app/Filament/Resources/CreditProducts/CreditProductResource.php new file mode 100644 index 0000000..ebcead5 --- /dev/null +++ b/app/Filament/Resources/CreditProducts/CreditProductResource.php @@ -0,0 +1,78 @@ +label(__('admin.credit_products.actions.sync_stripe')) + ->icon(Heroicon::OutlinedArrowPath) + ->visible(fn (CreditProduct $record): bool => blank($record->stripe_price_id)) + ->requiresConfirmation() + ->action(function (CreditProduct $record): void { + app(StripeCreditProductSyncer::class)->sync($record); + + Notification::make() + ->title(__('admin.credit_products.actions.sync_stripe_success')) + ->success() + ->send(); + }); + } + + public static function getPages(): array + { + return [ + 'index' => ListCreditProducts::route('/'), + 'create' => CreateCreditProduct::route('/create'), + 'edit' => EditCreditProduct::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/CreditProducts/Pages/CreateCreditProduct.php b/app/Filament/Resources/CreditProducts/Pages/CreateCreditProduct.php new file mode 100644 index 0000000..7f50b21 --- /dev/null +++ b/app/Filament/Resources/CreditProducts/Pages/CreateCreditProduct.php @@ -0,0 +1,11 @@ +components([ + Section::make(__('admin.credit_products.sections.product')) + ->columns(2) + ->schema([ + TextInput::make('name') + ->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) + ->required(), + TextInput::make('amount') + ->label(__('admin.credit_products.fields.amount')) + ->helperText(__('admin.credit_products.helpers.amount')) + ->integer() + ->minValue(1) + ->required(), + TextInput::make('currency') + ->label(__('admin.credit_products.fields.currency')) + ->default('eur') + ->required() + ->maxLength(3), + TextInput::make('sort_order') + ->label(__('admin.credit_products.fields.sort_order')) + ->integer() + ->default(0) + ->required(), + Textarea::make('description') + ->label(__('admin.credit_products.fields.description')) + ->rows(3) + ->columnSpanFull(), + Toggle::make('is_active') + ->label(__('admin.credit_products.fields.is_active')) + ->default(true), + ]), + Section::make(__('admin.credit_products.sections.stripe')) + ->columns(2) + ->schema([ + TextInput::make('stripe_product_id') + ->label(__('admin.credit_products.fields.stripe_product_id')) + ->maxLength(255), + TextInput::make('stripe_price_id') + ->label(__('admin.credit_products.fields.stripe_price_id')) + ->unique(ignoreRecord: true) + ->maxLength(255), + ]), + ]); + } +} diff --git a/app/Filament/Resources/CreditProducts/Tables/CreditProductsTable.php b/app/Filament/Resources/CreditProducts/Tables/CreditProductsTable.php new file mode 100644 index 0000000..ed44126 --- /dev/null +++ b/app/Filament/Resources/CreditProducts/Tables/CreditProductsTable.php @@ -0,0 +1,75 @@ +defaultSort('sort_order') + ->columns([ + TextColumn::make('name') + ->label(__('admin.credit_products.fields.name')) + ->searchable() + ->sortable(), + TextColumn::make('type') + ->label(__('admin.credit_products.fields.type')) + ->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) + ->sortable(), + TextColumn::make('stripe_price_id') + ->label(__('admin.credit_products.fields.stripe_price_id')) + ->copyable() + ->searchable() + ->toggleable(), + IconColumn::make('is_active') + ->label(__('admin.credit_products.fields.is_active')) + ->boolean() + ->sortable(), + TextColumn::make('sort_order') + ->label(__('admin.credit_products.fields.sort_order')) + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('created_at') + ->label(__('admin.credit_products.fields.created_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('type') + ->label(__('admin.credit_products.fields.type')) + ->options(CreditProductType::class), + TernaryFilter::make('is_active') + ->label(__('admin.credit_products.fields.is_active')), + ]) + ->recordActions([ + CreditProductResource::syncStripeAction(), + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php new file mode 100644 index 0000000..baaab2c --- /dev/null +++ b/app/Http/Controllers/BillingController.php @@ -0,0 +1,76 @@ +active() + ->whereNotNull('stripe_price_id') + ->orderBy('sort_order') + ->orderBy('amount') + ->get(); + + return CreditProductResource::collection($products); + } + + public function checkout(Request $request, CreditProduct $creditProduct): JsonResponse + { + abort_unless($creditProduct->is_active, 404); + abort_unless(filled($creditProduct->stripe_price_id), 404); + + $user = $request->user(); + $metadata = [ + 'credit_product_id' => $creditProduct->getKey(), + 'credit_product_type' => $creditProduct->type->value, + 'credits' => (string) $creditProduct->credits, + ]; + $sessionOptions = [ + 'success_url' => $this->redirectUrl('success'), + 'cancel_url' => $this->redirectUrl('cancel'), + 'metadata' => $metadata, + 'client_reference_id' => $creditProduct->getKey(), + ]; + + if ($creditProduct->type === CreditProductType::MONTHLY) { + $checkout = $user + ->newSubscription('default', $creditProduct->stripe_price_id) + ->withMetadata($metadata) + ->checkout($sessionOptions); + } else { + $checkout = $user->checkout([ + $creditProduct->stripe_price_id => 1, + ], $sessionOptions); + } + + $session = $checkout->asStripeCheckoutSession(); + + return response()->json([ + 'id' => $session->id, + 'url' => $session->url, + ]); + } + + public function portal(Request $request): JsonResponse + { + return response()->json([ + 'url' => $request->user()->billingPortalUrl($this->redirectUrl('portal')), + ]); + } + + private function redirectUrl(string $status): string + { + $scheme = trim((string) config('app.mobile_scheme', env('APP_MOBILE_SCHEME', 'bowly')), ':/'); + + return "{$scheme}:///billing/{$status}"; + } +} diff --git a/app/Http/Controllers/MealImageAnalysisController.php b/app/Http/Controllers/MealImageAnalysisController.php index 1c23824..e7f41d1 100644 --- a/app/Http/Controllers/MealImageAnalysisController.php +++ b/app/Http/Controllers/MealImageAnalysisController.php @@ -7,6 +7,7 @@ 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; @@ -17,11 +18,25 @@ use Throwable; class MealImageAnalysisController extends Controller { - public function store(MealImageAnalysisRequest $request, AiUsageRecorder $aiUsageRecorder): JsonResponse + 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)) { + return response()->json([ + 'message' => __('api.meal_image_analysis.insufficient_credits'), + 'credits' => [ + 'balance' => $user->ai_credits_balance, + ], + ], 402); + } + try { $response = MealImageAnalyzer::make()->prompt( $this->prompt(), @@ -31,8 +46,10 @@ class MealImageAnalysisController extends Controller } catch (Throwable $exception) { report($exception); + $aiCredits->refund($user); + $aiUsageRecorder->failed( - user: $request->user(), + user: $user, type: AiUsageType::MEAL_IMAGE_ANALYSIS, exception: $exception, input: $input, @@ -50,7 +67,7 @@ class MealImageAnalysisController extends Controller ); $aiUsageRecorder->success( - user: $request->user(), + user: $user, type: AiUsageType::MEAL_IMAGE_ANALYSIS, input: $input, output: $draft, @@ -63,6 +80,9 @@ class MealImageAnalysisController extends Controller return response()->json([ 'data' => $draft, + 'credits' => [ + 'balance' => $user->fresh()->ai_credits_balance, + ], ]); } diff --git a/app/Http/Resources/CreditProductResource.php b/app/Http/Resources/CreditProductResource.php new file mode 100644 index 0000000..aad5ed0 --- /dev/null +++ b/app/Http/Resources/CreditProductResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'type' => $this->type, + 'credits' => $this->credits, + 'amount' => $this->amount, + 'currency' => $this->currency, + 'formattedAmount' => $this->formattedAmount(), + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index e8461b5..0907893 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -27,6 +27,7 @@ class UserResource extends JsonResource 'bio' => $this->bio, 'role' => $this->role, 'accountVerified' => $this->account_verified_at !== null, + 'aiCreditsBalance' => (int) $this->ai_credits_balance, 'suspendedAt' => $this->suspended_at?->toISOString(), 'physicalActivityLevel' => $this->physical_activity_level, 'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(), diff --git a/app/Listeners/GrantCreditsFromStripeWebhook.php b/app/Listeners/GrantCreditsFromStripeWebhook.php new file mode 100644 index 0000000..db70cf8 --- /dev/null +++ b/app/Listeners/GrantCreditsFromStripeWebhook.php @@ -0,0 +1,126 @@ +} $event + */ + public function handle(object $event): void + { + $payload = $event->payload ?? []; + + match ($payload['type'] ?? null) { + 'checkout.session.completed' => $this->handleCheckoutSessionCompleted($payload), + 'invoice.paid' => $this->handleInvoicePaid($payload), + default => null, + }; + } + + /** + * @param array $payload + */ + private function handleCheckoutSessionCompleted(array $payload): void + { + $session = $payload['data']['object'] ?? []; + + if (($session['mode'] ?? null) !== 'payment' || ($session['payment_status'] ?? null) !== 'paid') { + return; + } + + $product = $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME); + $user = $this->userFromCustomer($session['customer'] ?? null); + + if (! $product || ! $user) { + return; + } + + $this->credits->grant( + user: $user, + credits: $product->credits, + type: CreditLedgerEntryType::PURCHASE, + product: $product, + source: 'stripe_checkout_session:'.$session['id'], + stripeCheckoutSessionId: $session['id'] ?? null, + stripePaymentIntentId: $session['payment_intent'] ?? null, + metadata: [ + 'stripe_event_id' => $payload['id'] ?? null, + ], + ); + } + + /** + * @param array $payload + */ + private function handleInvoicePaid(array $payload): void + { + $invoice = $payload['data']['object'] ?? []; + $user = $this->userFromCustomer($invoice['customer'] ?? null); + + if (! $user) { + return; + } + + 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(); + + if (! $product) { + continue; + } + + $this->credits->grant( + user: $user, + credits: $product->credits, + type: CreditLedgerEntryType::SUBSCRIPTION_RENEWAL, + product: $product, + source: 'stripe_invoice:'.$invoice['id'].':price:'.$priceId, + stripeInvoiceId: $invoice['id'] ?? null, + stripePaymentIntentId: $invoice['payment_intent'] ?? null, + stripeSubscriptionId: $invoice['subscription'] ?? null, + metadata: [ + 'billing_reason' => $invoice['billing_reason'] ?? null, + 'stripe_event_id' => $payload['id'] ?? null, + ], + ); + } + } + + private function userFromCustomer(mixed $customer): ?User + { + if (! is_string($customer) || $customer === '') { + return null; + } + + return User::query()->where('stripe_id', $customer)->first(); + } + + private function productFromMetadata(mixed $metadata, CreditProductType $type): ?CreditProduct + { + if (! is_array($metadata) || ! is_string($metadata['credit_product_id'] ?? null)) { + return null; + } + + return CreditProduct::query() + ->whereKey($metadata['credit_product_id']) + ->where('type', $type) + ->first(); + } +} diff --git a/app/Models/CreditLedgerEntry.php b/app/Models/CreditLedgerEntry.php new file mode 100644 index 0000000..ad99877 --- /dev/null +++ b/app/Models/CreditLedgerEntry.php @@ -0,0 +1,49 @@ + */ + use HasFactory, HasUlids; + + protected $fillable = [ + 'user_id', + 'credit_product_id', + 'type', + 'credits', + 'balance_after', + 'source', + 'stripe_checkout_session_id', + 'stripe_invoice_id', + 'stripe_payment_intent_id', + 'stripe_subscription_id', + 'metadata', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function creditProduct(): BelongsTo + { + return $this->belongsTo(CreditProduct::class); + } + + protected function casts(): array + { + return [ + 'type' => CreditLedgerEntryType::class, + 'credits' => 'integer', + 'balance_after' => 'integer', + 'metadata' => 'array', + ]; + } +} diff --git a/app/Models/CreditProduct.php b/app/Models/CreditProduct.php new file mode 100644 index 0000000..7286c67 --- /dev/null +++ b/app/Models/CreditProduct.php @@ -0,0 +1,55 @@ + */ + use HasFactory, HasUlids; + + protected $fillable = [ + 'name', + 'description', + 'type', + 'credits', + 'amount', + 'currency', + 'stripe_product_id', + 'stripe_price_id', + 'is_active', + 'sort_order', + ]; + + public function ledgerEntries(): HasMany + { + return $this->hasMany(CreditLedgerEntry::class); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + public function formattedAmount(): string + { + return number_format($this->amount / 100, 2, ',', ' ').' '.strtoupper($this->currency); + } + + protected function casts(): array + { + return [ + 'type' => CreditProductType::class, + 'credits' => 'integer', + 'amount' => 'integer', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index fc40e35..c9e4436 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -23,12 +23,13 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Storage; +use Laravel\Cashier\Billable; use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocalePreference, MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes; + use Billable, HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes; /** * The attributes that are mass assignable. @@ -58,6 +59,7 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale 'suspended_at', 'suspended_by', 'suspended_reason', + 'ai_credits_balance', ]; /** @@ -92,6 +94,8 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale 'role' => UserRole::class, 'date_of_birth' => 'immutable_date', 'suspended_at' => 'datetime', + 'trial_ends_at' => 'datetime', + 'ai_credits_balance' => 'integer', ]; } @@ -115,6 +119,11 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale return $this->hasMany(AiUsage::class); } + public function creditLedgerEntries(): HasMany + { + return $this->hasMany(CreditLedgerEntry::class); + } + public function workouts(): HasMany { return $this->hasMany(WorkoutSessions::class); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e7302ad..cc79196 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Broadcasting\ExpoPushChannel; +use App\Listeners\GrantCreditsFromStripeWebhook; use App\Mail\ResetPasswordMail; use App\Mail\VerifyAccount; use App\Models\User; @@ -11,6 +12,7 @@ use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\URL; @@ -49,6 +51,8 @@ class AppServiceProvider extends ServiceProvider Notification::extend('expo', fn ($app) => $app->make(ExpoPushChannel::class)); + Event::listen(\Laravel\Cashier\Events\WebhookReceived::class, GrantCreditsFromStripeWebhook::class); + VerifyEmail::createUrlUsing(function (User $notifiable): string { $relativeUrl = URL::temporarySignedRoute( 'verification.verify', diff --git a/app/Services/AiCreditService.php b/app/Services/AiCreditService.php new file mode 100644 index 0000000..c1ab091 --- /dev/null +++ b/app/Services/AiCreditService.php @@ -0,0 +1,142 @@ +whereKey($user->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + if ($lockedUser->ai_credits_balance < $credits) { + return false; + } + + $lockedUser->decrement('ai_credits_balance', $credits); + $lockedUser->refresh(); + + $this->record( + user: $lockedUser, + type: CreditLedgerEntryType::USAGE, + credits: -$credits, + source: $source, + ); + + $user->forceFill([ + 'ai_credits_balance' => $lockedUser->ai_credits_balance, + ]); + + return true; + }); + } + + public function refund(User $user, int $credits = 1, ?string $source = null): CreditLedgerEntry + { + return $this->grant( + user: $user, + credits: $credits, + type: CreditLedgerEntryType::REFUND, + source: $source, + ); + } + + /** + * @param array|null $metadata + */ + public function grant( + User $user, + int $credits, + CreditLedgerEntryType $type, + ?CreditProduct $product = null, + ?string $source = null, + ?string $stripeCheckoutSessionId = null, + ?string $stripeInvoiceId = null, + ?string $stripePaymentIntentId = null, + ?string $stripeSubscriptionId = null, + ?array $metadata = null, + ): ?CreditLedgerEntry { + return DB::transaction(function () use ( + $credits, + $metadata, + $product, + $source, + $stripeCheckoutSessionId, + $stripeInvoiceId, + $stripePaymentIntentId, + $stripeSubscriptionId, + $type, + $user, + ): ?CreditLedgerEntry { + if ($source && CreditLedgerEntry::query()->where('source', $source)->exists()) { + return null; + } + + $lockedUser = User::query() + ->whereKey($user->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + $lockedUser->increment('ai_credits_balance', $credits); + $lockedUser->refresh(); + + $entry = $this->record( + user: $lockedUser, + type: $type, + credits: $credits, + product: $product, + source: $source, + stripeCheckoutSessionId: $stripeCheckoutSessionId, + stripeInvoiceId: $stripeInvoiceId, + stripePaymentIntentId: $stripePaymentIntentId, + stripeSubscriptionId: $stripeSubscriptionId, + metadata: $metadata, + ); + + $user->forceFill([ + 'ai_credits_balance' => $lockedUser->ai_credits_balance, + ]); + + return $entry; + }); + } + + /** + * @param array|null $metadata + */ + private function record( + User $user, + CreditLedgerEntryType $type, + int $credits, + ?CreditProduct $product = null, + ?string $source = null, + ?string $stripeCheckoutSessionId = null, + ?string $stripeInvoiceId = null, + ?string $stripePaymentIntentId = null, + ?string $stripeSubscriptionId = null, + ?array $metadata = null, + ): CreditLedgerEntry { + return CreditLedgerEntry::create([ + 'user_id' => $user->getKey(), + 'credit_product_id' => $product?->getKey(), + 'type' => $type, + 'credits' => $credits, + 'balance_after' => $user->ai_credits_balance, + 'source' => $source, + 'stripe_checkout_session_id' => $stripeCheckoutSessionId, + 'stripe_invoice_id' => $stripeInvoiceId, + 'stripe_payment_intent_id' => $stripePaymentIntentId, + 'stripe_subscription_id' => $stripeSubscriptionId, + 'metadata' => $metadata, + ]); + } +} diff --git a/app/Services/StripeCreditProductSyncer.php b/app/Services/StripeCreditProductSyncer.php new file mode 100644 index 0000000..aa94414 --- /dev/null +++ b/app/Services/StripeCreditProductSyncer.php @@ -0,0 +1,58 @@ +stripe_product_id; + + if (! $stripeProductId) { + $stripeProduct = $stripe->products->create([ + 'name' => $product->name, + 'description' => $product->description, + 'metadata' => [ + 'credit_product_id' => $product->getKey(), + 'credits' => (string) $product->credits, + 'type' => $product->type->value, + ], + ]); + + $stripeProductId = $stripeProduct->id; + } + + if (! $product->stripe_price_id) { + $priceData = [ + 'product' => $stripeProductId, + 'unit_amount' => $product->amount, + 'currency' => strtolower($product->currency), + 'metadata' => [ + 'credit_product_id' => $product->getKey(), + 'credits' => (string) $product->credits, + 'type' => $product->type->value, + ], + ]; + + if ($product->type === CreditProductType::MONTHLY) { + $priceData['recurring'] = [ + 'interval' => 'month', + ]; + } + + $stripePrice = $stripe->prices->create($priceData); + } + + $product->forceFill([ + 'stripe_product_id' => $stripeProductId, + 'stripe_price_id' => $product->stripe_price_id ?: $stripePrice->id, + ])->save(); + + return $product; + } +} diff --git a/database/migrations/2026_05_27_100000_create_credit_products_and_ledger_entries.php b/database/migrations/2026_05_27_100000_create_credit_products_and_ledger_entries.php new file mode 100644 index 0000000..6b48ed4 --- /dev/null +++ b/database/migrations/2026_05_27_100000_create_credit_products_and_ledger_entries.php @@ -0,0 +1,61 @@ +unsignedInteger('ai_credits_balance')->default(3)->after('trial_ends_at'); + }); + + Schema::create('credit_products', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('type', 32); + $table->unsignedInteger('credits'); + $table->unsignedInteger('amount'); + $table->string('currency', 3)->default('eur'); + $table->string('stripe_product_id')->nullable()->index(); + $table->string('stripe_price_id')->nullable()->unique(); + $table->boolean('is_active')->default(true)->index(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index(['type', 'is_active', 'sort_order']); + }); + + Schema::create('credit_ledger_entries', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('credit_product_id')->nullable()->constrained('credit_products')->nullOnDelete(); + $table->string('type', 64); + $table->integer('credits'); + $table->unsignedInteger('balance_after'); + $table->string('source')->nullable()->unique(); + $table->string('stripe_checkout_session_id')->nullable()->index(); + $table->string('stripe_invoice_id')->nullable()->index(); + $table->string('stripe_payment_intent_id')->nullable()->index(); + $table->string('stripe_subscription_id')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'created_at']); + $table->index(['user_id', 'type', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('credit_ledger_entries'); + Schema::dropIfExists('credit_products'); + + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('ai_credits_balance'); + }); + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index 18e9862..837418e 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -67,6 +67,37 @@ return [ 'empty' => '-', ], ], + 'credit_products' => [ + 'navigation' => [ + 'label' => 'Credit products', + 'singular' => 'Credit product', + 'plural' => 'Credit products', + ], + 'sections' => [ + 'product' => 'Product', + 'stripe' => 'Stripe', + ], + 'fields' => [ + 'name' => 'Name', + 'description' => 'Description', + 'type' => 'Type', + 'credits' => 'Credits', + 'amount' => 'Price', + 'currency' => 'Currency', + 'stripe_product_id' => 'Stripe product ID', + 'stripe_price_id' => 'Stripe price ID', + 'is_active' => 'Active', + 'sort_order' => 'Order', + 'created_at' => 'Created at', + ], + 'helpers' => [ + 'amount' => 'Amount in cents, for example 999 for €9.99.', + ], + 'actions' => [ + 'sync_stripe' => 'Create in Stripe', + 'sync_stripe_success' => 'Stripe product synchronized.', + ], + ], 'moderation_cases' => [ 'navigation' => [ 'label' => 'Moderation', diff --git a/lang/en/api.php b/lang/en/api.php index aad3be1..4cae823 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -26,6 +26,7 @@ return [ ], 'meal_image_analysis' => [ 'failed' => 'Unable to analyze the image right now.', + 'insufficient_credits' => 'You do not have any analysis credits left.', ], 'legal_documents' => [ 'not_found' => 'Legal document not found.', diff --git a/lang/en/enums.php b/lang/en/enums.php index 472d6cc..ea4f0ab 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -49,6 +49,18 @@ return [ 'privacy_policy' => 'Privacy policy', 'terms' => 'Terms of service', ], + 'credit_product_type' => [ + 'monthly' => 'Monthly', + 'one_time' => 'Credit pack', + ], + 'credit_ledger_entry_type' => [ + 'free_grant' => 'Free credits', + 'purchase' => 'Purchase', + 'subscription_renewal' => 'Renewal', + 'usage' => 'Usage', + 'refund' => 'Refund', + 'adjustment' => 'Adjustment', + ], 'weight_goal' => [ 'lose_weight' => 'Lose weight', 'maintain_weight' => 'Maintain weight', diff --git a/lang/fr/admin.php b/lang/fr/admin.php index 0d30099..f23884e 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -67,6 +67,37 @@ return [ 'empty' => '-', ], ], + 'credit_products' => [ + 'navigation' => [ + 'label' => 'Produits de crédits', + 'singular' => 'Produit de crédits', + 'plural' => 'Produits de crédits', + ], + 'sections' => [ + 'product' => 'Produit', + 'stripe' => 'Stripe', + ], + 'fields' => [ + 'name' => 'Nom', + 'description' => 'Description', + 'type' => 'Type', + 'credits' => 'Crédits', + 'amount' => 'Prix', + 'currency' => 'Devise', + 'stripe_product_id' => 'Stripe product ID', + 'stripe_price_id' => 'Stripe price ID', + 'is_active' => 'Actif', + 'sort_order' => 'Ordre', + 'created_at' => 'Créé le', + ], + 'helpers' => [ + 'amount' => 'Montant en centimes, par exemple 999 pour 9,99 €.', + ], + 'actions' => [ + 'sync_stripe' => 'Créer dans Stripe', + 'sync_stripe_success' => 'Produit Stripe synchronisé.', + ], + ], 'moderation_cases' => [ 'navigation' => [ 'label' => 'Modération', diff --git a/lang/fr/api.php b/lang/fr/api.php index f76d6f5..d199cde 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -26,6 +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.", ], 'legal_documents' => [ 'not_found' => 'Document légal introuvable.', diff --git a/lang/fr/enums.php b/lang/fr/enums.php index 0dc30af..8b199a3 100644 --- a/lang/fr/enums.php +++ b/lang/fr/enums.php @@ -49,6 +49,18 @@ return [ 'privacy_policy' => 'Politique de confidentialité', 'terms' => 'Conditions d’utilisation', ], + 'credit_product_type' => [ + 'monthly' => 'Mensuel', + 'one_time' => 'Pack de crédits', + ], + 'credit_ledger_entry_type' => [ + 'free_grant' => 'Crédits offerts', + 'purchase' => 'Achat', + 'subscription_renewal' => 'Renouvellement', + 'usage' => 'Utilisation', + 'refund' => 'Remboursement', + 'adjustment' => 'Ajustement', + ], 'weight_goal' => [ 'lose_weight' => 'Perdre du poids', 'maintain_weight' => 'Maintenir le poids', diff --git a/routes/api.php b/routes/api.php index 6e52671..aaae7a4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ group(function Route::delete('device-tokens', [DeviceTokenController::class, 'destroy'])->middleware('throttle:device-token'); }); +// Billing +Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->prefix('billing')->group(function (): void { + Route::get('products', [BillingController::class, 'products'])->name('billing.products'); + Route::post('products/{creditProduct}/checkout', [BillingController::class, 'checkout'])->middleware('throttle:account-action')->name('billing.checkout'); + Route::post('portal', [BillingController::class, 'portal'])->middleware('throttle:account-action')->name('billing.portal'); +}); + // 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 new file mode 100644 index 0000000..0ba24a7 --- /dev/null +++ b/tests/Feature/BillingProductsTest.php @@ -0,0 +1,51 @@ +create()); + + CreditProduct::create([ + 'name' => 'Pack 10', + 'type' => CreditProductType::ONE_TIME, + 'credits' => 10, + 'amount' => 499, + 'currency' => 'eur', + 'stripe_price_id' => 'price_pack_10', + 'sort_order' => 20, + ]); + CreditProduct::create([ + 'name' => 'Mensuel 30', + 'type' => CreditProductType::MONTHLY, + 'credits' => 30, + 'amount' => 999, + 'currency' => 'eur', + 'stripe_price_id' => 'price_monthly_30', + 'sort_order' => 10, + ]); + CreditProduct::create([ + 'name' => 'Inactive', + 'type' => CreditProductType::ONE_TIME, + 'credits' => 5, + 'amount' => 299, + 'currency' => 'eur', + 'stripe_price_id' => 'price_inactive', + 'is_active' => false, + ]); + + $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.formattedAmount', '9,99 EUR') + ->assertJsonPath('data.1.name', 'Pack 10'); +}); diff --git a/tests/Feature/MealImageAnalysisTest.php b/tests/Feature/MealImageAnalysisTest.php index caa43a0..64d4158 100644 --- a/tests/Feature/MealImageAnalysisTest.php +++ b/tests/Feature/MealImageAnalysisTest.php @@ -3,9 +3,11 @@ 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; @@ -64,6 +66,7 @@ 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) @@ -106,6 +109,15 @@ 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 () { @@ -139,6 +151,34 @@ 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 () { + $user = User::factory()->create([ + 'ai_credits_balance' => 0, + ]); + + Sanctum::actingAs($user); + + MealImageAnalyzer::fake([ + [ + 'title' => 'Bowl', + ], + ]); + + $this + ->withHeader('Accept', 'application/json') + ->post('/api/meal-posts/analyze-image', [ + 'image' => fakeMealAnalysisImage(), + ]) + ->assertStatus(402) + ->assertJsonPath('message', __('api.meal_image_analysis.insufficient_credits')) + ->assertJsonPath('credits.balance', 0); + + expect(AiUsage::query()->count())->toBe(0) + ->and(CreditLedgerEntry::query()->count())->toBe(0); }); it('requires authentication to analyze a meal image', function () {