diff --git a/app/Http/Controllers/Api/TenantBillingController.php b/app/Http/Controllers/Api/TenantBillingController.php index d8ade7f..91e1583 100644 --- a/app/Http/Controllers/Api/TenantBillingController.php +++ b/app/Http/Controllers/Api/TenantBillingController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\EventPackageAddon; +use App\Services\Paddle\Exceptions\PaddleException; use App\Services\Paddle\PaddleCustomerPortalService; use App\Services\Paddle\PaddleCustomerService; use App\Services\Paddle\PaddleTransactionService; @@ -142,13 +143,36 @@ class TenantBillingController extends Controller ], 404); } + $customerId = null; + try { $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); } catch (\Throwable $exception) { - Log::warning('Failed to create Paddle customer portal session', [ + $context = [ 'tenant_id' => $tenant->id, + 'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id, '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([ @@ -162,6 +186,19 @@ class TenantBillingController extends Controller ?? Arr::get($session, 'urls.general'); 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([ 'message' => 'Paddle customer portal session missing URL.', ], 502); diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index 48d5add..59e3211 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Mail\ContactConfirmation; +use App\Mail\ContactRequest; use App\Models\BlogPost; use App\Models\CheckoutSession; use App\Models\Event; @@ -74,17 +75,13 @@ class MarketingController extends Controller } try { - Mail::raw( - __('emails.contact.body', [ - 'name' => $request->name, - 'email' => $request->email, - 'message' => $request->message, - ], $locale), - function ($message) use ($contactAddress, $locale) { - $message->to($contactAddress) - ->subject(__('emails.contact.subject', [], $locale)); - } - ); + Mail::to($contactAddress) + ->locale($locale) + ->send(new ContactRequest( + name: $request->name, + email: $request->email, + messageBody: $request->message, + )); Mail::to($request->email) ->locale($locale) diff --git a/app/Mail/ContactRequest.php b/app/Mail/ContactRequest.php new file mode 100644 index 0000000..1c05a04 --- /dev/null +++ b/app/Mail/ContactRequest.php @@ -0,0 +1,46 @@ +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 []; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 0ff026f..b53683c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Notifications\ResetPasswordNotification; use App\Notifications\VerifyEmailNotification; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasName; @@ -98,6 +99,11 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser, $this->notify(new VerifyEmailNotification); } + public function sendPasswordResetNotification($token): void + { + $this->notify(new ResetPasswordNotification($token)); + } + protected function fullName(): Attribute { return Attribute::make( diff --git a/app/Notifications/ResetPasswordNotification.php b/app/Notifications/ResetPasswordNotification.php new file mode 100644 index 0000000..d67bf00 --- /dev/null +++ b/app/Notifications/ResetPasswordNotification.php @@ -0,0 +1,23 @@ +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, + ]); + } +} diff --git a/resources/lang/de/emails.php b/resources/lang/de/emails.php index ecdfbe2..f24af6c 100644 --- a/resources/lang/de/emails.php +++ b/resources/lang/de/emails.php @@ -47,6 +47,27 @@ return [ 'line_time' => 'Zeitpunkt: :time', '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' => [ 'subject' => 'Kauf-Bestätigung - :package', diff --git a/resources/lang/en/emails.php b/resources/lang/en/emails.php index 82b292a..03c99a7 100644 --- a/resources/lang/en/emails.php +++ b/resources/lang/en/emails.php @@ -46,6 +46,27 @@ return [ 'line_time' => 'Time: :time', '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' => [ 'subject' => 'Purchase Confirmation - :package', diff --git a/resources/views/emails/contact-request.blade.php b/resources/views/emails/contact-request.blade.php new file mode 100644 index 0000000..f8fdda7 --- /dev/null +++ b/resources/views/emails/contact-request.blade.php @@ -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') +
+ + + + + + + +
+ {{ __('emails.contact_request.line_name', ['name' => $name]) }} +
+ {{ __('emails.contact_request.line_email', ['email' => $email]) }} +
+
+

+ {{ __('emails.contact_request.line_message') }} +

+

{{ $messageBody }}

+@endsection + +@section('footer') + {!! __('emails.contact_request.footer') !!} +@endsection diff --git a/resources/views/emails/reset-password.blade.php b/resources/views/emails/reset-password.blade.php new file mode 100644 index 0000000..739ab7a --- /dev/null +++ b/resources/views/emails/reset-password.blade.php @@ -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') +

+ {{ __('emails.reset_password.body') }} +

+

+ {{ __('emails.reset_password.expires', ['minutes' => $expiresIn]) }} +

+

+ {{ __('emails.reset_password.link_fallback') }}
+ {{ $resetUrl }} +

+@endsection + +@section('cta') + + {{ __('emails.reset_password.cta') }} + +@endsection + +@section('footer') + {!! __('emails.reset_password.footer') !!} +@endsection diff --git a/tests/Feature/BrandedMailableEmailsTest.php b/tests/Feature/BrandedMailableEmailsTest.php new file mode 100644 index 0000000..e5f45e5 --- /dev/null +++ b/tests/Feature/BrandedMailableEmailsTest.php @@ -0,0 +1,103 @@ + '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'; + }); + } +} diff --git a/tests/Feature/Tenant/TenantBillingPortalTest.php b/tests/Feature/Tenant/TenantBillingPortalTest.php new file mode 100644 index 0000000..1532c03 --- /dev/null +++ b/tests/Feature/Tenant/TenantBillingPortalTest.php @@ -0,0 +1,68 @@ + $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); + } +}