feat: add invoice
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user