feat: stripe

This commit is contained in:
2026-05-27 10:15:21 +02:00
parent 79e020ffe3
commit a99d027771
29 changed files with 1160 additions and 4 deletions
+38
View File
@@ -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',
};
}
}
+28
View File
@@ -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\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,
],
]);
}
@@ -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(),
];
}
}
+1
View File
@@ -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(),
@@ -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();
}
}
+49
View File
@@ -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',
];
}
}
+55
View File
@@ -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',
];
}
}
+10 -1
View File
@@ -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);
+4
View File
@@ -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',
+142
View File
@@ -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;
}
}