billing: Zahlungsbestätigung per Mail nach jeder Buchung
Sobald eine bezahlte Stripe-Rechnung in den STR-Kreis gespiegelt wird, erhält der Kunde eine Bestätigungsmail mit Rechnungsnummer und Beträgen — deckt Tarif (inkl. Verlängerung), Einzel-PM und Credits ab. Transaktionsmail ohne Opt-out; durch die idempotente Spiegelung genau eine Mail pro Beleg, Gutschriften bleiben stumm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
425467bbec
commit
e32431e948
4 changed files with 128 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ use App\Enums\InvoiceStatus;
|
|||
use App\Enums\PressReleaseStatus;
|
||||
use App\Enums\SinglePurchaseStatus;
|
||||
use App\Enums\SinglePurchaseType;
|
||||
use App\Mail\BookingPaymentConfirmed;
|
||||
use App\Mail\PressReleaseDepublished;
|
||||
use App\Models\CreditTopup;
|
||||
use App\Models\Invoice;
|
||||
|
|
@ -177,6 +178,13 @@ class ProcessStripeWebhook
|
|||
'number' => $invoice->number,
|
||||
'stripe_invoice_id' => $stripeInvoiceId,
|
||||
]);
|
||||
|
||||
// Zahlungsbestätigung an den Kunden — genau eine pro Beleg, da die
|
||||
// Spiegelung über stripe_invoice_id idempotent ist. Deckt alle
|
||||
// Buchungsarten ab (Tarif inkl. Verlängerung, Einzel-PM, Credits).
|
||||
if ($user->email) {
|
||||
Mail::to($user->email)->queue(new BookingPaymentConfirmed($user, $invoice));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
46
app/Mail/BookingPaymentConfirmed.php
Normal file
46
app/Mail/BookingPaymentConfirmed.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Zahlungs-/Buchungsbestätigung an den Kunden, sobald eine bezahlte
|
||||
* Stripe-Rechnung in den STR-Kreis gespiegelt wurde — greift damit für
|
||||
* jede Buchungsart (Tarif inkl. Verlängerung, Einzel-PM, Credits).
|
||||
*
|
||||
* Transaktionsmail: bewusst ohne Opt-out (analog Kündigungs-/
|
||||
* Widerrufsbestätigungen, siehe EmailNotificationType-Doku).
|
||||
*/
|
||||
class BookingPaymentConfirmed extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public string $invoicesUrl;
|
||||
|
||||
public function __construct(
|
||||
public readonly User $user,
|
||||
public readonly Invoice $invoice,
|
||||
) {
|
||||
$this->invoicesUrl = route('me.invoices.index');
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Zahlungsbestätigung für Ihre Buchung — Rechnung '.$this->invoice->number,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.billing.payment-confirmed');
|
||||
}
|
||||
}
|
||||
34
resources/views/emails/billing/payment-confirmed.blade.php
Normal file
34
resources/views/emails/billing/payment-confirmed.blade.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
@php
|
||||
$formatCents = fn (int $cents): string => number_format($cents / 100, 2, ',', '.')
|
||||
.' '.($invoice->currency === 'EUR' ? '€' : $invoice->currency);
|
||||
@endphp
|
||||
<x-emails.layout title="Zahlungsbestätigung für Ihre Buchung" preheader="Wir haben Ihre Zahlung erhalten — vielen Dank für Ihre Buchung.">
|
||||
<p>Hallo {{ $user->name }},</p>
|
||||
|
||||
<p>
|
||||
vielen Dank für Ihre Buchung. Wir haben Ihre Zahlung erhalten und
|
||||
die zugehörige Rechnung in Ihrem Konto hinterlegt:
|
||||
</p>
|
||||
|
||||
<dl class="meta">
|
||||
<dt>Rechnungsnummer</dt>
|
||||
<dd>{{ $invoice->number }}</dd>
|
||||
<dt>Rechnungsdatum</dt>
|
||||
<dd>{{ $invoice->invoice_date?->format('d.m.Y') }}</dd>
|
||||
<dt>Betrag (netto)</dt>
|
||||
<dd>{{ $formatCents($invoice->amount_cents) }}</dd>
|
||||
@if ($invoice->tax_cents > 0)
|
||||
<dt>Umsatzsteuer</dt>
|
||||
<dd>{{ $formatCents($invoice->tax_cents) }}</dd>
|
||||
@endif
|
||||
<dt>Gesamtbetrag</dt>
|
||||
<dd>{{ $formatCents($invoice->total_cents) }}</dd>
|
||||
</dl>
|
||||
|
||||
<x-emails.button :url="$invoicesUrl">Rechnung im Konto ansehen</x-emails.button>
|
||||
|
||||
<div class="note">
|
||||
<p>Alle Rechnungen und Belege finden Sie jederzeit in Ihrem Konto unter Rechnungen.</p>
|
||||
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
|
||||
</div>
|
||||
</x-emails.layout>
|
||||
|
|
@ -3,12 +3,14 @@
|
|||
use App\Enums\InvoiceStatus;
|
||||
use App\Enums\SinglePurchaseStatus;
|
||||
use App\Listeners\ProcessStripeWebhook;
|
||||
use App\Mail\BookingPaymentConfirmed;
|
||||
use App\Models\BillingAddress;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
function stripeInvoicePayload(User $user, array $overrides = []): array
|
||||
|
|
@ -166,6 +168,44 @@ test('an already paid purchase is not touched again', function () {
|
|||
expect($purchase->fresh()->stripe_payment_intent_id)->toBe('pi_original');
|
||||
});
|
||||
|
||||
test('a mirrored invoice queues a payment confirmation mail to the customer', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
|
||||
|
||||
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user)));
|
||||
|
||||
Mail::assertQueued(BookingPaymentConfirmed::class, function (BookingPaymentConfirmed $mail) use ($user) {
|
||||
return $mail->hasTo($user->email)
|
||||
&& $mail->invoice->number === 'STR-00001';
|
||||
});
|
||||
});
|
||||
|
||||
test('a replayed invoice webhook does not queue a second confirmation mail', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
|
||||
|
||||
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user)));
|
||||
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user)));
|
||||
|
||||
Mail::assertQueuedCount(1);
|
||||
});
|
||||
|
||||
test('a skipped credit note does not queue a confirmation mail', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
|
||||
|
||||
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user, [
|
||||
'subtotal' => -4900,
|
||||
'tax' => 0,
|
||||
'total' => -4900,
|
||||
])));
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
test('an unpaid async checkout session is not fulfilled yet', function () {
|
||||
// PayPal/SEPA & Co.: `checkout.session.completed` kommt schon nach dem
|
||||
// Redirect mit payment_status=unpaid — erfüllt wird erst mit
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue