Portal: Karte tauschen, offene Rechnungen begleichen, Hinweis bei Faelligkeit
parent
88382069b8
commit
b8a774b604
|
|
@ -606,6 +606,19 @@ class Billing extends Component
|
|||
: collect();
|
||||
|
||||
return view('livewire.billing', [
|
||||
// Ob eine Abbuchung offen ist. Bis zum 31.7.2026 stand dazu
|
||||
// nichts im Portal: invoicePaymentFailed() setzt `past_due` und
|
||||
// schweigt, und der Kunde erfuhr davon erst, wenn seine Cloud
|
||||
// stand.
|
||||
'pastDue' => $customer !== null && $customer->subscriptions()
|
||||
->where('stripe_status', 'past_due')
|
||||
->exists(),
|
||||
// Die zwei Bauteile darunter brauchen einen Stripe-Kunden: ohne
|
||||
// ihn gibt es weder ein Zahlungsmittel zu tauschen noch eine
|
||||
// Rechnung zu begleichen. Wer noch nie an der Kasse war, hat
|
||||
// keinen — und bekommt hier folgerichtig auch keine leere Karte
|
||||
// gezeigt, sondern gar keine.
|
||||
'hasStripeCustomer' => $customer !== null && filled($customer->stripe_customer_id),
|
||||
'hasContract' => $hasContract,
|
||||
'currentKey' => $currentKey,
|
||||
// What they HAVE comes from their contract; only what they could
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Was der Kunde noch schuldet, und der Knopf, es zu begleichen.
|
||||
*
|
||||
* Die Liste kommt bei jedem Rendern frisch von Stripe. Eine zwischengelagerte
|
||||
* Kopie zeigte einen Bezahlknopf neben einer Rechnung, die inzwischen beglichen
|
||||
* ist — und der zweite Klick wäre eine zweite Abbuchung.
|
||||
*/
|
||||
class OpenInvoices extends Component
|
||||
{
|
||||
/**
|
||||
* Was Stripe zur letzten Zahlung gesagt hat, wenn sie nicht durchging.
|
||||
*
|
||||
* Stripes eigener Wortlaut, nicht eine eigene Umschreibung: er nennt den
|
||||
* Grund — abgelaufen, abgelehnt, Deckung — und danach kann der Kunde
|
||||
* handeln. „Zahlung fehlgeschlagen" könnte er nur weiterreichen.
|
||||
*/
|
||||
public ?string $failure = null;
|
||||
|
||||
public function pay(string $invoiceId): void
|
||||
{
|
||||
$stripe = app(StripeClient::class);
|
||||
$customerId = $this->stripeCustomerId();
|
||||
|
||||
// Die ID kommt aus dem Browser. Nur was in DIESER Liste steht, darf
|
||||
// eingezogen werden — sonst reicht jemand eine fremde Rechnungs-ID ein
|
||||
// und Stripe bucht sie von der Karte eines fremden Kunden ab.
|
||||
$offen = array_column($stripe->openInvoices($customerId), 'id');
|
||||
abort_unless(in_array($invoiceId, $offen, true), 403);
|
||||
|
||||
$ergebnis = $stripe->payInvoice($invoiceId);
|
||||
$this->failure = $ergebnis['paid'] ? null : $ergebnis['failure'];
|
||||
|
||||
if ($ergebnis['paid']) {
|
||||
$this->dispatch('notify', message: __('billing.invoice_paid'));
|
||||
}
|
||||
}
|
||||
|
||||
public function payAll(): void
|
||||
{
|
||||
foreach (app(StripeClient::class)->openInvoices($this->stripeCustomerId()) as $rechnung) {
|
||||
$this->pay($rechnung['id']);
|
||||
|
||||
// Nach der ersten Ablehnung anhalten. Alle weiteren scheitern an
|
||||
// derselben Karte, und drei Ablehnungen hintereinander lassen
|
||||
// Stripes Betrugserkennung anschlagen — danach lehnt sie auch die
|
||||
// Karte ab, die in Ordnung wäre.
|
||||
if ($this->failure !== null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function stripeCustomerId(): string
|
||||
{
|
||||
$customer = Customer::forUser(Auth::user());
|
||||
|
||||
abort_if($customer === null || blank($customer->stripe_customer_id), 404);
|
||||
|
||||
return (string) $customer->stripe_customer_id;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.open-invoices', [
|
||||
'invoices' => app(StripeClient::class)->openInvoices($this->stripeCustomerId()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use App\Support\StripePublishableKey;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Die Karte des Kunden, getauscht ohne dass ein Kartendatum diesen Server
|
||||
* berührt: Stripe Elements schickt sie direkt an Stripe und bekommt eine ID
|
||||
* zurück, und nur die kommt hier an.
|
||||
*
|
||||
* Bis zum 31. Juli 2026 gab es das nicht — im ganzen Portal keine
|
||||
* Zahlungsmittel-Verwaltung. Eine abgelaufene Karte war damit eine Sackgasse,
|
||||
* und jeder Mahnlauf darüber hätte jemanden für etwas gemahnt, das er nicht
|
||||
* abstellen kann.
|
||||
*
|
||||
* Der SetupIntent entsteht beim Aufbau der Seite. Er ist an den Stripe-Kunden
|
||||
* gebunden und überlebt einen Neuaufbau, kostet also nichts, wenn niemand
|
||||
* etwas eingibt.
|
||||
*/
|
||||
class PaymentMethod extends Component
|
||||
{
|
||||
/**
|
||||
* Das Geheimnis, mit dem der Browser den SetupIntent bestätigt.
|
||||
*
|
||||
* Null heißt: es fehlt der veröffentlichbare Schlüssel. Dann wird das
|
||||
* gesagt, statt ein Kartenfeld zu zeichnen, das nie lädt — der Kunde tippt
|
||||
* sonst seine Nummer ein, nichts passiert, und er hält seine Karte für
|
||||
* abgelehnt.
|
||||
*/
|
||||
public ?string $clientSecret = null;
|
||||
|
||||
/** @var array{id: string, brand: string, last4: string, exp_month: int, exp_year: int}|null */
|
||||
public ?array $card = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$stripe = app(StripeClient::class);
|
||||
$customerId = $this->stripeCustomerId();
|
||||
|
||||
$this->card = $stripe->defaultPaymentMethod($customerId);
|
||||
|
||||
if (StripePublishableKey::current() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->clientSecret = $stripe->createSetupIntent($customerId)['client_secret'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vom Browser gerufen, nachdem Elements den SetupIntent bestätigt hat.
|
||||
*
|
||||
* Die ID kommt damit aus dem Browser und wird geprüft, bevor sie an Stripe
|
||||
* weitergereicht wird — sonst wäre das ein Weg, einen beliebigen Bezeichner
|
||||
* an das eigene Stripe-Konto zu hängen.
|
||||
*/
|
||||
public function confirmed(string $paymentMethodId): void
|
||||
{
|
||||
Validator::make(
|
||||
['paymentMethodId' => $paymentMethodId],
|
||||
['paymentMethodId' => ['required', 'string', 'max:255', 'regex:/^pm_[A-Za-z0-9]+$/']],
|
||||
)->validate();
|
||||
|
||||
$stripe = app(StripeClient::class);
|
||||
$customerId = $this->stripeCustomerId();
|
||||
|
||||
// Hinterlegen genügt nicht — ohne diesen Schritt bucht Stripe weiter
|
||||
// mit der alten Karte ab.
|
||||
$stripe->setDefaultPaymentMethod($customerId, $paymentMethodId);
|
||||
$this->card = $stripe->defaultPaymentMethod($customerId);
|
||||
|
||||
$this->dispatch('notify', message: __('billing.card_saved'));
|
||||
}
|
||||
|
||||
private function stripeCustomerId(): string
|
||||
{
|
||||
$customer = Customer::forUser(Auth::user());
|
||||
|
||||
abort_if($customer === null || blank($customer->stripe_customer_id), 404);
|
||||
|
||||
return (string) $customer->stripe_customer_id;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.payment-method', [
|
||||
'publishableKey' => StripePublishableKey::current(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,24 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'past_due_title' => 'Eine Abbuchung ist fehlgeschlagen.',
|
||||
'past_due_body' => 'Bitte prüfen Sie Ihr Zahlungsmittel und begleichen Sie den offenen Betrag. Ihre Cloud läuft vorerst weiter.',
|
||||
'invoices_open_title' => 'Offene Rechnungen',
|
||||
'invoices_open_body' => 'Wird über das hinterlegte Zahlungsmittel eingezogen.',
|
||||
'invoices_none' => 'Es ist nichts offen.',
|
||||
'invoice_number' => 'Nummer',
|
||||
'invoice_date' => 'Datum',
|
||||
'invoice_amount' => 'Betrag',
|
||||
'invoice_pay' => 'Jetzt bezahlen',
|
||||
'invoice_pay_all' => 'Alles offene bezahlen',
|
||||
'invoice_paid' => 'Bezahlt. Danke.',
|
||||
'card_title' => 'Zahlungsmittel',
|
||||
'card_body' => 'Die Karte, von der Ihre Pakete abgebucht werden. Sie wird direkt an Stripe übermittelt — die Nummer erreicht diesen Server nie.',
|
||||
'card_on_file' => 'Hinterlegt: :brand •••• :last4, gültig bis :month/:year',
|
||||
'card_new' => 'Neue Karte',
|
||||
'card_save' => 'Karte speichern',
|
||||
'card_saved' => 'Karte gespeichert. Ab der nächsten Abbuchung gilt sie.',
|
||||
'card_unavailable' => 'Die Kartenmaske kann gerade nicht geladen werden — dieser Installation fehlt der veröffentlichbare Stripe-Schlüssel. Bitte wenden Sie sich an den Support.',
|
||||
// Kleinere Pakete werden auch dann gezeigt, wenn sie gerade nicht gehen —
|
||||
// mit dem Grund in den Zahlen des Kunden.
|
||||
'downgrade' => 'Kleineres Paket',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'past_due_title' => 'A payment failed.',
|
||||
'past_due_body' => 'Please check your payment method and settle the outstanding amount. Your cloud keeps running for now.',
|
||||
'invoices_open_title' => 'Outstanding invoices',
|
||||
'invoices_open_body' => 'Charged to the payment method on file.',
|
||||
'invoices_none' => 'Nothing is outstanding.',
|
||||
'invoice_number' => 'Number',
|
||||
'invoice_date' => 'Date',
|
||||
'invoice_amount' => 'Amount',
|
||||
'invoice_pay' => 'Pay now',
|
||||
'invoice_pay_all' => 'Pay everything outstanding',
|
||||
'invoice_paid' => 'Paid. Thank you.',
|
||||
'card_title' => 'Payment method',
|
||||
'card_body' => 'The card your packages are charged to. It goes straight to Stripe — the number never reaches this server.',
|
||||
'card_on_file' => 'On file: :brand •••• :last4, valid until :month/:year',
|
||||
'card_new' => 'New card',
|
||||
'card_save' => 'Save card',
|
||||
'card_saved' => 'Card saved. It applies from the next charge.',
|
||||
'card_unavailable' => 'The card form cannot be loaded right now — this installation has no publishable Stripe key. Please contact support.',
|
||||
// Smaller plans are listed even when they cannot be taken, with the reason
|
||||
// in the customer's own numbers.
|
||||
'downgrade' => 'Smaller package',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,24 @@
|
|||
<p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Ganz oben, vor allem anderen: wer hier landet, weil eine Abbuchung
|
||||
gescheitert ist, soll nicht erst an Paketen vorbeiscrollen. Darunter
|
||||
die zwei Bauteile, die das Problem tatsächlich lösen — offene
|
||||
Rechnungen begleichen und die Karte tauschen. --}}
|
||||
@if ($pastDue)
|
||||
<x-ui.alert variant="danger" class="animate-rise">
|
||||
<p class="font-semibold">{{ __('billing.past_due_title') }}</p>
|
||||
<p class="mt-1 text-sm">{{ __('billing.past_due_body') }}</p>
|
||||
</x-ui.alert>
|
||||
@endif
|
||||
|
||||
@if ($hasStripeCustomer)
|
||||
<div class="grid gap-5 animate-rise [animation-delay:20ms] lg:grid-cols-2 lg:items-start">
|
||||
@livewire('open-invoices')
|
||||
@livewire('payment-method')
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- purchase() refuses before touching anything when the active mode has
|
||||
no Stripe key — a sentence here, not a 500 page, and not a cart entry
|
||||
nobody can ever pay for.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('billing.invoices_open_title') }}</h2>
|
||||
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('billing.invoices_open_body') }}</p>
|
||||
</div>
|
||||
|
||||
@if ($failure !== null)
|
||||
{{-- Stripes Wortlaut, nicht unsere Zusammenfassung: er nennt den Grund,
|
||||
und danach kann der Kunde handeln. --}}
|
||||
<x-ui.alert variant="danger">{{ $failure }}</x-ui.alert>
|
||||
@endif
|
||||
|
||||
@if ($invoices === [])
|
||||
<p class="text-sm text-muted">{{ __('billing.invoices_none') }}</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line text-left text-xs font-medium text-muted">
|
||||
<th class="py-2 pr-4">{{ __('billing.invoice_number') }}</th>
|
||||
<th class="py-2 pr-4">{{ __('billing.invoice_date') }}</th>
|
||||
<th class="py-2 pr-4 text-right">{{ __('billing.invoice_amount') }}</th>
|
||||
<th class="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($invoices as $invoice)
|
||||
<tr wire:key="invoice-{{ $invoice['id'] }}" class="border-b border-line last:border-0">
|
||||
<td class="py-3 pr-4 font-mono text-xs text-body">{{ $invoice['number'] ?? $invoice['id'] }}</td>
|
||||
<td class="py-3 pr-4 text-muted">
|
||||
{{-- Stripes `created` ist ein Unix-Zeitstempel, kein
|
||||
Carbon. ->local() ist R19 und nicht verhandelbar:
|
||||
was ein Mensch liest, geht auf die Wanduhr. --}}
|
||||
{{ \Illuminate\Support\Carbon::createFromTimestamp((int) $invoice['created_at'])->local()->isoFormat('D. MMM YYYY') }}
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-right font-medium text-ink">
|
||||
{{ number_format($invoice['amount_due_cents'] / 100, 2, ',', '.') }} {{ $invoice['currency'] }}
|
||||
</td>
|
||||
<td class="py-3 text-right">
|
||||
<x-ui.button variant="secondary" size="sm"
|
||||
wire:click="pay('{{ $invoice['id'] }}')"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="pay('{{ $invoice['id'] }}')">
|
||||
{{ __('billing.invoice_pay') }}
|
||||
</x-ui.button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (count($invoices) > 1)
|
||||
<x-ui.button variant="primary" wire:click="payAll"
|
||||
wire:loading.attr="disabled" wire:target="payAll">
|
||||
{{ __('billing.invoice_pay_all') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('billing.card_title') }}</h2>
|
||||
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('billing.card_body') }}</p>
|
||||
</div>
|
||||
|
||||
@if ($card !== null)
|
||||
<p class="rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm text-body">
|
||||
{{ __('billing.card_on_file', [
|
||||
'brand' => \Illuminate\Support\Str::title($card['brand']),
|
||||
'last4' => $card['last4'],
|
||||
'month' => str_pad((string) $card['exp_month'], 2, '0', STR_PAD_LEFT),
|
||||
'year' => $card['exp_year'],
|
||||
]) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if ($clientSecret === null)
|
||||
<x-ui.alert variant="warning">{{ __('billing.card_unavailable') }}</x-ui.alert>
|
||||
@else
|
||||
{{-- Stripe.js kommt von js.stripe.com und wird bewusst NICHT selbst
|
||||
gehostet. Das ist die eine Ausnahme von „keine fremden Quellen im
|
||||
Browser": Stripe untersagt das Spiegeln, und eine eigene Kopie
|
||||
nähme dieser Installation die vereinfachte PCI-Einstufung (SAQ-A),
|
||||
weil dann wieder Kartendaten durch eigenen Code laufen könnten.
|
||||
|
||||
Nur auf dieser Seite geladen, nicht im Layout — jede andere Seite
|
||||
des Portals kommt ohne aus und soll auch keine Verbindung dorthin
|
||||
aufbauen.
|
||||
|
||||
`wire:ignore` um das Feld: Elements hängt sein eigenes iframe
|
||||
hinein, und ein Livewire-Neuaufbau würde es abreißen. --}}
|
||||
<div wire:ignore>
|
||||
<label for="card-element" class="block text-sm font-medium text-body">{{ __('billing.card_new') }}</label>
|
||||
<div id="card-element" class="mt-1.5 rounded border border-line-strong bg-surface px-3.5 py-3"></div>
|
||||
<p id="card-error" class="mt-1.5 text-xs text-danger" role="alert"></p>
|
||||
</div>
|
||||
|
||||
<x-ui.button variant="primary" id="card-submit" type="button">
|
||||
{{ __('billing.card_save') }}
|
||||
</x-ui.button>
|
||||
|
||||
@script
|
||||
<script>
|
||||
const laden = () => new Promise((fertig) => {
|
||||
if (window.Stripe) { fertig(); return; }
|
||||
|
||||
const skript = document.createElement('script');
|
||||
skript.src = 'https://js.stripe.com/v3/';
|
||||
skript.onload = fertig;
|
||||
document.head.appendChild(skript);
|
||||
});
|
||||
|
||||
laden().then(() => {
|
||||
const stripe = Stripe(@js($publishableKey));
|
||||
const karte = stripe.elements().create('card');
|
||||
karte.mount('#card-element');
|
||||
|
||||
const knopf = document.getElementById('card-submit');
|
||||
const fehler = document.getElementById('card-error');
|
||||
|
||||
knopf.addEventListener('click', async () => {
|
||||
knopf.disabled = true;
|
||||
fehler.textContent = '';
|
||||
|
||||
const { setupIntent, error } = await stripe.confirmCardSetup(
|
||||
@js($clientSecret), { payment_method: { card: karte } }
|
||||
);
|
||||
|
||||
knopf.disabled = false;
|
||||
|
||||
// Stripes eigene Meldung, nicht eine eigene Umschreibung:
|
||||
// sie nennt den Grund (abgelaufen, abgelehnt, falsche
|
||||
// Prüfziffer), und der Kunde kann danach handeln.
|
||||
if (error) { fehler.textContent = error.message; return; }
|
||||
|
||||
$wire.confirmed(setupIntent.payment_method);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endscript
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\OpenInvoices;
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use App\Services\Stripe\FakeStripeClient;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function portalUserOwing(FakeStripeClient $fake): User
|
||||
{
|
||||
$fake->openInvoiceRows = [[
|
||||
'id' => 'in_1', 'number' => 'R-1', 'amount_due_cents' => 21480,
|
||||
'currency' => 'EUR', 'created_at' => '1750000000',
|
||||
]];
|
||||
|
||||
$user = User::factory()->create(['email' => 'schuldner@portal.test', 'is_admin' => false]);
|
||||
Customer::factory()->create(['email' => 'schuldner@portal.test', 'stripe_customer_id' => 'cus_42']);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('shows what is still owed', function () {
|
||||
$fake = new FakeStripeClient;
|
||||
app()->instance(StripeClient::class, $fake);
|
||||
|
||||
Livewire::actingAs(portalUserOwing($fake))
|
||||
->test(OpenInvoices::class)
|
||||
->assertSee('R-1')
|
||||
->assertSee('214,80');
|
||||
});
|
||||
|
||||
it('settles an invoice when the customer asks', function () {
|
||||
$fake = new FakeStripeClient;
|
||||
app()->instance(StripeClient::class, $fake);
|
||||
|
||||
Livewire::actingAs(portalUserOwing($fake))
|
||||
->test(OpenInvoices::class)
|
||||
->call('pay', 'in_1')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($fake->paidInvoices)->toBe(['in_1']);
|
||||
});
|
||||
|
||||
it('shows a refusal instead of pretending it worked', function () {
|
||||
// Der Fall, der wirklich eintritt. Eine grüne Meldung über einer
|
||||
// abgelehnten Karte ist die teuerste Anzeige, die diese Seite haben kann:
|
||||
// der Kunde geht davon aus, es sei erledigt, und die nächste Nachricht,
|
||||
// die er bekommt, ist eine Mahnung.
|
||||
$fake = new FakeStripeClient;
|
||||
$fake->payDeclines = true;
|
||||
app()->instance(StripeClient::class, $fake);
|
||||
|
||||
Livewire::actingAs(portalUserOwing($fake))
|
||||
->test(OpenInvoices::class)
|
||||
->call('pay', 'in_1')
|
||||
->assertSee('declined');
|
||||
|
||||
expect($fake->paidInvoices)->toBe([]);
|
||||
});
|
||||
|
||||
it('refuses to pay an invoice that is not this customer\'s', function () {
|
||||
// Die ID kommt aus dem Browser. Ohne diese Prüfung könnte jemand eine
|
||||
// fremde Rechnungs-ID einreichen — und Stripe zöge sie beim fremden Kunden
|
||||
// ein, auf dessen Karte.
|
||||
$fake = new FakeStripeClient;
|
||||
app()->instance(StripeClient::class, $fake);
|
||||
|
||||
Livewire::actingAs(portalUserOwing($fake))
|
||||
->test(OpenInvoices::class)
|
||||
->call('pay', 'in_fremd')
|
||||
->assertForbidden();
|
||||
|
||||
expect($fake->paidInvoices)->toBe([]);
|
||||
});
|
||||
|
||||
it('stops paying the rest after the first refusal', function () {
|
||||
// Alle weiteren scheitern an derselben Karte, und drei Ablehnungen
|
||||
// hintereinander lassen Stripes Betrugserkennung anschlagen — danach
|
||||
// lehnt sie auch die Karte ab, die in Ordnung wäre.
|
||||
$fake = new FakeStripeClient;
|
||||
$fake->payDeclines = true;
|
||||
app()->instance(StripeClient::class, $fake);
|
||||
|
||||
$user = portalUserOwing($fake);
|
||||
$fake->openInvoiceRows[] = [
|
||||
'id' => 'in_2', 'number' => 'R-2', 'amount_due_cents' => 1000,
|
||||
'currency' => 'EUR', 'created_at' => '1750000000',
|
||||
];
|
||||
|
||||
Livewire::actingAs($user)->test(OpenInvoices::class)->call('payAll');
|
||||
|
||||
expect($fake->paidInvoices)->toBe([]);
|
||||
});
|
||||
|
||||
it('says so plainly when nothing is open', function () {
|
||||
$fake = new FakeStripeClient;
|
||||
app()->instance(StripeClient::class, $fake);
|
||||
|
||||
$user = User::factory()->create(['email' => 'schuldenfrei@portal.test', 'is_admin' => false]);
|
||||
Customer::factory()->create(['email' => 'schuldenfrei@portal.test', 'stripe_customer_id' => 'cus_9']);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(OpenInvoices::class)
|
||||
->assertSee(__('billing.invoices_none'));
|
||||
});
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Billing;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Order;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use App\Services\Stripe\FakeStripeClient;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Bis hierher stand im Portal NICHTS, wenn eine Abbuchung scheiterte.
|
||||
* `ApplyStripeBillingEvent::invoicePaymentFailed()` setzt `past_due` und
|
||||
* schweigt — der Kunde erfuhr davon erst, wenn seine Cloud stand.
|
||||
*/
|
||||
function portalCustomerWith(string $stripeStatus): User
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'faellig@portal.test', 'is_admin' => false]);
|
||||
$customer = Customer::factory()->create(['email' => 'faellig@portal.test', 'stripe_customer_id' => 'cus_42']);
|
||||
$order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'start']);
|
||||
Instance::factory()->create([
|
||||
'customer_id' => $customer->id, 'order_id' => $order->id,
|
||||
'plan' => 'start', 'status' => 'active',
|
||||
]);
|
||||
Subscription::factory()->create([
|
||||
'customer_id' => $customer->id, 'stripe_status' => $stripeStatus,
|
||||
]);
|
||||
|
||||
withStripeSecret();
|
||||
app()->instance(StripeClient::class, new FakeStripeClient);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('says plainly that a payment failed', function () {
|
||||
Livewire::actingAs(portalCustomerWith('past_due'))
|
||||
->test(Billing::class)
|
||||
->assertSee(__('billing.past_due_title'));
|
||||
});
|
||||
|
||||
it('stays quiet when nothing is owed', function () {
|
||||
// Ein Hinweis, der immer da ist, ist keiner.
|
||||
Livewire::actingAs(portalCustomerWith('active'))
|
||||
->test(Billing::class)
|
||||
->assertDontSee(__('billing.past_due_title'));
|
||||
});
|
||||
|
||||
it('still renders for a customer who has never been to the checkout', function () {
|
||||
// Der Fehler, den ich beim ersten Anlauf gebaut habe: die zwei Bauteile
|
||||
// waren bedingungslos eingebettet, und beide brechen ohne Stripe-Kunden
|
||||
// mit 404 ab. 53 Bestandstests fielen um — jeder Kunde ohne
|
||||
// stripe_customer_id bekam statt seiner Abrechnungsseite eine 404.
|
||||
$user = User::factory()->create(['email' => 'ohnestripe@portal.test', 'is_admin' => false]);
|
||||
Customer::factory()->create(['email' => 'ohnestripe@portal.test', 'stripe_customer_id' => null]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Billing::class)
|
||||
->assertOk()
|
||||
->assertDontSee(__('billing.invoices_open_title'));
|
||||
});
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\PaymentMethod;
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use App\Services\Stripe\FakeStripeClient;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use App\Support\StripePublishableKey;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Bis hierher konnte ein Kunde seine Karte NICHT tauschen — es gab im ganzen
|
||||
* Portal keine Zahlungsmittel-Verwaltung. Eine abgelaufene Karte war damit
|
||||
* eine Sackgasse: die Abbuchung scheitert, der Vertrag geht auf `past_due`,
|
||||
* und der Kunde hat keine Stelle, an der er die Ursache beheben könnte.
|
||||
*
|
||||
* Deshalb ist das die ERSTE Lieferung und der Mahnlauf die zweite. Ein
|
||||
* Mahnlauf über einem Portal ohne Kartenwechsel mahnt jemanden für etwas, das
|
||||
* er nicht abstellen kann.
|
||||
*/
|
||||
function portalUserWithStripe(): User
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'karte@portal.test', 'is_admin' => false]);
|
||||
Customer::factory()->create(['email' => 'karte@portal.test', 'stripe_customer_id' => 'cus_42']);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
app()->instance(StripeClient::class, new FakeStripeClient);
|
||||
StripePublishableKey::set('pk_test_sichtbar');
|
||||
});
|
||||
|
||||
it('hands the browser a client secret and the publishable key', function () {
|
||||
// Am übergebenen Wert geprüft, nicht am HTML: der Schlüssel steht in einem
|
||||
// `@script`-Block, und den legt Livewire in die Effects-Nutzlast statt in
|
||||
// das gerenderte Markup. Ein assertSee darauf prüfte die Testfassung von
|
||||
// Livewire, nicht diese Komponente.
|
||||
Livewire::actingAs(portalUserWithStripe())
|
||||
->test(PaymentMethod::class)
|
||||
->assertSet('clientSecret', 'seti_fake_secret_cus_42')
|
||||
->assertViewHas('publishableKey', 'pk_test_sichtbar');
|
||||
});
|
||||
|
||||
it('stores the confirmed payment method as the default', function () {
|
||||
// Der Kern: eine Karte zu hinterlegen genügt nicht. Ohne diesen Schritt
|
||||
// bucht Stripe weiter mit der kaputten ab, und der Kunde steht im nächsten
|
||||
// Monat wieder in derselben Mahnung.
|
||||
$fake = new FakeStripeClient;
|
||||
app()->instance(StripeClient::class, $fake);
|
||||
|
||||
Livewire::actingAs(portalUserWithStripe())
|
||||
->test(PaymentMethod::class)
|
||||
->call('confirmed', 'pm_neu123')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($fake->defaultPaymentMethods['cus_42'])->toBe('pm_neu123');
|
||||
});
|
||||
|
||||
it('shows which card is on file once one is', function () {
|
||||
$fake = new FakeStripeClient;
|
||||
$fake->defaultPaymentMethods['cus_42'] = 'pm_vorhanden';
|
||||
app()->instance(StripeClient::class, $fake);
|
||||
|
||||
Livewire::actingAs(portalUserWithStripe())
|
||||
->test(PaymentMethod::class)
|
||||
->assertSee('4242');
|
||||
});
|
||||
|
||||
it('says so instead of drawing a form that never loads', function () {
|
||||
// Ohne Publishable Key lädt Elements nicht. Ein gezeichnetes, aber totes
|
||||
// Kartenfeld ist die schlimmste Variante: der Kunde tippt seine Nummer
|
||||
// ein, nichts passiert, und er hält seine Karte für abgelehnt.
|
||||
StripePublishableKey::set('');
|
||||
config()->set('services.stripe.key', '');
|
||||
config()->set('services.stripe.key_test', '');
|
||||
|
||||
Livewire::actingAs(portalUserWithStripe())
|
||||
->test(PaymentMethod::class)
|
||||
->assertSet('clientSecret', null)
|
||||
->assertSee(__('billing.card_unavailable'));
|
||||
});
|
||||
|
||||
it('refuses a payment method id that is not one', function () {
|
||||
// `confirmed()` wird aus dem Browser gerufen. Ohne diese Prüfung wäre das
|
||||
// ein Weg, einen fremden Bezeichner an das eigene Stripe-Konto zu hängen.
|
||||
$fake = new FakeStripeClient;
|
||||
app()->instance(StripeClient::class, $fake);
|
||||
|
||||
Livewire::actingAs(portalUserWithStripe())
|
||||
->test(PaymentMethod::class)
|
||||
->call('confirmed', 'nicht-eine-pm-id')
|
||||
->assertHasErrors();
|
||||
|
||||
expect($fake->defaultPaymentMethods)->toBe([]);
|
||||
});
|
||||
|
||||
it('is not reachable for a visitor without a customer record', function () {
|
||||
$fremder = User::factory()->create(['email' => 'ohne@portal.test', 'is_admin' => false]);
|
||||
|
||||
Livewire::actingAs($fremder)
|
||||
->test(PaymentMethod::class)
|
||||
->assertStatus(404);
|
||||
});
|
||||
Loading…
Reference in New Issue