all system emails look fresh now, plus added paddle portal debugging
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\EventPackageAddon;
|
use App\Models\EventPackageAddon;
|
||||||
|
use App\Services\Paddle\Exceptions\PaddleException;
|
||||||
use App\Services\Paddle\PaddleCustomerPortalService;
|
use App\Services\Paddle\PaddleCustomerPortalService;
|
||||||
use App\Services\Paddle\PaddleCustomerService;
|
use App\Services\Paddle\PaddleCustomerService;
|
||||||
use App\Services\Paddle\PaddleTransactionService;
|
use App\Services\Paddle\PaddleTransactionService;
|
||||||
@@ -142,13 +143,36 @@ class TenantBillingController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$customerId = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$customerId = $this->paddleCustomers->ensureCustomerId($tenant);
|
$customerId = $this->paddleCustomers->ensureCustomerId($tenant);
|
||||||
|
|
||||||
|
Log::debug('Creating Paddle customer portal session', [
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'paddle_customer_id' => $customerId,
|
||||||
|
'paddle_environment' => config('paddle.environment'),
|
||||||
|
'paddle_base_url' => config('paddle.base_url'),
|
||||||
|
]);
|
||||||
|
|
||||||
$session = $this->portalSessions->createSession($customerId);
|
$session = $this->portalSessions->createSession($customerId);
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
Log::warning('Failed to create Paddle customer portal session', [
|
$context = [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
|
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
|
||||||
'error' => $exception->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
|
'paddle_environment' => config('paddle.environment'),
|
||||||
|
'paddle_base_url' => config('paddle.base_url'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($exception instanceof PaddleException) {
|
||||||
|
$context['paddle_status'] = $exception->status();
|
||||||
|
$context['paddle_error_code'] = Arr::get($exception->context(), 'error.code');
|
||||||
|
$context['paddle_error_message'] = Arr::get($exception->context(), 'error.message');
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('Failed to create Paddle customer portal session', [
|
||||||
|
...$context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -162,6 +186,19 @@ class TenantBillingController extends Controller
|
|||||||
?? Arr::get($session, 'urls.general');
|
?? Arr::get($session, 'urls.general');
|
||||||
|
|
||||||
if (! $url) {
|
if (! $url) {
|
||||||
|
$sessionData = Arr::get($session, 'data');
|
||||||
|
$sessionUrls = Arr::get($session, 'data.urls') ?? Arr::get($session, 'urls');
|
||||||
|
|
||||||
|
Log::warning('Paddle customer portal session missing URL', [
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
|
||||||
|
'paddle_environment' => config('paddle.environment'),
|
||||||
|
'paddle_base_url' => config('paddle.base_url'),
|
||||||
|
'session_keys' => array_keys($session),
|
||||||
|
'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null,
|
||||||
|
'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Paddle customer portal session missing URL.',
|
'message' => 'Paddle customer portal session missing URL.',
|
||||||
], 502);
|
], 502);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Mail\ContactConfirmation;
|
use App\Mail\ContactConfirmation;
|
||||||
|
use App\Mail\ContactRequest;
|
||||||
use App\Models\BlogPost;
|
use App\Models\BlogPost;
|
||||||
use App\Models\CheckoutSession;
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
@@ -74,17 +75,13 @@ class MarketingController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Mail::raw(
|
Mail::to($contactAddress)
|
||||||
__('emails.contact.body', [
|
->locale($locale)
|
||||||
'name' => $request->name,
|
->send(new ContactRequest(
|
||||||
'email' => $request->email,
|
name: $request->name,
|
||||||
'message' => $request->message,
|
email: $request->email,
|
||||||
], $locale),
|
messageBody: $request->message,
|
||||||
function ($message) use ($contactAddress, $locale) {
|
));
|
||||||
$message->to($contactAddress)
|
|
||||||
->subject(__('emails.contact.subject', [], $locale));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Mail::to($request->email)
|
Mail::to($request->email)
|
||||||
->locale($locale)
|
->locale($locale)
|
||||||
|
|||||||
46
app/Mail/ContactRequest.php
Normal file
46
app/Mail/ContactRequest.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Address;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class ContactRequest extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
public string $email,
|
||||||
|
public string $messageBody,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: __('emails.contact_request.subject'),
|
||||||
|
replyTo: [new Address($this->email, $this->name)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
view: 'emails.contact-request',
|
||||||
|
with: [
|
||||||
|
'name' => $this->name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'messageBody' => $this->messageBody,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachments(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use App\Notifications\ResetPasswordNotification;
|
||||||
use App\Notifications\VerifyEmailNotification;
|
use App\Notifications\VerifyEmailNotification;
|
||||||
use Filament\Models\Contracts\FilamentUser;
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
use Filament\Models\Contracts\HasName;
|
use Filament\Models\Contracts\HasName;
|
||||||
@@ -98,6 +99,11 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
|||||||
$this->notify(new VerifyEmailNotification);
|
$this->notify(new VerifyEmailNotification);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sendPasswordResetNotification($token): void
|
||||||
|
{
|
||||||
|
$this->notify(new ResetPasswordNotification($token));
|
||||||
|
}
|
||||||
|
|
||||||
protected function fullName(): Attribute
|
protected function fullName(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
|
|||||||
23
app/Notifications/ResetPasswordNotification.php
Normal file
23
app/Notifications/ResetPasswordNotification.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
|
||||||
|
class ResetPasswordNotification extends ResetPassword
|
||||||
|
{
|
||||||
|
public function toMail($notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$resetUrl = $this->resetUrl($notifiable);
|
||||||
|
$expire = (int) config('auth.passwords.'.config('auth.defaults.passwords').'.expire', 60);
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject(__('emails.reset_password.subject'))
|
||||||
|
->view('emails.reset-password', [
|
||||||
|
'user' => $notifiable,
|
||||||
|
'resetUrl' => $resetUrl,
|
||||||
|
'expiresIn' => $expire,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,27 @@ return [
|
|||||||
'line_time' => 'Zeitpunkt: :time',
|
'line_time' => 'Zeitpunkt: :time',
|
||||||
'footer' => 'Bitte prüfen Sie die Queue-Logs für weitere Details.',
|
'footer' => 'Bitte prüfen Sie die Queue-Logs für weitere Details.',
|
||||||
],
|
],
|
||||||
|
'reset_password' => [
|
||||||
|
'subject' => 'Passwort zurücksetzen',
|
||||||
|
'preheader' => 'Verwenden Sie diesen Link, um Ihr Passwort zurückzusetzen.',
|
||||||
|
'hero_title' => 'Passwort zurücksetzen, :name',
|
||||||
|
'hero_subtitle' => 'Sichern Sie Ihr Konto in einem Schritt.',
|
||||||
|
'body' => 'Klicken Sie auf den Button, um Ihr Passwort zurückzusetzen.',
|
||||||
|
'expires' => 'Dieser Link ist :minutes Minuten gültig.',
|
||||||
|
'link_fallback' => 'Falls der Button nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:',
|
||||||
|
'cta' => 'Passwort zurücksetzen',
|
||||||
|
'footer' => 'Wenn Sie kein neues Passwort angefordert haben, können Sie diese E-Mail ignorieren.',
|
||||||
|
],
|
||||||
|
'contact_request' => [
|
||||||
|
'subject' => 'Neue Kontaktanfrage',
|
||||||
|
'preheader' => 'Eine neue Nachricht ist über das Kontaktformular eingegangen.',
|
||||||
|
'hero_title' => 'Neue Kontaktanfrage',
|
||||||
|
'hero_subtitle' => 'Ein Besucher hat eine neue Nachricht gesendet.',
|
||||||
|
'line_name' => 'Name: :name',
|
||||||
|
'line_email' => 'E-Mail: :email',
|
||||||
|
'line_message' => 'Nachricht:',
|
||||||
|
'footer' => 'Antworte direkt auf die Nachricht, um nachzufassen.',
|
||||||
|
],
|
||||||
|
|
||||||
'purchase' => [
|
'purchase' => [
|
||||||
'subject' => 'Kauf-Bestätigung - :package',
|
'subject' => 'Kauf-Bestätigung - :package',
|
||||||
|
|||||||
@@ -46,6 +46,27 @@ return [
|
|||||||
'line_time' => 'Time: :time',
|
'line_time' => 'Time: :time',
|
||||||
'footer' => 'Please investigate the failure in the queue logs.',
|
'footer' => 'Please investigate the failure in the queue logs.',
|
||||||
],
|
],
|
||||||
|
'reset_password' => [
|
||||||
|
'subject' => 'Reset your password',
|
||||||
|
'preheader' => 'Use this link to reset your password.',
|
||||||
|
'hero_title' => 'Reset your password, :name',
|
||||||
|
'hero_subtitle' => 'Secure your account in one step.',
|
||||||
|
'body' => 'Click the button below to reset your password.',
|
||||||
|
'expires' => 'This password reset link expires in :minutes minutes.',
|
||||||
|
'link_fallback' => 'If the button does not work, copy and paste this link into your browser:',
|
||||||
|
'cta' => 'Reset password',
|
||||||
|
'footer' => 'If you did not request a password reset, you can ignore this email.',
|
||||||
|
],
|
||||||
|
'contact_request' => [
|
||||||
|
'subject' => 'New contact request',
|
||||||
|
'preheader' => 'A new message arrived via the contact form.',
|
||||||
|
'hero_title' => 'New contact request',
|
||||||
|
'hero_subtitle' => 'A visitor sent a new message.',
|
||||||
|
'line_name' => 'Name: :name',
|
||||||
|
'line_email' => 'Email: :email',
|
||||||
|
'line_message' => 'Message:',
|
||||||
|
'footer' => 'Reply directly to the sender to follow up.',
|
||||||
|
],
|
||||||
|
|
||||||
'purchase' => [
|
'purchase' => [
|
||||||
'subject' => 'Purchase Confirmation - :package',
|
'subject' => 'Purchase Confirmation - :package',
|
||||||
|
|||||||
31
resources/views/emails/contact-request.blade.php
Normal file
31
resources/views/emails/contact-request.blade.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
@extends('emails.partials.layout')
|
||||||
|
|
||||||
|
@section('title', __('emails.contact_request.subject'))
|
||||||
|
@section('preheader', __('emails.contact_request.preheader'))
|
||||||
|
@section('hero_title', __('emails.contact_request.hero_title'))
|
||||||
|
@section('hero_subtitle', __('emails.contact_request.hero_subtitle'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div style="background-color:#f8fafc; border:1px solid #e2e8f0; border-radius:12px; padding:16px; margin-bottom:16px;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; font-size:14px; color:#0f172a;">
|
||||||
|
{{ __('emails.contact_request.line_name', ['name' => $name]) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; font-size:14px; color:#0f172a;">
|
||||||
|
{{ __('emails.contact_request.line_email', ['email' => $email]) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 8px; font-size:13px; text-transform:uppercase; letter-spacing:0.08em; color:#64748b;">
|
||||||
|
{{ __('emails.contact_request.line_message') }}
|
||||||
|
</p>
|
||||||
|
<p style="margin:0; font-size:14px; color:#1f2937; white-space:pre-line;">{{ $messageBody }}</p>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('footer')
|
||||||
|
{!! __('emails.contact_request.footer') !!}
|
||||||
|
@endsection
|
||||||
29
resources/views/emails/reset-password.blade.php
Normal file
29
resources/views/emails/reset-password.blade.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
@extends('emails.partials.layout')
|
||||||
|
|
||||||
|
@section('title', __('emails.reset_password.subject'))
|
||||||
|
@section('preheader', __('emails.reset_password.preheader'))
|
||||||
|
@section('hero_title', __('emails.reset_password.hero_title', ['name' => $user->fullName ?? $user->name ?? $user->email]))
|
||||||
|
@section('hero_subtitle', __('emails.reset_password.hero_subtitle'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<p style="margin:0 0 12px; font-size:15px; color:#1f2937;">
|
||||||
|
{{ __('emails.reset_password.body') }}
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 16px; font-size:14px; color:#1f2937;">
|
||||||
|
{{ __('emails.reset_password.expires', ['minutes' => $expiresIn]) }}
|
||||||
|
</p>
|
||||||
|
<p style="margin:0; font-size:13px; color:#6b7280;">
|
||||||
|
{{ __('emails.reset_password.link_fallback') }}<br>
|
||||||
|
<span style="word-break:break-all;">{{ $resetUrl }}</span>
|
||||||
|
</p>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('cta')
|
||||||
|
<a href="{{ $resetUrl }}" style="display:inline-block; background-color:#111827; color:#ffffff; text-decoration:none; padding:12px 20px; border-radius:999px; font-weight:600; font-size:14px;">
|
||||||
|
{{ __('emails.reset_password.cta') }}
|
||||||
|
</a>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('footer')
|
||||||
|
{!! __('emails.reset_password.footer') !!}
|
||||||
|
@endsection
|
||||||
103
tests/Feature/BrandedMailableEmailsTest.php
Normal file
103
tests/Feature/BrandedMailableEmailsTest.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Mail\ContactRequest;
|
||||||
|
use App\Models\PackagePurchase;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\Customer\RefundReceipt;
|
||||||
|
use App\Notifications\InactiveTenantDeletionWarning;
|
||||||
|
use App\Notifications\Ops\PurchaseCreated;
|
||||||
|
use App\Notifications\ResetPasswordNotification;
|
||||||
|
use App\Notifications\UploadPipelineFailed;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class BrandedMailableEmailsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_upload_pipeline_failed_uses_branded_view(): void
|
||||||
|
{
|
||||||
|
$notification = new UploadPipelineFailed([
|
||||||
|
'job' => 'UploadJob',
|
||||||
|
'queue' => 'uploads',
|
||||||
|
'event_id' => 123,
|
||||||
|
'photo_id' => 456,
|
||||||
|
'exception' => 'ExampleException',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mailMessage = $notification->toMail((object) []);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(MailMessage::class, $mailMessage);
|
||||||
|
$this->assertSame('emails.notifications.basic', $mailMessage->view);
|
||||||
|
$this->assertArrayHasKey('lines', $mailMessage->viewData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_inactive_tenant_deletion_warning_uses_branded_view(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'name' => 'Demo Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$notification = new InactiveTenantDeletionWarning($tenant, Carbon::now()->addDays(10));
|
||||||
|
$mailMessage = $notification->toMail((object) []);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(MailMessage::class, $mailMessage);
|
||||||
|
$this->assertSame('emails.notifications.basic', $mailMessage->view);
|
||||||
|
$this->assertArrayHasKey('cta', $mailMessage->viewData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_refund_receipt_uses_branded_view(): void
|
||||||
|
{
|
||||||
|
$purchase = PackagePurchase::factory()->create();
|
||||||
|
$notification = new RefundReceipt($purchase);
|
||||||
|
$mailMessage = $notification->toMail((object) []);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(MailMessage::class, $mailMessage);
|
||||||
|
$this->assertSame('emails.notifications.basic', $mailMessage->view);
|
||||||
|
$this->assertArrayHasKey('footer', $mailMessage->viewData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ops_purchase_created_uses_branded_view(): void
|
||||||
|
{
|
||||||
|
$purchase = PackagePurchase::factory()->create();
|
||||||
|
$notification = new PurchaseCreated($purchase);
|
||||||
|
$mailMessage = $notification->toMail((object) []);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(MailMessage::class, $mailMessage);
|
||||||
|
$this->assertSame('emails.notifications.basic', $mailMessage->view);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reset_password_notification_uses_branded_view(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$notification = new ResetPasswordNotification('token-123');
|
||||||
|
$mailMessage = $notification->toMail($user);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(MailMessage::class, $mailMessage);
|
||||||
|
$this->assertSame('emails.reset-password', $mailMessage->view);
|
||||||
|
$this->assertArrayHasKey('resetUrl', $mailMessage->viewData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_contact_request_email_uses_branded_layout(): void
|
||||||
|
{
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$mail = new ContactRequest(
|
||||||
|
name: 'Alex',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
messageBody: 'Hello from the contact form.',
|
||||||
|
);
|
||||||
|
|
||||||
|
Mail::to('support@example.test')->send($mail);
|
||||||
|
|
||||||
|
Mail::assertSent(ContactRequest::class, function (ContactRequest $sent) {
|
||||||
|
return $sent->content()->view === 'emails.contact-request';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
68
tests/Feature/Tenant/TenantBillingPortalTest.php
Normal file
68
tests/Feature/Tenant/TenantBillingPortalTest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
|
use App\Services\Paddle\Exceptions\PaddleException;
|
||||||
|
use App\Services\Paddle\PaddleCustomerPortalService;
|
||||||
|
use App\Services\Paddle\PaddleCustomerService;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Mockery;
|
||||||
|
|
||||||
|
class TenantBillingPortalTest extends TenantTestCase
|
||||||
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Mockery::close();
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_portal_logs_paddle_error_context(): void
|
||||||
|
{
|
||||||
|
$logged = [];
|
||||||
|
Log::listen(function ($event) use (&$logged): void {
|
||||||
|
$logged[] = [
|
||||||
|
'level' => $event->level,
|
||||||
|
'message' => $event->message,
|
||||||
|
'context' => $event->context,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$customerService = Mockery::mock(PaddleCustomerService::class);
|
||||||
|
$customerService->shouldReceive('ensureCustomerId')
|
||||||
|
->once()
|
||||||
|
->withArgs(fn ($tenant) => $tenant->is($this->tenant))
|
||||||
|
->andReturn('ctm_test_123');
|
||||||
|
$this->instance(PaddleCustomerService::class, $customerService);
|
||||||
|
|
||||||
|
$portalService = Mockery::mock(PaddleCustomerPortalService::class);
|
||||||
|
$portalService->shouldReceive('createSession')
|
||||||
|
->once()
|
||||||
|
->with('ctm_test_123')
|
||||||
|
->andThrow(new PaddleException('Paddle request failed with status 404', 404, [
|
||||||
|
'error' => [
|
||||||
|
'code' => 'entity_not_found',
|
||||||
|
'message' => 'Not found',
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
$this->instance(PaddleCustomerPortalService::class, $portalService);
|
||||||
|
|
||||||
|
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/billing/portal');
|
||||||
|
|
||||||
|
$response->assertStatus(502)
|
||||||
|
->assertJson([
|
||||||
|
'message' => 'Failed to create Paddle customer portal session.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$matched = collect($logged)->contains(function (array $entry): bool {
|
||||||
|
return $entry['level'] === 'warning'
|
||||||
|
&& $entry['message'] === 'Failed to create Paddle customer portal session'
|
||||||
|
&& ($entry['context']['tenant_id'] ?? null) === $this->tenant->id
|
||||||
|
&& ($entry['context']['paddle_customer_id'] ?? null) === 'ctm_test_123'
|
||||||
|
&& ($entry['context']['paddle_status'] ?? null) === 404
|
||||||
|
&& ($entry['context']['paddle_error_code'] ?? null) === 'entity_not_found';
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->assertTrue($matched);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user