feat: add invoice
Laravel CI-CD / Tests Unitaires (push) Successful in 1m31s Details
Laravel CI-CD / Deploy with Kamal (push) Successful in 1m58s Details

This commit is contained in:
Leon Morival 2026-05-27 13:14:09 +02:00
parent a01387bb09
commit 1216c8a073
17 changed files with 352 additions and 2 deletions

View File

@ -44,6 +44,10 @@ class PaymentTransactionInfolist
->label(__('admin.payment_transactions.fields.processed_at')) ->label(__('admin.payment_transactions.fields.processed_at'))
->dateTime() ->dateTime()
->placeholder(__('admin.payment_transactions.placeholders.empty')), ->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')) Section::make(__('admin.payment_transactions.sections.stripe'))
->columns(2) ->columns(2)
@ -79,6 +83,18 @@ class PaymentTransactionInfolist
->label(__('admin.payment_transactions.fields.stripe_price_id')) ->label(__('admin.payment_transactions.fields.stripe_price_id'))
->copyable() ->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')), ->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')) Section::make(__('admin.payment_transactions.sections.error'))
->schema([ ->schema([

View File

@ -72,6 +72,11 @@ class PaymentTransactionsTable
->label(__('admin.payment_transactions.fields.error_message')) ->label(__('admin.payment_transactions.fields.error_message'))
->limit(60) ->limit(60)
->toggleable(), ->toggleable(),
TextColumn::make('invoice_email_sent_at')
->label(__('admin.payment_transactions.fields.invoice_email_sent_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters([ ->filters([
SelectFilter::make('status') SelectFilter::make('status')

View File

@ -51,11 +51,24 @@ class BillingController extends Controller
]; ];
if ($creditProduct->type === CreditProductType::MONTHLY) { if ($creditProduct->type === CreditProductType::MONTHLY) {
if ($user->subscribed('default')) {
return response()->json([
'message' => __('api.billing.active_subscription_exists'),
], 409);
}
$checkout = $user $checkout = $user
->newSubscription('default', $creditProduct->stripe_price_id) ->newSubscription('default', $creditProduct->stripe_price_id)
->withMetadata($metadata) ->withMetadata($metadata)
->checkout($sessionOptions); ->checkout($sessionOptions);
} else { } else {
$sessionOptions['invoice_creation'] = [
'enabled' => true,
'invoice_data' => [
'metadata' => $metadata,
],
];
$checkout = $user->checkout([ $checkout = $user->checkout([
$creditProduct->stripe_price_id => 1, $creditProduct->stripe_price_id => 1,
], $sessionOptions); ], $sessionOptions);
@ -83,6 +96,12 @@ class BillingController extends Controller
$user = $request->user(); $user = $request->user();
$this->ensureStripeCustomer($user); $this->ensureStripeCustomer($user);
if (! $user->subscribed('default')) {
return response()->json([
'message' => __('api.billing.no_active_subscription'),
], 409);
}
$portalUrl = $user->billingPortalUrl($this->redirectUrl('portal')); $portalUrl = $user->billingPortalUrl($this->redirectUrl('portal'));
return response()->json([ return response()->json([

View File

@ -28,6 +28,7 @@ class UserResource extends JsonResource
'role' => $this->role, 'role' => $this->role,
'accountVerified' => $this->account_verified_at !== null, 'accountVerified' => $this->account_verified_at !== null,
'aiCreditsBalance' => (int) $this->ai_credits_balance, 'aiCreditsBalance' => (int) $this->ai_credits_balance,
'hasActiveSubscription' => $this->subscribed('default'),
'suspendedAt' => $this->suspended_at?->toISOString(), 'suspendedAt' => $this->suspended_at?->toISOString(),
'physicalActivityLevel' => $this->physical_activity_level, 'physicalActivityLevel' => $this->physical_activity_level,
'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(), 'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(),

View File

@ -8,12 +8,15 @@ use App\Models\CreditProduct;
use App\Models\CreditLedgerEntry; use App\Models\CreditLedgerEntry;
use App\Models\User; use App\Models\User;
use App\Services\AiCreditService; use App\Services\AiCreditService;
use App\Services\PaymentInvoiceEmailer;
use App\Services\PaymentTransactionRecorder; use App\Services\PaymentTransactionRecorder;
use Laravel\Cashier\Cashier;
class GrantCreditsFromStripeWebhook class GrantCreditsFromStripeWebhook
{ {
public function __construct( public function __construct(
private AiCreditService $credits, private AiCreditService $credits,
private PaymentInvoiceEmailer $invoiceEmails,
private PaymentTransactionRecorder $transactions, private PaymentTransactionRecorder $transactions,
) {} ) {}
@ -106,8 +109,9 @@ class GrantCreditsFromStripeWebhook
); );
$entry ??= CreditLedgerEntry::query()->where('source', $source)->first(); $entry ??= CreditLedgerEntry::query()->where('source', $source)->first();
$invoice = $this->retrieveInvoice($this->stringValue($session['invoice'] ?? null));
$this->transactions->recordCheckoutSession( $transaction = $this->transactions->recordCheckoutSession(
user: $user, user: $user,
product: $product, product: $product,
stripeCheckoutSessionId: $sessionId, stripeCheckoutSessionId: $sessionId,
@ -120,8 +124,12 @@ class GrantCreditsFromStripeWebhook
currency: $this->stringValue($session['currency'] ?? null), currency: $this->stringValue($session['currency'] ?? null),
creditsGranted: $entry ? $product->credits : 0, creditsGranted: $entry ? $product->credits : 0,
ledgerEntry: $entry, ledgerEntry: $entry,
invoiceUrl: $invoice ? $this->stringValue($invoice['hosted_invoice_url'] ?? null) : null,
invoicePdfUrl: $invoice ? $this->stringValue($invoice['invoice_pdf'] ?? null) : null,
payload: $session, payload: $session,
); );
$this->invoiceEmails->sendIfAvailable($transaction);
} }
/** /**
@ -243,7 +251,7 @@ class GrantCreditsFromStripeWebhook
$entry ??= CreditLedgerEntry::query()->where('source', $source)->first(); $entry ??= CreditLedgerEntry::query()->where('source', $source)->first();
$this->transactions->recordInvoice( $transaction = $this->transactions->recordInvoice(
user: $user, user: $user,
product: $product, product: $product,
stripeInvoiceId: $invoiceId, stripeInvoiceId: $invoiceId,
@ -258,8 +266,12 @@ class GrantCreditsFromStripeWebhook
currency: $this->stringValue($invoice['currency'] ?? null), currency: $this->stringValue($invoice['currency'] ?? null),
creditsGranted: $entry ? $product->credits : 0, creditsGranted: $entry ? $product->credits : 0,
ledgerEntry: $entry, ledgerEntry: $entry,
invoiceUrl: $this->stringValue($invoice['hosted_invoice_url'] ?? null),
invoicePdfUrl: $this->stringValue($invoice['invoice_pdf'] ?? null),
payload: $invoice, payload: $invoice,
); );
$this->invoiceEmails->sendIfAvailable($transaction);
} }
if (! $matchedLine) { if (! $matchedLine) {
@ -352,4 +364,23 @@ class GrantCreditsFromStripeWebhook
{ {
return is_numeric($value) ? (int) $value : null; return is_numeric($value) ? (int) $value : null;
} }
/**
* @return array<string, mixed>|null
*/
private function retrieveInvoice(?string $invoiceId): ?array
{
if (! $invoiceId) {
return null;
}
try {
return Cashier::stripe()
->invoices
->retrieve($invoiceId)
->toArray();
} catch (\Throwable) {
return null;
}
}
} }

View File

@ -0,0 +1,65 @@
<?php
namespace App\Mail;
use App\Models\PaymentTransaction;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PaymentInvoiceMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public PaymentTransaction $transaction,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: trans('mail.payment_invoice.subject', [], $this->mailLocale()),
);
}
public function content(): Content
{
return new Content(
view: 'emails.payment-invoice',
with: [
'amount' => $this->formattedAmount(),
'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';
}
}

View File

@ -32,6 +32,9 @@ class PaymentTransaction extends Model
'currency', 'currency',
'credits_expected', 'credits_expected',
'credits_granted', 'credits_granted',
'invoice_url',
'invoice_pdf_url',
'invoice_email_sent_at',
'error_message', 'error_message',
'payload', 'payload',
'processed_at', 'processed_at',
@ -60,6 +63,7 @@ class PaymentTransaction extends Model
'amount' => 'integer', 'amount' => 'integer',
'credits_expected' => 'integer', 'credits_expected' => 'integer',
'credits_granted' => 'integer', 'credits_granted' => 'integer',
'invoice_email_sent_at' => 'datetime',
'payload' => 'array', 'payload' => 'array',
'processed_at' => 'datetime', 'processed_at' => 'datetime',
]; ];

View File

@ -0,0 +1,42 @@
<?php
namespace App\Services;
use App\Mail\PaymentInvoiceMail;
use App\Models\PaymentTransaction;
use Illuminate\Support\Facades\Mail;
use Throwable;
class PaymentInvoiceEmailer
{
public function sendIfAvailable(PaymentTransaction $transaction): void
{
if ($transaction->invoice_email_sent_at !== null) {
return;
}
if (! $transaction->user || ! $transaction->user->email) {
return;
}
if (! $transaction->invoice_url && ! $transaction->invoice_pdf_url) {
return;
}
try {
Mail::to($transaction->user->email)->send(
new PaymentInvoiceMail($transaction->user, $transaction->loadMissing('creditProduct'))
);
} catch (Throwable $exception) {
$transaction->forceFill([
'error_message' => 'Invoice email failed: '.$exception->getMessage(),
])->save();
return;
}
$transaction->forceFill([
'invoice_email_sent_at' => now(),
])->save();
}
}

View File

@ -28,6 +28,8 @@ class PaymentTransactionRecorder
?int $creditsExpected = null, ?int $creditsExpected = null,
?int $creditsGranted = null, ?int $creditsGranted = null,
?CreditLedgerEntry $ledgerEntry = null, ?CreditLedgerEntry $ledgerEntry = null,
?string $invoiceUrl = null,
?string $invoicePdfUrl = null,
?string $errorMessage = null, ?string $errorMessage = null,
?array $payload = null, ?array $payload = null,
): PaymentTransaction { ): PaymentTransaction {
@ -47,6 +49,8 @@ class PaymentTransactionRecorder
'currency' => $currency, 'currency' => $currency,
'credits_expected' => $creditsExpected ?? $product?->credits, 'credits_expected' => $creditsExpected ?? $product?->credits,
'credits_granted' => $creditsGranted ?? 0, 'credits_granted' => $creditsGranted ?? 0,
'invoice_url' => $invoiceUrl,
'invoice_pdf_url' => $invoicePdfUrl,
'error_message' => $errorMessage, 'error_message' => $errorMessage,
'payload' => $payload, 'payload' => $payload,
'processed_at' => $status === PaymentTransactionStatus::PENDING ? null : now(), 'processed_at' => $status === PaymentTransactionStatus::PENDING ? null : now(),
@ -72,6 +76,8 @@ class PaymentTransactionRecorder
?int $creditsExpected = null, ?int $creditsExpected = null,
?int $creditsGranted = null, ?int $creditsGranted = null,
?CreditLedgerEntry $ledgerEntry = null, ?CreditLedgerEntry $ledgerEntry = null,
?string $invoiceUrl = null,
?string $invoicePdfUrl = null,
?string $errorMessage = null, ?string $errorMessage = null,
?array $payload = null, ?array $payload = null,
): PaymentTransaction { ): PaymentTransaction {
@ -93,6 +99,8 @@ class PaymentTransactionRecorder
'currency' => $currency, 'currency' => $currency,
'credits_expected' => $creditsExpected ?? $product?->credits, 'credits_expected' => $creditsExpected ?? $product?->credits,
'credits_granted' => $creditsGranted ?? 0, 'credits_granted' => $creditsGranted ?? 0,
'invoice_url' => $invoiceUrl,
'invoice_pdf_url' => $invoicePdfUrl,
'error_message' => $errorMessage, 'error_message' => $errorMessage,
'payload' => $payload, 'payload' => $payload,
'processed_at' => now(), 'processed_at' => now(),

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_transactions', function (Blueprint $table): void {
$table->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',
]);
});
}
};

View File

@ -119,6 +119,9 @@ return [
'amount' => 'Amount', 'amount' => 'Amount',
'credits_expected' => 'Expected credits', 'credits_expected' => 'Expected credits',
'credits_granted' => 'Granted 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_id' => 'Stripe event ID',
'stripe_event_type' => 'Event', 'stripe_event_type' => 'Event',
'stripe_customer_id' => 'Stripe customer ID', 'stripe_customer_id' => 'Stripe customer ID',

View File

@ -31,6 +31,10 @@ return [
'legal_documents' => [ 'legal_documents' => [
'not_found' => 'Legal document not found.', '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' => [ 'notifications' => [
'test_title' => 'Test', 'test_title' => 'Test',
'test_body' => 'Test notification from the Laravel API.', 'test_body' => 'Test notification from the Laravel API.',

View File

@ -33,4 +33,19 @@ return [
'password_confirmation' => 'Confirm password', 'password_confirmation' => 'Confirm password',
'submit' => 'Update 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.',
],
]; ];

View File

@ -119,6 +119,9 @@ return [
'amount' => 'Montant', 'amount' => 'Montant',
'credits_expected' => 'Crédits attendus', 'credits_expected' => 'Crédits attendus',
'credits_granted' => 'Crédits crédités', '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_id' => 'Stripe event ID',
'stripe_event_type' => 'Événement', 'stripe_event_type' => 'Événement',
'stripe_customer_id' => 'Stripe customer ID', 'stripe_customer_id' => 'Stripe customer ID',

View File

@ -31,6 +31,10 @@ return [
'legal_documents' => [ 'legal_documents' => [
'not_found' => 'Document légal introuvable.', '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' => [ 'notifications' => [
'test_title' => 'Test', 'test_title' => 'Test',
'test_body' => 'Notification test depuis Laravel API.', 'test_body' => 'Notification test depuis Laravel API.',

View File

@ -33,4 +33,19 @@ return [
'password_confirmation' => 'Confirmer le mot de passe', 'password_confirmation' => 'Confirmer le mot de passe',
'submit' => 'Modifier 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.',
],
]; ];

View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="{{ $locale }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ trans('mail.payment_invoice.subject', [], $locale) }}</title>
</head>
<body style="margin: 0; padding: 0; background: #f5efe6; color: #232323; font-family: Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f5efe6; padding: 32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 560px; background: #ffffff; border-radius: 8px; overflow: hidden;">
<tr>
<td style="padding: 32px;">
<p style="margin: 0 0 8px; color: #426fb3; font-size: 14px; font-weight: 700; text-transform: uppercase;">
Bowli
</p>
<h1 style="margin: 0 0 16px; color: #000000; font-size: 28px; line-height: 34px;">
{{ trans('mail.payment_invoice.title', [], $locale) }}
</h1>
<p style="margin: 0 0 16px; color: #232323; font-size: 16px; line-height: 24px;">
{{ trans('mail.payment_invoice.greeting', ['name' => $userName ?: trans('mail.payment_invoice.default_name', [], $locale)], $locale) }}
</p>
<p style="margin: 0 0 20px; color: #232323; font-size: 16px; line-height: 24px;">
{{ trans('mail.payment_invoice.intro', [], $locale) }}
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 0 0 24px; border-collapse: collapse;">
<tr>
<td style="padding: 10px 0; color: #666666; font-size: 14px;">
{{ trans('mail.payment_invoice.product', [], $locale) }}
</td>
<td align="right" style="padding: 10px 0; color: #232323; font-size: 14px; font-weight: 700;">
{{ $productName ?: trans('mail.payment_invoice.default_product', [], $locale) }}
</td>
</tr>
<tr>
<td style="padding: 10px 0; color: #666666; font-size: 14px; border-top: 1px solid #eeeeee;">
{{ trans('mail.payment_invoice.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>
<td style="padding: 10px 0; color: #666666; font-size: 14px; border-top: 1px solid #eeeeee;">
{{ trans('mail.payment_invoice.amount', [], $locale) }}
</td>
<td align="right" style="padding: 10px 0; color: #232323; font-size: 14px; font-weight: 700; border-top: 1px solid #eeeeee;">
{{ $amount }}
</td>
</tr>
</table>
@if ($invoiceUrl)
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 24px;">
<tr>
<td style="background: #000000; border-radius: 999px;">
<a href="{{ $invoiceUrl }}" style="display: inline-block; padding: 14px 24px; color: #ffffff; font-size: 16px; font-weight: 700; text-decoration: none;">
{{ trans('mail.payment_invoice.action', [], $locale) }}
</a>
</td>
</tr>
</table>
@endif
@if ($invoicePdfUrl)
<p style="margin: 0 0 16px; color: #666666; font-size: 14px; line-height: 22px;">
{{ trans('mail.payment_invoice.pdf_link', [], $locale) }}
<a href="{{ $invoicePdfUrl }}" style="color: #426fb3;">{{ $invoicePdfUrl }}</a>
</p>
@endif
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 22px;">
{{ trans('mail.payment_invoice.footer', [], $locale) }}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>