feat: revenue cat
CI / 🧪 Tests Laravel (push) Successful in 2m24s
CI / 🐳 Build & Push Image (push) Successful in 1m13s

This commit is contained in:
2026-08-12 16:00:11 +02:00
parent 31633cfd2c
commit 29e85589b0
55 changed files with 703 additions and 2363 deletions
+5 -6
View File
@@ -84,9 +84,8 @@ MEILISEARCH_KEY=masterKey
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
#Stripe # RevenueCat (secret server key and exact webhook Authorization header)
STRIPE_KEY=your-stripe-key REVENUECAT_SECRET_KEY=
STRIPE_SECRET=your-stripe-secret REVENUECAT_WEBHOOK_AUTHORIZATION=
STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret REVENUECAT_SUBSCRIPTION_ENTITLEMENT="bowli Pro"
CASHIER_CURRENCY=eur REVENUECAT_CERTIFICATION_ENTITLEMENT=certification
BILLING_TRIAL_DAYS=7
+4 -5
View File
@@ -70,11 +70,10 @@ STRAVA_CLIENT_ID=
STRAVA_CLIENT_SECRET= STRAVA_CLIENT_SECRET=
STRAVA_SCOPE=read,activity:read STRAVA_SCOPE=read,activity:read
STRIPE_KEY= REVENUECAT_SECRET_KEY=
STRIPE_SECRET= REVENUECAT_WEBHOOK_AUTHORIZATION=
STRIPE_WEBHOOK_SECRET= REVENUECAT_SUBSCRIPTION_ENTITLEMENT="bowli Pro"
CASHIER_CURRENCY=eur REVENUECAT_CERTIFICATION_ENTITLEMENT=certification
BILLING_TRIAL_DAYS=7
HEALTH_SECRET_TOKEN= HEALTH_SECRET_TOKEN=
HORIZON_HEARTBEAT_URL= HORIZON_HEARTBEAT_URL=
-3
View File
@@ -78,8 +78,6 @@ class CreateUser extends Command
return self::FAILURE; return self::FAILURE;
} }
$trialDays = max(0, (int) config('billing.trial_days', 7));
$user = User::query()->create([ $user = User::query()->create([
'name' => $name, 'name' => $name,
'email' => $email, 'email' => $email,
@@ -87,7 +85,6 @@ class CreateUser extends Command
'locale' => $locale, 'locale' => $locale,
'password' => Hash::make($password), 'password' => Hash::make($password),
'role' => UserRole::from($role), 'role' => UserRole::from($role),
'trial_ends_at' => $trialDays > 0 ? now()->addDays($trialDays) : null,
]); ]);
$this->components->info("User {$user->email} created with ID {$user->getKey()} and role {$user->role->value}."); $this->components->info("User {$user->email} created with ID {$user->getKey()} and role {$user->role->value}.");
-28
View File
@@ -1,28 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum BillingProductPurpose: string implements HasColor, HasLabel
{
case SUBSCRIPTION = 'subscription';
case CERTIFICATION = 'certification';
public function getLabel(): string
{
return match ($this) {
self::SUBSCRIPTION => __('enums.billing_product_purpose.subscription'),
self::CERTIFICATION => __('enums.billing_product_purpose.certification'),
};
}
public function getColor(): string
{
return match ($this) {
self::SUBSCRIPTION => 'info',
self::CERTIFICATION => 'success',
};
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum PaymentTransactionStatus: string implements HasColor, HasLabel
{
case PENDING = 'pending';
case PAID = 'paid';
case FAILED = 'failed';
case IGNORED = 'ignored';
case ERROR = 'error';
public function getLabel(): string
{
return match ($this) {
self::PENDING => __('enums.payment_transaction_status.pending'),
self::PAID => __('enums.payment_transaction_status.paid'),
self::FAILED => __('enums.payment_transaction_status.failed'),
self::IGNORED => __('enums.payment_transaction_status.ignored'),
self::ERROR => __('enums.payment_transaction_status.error'),
};
}
public function getColor(): string
{
return match ($this) {
self::PENDING => 'gray',
self::PAID => 'success',
self::FAILED, self::ERROR => 'danger',
self::IGNORED => 'warning',
};
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum PaymentTransactionType: string implements HasColor, HasLabel
{
case CHECKOUT = 'checkout';
case SUBSCRIPTION_INVOICE = 'subscription_invoice';
public function getLabel(): string
{
return match ($this) {
self::CHECKOUT => __('enums.payment_transaction_type.checkout'),
self::SUBSCRIPTION_INVOICE => __('enums.payment_transaction_type.subscription_invoice'),
};
}
public function getColor(): string
{
return match ($this) {
self::CHECKOUT => 'success',
self::SUBSCRIPTION_INVOICE => 'info',
};
}
}
@@ -1,78 +0,0 @@
<?php
namespace App\Filament\Resources\BillingProducts;
use App\Filament\Resources\BillingProducts\Pages\CreateBillingProduct;
use App\Filament\Resources\BillingProducts\Pages\EditBillingProduct;
use App\Filament\Resources\BillingProducts\Pages\ListBillingProducts;
use App\Filament\Resources\BillingProducts\Schemas\BillingProductForm;
use App\Filament\Resources\BillingProducts\Tables\BillingProductsTable;
use App\Models\BillingProduct;
use App\Services\StripeBillingProductSyncer;
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 BillingProductResource extends Resource
{
protected static ?string $model = BillingProduct::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCreditCard;
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string
{
return __('admin.billing_products.navigation.label');
}
public static function getModelLabel(): string
{
return __('admin.billing_products.navigation.singular');
}
public static function getPluralModelLabel(): string
{
return __('admin.billing_products.navigation.plural');
}
public static function form(Schema $schema): Schema
{
return BillingProductForm::configure($schema);
}
public static function table(Table $table): Table
{
return BillingProductsTable::configure($table);
}
public static function syncStripeAction(): Action
{
return Action::make('syncStripe')
->label(__('admin.billing_products.actions.sync_stripe'))
->icon(Heroicon::OutlinedArrowPath)
->visible(fn (BillingProduct $record): bool => blank($record->stripe_price_id) && $record->purpose !== null)
->requiresConfirmation()
->action(function (BillingProduct $record): void {
app(StripeBillingProductSyncer::class)->sync($record);
Notification::make()
->title(__('admin.billing_products.actions.sync_stripe_success'))
->success()
->send();
});
}
public static function getPages(): array
{
return [
'index' => ListBillingProducts::route('/'),
'create' => CreateBillingProduct::route('/create'),
'edit' => EditBillingProduct::route('/{record}/edit'),
];
}
}
@@ -1,17 +0,0 @@
<?php
namespace App\Filament\Resources\BillingProducts\Pages;
use App\Filament\Resources\BillingProducts\BillingProductResource;
use App\Services\StripeBillingProductSyncer;
use Filament\Resources\Pages\CreateRecord;
class CreateBillingProduct extends CreateRecord
{
protected static string $resource = BillingProductResource::class;
protected function afterCreate(): void
{
app(StripeBillingProductSyncer::class)->sync($this->record);
}
}
@@ -1,26 +0,0 @@
<?php
namespace App\Filament\Resources\BillingProducts\Pages;
use App\Filament\Resources\BillingProducts\BillingProductResource;
use App\Services\StripeBillingProductSyncer;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditBillingProduct extends EditRecord
{
protected static string $resource = BillingProductResource::class;
protected function getHeaderActions(): array
{
return [
BillingProductResource::syncStripeAction(),
DeleteAction::make(),
];
}
protected function afterSave(): void
{
app(StripeBillingProductSyncer::class)->sync($this->record);
}
}
@@ -1,19 +0,0 @@
<?php
namespace App\Filament\Resources\BillingProducts\Pages;
use App\Filament\Resources\BillingProducts\BillingProductResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListBillingProducts extends ListRecords
{
protected static string $resource = BillingProductResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -1,68 +0,0 @@
<?php
namespace App\Filament\Resources\BillingProducts\Schemas;
use App\Enums\BillingProductPurpose;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class BillingProductForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('admin.billing_products.sections.product'))
->columns(2)
->schema([
TextInput::make('name')
->label(__('admin.billing_products.fields.name'))
->required()
->maxLength(255),
Select::make('purpose')
->label(__('admin.billing_products.fields.purpose'))
->options(BillingProductPurpose::class)
->unique(ignoreRecord: true)
->required(),
TextInput::make('amount')
->label(__('admin.billing_products.fields.amount'))
->helperText(__('admin.billing_products.helpers.amount'))
->integer()
->minValue(1)
->required(),
TextInput::make('currency')
->label(__('admin.billing_products.fields.currency'))
->default('eur')
->required()
->maxLength(3),
TextInput::make('sort_order')
->label(__('admin.billing_products.fields.sort_order'))
->integer()
->default(0)
->required(),
Textarea::make('description')
->label(__('admin.billing_products.fields.description'))
->rows(3)
->columnSpanFull(),
Toggle::make('is_active')
->label(__('admin.billing_products.fields.is_active'))
->default(true),
]),
Section::make(__('admin.billing_products.sections.stripe'))
->columns(2)
->schema([
TextInput::make('stripe_product_id')
->label(__('admin.billing_products.fields.stripe_product_id'))
->maxLength(255),
TextInput::make('stripe_price_id')
->label(__('admin.billing_products.fields.stripe_price_id'))
->unique(ignoreRecord: true)
->maxLength(255),
]),
]);
}
}
@@ -1,71 +0,0 @@
<?php
namespace App\Filament\Resources\BillingProducts\Tables;
use App\Enums\BillingProductPurpose;
use App\Filament\Resources\BillingProducts\BillingProductResource;
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 BillingProductsTable
{
public static function configure(Table $table): Table
{
return $table
->defaultSort('sort_order')
->columns([
TextColumn::make('name')
->label(__('admin.billing_products.fields.name'))
->searchable()
->sortable(),
TextColumn::make('purpose')
->label(__('admin.billing_products.fields.purpose'))
->badge()
->sortable(),
TextColumn::make('amount')
->label(__('admin.billing_products.fields.amount'))
->money(fn ($record): string => $record->currency, divideBy: 100)
->sortable(),
TextColumn::make('stripe_price_id')
->label(__('admin.billing_products.fields.stripe_price_id'))
->copyable()
->searchable()
->toggleable(),
IconColumn::make('is_active')
->label(__('admin.billing_products.fields.is_active'))
->boolean()
->sortable(),
TextColumn::make('sort_order')
->label(__('admin.billing_products.fields.sort_order'))
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at')
->label(__('admin.billing_products.fields.created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('purpose')
->label(__('admin.billing_products.fields.purpose'))
->options(BillingProductPurpose::class),
TernaryFilter::make('is_active')
->label(__('admin.billing_products.fields.is_active')),
])
->recordActions([
BillingProductResource::syncStripeAction(),
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Resources\PaymentTransactions\Pages;
use App\Filament\Resources\PaymentTransactions\PaymentTransactionResource;
use Filament\Resources\Pages\ListRecords;
class ListPaymentTransactions extends ListRecords
{
protected static string $resource = PaymentTransactionResource::class;
}
@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Resources\PaymentTransactions\Pages;
use App\Filament\Resources\PaymentTransactions\PaymentTransactionResource;
use Filament\Resources\Pages\ViewRecord;
class ViewPaymentTransaction extends ViewRecord
{
protected static string $resource = PaymentTransactionResource::class;
}
@@ -1,61 +0,0 @@
<?php
namespace App\Filament\Resources\PaymentTransactions;
use App\Filament\Resources\PaymentTransactions\Pages\ListPaymentTransactions;
use App\Filament\Resources\PaymentTransactions\Pages\ViewPaymentTransaction;
use App\Filament\Resources\PaymentTransactions\Schemas\PaymentTransactionInfolist;
use App\Filament\Resources\PaymentTransactions\Tables\PaymentTransactionsTable;
use App\Models\PaymentTransaction;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class PaymentTransactionResource extends Resource
{
protected static ?string $model = PaymentTransaction::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedReceiptPercent;
protected static ?string $recordTitleAttribute = 'id';
public static function getNavigationLabel(): string
{
return __('admin.payment_transactions.navigation.label');
}
public static function getModelLabel(): string
{
return __('admin.payment_transactions.navigation.singular');
}
public static function getPluralModelLabel(): string
{
return __('admin.payment_transactions.navigation.plural');
}
public static function canCreate(): bool
{
return false;
}
public static function infolist(Schema $schema): Schema
{
return PaymentTransactionInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return PaymentTransactionsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListPaymentTransactions::route('/'),
'view' => ViewPaymentTransaction::route('/{record}'),
];
}
}
@@ -1,111 +0,0 @@
<?php
namespace App\Filament\Resources\PaymentTransactions\Schemas;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class PaymentTransactionInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('admin.payment_transactions.sections.summary'))
->columns(3)
->schema([
TextEntry::make('id')
->label(__('admin.payment_transactions.fields.id'))
->copyable(),
TextEntry::make('status')
->label(__('admin.payment_transactions.fields.status'))
->badge(),
TextEntry::make('type')
->label(__('admin.payment_transactions.fields.type'))
->badge(),
TextEntry::make('user.email')
->label(__('admin.payment_transactions.fields.user'))
->placeholder(__('admin.payment_transactions.placeholders.empty'))
->copyable(),
TextEntry::make('billingProduct.name')
->label(__('admin.payment_transactions.fields.billing_product'))
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('amount')
->label(__('admin.payment_transactions.fields.amount'))
->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100)
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('processed_at')
->label(__('admin.payment_transactions.fields.processed_at'))
->dateTime()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('invoice_email_sent_at')
->label(__('admin.payment_transactions.fields.invoice_email_sent_at'))
->dateTime()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
]),
Section::make(__('admin.payment_transactions.sections.stripe'))
->columns(2)
->schema([
TextEntry::make('stripe_event_type')
->label(__('admin.payment_transactions.fields.stripe_event_type'))
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_event_id')
->label(__('admin.payment_transactions.fields.stripe_event_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_customer_id')
->label(__('admin.payment_transactions.fields.stripe_customer_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_checkout_session_id')
->label(__('admin.payment_transactions.fields.stripe_checkout_session_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_invoice_id')
->label(__('admin.payment_transactions.fields.stripe_invoice_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_payment_intent_id')
->label(__('admin.payment_transactions.fields.stripe_payment_intent_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_subscription_id')
->label(__('admin.payment_transactions.fields.stripe_subscription_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_price_id')
->label(__('admin.payment_transactions.fields.stripe_price_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('invoice_url')
->label(__('admin.payment_transactions.fields.invoice_url'))
->copyable()
->url(fn ($state): ?string => $state)
->openUrlInNewTab()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('invoice_pdf_url')
->label(__('admin.payment_transactions.fields.invoice_pdf_url'))
->copyable()
->url(fn ($state): ?string => $state)
->openUrlInNewTab()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
]),
Section::make(__('admin.payment_transactions.sections.error'))
->schema([
TextEntry::make('error_message')
->label(__('admin.payment_transactions.fields.error_message'))
->placeholder(__('admin.payment_transactions.placeholders.empty'))
->columnSpanFull(),
]),
Section::make(__('admin.payment_transactions.sections.payload'))
->schema([
TextEntry::make('payload')
->label(__('admin.payment_transactions.fields.payload'))
->formatStateUsing(fn (mixed $state): string => json_encode($state ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}')
->fontFamily('mono')
->columnSpanFull(),
]),
]);
}
}
@@ -1,84 +0,0 @@
<?php
namespace App\Filament\Resources\PaymentTransactions\Tables;
use App\Enums\PaymentTransactionStatus;
use App\Enums\PaymentTransactionType;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class PaymentTransactionsTable
{
public static function configure(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('created_at')
->label(__('admin.payment_transactions.fields.created_at'))
->dateTime()
->sortable(),
TextColumn::make('status')
->label(__('admin.payment_transactions.fields.status'))
->badge()
->sortable(),
TextColumn::make('type')
->label(__('admin.payment_transactions.fields.type'))
->badge()
->sortable(),
TextColumn::make('user.email')
->label(__('admin.payment_transactions.fields.user'))
->searchable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextColumn::make('billingProduct.name')
->label(__('admin.payment_transactions.fields.billing_product'))
->searchable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextColumn::make('amount')
->label(__('admin.payment_transactions.fields.amount'))
->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100)
->sortable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextColumn::make('stripe_event_type')
->label(__('admin.payment_transactions.fields.stripe_event_type'))
->toggleable(),
TextColumn::make('stripe_checkout_session_id')
->label(__('admin.payment_transactions.fields.stripe_checkout_session_id_short'))
->copyable()
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('stripe_invoice_id')
->label(__('admin.payment_transactions.fields.stripe_invoice_id_short'))
->copyable()
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('stripe_price_id')
->label(__('admin.payment_transactions.fields.stripe_price_id'))
->copyable()
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('error_message')
->label(__('admin.payment_transactions.fields.error_message'))
->limit(60)
->toggleable(),
TextColumn::make('invoice_email_sent_at')
->label(__('admin.payment_transactions.fields.invoice_email_sent_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')
->label(__('admin.payment_transactions.fields.status'))
->options(PaymentTransactionStatus::class),
SelectFilter::make('type')
->label(__('admin.payment_transactions.fields.type'))
->options(PaymentTransactionType::class),
])
->recordActions([
ViewAction::make(),
]);
}
}
@@ -63,8 +63,6 @@ class UserForm
->autocomplete('new-password'), ->autocomplete('new-password'),
DateTimePicker::make('email_verified_at') DateTimePicker::make('email_verified_at')
->label(__('admin.users.fields.email_verified_at')), ->label(__('admin.users.fields.email_verified_at')),
DateTimePicker::make('trial_ends_at')
->label(__('admin.users.fields.trial_ends_at')),
]), ]),
Section::make(__('admin.users.sections.profile')) Section::make(__('admin.users.sections.profile'))
->columns(2) ->columns(2)
@@ -39,8 +39,8 @@ class UserInfolist
->label(__('admin.users.fields.certification_purchased_at')) ->label(__('admin.users.fields.certification_purchased_at'))
->dateTime() ->dateTime()
->placeholder(__('admin.users.placeholders.empty')), ->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('trial_ends_at') TextEntry::make('subscription_expires_at')
->label(__('admin.users.fields.trial_ends_at')) ->label(__('admin.users.fields.subscription_expires_at'))
->dateTime() ->dateTime()
->placeholder(__('admin.users.placeholders.empty')), ->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('suspended_at') TextEntry::make('suspended_at')
@@ -51,8 +51,8 @@ class UsersTable
->sortable() ->sortable()
->placeholder(__('admin.users.placeholders.not_verified')) ->placeholder(__('admin.users.placeholders.not_verified'))
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('trial_ends_at') TextColumn::make('subscription_expires_at')
->label(__('admin.users.fields.trial_ends_at')) ->label(__('admin.users.fields.subscription_expires_at'))
->dateTime() ->dateTime()
->sortable() ->sortable()
->placeholder(__('admin.users.placeholders.empty')) ->placeholder(__('admin.users.placeholders.empty'))
+8 -7
View File
@@ -11,6 +11,7 @@ use App\Http\Requests\UpdatePasswordRequest;
use App\Http\Requests\UpdateUserRequest; use App\Http\Requests\UpdateUserRequest;
use App\Http\Resources\UserResource; use App\Http\Resources\UserResource;
use App\Models\User; use App\Models\User;
use App\Services\RevenueCatService;
use Illuminate\Auth\Events\PasswordReset; use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\Verified;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -302,7 +303,7 @@ class AuthController extends Controller
]); ]);
} }
public function destroy(Request $request): JsonResponse public function destroy(Request $request, RevenueCatService $revenueCat): JsonResponse
{ {
$user = $request->user(); $user = $request->user();
$identityVerificationRequest = $user->identityVerificationRequest()->first(); $identityVerificationRequest = $user->identityVerificationRequest()->first();
@@ -312,6 +313,12 @@ class AuthController extends Controller
->values() ->values()
->all(); ->all();
try {
$revenueCat->deleteCustomer($user);
} catch (Throwable $exception) {
report($exception);
}
DB::transaction(function () use ($user): void { DB::transaction(function () use ($user): void {
$user->tokens()->delete(); $user->tokens()->delete();
$user->notifications()->delete(); $user->notifications()->delete();
@@ -361,12 +368,6 @@ class AuthController extends Controller
$userAttributes['password'] = Hash::make($data['password']); $userAttributes['password'] = Hash::make($data['password']);
$userAttributes['avatar_url'] = $avatarPath; $userAttributes['avatar_url'] = $avatarPath;
$userAttributes['terms_accepted_at'] = now(); $userAttributes['terms_accepted_at'] = now();
$trialDays = max(0, (int) config('billing.trial_days', 7));
if ($trialDays > 0) {
$userAttributes['trial_ends_at'] = now()->addDays($trialDays);
}
$user = User::create($userAttributes); $user = User::create($userAttributes);
try { try {
-138
View File
@@ -1,138 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\BillingProductPurpose;
use App\Http\Resources\BillingProductResource;
use App\Models\BillingProduct;
use App\Services\MobileDeepLink;
use App\Services\PaymentTransactionRecorder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class BillingController extends Controller
{
public function products(): AnonymousResourceCollection
{
$products = BillingProduct::query()
->active()
->whereNotNull('purpose')
->whereNotNull('stripe_price_id')
->orderBy('sort_order')
->orderBy('amount')
->get();
return BillingProductResource::collection($products);
}
public function checkout(
Request $request,
BillingProduct $billingProduct,
PaymentTransactionRecorder $transactions,
): JsonResponse {
abort_unless($billingProduct->is_active, 404);
abort_unless($billingProduct->purpose !== null, 404);
abort_unless(filled($billingProduct->stripe_price_id), 404);
$user = $request->user();
$this->ensureStripeCustomer($user);
$metadata = [
'billing_product_id' => $billingProduct->getKey(),
'billing_product_purpose' => $billingProduct->purpose->value,
];
$sessionOptions = [
'success_url' => $this->redirectUrl('success'),
'cancel_url' => $this->redirectUrl('cancel'),
'metadata' => $metadata,
'client_reference_id' => $billingProduct->getKey(),
];
if ($billingProduct->purpose === BillingProductPurpose::SUBSCRIPTION) {
if ($user->subscribed('default')) {
return response()->json([
'message' => __('api.billing.active_subscription_exists'),
], 409);
}
$checkout = $user
->newSubscription('default', $billingProduct->stripe_price_id)
->withMetadata($metadata)
->checkout($sessionOptions);
} else {
if ($user->hasPurchasedCertification() || $user->account_verified_at !== null) {
return response()->json([
'message' => __('api.billing.certification_already_purchased'),
], 409);
}
$sessionOptions['invoice_creation'] = [
'enabled' => true,
'invoice_data' => [
'metadata' => $metadata,
],
];
$checkout = $user->checkout([
$billingProduct->stripe_price_id => 1,
], $sessionOptions);
}
$session = $checkout->asStripeCheckoutSession();
$transactions->recordCheckoutSession(
user: $user,
product: $billingProduct,
stripeCheckoutSessionId: $session->id,
stripeCustomerId: $session->customer,
amount: $session->amount_total,
currency: $session->currency,
);
return response()->json([
'id' => $session->id,
'url' => $session->url,
]);
}
public function portal(Request $request): JsonResponse
{
$user = $request->user();
$this->ensureStripeCustomer($user);
if (! $user->subscribed('default')) {
return response()->json([
'message' => __('api.billing.no_active_subscription'),
], 409);
}
$portalUrl = $user->billingPortalUrl($this->redirectUrl('portal'));
return response()->json([
'url' => $portalUrl,
]);
}
public function redirectToMobileApp(string $status): RedirectResponse
{
abort_unless(in_array($status, ['success', 'cancel', 'portal'], true), 404);
return redirect()->away(MobileDeepLink::to("billing/{$status}"));
}
private function ensureStripeCustomer(mixed $user): void
{
if (! is_object($user) || ! method_exists($user, 'createOrGetStripeCustomer')) {
return;
}
$user->createOrGetStripeCustomer();
}
private function redirectUrl(string $status): string
{
return route('billing.web-return', ['status' => $status]);
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\UserResource;
use App\Models\RevenueCatWebhookEvent;
use App\Models\User;
use App\Notifications\PaymentFailedNotification;
use App\Services\RevenueCatService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class RevenueCatController extends Controller
{
public function sync(Request $request, RevenueCatService $revenueCat): UserResource
{
return new UserResource($revenueCat->sync($request->user()));
}
public function webhook(Request $request, RevenueCatService $revenueCat): JsonResponse
{
$expectedAuthorization = trim((string) config('billing.revenuecat.webhook_authorization'));
$providedAuthorization = (string) $request->header('Authorization');
abort_if(
$expectedAuthorization === '' || ! hash_equals($expectedAuthorization, $providedAuthorization),
401,
);
$event = $request->input('event');
abort_unless(is_array($event), 422);
$eventId = $this->stringValue($event['id'] ?? null);
$eventType = $this->stringValue($event['type'] ?? null);
abort_unless($eventId && $eventType, 422);
$webhookEvent = RevenueCatWebhookEvent::query()->firstOrCreate([
'event_id' => $eventId,
], [
'type' => $eventType,
'app_user_id' => $this->stringValue($event['app_user_id'] ?? null),
'product_id' => $this->stringValue($event['product_id'] ?? null),
'entitlement_ids' => $this->stringList($event['entitlement_ids'] ?? []),
'environment' => $this->stringValue($event['environment'] ?? null),
'payload' => $event,
]);
if ($webhookEvent->processed_at !== null) {
return response()->json(['received' => true]);
}
$user = $this->userFromEvent($event);
if ($user) {
$revenueCat->sync($user);
if ($eventType === 'BILLING_ISSUE') {
$user->notify(
(new PaymentFailedNotification)->locale($user->preferredLocale()),
);
}
}
$webhookEvent->forceFill(['processed_at' => now()])->save();
return response()->json(['received' => true]);
}
/**
* @param array<string, mixed> $event
*/
private function userFromEvent(array $event): ?User
{
$identifiers = collect([
$event['app_user_id'] ?? null,
$event['original_app_user_id'] ?? null,
])
->merge(Arr::wrap($event['aliases'] ?? []))
->merge(Arr::wrap($event['transferred_to'] ?? []))
->filter(fn (mixed $identifier): bool => is_string($identifier) && $identifier !== '')
->unique()
->values();
return User::query()->whereIn('id', $identifiers)->first();
}
/**
* @return list<string>
*/
private function stringList(mixed $value): array
{
return collect(Arr::wrap($value))
->filter(fn (mixed $item): bool => is_string($item) && $item !== '')
->values()
->all();
}
private function stringValue(mixed $value): ?string
{
return is_string($value) && $value !== '' ? $value : null;
}
}
@@ -1,25 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BillingProductResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'purpose' => $this->purpose,
'amount' => $this->amount,
'currency' => $this->currency,
'formattedAmount' => $this->formattedAmount(),
];
}
}
+6 -2
View File
@@ -30,8 +30,12 @@ class UserResource extends JsonResource
'certificationPurchased' => $this->hasPurchasedCertification(), 'certificationPurchased' => $this->hasPurchasedCertification(),
'canAnalyzeMeals' => $this->canAnalyzeMeals(), 'canAnalyzeMeals' => $this->canAnalyzeMeals(),
'analysisAccessLevel' => $this->analysisAccessLevel(), 'analysisAccessLevel' => $this->analysisAccessLevel(),
'trialEndsAt' => $this->trial_ends_at?->toISOString(), 'trialEndsAt' => $this->analysisAccessLevel() === 'trial'
'hasActiveSubscription' => $this->subscribed('default'), ? $this->subscription_expires_at?->toISOString()
: null,
'subscriptionExpiresAt' => $this->subscription_expires_at?->toISOString(),
'subscriptionProductId' => $this->subscription_product_id,
'hasActiveSubscription' => $this->hasActiveSubscription(),
'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(),
-367
View File
@@ -1,367 +0,0 @@
<?php
namespace App\Listeners;
use App\Enums\BillingProductPurpose;
use App\Enums\PaymentTransactionStatus;
use App\Models\BillingProduct;
use App\Models\PaymentTransaction;
use App\Models\User;
use App\Notifications\PaymentFailedNotification;
use App\Services\PaymentInvoiceEmailer;
use App\Services\PaymentTransactionRecorder;
use Laravel\Cashier\Cashier;
class HandleStripeWebhook
{
public function __construct(
private PaymentInvoiceEmailer $invoiceEmails,
private PaymentTransactionRecorder $transactions,
) {}
/**
* @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),
'checkout.session.expired' => $this->handleCheckoutSessionExpired($payload),
'invoice.paid', 'invoice.payment_succeeded' => $this->handleInvoicePaid($payload),
'invoice.payment_failed' => $this->handleInvoicePaymentFailed($payload),
default => null,
};
}
/**
* @param array<string, mixed> $payload
*/
private function handleCheckoutSessionCompleted(array $payload): void
{
$session = $payload['data']['object'] ?? [];
$sessionId = $this->stringValue($session['id'] ?? null);
if (! $sessionId) {
return;
}
$product = $this->productFromMetadata($session['metadata'] ?? null);
$user = $this->userFromCustomer($session['customer'] ?? null);
$paymentStatus = $this->stringValue($session['payment_status'] ?? null);
$expectedMode = $product?->purpose === BillingProductPurpose::SUBSCRIPTION
? 'subscription'
: 'payment';
$isPaid = $paymentStatus === 'paid'
|| ($expectedMode === 'subscription' && $paymentStatus === 'no_payment_required');
if (! $product || ! $user || ($session['mode'] ?? null) !== $expectedMode || ! $isPaid) {
$this->transactions->recordCheckoutSession(
user: $user,
product: $product,
stripeCheckoutSessionId: $sessionId,
status: $product && $user ? PaymentTransactionStatus::IGNORED : PaymentTransactionStatus::ERROR,
stripeEventId: $this->stringValue($payload['id'] ?? null),
stripeEventType: $this->stringValue($payload['type'] ?? null),
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
amount: $this->intValue($session['amount_total'] ?? null),
currency: $this->stringValue($session['currency'] ?? null),
errorMessage: 'Checkout session does not match an eligible paid billing product.',
payload: $session,
);
return;
}
if ($product->purpose === BillingProductPurpose::CERTIFICATION
&& $user->certification_purchased_at === null) {
$user->forceFill([
'certification_purchased_at' => now(),
])->save();
}
$invoice = $this->retrieveInvoice($this->stringValue($session['invoice'] ?? null));
$transaction = $this->transactions->recordCheckoutSession(
user: $user,
product: $product,
stripeCheckoutSessionId: $sessionId,
status: PaymentTransactionStatus::PAID,
stripeEventId: $this->stringValue($payload['id'] ?? null),
stripeEventType: $this->stringValue($payload['type'] ?? null),
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
amount: $this->intValue($session['amount_total'] ?? null),
currency: $this->stringValue($session['currency'] ?? null),
invoiceUrl: $invoice ? $this->stringValue($invoice['hosted_invoice_url'] ?? null) : null,
invoicePdfUrl: $invoice ? $this->stringValue($invoice['invoice_pdf'] ?? null) : null,
payload: $session,
);
$this->invoiceEmails->sendIfAvailable($transaction);
}
/**
* @param array<string, mixed> $payload
*/
private function handleCheckoutSessionExpired(array $payload): void
{
$session = $payload['data']['object'] ?? [];
$sessionId = $this->stringValue($session['id'] ?? null);
if (! $sessionId) {
return;
}
$transaction = $this->transactions->recordCheckoutSession(
user: $this->userFromCustomer($session['customer'] ?? null),
product: $this->productFromMetadata($session['metadata'] ?? null),
stripeCheckoutSessionId: $sessionId,
status: PaymentTransactionStatus::FAILED,
stripeEventId: $this->stringValue($payload['id'] ?? null),
stripeEventType: $this->stringValue($payload['type'] ?? null),
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
amount: $this->intValue($session['amount_total'] ?? null),
currency: $this->stringValue($session['currency'] ?? null),
errorMessage: 'Checkout session expired before payment.',
payload: $session,
);
$this->notifyPaymentFailure($transaction);
}
/**
* @param array<string, mixed> $payload
*/
private function handleInvoicePaid(array $payload): void
{
$invoice = $payload['data']['object'] ?? [];
$invoiceId = $this->stringValue($invoice['id'] ?? null);
if (! $invoiceId) {
return;
}
$matchedProduct = false;
foreach (($invoice['lines']['data'] ?? []) as $line) {
$priceId = $this->linePriceId($line);
$product = $priceId
? BillingProduct::query()
->where('purpose', BillingProductPurpose::SUBSCRIPTION)
->where('stripe_price_id', $priceId)
->first()
: null;
if (! $product) {
continue;
}
$matchedProduct = true;
$transaction = $this->transactions->recordInvoice(
user: $this->userFromCustomer($invoice['customer'] ?? null),
product: $product,
stripeInvoiceId: $invoiceId,
stripePriceId: $priceId,
status: PaymentTransactionStatus::PAID,
stripeEventId: $this->stringValue($payload['id'] ?? null),
stripeEventType: $this->stringValue($payload['type'] ?? null),
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
stripeSubscriptionId: $this->invoiceSubscriptionId($invoice),
amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null),
currency: $this->stringValue($invoice['currency'] ?? null),
invoiceUrl: $this->stringValue($invoice['hosted_invoice_url'] ?? null),
invoicePdfUrl: $this->stringValue($invoice['invoice_pdf'] ?? null),
payload: $invoice,
);
$this->invoiceEmails->sendIfAvailable($transaction);
}
if (! $matchedProduct) {
$this->transactions->recordInvoice(
user: $this->userFromCustomer($invoice['customer'] ?? null),
product: null,
stripeInvoiceId: $invoiceId,
stripePriceId: null,
status: PaymentTransactionStatus::IGNORED,
stripeEventId: $this->stringValue($payload['id'] ?? null),
stripeEventType: $this->stringValue($payload['type'] ?? null),
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
stripeSubscriptionId: $this->invoiceSubscriptionId($invoice),
amount: $this->intValue($invoice['amount_paid'] ?? null),
currency: $this->stringValue($invoice['currency'] ?? null),
errorMessage: 'Invoice does not contain the active subscription product.',
payload: $invoice,
);
}
}
/**
* @param array<string, mixed> $payload
*/
private function handleInvoicePaymentFailed(array $payload): void
{
$invoice = $payload['data']['object'] ?? [];
$invoiceId = $this->stringValue($invoice['id'] ?? null);
if (! $invoiceId) {
return;
}
$lines = $invoice['lines']['data'] ?? [null];
$notificationUser = null;
$shouldNotify = false;
foreach ($lines ?: [null] as $line) {
$priceId = is_array($line) ? $this->linePriceId($line) : null;
$product = $priceId
? BillingProduct::query()->where('stripe_price_id', $priceId)->first()
: null;
$transaction = $this->transactions->recordInvoice(
user: $this->userFromCustomer($invoice['customer'] ?? null),
product: $product,
stripeInvoiceId: $invoiceId,
stripePriceId: $priceId,
status: PaymentTransactionStatus::FAILED,
stripeEventId: $this->stringValue($payload['id'] ?? null),
stripeEventType: $this->stringValue($payload['type'] ?? null),
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
stripeSubscriptionId: $this->invoiceSubscriptionId($invoice),
amount: $this->intValue(
is_array($line)
? ($line['amount'] ?? $invoice['amount_due'] ?? null)
: ($invoice['amount_due'] ?? null)
),
currency: $this->stringValue($invoice['currency'] ?? null),
errorMessage: 'Invoice payment failed.',
payload: $invoice,
);
if ($this->shouldNotifyPaymentFailure($transaction)) {
$notificationUser ??= $transaction->user;
$shouldNotify = true;
}
}
if ($shouldNotify && $notificationUser) {
$notificationUser->notify(
(new PaymentFailedNotification)
->locale($notificationUser->preferredLocale()),
);
}
}
private function notifyPaymentFailure(PaymentTransaction $transaction): void
{
if (! $this->shouldNotifyPaymentFailure($transaction)) {
return;
}
$user = $transaction->user;
if (! $user) {
return;
}
$user->notify(
(new PaymentFailedNotification)
->locale($user->preferredLocale()),
);
}
private function shouldNotifyPaymentFailure(PaymentTransaction $transaction): bool
{
return $transaction->wasRecentlyCreated
|| $transaction->wasChanged('status')
|| $transaction->wasChanged('user_id');
}
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): ?BillingProduct
{
if (! is_array($metadata)
|| ! is_string($metadata['billing_product_id'] ?? null)
|| ! is_string($metadata['billing_product_purpose'] ?? null)) {
return null;
}
$purpose = BillingProductPurpose::tryFrom($metadata['billing_product_purpose']);
if (! $purpose) {
return null;
}
return BillingProduct::query()
->whereKey($metadata['billing_product_id'])
->where('purpose', $purpose)
->first();
}
/**
* @param array<string, mixed> $line
*/
private function linePriceId(array $line): ?string
{
return $this->stringValue(
$line['price']['id']
?? $line['pricing']['price_details']['price']
?? null
);
}
/**
* @param array<string, mixed> $invoice
*/
private function invoiceSubscriptionId(array $invoice): ?string
{
return $this->stringValue(
$invoice['subscription']
?? $invoice['parent']['subscription_details']['subscription']
?? null
);
}
private function stringValue(mixed $value): ?string
{
return is_string($value) && $value !== '' ? $value : null;
}
private function intValue(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
/**
* @return array<string, mixed>|null
*/
private function retrieveInvoice(?string $invoiceId): ?array
{
if (! $invoiceId) {
return null;
}
try {
return Cashier::stripe()
->invoices
->retrieve($invoiceId)
->toArray();
} catch (\Throwable) {
return null;
}
}
}
-64
View File
@@ -1,64 +0,0 @@
<?php
namespace App\Mail;
use App\Models\PaymentTransaction;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PaymentInvoiceMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public PaymentTransaction $transaction,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: trans('mail.payment_invoice.subject', [], $this->mailLocale()),
);
}
public function content(): Content
{
return new Content(
view: 'emails.payment-invoice',
with: [
'amount' => $this->formattedAmount(),
'invoicePdfUrl' => $this->transaction->invoice_pdf_url,
'invoiceUrl' => $this->transaction->invoice_url,
'locale' => $this->mailLocale(),
'productName' => $this->transaction->billingProduct?->name,
'userName' => $this->user->name,
],
);
}
public function attachments(): array
{
return [];
}
private function formattedAmount(): string
{
if ($this->transaction->amount === null) {
return trans('mail.payment_invoice.unknown_amount', [], $this->mailLocale());
}
return number_format($this->transaction->amount / 100, 2, ',', ' ')
.' '
.strtoupper($this->transaction->currency ?: 'eur');
}
private function mailLocale(): string
{
return $this->user->preferredLocale() ?? 'en';
}
}
-52
View File
@@ -1,52 +0,0 @@
<?php
namespace App\Models;
use App\Enums\BillingProductPurpose;
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 BillingProduct extends Model
{
use HasFactory, HasUlids;
protected $fillable = [
'name',
'description',
'purpose',
'amount',
'currency',
'stripe_product_id',
'stripe_price_id',
'is_active',
'sort_order',
];
public function paymentTransactions(): HasMany
{
return $this->hasMany(PaymentTransaction::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 [
'purpose' => BillingProductPurpose::class,
'amount' => 'integer',
'is_active' => 'boolean',
'sort_order' => 'integer',
];
}
}
-61
View File
@@ -1,61 +0,0 @@
<?php
namespace App\Models;
use App\Enums\PaymentTransactionStatus;
use App\Enums\PaymentTransactionType;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PaymentTransaction extends Model
{
/** @use HasFactory<\Database\Factories\PaymentTransactionFactory> */
use HasFactory, HasUlids;
protected $fillable = [
'user_id',
'billing_product_id',
'type',
'status',
'stripe_event_id',
'stripe_event_type',
'stripe_customer_id',
'stripe_checkout_session_id',
'stripe_invoice_id',
'stripe_payment_intent_id',
'stripe_subscription_id',
'stripe_price_id',
'amount',
'currency',
'invoice_url',
'invoice_pdf_url',
'invoice_email_sent_at',
'error_message',
'payload',
'processed_at',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function billingProduct(): BelongsTo
{
return $this->belongsTo(BillingProduct::class);
}
protected function casts(): array
{
return [
'type' => PaymentTransactionType::class,
'status' => PaymentTransactionStatus::class,
'amount' => 'integer',
'invoice_email_sent_at' => 'datetime',
'payload' => 'array',
'processed_at' => 'datetime',
];
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
class RevenueCatWebhookEvent extends Model
{
use HasUlids;
protected $fillable = [
'event_id',
'type',
'app_user_id',
'product_id',
'entitlement_ids',
'environment',
'payload',
'processed_at',
];
protected function casts(): array
{
return [
'entitlement_ids' => 'array',
'payload' => 'array',
'processed_at' => 'datetime',
];
}
}
+17 -13
View File
@@ -25,13 +25,12 @@ 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 Billable, HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes; use HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes;
protected $attributes = [ protected $attributes = [
'social_notifications_enabled' => true, 'social_notifications_enabled' => true,
@@ -55,7 +54,10 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
'bio', 'bio',
'account_verified_at', 'account_verified_at',
'certification_purchased_at', 'certification_purchased_at',
'trial_ends_at', 'subscription_product_id',
'subscription_store',
'subscription_expires_at',
'subscription_is_trial',
'daily_calorie_goal', 'daily_calorie_goal',
'daily_protein_goal', 'daily_protein_goal',
'daily_carbs_goal', 'daily_carbs_goal',
@@ -109,7 +111,8 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
'social_notifications_enabled' => 'boolean', 'social_notifications_enabled' => 'boolean',
'engagement_reminders_enabled' => 'boolean', 'engagement_reminders_enabled' => 'boolean',
'suspended_at' => 'datetime', 'suspended_at' => 'datetime',
'trial_ends_at' => 'datetime', 'subscription_expires_at' => 'datetime',
'subscription_is_trial' => 'boolean',
]; ];
} }
@@ -133,11 +136,6 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
return $this->hasMany(AiUsage::class); return $this->hasMany(AiUsage::class);
} }
public function paymentTransactions(): HasMany
{
return $this->hasMany(PaymentTransaction::class);
}
public function identityVerificationRequest(): HasOne public function identityVerificationRequest(): HasOne
{ {
return $this->hasOne(IdentityVerificationRequest::class); return $this->hasOne(IdentityVerificationRequest::class);
@@ -229,16 +227,22 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
public function canAnalyzeMeals(): bool public function canAnalyzeMeals(): bool
{ {
return $this->subscribed('default') || $this->onGenericTrial(); return $this->hasActiveSubscription();
} }
public function analysisAccessLevel(): string public function analysisAccessLevel(): string
{ {
if ($this->subscribed('default')) { if (! $this->hasActiveSubscription()) {
return 'subscribed'; return 'free';
} }
return $this->onGenericTrial() ? 'trial' : 'free'; return $this->subscription_is_trial ? 'trial' : 'subscribed';
}
public function hasActiveSubscription(): bool
{
return $this->subscription_product_id !== null
&& $this->subscription_expires_at?->isFuture() === true;
} }
public function hasPurchasedCertification(): bool public function hasPurchasedCertification(): bool
-4
View File
@@ -3,7 +3,6 @@
namespace App\Providers; namespace App\Providers;
use App\Broadcasting\ExpoPushChannel; use App\Broadcasting\ExpoPushChannel;
use App\Listeners\HandleStripeWebhook;
use App\Mail\ResetPasswordMail; use App\Mail\ResetPasswordMail;
use App\Mail\VerifyAccount; use App\Mail\VerifyAccount;
use App\Models\User; use App\Models\User;
@@ -14,7 +13,6 @@ use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Notifications\VerifyEmail; 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\Event;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@@ -60,8 +58,6 @@ 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, HandleStripeWebhook::class);
VerifyEmail::createUrlUsing(function (User $notifiable): string { VerifyEmail::createUrlUsing(function (User $notifiable): string {
$relativeUrl = URL::temporarySignedRoute( $relativeUrl = URL::temporarySignedRoute(
'verification.verify', 'verification.verify',
-42
View File
@@ -1,42 +0,0 @@
<?php
namespace App\Services;
use App\Mail\PaymentInvoiceMail;
use App\Models\PaymentTransaction;
use Illuminate\Support\Facades\Mail;
use Throwable;
class PaymentInvoiceEmailer
{
public function sendIfAvailable(PaymentTransaction $transaction): void
{
if ($transaction->invoice_email_sent_at !== null) {
return;
}
if (! $transaction->user || ! $transaction->user->email) {
return;
}
if (! $transaction->invoice_url && ! $transaction->invoice_pdf_url) {
return;
}
try {
Mail::to($transaction->user->email)->send(
new PaymentInvoiceMail($transaction->user, $transaction->loadMissing('billingProduct'))
);
} catch (Throwable $exception) {
$transaction->forceFill([
'error_message' => 'Invoice email failed: '.$exception->getMessage(),
])->save();
return;
}
$transaction->forceFill([
'invoice_email_sent_at' => now(),
])->save();
}
}
@@ -1,96 +0,0 @@
<?php
namespace App\Services;
use App\Enums\PaymentTransactionStatus;
use App\Enums\PaymentTransactionType;
use App\Models\BillingProduct;
use App\Models\PaymentTransaction;
use App\Models\User;
class PaymentTransactionRecorder
{
/**
* @param array<string, mixed>|null $payload
*/
public function recordCheckoutSession(
?User $user,
?BillingProduct $product,
string $stripeCheckoutSessionId,
PaymentTransactionStatus $status = PaymentTransactionStatus::PENDING,
?string $stripeEventId = null,
?string $stripeEventType = null,
?string $stripeCustomerId = null,
?string $stripePaymentIntentId = null,
?int $amount = null,
?string $currency = null,
?string $invoiceUrl = null,
?string $invoicePdfUrl = null,
?string $errorMessage = null,
?array $payload = null,
): PaymentTransaction {
return PaymentTransaction::query()->updateOrCreate([
'stripe_checkout_session_id' => $stripeCheckoutSessionId,
], [
'user_id' => $user?->getKey(),
'billing_product_id' => $product?->getKey(),
'type' => PaymentTransactionType::CHECKOUT,
'status' => $status,
'stripe_event_id' => $stripeEventId,
'stripe_event_type' => $stripeEventType,
'stripe_customer_id' => $stripeCustomerId,
'stripe_payment_intent_id' => $stripePaymentIntentId,
'amount' => $amount,
'currency' => $currency,
'invoice_url' => $invoiceUrl,
'invoice_pdf_url' => $invoicePdfUrl,
'error_message' => $errorMessage,
'payload' => $payload,
'processed_at' => $status === PaymentTransactionStatus::PENDING ? null : now(),
]);
}
/**
* @param array<string, mixed>|null $payload
*/
public function recordInvoice(
?User $user,
?BillingProduct $product,
string $stripeInvoiceId,
?string $stripePriceId,
PaymentTransactionStatus $status,
?string $stripeEventId = null,
?string $stripeEventType = null,
?string $stripeCustomerId = null,
?string $stripePaymentIntentId = null,
?string $stripeSubscriptionId = null,
?int $amount = null,
?string $currency = null,
?string $invoiceUrl = null,
?string $invoicePdfUrl = null,
?string $errorMessage = null,
?array $payload = null,
): PaymentTransaction {
return PaymentTransaction::query()->updateOrCreate([
'stripe_invoice_id' => $stripeInvoiceId,
'stripe_price_id' => $stripePriceId,
], [
'user_id' => $user?->getKey(),
'billing_product_id' => $product?->getKey(),
'type' => PaymentTransactionType::SUBSCRIPTION_INVOICE,
'status' => $status,
'stripe_event_id' => $stripeEventId,
'stripe_event_type' => $stripeEventType,
'stripe_customer_id' => $stripeCustomerId,
'stripe_payment_intent_id' => $stripePaymentIntentId,
'stripe_subscription_id' => $stripeSubscriptionId,
'amount' => $amount,
'currency' => $currency,
'invoice_url' => $invoiceUrl,
'invoice_pdf_url' => $invoicePdfUrl,
'error_message' => $errorMessage,
'payload' => $payload,
'processed_at' => now(),
]);
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace App\Services;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class RevenueCatService
{
public function sync(User $user): User
{
$response = $this->client()
->get('subscribers/'.rawurlencode((string) $user->getKey()))
->throw()
->json();
$subscriber = Arr::get($response, 'subscriber', []);
if (! is_array($subscriber)) {
throw new RuntimeException('RevenueCat returned an invalid subscriber payload.');
}
$subscription = $this->entitlement(
$subscriber,
(string) config('billing.revenuecat.subscription_entitlement'),
);
$certification = $this->entitlement(
$subscriber,
(string) config('billing.revenuecat.certification_entitlement'),
);
$subscriptionProductId = $this->stringValue($subscription['product_identifier'] ?? null);
$subscriptionDetails = $subscriptionProductId
? Arr::get($subscriber, "subscriptions.{$subscriptionProductId}", [])
: [];
$user->forceFill([
'subscription_product_id' => $this->entitlementIsActive($subscription)
? $subscriptionProductId
: null,
'subscription_store' => $this->entitlementIsActive($subscription)
? $this->stringValue(is_array($subscriptionDetails) ? ($subscriptionDetails['store'] ?? null) : null)
: null,
'subscription_expires_at' => $this->entitlementIsActive($subscription)
? $this->effectiveExpiration($subscription)
: null,
'subscription_is_trial' => $this->entitlementIsActive($subscription)
&& is_array($subscriptionDetails)
&& strtoupper((string) ($subscriptionDetails['period_type'] ?? '')) === 'TRIAL',
'certification_purchased_at' => $this->entitlementIsActive($certification)
? $this->dateValue($certification['purchase_date'] ?? null) ?? now()
: null,
])->save();
return $user->fresh();
}
public function deleteCustomer(User $user): void
{
if (trim((string) config('billing.revenuecat.secret_key')) === '') {
return;
}
$response = $this->client()
->delete('subscribers/'.rawurlencode((string) $user->getKey()));
if (! $response->notFound()) {
$response->throw();
}
}
private function client(): PendingRequest
{
$secretKey = trim((string) config('billing.revenuecat.secret_key'));
if ($secretKey === '') {
throw new RuntimeException('REVENUECAT_SECRET_KEY is not configured.');
}
return Http::baseUrl(rtrim((string) config('billing.revenuecat.api_url'), '/'))
->withToken($secretKey)
->acceptJson()
->connectTimeout(5)
->timeout(12)
->retry([200, 500], throw: false);
}
/**
* @param array<string, mixed> $subscriber
* @return array<string, mixed>
*/
private function entitlement(array $subscriber, string $identifier): array
{
$entitlement = Arr::get($subscriber, "entitlements.{$identifier}", []);
return is_array($entitlement) ? $entitlement : [];
}
/**
* @param array<string, mixed> $entitlement
*/
private function entitlementIsActive(array $entitlement): bool
{
if ($entitlement === []) {
return false;
}
$expiration = $this->effectiveExpiration($entitlement);
return $expiration === null || $expiration->isFuture();
}
/**
* @param array<string, mixed> $entitlement
*/
private function effectiveExpiration(array $entitlement): ?CarbonImmutable
{
$expiration = $this->dateValue($entitlement['expires_date'] ?? null);
$gracePeriodExpiration = $this->dateValue($entitlement['grace_period_expires_date'] ?? null);
if ($expiration === null) {
return $gracePeriodExpiration;
}
if ($gracePeriodExpiration === null) {
return $expiration;
}
return $gracePeriodExpiration->greaterThan($expiration)
? $gracePeriodExpiration
: $expiration;
}
private function dateValue(mixed $value): ?CarbonImmutable
{
if (! is_string($value) || $value === '') {
return null;
}
return CarbonImmutable::parse($value);
}
private function stringValue(mixed $value): ?string
{
return is_string($value) && $value !== '' ? $value : null;
}
}
@@ -1,96 +0,0 @@
<?php
namespace App\Services;
use App\Enums\BillingProductPurpose;
use App\Models\BillingProduct;
use Laravel\Cashier\Cashier;
class StripeBillingProductSyncer
{
public function sync(BillingProduct $product): BillingProduct
{
throw_if(
$product->purpose === null,
\InvalidArgumentException::class,
'A billing product purpose is required before syncing with Stripe.',
);
$stripe = Cashier::stripe();
$stripeProductId = $product->stripe_product_id;
if (! $stripeProductId) {
$stripeProduct = $stripe->products->create([
'name' => $product->name,
'description' => $product->description,
'metadata' => [
'billing_product_id' => $product->getKey(),
'billing_product_purpose' => $product->purpose?->value,
],
]);
$stripeProductId = $stripeProduct->id;
} else {
$stripe->products->update($stripeProductId, [
'name' => $product->name,
'description' => $product->description,
'active' => (bool) $product->is_active,
'metadata' => [
'billing_product_id' => $product->getKey(),
'billing_product_purpose' => $product->purpose?->value,
],
]);
}
$needsNewPrice = false;
if (! $product->stripe_price_id) {
$needsNewPrice = true;
} elseif ($product->wasChanged(['amount', 'currency', 'purpose'])) {
$needsNewPrice = true;
}
$stripePriceId = $product->stripe_price_id;
if ($needsNewPrice) {
$priceData = [
'product' => $stripeProductId,
'unit_amount' => $product->amount,
'currency' => strtolower($product->currency),
'metadata' => [
'billing_product_id' => $product->getKey(),
'billing_product_purpose' => $product->purpose?->value,
],
];
if ($product->purpose === BillingProductPurpose::SUBSCRIPTION) {
$priceData['recurring'] = [
'interval' => 'month',
];
}
$stripePrice = $stripe->prices->create($priceData);
$stripePriceId = $stripePrice->id;
// Archive the old price if it exists
if ($product->stripe_price_id && $product->stripe_price_id !== $stripePriceId) {
try {
$stripe->prices->update($product->stripe_price_id, [
'active' => false,
]);
} catch (\Exception $e) {
// Ignore error if price not found
}
}
}
if ($product->stripe_product_id !== $stripeProductId || $product->stripe_price_id !== $stripePriceId) {
$product->forceFill([
'stripe_product_id' => $stripeProductId,
'stripe_price_id' => $stripePriceId,
])->saveQuietly();
}
return $product;
}
}
-1
View File
@@ -13,7 +13,6 @@
"filament/widgets": "^5.3", "filament/widgets": "^5.3",
"http-interop/http-factory-guzzle": "^1.2", "http-interop/http-factory-guzzle": "^1.2",
"laravel/ai": "^0.6.8", "laravel/ai": "^0.6.8",
"laravel/cashier": "^16.5",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/horizon": "^5.45", "laravel/horizon": "^5.45",
"laravel/octane": "^2.17", "laravel/octane": "^2.17",
Generated
+1 -327
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "90f8a628941b90402c49fc7d951c3329", "content-hash": "df1fb81f35dace6e7c3dd49b05ade882",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -2694,95 +2694,6 @@
}, },
"time": "2026-05-11T10:46:54+00:00" "time": "2026-05-11T10:46:54+00:00"
}, },
{
"name": "laravel/cashier",
"version": "v16.5.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/cashier-stripe.git",
"reference": "b6bcd6b4d79acead34d00a5a528c904d67c5e08a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/b6bcd6b4d79acead34d00a5a528c904d67c5e08a",
"reference": "b6bcd6b4d79acead34d00a5a528c904d67c5e08a",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
"illuminate/database": "^10.0|^11.0|^12.0|^13.0",
"illuminate/http": "^10.0|^11.0|^12.0|^13.0",
"illuminate/log": "^10.0|^11.0|^12.0|^13.0",
"illuminate/notifications": "^10.0|^11.0|^12.0|^13.0",
"illuminate/pagination": "^10.0|^11.0|^12.0|^13.0",
"illuminate/routing": "^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"illuminate/view": "^10.0|^11.0|^12.0|^13.0",
"moneyphp/money": "^4.0",
"nesbot/carbon": "^2.0|^3.0",
"php": "^8.1",
"stripe/stripe-php": "^17.3.0",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/http-kernel": "^6.0|^7.0|^8.0",
"symfony/polyfill-intl-icu": "^1.22.1",
"symfony/polyfill-php84": "^1.32"
},
"require-dev": {
"dompdf/dompdf": "^2.0|^3.0",
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10",
"spatie/laravel-ray": "^1.40"
},
"suggest": {
"dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^2.0|^3.0).",
"ext-intl": "Allows for more locales besides the default \"en\" when formatting money values.",
"spatie/laravel-pdf": "Required when generating and downloading invoice PDF's using Cashier's LaravelPdfInvoiceRenderer."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Cashier\\CashierServiceProvider"
]
},
"branch-alias": {
"dev-master": "16.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Cashier\\": "src/",
"Laravel\\Cashier\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Dries Vints",
"email": "dries@laravel.com"
}
],
"description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.",
"keywords": [
"billing",
"laravel",
"stripe"
],
"support": {
"issues": "https://github.com/laravel/cashier/issues",
"source": "https://github.com/laravel/cashier"
},
"time": "2026-05-05T21:18:35+00:00"
},
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v12.53.0", "version": "v12.53.0",
@@ -4574,96 +4485,6 @@
}, },
"time": "2025-09-18T10:15:45+00:00" "time": "2025-09-18T10:15:45+00:00"
}, },
{
"name": "moneyphp/money",
"version": "v4.9.0",
"source": {
"type": "git",
"url": "https://github.com/moneyphp/money.git",
"reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/moneyphp/money/zipball/d49ee625c6ba79b9d7a228ce153b02fc1032152b",
"reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b",
"shasum": ""
},
"require": {
"ext-bcmath": "*",
"ext-filter": "*",
"ext-json": "*",
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"cache/taggable-cache": "^1.1.0",
"doctrine/coding-standard": "^12.0",
"doctrine/instantiator": "^1.5.0 || ^2.0",
"ext-gmp": "*",
"ext-intl": "*",
"florianv/exchanger": "^2.8.1",
"florianv/swap": "^4.3.0",
"moneyphp/crypto-currencies": "^1.1.0",
"moneyphp/iso-currencies": "^3.4",
"php-http/message": "^1.16.0",
"php-http/mock-client": "^1.6.0",
"phpbench/phpbench": "^1.2.5",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1.9",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^10.5.9",
"psr/cache": "^1.0.1 || ^2.0 || ^3.0",
"ticketswap/phpstan-error-formatter": "^1.1"
},
"suggest": {
"ext-gmp": "Calculate without integer limits",
"ext-intl": "Format Money objects with intl",
"florianv/exchanger": "Exchange rates library for PHP",
"florianv/swap": "Exchange rates library for PHP",
"psr/cache-implementation": "Used for Currency caching"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Money\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mathias Verraes",
"email": "mathias@verraes.net",
"homepage": "http://verraes.net"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
},
{
"name": "Frederik Bosch",
"email": "f.bosch@genkgo.nl"
}
],
"description": "PHP implementation of Fowler's Money pattern",
"homepage": "http://moneyphp.org",
"keywords": [
"Value Object",
"money",
"vo"
],
"support": {
"issues": "https://github.com/moneyphp/money/issues",
"source": "https://github.com/moneyphp/money/tree/v4.9.0"
},
"time": "2026-05-04T20:23:15+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -7827,65 +7648,6 @@
], ],
"time": "2026-01-12T07:42:22+00:00" "time": "2026-01-12T07:42:22+00:00"
}, },
{
"name": "stripe/stripe-php",
"version": "v17.6.0",
"source": {
"type": "git",
"url": "https://github.com/stripe/stripe-php.git",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": ">=5.6.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.72.0",
"phpstan/phpstan": "^1.2",
"phpunit/phpunit": "^5.7 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Stripe\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Stripe and contributors",
"homepage": "https://github.com/stripe/stripe-php/contributors"
}
],
"description": "Stripe PHP Library",
"homepage": "https://stripe.com/",
"keywords": [
"api",
"payment processing",
"stripe"
],
"support": {
"issues": "https://github.com/stripe/stripe-php/issues",
"source": "https://github.com/stripe/stripe-php/tree/v17.6.0"
},
"time": "2025-08-27T19:32:42+00:00"
},
{ {
"name": "symfony/clock", "name": "symfony/clock",
"version": "v7.4.0", "version": "v7.4.0",
@@ -9196,94 +8958,6 @@
], ],
"time": "2025-06-27T09:58:17+00:00" "time": "2025-06-27T09:58:17+00:00"
}, },
{
"name": "symfony/polyfill-intl-icu",
"version": "v1.38.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-icu.git",
"reference": "445c90e341fccda10311019cf82ff73bb7343945"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/445c90e341fccda10311019cf82ff73bb7343945",
"reference": "445c90e341fccda10311019cf82ff73bb7343945",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"suggest": {
"ext-intl": "For best performance and support of other locales than \"en\""
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Icu\\": ""
},
"classmap": [
"Resources/stubs"
],
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's ICU-related data and classes",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"icu",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.38.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-25T11:52:53+00:00"
},
{ {
"name": "symfony/polyfill-intl-idn", "name": "symfony/polyfill-intl-idn",
"version": "v1.37.0", "version": "v1.37.0",
+7 -1
View File
@@ -1,7 +1,13 @@
<?php <?php
return [ return [
'trial_days' => (int) env('BILLING_TRIAL_DAYS', 7), 'revenuecat' => [
'api_url' => env('REVENUECAT_API_URL', 'https://api.revenuecat.com/v1'),
'secret_key' => env('REVENUECAT_SECRET_KEY'),
'webhook_authorization' => env('REVENUECAT_WEBHOOK_AUTHORIZATION'),
'subscription_entitlement' => env('REVENUECAT_SUBSCRIPTION_ENTITLEMENT', 'bowli Pro'),
'certification_entitlement' => env('REVENUECAT_CERTIFICATION_ENTITLEMENT', 'certification'),
],
'identity_verification' => [ 'identity_verification' => [
'disk' => 'identity_documents', 'disk' => 'identity_documents',
@@ -0,0 +1,141 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->string('subscription_product_id')->nullable()->index();
$table->string('subscription_store')->nullable();
$table->timestampTz('subscription_expires_at')->nullable()->index();
$table->boolean('subscription_is_trial')->default(false);
});
Schema::create('revenue_cat_webhook_events', function (Blueprint $table): void {
$table->ulid('id')->primary();
$table->string('event_id')->unique();
$table->string('type')->index();
$table->string('app_user_id')->nullable()->index();
$table->string('product_id')->nullable();
$table->json('entitlement_ids')->nullable();
$table->string('environment')->nullable();
$table->json('payload');
$table->timestampTz('processed_at')->nullable();
$table->timestamps();
});
Schema::dropIfExists('subscription_items');
Schema::dropIfExists('subscriptions');
Schema::dropIfExists('payment_transactions');
Schema::dropIfExists('billing_products');
Schema::table('users', function (Blueprint $table): void {
$table->dropIndex(['stripe_id']);
$table->dropColumn([
'stripe_id',
'pm_type',
'pm_last_four',
'trial_ends_at',
]);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('revenue_cat_webhook_events');
Schema::table('users', function (Blueprint $table): void {
$table->dropIndex(['subscription_product_id']);
$table->dropIndex(['subscription_expires_at']);
$table->dropColumn([
'subscription_product_id',
'subscription_store',
'subscription_expires_at',
'subscription_is_trial',
]);
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();
$table->string('pm_last_four', 4)->nullable();
$table->timestamp('trial_ends_at')->nullable();
});
Schema::create('billing_products', function (Blueprint $table): void {
$table->ulid('id')->primary();
$table->string('name');
$table->text('description')->nullable();
$table->string('purpose', 32)->unique();
$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(['purpose', 'is_active', 'sort_order']);
});
Schema::create('payment_transactions', function (Blueprint $table): void {
$table->ulid('id')->primary();
$table->foreignUlid('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignUlid('billing_product_id')->nullable()->constrained('billing_products')->nullOnDelete();
$table->string('type', 64);
$table->string('status', 64);
$table->string('stripe_event_id')->nullable()->index();
$table->string('stripe_event_type')->nullable()->index();
$table->string('stripe_customer_id')->nullable()->index();
$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->string('stripe_price_id')->nullable()->index();
$table->unsignedInteger('amount')->nullable();
$table->string('currency', 3)->nullable();
$table->string('invoice_url', 2048)->nullable();
$table->string('invoice_pdf_url', 2048)->nullable();
$table->timestamp('invoice_email_sent_at')->nullable();
$table->text('error_message')->nullable();
$table->json('payload')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
$table->index(['status', 'created_at']);
$table->index(['type', 'status', 'created_at']);
});
Schema::create('subscriptions', function (Blueprint $table): void {
$table->id();
$table->foreignUlid('user_id');
$table->string('type');
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->string('stripe_price')->nullable();
$table->integer('quantity')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'stripe_status']);
});
Schema::create('subscription_items', function (Blueprint $table): void {
$table->id();
$table->foreignId('subscription_id');
$table->string('stripe_id')->unique();
$table->string('stripe_product');
$table->string('stripe_price');
$table->string('meter_id')->nullable();
$table->integer('quantity')->nullable();
$table->string('meter_event_name')->nullable();
$table->timestamps();
$table->index(['subscription_id', 'stripe_price']);
});
}
};
+3 -3
View File
@@ -64,7 +64,7 @@ class LegalDocumentSeeder extends Seeder
[ [
'title' => 'Paiements et abonnements', 'title' => 'Paiements et abonnements',
'body' => [ 'body' => [
'Stripe traite les paiements, les abonnements, les factures et les informations de moyen de paiement. Bowli conserve uniquement les identifiants et statuts nécessaires au suivi de la transaction et de tes droits daccès.', 'Apple ou Google traite le paiement selon ton appareil. RevenueCat synchronise les achats, abonnements et droits daccès sans recevoir les informations complètes de ton moyen de paiement.',
], ],
], ],
[ [
@@ -118,8 +118,8 @@ class LegalDocumentSeeder extends Seeder
[ [
'title' => 'Essai, abonnement et certification', 'title' => 'Essai, abonnement et certification',
'body' => [ 'body' => [
'La publication de plats reste accessible sans abonnement. Lanalyse automatique des photos est accessible pendant la période dessai indiquée dans lapplication, puis avec lunique formule dabonnement active.', 'La publication de plats reste accessible sans abonnement. Lanalyse automatique des photos est accessible avec un abonnement mensuel ou annuel. Une éventuelle période dessai concerne uniquement ces abonnements et sa durée est affichée avant validation par le store.',
'La certification est un achat unique distinct de labonnement. Le paiement donne le droit de soumettre une demande, mais le badge nest accordé quaprès vérification dune pièce didentité valide. En cas de refus, une nouvelle pièce peut être envoyée sans nouvel achat.', 'La certification ne comporte aucune période dessai. Cest un achat unique distinct de labonnement : le paiement donne le droit de soumettre une demande, mais le badge nest accordé quaprès vérification dune pièce didentité valide. En cas de refus, une nouvelle pièce peut être envoyée sans nouvel achat.',
], ],
], ],
[ [
+1 -72
View File
@@ -29,7 +29,7 @@ return [
'email_verified_at' => 'Email verified at', 'email_verified_at' => 'Email verified at',
'account_verified_at' => 'Account verified at', 'account_verified_at' => 'Account verified at',
'certification_purchased_at' => 'Certification purchased at', 'certification_purchased_at' => 'Certification purchased at',
'trial_ends_at' => 'Trial ends at', 'subscription_expires_at' => 'Subscription valid until',
'suspended_at' => 'Suspended at', 'suspended_at' => 'Suspended at',
'suspended_by' => 'Suspended by', 'suspended_by' => 'Suspended by',
'avatar' => 'Avatar', 'avatar' => 'Avatar',
@@ -69,36 +69,6 @@ return [
'empty' => '-', 'empty' => '-',
], ],
], ],
'billing_products' => [
'navigation' => [
'label' => 'Offers',
'singular' => 'Offer',
'plural' => 'Offers',
],
'sections' => [
'product' => 'Product',
'stripe' => 'Stripe',
],
'fields' => [
'name' => 'Name',
'description' => 'Description',
'purpose' => 'Offer',
'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.',
],
],
'identity_verification_requests' => [ 'identity_verification_requests' => [
'navigation' => [ 'navigation' => [
'label' => 'Identity verifications', 'label' => 'Identity verifications',
@@ -128,47 +98,6 @@ return [
'empty' => '-', 'empty' => '-',
], ],
], ],
'payment_transactions' => [
'navigation' => [
'label' => 'Payments',
'singular' => 'Payment',
'plural' => 'Payments',
],
'sections' => [
'summary' => 'Summary',
'stripe' => 'Stripe',
'error' => 'Error',
'payload' => 'Stripe payload',
],
'fields' => [
'id' => 'ID',
'user' => 'User',
'billing_product' => 'Product',
'type' => 'Type',
'status' => 'Status',
'amount' => 'Amount',
'invoice_url' => 'Invoice URL',
'invoice_pdf_url' => 'Invoice PDF',
'invoice_email_sent_at' => 'Invoice email sent at',
'stripe_event_id' => 'Stripe event ID',
'stripe_event_type' => 'Event',
'stripe_customer_id' => 'Stripe customer ID',
'stripe_checkout_session_id' => 'Stripe checkout session ID',
'stripe_checkout_session_id_short' => 'Checkout session',
'stripe_invoice_id' => 'Stripe invoice ID',
'stripe_invoice_id_short' => 'Invoice',
'stripe_payment_intent_id' => 'Stripe payment intent ID',
'stripe_subscription_id' => 'Stripe subscription ID',
'stripe_price_id' => 'Stripe price ID',
'error_message' => 'Message',
'payload' => 'Payload',
'processed_at' => 'Processed at',
'created_at' => 'Created at',
],
'placeholders' => [
'empty' => '-',
],
],
'moderation_cases' => [ 'moderation_cases' => [
'navigation' => [ 'navigation' => [
'label' => 'Moderation', 'label' => 'Moderation',
+1 -72
View File
@@ -29,7 +29,7 @@ return [
'email_verified_at' => 'Email vérifié le', 'email_verified_at' => 'Email vérifié le',
'account_verified_at' => 'Compte vérifié le', 'account_verified_at' => 'Compte vérifié le',
'certification_purchased_at' => 'Certification achetée le', 'certification_purchased_at' => 'Certification achetée le',
'trial_ends_at' => 'Fin de lessai', 'subscription_expires_at' => 'Abonnement valable jusquau',
'suspended_at' => 'Suspendu le', 'suspended_at' => 'Suspendu le',
'suspended_by' => 'Suspendu par', 'suspended_by' => 'Suspendu par',
'avatar' => 'Avatar', 'avatar' => 'Avatar',
@@ -69,36 +69,6 @@ return [
'empty' => '-', 'empty' => '-',
], ],
], ],
'billing_products' => [
'navigation' => [
'label' => 'Offres',
'singular' => 'Offre',
'plural' => 'Offres',
],
'sections' => [
'product' => 'Produit',
'stripe' => 'Stripe',
],
'fields' => [
'name' => 'Nom',
'description' => 'Description',
'purpose' => 'Offre',
'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é.',
],
],
'identity_verification_requests' => [ 'identity_verification_requests' => [
'navigation' => [ 'navigation' => [
'label' => 'Vérifications didentité', 'label' => 'Vérifications didentité',
@@ -128,47 +98,6 @@ return [
'empty' => '-', 'empty' => '-',
], ],
], ],
'payment_transactions' => [
'navigation' => [
'label' => 'Paiements',
'singular' => 'Paiement',
'plural' => 'Paiements',
],
'sections' => [
'summary' => 'Résumé',
'stripe' => 'Stripe',
'error' => 'Erreur',
'payload' => 'Payload Stripe',
],
'fields' => [
'id' => 'ID',
'user' => 'Utilisateur',
'billing_product' => 'Produit',
'type' => 'Type',
'status' => 'Statut',
'amount' => 'Montant',
'invoice_url' => 'URL facture',
'invoice_pdf_url' => 'PDF facture',
'invoice_email_sent_at' => 'Email facture envoyé le',
'stripe_event_id' => 'Stripe event ID',
'stripe_event_type' => 'Événement',
'stripe_customer_id' => 'Stripe customer ID',
'stripe_checkout_session_id' => 'Stripe checkout session ID',
'stripe_checkout_session_id_short' => 'Checkout session',
'stripe_invoice_id' => 'Stripe invoice ID',
'stripe_invoice_id_short' => 'Invoice',
'stripe_payment_intent_id' => 'Stripe payment intent ID',
'stripe_subscription_id' => 'Stripe subscription ID',
'stripe_price_id' => 'Stripe price ID',
'error_message' => 'Message',
'payload' => 'Payload',
'processed_at' => 'Traité le',
'created_at' => 'Créé le',
],
'placeholders' => [
'empty' => '-',
],
],
'moderation_cases' => [ 'moderation_cases' => [
'navigation' => [ 'navigation' => [
'label' => 'Modération', 'label' => 'Modération',
@@ -1,79 +0,0 @@
<!DOCTYPE html>
<html lang="{{ $locale }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ trans('mail.payment_invoice.subject', [], $locale) }}</title>
</head>
<body style="margin: 0; padding: 0; background: #f5efe6; color: #232323; font-family: Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f5efe6; padding: 32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 560px; background: #ffffff; border-radius: 8px; overflow: hidden;">
<tr>
<td style="padding: 32px;">
<p style="margin: 0 0 8px; color: #426fb3; font-size: 14px; font-weight: 700; text-transform: uppercase;">
Bowli
</p>
<h1 style="margin: 0 0 16px; color: #000000; font-size: 28px; line-height: 34px;">
{{ trans('mail.payment_invoice.title', [], $locale) }}
</h1>
<p style="margin: 0 0 16px; color: #232323; font-size: 16px; line-height: 24px;">
{{ trans('mail.payment_invoice.greeting', ['name' => $userName ?: trans('mail.payment_invoice.default_name', [], $locale)], $locale) }}
</p>
<p style="margin: 0 0 20px; color: #232323; font-size: 16px; line-height: 24px;">
{{ trans('mail.payment_invoice.intro', [], $locale) }}
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 0 0 24px; border-collapse: collapse;">
<tr>
<td style="padding: 10px 0; color: #666666; font-size: 14px;">
{{ trans('mail.payment_invoice.product', [], $locale) }}
</td>
<td align="right" style="padding: 10px 0; color: #232323; font-size: 14px; font-weight: 700;">
{{ $productName ?: trans('mail.payment_invoice.default_product', [], $locale) }}
</td>
</tr>
<tr>
<td style="padding: 10px 0; color: #666666; font-size: 14px; border-top: 1px solid #eeeeee;">
{{ trans('mail.payment_invoice.amount', [], $locale) }}
</td>
<td align="right" style="padding: 10px 0; color: #232323; font-size: 14px; font-weight: 700; border-top: 1px solid #eeeeee;">
{{ $amount }}
</td>
</tr>
</table>
@if ($invoiceUrl)
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 24px;">
<tr>
<td style="background: #000000; border-radius: 999px;">
<a href="{{ $invoiceUrl }}" style="display: inline-block; padding: 14px 24px; color: #ffffff; font-size: 16px; font-weight: 700; text-decoration: none;">
{{ trans('mail.payment_invoice.action', [], $locale) }}
</a>
</td>
</tr>
</table>
@endif
@if ($invoicePdfUrl)
<p style="margin: 0 0 16px; color: #666666; font-size: 14px; line-height: 22px;">
{{ trans('mail.payment_invoice.pdf_link', [], $locale) }}
<a href="{{ $invoicePdfUrl }}" style="color: #426fb3;">{{ $invoicePdfUrl }}</a>
</p>
@endif
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 22px;">
{{ trans('mail.payment_invoice.footer', [], $locale) }}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+8 -4
View File
@@ -1,7 +1,6 @@
<?php <?php
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\BillingController;
use App\Http\Controllers\CertificationController; use App\Http\Controllers\CertificationController;
use App\Http\Controllers\DeviceTokenController; use App\Http\Controllers\DeviceTokenController;
use App\Http\Controllers\FollowController; use App\Http\Controllers\FollowController;
@@ -13,6 +12,7 @@ use App\Http\Controllers\NotificationPreferenceController;
use App\Http\Controllers\PostReviewsController; use App\Http\Controllers\PostReviewsController;
use App\Http\Controllers\PublicUserProfileController; use App\Http\Controllers\PublicUserProfileController;
use App\Http\Controllers\ReportController; use App\Http\Controllers\ReportController;
use App\Http\Controllers\RevenueCatController;
use App\Http\Controllers\SearchController; use App\Http\Controllers\SearchController;
use App\Http\Controllers\StravaController; use App\Http\Controllers\StravaController;
use App\Http\Controllers\UserBlockController; use App\Http\Controllers\UserBlockController;
@@ -69,11 +69,15 @@ Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function
Route::patch('notification-preferences', [NotificationPreferenceController::class, 'update'])->middleware('throttle:account-action')->name('notification-preferences.update'); Route::patch('notification-preferences', [NotificationPreferenceController::class, 'update'])->middleware('throttle:account-action')->name('notification-preferences.update');
}); });
Route::post('webhooks/revenuecat', [RevenueCatController::class, 'webhook'])
->middleware('throttle:60,1')
->name('revenuecat.webhook');
// Billing // Billing
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->prefix('billing')->group(function (): void { Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->prefix('billing')->group(function (): void {
Route::get('products', [BillingController::class, 'products'])->name('billing.products'); Route::post('sync', [RevenueCatController::class, 'sync'])
Route::post('products/{billingProduct}/checkout', [BillingController::class, 'checkout'])->middleware('throttle:account-action')->name('billing.checkout'); ->middleware('throttle:account-action')
Route::post('portal', [BillingController::class, 'portal'])->middleware('throttle:account-action')->name('billing.portal'); ->name('billing.sync');
}); });
Route::middleware(['auth:sanctum', 'verified', 'not_suspended']) Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])
-2
View File
@@ -1,7 +1,6 @@
<?php <?php
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\BillingController;
use App\Http\Controllers\StravaController; use App\Http\Controllers\StravaController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -11,4 +10,3 @@ Route::post('password/reset', [AuthController::class, 'resetPasswordFromView'])
->middleware('throttle:6,1') ->middleware('throttle:6,1')
->name('password.web-update'); ->name('password.web-update');
Route::get('strava/callback', [StravaController::class, 'redirectToMobileApp'])->name('strava.web-callback'); Route::get('strava/callback', [StravaController::class, 'redirectToMobileApp'])->name('strava.web-callback');
Route::get('billing/return/{status}', [BillingController::class, 'redirectToMobileApp'])->name('billing.web-return');
-39
View File
@@ -1,39 +0,0 @@
<?php
use App\Enums\BillingProductPurpose;
use App\Models\BillingProduct;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
it('lists the subscription and certification offers for the mobile app', function () {
Sanctum::actingAs(User::factory()->create());
BillingProduct::create([
'name' => 'Certification',
'purpose' => BillingProductPurpose::CERTIFICATION,
'amount' => 1999,
'currency' => 'eur',
'stripe_price_id' => 'price_certification',
'sort_order' => 20,
]);
BillingProduct::create([
'name' => 'Bowli Plus',
'purpose' => BillingProductPurpose::SUBSCRIPTION,
'amount' => 999,
'currency' => 'eur',
'stripe_price_id' => 'price_subscription',
'sort_order' => 10,
]);
$this
->getJson('/api/billing/products')
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.name', 'Bowli Plus')
->assertJsonPath('data.0.purpose', BillingProductPurpose::SUBSCRIPTION->value)
->assertJsonPath('data.0.formattedAmount', '9,99 EUR')
->assertJsonPath('data.1.name', 'Certification')
->assertJsonPath('data.1.purpose', BillingProductPurpose::CERTIFICATION->value);
});
+29 -45
View File
@@ -1,20 +1,16 @@
<?php <?php
use App\Actions\ReviewIdentityVerification; use App\Actions\ReviewIdentityVerification;
use App\Enums\BillingProductPurpose;
use App\Enums\IdentityVerificationStatus; use App\Enums\IdentityVerificationStatus;
use App\Enums\PaymentTransactionStatus;
use App\Enums\UserRole; use App\Enums\UserRole;
use App\Filament\Resources\IdentityVerificationRequests\Pages\ListIdentityVerificationRequests; use App\Filament\Resources\IdentityVerificationRequests\Pages\ListIdentityVerificationRequests;
use App\Listeners\HandleStripeWebhook;
use App\Models\BillingProduct;
use App\Models\IdentityVerificationRequest; use App\Models\IdentityVerificationRequest;
use App\Models\PaymentTransaction;
use App\Models\User; use App\Models\User;
use App\Policies\IdentityVerificationRequestPolicy; use App\Policies\IdentityVerificationRequestPolicy;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use Livewire\Livewire; use Livewire\Livewire;
@@ -168,46 +164,34 @@ it('lets an administrator approve the identity request from filament', function
->and($user->fresh()->account_verified_at)->not->toBeNull(); ->and($user->fresh()->account_verified_at)->not->toBeNull();
}); });
it('grants lifetime certification entitlement from the paid Stripe webhook once', function () { it('grants the lifetime certification entitlement from RevenueCat without a trial', function () {
$user = User::factory()->create([ config()->set('billing.revenuecat.secret_key', 'rc-secret');
'stripe_id' => 'cus_certification', config()->set('billing.revenuecat.certification_entitlement', 'certification');
]); $user = User::factory()->create();
$product = BillingProduct::create([ $purchaseDate = now()->subDay()->startOfSecond();
'name' => 'Certification', Sanctum::actingAs($user);
'purpose' => BillingProductPurpose::CERTIFICATION,
'amount' => 1999,
'currency' => 'eur',
'stripe_price_id' => 'price_certification',
]);
$event = (object) [
'payload' => [
'id' => 'evt_certification',
'type' => 'checkout.session.completed',
'data' => [
'object' => [
'id' => 'cs_certification',
'mode' => 'payment',
'payment_status' => 'paid',
'customer' => $user->stripe_id,
'payment_intent' => 'pi_certification',
'amount_total' => 1999,
'currency' => 'eur',
'metadata' => [
'billing_product_id' => $product->getKey(),
'billing_product_purpose' => BillingProductPurpose::CERTIFICATION->value,
],
],
],
],
];
$listener = app(HandleStripeWebhook::class); Http::fake([
$listener->handle($event); "api.revenuecat.com/v1/subscribers/{$user->id}" => Http::response([
$firstPurchaseDate = $user->fresh()->certification_purchased_at; 'subscriber' => [
$listener->handle($event); 'entitlements' => [
'certification' => [
'expires_date' => null,
'grace_period_expires_date' => null,
'product_identifier' => 'lifetime',
'purchase_date' => $purchaseDate->toISOString(),
],
],
'subscriptions' => [],
],
]),
]);
expect($firstPurchaseDate)->not->toBeNull() $this->postJson('/api/billing/sync')
->and($user->fresh()->certification_purchased_at?->equalTo($firstPurchaseDate))->toBeTrue() ->assertOk()
->and(PaymentTransaction::query()->count())->toBe(1) ->assertJsonPath('data.certificationPurchased', true)
->and(PaymentTransaction::query()->sole()->status)->toBe(PaymentTransactionStatus::PAID); ->assertJsonPath('data.analysisAccessLevel', 'free');
expect($user->fresh()->certification_purchased_at?->equalTo($purchaseDate))->toBeTrue()
->and($user->fresh()->subscription_is_trial)->toBeFalse();
}); });
+1 -1
View File
@@ -25,7 +25,7 @@ it('creates a verified administrator with the application command', function ()
->and($user->role)->toBe(UserRole::ADMIN) ->and($user->role)->toBe(UserRole::ADMIN)
->and($user->locale)->toBe('fr') ->and($user->locale)->toBe('fr')
->and($user->email_verified_at)->not->toBeNull() ->and($user->email_verified_at)->not->toBeNull()
->and($user->trial_ends_at)->not->toBeNull() ->and($user->subscription_expires_at)->toBeNull()
->and(Hash::check('Strong-password1', $user->password))->toBeTrue(); ->and(Hash::check('Strong-password1', $user->password))->toBeTrue();
}); });
+3 -4
View File
@@ -33,8 +33,8 @@ it('sends a verification email when a user registers', function () {
->assertJsonPath('user.email', 'leon@example.com') ->assertJsonPath('user.email', 'leon@example.com')
->assertJsonPath('user.locale', 'fr') ->assertJsonPath('user.locale', 'fr')
->assertJsonPath('user.emailVerified', false) ->assertJsonPath('user.emailVerified', false)
->assertJsonPath('user.analysisAccessLevel', 'trial') ->assertJsonPath('user.analysisAccessLevel', 'free')
->assertJsonPath('user.canAnalyzeMeals', true) ->assertJsonPath('user.canAnalyzeMeals', false)
->assertJsonPath('user.physicalActivityLevel', PhysicalActivityLevel::LIGHTLY_ACTIVE->value) ->assertJsonPath('user.physicalActivityLevel', PhysicalActivityLevel::LIGHTLY_ACTIVE->value)
->assertJsonPath('user.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value) ->assertJsonPath('user.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value)
->assertJsonPath('user.pacePreference', PacePreference::NORMAL->value) ->assertJsonPath('user.pacePreference', PacePreference::NORMAL->value)
@@ -45,8 +45,7 @@ it('sends a verification email when a user registers', function () {
$user = User::where('email', 'leon@example.com')->firstOrFail(); $user = User::where('email', 'leon@example.com')->firstOrFail();
expect($user->hasVerifiedEmail())->toBeFalse() expect($user->hasVerifiedEmail())->toBeFalse()
->and($user->trial_ends_at)->not->toBeNull() ->and($user->subscription_expires_at)->toBeNull()
->and($user->trial_ends_at->isFuture())->toBeTrue()
->and($user->terms_accepted_at)->not->toBeNull(); ->and($user->terms_accepted_at)->not->toBeNull();
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
+13 -5
View File
@@ -27,7 +27,9 @@ function fakeMealAnalysisImage(): UploadedFile
it('analyzes a meal image into a draft', function () { it('analyzes a meal image into a draft', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'trial_ends_at' => now()->addDay(), 'subscription_expires_at' => now()->addDay(),
'subscription_is_trial' => true,
'subscription_product_id' => 'monthly',
]); ]);
Sanctum::actingAs($user); Sanctum::actingAs($user);
@@ -113,7 +115,9 @@ it('analyzes a meal image into a draft', function () {
it('records a failed meal image analysis attempt', function () { it('records a failed meal image analysis attempt', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'trial_ends_at' => now()->addDay(), 'subscription_expires_at' => now()->addDay(),
'subscription_is_trial' => true,
'subscription_product_id' => 'monthly',
]); ]);
Sanctum::actingAs($user); Sanctum::actingAs($user);
@@ -147,9 +151,11 @@ it('records a failed meal image analysis attempt', function () {
}); });
it('requires a trial or subscription to analyze a meal image', function () { it('requires an active subscription to analyze a meal image', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'trial_ends_at' => now()->subDay(), 'subscription_expires_at' => now()->subDay(),
'subscription_is_trial' => true,
'subscription_product_id' => 'monthly',
]); ]);
Sanctum::actingAs($user); Sanctum::actingAs($user);
@@ -182,7 +188,9 @@ it('requires authentication to analyze a meal image', function () {
it('validates the analyzed image', function () { it('validates the analyzed image', function () {
Sanctum::actingAs(User::factory()->create([ Sanctum::actingAs(User::factory()->create([
'trial_ends_at' => now()->addDay(), 'subscription_expires_at' => now()->addDay(),
'subscription_is_trial' => true,
'subscription_product_id' => 'monthly',
])); ]));
$this $this
+26 -18
View File
@@ -3,7 +3,6 @@
use App\Actions\ReviewIdentityVerification; use App\Actions\ReviewIdentityVerification;
use App\Enums\IdentityVerificationStatus; use App\Enums\IdentityVerificationStatus;
use App\Enums\UserRole; use App\Enums\UserRole;
use App\Listeners\HandleStripeWebhook;
use App\Models\IdentityVerificationRequest; use App\Models\IdentityVerificationRequest;
use App\Models\User; use App\Models\User;
use App\Notifications\CertificationReviewedNotification; use App\Notifications\CertificationReviewedNotification;
@@ -11,6 +10,7 @@ use App\Notifications\EngagementReminderNotification;
use App\Notifications\NewFollowerNotification; use App\Notifications\NewFollowerNotification;
use App\Notifications\PaymentFailedNotification; use App\Notifications\PaymentFailedNotification;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
@@ -68,28 +68,36 @@ it('notifies the user when certification is reviewed', function () {
Notification::assertSentTo($user, CertificationReviewedNotification::class); Notification::assertSentTo($user, CertificationReviewedNotification::class);
}); });
it('notifies a user only once when a failed invoice webhook is replayed', function () { it('notifies a user only once when a RevenueCat billing issue is replayed', function () {
Notification::fake(); Notification::fake();
$user = User::factory()->create(['stripe_id' => 'cus_failed_payment']); config()->set('billing.revenuecat.secret_key', 'rc-secret');
$event = (object) [ config()->set('billing.revenuecat.webhook_authorization', 'Bearer rc-webhook');
'payload' => [ $user = User::factory()->create();
'id' => 'evt_failed_payment', Http::fake([
'type' => 'invoice.payment_failed', "api.revenuecat.com/v1/subscribers/{$user->id}" => Http::response([
'data' => [ 'subscriber' => [
'object' => [ 'entitlements' => [],
'id' => 'in_failed_payment', 'subscriptions' => [],
'customer' => 'cus_failed_payment',
'amount_due' => 999,
'currency' => 'eur',
'lines' => ['data' => []],
],
], ],
]),
]);
$event = [
'event' => [
'id' => 'rc-billing-issue',
'type' => 'BILLING_ISSUE',
'app_user_id' => $user->id,
'product_id' => 'monthly',
'entitlement_ids' => ['bowli Pro'],
'environment' => 'PRODUCTION',
], ],
]; ];
$listener = app(HandleStripeWebhook::class); $this->withHeader('Authorization', 'Bearer rc-webhook')
$listener->handle($event); ->postJson('/api/webhooks/revenuecat', $event)
$listener->handle($event); ->assertOk();
$this->withHeader('Authorization', 'Bearer rc-webhook')
->postJson('/api/webhooks/revenuecat', $event)
->assertOk();
Notification::assertSentToTimes($user, PaymentFailedNotification::class, 1); Notification::assertSentToTimes($user, PaymentFailedNotification::class, 1);
}); });
-45
View File
@@ -1,45 +0,0 @@
<?php
use App\Enums\BillingProductPurpose;
use App\Enums\PaymentTransactionStatus;
use App\Enums\PaymentTransactionType;
use App\Mail\PaymentInvoiceMail;
use App\Models\BillingProduct;
use App\Models\PaymentTransaction;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('renders invoice emails without the retired credit system', function () {
$user = User::factory()->create([
'locale' => 'fr',
]);
$product = BillingProduct::query()->create([
'name' => 'Bowli Plus',
'purpose' => BillingProductPurpose::SUBSCRIPTION,
'amount' => 999,
'currency' => 'eur',
]);
$transaction = PaymentTransaction::query()->create([
'user_id' => $user->getKey(),
'billing_product_id' => $product->getKey(),
'type' => PaymentTransactionType::SUBSCRIPTION_INVOICE,
'status' => PaymentTransactionStatus::PAID,
'amount' => 999,
'currency' => 'eur',
'stripe_invoice_id' => 'in_bowli',
'invoice_url' => 'https://invoice.stripe.test/in_bowli',
]);
$mail = new PaymentInvoiceMail(
$user,
$transaction->load('billingProduct'),
);
$mail
->assertSeeInHtml('Bowli Plus')
->assertSeeInHtml('9,99 EUR')
->assertDontSeeInHtml('Crédits')
->assertDontSeeInHtml('Credits');
});
+132
View File
@@ -0,0 +1,132 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('billing.revenuecat.secret_key', 'rc-secret');
config()->set('billing.revenuecat.subscription_entitlement', 'bowli Pro');
config()->set('billing.revenuecat.certification_entitlement', 'certification');
});
it('synchronizes a monthly subscription trial from RevenueCat', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
Http::fake([
"api.revenuecat.com/v1/subscribers/{$user->id}" => Http::response([
'subscriber' => [
'entitlements' => [
'bowli Pro' => [
'expires_date' => now()->addDays(7)->toISOString(),
'grace_period_expires_date' => null,
'product_identifier' => 'monthly',
'purchase_date' => now()->toISOString(),
],
],
'subscriptions' => [
'monthly' => [
'period_type' => 'trial',
'store' => 'app_store',
],
],
],
]),
]);
$this->postJson('/api/billing/sync')
->assertOk()
->assertJsonPath('data.hasActiveSubscription', true)
->assertJsonPath('data.analysisAccessLevel', 'trial')
->assertJsonPath('data.subscriptionProductId', 'monthly')
->assertJsonPath('data.certificationPurchased', false);
expect($user->fresh()->subscription_is_trial)->toBeTrue()
->and($user->fresh()->subscription_store)->toBe('app_store');
Http::assertSent(fn ($request): bool => $request->hasHeader('Authorization', 'Bearer rc-secret'));
});
it('synchronizes annual access and the lifetime certification independently', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
$certificationPurchasedAt = now()->subDay()->startOfSecond();
Http::fake([
"api.revenuecat.com/v1/subscribers/{$user->id}" => Http::response([
'subscriber' => [
'entitlements' => [
'bowli Pro' => [
'expires_date' => now()->addYear()->toISOString(),
'grace_period_expires_date' => null,
'product_identifier' => 'yearly',
'purchase_date' => now()->toISOString(),
],
'certification' => [
'expires_date' => null,
'grace_period_expires_date' => null,
'product_identifier' => 'lifetime',
'purchase_date' => $certificationPurchasedAt->toISOString(),
],
],
'subscriptions' => [
'yearly' => [
'period_type' => 'normal',
'store' => 'play_store',
],
],
],
]),
]);
$this->postJson('/api/billing/sync')
->assertOk()
->assertJsonPath('data.analysisAccessLevel', 'subscribed')
->assertJsonPath('data.certificationPurchased', true);
$user->refresh();
expect($user->subscription_product_id)->toBe('yearly')
->and($user->subscription_is_trial)->toBeFalse()
->and($user->certification_purchased_at?->equalTo($certificationPurchasedAt))->toBeTrue();
});
it('removes access when RevenueCat returns expired entitlements', function () {
$user = User::factory()->create([
'certification_purchased_at' => now()->subMonth(),
'subscription_expires_at' => now()->addMonth(),
'subscription_is_trial' => true,
'subscription_product_id' => 'monthly',
'subscription_store' => 'app_store',
]);
Sanctum::actingAs($user);
Http::fake([
"api.revenuecat.com/v1/subscribers/{$user->id}" => Http::response([
'subscriber' => [
'entitlements' => [
'bowli Pro' => [
'expires_date' => now()->subDay()->toISOString(),
'grace_period_expires_date' => null,
'product_identifier' => 'monthly',
],
],
'subscriptions' => [],
],
]),
]);
$this->postJson('/api/billing/sync')
->assertOk()
->assertJsonPath('data.analysisAccessLevel', 'free')
->assertJsonPath('data.hasActiveSubscription', false)
->assertJsonPath('data.certificationPurchased', false);
expect($user->fresh()->subscription_product_id)->toBeNull()
->and($user->fresh()->subscription_is_trial)->toBeFalse()
->and($user->fresh()->certification_purchased_at)->toBeNull();
});
+11 -12
View File
@@ -5,9 +5,11 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('grants analysis access during the generic trial', function () { it('grants analysis access during a RevenueCat subscription trial', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'trial_ends_at' => now()->addDay(), 'subscription_expires_at' => now()->addDay(),
'subscription_is_trial' => true,
'subscription_product_id' => 'monthly',
]); ]);
expect($user->canAnalyzeMeals())->toBeTrue() expect($user->canAnalyzeMeals())->toBeTrue()
@@ -16,23 +18,20 @@ it('grants analysis access during the generic trial', function () {
it('grants analysis access to active subscribers', function () { it('grants analysis access to active subscribers', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'trial_ends_at' => now()->subDay(), 'subscription_expires_at' => now()->addYear(),
]); 'subscription_is_trial' => false,
'subscription_product_id' => 'yearly',
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_active',
'stripe_status' => 'active',
'stripe_price' => 'price_subscription',
]); ]);
expect($user->canAnalyzeMeals())->toBeTrue() expect($user->canAnalyzeMeals())->toBeTrue()
->and($user->analysisAccessLevel())->toBe('subscribed'); ->and($user->analysisAccessLevel())->toBe('subscribed');
}); });
it('keeps publishing accounts free without analysis access after the trial', function () { it('keeps publishing accounts free without a subscription', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'trial_ends_at' => now()->subDay(), 'subscription_expires_at' => now()->subDay(),
'subscription_is_trial' => true,
'subscription_product_id' => 'monthly',
]); ]);
expect($user->canAnalyzeMeals())->toBeFalse() expect($user->canAnalyzeMeals())->toBeFalse()