feat: stripe
This commit is contained in:
parent
79e020ffe3
commit
a99d027771
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Filament\Support\Contracts\HasColor;
|
||||||
|
use Filament\Support\Contracts\HasLabel;
|
||||||
|
|
||||||
|
enum CreditLedgerEntryType: string implements HasColor, HasLabel
|
||||||
|
{
|
||||||
|
case FREE_GRANT = 'free_grant';
|
||||||
|
case PURCHASE = 'purchase';
|
||||||
|
case SUBSCRIPTION_RENEWAL = 'subscription_renewal';
|
||||||
|
case USAGE = 'usage';
|
||||||
|
case REFUND = 'refund';
|
||||||
|
case ADJUSTMENT = 'adjustment';
|
||||||
|
|
||||||
|
public function getLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::FREE_GRANT => __('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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Filament\Support\Contracts\HasColor;
|
||||||
|
use Filament\Support\Contracts\HasLabel;
|
||||||
|
|
||||||
|
enum CreditProductType: string implements HasColor, HasLabel
|
||||||
|
{
|
||||||
|
case MONTHLY = 'monthly';
|
||||||
|
case ONE_TIME = 'one_time';
|
||||||
|
|
||||||
|
public function getLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::MONTHLY => __('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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\CreditProducts;
|
||||||
|
|
||||||
|
use App\Filament\Resources\CreditProducts\Pages\CreateCreditProduct;
|
||||||
|
use App\Filament\Resources\CreditProducts\Pages\EditCreditProduct;
|
||||||
|
use App\Filament\Resources\CreditProducts\Pages\ListCreditProducts;
|
||||||
|
use App\Filament\Resources\CreditProducts\Schemas\CreditProductForm;
|
||||||
|
use App\Filament\Resources\CreditProducts\Tables\CreditProductsTable;
|
||||||
|
use App\Models\CreditProduct;
|
||||||
|
use App\Services\StripeCreditProductSyncer;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class CreditProductResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = CreditProduct::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCreditCard;
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'name';
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('admin.credit_products.navigation.label');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('admin.credit_products.navigation.singular');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('admin.credit_products.navigation.plural');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return CreditProductForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return CreditProductsTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function syncStripeAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('syncStripe')
|
||||||
|
->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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\CreditProducts\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\CreditProducts\CreditProductResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateCreditProduct extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = CreditProductResource::class;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\CreditProducts\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\CreditProducts\CreditProductResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditCreditProduct extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = CreditProductResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreditProductResource::syncStripeAction(),
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\CreditProducts\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\CreditProducts\CreditProductResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListCreditProducts extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = CreditProductResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\CreditProducts\Schemas;
|
||||||
|
|
||||||
|
use App\Enums\CreditProductType;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class CreditProductForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->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),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\CreditProducts\Tables;
|
||||||
|
|
||||||
|
use App\Enums\CreditProductType;
|
||||||
|
use App\Filament\Resources\CreditProducts\CreditProductResource;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class CreditProductsTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->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(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\CreditProductType;
|
||||||
|
use App\Http\Resources\CreditProductResource;
|
||||||
|
use App\Models\CreditProduct;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
class BillingController extends Controller
|
||||||
|
{
|
||||||
|
public function products(): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$products = CreditProduct::query()
|
||||||
|
->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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Enums\AiUsageType;
|
||||||
use App\Enums\IngredientUnit;
|
use App\Enums\IngredientUnit;
|
||||||
use App\Enums\MealPostType;
|
use App\Enums\MealPostType;
|
||||||
use App\Http\Requests\MealImageAnalysisRequest;
|
use App\Http\Requests\MealImageAnalysisRequest;
|
||||||
|
use App\Services\AiCreditService;
|
||||||
use App\Services\AiUsageRecorder;
|
use App\Services\AiUsageRecorder;
|
||||||
use Illuminate\Contracts\Support\Arrayable;
|
use Illuminate\Contracts\Support\Arrayable;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
@ -17,11 +18,25 @@ use Throwable;
|
||||||
|
|
||||||
class MealImageAnalysisController extends Controller
|
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');
|
$image = $request->file('image');
|
||||||
$input = $this->analysisInput($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 {
|
try {
|
||||||
$response = MealImageAnalyzer::make()->prompt(
|
$response = MealImageAnalyzer::make()->prompt(
|
||||||
$this->prompt(),
|
$this->prompt(),
|
||||||
|
|
@ -31,8 +46,10 @@ class MealImageAnalysisController extends Controller
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
report($exception);
|
report($exception);
|
||||||
|
|
||||||
|
$aiCredits->refund($user);
|
||||||
|
|
||||||
$aiUsageRecorder->failed(
|
$aiUsageRecorder->failed(
|
||||||
user: $request->user(),
|
user: $user,
|
||||||
type: AiUsageType::MEAL_IMAGE_ANALYSIS,
|
type: AiUsageType::MEAL_IMAGE_ANALYSIS,
|
||||||
exception: $exception,
|
exception: $exception,
|
||||||
input: $input,
|
input: $input,
|
||||||
|
|
@ -50,7 +67,7 @@ class MealImageAnalysisController extends Controller
|
||||||
);
|
);
|
||||||
|
|
||||||
$aiUsageRecorder->success(
|
$aiUsageRecorder->success(
|
||||||
user: $request->user(),
|
user: $user,
|
||||||
type: AiUsageType::MEAL_IMAGE_ANALYSIS,
|
type: AiUsageType::MEAL_IMAGE_ANALYSIS,
|
||||||
input: $input,
|
input: $input,
|
||||||
output: $draft,
|
output: $draft,
|
||||||
|
|
@ -63,6 +80,9 @@ class MealImageAnalysisController extends Controller
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $draft,
|
'data' => $draft,
|
||||||
|
'credits' => [
|
||||||
|
'balance' => $user->fresh()->ai_credits_balance,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class CreditProductResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ class UserResource extends JsonResource
|
||||||
'bio' => $this->bio,
|
'bio' => $this->bio,
|
||||||
'role' => $this->role,
|
'role' => $this->role,
|
||||||
'accountVerified' => $this->account_verified_at !== null,
|
'accountVerified' => $this->account_verified_at !== null,
|
||||||
|
'aiCreditsBalance' => (int) $this->ai_credits_balance,
|
||||||
'suspendedAt' => $this->suspended_at?->toISOString(),
|
'suspendedAt' => $this->suspended_at?->toISOString(),
|
||||||
'physicalActivityLevel' => $this->physical_activity_level,
|
'physicalActivityLevel' => $this->physical_activity_level,
|
||||||
'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(),
|
'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Enums\CreditLedgerEntryType;
|
||||||
|
use App\Enums\CreditProductType;
|
||||||
|
use App\Models\CreditProduct;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\AiCreditService;
|
||||||
|
|
||||||
|
class GrantCreditsFromStripeWebhook
|
||||||
|
{
|
||||||
|
public function __construct(private AiCreditService $credits) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{payload: array<string, mixed>} $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<string, mixed> $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<string, mixed> $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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\CreditLedgerEntryType;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class CreditLedgerEntry extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\CreditLedgerEntryFactory> */
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\CreditProductType;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class CreditProduct extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\CreditProductFactory> */
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,12 +23,13 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Laravel\Cashier\Billable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocalePreference, MustVerifyEmail
|
class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocalePreference, MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes;
|
use Billable, HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
|
|
@ -58,6 +59,7 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
|
||||||
'suspended_at',
|
'suspended_at',
|
||||||
'suspended_by',
|
'suspended_by',
|
||||||
'suspended_reason',
|
'suspended_reason',
|
||||||
|
'ai_credits_balance',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -92,6 +94,8 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
|
||||||
'role' => UserRole::class,
|
'role' => UserRole::class,
|
||||||
'date_of_birth' => 'immutable_date',
|
'date_of_birth' => 'immutable_date',
|
||||||
'suspended_at' => 'datetime',
|
'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);
|
return $this->hasMany(AiUsage::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function creditLedgerEntries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CreditLedgerEntry::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function workouts(): HasMany
|
public function workouts(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(WorkoutSessions::class);
|
return $this->hasMany(WorkoutSessions::class);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Broadcasting\ExpoPushChannel;
|
use App\Broadcasting\ExpoPushChannel;
|
||||||
|
use App\Listeners\GrantCreditsFromStripeWebhook;
|
||||||
use App\Mail\ResetPasswordMail;
|
use App\Mail\ResetPasswordMail;
|
||||||
use App\Mail\VerifyAccount;
|
use App\Mail\VerifyAccount;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
@ -11,6 +12,7 @@ use Illuminate\Auth\Notifications\VerifyEmail;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
@ -49,6 +51,8 @@ class AppServiceProvider extends ServiceProvider
|
||||||
|
|
||||||
Notification::extend('expo', fn ($app) => $app->make(ExpoPushChannel::class));
|
Notification::extend('expo', fn ($app) => $app->make(ExpoPushChannel::class));
|
||||||
|
|
||||||
|
Event::listen(\Laravel\Cashier\Events\WebhookReceived::class, GrantCreditsFromStripeWebhook::class);
|
||||||
|
|
||||||
VerifyEmail::createUrlUsing(function (User $notifiable): string {
|
VerifyEmail::createUrlUsing(function (User $notifiable): string {
|
||||||
$relativeUrl = URL::temporarySignedRoute(
|
$relativeUrl = URL::temporarySignedRoute(
|
||||||
'verification.verify',
|
'verification.verify',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\CreditLedgerEntryType;
|
||||||
|
use App\Models\CreditLedgerEntry;
|
||||||
|
use App\Models\CreditProduct;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class AiCreditService
|
||||||
|
{
|
||||||
|
public function consume(User $user, int $credits = 1, ?string $source = null): bool
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($credits, $source, $user): bool {
|
||||||
|
$lockedUser = User::query()
|
||||||
|
->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<string, mixed>|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<string, mixed>|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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\CreditProductType;
|
||||||
|
use App\Models\CreditProduct;
|
||||||
|
use Laravel\Cashier\Cashier;
|
||||||
|
|
||||||
|
class StripeCreditProductSyncer
|
||||||
|
{
|
||||||
|
public function sync(CreditProduct $product): CreditProduct
|
||||||
|
{
|
||||||
|
$stripe = Cashier::stripe();
|
||||||
|
$stripeProductId = $product->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -67,6 +67,37 @@ return [
|
||||||
'empty' => '-',
|
'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' => [
|
'moderation_cases' => [
|
||||||
'navigation' => [
|
'navigation' => [
|
||||||
'label' => 'Moderation',
|
'label' => 'Moderation',
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ return [
|
||||||
],
|
],
|
||||||
'meal_image_analysis' => [
|
'meal_image_analysis' => [
|
||||||
'failed' => 'Unable to analyze the image right now.',
|
'failed' => 'Unable to analyze the image right now.',
|
||||||
|
'insufficient_credits' => 'You do not have any analysis credits left.',
|
||||||
],
|
],
|
||||||
'legal_documents' => [
|
'legal_documents' => [
|
||||||
'not_found' => 'Legal document not found.',
|
'not_found' => 'Legal document not found.',
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,18 @@ return [
|
||||||
'privacy_policy' => 'Privacy policy',
|
'privacy_policy' => 'Privacy policy',
|
||||||
'terms' => 'Terms of service',
|
'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' => [
|
'weight_goal' => [
|
||||||
'lose_weight' => 'Lose weight',
|
'lose_weight' => 'Lose weight',
|
||||||
'maintain_weight' => 'Maintain weight',
|
'maintain_weight' => 'Maintain weight',
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,37 @@ return [
|
||||||
'empty' => '-',
|
'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' => [
|
'moderation_cases' => [
|
||||||
'navigation' => [
|
'navigation' => [
|
||||||
'label' => 'Modération',
|
'label' => 'Modération',
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ return [
|
||||||
],
|
],
|
||||||
'meal_image_analysis' => [
|
'meal_image_analysis' => [
|
||||||
'failed' => "Impossible d'analyser l'image pour le moment.",
|
'failed' => "Impossible d'analyser l'image pour le moment.",
|
||||||
|
'insufficient_credits' => "Tu n'as plus de crédits d'analyse.",
|
||||||
],
|
],
|
||||||
'legal_documents' => [
|
'legal_documents' => [
|
||||||
'not_found' => 'Document légal introuvable.',
|
'not_found' => 'Document légal introuvable.',
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,18 @@ return [
|
||||||
'privacy_policy' => 'Politique de confidentialité',
|
'privacy_policy' => 'Politique de confidentialité',
|
||||||
'terms' => 'Conditions d’utilisation',
|
'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' => [
|
'weight_goal' => [
|
||||||
'lose_weight' => 'Perdre du poids',
|
'lose_weight' => 'Perdre du poids',
|
||||||
'maintain_weight' => 'Maintenir le poids',
|
'maintain_weight' => 'Maintenir le poids',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
|
use App\Http\Controllers\BillingController;
|
||||||
use App\Http\Controllers\DeviceTokenController;
|
use App\Http\Controllers\DeviceTokenController;
|
||||||
use App\Http\Controllers\LegalDocumentController;
|
use App\Http\Controllers\LegalDocumentController;
|
||||||
use App\Http\Controllers\MealImageAnalysisController;
|
use App\Http\Controllers\MealImageAnalysisController;
|
||||||
|
|
@ -56,6 +57,13 @@ Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function
|
||||||
Route::delete('device-tokens', [DeviceTokenController::class, 'destroy'])->middleware('throttle:device-token');
|
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
|
// Workouts
|
||||||
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
||||||
Route::get('workouts/calendar', [WorkoutSessionController::class, 'calendar'])->name('workouts.calendar');
|
Route::get('workouts/calendar', [WorkoutSessionController::class, 'calendar'])->name('workouts.calendar');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\CreditProductType;
|
||||||
|
use App\Models\CreditProduct;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('lists active credit products ordered for the mobile app', function () {
|
||||||
|
Sanctum::actingAs(User::factory()->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');
|
||||||
|
});
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
use App\Ai\Agents\MealImageAnalyzer;
|
use App\Ai\Agents\MealImageAnalyzer;
|
||||||
use App\Enums\AiUsageStatus;
|
use App\Enums\AiUsageStatus;
|
||||||
use App\Enums\AiUsageType;
|
use App\Enums\AiUsageType;
|
||||||
|
use App\Enums\CreditLedgerEntryType;
|
||||||
use App\Enums\IngredientUnit;
|
use App\Enums\IngredientUnit;
|
||||||
use App\Enums\MealPostType;
|
use App\Enums\MealPostType;
|
||||||
use App\Models\AiUsage;
|
use App\Models\AiUsage;
|
||||||
|
use App\Models\CreditLedgerEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
@ -64,6 +66,7 @@ it('analyzes a meal image into a draft', function () {
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
->assertJsonPath('credits.balance', 2)
|
||||||
->assertJsonPath('data.title', 'Bowl saumon avocat')
|
->assertJsonPath('data.title', 'Bowl saumon avocat')
|
||||||
->assertJsonPath('data.type', MealPostType::LUNCH->value)
|
->assertJsonPath('data.type', MealPostType::LUNCH->value)
|
||||||
->assertJsonPath('data.calories', 612)
|
->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 () {
|
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->output)->toBeNull()
|
||||||
->and($usage->error_message)->toContain('Provider unavailable');
|
->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 () {
|
it('requires authentication to analyze a meal image', function () {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue