feat: revenue cat
This commit is contained in:
@@ -78,8 +78,6 @@ class CreateUser extends Command
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$trialDays = max(0, (int) config('billing.trial_days', 7));
|
||||
|
||||
$user = User::query()->create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
@@ -87,7 +85,6 @@ class CreateUser extends Command
|
||||
'locale' => $locale,
|
||||
'password' => Hash::make($password),
|
||||
'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}.");
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
DateTimePicker::make('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'))
|
||||
->columns(2)
|
||||
|
||||
@@ -39,8 +39,8 @@ class UserInfolist
|
||||
->label(__('admin.users.fields.certification_purchased_at'))
|
||||
->dateTime()
|
||||
->placeholder(__('admin.users.placeholders.empty')),
|
||||
TextEntry::make('trial_ends_at')
|
||||
->label(__('admin.users.fields.trial_ends_at'))
|
||||
TextEntry::make('subscription_expires_at')
|
||||
->label(__('admin.users.fields.subscription_expires_at'))
|
||||
->dateTime()
|
||||
->placeholder(__('admin.users.placeholders.empty')),
|
||||
TextEntry::make('suspended_at')
|
||||
|
||||
@@ -51,8 +51,8 @@ class UsersTable
|
||||
->sortable()
|
||||
->placeholder(__('admin.users.placeholders.not_verified'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('trial_ends_at')
|
||||
->label(__('admin.users.fields.trial_ends_at'))
|
||||
TextColumn::make('subscription_expires_at')
|
||||
->label(__('admin.users.fields.subscription_expires_at'))
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->placeholder(__('admin.users.placeholders.empty'))
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Http\Requests\UpdatePasswordRequest;
|
||||
use App\Http\Requests\UpdateUserRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Services\RevenueCatService;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
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();
|
||||
$identityVerificationRequest = $user->identityVerificationRequest()->first();
|
||||
@@ -312,6 +313,12 @@ class AuthController extends Controller
|
||||
->values()
|
||||
->all();
|
||||
|
||||
try {
|
||||
$revenueCat->deleteCustomer($user);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user): void {
|
||||
$user->tokens()->delete();
|
||||
$user->notifications()->delete();
|
||||
@@ -361,12 +368,6 @@ class AuthController extends Controller
|
||||
$userAttributes['password'] = Hash::make($data['password']);
|
||||
$userAttributes['avatar_url'] = $avatarPath;
|
||||
$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);
|
||||
|
||||
try {
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,12 @@ class UserResource extends JsonResource
|
||||
'certificationPurchased' => $this->hasPurchasedCertification(),
|
||||
'canAnalyzeMeals' => $this->canAnalyzeMeals(),
|
||||
'analysisAccessLevel' => $this->analysisAccessLevel(),
|
||||
'trialEndsAt' => $this->trial_ends_at?->toISOString(),
|
||||
'hasActiveSubscription' => $this->subscribed('default'),
|
||||
'trialEndsAt' => $this->analysisAccessLevel() === 'trial'
|
||||
? $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(),
|
||||
'physicalActivityLevel' => $this->physical_activity_level,
|
||||
'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -25,13 +25,12 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Cashier\Billable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocalePreference, MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use Billable, HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes;
|
||||
use HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes;
|
||||
|
||||
protected $attributes = [
|
||||
'social_notifications_enabled' => true,
|
||||
@@ -55,7 +54,10 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
|
||||
'bio',
|
||||
'account_verified_at',
|
||||
'certification_purchased_at',
|
||||
'trial_ends_at',
|
||||
'subscription_product_id',
|
||||
'subscription_store',
|
||||
'subscription_expires_at',
|
||||
'subscription_is_trial',
|
||||
'daily_calorie_goal',
|
||||
'daily_protein_goal',
|
||||
'daily_carbs_goal',
|
||||
@@ -109,7 +111,8 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
|
||||
'social_notifications_enabled' => 'boolean',
|
||||
'engagement_reminders_enabled' => 'boolean',
|
||||
'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);
|
||||
}
|
||||
|
||||
public function paymentTransactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(PaymentTransaction::class);
|
||||
}
|
||||
|
||||
public function identityVerificationRequest(): HasOne
|
||||
{
|
||||
return $this->hasOne(IdentityVerificationRequest::class);
|
||||
@@ -229,16 +227,22 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
|
||||
|
||||
public function canAnalyzeMeals(): bool
|
||||
{
|
||||
return $this->subscribed('default') || $this->onGenericTrial();
|
||||
return $this->hasActiveSubscription();
|
||||
}
|
||||
|
||||
public function analysisAccessLevel(): string
|
||||
{
|
||||
if ($this->subscribed('default')) {
|
||||
return 'subscribed';
|
||||
if (! $this->hasActiveSubscription()) {
|
||||
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
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Broadcasting\ExpoPushChannel;
|
||||
use App\Listeners\HandleStripeWebhook;
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Mail\VerifyAccount;
|
||||
use App\Models\User;
|
||||
@@ -14,7 +13,6 @@ use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
@@ -60,8 +58,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
Notification::extend('expo', fn ($app) => $app->make(ExpoPushChannel::class));
|
||||
|
||||
Event::listen(\Laravel\Cashier\Events\WebhookReceived::class, HandleStripeWebhook::class);
|
||||
|
||||
VerifyEmail::createUrlUsing(function (User $notifiable): string {
|
||||
$relativeUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user