feat: clean credits users
CI / 🧪 Tests Laravel (push) Successful in 2m0s
CI / 🐳 Build & Push Image (push) Successful in 35s

This commit is contained in:
2026-08-07 15:32:40 +02:00
parent eac86afa53
commit 68560655f3
44 changed files with 417 additions and 661 deletions
+2 -2
View File
@@ -35,9 +35,9 @@ jobs:
cd /data/stacks/daily-meal-api cd /data/stacks/daily-meal-api
echo "Pull nouvelle image" echo "Pull nouvelle image"
docker compose -f docker-compose.prod.yml pull docker compose pull
echo "Redémarrage" echo "Redémarrage"
docker compose -f docker-compose.prod.yml up -d --wait --wait-timeout 180 docker compose up -d --wait --wait-timeout 180
EOF EOF
-38
View File
@@ -1,38 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum CreditLedgerEntryType: string implements HasColor, HasLabel
{
case FREE_GRANT = 'free_grant';
case PURCHASE = 'purchase';
case SUBSCRIPTION_RENEWAL = 'subscription_renewal';
case USAGE = 'usage';
case REFUND = 'refund';
case ADJUSTMENT = 'adjustment';
public function getLabel(): string
{
return match ($this) {
self::FREE_GRANT => __('enums.credit_ledger_entry_type.free_grant'),
self::PURCHASE => __('enums.credit_ledger_entry_type.purchase'),
self::SUBSCRIPTION_RENEWAL => __('enums.credit_ledger_entry_type.subscription_renewal'),
self::USAGE => __('enums.credit_ledger_entry_type.usage'),
self::REFUND => __('enums.credit_ledger_entry_type.refund'),
self::ADJUSTMENT => __('enums.credit_ledger_entry_type.adjustment'),
};
}
public function getColor(): string
{
return match ($this) {
self::FREE_GRANT, self::PURCHASE, self::SUBSCRIPTION_RENEWAL => 'success',
self::USAGE => 'warning',
self::REFUND => 'info',
self::ADJUSTMENT => 'gray',
};
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum CreditProductType: string implements HasColor, HasLabel
{
case MONTHLY = 'monthly';
case ONE_TIME = 'one_time';
public function getLabel(): string
{
return match ($this) {
self::MONTHLY => __('enums.credit_product_type.monthly'),
self::ONE_TIME => __('enums.credit_product_type.one_time'),
};
}
public function getColor(): string
{
return match ($this) {
self::MONTHLY => 'info',
self::ONE_TIME => 'success',
};
}
}
+1 -4
View File
@@ -9,7 +9,6 @@ enum PaymentTransactionStatus: string implements HasColor, HasLabel
{ {
case PENDING = 'pending'; case PENDING = 'pending';
case PAID = 'paid'; case PAID = 'paid';
case CREDITED = 'credited';
case FAILED = 'failed'; case FAILED = 'failed';
case IGNORED = 'ignored'; case IGNORED = 'ignored';
case ERROR = 'error'; case ERROR = 'error';
@@ -19,7 +18,6 @@ enum PaymentTransactionStatus: string implements HasColor, HasLabel
return match ($this) { return match ($this) {
self::PENDING => __('enums.payment_transaction_status.pending'), self::PENDING => __('enums.payment_transaction_status.pending'),
self::PAID => __('enums.payment_transaction_status.paid'), self::PAID => __('enums.payment_transaction_status.paid'),
self::CREDITED => __('enums.payment_transaction_status.credited'),
self::FAILED => __('enums.payment_transaction_status.failed'), self::FAILED => __('enums.payment_transaction_status.failed'),
self::IGNORED => __('enums.payment_transaction_status.ignored'), self::IGNORED => __('enums.payment_transaction_status.ignored'),
self::ERROR => __('enums.payment_transaction_status.error'), self::ERROR => __('enums.payment_transaction_status.error'),
@@ -30,8 +28,7 @@ enum PaymentTransactionStatus: string implements HasColor, HasLabel
{ {
return match ($this) { return match ($this) {
self::PENDING => 'gray', self::PENDING => 'gray',
self::PAID => 'info', self::PAID => 'success',
self::CREDITED => 'success',
self::FAILED, self::ERROR => 'danger', self::FAILED, self::ERROR => 'danger',
self::IGNORED => 'warning', self::IGNORED => 'warning',
}; };
@@ -0,0 +1,78 @@
<?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'),
];
}
}
@@ -0,0 +1,17 @@
<?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);
}
}
@@ -0,0 +1,26 @@
<?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);
}
}
@@ -0,0 +1,19 @@
<?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,10 +1,8 @@
<?php <?php
namespace App\Filament\Resources\CreditProducts\Schemas; namespace App\Filament\Resources\BillingProducts\Schemas;
use App\Enums\BillingProductPurpose; use App\Enums\BillingProductPurpose;
use App\Enums\CreditProductType;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@@ -12,60 +10,56 @@ use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
class CreditProductForm class BillingProductForm
{ {
public static function configure(Schema $schema): Schema public static function configure(Schema $schema): Schema
{ {
return $schema return $schema
->components([ ->components([
Section::make(__('admin.credit_products.sections.product')) Section::make(__('admin.billing_products.sections.product'))
->columns(2) ->columns(2)
->schema([ ->schema([
TextInput::make('name') TextInput::make('name')
->label(__('admin.credit_products.fields.name')) ->label(__('admin.billing_products.fields.name'))
->required() ->required()
->maxLength(255), ->maxLength(255),
Select::make('purpose') Select::make('purpose')
->label(__('admin.credit_products.fields.purpose')) ->label(__('admin.billing_products.fields.purpose'))
->options(BillingProductPurpose::class) ->options(BillingProductPurpose::class)
->unique(ignoreRecord: true) ->unique(ignoreRecord: true)
->required(), ->required(),
Hidden::make('type')
->default(CreditProductType::ONE_TIME->value),
Hidden::make('credits')
->default(0),
TextInput::make('amount') TextInput::make('amount')
->label(__('admin.credit_products.fields.amount')) ->label(__('admin.billing_products.fields.amount'))
->helperText(__('admin.credit_products.helpers.amount')) ->helperText(__('admin.billing_products.helpers.amount'))
->integer() ->integer()
->minValue(1) ->minValue(1)
->required(), ->required(),
TextInput::make('currency') TextInput::make('currency')
->label(__('admin.credit_products.fields.currency')) ->label(__('admin.billing_products.fields.currency'))
->default('eur') ->default('eur')
->required() ->required()
->maxLength(3), ->maxLength(3),
TextInput::make('sort_order') TextInput::make('sort_order')
->label(__('admin.credit_products.fields.sort_order')) ->label(__('admin.billing_products.fields.sort_order'))
->integer() ->integer()
->default(0) ->default(0)
->required(), ->required(),
Textarea::make('description') Textarea::make('description')
->label(__('admin.credit_products.fields.description')) ->label(__('admin.billing_products.fields.description'))
->rows(3) ->rows(3)
->columnSpanFull(), ->columnSpanFull(),
Toggle::make('is_active') Toggle::make('is_active')
->label(__('admin.credit_products.fields.is_active')) ->label(__('admin.billing_products.fields.is_active'))
->default(true), ->default(true),
]), ]),
Section::make(__('admin.credit_products.sections.stripe')) Section::make(__('admin.billing_products.sections.stripe'))
->columns(2) ->columns(2)
->schema([ ->schema([
TextInput::make('stripe_product_id') TextInput::make('stripe_product_id')
->label(__('admin.credit_products.fields.stripe_product_id')) ->label(__('admin.billing_products.fields.stripe_product_id'))
->maxLength(255), ->maxLength(255),
TextInput::make('stripe_price_id') TextInput::make('stripe_price_id')
->label(__('admin.credit_products.fields.stripe_price_id')) ->label(__('admin.billing_products.fields.stripe_price_id'))
->unique(ignoreRecord: true) ->unique(ignoreRecord: true)
->maxLength(255), ->maxLength(255),
]), ]),
@@ -1,9 +1,9 @@
<?php <?php
namespace App\Filament\Resources\CreditProducts\Tables; namespace App\Filament\Resources\BillingProducts\Tables;
use App\Enums\BillingProductPurpose; use App\Enums\BillingProductPurpose;
use App\Filament\Resources\CreditProducts\CreditProductResource; use App\Filament\Resources\BillingProducts\BillingProductResource;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
@@ -13,7 +13,7 @@ use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
class CreditProductsTable class BillingProductsTable
{ {
public static function configure(Table $table): Table public static function configure(Table $table): Table
{ {
@@ -21,45 +21,45 @@ class CreditProductsTable
->defaultSort('sort_order') ->defaultSort('sort_order')
->columns([ ->columns([
TextColumn::make('name') TextColumn::make('name')
->label(__('admin.credit_products.fields.name')) ->label(__('admin.billing_products.fields.name'))
->searchable() ->searchable()
->sortable(), ->sortable(),
TextColumn::make('purpose') TextColumn::make('purpose')
->label(__('admin.credit_products.fields.purpose')) ->label(__('admin.billing_products.fields.purpose'))
->badge() ->badge()
->sortable(), ->sortable(),
TextColumn::make('amount') TextColumn::make('amount')
->label(__('admin.credit_products.fields.amount')) ->label(__('admin.billing_products.fields.amount'))
->money(fn ($record): string => $record->currency, divideBy: 100) ->money(fn ($record): string => $record->currency, divideBy: 100)
->sortable(), ->sortable(),
TextColumn::make('stripe_price_id') TextColumn::make('stripe_price_id')
->label(__('admin.credit_products.fields.stripe_price_id')) ->label(__('admin.billing_products.fields.stripe_price_id'))
->copyable() ->copyable()
->searchable() ->searchable()
->toggleable(), ->toggleable(),
IconColumn::make('is_active') IconColumn::make('is_active')
->label(__('admin.credit_products.fields.is_active')) ->label(__('admin.billing_products.fields.is_active'))
->boolean() ->boolean()
->sortable(), ->sortable(),
TextColumn::make('sort_order') TextColumn::make('sort_order')
->label(__('admin.credit_products.fields.sort_order')) ->label(__('admin.billing_products.fields.sort_order'))
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at') TextColumn::make('created_at')
->label(__('admin.credit_products.fields.created_at')) ->label(__('admin.billing_products.fields.created_at'))
->dateTime() ->dateTime()
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters([ ->filters([
SelectFilter::make('purpose') SelectFilter::make('purpose')
->label(__('admin.credit_products.fields.purpose')) ->label(__('admin.billing_products.fields.purpose'))
->options(BillingProductPurpose::class), ->options(BillingProductPurpose::class),
TernaryFilter::make('is_active') TernaryFilter::make('is_active')
->label(__('admin.credit_products.fields.is_active')), ->label(__('admin.billing_products.fields.is_active')),
]) ])
->recordActions([ ->recordActions([
CreditProductResource::syncStripeAction(), BillingProductResource::syncStripeAction(),
EditAction::make(), EditAction::make(),
]) ])
->toolbarActions([ ->toolbarActions([
@@ -1,78 +0,0 @@
<?php
namespace App\Filament\Resources\CreditProducts;
use App\Filament\Resources\CreditProducts\Pages\CreateCreditProduct;
use App\Filament\Resources\CreditProducts\Pages\EditCreditProduct;
use App\Filament\Resources\CreditProducts\Pages\ListCreditProducts;
use App\Filament\Resources\CreditProducts\Schemas\CreditProductForm;
use App\Filament\Resources\CreditProducts\Tables\CreditProductsTable;
use App\Models\CreditProduct;
use App\Services\StripeCreditProductSyncer;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class CreditProductResource extends Resource
{
protected static ?string $model = CreditProduct::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCreditCard;
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string
{
return __('admin.credit_products.navigation.label');
}
public static function getModelLabel(): string
{
return __('admin.credit_products.navigation.singular');
}
public static function getPluralModelLabel(): string
{
return __('admin.credit_products.navigation.plural');
}
public static function form(Schema $schema): Schema
{
return CreditProductForm::configure($schema);
}
public static function table(Table $table): Table
{
return CreditProductsTable::configure($table);
}
public static function syncStripeAction(): Action
{
return Action::make('syncStripe')
->label(__('admin.credit_products.actions.sync_stripe'))
->icon(Heroicon::OutlinedArrowPath)
->visible(fn (CreditProduct $record): bool => blank($record->stripe_price_id) && $record->purpose !== null)
->requiresConfirmation()
->action(function (CreditProduct $record): void {
app(StripeCreditProductSyncer::class)->sync($record);
Notification::make()
->title(__('admin.credit_products.actions.sync_stripe_success'))
->success()
->send();
});
}
public static function getPages(): array
{
return [
'index' => ListCreditProducts::route('/'),
'create' => CreateCreditProduct::route('/create'),
'edit' => EditCreditProduct::route('/{record}/edit'),
];
}
}
@@ -1,16 +0,0 @@
<?php
namespace App\Filament\Resources\CreditProducts\Pages;
use App\Filament\Resources\CreditProducts\CreditProductResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCreditProduct extends CreateRecord
{
protected static string $resource = CreditProductResource::class;
protected function afterCreate(): void
{
app(\App\Services\StripeCreditProductSyncer::class)->sync($this->record);
}
}
@@ -1,25 +0,0 @@
<?php
namespace App\Filament\Resources\CreditProducts\Pages;
use App\Filament\Resources\CreditProducts\CreditProductResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditCreditProduct extends EditRecord
{
protected static string $resource = CreditProductResource::class;
protected function getHeaderActions(): array
{
return [
CreditProductResource::syncStripeAction(),
DeleteAction::make(),
];
}
protected function afterSave(): void
{
app(\App\Services\StripeCreditProductSyncer::class)->sync($this->record);
}
}
@@ -1,19 +0,0 @@
<?php
namespace App\Filament\Resources\CreditProducts\Pages;
use App\Filament\Resources\CreditProducts\CreditProductResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListCreditProducts extends ListRecords
{
protected static string $resource = CreditProductResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -28,18 +28,13 @@ class PaymentTransactionInfolist
->label(__('admin.payment_transactions.fields.user')) ->label(__('admin.payment_transactions.fields.user'))
->placeholder(__('admin.payment_transactions.placeholders.empty')) ->placeholder(__('admin.payment_transactions.placeholders.empty'))
->copyable(), ->copyable(),
TextEntry::make('creditProduct.name') TextEntry::make('billingProduct.name')
->label(__('admin.payment_transactions.fields.credit_product')) ->label(__('admin.payment_transactions.fields.billing_product'))
->placeholder(__('admin.payment_transactions.placeholders.empty')), ->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('amount') TextEntry::make('amount')
->label(__('admin.payment_transactions.fields.amount')) ->label(__('admin.payment_transactions.fields.amount'))
->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100) ->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100)
->placeholder(__('admin.payment_transactions.placeholders.empty')), ->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('credits_expected')
->label(__('admin.payment_transactions.fields.credits_expected'))
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('credits_granted')
->label(__('admin.payment_transactions.fields.credits_granted')),
TextEntry::make('processed_at') TextEntry::make('processed_at')
->label(__('admin.payment_transactions.fields.processed_at')) ->label(__('admin.payment_transactions.fields.processed_at'))
->dateTime() ->dateTime()
@@ -32,8 +32,8 @@ class PaymentTransactionsTable
->label(__('admin.payment_transactions.fields.user')) ->label(__('admin.payment_transactions.fields.user'))
->searchable() ->searchable()
->placeholder(__('admin.payment_transactions.placeholders.empty')), ->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextColumn::make('creditProduct.name') TextColumn::make('billingProduct.name')
->label(__('admin.payment_transactions.fields.credit_product')) ->label(__('admin.payment_transactions.fields.billing_product'))
->searchable() ->searchable()
->placeholder(__('admin.payment_transactions.placeholders.empty')), ->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextColumn::make('amount') TextColumn::make('amount')
@@ -41,15 +41,6 @@ class PaymentTransactionsTable
->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100) ->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100)
->sortable() ->sortable()
->placeholder(__('admin.payment_transactions.placeholders.empty')), ->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextColumn::make('credits_expected')
->label(__('admin.payment_transactions.fields.credits_expected'))
->numeric(decimalPlaces: 0)
->sortable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextColumn::make('credits_granted')
->label(__('admin.payment_transactions.fields.credits_granted'))
->numeric(decimalPlaces: 0)
->sortable(),
TextColumn::make('stripe_event_type') TextColumn::make('stripe_event_type')
->label(__('admin.payment_transactions.fields.stripe_event_type')) ->label(__('admin.payment_transactions.fields.stripe_event_type'))
->toggleable(), ->toggleable(),
+15 -15
View File
@@ -3,8 +3,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\BillingProductPurpose; use App\Enums\BillingProductPurpose;
use App\Http\Resources\CreditProductResource; use App\Http\Resources\BillingProductResource;
use App\Models\CreditProduct; use App\Models\BillingProduct;
use App\Services\MobileDeepLink; use App\Services\MobileDeepLink;
use App\Services\PaymentTransactionRecorder; use App\Services\PaymentTransactionRecorder;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -16,7 +16,7 @@ class BillingController extends Controller
{ {
public function products(): AnonymousResourceCollection public function products(): AnonymousResourceCollection
{ {
$products = CreditProduct::query() $products = BillingProduct::query()
->active() ->active()
->whereNotNull('purpose') ->whereNotNull('purpose')
->whereNotNull('stripe_price_id') ->whereNotNull('stripe_price_id')
@@ -24,33 +24,33 @@ class BillingController extends Controller
->orderBy('amount') ->orderBy('amount')
->get(); ->get();
return CreditProductResource::collection($products); return BillingProductResource::collection($products);
} }
public function checkout( public function checkout(
Request $request, Request $request,
CreditProduct $creditProduct, BillingProduct $billingProduct,
PaymentTransactionRecorder $transactions, PaymentTransactionRecorder $transactions,
): JsonResponse { ): JsonResponse {
abort_unless($creditProduct->is_active, 404); abort_unless($billingProduct->is_active, 404);
abort_unless($creditProduct->purpose !== null, 404); abort_unless($billingProduct->purpose !== null, 404);
abort_unless(filled($creditProduct->stripe_price_id), 404); abort_unless(filled($billingProduct->stripe_price_id), 404);
$user = $request->user(); $user = $request->user();
$this->ensureStripeCustomer($user); $this->ensureStripeCustomer($user);
$metadata = [ $metadata = [
'credit_product_id' => $creditProduct->getKey(), 'billing_product_id' => $billingProduct->getKey(),
'billing_product_purpose' => $creditProduct->purpose->value, 'billing_product_purpose' => $billingProduct->purpose->value,
]; ];
$sessionOptions = [ $sessionOptions = [
'success_url' => $this->redirectUrl('success'), 'success_url' => $this->redirectUrl('success'),
'cancel_url' => $this->redirectUrl('cancel'), 'cancel_url' => $this->redirectUrl('cancel'),
'metadata' => $metadata, 'metadata' => $metadata,
'client_reference_id' => $creditProduct->getKey(), 'client_reference_id' => $billingProduct->getKey(),
]; ];
if ($creditProduct->purpose === BillingProductPurpose::SUBSCRIPTION) { if ($billingProduct->purpose === BillingProductPurpose::SUBSCRIPTION) {
if ($user->subscribed('default')) { if ($user->subscribed('default')) {
return response()->json([ return response()->json([
'message' => __('api.billing.active_subscription_exists'), 'message' => __('api.billing.active_subscription_exists'),
@@ -58,7 +58,7 @@ class BillingController extends Controller
} }
$checkout = $user $checkout = $user
->newSubscription('default', $creditProduct->stripe_price_id) ->newSubscription('default', $billingProduct->stripe_price_id)
->withMetadata($metadata) ->withMetadata($metadata)
->checkout($sessionOptions); ->checkout($sessionOptions);
} else { } else {
@@ -76,7 +76,7 @@ class BillingController extends Controller
]; ];
$checkout = $user->checkout([ $checkout = $user->checkout([
$creditProduct->stripe_price_id => 1, $billingProduct->stripe_price_id => 1,
], $sessionOptions); ], $sessionOptions);
} }
@@ -84,7 +84,7 @@ class BillingController extends Controller
$transactions->recordCheckoutSession( $transactions->recordCheckoutSession(
user: $user, user: $user,
product: $creditProduct, product: $billingProduct,
stripeCheckoutSessionId: $session->id, stripeCheckoutSessionId: $session->id,
stripeCustomerId: $session->customer, stripeCustomerId: $session->customer,
amount: $session->amount_total, amount: $session->amount_total,
@@ -5,7 +5,7 @@ namespace App\Http\Resources;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
class CreditProductResource extends JsonResource class BillingProductResource extends JsonResource
{ {
/** /**
* @return array<string, mixed> * @return array<string, mixed>
@@ -4,13 +4,13 @@ namespace App\Listeners;
use App\Enums\BillingProductPurpose; use App\Enums\BillingProductPurpose;
use App\Enums\PaymentTransactionStatus; use App\Enums\PaymentTransactionStatus;
use App\Models\CreditProduct; use App\Models\BillingProduct;
use App\Models\User; use App\Models\User;
use App\Services\PaymentInvoiceEmailer; use App\Services\PaymentInvoiceEmailer;
use App\Services\PaymentTransactionRecorder; use App\Services\PaymentTransactionRecorder;
use Laravel\Cashier\Cashier; use Laravel\Cashier\Cashier;
class GrantCreditsFromStripeWebhook class HandleStripeWebhook
{ {
public function __construct( public function __construct(
private PaymentInvoiceEmailer $invoiceEmails, private PaymentInvoiceEmailer $invoiceEmails,
@@ -92,7 +92,6 @@ class GrantCreditsFromStripeWebhook
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
amount: $this->intValue($session['amount_total'] ?? null), amount: $this->intValue($session['amount_total'] ?? null),
currency: $this->stringValue($session['currency'] ?? null), currency: $this->stringValue($session['currency'] ?? null),
creditsExpected: 0,
invoiceUrl: $invoice ? $this->stringValue($invoice['hosted_invoice_url'] ?? null) : null, invoiceUrl: $invoice ? $this->stringValue($invoice['hosted_invoice_url'] ?? null) : null,
invoicePdfUrl: $invoice ? $this->stringValue($invoice['invoice_pdf'] ?? null) : null, invoicePdfUrl: $invoice ? $this->stringValue($invoice['invoice_pdf'] ?? null) : null,
payload: $session, payload: $session,
@@ -124,7 +123,6 @@ class GrantCreditsFromStripeWebhook
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
amount: $this->intValue($session['amount_total'] ?? null), amount: $this->intValue($session['amount_total'] ?? null),
currency: $this->stringValue($session['currency'] ?? null), currency: $this->stringValue($session['currency'] ?? null),
creditsExpected: 0,
errorMessage: 'Checkout session expired before payment.', errorMessage: 'Checkout session expired before payment.',
payload: $session, payload: $session,
); );
@@ -147,7 +145,7 @@ class GrantCreditsFromStripeWebhook
foreach (($invoice['lines']['data'] ?? []) as $line) { foreach (($invoice['lines']['data'] ?? []) as $line) {
$priceId = $this->linePriceId($line); $priceId = $this->linePriceId($line);
$product = $priceId $product = $priceId
? CreditProduct::query() ? BillingProduct::query()
->where('purpose', BillingProductPurpose::SUBSCRIPTION) ->where('purpose', BillingProductPurpose::SUBSCRIPTION)
->where('stripe_price_id', $priceId) ->where('stripe_price_id', $priceId)
->first() ->first()
@@ -171,7 +169,6 @@ class GrantCreditsFromStripeWebhook
stripeSubscriptionId: $this->invoiceSubscriptionId($invoice), stripeSubscriptionId: $this->invoiceSubscriptionId($invoice),
amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null), amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null),
currency: $this->stringValue($invoice['currency'] ?? null), currency: $this->stringValue($invoice['currency'] ?? null),
creditsExpected: 0,
invoiceUrl: $this->stringValue($invoice['hosted_invoice_url'] ?? null), invoiceUrl: $this->stringValue($invoice['hosted_invoice_url'] ?? null),
invoicePdfUrl: $this->stringValue($invoice['invoice_pdf'] ?? null), invoicePdfUrl: $this->stringValue($invoice['invoice_pdf'] ?? null),
payload: $invoice, payload: $invoice,
@@ -194,7 +191,6 @@ class GrantCreditsFromStripeWebhook
stripeSubscriptionId: $this->invoiceSubscriptionId($invoice), stripeSubscriptionId: $this->invoiceSubscriptionId($invoice),
amount: $this->intValue($invoice['amount_paid'] ?? null), amount: $this->intValue($invoice['amount_paid'] ?? null),
currency: $this->stringValue($invoice['currency'] ?? null), currency: $this->stringValue($invoice['currency'] ?? null),
creditsExpected: 0,
errorMessage: 'Invoice does not contain the active subscription product.', errorMessage: 'Invoice does not contain the active subscription product.',
payload: $invoice, payload: $invoice,
); );
@@ -218,7 +214,7 @@ class GrantCreditsFromStripeWebhook
foreach ($lines ?: [null] as $line) { foreach ($lines ?: [null] as $line) {
$priceId = is_array($line) ? $this->linePriceId($line) : null; $priceId = is_array($line) ? $this->linePriceId($line) : null;
$product = $priceId $product = $priceId
? CreditProduct::query()->where('stripe_price_id', $priceId)->first() ? BillingProduct::query()->where('stripe_price_id', $priceId)->first()
: null; : null;
$this->transactions->recordInvoice( $this->transactions->recordInvoice(
@@ -238,7 +234,6 @@ class GrantCreditsFromStripeWebhook
: ($invoice['amount_due'] ?? null) : ($invoice['amount_due'] ?? null)
), ),
currency: $this->stringValue($invoice['currency'] ?? null), currency: $this->stringValue($invoice['currency'] ?? null),
creditsExpected: 0,
errorMessage: 'Invoice payment failed.', errorMessage: 'Invoice payment failed.',
payload: $invoice, payload: $invoice,
); );
@@ -254,10 +249,10 @@ class GrantCreditsFromStripeWebhook
return User::query()->where('stripe_id', $customer)->first(); return User::query()->where('stripe_id', $customer)->first();
} }
private function productFromMetadata(mixed $metadata): ?CreditProduct private function productFromMetadata(mixed $metadata): ?BillingProduct
{ {
if (! is_array($metadata) if (! is_array($metadata)
|| ! is_string($metadata['credit_product_id'] ?? null) || ! is_string($metadata['billing_product_id'] ?? null)
|| ! is_string($metadata['billing_product_purpose'] ?? null)) { || ! is_string($metadata['billing_product_purpose'] ?? null)) {
return null; return null;
} }
@@ -268,8 +263,8 @@ class GrantCreditsFromStripeWebhook
return null; return null;
} }
return CreditProduct::query() return BillingProduct::query()
->whereKey($metadata['credit_product_id']) ->whereKey($metadata['billing_product_id'])
->where('purpose', $purpose) ->where('purpose', $purpose)
->first(); ->first();
} }
+1 -2
View File
@@ -32,11 +32,10 @@ class PaymentInvoiceMail extends Mailable
view: 'emails.payment-invoice', view: 'emails.payment-invoice',
with: [ with: [
'amount' => $this->formattedAmount(), 'amount' => $this->formattedAmount(),
'credits' => $this->transaction->credits_granted ?: $this->transaction->credits_expected,
'invoicePdfUrl' => $this->transaction->invoice_pdf_url, 'invoicePdfUrl' => $this->transaction->invoice_pdf_url,
'invoiceUrl' => $this->transaction->invoice_url, 'invoiceUrl' => $this->transaction->invoice_url,
'locale' => $this->mailLocale(), 'locale' => $this->mailLocale(),
'productName' => $this->transaction->creditProduct?->name, 'productName' => $this->transaction->billingProduct?->name,
'userName' => $this->user->name, 'userName' => $this->user->name,
], ],
); );
@@ -3,24 +3,20 @@
namespace App\Models; namespace App\Models;
use App\Enums\BillingProductPurpose; use App\Enums\BillingProductPurpose;
use App\Enums\CreditProductType;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
class CreditProduct extends Model class BillingProduct extends Model
{ {
/** @use HasFactory<\Database\Factories\CreditProductFactory> */
use HasFactory, HasUlids; use HasFactory, HasUlids;
protected $fillable = [ protected $fillable = [
'name', 'name',
'description', 'description',
'type',
'purpose', 'purpose',
'credits',
'amount', 'amount',
'currency', 'currency',
'stripe_product_id', 'stripe_product_id',
@@ -29,11 +25,6 @@ class CreditProduct extends Model
'sort_order', 'sort_order',
]; ];
public function ledgerEntries(): HasMany
{
return $this->hasMany(CreditLedgerEntry::class);
}
public function paymentTransactions(): HasMany public function paymentTransactions(): HasMany
{ {
return $this->hasMany(PaymentTransaction::class); return $this->hasMany(PaymentTransaction::class);
@@ -52,9 +43,7 @@ class CreditProduct extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'type' => CreditProductType::class,
'purpose' => BillingProductPurpose::class, 'purpose' => BillingProductPurpose::class,
'credits' => 'integer',
'amount' => 'integer', 'amount' => 'integer',
'is_active' => 'boolean', 'is_active' => 'boolean',
'sort_order' => 'integer', 'sort_order' => 'integer',
-49
View File
@@ -1,49 +0,0 @@
<?php
namespace App\Models;
use App\Enums\CreditLedgerEntryType;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CreditLedgerEntry extends Model
{
/** @use HasFactory<\Database\Factories\CreditLedgerEntryFactory> */
use HasFactory, HasUlids;
protected $fillable = [
'user_id',
'credit_product_id',
'type',
'credits',
'balance_after',
'source',
'stripe_checkout_session_id',
'stripe_invoice_id',
'stripe_payment_intent_id',
'stripe_subscription_id',
'metadata',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function creditProduct(): BelongsTo
{
return $this->belongsTo(CreditProduct::class);
}
protected function casts(): array
{
return [
'type' => CreditLedgerEntryType::class,
'credits' => 'integer',
'balance_after' => 'integer',
'metadata' => 'array',
];
}
}
+3 -13
View File
@@ -16,8 +16,7 @@ class PaymentTransaction extends Model
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'credit_product_id', 'billing_product_id',
'credit_ledger_entry_id',
'type', 'type',
'status', 'status',
'stripe_event_id', 'stripe_event_id',
@@ -30,8 +29,6 @@ class PaymentTransaction extends Model
'stripe_price_id', 'stripe_price_id',
'amount', 'amount',
'currency', 'currency',
'credits_expected',
'credits_granted',
'invoice_url', 'invoice_url',
'invoice_pdf_url', 'invoice_pdf_url',
'invoice_email_sent_at', 'invoice_email_sent_at',
@@ -45,14 +42,9 @@ class PaymentTransaction extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function creditProduct(): BelongsTo public function billingProduct(): BelongsTo
{ {
return $this->belongsTo(CreditProduct::class); return $this->belongsTo(BillingProduct::class);
}
public function creditLedgerEntry(): BelongsTo
{
return $this->belongsTo(CreditLedgerEntry::class);
} }
protected function casts(): array protected function casts(): array
@@ -61,8 +53,6 @@ class PaymentTransaction extends Model
'type' => PaymentTransactionType::class, 'type' => PaymentTransactionType::class,
'status' => PaymentTransactionStatus::class, 'status' => PaymentTransactionStatus::class,
'amount' => 'integer', 'amount' => 'integer',
'credits_expected' => 'integer',
'credits_granted' => 'integer',
'invoice_email_sent_at' => 'datetime', 'invoice_email_sent_at' => 'datetime',
'payload' => 'array', 'payload' => 'array',
'processed_at' => 'datetime', 'processed_at' => 'datetime',
-7
View File
@@ -61,7 +61,6 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
'suspended_at', 'suspended_at',
'suspended_by', 'suspended_by',
'suspended_reason', 'suspended_reason',
'ai_credits_balance',
]; ];
/** /**
@@ -98,7 +97,6 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
'date_of_birth' => 'immutable_date', 'date_of_birth' => 'immutable_date',
'suspended_at' => 'datetime', 'suspended_at' => 'datetime',
'trial_ends_at' => 'datetime', 'trial_ends_at' => 'datetime',
'ai_credits_balance' => 'integer',
]; ];
} }
@@ -122,11 +120,6 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
return $this->hasMany(AiUsage::class); return $this->hasMany(AiUsage::class);
} }
public function creditLedgerEntries(): HasMany
{
return $this->hasMany(CreditLedgerEntry::class);
}
public function paymentTransactions(): HasMany public function paymentTransactions(): HasMany
{ {
return $this->hasMany(PaymentTransaction::class); return $this->hasMany(PaymentTransaction::class);
+2 -2
View File
@@ -3,7 +3,7 @@
namespace App\Providers; namespace App\Providers;
use App\Broadcasting\ExpoPushChannel; use App\Broadcasting\ExpoPushChannel;
use App\Listeners\GrantCreditsFromStripeWebhook; 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;
@@ -60,7 +60,7 @@ class AppServiceProvider extends ServiceProvider
Notification::extend('expo', fn ($app) => $app->make(ExpoPushChannel::class)); Notification::extend('expo', fn ($app) => $app->make(ExpoPushChannel::class));
Event::listen(\Laravel\Cashier\Events\WebhookReceived::class, GrantCreditsFromStripeWebhook::class); 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(
-142
View File
@@ -1,142 +0,0 @@
<?php
namespace App\Services;
use App\Enums\CreditLedgerEntryType;
use App\Models\CreditLedgerEntry;
use App\Models\CreditProduct;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class AiCreditService
{
public function consume(User $user, int $credits = 1, ?string $source = null): bool
{
return DB::transaction(function () use ($credits, $source, $user): bool {
$lockedUser = User::query()
->whereKey($user->getKey())
->lockForUpdate()
->firstOrFail();
if ($lockedUser->ai_credits_balance < $credits) {
return false;
}
$lockedUser->decrement('ai_credits_balance', $credits);
$lockedUser->refresh();
$this->record(
user: $lockedUser,
type: CreditLedgerEntryType::USAGE,
credits: -$credits,
source: $source,
);
$user->forceFill([
'ai_credits_balance' => $lockedUser->ai_credits_balance,
]);
return true;
});
}
public function refund(User $user, int $credits = 1, ?string $source = null): CreditLedgerEntry
{
return $this->grant(
user: $user,
credits: $credits,
type: CreditLedgerEntryType::REFUND,
source: $source,
);
}
/**
* @param array<string, mixed>|null $metadata
*/
public function grant(
User $user,
int $credits,
CreditLedgerEntryType $type,
?CreditProduct $product = null,
?string $source = null,
?string $stripeCheckoutSessionId = null,
?string $stripeInvoiceId = null,
?string $stripePaymentIntentId = null,
?string $stripeSubscriptionId = null,
?array $metadata = null,
): ?CreditLedgerEntry {
return DB::transaction(function () use (
$credits,
$metadata,
$product,
$source,
$stripeCheckoutSessionId,
$stripeInvoiceId,
$stripePaymentIntentId,
$stripeSubscriptionId,
$type,
$user,
): ?CreditLedgerEntry {
if ($source && CreditLedgerEntry::query()->where('source', $source)->exists()) {
return null;
}
$lockedUser = User::query()
->whereKey($user->getKey())
->lockForUpdate()
->firstOrFail();
$lockedUser->increment('ai_credits_balance', $credits);
$lockedUser->refresh();
$entry = $this->record(
user: $lockedUser,
type: $type,
credits: $credits,
product: $product,
source: $source,
stripeCheckoutSessionId: $stripeCheckoutSessionId,
stripeInvoiceId: $stripeInvoiceId,
stripePaymentIntentId: $stripePaymentIntentId,
stripeSubscriptionId: $stripeSubscriptionId,
metadata: $metadata,
);
$user->forceFill([
'ai_credits_balance' => $lockedUser->ai_credits_balance,
]);
return $entry;
});
}
/**
* @param array<string, mixed>|null $metadata
*/
private function record(
User $user,
CreditLedgerEntryType $type,
int $credits,
?CreditProduct $product = null,
?string $source = null,
?string $stripeCheckoutSessionId = null,
?string $stripeInvoiceId = null,
?string $stripePaymentIntentId = null,
?string $stripeSubscriptionId = null,
?array $metadata = null,
): CreditLedgerEntry {
return CreditLedgerEntry::create([
'user_id' => $user->getKey(),
'credit_product_id' => $product?->getKey(),
'type' => $type,
'credits' => $credits,
'balance_after' => $user->ai_credits_balance,
'source' => $source,
'stripe_checkout_session_id' => $stripeCheckoutSessionId,
'stripe_invoice_id' => $stripeInvoiceId,
'stripe_payment_intent_id' => $stripePaymentIntentId,
'stripe_subscription_id' => $stripeSubscriptionId,
'metadata' => $metadata,
]);
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ class PaymentInvoiceEmailer
try { try {
Mail::to($transaction->user->email)->send( Mail::to($transaction->user->email)->send(
new PaymentInvoiceMail($transaction->user, $transaction->loadMissing('creditProduct')) new PaymentInvoiceMail($transaction->user, $transaction->loadMissing('billingProduct'))
); );
} catch (Throwable $exception) { } catch (Throwable $exception) {
$transaction->forceFill([ $transaction->forceFill([
+5 -18
View File
@@ -4,8 +4,7 @@ namespace App\Services;
use App\Enums\PaymentTransactionStatus; use App\Enums\PaymentTransactionStatus;
use App\Enums\PaymentTransactionType; use App\Enums\PaymentTransactionType;
use App\Models\CreditLedgerEntry; use App\Models\BillingProduct;
use App\Models\CreditProduct;
use App\Models\PaymentTransaction; use App\Models\PaymentTransaction;
use App\Models\User; use App\Models\User;
@@ -16,7 +15,7 @@ class PaymentTransactionRecorder
*/ */
public function recordCheckoutSession( public function recordCheckoutSession(
?User $user, ?User $user,
?CreditProduct $product, ?BillingProduct $product,
string $stripeCheckoutSessionId, string $stripeCheckoutSessionId,
PaymentTransactionStatus $status = PaymentTransactionStatus::PENDING, PaymentTransactionStatus $status = PaymentTransactionStatus::PENDING,
?string $stripeEventId = null, ?string $stripeEventId = null,
@@ -25,9 +24,6 @@ class PaymentTransactionRecorder
?string $stripePaymentIntentId = null, ?string $stripePaymentIntentId = null,
?int $amount = null, ?int $amount = null,
?string $currency = null, ?string $currency = null,
?int $creditsExpected = null,
?int $creditsGranted = null,
?CreditLedgerEntry $ledgerEntry = null,
?string $invoiceUrl = null, ?string $invoiceUrl = null,
?string $invoicePdfUrl = null, ?string $invoicePdfUrl = null,
?string $errorMessage = null, ?string $errorMessage = null,
@@ -37,8 +33,7 @@ class PaymentTransactionRecorder
'stripe_checkout_session_id' => $stripeCheckoutSessionId, 'stripe_checkout_session_id' => $stripeCheckoutSessionId,
], [ ], [
'user_id' => $user?->getKey(), 'user_id' => $user?->getKey(),
'credit_product_id' => $product?->getKey(), 'billing_product_id' => $product?->getKey(),
'credit_ledger_entry_id' => $ledgerEntry?->getKey(),
'type' => PaymentTransactionType::CHECKOUT, 'type' => PaymentTransactionType::CHECKOUT,
'status' => $status, 'status' => $status,
'stripe_event_id' => $stripeEventId, 'stripe_event_id' => $stripeEventId,
@@ -47,8 +42,6 @@ class PaymentTransactionRecorder
'stripe_payment_intent_id' => $stripePaymentIntentId, 'stripe_payment_intent_id' => $stripePaymentIntentId,
'amount' => $amount, 'amount' => $amount,
'currency' => $currency, 'currency' => $currency,
'credits_expected' => $creditsExpected ?? $product?->credits,
'credits_granted' => $creditsGranted ?? 0,
'invoice_url' => $invoiceUrl, 'invoice_url' => $invoiceUrl,
'invoice_pdf_url' => $invoicePdfUrl, 'invoice_pdf_url' => $invoicePdfUrl,
'error_message' => $errorMessage, 'error_message' => $errorMessage,
@@ -62,7 +55,7 @@ class PaymentTransactionRecorder
*/ */
public function recordInvoice( public function recordInvoice(
?User $user, ?User $user,
?CreditProduct $product, ?BillingProduct $product,
string $stripeInvoiceId, string $stripeInvoiceId,
?string $stripePriceId, ?string $stripePriceId,
PaymentTransactionStatus $status, PaymentTransactionStatus $status,
@@ -73,9 +66,6 @@ class PaymentTransactionRecorder
?string $stripeSubscriptionId = null, ?string $stripeSubscriptionId = null,
?int $amount = null, ?int $amount = null,
?string $currency = null, ?string $currency = null,
?int $creditsExpected = null,
?int $creditsGranted = null,
?CreditLedgerEntry $ledgerEntry = null,
?string $invoiceUrl = null, ?string $invoiceUrl = null,
?string $invoicePdfUrl = null, ?string $invoicePdfUrl = null,
?string $errorMessage = null, ?string $errorMessage = null,
@@ -86,8 +76,7 @@ class PaymentTransactionRecorder
'stripe_price_id' => $stripePriceId, 'stripe_price_id' => $stripePriceId,
], [ ], [
'user_id' => $user?->getKey(), 'user_id' => $user?->getKey(),
'credit_product_id' => $product?->getKey(), 'billing_product_id' => $product?->getKey(),
'credit_ledger_entry_id' => $ledgerEntry?->getKey(),
'type' => PaymentTransactionType::SUBSCRIPTION_INVOICE, 'type' => PaymentTransactionType::SUBSCRIPTION_INVOICE,
'status' => $status, 'status' => $status,
'stripe_event_id' => $stripeEventId, 'stripe_event_id' => $stripeEventId,
@@ -97,8 +86,6 @@ class PaymentTransactionRecorder
'stripe_subscription_id' => $stripeSubscriptionId, 'stripe_subscription_id' => $stripeSubscriptionId,
'amount' => $amount, 'amount' => $amount,
'currency' => $currency, 'currency' => $currency,
'credits_expected' => $creditsExpected ?? $product?->credits,
'credits_granted' => $creditsGranted ?? 0,
'invoice_url' => $invoiceUrl, 'invoice_url' => $invoiceUrl,
'invoice_pdf_url' => $invoicePdfUrl, 'invoice_pdf_url' => $invoicePdfUrl,
'error_message' => $errorMessage, 'error_message' => $errorMessage,
@@ -3,12 +3,12 @@
namespace App\Services; namespace App\Services;
use App\Enums\BillingProductPurpose; use App\Enums\BillingProductPurpose;
use App\Models\CreditProduct; use App\Models\BillingProduct;
use Laravel\Cashier\Cashier; use Laravel\Cashier\Cashier;
class StripeCreditProductSyncer class StripeBillingProductSyncer
{ {
public function sync(CreditProduct $product): CreditProduct public function sync(BillingProduct $product): BillingProduct
{ {
throw_if( throw_if(
$product->purpose === null, $product->purpose === null,
@@ -24,7 +24,7 @@ class StripeCreditProductSyncer
'name' => $product->name, 'name' => $product->name,
'description' => $product->description, 'description' => $product->description,
'metadata' => [ 'metadata' => [
'credit_product_id' => $product->getKey(), 'billing_product_id' => $product->getKey(),
'billing_product_purpose' => $product->purpose?->value, 'billing_product_purpose' => $product->purpose?->value,
], ],
]); ]);
@@ -36,7 +36,7 @@ class StripeCreditProductSyncer
'description' => $product->description, 'description' => $product->description,
'active' => (bool) $product->is_active, 'active' => (bool) $product->is_active,
'metadata' => [ 'metadata' => [
'credit_product_id' => $product->getKey(), 'billing_product_id' => $product->getKey(),
'billing_product_purpose' => $product->purpose?->value, 'billing_product_purpose' => $product->purpose?->value,
], ],
]); ]);
@@ -58,7 +58,7 @@ class StripeCreditProductSyncer
'unit_amount' => $product->amount, 'unit_amount' => $product->amount,
'currency' => strtolower($product->currency), 'currency' => strtolower($product->currency),
'metadata' => [ 'metadata' => [
'credit_product_id' => $product->getKey(), 'billing_product_id' => $product->getKey(),
'billing_product_purpose' => $product->purpose?->value, 'billing_product_purpose' => $product->purpose?->value,
], ],
]; ];
@@ -8,16 +8,11 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('users', function (Blueprint $table): void { Schema::create('billing_products', function (Blueprint $table): void {
$table->unsignedInteger('ai_credits_balance')->default(3)->after('trial_ends_at');
});
Schema::create('credit_products', function (Blueprint $table): void {
$table->ulid('id')->primary(); $table->ulid('id')->primary();
$table->string('name'); $table->string('name');
$table->text('description')->nullable(); $table->text('description')->nullable();
$table->string('type', 32); $table->string('purpose', 32)->unique();
$table->unsignedInteger('credits');
$table->unsignedInteger('amount'); $table->unsignedInteger('amount');
$table->string('currency', 3)->default('eur'); $table->string('currency', 3)->default('eur');
$table->string('stripe_product_id')->nullable()->index(); $table->string('stripe_product_id')->nullable()->index();
@@ -26,36 +21,12 @@ return new class extends Migration
$table->unsignedInteger('sort_order')->default(0); $table->unsignedInteger('sort_order')->default(0);
$table->timestamps(); $table->timestamps();
$table->index(['type', 'is_active', 'sort_order']); $table->index(['purpose', 'is_active', 'sort_order']);
});
Schema::create('credit_ledger_entries', function (Blueprint $table): void {
$table->ulid('id')->primary();
$table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignUlid('credit_product_id')->nullable()->constrained('credit_products')->nullOnDelete();
$table->string('type', 64);
$table->integer('credits');
$table->unsignedInteger('balance_after');
$table->string('source')->nullable()->unique();
$table->string('stripe_checkout_session_id')->nullable()->index();
$table->string('stripe_invoice_id')->nullable()->index();
$table->string('stripe_payment_intent_id')->nullable()->index();
$table->string('stripe_subscription_id')->nullable()->index();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['user_id', 'created_at']);
$table->index(['user_id', 'type', 'created_at']);
}); });
} }
public function down(): void public function down(): void
{ {
Schema::dropIfExists('credit_ledger_entries'); Schema::dropIfExists('billing_products');
Schema::dropIfExists('credit_products');
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('ai_credits_balance');
});
} }
}; };
@@ -11,8 +11,7 @@ return new class extends Migration
Schema::create('payment_transactions', function (Blueprint $table): void { Schema::create('payment_transactions', function (Blueprint $table): void {
$table->ulid('id')->primary(); $table->ulid('id')->primary();
$table->foreignUlid('user_id')->nullable()->constrained('users')->nullOnDelete(); $table->foreignUlid('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignUlid('credit_product_id')->nullable()->constrained('credit_products')->nullOnDelete(); $table->foreignUlid('billing_product_id')->nullable()->constrained('billing_products')->nullOnDelete();
$table->foreignUlid('credit_ledger_entry_id')->nullable()->constrained('credit_ledger_entries')->nullOnDelete();
$table->string('type', 64); $table->string('type', 64);
$table->string('status', 64); $table->string('status', 64);
$table->string('stripe_event_id')->nullable()->index(); $table->string('stripe_event_id')->nullable()->index();
@@ -25,8 +24,6 @@ return new class extends Migration
$table->string('stripe_price_id')->nullable()->index(); $table->string('stripe_price_id')->nullable()->index();
$table->unsignedInteger('amount')->nullable(); $table->unsignedInteger('amount')->nullable();
$table->string('currency', 3)->nullable(); $table->string('currency', 3)->nullable();
$table->unsignedInteger('credits_expected')->nullable();
$table->unsignedInteger('credits_granted')->default(0);
$table->text('error_message')->nullable(); $table->text('error_message')->nullable();
$table->json('payload')->nullable(); $table->json('payload')->nullable();
$table->timestamp('processed_at')->nullable(); $table->timestamp('processed_at')->nullable();
@@ -11,9 +11,11 @@ return new class extends Migration
*/ */
public function up(): void public function up(): void
{ {
Schema::table('credit_products', function (Blueprint $table): void { if (Schema::hasTable('credit_products') && ! Schema::hasColumn('credit_products', 'purpose')) {
$table->string('purpose', 32)->nullable()->after('type')->unique(); Schema::table('credit_products', function (Blueprint $table): void {
}); $table->string('purpose', 32)->nullable()->after('type')->unique();
});
}
} }
/** /**
@@ -21,8 +23,10 @@ return new class extends Migration
*/ */
public function down(): void public function down(): void
{ {
Schema::table('credit_products', function (Blueprint $table): void { if (Schema::hasTable('credit_products') && Schema::hasColumn('credit_products', 'purpose')) {
$table->dropColumn('purpose'); Schema::table('credit_products', function (Blueprint $table): void {
}); $table->dropColumn('purpose');
});
}
} }
}; };
@@ -0,0 +1,125 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('credit_products')) {
if (Schema::hasColumn('payment_transactions', 'credit_product_id')) {
Schema::table('payment_transactions', function (Blueprint $table): void {
$table->dropForeign(['credit_product_id']);
});
}
if (Schema::hasColumn('payment_transactions', 'credit_ledger_entry_id')) {
Schema::table('payment_transactions', function (Blueprint $table): void {
$table->dropForeign(['credit_ledger_entry_id']);
});
}
Schema::dropIfExists('credit_ledger_entries');
Schema::rename('credit_products', 'billing_products');
}
if (Schema::hasColumn('billing_products', 'type')) {
Schema::table('billing_products', function (Blueprint $table): void {
$table->dropColumn('type');
});
}
if (Schema::hasColumn('billing_products', 'credits')) {
Schema::table('billing_products', function (Blueprint $table): void {
$table->dropColumn('credits');
});
}
if (Schema::hasColumn('payment_transactions', 'credit_product_id')) {
Schema::table('payment_transactions', function (Blueprint $table): void {
$table->renameColumn('credit_product_id', 'billing_product_id');
});
Schema::table('payment_transactions', function (Blueprint $table): void {
$table->foreign('billing_product_id')
->references('id')
->on('billing_products')
->nullOnDelete();
});
}
$paymentColumns = array_values(array_filter([
Schema::hasColumn('payment_transactions', 'credit_ledger_entry_id') ? 'credit_ledger_entry_id' : null,
Schema::hasColumn('payment_transactions', 'credits_expected') ? 'credits_expected' : null,
Schema::hasColumn('payment_transactions', 'credits_granted') ? 'credits_granted' : null,
]));
if ($paymentColumns !== []) {
Schema::table('payment_transactions', function (Blueprint $table) use ($paymentColumns): void {
$table->dropColumn($paymentColumns);
});
}
if (Schema::hasColumn('users', 'ai_credits_balance')) {
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('ai_credits_balance');
});
}
DB::table('payment_transactions')
->where('status', 'credited')
->update(['status' => 'paid']);
}
public function down(): void
{
Schema::table('payment_transactions', function (Blueprint $table): void {
$table->dropForeign(['billing_product_id']);
});
Schema::rename('billing_products', 'credit_products');
Schema::table('credit_products', function (Blueprint $table): void {
$table->string('type', 32)->default('one_time');
$table->unsignedInteger('credits')->default(0);
});
DB::table('credit_products')
->where('purpose', 'subscription')
->update(['type' => 'monthly']);
Schema::table('users', function (Blueprint $table): void {
$table->unsignedInteger('ai_credits_balance')->default(0);
});
Schema::create('credit_ledger_entries', function (Blueprint $table): void {
$table->ulid('id')->primary();
$table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignUlid('credit_product_id')->nullable()->constrained('credit_products')->nullOnDelete();
$table->string('type', 64);
$table->integer('credits');
$table->unsignedInteger('balance_after');
$table->string('source')->nullable()->unique();
$table->string('stripe_checkout_session_id')->nullable()->index();
$table->string('stripe_invoice_id')->nullable()->index();
$table->string('stripe_payment_intent_id')->nullable()->index();
$table->string('stripe_subscription_id')->nullable()->index();
$table->json('metadata')->nullable();
$table->timestamps();
});
Schema::table('payment_transactions', function (Blueprint $table): void {
$table->renameColumn('billing_product_id', 'credit_product_id');
});
Schema::table('payment_transactions', function (Blueprint $table): void {
$table->foreign('credit_product_id')->references('id')->on('credit_products')->nullOnDelete();
$table->foreignUlid('credit_ledger_entry_id')->nullable()->constrained('credit_ledger_entries')->nullOnDelete();
$table->unsignedInteger('credits_expected')->nullable();
$table->unsignedInteger('credits_granted')->default(0);
});
}
};
+2 -6
View File
@@ -69,7 +69,7 @@ return [
'empty' => '-', 'empty' => '-',
], ],
], ],
'credit_products' => [ 'billing_products' => [
'navigation' => [ 'navigation' => [
'label' => 'Offers', 'label' => 'Offers',
'singular' => 'Offer', 'singular' => 'Offer',
@@ -82,9 +82,7 @@ return [
'fields' => [ 'fields' => [
'name' => 'Name', 'name' => 'Name',
'description' => 'Description', 'description' => 'Description',
'type' => 'Type',
'purpose' => 'Offer', 'purpose' => 'Offer',
'credits' => 'Credits',
'amount' => 'Price', 'amount' => 'Price',
'currency' => 'Currency', 'currency' => 'Currency',
'stripe_product_id' => 'Stripe product ID', 'stripe_product_id' => 'Stripe product ID',
@@ -145,12 +143,10 @@ return [
'fields' => [ 'fields' => [
'id' => 'ID', 'id' => 'ID',
'user' => 'User', 'user' => 'User',
'credit_product' => 'Product', 'billing_product' => 'Product',
'type' => 'Type', 'type' => 'Type',
'status' => 'Status', 'status' => 'Status',
'amount' => 'Amount', 'amount' => 'Amount',
'credits_expected' => 'Expected credits',
'credits_granted' => 'Granted credits',
'invoice_url' => 'Invoice URL', 'invoice_url' => 'Invoice URL',
'invoice_pdf_url' => 'Invoice PDF', 'invoice_pdf_url' => 'Invoice PDF',
'invoice_email_sent_at' => 'Invoice email sent at', 'invoice_email_sent_at' => 'Invoice email sent at',
-13
View File
@@ -49,10 +49,6 @@ return [
'privacy_policy' => 'Privacy policy', 'privacy_policy' => 'Privacy policy',
'terms' => 'Terms of service', 'terms' => 'Terms of service',
], ],
'credit_product_type' => [
'monthly' => 'Monthly',
'one_time' => 'Credit pack',
],
'billing_product_purpose' => [ 'billing_product_purpose' => [
'subscription' => 'Analysis subscription', 'subscription' => 'Analysis subscription',
'certification' => 'Lifetime certification', 'certification' => 'Lifetime certification',
@@ -62,18 +58,9 @@ return [
'approved' => 'Approved', 'approved' => 'Approved',
'rejected' => 'Rejected', 'rejected' => 'Rejected',
], ],
'credit_ledger_entry_type' => [
'free_grant' => 'Free credits',
'purchase' => 'Purchase',
'subscription_renewal' => 'Renewal',
'usage' => 'Usage',
'refund' => 'Refund',
'adjustment' => 'Adjustment',
],
'payment_transaction_status' => [ 'payment_transaction_status' => [
'pending' => 'Pending', 'pending' => 'Pending',
'paid' => 'Paid', 'paid' => 'Paid',
'credited' => 'Credited',
'failed' => 'Failed', 'failed' => 'Failed',
'ignored' => 'Ignored', 'ignored' => 'Ignored',
'error' => 'Error', 'error' => 'Error',
+1 -2
View File
@@ -40,8 +40,7 @@ return [
'default_name' => 'there', 'default_name' => 'there',
'intro' => 'Thanks for your payment. Your summary and invoice link are below.', 'intro' => 'Thanks for your payment. Your summary and invoice link are below.',
'product' => 'Product', 'product' => 'Product',
'default_product' => 'Bowli credits', 'default_product' => 'Bowli purchase',
'credits' => 'Credits',
'amount' => 'Amount', 'amount' => 'Amount',
'unknown_amount' => 'Amount unavailable', 'unknown_amount' => 'Amount unavailable',
'action' => 'View my invoice', 'action' => 'View my invoice',
+2 -6
View File
@@ -69,7 +69,7 @@ return [
'empty' => '-', 'empty' => '-',
], ],
], ],
'credit_products' => [ 'billing_products' => [
'navigation' => [ 'navigation' => [
'label' => 'Offres', 'label' => 'Offres',
'singular' => 'Offre', 'singular' => 'Offre',
@@ -82,9 +82,7 @@ return [
'fields' => [ 'fields' => [
'name' => 'Nom', 'name' => 'Nom',
'description' => 'Description', 'description' => 'Description',
'type' => 'Type',
'purpose' => 'Offre', 'purpose' => 'Offre',
'credits' => 'Crédits',
'amount' => 'Prix', 'amount' => 'Prix',
'currency' => 'Devise', 'currency' => 'Devise',
'stripe_product_id' => 'Stripe product ID', 'stripe_product_id' => 'Stripe product ID',
@@ -145,12 +143,10 @@ return [
'fields' => [ 'fields' => [
'id' => 'ID', 'id' => 'ID',
'user' => 'Utilisateur', 'user' => 'Utilisateur',
'credit_product' => 'Produit', 'billing_product' => 'Produit',
'type' => 'Type', 'type' => 'Type',
'status' => 'Statut', 'status' => 'Statut',
'amount' => 'Montant', 'amount' => 'Montant',
'credits_expected' => 'Crédits attendus',
'credits_granted' => 'Crédits crédités',
'invoice_url' => 'URL facture', 'invoice_url' => 'URL facture',
'invoice_pdf_url' => 'PDF facture', 'invoice_pdf_url' => 'PDF facture',
'invoice_email_sent_at' => 'Email facture envoyé le', 'invoice_email_sent_at' => 'Email facture envoyé le',
-13
View File
@@ -49,10 +49,6 @@ return [
'privacy_policy' => 'Politique de confidentialité', 'privacy_policy' => 'Politique de confidentialité',
'terms' => 'Conditions dutilisation', 'terms' => 'Conditions dutilisation',
], ],
'credit_product_type' => [
'monthly' => 'Mensuel',
'one_time' => 'Pack de crédits',
],
'billing_product_purpose' => [ 'billing_product_purpose' => [
'subscription' => 'Abonnement analyse', 'subscription' => 'Abonnement analyse',
'certification' => 'Certification à vie', 'certification' => 'Certification à vie',
@@ -62,18 +58,9 @@ return [
'approved' => 'Approuvée', 'approved' => 'Approuvée',
'rejected' => 'Refusée', 'rejected' => 'Refusée',
], ],
'credit_ledger_entry_type' => [
'free_grant' => 'Crédits offerts',
'purchase' => 'Achat',
'subscription_renewal' => 'Renouvellement',
'usage' => 'Utilisation',
'refund' => 'Remboursement',
'adjustment' => 'Ajustement',
],
'payment_transaction_status' => [ 'payment_transaction_status' => [
'pending' => 'En attente', 'pending' => 'En attente',
'paid' => 'Payé', 'paid' => 'Payé',
'credited' => 'Crédité',
'failed' => 'Échec', 'failed' => 'Échec',
'ignored' => 'Ignoré', 'ignored' => 'Ignoré',
'error' => 'Erreur', 'error' => 'Erreur',
+1 -2
View File
@@ -40,8 +40,7 @@ return [
'default_name' => 'à toi', 'default_name' => 'à toi',
'intro' => 'Merci pour ton paiement. Tu trouveras ci-dessous le récapitulatif et le lien vers ta facture.', 'intro' => 'Merci pour ton paiement. Tu trouveras ci-dessous le récapitulatif et le lien vers ta facture.',
'product' => 'Produit', 'product' => 'Produit',
'default_product' => 'Crédits Bowli', 'default_product' => 'Achat Bowli',
'credits' => 'Crédits',
'amount' => 'Montant', 'amount' => 'Montant',
'unknown_amount' => 'Montant indisponible', 'unknown_amount' => 'Montant indisponible',
'action' => 'Voir ma facture', 'action' => 'Voir ma facture',
@@ -37,14 +37,6 @@
{{ $productName ?: trans('mail.payment_invoice.default_product', [], $locale) }} {{ $productName ?: trans('mail.payment_invoice.default_product', [], $locale) }}
</td> </td>
</tr> </tr>
<tr>
<td style="padding: 10px 0; color: #666666; font-size: 14px; border-top: 1px solid #eeeeee;">
{{ trans('mail.payment_invoice.credits', [], $locale) }}
</td>
<td align="right" style="padding: 10px 0; color: #232323; font-size: 14px; font-weight: 700; border-top: 1px solid #eeeeee;">
{{ $credits }}
</td>
</tr>
<tr> <tr>
<td style="padding: 10px 0; color: #666666; font-size: 14px; border-top: 1px solid #eeeeee;"> <td style="padding: 10px 0; color: #666666; font-size: 14px; border-top: 1px solid #eeeeee;">
{{ trans('mail.payment_invoice.amount', [], $locale) }} {{ trans('mail.payment_invoice.amount', [], $locale) }}
+1 -1
View File
@@ -62,7 +62,7 @@ Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function
// 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::get('products', [BillingController::class, 'products'])->name('billing.products');
Route::post('products/{creditProduct}/checkout', [BillingController::class, 'checkout'])->middleware('throttle:account-action')->name('billing.checkout'); Route::post('products/{billingProduct}/checkout', [BillingController::class, 'checkout'])->middleware('throttle:account-action')->name('billing.checkout');
Route::post('portal', [BillingController::class, 'portal'])->middleware('throttle:account-action')->name('billing.portal'); Route::post('portal', [BillingController::class, 'portal'])->middleware('throttle:account-action')->name('billing.portal');
}); });
+3 -17
View File
@@ -1,8 +1,7 @@
<?php <?php
use App\Enums\BillingProductPurpose; use App\Enums\BillingProductPurpose;
use App\Enums\CreditProductType; use App\Models\BillingProduct;
use App\Models\CreditProduct;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
@@ -12,35 +11,22 @@ uses(RefreshDatabase::class);
it('lists the subscription and certification offers for the mobile app', function () { it('lists the subscription and certification offers for the mobile app', function () {
Sanctum::actingAs(User::factory()->create()); Sanctum::actingAs(User::factory()->create());
CreditProduct::create([ BillingProduct::create([
'name' => 'Certification', 'name' => 'Certification',
'type' => CreditProductType::ONE_TIME,
'purpose' => BillingProductPurpose::CERTIFICATION, 'purpose' => BillingProductPurpose::CERTIFICATION,
'credits' => 0,
'amount' => 1999, 'amount' => 1999,
'currency' => 'eur', 'currency' => 'eur',
'stripe_price_id' => 'price_certification', 'stripe_price_id' => 'price_certification',
'sort_order' => 20, 'sort_order' => 20,
]); ]);
CreditProduct::create([ BillingProduct::create([
'name' => 'Bowli Plus', 'name' => 'Bowli Plus',
'type' => CreditProductType::MONTHLY,
'purpose' => BillingProductPurpose::SUBSCRIPTION, 'purpose' => BillingProductPurpose::SUBSCRIPTION,
'credits' => 0,
'amount' => 999, 'amount' => 999,
'currency' => 'eur', 'currency' => 'eur',
'stripe_price_id' => 'price_subscription', 'stripe_price_id' => 'price_subscription',
'sort_order' => 10, 'sort_order' => 10,
]); ]);
CreditProduct::create([
'name' => 'Ancien pack',
'type' => CreditProductType::ONE_TIME,
'credits' => 5,
'amount' => 299,
'currency' => 'eur',
'stripe_price_id' => 'price_legacy',
]);
$this $this
->getJson('/api/billing/products') ->getJson('/api/billing/products')
->assertOk() ->assertOk()
+5 -8
View File
@@ -2,13 +2,12 @@
use App\Actions\ReviewIdentityVerification; use App\Actions\ReviewIdentityVerification;
use App\Enums\BillingProductPurpose; use App\Enums\BillingProductPurpose;
use App\Enums\CreditProductType;
use App\Enums\IdentityVerificationStatus; use App\Enums\IdentityVerificationStatus;
use App\Enums\PaymentTransactionStatus; 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\GrantCreditsFromStripeWebhook; use App\Listeners\HandleStripeWebhook;
use App\Models\CreditProduct; use App\Models\BillingProduct;
use App\Models\IdentityVerificationRequest; use App\Models\IdentityVerificationRequest;
use App\Models\PaymentTransaction; use App\Models\PaymentTransaction;
use App\Models\User; use App\Models\User;
@@ -173,11 +172,9 @@ it('grants lifetime certification entitlement from the paid Stripe webhook once'
$user = User::factory()->create([ $user = User::factory()->create([
'stripe_id' => 'cus_certification', 'stripe_id' => 'cus_certification',
]); ]);
$product = CreditProduct::create([ $product = BillingProduct::create([
'name' => 'Certification', 'name' => 'Certification',
'type' => CreditProductType::ONE_TIME,
'purpose' => BillingProductPurpose::CERTIFICATION, 'purpose' => BillingProductPurpose::CERTIFICATION,
'credits' => 0,
'amount' => 1999, 'amount' => 1999,
'currency' => 'eur', 'currency' => 'eur',
'stripe_price_id' => 'price_certification', 'stripe_price_id' => 'price_certification',
@@ -196,7 +193,7 @@ it('grants lifetime certification entitlement from the paid Stripe webhook once'
'amount_total' => 1999, 'amount_total' => 1999,
'currency' => 'eur', 'currency' => 'eur',
'metadata' => [ 'metadata' => [
'credit_product_id' => $product->getKey(), 'billing_product_id' => $product->getKey(),
'billing_product_purpose' => BillingProductPurpose::CERTIFICATION->value, 'billing_product_purpose' => BillingProductPurpose::CERTIFICATION->value,
], ],
], ],
@@ -204,7 +201,7 @@ it('grants lifetime certification entitlement from the paid Stripe webhook once'
], ],
]; ];
$listener = app(GrantCreditsFromStripeWebhook::class); $listener = app(HandleStripeWebhook::class);
$listener->handle($event); $listener->handle($event);
$firstPurchaseDate = $user->fresh()->certification_purchased_at; $firstPurchaseDate = $user->fresh()->certification_purchased_at;
$listener->handle($event); $listener->handle($event);
+45
View File
@@ -0,0 +1,45 @@
<?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');
});