diff --git a/app/Filament/Resources/PaymentTransactions/Schemas/PaymentTransactionInfolist.php b/app/Filament/Resources/PaymentTransactions/Schemas/PaymentTransactionInfolist.php index d2ae6a2..7d68473 100644 --- a/app/Filament/Resources/PaymentTransactions/Schemas/PaymentTransactionInfolist.php +++ b/app/Filament/Resources/PaymentTransactions/Schemas/PaymentTransactionInfolist.php @@ -44,6 +44,10 @@ class PaymentTransactionInfolist ->label(__('admin.payment_transactions.fields.processed_at')) ->dateTime() ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('invoice_email_sent_at') + ->label(__('admin.payment_transactions.fields.invoice_email_sent_at')) + ->dateTime() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), ]), Section::make(__('admin.payment_transactions.sections.stripe')) ->columns(2) @@ -79,6 +83,18 @@ class PaymentTransactionInfolist ->label(__('admin.payment_transactions.fields.stripe_price_id')) ->copyable() ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('invoice_url') + ->label(__('admin.payment_transactions.fields.invoice_url')) + ->copyable() + ->url(fn ($state): ?string => $state) + ->openUrlInNewTab() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('invoice_pdf_url') + ->label(__('admin.payment_transactions.fields.invoice_pdf_url')) + ->copyable() + ->url(fn ($state): ?string => $state) + ->openUrlInNewTab() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), ]), Section::make(__('admin.payment_transactions.sections.error')) ->schema([ diff --git a/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php b/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php index d77774c..db47879 100644 --- a/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php +++ b/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php @@ -72,6 +72,11 @@ class PaymentTransactionsTable ->label(__('admin.payment_transactions.fields.error_message')) ->limit(60) ->toggleable(), + TextColumn::make('invoice_email_sent_at') + ->label(__('admin.payment_transactions.fields.invoice_email_sent_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ SelectFilter::make('status') diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php index 7120b72..edb6388 100644 --- a/app/Http/Controllers/BillingController.php +++ b/app/Http/Controllers/BillingController.php @@ -51,11 +51,24 @@ class BillingController extends Controller ]; if ($creditProduct->type === CreditProductType::MONTHLY) { + if ($user->subscribed('default')) { + return response()->json([ + 'message' => __('api.billing.active_subscription_exists'), + ], 409); + } + $checkout = $user ->newSubscription('default', $creditProduct->stripe_price_id) ->withMetadata($metadata) ->checkout($sessionOptions); } else { + $sessionOptions['invoice_creation'] = [ + 'enabled' => true, + 'invoice_data' => [ + 'metadata' => $metadata, + ], + ]; + $checkout = $user->checkout([ $creditProduct->stripe_price_id => 1, ], $sessionOptions); @@ -83,6 +96,12 @@ class BillingController extends Controller $user = $request->user(); $this->ensureStripeCustomer($user); + if (! $user->subscribed('default')) { + return response()->json([ + 'message' => __('api.billing.no_active_subscription'), + ], 409); + } + $portalUrl = $user->billingPortalUrl($this->redirectUrl('portal')); return response()->json([ diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 0907893..7153565 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -28,6 +28,7 @@ class UserResource extends JsonResource 'role' => $this->role, 'accountVerified' => $this->account_verified_at !== null, 'aiCreditsBalance' => (int) $this->ai_credits_balance, + 'hasActiveSubscription' => $this->subscribed('default'), 'suspendedAt' => $this->suspended_at?->toISOString(), 'physicalActivityLevel' => $this->physical_activity_level, 'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(), diff --git a/app/Listeners/GrantCreditsFromStripeWebhook.php b/app/Listeners/GrantCreditsFromStripeWebhook.php index 869dc1b..f065979 100644 --- a/app/Listeners/GrantCreditsFromStripeWebhook.php +++ b/app/Listeners/GrantCreditsFromStripeWebhook.php @@ -8,12 +8,15 @@ use App\Models\CreditProduct; use App\Models\CreditLedgerEntry; use App\Models\User; use App\Services\AiCreditService; +use App\Services\PaymentInvoiceEmailer; use App\Services\PaymentTransactionRecorder; +use Laravel\Cashier\Cashier; class GrantCreditsFromStripeWebhook { public function __construct( private AiCreditService $credits, + private PaymentInvoiceEmailer $invoiceEmails, private PaymentTransactionRecorder $transactions, ) {} @@ -106,8 +109,9 @@ class GrantCreditsFromStripeWebhook ); $entry ??= CreditLedgerEntry::query()->where('source', $source)->first(); + $invoice = $this->retrieveInvoice($this->stringValue($session['invoice'] ?? null)); - $this->transactions->recordCheckoutSession( + $transaction = $this->transactions->recordCheckoutSession( user: $user, product: $product, stripeCheckoutSessionId: $sessionId, @@ -120,8 +124,12 @@ class GrantCreditsFromStripeWebhook currency: $this->stringValue($session['currency'] ?? null), creditsGranted: $entry ? $product->credits : 0, ledgerEntry: $entry, + invoiceUrl: $invoice ? $this->stringValue($invoice['hosted_invoice_url'] ?? null) : null, + invoicePdfUrl: $invoice ? $this->stringValue($invoice['invoice_pdf'] ?? null) : null, payload: $session, ); + + $this->invoiceEmails->sendIfAvailable($transaction); } /** @@ -243,7 +251,7 @@ class GrantCreditsFromStripeWebhook $entry ??= CreditLedgerEntry::query()->where('source', $source)->first(); - $this->transactions->recordInvoice( + $transaction = $this->transactions->recordInvoice( user: $user, product: $product, stripeInvoiceId: $invoiceId, @@ -258,8 +266,12 @@ class GrantCreditsFromStripeWebhook currency: $this->stringValue($invoice['currency'] ?? null), creditsGranted: $entry ? $product->credits : 0, ledgerEntry: $entry, + invoiceUrl: $this->stringValue($invoice['hosted_invoice_url'] ?? null), + invoicePdfUrl: $this->stringValue($invoice['invoice_pdf'] ?? null), payload: $invoice, ); + + $this->invoiceEmails->sendIfAvailable($transaction); } if (! $matchedLine) { @@ -352,4 +364,23 @@ class GrantCreditsFromStripeWebhook { return is_numeric($value) ? (int) $value : null; } + + /** + * @return array|null + */ + private function retrieveInvoice(?string $invoiceId): ?array + { + if (! $invoiceId) { + return null; + } + + try { + return Cashier::stripe() + ->invoices + ->retrieve($invoiceId) + ->toArray(); + } catch (\Throwable) { + return null; + } + } } diff --git a/app/Mail/PaymentInvoiceMail.php b/app/Mail/PaymentInvoiceMail.php new file mode 100644 index 0000000..e5a0ca7 --- /dev/null +++ b/app/Mail/PaymentInvoiceMail.php @@ -0,0 +1,65 @@ +mailLocale()), + ); + } + + public function content(): Content + { + return new Content( + 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, + 'userName' => $this->user->name, + ], + ); + } + + public function attachments(): array + { + return []; + } + + private function formattedAmount(): string + { + if ($this->transaction->amount === null) { + return trans('mail.payment_invoice.unknown_amount', [], $this->mailLocale()); + } + + return number_format($this->transaction->amount / 100, 2, ',', ' ') + .' ' + .strtoupper($this->transaction->currency ?: 'eur'); + } + + private function mailLocale(): string + { + return $this->user->preferredLocale() ?? 'en'; + } +} diff --git a/app/Models/PaymentTransaction.php b/app/Models/PaymentTransaction.php index 8ff2072..9d1f71e 100644 --- a/app/Models/PaymentTransaction.php +++ b/app/Models/PaymentTransaction.php @@ -32,6 +32,9 @@ class PaymentTransaction extends Model 'currency', 'credits_expected', 'credits_granted', + 'invoice_url', + 'invoice_pdf_url', + 'invoice_email_sent_at', 'error_message', 'payload', 'processed_at', @@ -60,6 +63,7 @@ class PaymentTransaction extends Model 'amount' => 'integer', 'credits_expected' => 'integer', 'credits_granted' => 'integer', + 'invoice_email_sent_at' => 'datetime', 'payload' => 'array', 'processed_at' => 'datetime', ]; diff --git a/app/Services/PaymentInvoiceEmailer.php b/app/Services/PaymentInvoiceEmailer.php new file mode 100644 index 0000000..45cdf0d --- /dev/null +++ b/app/Services/PaymentInvoiceEmailer.php @@ -0,0 +1,42 @@ +invoice_email_sent_at !== null) { + return; + } + + if (! $transaction->user || ! $transaction->user->email) { + return; + } + + if (! $transaction->invoice_url && ! $transaction->invoice_pdf_url) { + return; + } + + try { + Mail::to($transaction->user->email)->send( + new PaymentInvoiceMail($transaction->user, $transaction->loadMissing('creditProduct')) + ); + } catch (Throwable $exception) { + $transaction->forceFill([ + 'error_message' => 'Invoice email failed: '.$exception->getMessage(), + ])->save(); + + return; + } + + $transaction->forceFill([ + 'invoice_email_sent_at' => now(), + ])->save(); + } +} diff --git a/app/Services/PaymentTransactionRecorder.php b/app/Services/PaymentTransactionRecorder.php index 9fac269..f00c4a6 100644 --- a/app/Services/PaymentTransactionRecorder.php +++ b/app/Services/PaymentTransactionRecorder.php @@ -28,6 +28,8 @@ class PaymentTransactionRecorder ?int $creditsExpected = null, ?int $creditsGranted = null, ?CreditLedgerEntry $ledgerEntry = null, + ?string $invoiceUrl = null, + ?string $invoicePdfUrl = null, ?string $errorMessage = null, ?array $payload = null, ): PaymentTransaction { @@ -47,6 +49,8 @@ class PaymentTransactionRecorder 'currency' => $currency, 'credits_expected' => $creditsExpected ?? $product?->credits, 'credits_granted' => $creditsGranted ?? 0, + 'invoice_url' => $invoiceUrl, + 'invoice_pdf_url' => $invoicePdfUrl, 'error_message' => $errorMessage, 'payload' => $payload, 'processed_at' => $status === PaymentTransactionStatus::PENDING ? null : now(), @@ -72,6 +76,8 @@ class PaymentTransactionRecorder ?int $creditsExpected = null, ?int $creditsGranted = null, ?CreditLedgerEntry $ledgerEntry = null, + ?string $invoiceUrl = null, + ?string $invoicePdfUrl = null, ?string $errorMessage = null, ?array $payload = null, ): PaymentTransaction { @@ -93,6 +99,8 @@ class PaymentTransactionRecorder 'currency' => $currency, 'credits_expected' => $creditsExpected ?? $product?->credits, 'credits_granted' => $creditsGranted ?? 0, + 'invoice_url' => $invoiceUrl, + 'invoice_pdf_url' => $invoicePdfUrl, 'error_message' => $errorMessage, 'payload' => $payload, 'processed_at' => now(), diff --git a/database/migrations/2026_05_27_120000_add_invoice_fields_to_payment_transactions_table.php b/database/migrations/2026_05_27_120000_add_invoice_fields_to_payment_transactions_table.php new file mode 100644 index 0000000..0140cda --- /dev/null +++ b/database/migrations/2026_05_27_120000_add_invoice_fields_to_payment_transactions_table.php @@ -0,0 +1,28 @@ +string('invoice_url', 2048)->nullable(); + $table->string('invoice_pdf_url', 2048)->nullable(); + $table->timestamp('invoice_email_sent_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('payment_transactions', function (Blueprint $table): void { + $table->dropColumn([ + 'invoice_url', + 'invoice_pdf_url', + 'invoice_email_sent_at', + ]); + }); + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index ca55041..075a74e 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -119,6 +119,9 @@ return [ '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', 'stripe_event_id' => 'Stripe event ID', 'stripe_event_type' => 'Event', 'stripe_customer_id' => 'Stripe customer ID', diff --git a/lang/en/api.php b/lang/en/api.php index 4cae823..f9edfc6 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -31,6 +31,10 @@ return [ 'legal_documents' => [ 'not_found' => 'Legal document not found.', ], + 'billing' => [ + 'active_subscription_exists' => 'You already have an active subscription.', + 'no_active_subscription' => 'There is no active subscription to manage.', + ], 'notifications' => [ 'test_title' => 'Test', 'test_body' => 'Test notification from the Laravel API.', diff --git a/lang/en/mail.php b/lang/en/mail.php index f1d76c4..4699924 100644 --- a/lang/en/mail.php +++ b/lang/en/mail.php @@ -33,4 +33,19 @@ return [ 'password_confirmation' => 'Confirm password', 'submit' => 'Update password', ], + 'payment_invoice' => [ + 'subject' => 'Your Bowli invoice', + 'title' => 'Your invoice is available', + 'greeting' => 'Hi :name,', + 'default_name' => 'there', + 'intro' => 'Thanks for your payment. Your summary and invoice link are below.', + 'product' => 'Product', + 'default_product' => 'Bowli credits', + 'credits' => 'Credits', + 'amount' => 'Amount', + 'unknown_amount' => 'Amount unavailable', + 'action' => 'View my invoice', + 'pdf_link' => 'PDF link:', + 'footer' => 'If you have any question, simply reply to this email.', + ], ]; diff --git a/lang/fr/admin.php b/lang/fr/admin.php index c10c40e..39b115f 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -119,6 +119,9 @@ return [ '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', 'stripe_event_id' => 'Stripe event ID', 'stripe_event_type' => 'Événement', 'stripe_customer_id' => 'Stripe customer ID', diff --git a/lang/fr/api.php b/lang/fr/api.php index d199cde..b549355 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -31,6 +31,10 @@ return [ 'legal_documents' => [ 'not_found' => 'Document légal introuvable.', ], + 'billing' => [ + 'active_subscription_exists' => 'Tu as déjà un abonnement en cours.', + 'no_active_subscription' => 'Aucun abonnement actif à gérer.', + ], 'notifications' => [ 'test_title' => 'Test', 'test_body' => 'Notification test depuis Laravel API.', diff --git a/lang/fr/mail.php b/lang/fr/mail.php index 6ca7e45..a45d6f0 100644 --- a/lang/fr/mail.php +++ b/lang/fr/mail.php @@ -33,4 +33,19 @@ return [ 'password_confirmation' => 'Confirmer le mot de passe', 'submit' => 'Modifier le mot de passe', ], + 'payment_invoice' => [ + 'subject' => 'Ta facture Bowli', + 'title' => 'Ta facture est disponible', + 'greeting' => 'Bonjour :name,', + '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', + 'amount' => 'Montant', + 'unknown_amount' => 'Montant indisponible', + 'action' => 'Voir ma facture', + 'pdf_link' => 'Lien PDF :', + 'footer' => 'Si tu as une question, réponds simplement à cet email.', + ], ]; diff --git a/resources/views/emails/payment-invoice.blade.php b/resources/views/emails/payment-invoice.blade.php new file mode 100644 index 0000000..5af6e5e --- /dev/null +++ b/resources/views/emails/payment-invoice.blade.php @@ -0,0 +1,87 @@ + + + + + + {{ trans('mail.payment_invoice.subject', [], $locale) }} + + + + + + +
+ + + + +
+

+ Bowli +

+ +

+ {{ trans('mail.payment_invoice.title', [], $locale) }} +

+ +

+ {{ trans('mail.payment_invoice.greeting', ['name' => $userName ?: trans('mail.payment_invoice.default_name', [], $locale)], $locale) }} +

+ +

+ {{ trans('mail.payment_invoice.intro', [], $locale) }} +

+ + + + + + + + + + + + + + +
+ {{ trans('mail.payment_invoice.product', [], $locale) }} + + {{ $productName ?: trans('mail.payment_invoice.default_product', [], $locale) }} +
+ {{ trans('mail.payment_invoice.credits', [], $locale) }} + + {{ $credits }} +
+ {{ trans('mail.payment_invoice.amount', [], $locale) }} + + {{ $amount }} +
+ + @if ($invoiceUrl) + + + + +
+ + {{ trans('mail.payment_invoice.action', [], $locale) }} + +
+ @endif + + @if ($invoicePdfUrl) +

+ {{ trans('mail.payment_invoice.pdf_link', [], $locale) }} + {{ $invoicePdfUrl }} +

+ @endif + +

+ {{ trans('mail.payment_invoice.footer', [], $locale) }} +

+
+
+ +