feat: add invoice

This commit is contained in:
2026-05-27 13:14:09 +02:00
parent a01387bb09
commit 1216c8a073
17 changed files with 352 additions and 2 deletions
@@ -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([
@@ -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')
@@ -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([
+1
View File
@@ -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(),
@@ -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<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;
}
}
}
+65
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';
}
}
+4
View File
@@ -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',
];
+42
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();
}
}
@@ -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(),