schickere bestellbestätigung und user role detaults auf "user" gesetzt.

This commit is contained in:
Codex Agent
2025-12-23 10:33:06 +01:00
parent ed5c1918fc
commit 886b24b06b
8 changed files with 409 additions and 12 deletions

View File

@@ -88,6 +88,7 @@ class CheckoutController extends Controller
'address' => $validated['address'], 'address' => $validated['address'],
'phone' => $validated['phone'], 'phone' => $validated['phone'],
'preferred_locale' => $validated['locale'] ?? null, 'preferred_locale' => $validated['locale'] ?? null,
'role' => 'user',
'password' => Hash::make($validated['password']), 'password' => Hash::make($validated['password']),
'pending_purchase' => true, 'pending_purchase' => true,
]); ]);

View File

@@ -35,6 +35,13 @@ class PurchaseConfirmation extends Mailable
'package' => $this->purchase->package, 'package' => $this->purchase->package,
'packageName' => $this->localizedPackageName(), 'packageName' => $this->localizedPackageName(),
'priceFormatted' => $this->formattedTotal(), 'priceFormatted' => $this->formattedTotal(),
'purchaseDate' => $this->formattedPurchaseDate(),
'providerLabel' => $this->providerLabel(),
'orderId' => $this->purchase->provider_id,
'packageTypeLabel' => $this->packageTypeLabel(),
'limits' => $this->packageLimits(),
'invoiceUrl' => $this->resolveInvoiceUrl(),
'ctaUrl' => route('tenant.admin.dashboard'),
], ],
); );
} }
@@ -81,6 +88,18 @@ class PurchaseConfirmation extends Mailable
return number_format($amount, 2, ',', '.').' '.$symbol; return number_format($amount, 2, ',', '.').' '.$symbol;
} }
private function formattedPurchaseDate(): string
{
$locale = $this->locale ?? app()->getLocale();
$date = $this->purchase->purchased_at ?? now();
if (str_starts_with($locale, 'en')) {
return $date->translatedFormat('F j, Y');
}
return $date->translatedFormat('d. F Y');
}
private function mapLocale(string $locale): string private function mapLocale(string $locale): string
{ {
$normalized = strtolower(str_replace('_', '-', $locale)); $normalized = strtolower(str_replace('_', '-', $locale));
@@ -91,4 +110,81 @@ class PurchaseConfirmation extends Mailable
default => 'de_DE', default => 'de_DE',
}; };
} }
private function providerLabel(): string
{
$provider = $this->purchase->provider ?? 'paddle';
$labelKey = 'emails.purchase.provider.'.$provider;
$label = __($labelKey);
if ($label === $labelKey) {
return ucfirst((string) $provider);
}
return $label;
}
private function packageTypeLabel(): string
{
$type = $this->purchase->package?->type ?? 'endcustomer';
$labelKey = 'emails.purchase.package_type.'.$type;
$label = __($labelKey);
if ($label === $labelKey) {
return ucfirst((string) $type);
}
return $label;
}
/**
* @return array<int, array{label: string, value: string}>
*/
private function packageLimits(): array
{
$package = $this->purchase->package;
if (! $package) {
return [];
}
$limits = [
'max_photos' => $package->max_photos,
'max_guests' => $package->max_guests,
'gallery_days' => $package->gallery_days,
'max_tasks' => $package->max_tasks,
'max_events_per_year' => $package->max_events_per_year,
];
$result = [];
foreach ($limits as $key => $value) {
if (! is_numeric($value) || (int) $value <= 0) {
continue;
}
$result[] = [
'label' => __('emails.purchase.limits.'.$key),
'value' => number_format((int) $value, 0, ',', '.'),
];
}
return $result;
}
private function resolveInvoiceUrl(): ?string
{
$payload = $this->purchase->metadata['payload'] ?? [];
$invoiceUrl = $payload['invoice_url'] ?? null;
if (is_string($invoiceUrl) && $invoiceUrl !== '') {
return $invoiceUrl;
}
$receiptUrl = $payload['receipt_url'] ?? null;
if (is_string($receiptUrl) && $receiptUrl !== '') {
return $receiptUrl;
}
return null;
}
} }

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (Schema::getConnection()->getDriverName() === 'sqlite') {
return;
}
DB::statement("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'user'");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::getConnection()->getDriverName() === 'sqlite') {
return;
}
DB::statement("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'super_admin'");
}
};

View File

@@ -15,9 +15,40 @@ return [
'subject' => 'Kauf-Bestätigung - :package', 'subject' => 'Kauf-Bestätigung - :package',
'greeting' => 'Vielen Dank für Ihren Kauf, :name!', 'greeting' => 'Vielen Dank für Ihren Kauf, :name!',
'package' => 'Package: :package', 'package' => 'Package: :package',
'price' => 'Preis: :price', 'price' => 'Preis: :price',
'activation' => 'Das Package ist nun in Ihrem Tenant-Account aktiviert.', 'activation' => 'Ihr Event-Paket ist jetzt im Tenant-Account aktiviert.',
'footer' => 'Mit freundlichen Grüßen,<br>Das Fotospiel-Team', 'footer' => 'Mit freundlichen Grüßen,<br>Das Team von Die Fotospiel.App',
'brand_label' => 'Die Fotospiel.App',
'brand_footer' => 'Die Fotospiel.App · Event-Pakete mit Wow-Effekt',
'subtitle' => 'Ihre Bestellung wurde erfolgreich bestätigt. Hier finden Sie alle Details auf einen Blick.',
'summary_title' => 'Bestellübersicht',
'package_label' => 'Event-Paket',
'type_label' => 'Paketart',
'date_label' => 'Kaufdatum',
'provider_label' => 'Zahlungsanbieter',
'order_label' => 'Bestellnummer',
'price_label' => 'Gesamtbetrag',
'activation_label' => 'Aktivierung',
'limits_title' => 'Ihre Paketdetails',
'invoice_title' => 'Rechnung',
'invoice_link' => 'Rechnung öffnen',
'cta' => 'Zum Event-Admin',
'provider' => [
'paddle' => 'Paddle',
'manual' => 'Manuell',
'free' => 'Kostenfrei',
],
'package_type' => [
'endcustomer' => 'Einmalkauf pro Event',
'reseller' => 'Jahresabo für Reseller',
],
'limits' => [
'max_photos' => 'Max. Fotos',
'max_guests' => 'Max. Gäste',
'gallery_days' => 'Galerie-Tage',
'max_tasks' => 'Max. Aufgaben',
'max_events_per_year' => 'Events pro Jahr',
],
], ],
'abandoned_checkout' => [ 'abandoned_checkout' => [

View File

@@ -15,9 +15,40 @@ return [
'subject' => 'Purchase Confirmation - :package', 'subject' => 'Purchase Confirmation - :package',
'greeting' => 'Thank you for your purchase, :name!', 'greeting' => 'Thank you for your purchase, :name!',
'package' => 'Package: :package', 'package' => 'Package: :package',
'price' => 'Price: :price', 'price' => 'Price: :price',
'activation' => 'The package is now activated in your tenant account.', 'activation' => 'Your event package is now activated in your tenant account.',
'footer' => 'Best regards,<br>The Fotospiel Team', 'footer' => 'Best regards,<br>The team at Die Fotospiel.App',
'brand_label' => 'Die Fotospiel.App',
'brand_footer' => 'Die Fotospiel.App · Event packages with wow-factor',
'subtitle' => 'Your order has been confirmed successfully. Here are the details at a glance.',
'summary_title' => 'Order summary',
'package_label' => 'Event package',
'type_label' => 'Package type',
'date_label' => 'Purchase date',
'provider_label' => 'Payment provider',
'order_label' => 'Order ID',
'price_label' => 'Total amount',
'activation_label' => 'Activation',
'limits_title' => 'Your package details',
'invoice_title' => 'Invoice',
'invoice_link' => 'Open invoice',
'cta' => 'Open Event Admin',
'provider' => [
'paddle' => 'Paddle',
'manual' => 'Manual',
'free' => 'Free',
],
'package_type' => [
'endcustomer' => 'One-time purchase per event',
'reseller' => 'Annual subscription for resellers',
],
'limits' => [
'max_photos' => 'Max. photos',
'max_guests' => 'Max. guests',
'gallery_days' => 'Gallery days',
'max_tasks' => 'Max. tasks',
'max_events_per_year' => 'Events per year',
],
], ],
'abandoned_checkout' => [ 'abandoned_checkout' => [

View File

@@ -3,11 +3,114 @@
<head> <head>
<title>{{ __('emails.purchase.subject', ['package' => $packageName]) }}</title> <title>{{ __('emails.purchase.subject', ['package' => $packageName]) }}</title>
</head> </head>
<body> <body style="margin:0; padding:0; background-color:#f4f5f7; font-family:Arial, Helvetica, sans-serif; color:#1a1a1a;">
<h1>{{ __('emails.purchase.greeting', ['name' => $user->fullName]) }}</h1> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f5f7; padding:32px 0;">
<p>{{ __('emails.purchase.package', ['package' => $packageName]) }}</p> <tr>
<p>{{ __('emails.purchase.price', ['price' => $priceFormatted]) }}</p> <td align="center">
<p>{{ __('emails.purchase.activation') }}</p> <table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color:#ffffff; border-radius:16px; overflow:hidden; box-shadow:0 12px 30px rgba(15, 23, 42, 0.08);">
<p>{!! __('emails.purchase.footer') !!}</p> <tr>
<td style="background:linear-gradient(135deg,#0f172a,#334155); color:#ffffff; padding:32px;">
<p style="margin:0 0 10px; font-size:12px; letter-spacing:0.12em; text-transform:uppercase; opacity:0.7;">
{{ __('emails.purchase.brand_label') }}
</p>
<h1 style="margin:0; font-size:24px; line-height:1.35;">
{{ __('emails.purchase.greeting', ['name' => $user->fullName]) }}
</h1>
<p style="margin:12px 0 0; font-size:15px; opacity:0.9;">
{{ __('emails.purchase.subtitle') }}
</p>
</td>
</tr>
<tr>
<td style="padding:28px 32px 12px;">
<h2 style="margin:0 0 12px; font-size:18px;">
{{ __('emails.purchase.summary_title') }}
</h2>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">
<tr>
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.purchase.package_label') }}</td>
<td style="padding:10px 0; font-size:14px; text-align:right; font-weight:600;">{{ $packageName }}</td>
</tr>
<tr>
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.purchase.type_label') }}</td>
<td style="padding:10px 0; font-size:14px; text-align:right;">{{ $packageTypeLabel }}</td>
</tr>
<tr>
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.purchase.date_label') }}</td>
<td style="padding:10px 0; font-size:14px; text-align:right;">{{ $purchaseDate }}</td>
</tr>
<tr>
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.purchase.provider_label') }}</td>
<td style="padding:10px 0; font-size:14px; text-align:right;">{{ $providerLabel }}</td>
</tr>
<tr>
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.purchase.order_label') }}</td>
<td style="padding:10px 0; font-size:14px; text-align:right;">{{ $orderId }}</td>
</tr>
<tr>
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.purchase.price_label') }}</td>
<td style="padding:10px 0; font-size:16px; text-align:right; font-weight:700;">{{ $priceFormatted }}</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 32px 18px;">
<div style="background-color:#f8fafc; border:1px solid #e2e8f0; border-radius:12px; padding:16px;">
<p style="margin:0 0 6px; font-size:13px; text-transform:uppercase; letter-spacing:0.08em; color:#64748b;">
{{ __('emails.purchase.activation_label') }}
</p>
<p style="margin:0; font-size:14px; color:#0f172a;">
{{ __('emails.purchase.activation') }}
</p>
</div>
</td>
</tr>
@if (! empty($limits))
<tr>
<td style="padding:0 32px 18px;">
<h3 style="margin:0 0 12px; font-size:16px;">{{ __('emails.purchase.limits_title') }}</h3>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">
@foreach ($limits as $limit)
<tr>
<td style="padding:6px 0; font-size:14px; color:#6b7280;">{{ $limit['label'] }}</td>
<td style="padding:6px 0; font-size:14px; text-align:right; font-weight:600;">{{ $limit['value'] }}</td>
</tr>
@endforeach
</table>
</td>
</tr>
@endif
@if ($invoiceUrl)
<tr>
<td style="padding:0 32px 18px;">
<h3 style="margin:0 0 8px; font-size:16px;">{{ __('emails.purchase.invoice_title') }}</h3>
<p style="margin:0; font-size:14px; color:#6b7280;">
<a href="{{ $invoiceUrl }}" style="color:#1d4ed8; text-decoration:none;">
{{ __('emails.purchase.invoice_link') }}
</a>
</p>
</td>
</tr>
@endif
<tr>
<td style="padding:0 32px 32px;">
<a href="{{ $ctaUrl }}" 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.purchase.cta') }}
</a>
</td>
</tr>
<tr>
<td style="padding:0 32px 32px; font-size:12px; color:#6b7280;">
{!! __('emails.purchase.footer') !!}
</td>
</tr>
</table>
<p style="margin:16px 0 0; font-size:12px; color:#94a3b8;">
{{ __('emails.purchase.brand_footer') }}
</p>
</td>
</tr>
</table>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,45 @@
<?php
namespace Tests\Feature;
use App\Models\Package;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class CheckoutRegisterRoleTest extends TestCase
{
use RefreshDatabase;
public function test_checkout_register_sets_user_role_until_purchase(): void
{
Mail::fake();
$package = Package::factory()->create();
$payload = [
'email' => 'buyer@example.test',
'username' => 'buyer',
'password' => 'Password!123',
'password_confirmation' => 'Password!123',
'first_name' => 'Soren',
'last_name' => 'Eberhardt',
'address' => 'Example Street 1',
'phone' => '123456789',
'package_id' => $package->id,
'terms' => true,
'privacy_consent' => true,
'locale' => 'de',
];
$response = $this->postJson('/checkout/register', $payload);
$response->assertOk();
$user = User::where('email', 'buyer@example.test')->first();
$this->assertNotNull($user);
$this->assertSame('user', $user->role);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Tests\Feature;
use App\Mail\PurchaseConfirmation;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PurchaseConfirmationMailTest extends TestCase
{
use RefreshDatabase;
public function test_purchase_confirmation_mail_renders_expected_details(): void
{
$user = User::factory()->create([
'first_name' => 'Soren',
'last_name' => 'Eberhardt',
]);
$tenant = Tenant::factory()->create([
'user_id' => $user->id,
]);
$user->forceFill(['tenant_id' => $tenant->id])->save();
$package = Package::factory()->create([
'name' => 'Standard',
'type' => 'endcustomer',
'max_photos' => 500,
'max_guests' => 200,
'gallery_days' => 30,
]);
$purchase = PackagePurchase::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider' => 'paddle',
'provider_id' => 'txn_123',
'price' => 59,
'metadata' => [
'payload' => [
'invoice_url' => 'https://paddle.test/invoice/123',
],
'currency' => 'EUR',
],
]);
$mailable = (new PurchaseConfirmation($purchase))->locale('de');
$html = $mailable->render();
$this->assertStringContainsString('Die Fotospiel.App', $html);
$this->assertStringContainsString('Standard', $html);
$this->assertStringContainsString('txn_123', $html);
$this->assertStringContainsString('https://paddle.test/invoice/123', $html);
}
}