From 68560655f365024353dc29a982f079c0c33849e3 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Fri, 7 Aug 2026 15:32:40 +0200 Subject: [PATCH] feat: clean credits users --- .gitea/workflows/cd.yml | 4 +- app/Enums/CreditLedgerEntryType.php | 38 ----- app/Enums/CreditProductType.php | 28 ---- app/Enums/PaymentTransactionStatus.php | 5 +- .../BillingProductResource.php | 78 ++++++++++ .../Pages/CreateBillingProduct.php | 17 +++ .../Pages/EditBillingProduct.php | 26 ++++ .../Pages/ListBillingProducts.php | 19 +++ .../Schemas/BillingProductForm.php} | 34 ++--- .../Tables/BillingProductsTable.php} | 26 ++-- .../CreditProducts/CreditProductResource.php | 78 ---------- .../Pages/CreateCreditProduct.php | 16 -- .../Pages/EditCreditProduct.php | 25 --- .../Pages/ListCreditProducts.php | 19 --- .../Schemas/PaymentTransactionInfolist.php | 9 +- .../Tables/PaymentTransactionsTable.php | 13 +- app/Http/Controllers/BillingController.php | 30 ++-- ...esource.php => BillingProductResource.php} | 2 +- ...ipeWebhook.php => HandleStripeWebhook.php} | 21 +-- app/Mail/PaymentInvoiceMail.php | 3 +- .../{CreditProduct.php => BillingProduct.php} | 13 +- app/Models/CreditLedgerEntry.php | 49 ------ app/Models/PaymentTransaction.php | 16 +- app/Models/User.php | 7 - app/Providers/AppServiceProvider.php | 4 +- app/Services/AiCreditService.php | 142 ------------------ app/Services/PaymentInvoiceEmailer.php | 2 +- app/Services/PaymentTransactionRecorder.php | 23 +-- ...cer.php => StripeBillingProductSyncer.php} | 12 +- ...ate_credit_products_and_ledger_entries.php | 37 +---- ...0000_create_payment_transactions_table.php | 5 +- ...8_add_purpose_to_credit_products_table.php | 16 +- ..._08_07_130000_normalize_billing_domain.php | 125 +++++++++++++++ lang/en/admin.php | 8 +- lang/en/enums.php | 13 -- lang/en/mail.php | 3 +- lang/fr/admin.php | 8 +- lang/fr/enums.php | 13 -- lang/fr/mail.php | 3 +- .../views/emails/payment-invoice.blade.php | 8 - routes/api.php | 2 +- tests/Feature/BillingProductsTest.php | 20 +-- tests/Feature/CertificationTest.php | 13 +- tests/Feature/PaymentInvoiceMailTest.php | 45 ++++++ 44 files changed, 417 insertions(+), 661 deletions(-) delete mode 100644 app/Enums/CreditLedgerEntryType.php delete mode 100644 app/Enums/CreditProductType.php create mode 100644 app/Filament/Resources/BillingProducts/BillingProductResource.php create mode 100644 app/Filament/Resources/BillingProducts/Pages/CreateBillingProduct.php create mode 100644 app/Filament/Resources/BillingProducts/Pages/EditBillingProduct.php create mode 100644 app/Filament/Resources/BillingProducts/Pages/ListBillingProducts.php rename app/Filament/Resources/{CreditProducts/Schemas/CreditProductForm.php => BillingProducts/Schemas/BillingProductForm.php} (59%) rename app/Filament/Resources/{CreditProducts/Tables/CreditProductsTable.php => BillingProducts/Tables/BillingProductsTable.php} (68%) delete mode 100644 app/Filament/Resources/CreditProducts/CreditProductResource.php delete mode 100644 app/Filament/Resources/CreditProducts/Pages/CreateCreditProduct.php delete mode 100644 app/Filament/Resources/CreditProducts/Pages/EditCreditProduct.php delete mode 100644 app/Filament/Resources/CreditProducts/Pages/ListCreditProducts.php rename app/Http/Resources/{CreditProductResource.php => BillingProductResource.php} (91%) rename app/Listeners/{GrantCreditsFromStripeWebhook.php => HandleStripeWebhook.php} (95%) rename app/Models/{CreditProduct.php => BillingProduct.php} (76%) delete mode 100644 app/Models/CreditLedgerEntry.php delete mode 100644 app/Services/AiCreditService.php rename app/Services/{StripeCreditProductSyncer.php => StripeBillingProductSyncer.php} (90%) create mode 100644 database/migrations/2026_08_07_130000_normalize_billing_domain.php create mode 100644 tests/Feature/PaymentInvoiceMailTest.php diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index ee2106c..61c81da 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -35,9 +35,9 @@ jobs: cd /data/stacks/daily-meal-api echo "Pull nouvelle image" - docker compose -f docker-compose.prod.yml pull + docker compose pull 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 diff --git a/app/Enums/CreditLedgerEntryType.php b/app/Enums/CreditLedgerEntryType.php deleted file mode 100644 index 0b2b9a3..0000000 --- a/app/Enums/CreditLedgerEntryType.php +++ /dev/null @@ -1,38 +0,0 @@ - __('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', - }; - } -} diff --git a/app/Enums/CreditProductType.php b/app/Enums/CreditProductType.php deleted file mode 100644 index e532cd4..0000000 --- a/app/Enums/CreditProductType.php +++ /dev/null @@ -1,28 +0,0 @@ - __('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', - }; - } -} diff --git a/app/Enums/PaymentTransactionStatus.php b/app/Enums/PaymentTransactionStatus.php index 5728181..668b1cf 100644 --- a/app/Enums/PaymentTransactionStatus.php +++ b/app/Enums/PaymentTransactionStatus.php @@ -9,7 +9,6 @@ enum PaymentTransactionStatus: string implements HasColor, HasLabel { case PENDING = 'pending'; case PAID = 'paid'; - case CREDITED = 'credited'; case FAILED = 'failed'; case IGNORED = 'ignored'; case ERROR = 'error'; @@ -19,7 +18,6 @@ enum PaymentTransactionStatus: string implements HasColor, HasLabel return match ($this) { self::PENDING => __('enums.payment_transaction_status.pending'), self::PAID => __('enums.payment_transaction_status.paid'), - self::CREDITED => __('enums.payment_transaction_status.credited'), self::FAILED => __('enums.payment_transaction_status.failed'), self::IGNORED => __('enums.payment_transaction_status.ignored'), self::ERROR => __('enums.payment_transaction_status.error'), @@ -30,8 +28,7 @@ enum PaymentTransactionStatus: string implements HasColor, HasLabel { return match ($this) { self::PENDING => 'gray', - self::PAID => 'info', - self::CREDITED => 'success', + self::PAID => 'success', self::FAILED, self::ERROR => 'danger', self::IGNORED => 'warning', }; diff --git a/app/Filament/Resources/BillingProducts/BillingProductResource.php b/app/Filament/Resources/BillingProducts/BillingProductResource.php new file mode 100644 index 0000000..9eb5ffe --- /dev/null +++ b/app/Filament/Resources/BillingProducts/BillingProductResource.php @@ -0,0 +1,78 @@ +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'), + ]; + } +} diff --git a/app/Filament/Resources/BillingProducts/Pages/CreateBillingProduct.php b/app/Filament/Resources/BillingProducts/Pages/CreateBillingProduct.php new file mode 100644 index 0000000..31ad0ee --- /dev/null +++ b/app/Filament/Resources/BillingProducts/Pages/CreateBillingProduct.php @@ -0,0 +1,17 @@ +sync($this->record); + } +} diff --git a/app/Filament/Resources/BillingProducts/Pages/EditBillingProduct.php b/app/Filament/Resources/BillingProducts/Pages/EditBillingProduct.php new file mode 100644 index 0000000..0351fc8 --- /dev/null +++ b/app/Filament/Resources/BillingProducts/Pages/EditBillingProduct.php @@ -0,0 +1,26 @@ +sync($this->record); + } +} diff --git a/app/Filament/Resources/BillingProducts/Pages/ListBillingProducts.php b/app/Filament/Resources/BillingProducts/Pages/ListBillingProducts.php new file mode 100644 index 0000000..9945963 --- /dev/null +++ b/app/Filament/Resources/BillingProducts/Pages/ListBillingProducts.php @@ -0,0 +1,19 @@ +components([ - Section::make(__('admin.credit_products.sections.product')) + Section::make(__('admin.billing_products.sections.product')) ->columns(2) ->schema([ TextInput::make('name') - ->label(__('admin.credit_products.fields.name')) + ->label(__('admin.billing_products.fields.name')) ->required() ->maxLength(255), Select::make('purpose') - ->label(__('admin.credit_products.fields.purpose')) + ->label(__('admin.billing_products.fields.purpose')) ->options(BillingProductPurpose::class) ->unique(ignoreRecord: true) ->required(), - Hidden::make('type') - ->default(CreditProductType::ONE_TIME->value), - Hidden::make('credits') - ->default(0), TextInput::make('amount') - ->label(__('admin.credit_products.fields.amount')) - ->helperText(__('admin.credit_products.helpers.amount')) + ->label(__('admin.billing_products.fields.amount')) + ->helperText(__('admin.billing_products.helpers.amount')) ->integer() ->minValue(1) ->required(), TextInput::make('currency') - ->label(__('admin.credit_products.fields.currency')) + ->label(__('admin.billing_products.fields.currency')) ->default('eur') ->required() ->maxLength(3), TextInput::make('sort_order') - ->label(__('admin.credit_products.fields.sort_order')) + ->label(__('admin.billing_products.fields.sort_order')) ->integer() ->default(0) ->required(), Textarea::make('description') - ->label(__('admin.credit_products.fields.description')) + ->label(__('admin.billing_products.fields.description')) ->rows(3) ->columnSpanFull(), Toggle::make('is_active') - ->label(__('admin.credit_products.fields.is_active')) + ->label(__('admin.billing_products.fields.is_active')) ->default(true), ]), - Section::make(__('admin.credit_products.sections.stripe')) + Section::make(__('admin.billing_products.sections.stripe')) ->columns(2) ->schema([ TextInput::make('stripe_product_id') - ->label(__('admin.credit_products.fields.stripe_product_id')) + ->label(__('admin.billing_products.fields.stripe_product_id')) ->maxLength(255), TextInput::make('stripe_price_id') - ->label(__('admin.credit_products.fields.stripe_price_id')) + ->label(__('admin.billing_products.fields.stripe_price_id')) ->unique(ignoreRecord: true) ->maxLength(255), ]), diff --git a/app/Filament/Resources/CreditProducts/Tables/CreditProductsTable.php b/app/Filament/Resources/BillingProducts/Tables/BillingProductsTable.php similarity index 68% rename from app/Filament/Resources/CreditProducts/Tables/CreditProductsTable.php rename to app/Filament/Resources/BillingProducts/Tables/BillingProductsTable.php index fc45fa2..e0766f2 100644 --- a/app/Filament/Resources/CreditProducts/Tables/CreditProductsTable.php +++ b/app/Filament/Resources/BillingProducts/Tables/BillingProductsTable.php @@ -1,9 +1,9 @@ defaultSort('sort_order') ->columns([ TextColumn::make('name') - ->label(__('admin.credit_products.fields.name')) + ->label(__('admin.billing_products.fields.name')) ->searchable() ->sortable(), TextColumn::make('purpose') - ->label(__('admin.credit_products.fields.purpose')) + ->label(__('admin.billing_products.fields.purpose')) ->badge() ->sortable(), TextColumn::make('amount') - ->label(__('admin.credit_products.fields.amount')) + ->label(__('admin.billing_products.fields.amount')) ->money(fn ($record): string => $record->currency, divideBy: 100) ->sortable(), TextColumn::make('stripe_price_id') - ->label(__('admin.credit_products.fields.stripe_price_id')) + ->label(__('admin.billing_products.fields.stripe_price_id')) ->copyable() ->searchable() ->toggleable(), IconColumn::make('is_active') - ->label(__('admin.credit_products.fields.is_active')) + ->label(__('admin.billing_products.fields.is_active')) ->boolean() ->sortable(), TextColumn::make('sort_order') - ->label(__('admin.credit_products.fields.sort_order')) + ->label(__('admin.billing_products.fields.sort_order')) ->sortable() ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('created_at') - ->label(__('admin.credit_products.fields.created_at')) + ->label(__('admin.billing_products.fields.created_at')) ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ SelectFilter::make('purpose') - ->label(__('admin.credit_products.fields.purpose')) + ->label(__('admin.billing_products.fields.purpose')) ->options(BillingProductPurpose::class), TernaryFilter::make('is_active') - ->label(__('admin.credit_products.fields.is_active')), + ->label(__('admin.billing_products.fields.is_active')), ]) ->recordActions([ - CreditProductResource::syncStripeAction(), + BillingProductResource::syncStripeAction(), EditAction::make(), ]) ->toolbarActions([ diff --git a/app/Filament/Resources/CreditProducts/CreditProductResource.php b/app/Filament/Resources/CreditProducts/CreditProductResource.php deleted file mode 100644 index 376d915..0000000 --- a/app/Filament/Resources/CreditProducts/CreditProductResource.php +++ /dev/null @@ -1,78 +0,0 @@ -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'), - ]; - } -} diff --git a/app/Filament/Resources/CreditProducts/Pages/CreateCreditProduct.php b/app/Filament/Resources/CreditProducts/Pages/CreateCreditProduct.php deleted file mode 100644 index 579ce8d..0000000 --- a/app/Filament/Resources/CreditProducts/Pages/CreateCreditProduct.php +++ /dev/null @@ -1,16 +0,0 @@ -sync($this->record); - } -} diff --git a/app/Filament/Resources/CreditProducts/Pages/EditCreditProduct.php b/app/Filament/Resources/CreditProducts/Pages/EditCreditProduct.php deleted file mode 100644 index 9ece616..0000000 --- a/app/Filament/Resources/CreditProducts/Pages/EditCreditProduct.php +++ /dev/null @@ -1,25 +0,0 @@ -sync($this->record); - } -} diff --git a/app/Filament/Resources/CreditProducts/Pages/ListCreditProducts.php b/app/Filament/Resources/CreditProducts/Pages/ListCreditProducts.php deleted file mode 100644 index f9bc19f..0000000 --- a/app/Filament/Resources/CreditProducts/Pages/ListCreditProducts.php +++ /dev/null @@ -1,19 +0,0 @@ -label(__('admin.payment_transactions.fields.user')) ->placeholder(__('admin.payment_transactions.placeholders.empty')) ->copyable(), - TextEntry::make('creditProduct.name') - ->label(__('admin.payment_transactions.fields.credit_product')) + 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('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') ->label(__('admin.payment_transactions.fields.processed_at')) ->dateTime() diff --git a/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php b/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php index db47879..cab1c64 100644 --- a/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php +++ b/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php @@ -32,8 +32,8 @@ class PaymentTransactionsTable ->label(__('admin.payment_transactions.fields.user')) ->searchable() ->placeholder(__('admin.payment_transactions.placeholders.empty')), - TextColumn::make('creditProduct.name') - ->label(__('admin.payment_transactions.fields.credit_product')) + TextColumn::make('billingProduct.name') + ->label(__('admin.payment_transactions.fields.billing_product')) ->searchable() ->placeholder(__('admin.payment_transactions.placeholders.empty')), TextColumn::make('amount') @@ -41,15 +41,6 @@ class PaymentTransactionsTable ->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100) ->sortable() ->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') ->label(__('admin.payment_transactions.fields.stripe_event_type')) ->toggleable(), diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php index 55f146f..810920c 100644 --- a/app/Http/Controllers/BillingController.php +++ b/app/Http/Controllers/BillingController.php @@ -3,8 +3,8 @@ namespace App\Http\Controllers; use App\Enums\BillingProductPurpose; -use App\Http\Resources\CreditProductResource; -use App\Models\CreditProduct; +use App\Http\Resources\BillingProductResource; +use App\Models\BillingProduct; use App\Services\MobileDeepLink; use App\Services\PaymentTransactionRecorder; use Illuminate\Http\JsonResponse; @@ -16,7 +16,7 @@ class BillingController extends Controller { public function products(): AnonymousResourceCollection { - $products = CreditProduct::query() + $products = BillingProduct::query() ->active() ->whereNotNull('purpose') ->whereNotNull('stripe_price_id') @@ -24,33 +24,33 @@ class BillingController extends Controller ->orderBy('amount') ->get(); - return CreditProductResource::collection($products); + return BillingProductResource::collection($products); } public function checkout( Request $request, - CreditProduct $creditProduct, + BillingProduct $billingProduct, PaymentTransactionRecorder $transactions, ): JsonResponse { - abort_unless($creditProduct->is_active, 404); - abort_unless($creditProduct->purpose !== null, 404); - abort_unless(filled($creditProduct->stripe_price_id), 404); + 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 = [ - 'credit_product_id' => $creditProduct->getKey(), - 'billing_product_purpose' => $creditProduct->purpose->value, + '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' => $creditProduct->getKey(), + 'client_reference_id' => $billingProduct->getKey(), ]; - if ($creditProduct->purpose === BillingProductPurpose::SUBSCRIPTION) { + if ($billingProduct->purpose === BillingProductPurpose::SUBSCRIPTION) { if ($user->subscribed('default')) { return response()->json([ 'message' => __('api.billing.active_subscription_exists'), @@ -58,7 +58,7 @@ class BillingController extends Controller } $checkout = $user - ->newSubscription('default', $creditProduct->stripe_price_id) + ->newSubscription('default', $billingProduct->stripe_price_id) ->withMetadata($metadata) ->checkout($sessionOptions); } else { @@ -76,7 +76,7 @@ class BillingController extends Controller ]; $checkout = $user->checkout([ - $creditProduct->stripe_price_id => 1, + $billingProduct->stripe_price_id => 1, ], $sessionOptions); } @@ -84,7 +84,7 @@ class BillingController extends Controller $transactions->recordCheckoutSession( user: $user, - product: $creditProduct, + product: $billingProduct, stripeCheckoutSessionId: $session->id, stripeCustomerId: $session->customer, amount: $session->amount_total, diff --git a/app/Http/Resources/CreditProductResource.php b/app/Http/Resources/BillingProductResource.php similarity index 91% rename from app/Http/Resources/CreditProductResource.php rename to app/Http/Resources/BillingProductResource.php index d9f46de..5407048 100644 --- a/app/Http/Resources/CreditProductResource.php +++ b/app/Http/Resources/BillingProductResource.php @@ -5,7 +5,7 @@ namespace App\Http\Resources; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; -class CreditProductResource extends JsonResource +class BillingProductResource extends JsonResource { /** * @return array diff --git a/app/Listeners/GrantCreditsFromStripeWebhook.php b/app/Listeners/HandleStripeWebhook.php similarity index 95% rename from app/Listeners/GrantCreditsFromStripeWebhook.php rename to app/Listeners/HandleStripeWebhook.php index 865fa1c..7ca7778 100644 --- a/app/Listeners/GrantCreditsFromStripeWebhook.php +++ b/app/Listeners/HandleStripeWebhook.php @@ -4,13 +4,13 @@ namespace App\Listeners; use App\Enums\BillingProductPurpose; use App\Enums\PaymentTransactionStatus; -use App\Models\CreditProduct; +use App\Models\BillingProduct; use App\Models\User; use App\Services\PaymentInvoiceEmailer; use App\Services\PaymentTransactionRecorder; use Laravel\Cashier\Cashier; -class GrantCreditsFromStripeWebhook +class HandleStripeWebhook { public function __construct( private PaymentInvoiceEmailer $invoiceEmails, @@ -92,7 +92,6 @@ class GrantCreditsFromStripeWebhook stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), amount: $this->intValue($session['amount_total'] ?? null), currency: $this->stringValue($session['currency'] ?? null), - creditsExpected: 0, invoiceUrl: $invoice ? $this->stringValue($invoice['hosted_invoice_url'] ?? null) : null, invoicePdfUrl: $invoice ? $this->stringValue($invoice['invoice_pdf'] ?? null) : null, payload: $session, @@ -124,7 +123,6 @@ class GrantCreditsFromStripeWebhook stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), amount: $this->intValue($session['amount_total'] ?? null), currency: $this->stringValue($session['currency'] ?? null), - creditsExpected: 0, errorMessage: 'Checkout session expired before payment.', payload: $session, ); @@ -147,7 +145,7 @@ class GrantCreditsFromStripeWebhook foreach (($invoice['lines']['data'] ?? []) as $line) { $priceId = $this->linePriceId($line); $product = $priceId - ? CreditProduct::query() + ? BillingProduct::query() ->where('purpose', BillingProductPurpose::SUBSCRIPTION) ->where('stripe_price_id', $priceId) ->first() @@ -171,7 +169,6 @@ class GrantCreditsFromStripeWebhook stripeSubscriptionId: $this->invoiceSubscriptionId($invoice), amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null), currency: $this->stringValue($invoice['currency'] ?? null), - creditsExpected: 0, invoiceUrl: $this->stringValue($invoice['hosted_invoice_url'] ?? null), invoicePdfUrl: $this->stringValue($invoice['invoice_pdf'] ?? null), payload: $invoice, @@ -194,7 +191,6 @@ class GrantCreditsFromStripeWebhook stripeSubscriptionId: $this->invoiceSubscriptionId($invoice), amount: $this->intValue($invoice['amount_paid'] ?? null), currency: $this->stringValue($invoice['currency'] ?? null), - creditsExpected: 0, errorMessage: 'Invoice does not contain the active subscription product.', payload: $invoice, ); @@ -218,7 +214,7 @@ class GrantCreditsFromStripeWebhook foreach ($lines ?: [null] as $line) { $priceId = is_array($line) ? $this->linePriceId($line) : null; $product = $priceId - ? CreditProduct::query()->where('stripe_price_id', $priceId)->first() + ? BillingProduct::query()->where('stripe_price_id', $priceId)->first() : null; $this->transactions->recordInvoice( @@ -238,7 +234,6 @@ class GrantCreditsFromStripeWebhook : ($invoice['amount_due'] ?? null) ), currency: $this->stringValue($invoice['currency'] ?? null), - creditsExpected: 0, errorMessage: 'Invoice payment failed.', payload: $invoice, ); @@ -254,10 +249,10 @@ class GrantCreditsFromStripeWebhook return User::query()->where('stripe_id', $customer)->first(); } - private function productFromMetadata(mixed $metadata): ?CreditProduct + private function productFromMetadata(mixed $metadata): ?BillingProduct { 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)) { return null; } @@ -268,8 +263,8 @@ class GrantCreditsFromStripeWebhook return null; } - return CreditProduct::query() - ->whereKey($metadata['credit_product_id']) + return BillingProduct::query() + ->whereKey($metadata['billing_product_id']) ->where('purpose', $purpose) ->first(); } diff --git a/app/Mail/PaymentInvoiceMail.php b/app/Mail/PaymentInvoiceMail.php index e5a0ca7..a70101b 100644 --- a/app/Mail/PaymentInvoiceMail.php +++ b/app/Mail/PaymentInvoiceMail.php @@ -32,11 +32,10 @@ class PaymentInvoiceMail extends Mailable view: 'emails.payment-invoice', with: [ 'amount' => $this->formattedAmount(), - 'credits' => $this->transaction->credits_granted ?: $this->transaction->credits_expected, 'invoicePdfUrl' => $this->transaction->invoice_pdf_url, 'invoiceUrl' => $this->transaction->invoice_url, 'locale' => $this->mailLocale(), - 'productName' => $this->transaction->creditProduct?->name, + 'productName' => $this->transaction->billingProduct?->name, 'userName' => $this->user->name, ], ); diff --git a/app/Models/CreditProduct.php b/app/Models/BillingProduct.php similarity index 76% rename from app/Models/CreditProduct.php rename to app/Models/BillingProduct.php index 2703aab..39d1120 100644 --- a/app/Models/CreditProduct.php +++ b/app/Models/BillingProduct.php @@ -3,24 +3,20 @@ namespace App\Models; use App\Enums\BillingProductPurpose; -use App\Enums\CreditProductType; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; -class CreditProduct extends Model +class BillingProduct extends Model { - /** @use HasFactory<\Database\Factories\CreditProductFactory> */ use HasFactory, HasUlids; protected $fillable = [ 'name', 'description', - 'type', 'purpose', - 'credits', 'amount', 'currency', 'stripe_product_id', @@ -29,11 +25,6 @@ class CreditProduct extends Model 'sort_order', ]; - public function ledgerEntries(): HasMany - { - return $this->hasMany(CreditLedgerEntry::class); - } - public function paymentTransactions(): HasMany { return $this->hasMany(PaymentTransaction::class); @@ -52,9 +43,7 @@ class CreditProduct extends Model protected function casts(): array { return [ - 'type' => CreditProductType::class, 'purpose' => BillingProductPurpose::class, - 'credits' => 'integer', 'amount' => 'integer', 'is_active' => 'boolean', 'sort_order' => 'integer', diff --git a/app/Models/CreditLedgerEntry.php b/app/Models/CreditLedgerEntry.php deleted file mode 100644 index ad99877..0000000 --- a/app/Models/CreditLedgerEntry.php +++ /dev/null @@ -1,49 +0,0 @@ - */ - 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', - ]; - } -} diff --git a/app/Models/PaymentTransaction.php b/app/Models/PaymentTransaction.php index 9d1f71e..9ee0257 100644 --- a/app/Models/PaymentTransaction.php +++ b/app/Models/PaymentTransaction.php @@ -16,8 +16,7 @@ class PaymentTransaction extends Model protected $fillable = [ 'user_id', - 'credit_product_id', - 'credit_ledger_entry_id', + 'billing_product_id', 'type', 'status', 'stripe_event_id', @@ -30,8 +29,6 @@ class PaymentTransaction extends Model 'stripe_price_id', 'amount', 'currency', - 'credits_expected', - 'credits_granted', 'invoice_url', 'invoice_pdf_url', 'invoice_email_sent_at', @@ -45,14 +42,9 @@ class PaymentTransaction extends Model return $this->belongsTo(User::class); } - public function creditProduct(): BelongsTo + public function billingProduct(): BelongsTo { - return $this->belongsTo(CreditProduct::class); - } - - public function creditLedgerEntry(): BelongsTo - { - return $this->belongsTo(CreditLedgerEntry::class); + return $this->belongsTo(BillingProduct::class); } protected function casts(): array @@ -61,8 +53,6 @@ class PaymentTransaction extends Model 'type' => PaymentTransactionType::class, 'status' => PaymentTransactionStatus::class, 'amount' => 'integer', - 'credits_expected' => 'integer', - 'credits_granted' => 'integer', 'invoice_email_sent_at' => 'datetime', 'payload' => 'array', 'processed_at' => 'datetime', diff --git a/app/Models/User.php b/app/Models/User.php index f7de7ac..196305b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -61,7 +61,6 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale 'suspended_at', 'suspended_by', 'suspended_reason', - 'ai_credits_balance', ]; /** @@ -98,7 +97,6 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale 'date_of_birth' => 'immutable_date', 'suspended_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); } - public function creditLedgerEntries(): HasMany - { - return $this->hasMany(CreditLedgerEntry::class); - } - public function paymentTransactions(): HasMany { return $this->hasMany(PaymentTransaction::class); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 78de90d..32616d1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,7 +3,7 @@ namespace App\Providers; use App\Broadcasting\ExpoPushChannel; -use App\Listeners\GrantCreditsFromStripeWebhook; +use App\Listeners\HandleStripeWebhook; use App\Mail\ResetPasswordMail; use App\Mail\VerifyAccount; use App\Models\User; @@ -60,7 +60,7 @@ class AppServiceProvider extends ServiceProvider 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 { $relativeUrl = URL::temporarySignedRoute( diff --git a/app/Services/AiCreditService.php b/app/Services/AiCreditService.php deleted file mode 100644 index c1ab091..0000000 --- a/app/Services/AiCreditService.php +++ /dev/null @@ -1,142 +0,0 @@ -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|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|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, - ]); - } -} diff --git a/app/Services/PaymentInvoiceEmailer.php b/app/Services/PaymentInvoiceEmailer.php index 45cdf0d..0a8d754 100644 --- a/app/Services/PaymentInvoiceEmailer.php +++ b/app/Services/PaymentInvoiceEmailer.php @@ -25,7 +25,7 @@ class PaymentInvoiceEmailer try { Mail::to($transaction->user->email)->send( - new PaymentInvoiceMail($transaction->user, $transaction->loadMissing('creditProduct')) + new PaymentInvoiceMail($transaction->user, $transaction->loadMissing('billingProduct')) ); } catch (Throwable $exception) { $transaction->forceFill([ diff --git a/app/Services/PaymentTransactionRecorder.php b/app/Services/PaymentTransactionRecorder.php index f00c4a6..d0cab72 100644 --- a/app/Services/PaymentTransactionRecorder.php +++ b/app/Services/PaymentTransactionRecorder.php @@ -4,8 +4,7 @@ namespace App\Services; use App\Enums\PaymentTransactionStatus; use App\Enums\PaymentTransactionType; -use App\Models\CreditLedgerEntry; -use App\Models\CreditProduct; +use App\Models\BillingProduct; use App\Models\PaymentTransaction; use App\Models\User; @@ -16,7 +15,7 @@ class PaymentTransactionRecorder */ public function recordCheckoutSession( ?User $user, - ?CreditProduct $product, + ?BillingProduct $product, string $stripeCheckoutSessionId, PaymentTransactionStatus $status = PaymentTransactionStatus::PENDING, ?string $stripeEventId = null, @@ -25,9 +24,6 @@ class PaymentTransactionRecorder ?string $stripePaymentIntentId = null, ?int $amount = null, ?string $currency = null, - ?int $creditsExpected = null, - ?int $creditsGranted = null, - ?CreditLedgerEntry $ledgerEntry = null, ?string $invoiceUrl = null, ?string $invoicePdfUrl = null, ?string $errorMessage = null, @@ -37,8 +33,7 @@ class PaymentTransactionRecorder 'stripe_checkout_session_id' => $stripeCheckoutSessionId, ], [ 'user_id' => $user?->getKey(), - 'credit_product_id' => $product?->getKey(), - 'credit_ledger_entry_id' => $ledgerEntry?->getKey(), + 'billing_product_id' => $product?->getKey(), 'type' => PaymentTransactionType::CHECKOUT, 'status' => $status, 'stripe_event_id' => $stripeEventId, @@ -47,8 +42,6 @@ class PaymentTransactionRecorder 'stripe_payment_intent_id' => $stripePaymentIntentId, 'amount' => $amount, 'currency' => $currency, - 'credits_expected' => $creditsExpected ?? $product?->credits, - 'credits_granted' => $creditsGranted ?? 0, 'invoice_url' => $invoiceUrl, 'invoice_pdf_url' => $invoicePdfUrl, 'error_message' => $errorMessage, @@ -62,7 +55,7 @@ class PaymentTransactionRecorder */ public function recordInvoice( ?User $user, - ?CreditProduct $product, + ?BillingProduct $product, string $stripeInvoiceId, ?string $stripePriceId, PaymentTransactionStatus $status, @@ -73,9 +66,6 @@ class PaymentTransactionRecorder ?string $stripeSubscriptionId = null, ?int $amount = null, ?string $currency = null, - ?int $creditsExpected = null, - ?int $creditsGranted = null, - ?CreditLedgerEntry $ledgerEntry = null, ?string $invoiceUrl = null, ?string $invoicePdfUrl = null, ?string $errorMessage = null, @@ -86,8 +76,7 @@ class PaymentTransactionRecorder 'stripe_price_id' => $stripePriceId, ], [ 'user_id' => $user?->getKey(), - 'credit_product_id' => $product?->getKey(), - 'credit_ledger_entry_id' => $ledgerEntry?->getKey(), + 'billing_product_id' => $product?->getKey(), 'type' => PaymentTransactionType::SUBSCRIPTION_INVOICE, 'status' => $status, 'stripe_event_id' => $stripeEventId, @@ -97,8 +86,6 @@ class PaymentTransactionRecorder 'stripe_subscription_id' => $stripeSubscriptionId, 'amount' => $amount, 'currency' => $currency, - 'credits_expected' => $creditsExpected ?? $product?->credits, - 'credits_granted' => $creditsGranted ?? 0, 'invoice_url' => $invoiceUrl, 'invoice_pdf_url' => $invoicePdfUrl, 'error_message' => $errorMessage, diff --git a/app/Services/StripeCreditProductSyncer.php b/app/Services/StripeBillingProductSyncer.php similarity index 90% rename from app/Services/StripeCreditProductSyncer.php rename to app/Services/StripeBillingProductSyncer.php index 40436eb..872e7d3 100644 --- a/app/Services/StripeCreditProductSyncer.php +++ b/app/Services/StripeBillingProductSyncer.php @@ -3,12 +3,12 @@ namespace App\Services; use App\Enums\BillingProductPurpose; -use App\Models\CreditProduct; +use App\Models\BillingProduct; use Laravel\Cashier\Cashier; -class StripeCreditProductSyncer +class StripeBillingProductSyncer { - public function sync(CreditProduct $product): CreditProduct + public function sync(BillingProduct $product): BillingProduct { throw_if( $product->purpose === null, @@ -24,7 +24,7 @@ class StripeCreditProductSyncer 'name' => $product->name, 'description' => $product->description, 'metadata' => [ - 'credit_product_id' => $product->getKey(), + 'billing_product_id' => $product->getKey(), 'billing_product_purpose' => $product->purpose?->value, ], ]); @@ -36,7 +36,7 @@ class StripeCreditProductSyncer 'description' => $product->description, 'active' => (bool) $product->is_active, 'metadata' => [ - 'credit_product_id' => $product->getKey(), + 'billing_product_id' => $product->getKey(), 'billing_product_purpose' => $product->purpose?->value, ], ]); @@ -58,7 +58,7 @@ class StripeCreditProductSyncer 'unit_amount' => $product->amount, 'currency' => strtolower($product->currency), 'metadata' => [ - 'credit_product_id' => $product->getKey(), + 'billing_product_id' => $product->getKey(), 'billing_product_purpose' => $product->purpose?->value, ], ]; diff --git a/database/migrations/2026_05_27_100000_create_credit_products_and_ledger_entries.php b/database/migrations/2026_05_27_100000_create_credit_products_and_ledger_entries.php index 6b48ed4..110ca74 100644 --- a/database/migrations/2026_05_27_100000_create_credit_products_and_ledger_entries.php +++ b/database/migrations/2026_05_27_100000_create_credit_products_and_ledger_entries.php @@ -8,16 +8,11 @@ return new class extends Migration { public function up(): void { - Schema::table('users', function (Blueprint $table): void { - $table->unsignedInteger('ai_credits_balance')->default(3)->after('trial_ends_at'); - }); - - Schema::create('credit_products', function (Blueprint $table): void { + Schema::create('billing_products', function (Blueprint $table): void { $table->ulid('id')->primary(); $table->string('name'); $table->text('description')->nullable(); - $table->string('type', 32); - $table->unsignedInteger('credits'); + $table->string('purpose', 32)->unique(); $table->unsignedInteger('amount'); $table->string('currency', 3)->default('eur'); $table->string('stripe_product_id')->nullable()->index(); @@ -26,36 +21,12 @@ return new class extends Migration $table->unsignedInteger('sort_order')->default(0); $table->timestamps(); - $table->index(['type', 'is_active', 'sort_order']); - }); - - Schema::create('credit_ledger_entries', function (Blueprint $table): void { - $table->ulid('id')->primary(); - $table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete(); - $table->foreignUlid('credit_product_id')->nullable()->constrained('credit_products')->nullOnDelete(); - $table->string('type', 64); - $table->integer('credits'); - $table->unsignedInteger('balance_after'); - $table->string('source')->nullable()->unique(); - $table->string('stripe_checkout_session_id')->nullable()->index(); - $table->string('stripe_invoice_id')->nullable()->index(); - $table->string('stripe_payment_intent_id')->nullable()->index(); - $table->string('stripe_subscription_id')->nullable()->index(); - $table->json('metadata')->nullable(); - $table->timestamps(); - - $table->index(['user_id', 'created_at']); - $table->index(['user_id', 'type', 'created_at']); + $table->index(['purpose', 'is_active', 'sort_order']); }); } public function down(): void { - Schema::dropIfExists('credit_ledger_entries'); - Schema::dropIfExists('credit_products'); - - Schema::table('users', function (Blueprint $table): void { - $table->dropColumn('ai_credits_balance'); - }); + Schema::dropIfExists('billing_products'); } }; diff --git a/database/migrations/2026_05_27_110000_create_payment_transactions_table.php b/database/migrations/2026_05_27_110000_create_payment_transactions_table.php index 9006412..ca6528a 100644 --- a/database/migrations/2026_05_27_110000_create_payment_transactions_table.php +++ b/database/migrations/2026_05_27_110000_create_payment_transactions_table.php @@ -11,8 +11,7 @@ return new class extends Migration Schema::create('payment_transactions', function (Blueprint $table): void { $table->ulid('id')->primary(); $table->foreignUlid('user_id')->nullable()->constrained('users')->nullOnDelete(); - $table->foreignUlid('credit_product_id')->nullable()->constrained('credit_products')->nullOnDelete(); - $table->foreignUlid('credit_ledger_entry_id')->nullable()->constrained('credit_ledger_entries')->nullOnDelete(); + $table->foreignUlid('billing_product_id')->nullable()->constrained('billing_products')->nullOnDelete(); $table->string('type', 64); $table->string('status', 64); $table->string('stripe_event_id')->nullable()->index(); @@ -25,8 +24,6 @@ return new class extends Migration $table->string('stripe_price_id')->nullable()->index(); $table->unsignedInteger('amount')->nullable(); $table->string('currency', 3)->nullable(); - $table->unsignedInteger('credits_expected')->nullable(); - $table->unsignedInteger('credits_granted')->default(0); $table->text('error_message')->nullable(); $table->json('payload')->nullable(); $table->timestamp('processed_at')->nullable(); diff --git a/database/migrations/2026_08_07_092018_add_purpose_to_credit_products_table.php b/database/migrations/2026_08_07_092018_add_purpose_to_credit_products_table.php index b22b26f..4f4fbe9 100644 --- a/database/migrations/2026_08_07_092018_add_purpose_to_credit_products_table.php +++ b/database/migrations/2026_08_07_092018_add_purpose_to_credit_products_table.php @@ -11,9 +11,11 @@ return new class extends Migration */ public function up(): void { - Schema::table('credit_products', function (Blueprint $table): void { - $table->string('purpose', 32)->nullable()->after('type')->unique(); - }); + if (Schema::hasTable('credit_products') && ! Schema::hasColumn('credit_products', 'purpose')) { + 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 { - Schema::table('credit_products', function (Blueprint $table): void { - $table->dropColumn('purpose'); - }); + if (Schema::hasTable('credit_products') && Schema::hasColumn('credit_products', 'purpose')) { + Schema::table('credit_products', function (Blueprint $table): void { + $table->dropColumn('purpose'); + }); + } } }; diff --git a/database/migrations/2026_08_07_130000_normalize_billing_domain.php b/database/migrations/2026_08_07_130000_normalize_billing_domain.php new file mode 100644 index 0000000..9c969fd --- /dev/null +++ b/database/migrations/2026_08_07_130000_normalize_billing_domain.php @@ -0,0 +1,125 @@ +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); + }); + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index 325edff..b47d5fc 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -69,7 +69,7 @@ return [ 'empty' => '-', ], ], - 'credit_products' => [ + 'billing_products' => [ 'navigation' => [ 'label' => 'Offers', 'singular' => 'Offer', @@ -82,9 +82,7 @@ return [ 'fields' => [ 'name' => 'Name', 'description' => 'Description', - 'type' => 'Type', 'purpose' => 'Offer', - 'credits' => 'Credits', 'amount' => 'Price', 'currency' => 'Currency', 'stripe_product_id' => 'Stripe product ID', @@ -145,12 +143,10 @@ return [ 'fields' => [ 'id' => 'ID', 'user' => 'User', - 'credit_product' => 'Product', + 'billing_product' => 'Product', 'type' => 'Type', 'status' => 'Status', 'amount' => 'Amount', - 'credits_expected' => 'Expected credits', - 'credits_granted' => 'Granted credits', 'invoice_url' => 'Invoice URL', 'invoice_pdf_url' => 'Invoice PDF', 'invoice_email_sent_at' => 'Invoice email sent at', diff --git a/lang/en/enums.php b/lang/en/enums.php index 28f6d0e..fbd44ac 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -49,10 +49,6 @@ return [ 'privacy_policy' => 'Privacy policy', 'terms' => 'Terms of service', ], - 'credit_product_type' => [ - 'monthly' => 'Monthly', - 'one_time' => 'Credit pack', - ], 'billing_product_purpose' => [ 'subscription' => 'Analysis subscription', 'certification' => 'Lifetime certification', @@ -62,18 +58,9 @@ return [ 'approved' => 'Approved', 'rejected' => 'Rejected', ], - 'credit_ledger_entry_type' => [ - 'free_grant' => 'Free credits', - 'purchase' => 'Purchase', - 'subscription_renewal' => 'Renewal', - 'usage' => 'Usage', - 'refund' => 'Refund', - 'adjustment' => 'Adjustment', - ], 'payment_transaction_status' => [ 'pending' => 'Pending', 'paid' => 'Paid', - 'credited' => 'Credited', 'failed' => 'Failed', 'ignored' => 'Ignored', 'error' => 'Error', diff --git a/lang/en/mail.php b/lang/en/mail.php index 4699924..0bf8181 100644 --- a/lang/en/mail.php +++ b/lang/en/mail.php @@ -40,8 +40,7 @@ return [ 'default_name' => 'there', 'intro' => 'Thanks for your payment. Your summary and invoice link are below.', 'product' => 'Product', - 'default_product' => 'Bowli credits', - 'credits' => 'Credits', + 'default_product' => 'Bowli purchase', 'amount' => 'Amount', 'unknown_amount' => 'Amount unavailable', 'action' => 'View my invoice', diff --git a/lang/fr/admin.php b/lang/fr/admin.php index bde332b..bafdced 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -69,7 +69,7 @@ return [ 'empty' => '-', ], ], - 'credit_products' => [ + 'billing_products' => [ 'navigation' => [ 'label' => 'Offres', 'singular' => 'Offre', @@ -82,9 +82,7 @@ return [ 'fields' => [ 'name' => 'Nom', 'description' => 'Description', - 'type' => 'Type', 'purpose' => 'Offre', - 'credits' => 'Crédits', 'amount' => 'Prix', 'currency' => 'Devise', 'stripe_product_id' => 'Stripe product ID', @@ -145,12 +143,10 @@ return [ 'fields' => [ 'id' => 'ID', 'user' => 'Utilisateur', - 'credit_product' => 'Produit', + 'billing_product' => 'Produit', 'type' => 'Type', 'status' => 'Statut', 'amount' => 'Montant', - 'credits_expected' => 'Crédits attendus', - 'credits_granted' => 'Crédits crédités', 'invoice_url' => 'URL facture', 'invoice_pdf_url' => 'PDF facture', 'invoice_email_sent_at' => 'Email facture envoyé le', diff --git a/lang/fr/enums.php b/lang/fr/enums.php index 751e25a..c2c1640 100644 --- a/lang/fr/enums.php +++ b/lang/fr/enums.php @@ -49,10 +49,6 @@ return [ 'privacy_policy' => 'Politique de confidentialité', 'terms' => 'Conditions d’utilisation', ], - 'credit_product_type' => [ - 'monthly' => 'Mensuel', - 'one_time' => 'Pack de crédits', - ], 'billing_product_purpose' => [ 'subscription' => 'Abonnement analyse', 'certification' => 'Certification à vie', @@ -62,18 +58,9 @@ return [ 'approved' => 'Approuvé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' => [ 'pending' => 'En attente', 'paid' => 'Payé', - 'credited' => 'Crédité', 'failed' => 'Échec', 'ignored' => 'Ignoré', 'error' => 'Erreur', diff --git a/lang/fr/mail.php b/lang/fr/mail.php index a45d6f0..fc9e5ea 100644 --- a/lang/fr/mail.php +++ b/lang/fr/mail.php @@ -40,8 +40,7 @@ return [ 'default_name' => 'à toi', 'intro' => 'Merci pour ton paiement. Tu trouveras ci-dessous le récapitulatif et le lien vers ta facture.', 'product' => 'Produit', - 'default_product' => 'Crédits Bowli', - 'credits' => 'Crédits', + 'default_product' => 'Achat Bowli', 'amount' => 'Montant', 'unknown_amount' => 'Montant indisponible', 'action' => 'Voir ma facture', diff --git a/resources/views/emails/payment-invoice.blade.php b/resources/views/emails/payment-invoice.blade.php index 5af6e5e..45e0ec3 100644 --- a/resources/views/emails/payment-invoice.blade.php +++ b/resources/views/emails/payment-invoice.blade.php @@ -37,14 +37,6 @@ {{ $productName ?: trans('mail.payment_invoice.default_product', [], $locale) }} - - - {{ trans('mail.payment_invoice.credits', [], $locale) }} - - - {{ $credits }} - - {{ trans('mail.payment_invoice.amount', [], $locale) }} diff --git a/routes/api.php b/routes/api.php index 7491dac..2aa9b01 100644 --- a/routes/api.php +++ b/routes/api.php @@ -62,7 +62,7 @@ Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function // Billing Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->prefix('billing')->group(function (): void { Route::get('products', [BillingController::class, 'products'])->name('billing.products'); - Route::post('products/{creditProduct}/checkout', [BillingController::class, 'checkout'])->middleware('throttle:account-action')->name('billing.checkout'); + Route::post('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'); }); diff --git a/tests/Feature/BillingProductsTest.php b/tests/Feature/BillingProductsTest.php index a2250c2..b83d077 100644 --- a/tests/Feature/BillingProductsTest.php +++ b/tests/Feature/BillingProductsTest.php @@ -1,8 +1,7 @@ create()); - CreditProduct::create([ + BillingProduct::create([ 'name' => 'Certification', - 'type' => CreditProductType::ONE_TIME, 'purpose' => BillingProductPurpose::CERTIFICATION, - 'credits' => 0, 'amount' => 1999, 'currency' => 'eur', 'stripe_price_id' => 'price_certification', 'sort_order' => 20, ]); - CreditProduct::create([ + BillingProduct::create([ 'name' => 'Bowli Plus', - 'type' => CreditProductType::MONTHLY, 'purpose' => BillingProductPurpose::SUBSCRIPTION, - 'credits' => 0, 'amount' => 999, 'currency' => 'eur', 'stripe_price_id' => 'price_subscription', 'sort_order' => 10, ]); - CreditProduct::create([ - 'name' => 'Ancien pack', - 'type' => CreditProductType::ONE_TIME, - 'credits' => 5, - 'amount' => 299, - 'currency' => 'eur', - 'stripe_price_id' => 'price_legacy', - ]); - $this ->getJson('/api/billing/products') ->assertOk() diff --git a/tests/Feature/CertificationTest.php b/tests/Feature/CertificationTest.php index 4fcf08e..1d94ad7 100644 --- a/tests/Feature/CertificationTest.php +++ b/tests/Feature/CertificationTest.php @@ -2,13 +2,12 @@ use App\Actions\ReviewIdentityVerification; use App\Enums\BillingProductPurpose; -use App\Enums\CreditProductType; use App\Enums\IdentityVerificationStatus; use App\Enums\PaymentTransactionStatus; use App\Enums\UserRole; use App\Filament\Resources\IdentityVerificationRequests\Pages\ListIdentityVerificationRequests; -use App\Listeners\GrantCreditsFromStripeWebhook; -use App\Models\CreditProduct; +use App\Listeners\HandleStripeWebhook; +use App\Models\BillingProduct; use App\Models\IdentityVerificationRequest; use App\Models\PaymentTransaction; use App\Models\User; @@ -173,11 +172,9 @@ it('grants lifetime certification entitlement from the paid Stripe webhook once' $user = User::factory()->create([ 'stripe_id' => 'cus_certification', ]); - $product = CreditProduct::create([ + $product = BillingProduct::create([ 'name' => 'Certification', - 'type' => CreditProductType::ONE_TIME, 'purpose' => BillingProductPurpose::CERTIFICATION, - 'credits' => 0, 'amount' => 1999, 'currency' => 'eur', 'stripe_price_id' => 'price_certification', @@ -196,7 +193,7 @@ it('grants lifetime certification entitlement from the paid Stripe webhook once' 'amount_total' => 1999, 'currency' => 'eur', 'metadata' => [ - 'credit_product_id' => $product->getKey(), + 'billing_product_id' => $product->getKey(), '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); $firstPurchaseDate = $user->fresh()->certification_purchased_at; $listener->handle($event); diff --git a/tests/Feature/PaymentInvoiceMailTest.php b/tests/Feature/PaymentInvoiceMailTest.php new file mode 100644 index 0000000..a9d159e --- /dev/null +++ b/tests/Feature/PaymentInvoiceMailTest.php @@ -0,0 +1,45 @@ +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'); +});