mivita/tests/Feature/StripeAboTest.php
Kevin 68d230d4e5 Stripe: Kundendaten, Rechnungsnummer, manueller Zahlungseingang
Rueckmeldung der Buchhaltung und des Stripe-Calls (dev/2026-08-26-stripe/):
Stripe kannte bisher nur die E-Mail des Kunden, Zahlungen waren im Dashboard
weder ueber die Bestell- noch ueber die Rechnungsnummer auffindbar, und ein
Zahlungseingang mit falschem Verwendungszweck zwang die Buchhaltung dazu, die
Rechnung von Hand zu schreiben.

- SE1: Der Checkout haengt einen Stripe-Customer mit Name, E-Mail und
  Rechnungsanschrift an die Session (customer statt customer_email); die ID
  liegt an shopping_users und wird wiederverwendet. Namensformat "Vorname
  Nachname (Firma)", reine Firmenkunden unter der Firma. Faellt die
  Customer-Anlage aus, laeuft der Checkout auf dem bisherigen Weg weiter.
  DSGVO-Loeschungen entfernen jetzt auch das Customer-Objekt; die Charges
  bleiben als Buchungsbeleg bestehen.
- SE2: StripeMetadataBuilder als einzige Quelle fuer Metadaten und die
  description (Bestellnummer, Referenz, Kundenname) - damit greift die
  Dashboard-Suche, die bisher nichts Lesbares zu fassen bekam.
- SE3: Die Rechnungsnummer entsteht erst nach der Zahlung und wird daher per
  Job am PaymentIntent nachgetragen (Vorab-Reservierung wuerde Luecken im
  Nummernkreis erzeugen). stripe:backfill-invoice-metadata traegt sie
  rueckwirkend ab dem Go-Live 2026-07-10 nach.
- SE4: Neuer Admin-Button "Zahlungseingang erfassen" an jeder unbezahlten
  Bestellung - bisher gab es ihn nur bei Rechnungskauf, und er setzte nur
  paid, ohne Rechnung, Punkte, Abo-Aktivierung oder Mail. Der neue Weg laeuft
  durch paymentStatusPaidAction, verlangt den Verwendungszweck als Beleg und
  laesst einen abweichenden Betrag nur mit ausdruecklicher Bestaetigung zu.
- S6.4 (aus dem Juli-Plan vorgezogen): stripe:sync-payment-status gleicht
  offene Zahlungen aktiv gegen die API ab. Aufgefallen beim Testlauf - ein nie
  zugestellter Webhook war bisher nicht reparierbar, das Geld lag bei Stripe
  waehrend die Bestellung auf offen stand. Laeuft stuendlich im Scheduler.
- 110 Tests (Pest) fuer alle Pfade, Stripe-Client durchgehend gemockt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:04:28 +00:00

622 lines
22 KiB
PHP

<?php
use App\Cron\UserMakeOrder;
use App\Models\PaymentTransaction;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\StripeWebhookEvent;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserShop;
use App\Services\AboHelper;
use App\Services\Payment\Stripe\DTOs\StripeWebhookResult;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Payment\Stripe\StripePaymentService;
use App\Services\Payment\Stripe\StripeWebhookHandler;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\Exception\CardException;
use Stripe\PaymentIntent;
use Stripe\StripeClient;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function (): void {
Mail::fake();
});
/**
* Order-Fixture für eine 89,00-EUR-Abo-Bestellung (Plan S4).
*
* @return array{0: ShoppingOrder, 1: ShoppingUser}
*/
function stripeAboOrderBootstrap(array $orderOverrides = []): array
{
$country = ShippingCountry::first() ?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
$shopOwner = User::forceCreate([
'email' => 'sa-owner-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$userShop = UserShop::first() ?? UserShop::create([
'user_id' => $shopOwner->id,
'name' => 'SA Shop',
'slug' => 'sa-shop-'.uniqid(),
'domain' => 'sa-test.local',
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $shopOwner->id,
'is_for' => 'me',
'billing_firstname' => 'Stripe',
'billing_lastname' => 'Abo',
'billing_email' => 'stripe-abo-'.uniqid().'@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
]);
$order = ShoppingOrder::create(array_merge([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $shopOwner->id,
'country_id' => $country->id,
'user_shop_id' => $userShop->id,
'total' => 89.00,
'total_shipping' => 89.00,
'paid' => false,
'txaction' => null,
'mode' => 'test',
'is_abo' => 1,
'abo_interval' => 5,
], $orderOverrides));
return [$order, $shoppingUser];
}
function stripeAboUserAbo(array $overrides = []): UserAbo
{
$consultant = User::forceCreate([
'email' => 'sa-abo-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
return UserAbo::create(array_merge([
'user_id' => $consultant->id,
'shopping_user_id' => 1,
'is_for' => 'me',
'email' => $consultant->email,
'payone_userid' => 0,
'payment_provider' => 'stripe',
'stripe_customer_id' => 'cus_abo_1',
'stripe_payment_method_id' => 'pm_abo_1',
'clearingtype' => 'stripe',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => now()->addDays(3),
], $overrides));
}
/**
* Stripe-Client-Mock, der genau einen fachlichen request()-Aufruf beantwortet
* und die Argumente zur Assertion herausreicht (Muster: StripeCheckoutTest).
*
* Die Customer-Anlage aus Phase SE1 wird transparent mitbeantwortet — sie ist
* Infrastruktur und nicht Gegenstand dieser Assertions.
*/
function stripeAboMockClient(mixed $result, ?array &$captured = null): void
{
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$client->shouldReceive('request')
->withArgs(fn (...$args) => ($args[1] ?? null) === '/v1/customers')
->andReturn(Customer::constructFrom(['id' => 'cus_abo_checkout', 'object' => 'customer']));
$expectation = $client->shouldReceive('request')
->once()
->withArgs(function (...$args) use (&$captured) {
if (($args[1] ?? null) === '/v1/customers') {
return false;
}
$captured = $args;
return true;
});
if ($result instanceof Throwable) {
$expectation->andThrow($result);
} else {
$expectation->andReturn($result);
}
$wrapper = Mockery::mock(StripeClientWrapper::class);
$wrapper->shouldReceive('client')->andReturn($client);
app()->instance(StripeClientWrapper::class, $wrapper);
}
function handleStripeAboEvent(string $type, array $object, string $eventId): StripeWebhookResult
{
$payload = json_encode([
'id' => $eventId,
'object' => 'event',
'api_version' => '2026-06-30',
'type' => $type,
'data' => ['object' => $object],
]);
$timestamp = time();
$signed = hash_hmac('sha256', $timestamp.'.'.$payload, config('stripe.webhook_secret'));
return app(StripeWebhookHandler::class)->handle($payload, "t={$timestamp},v1={$signed}");
}
// --- S4.1: Abo-Erstzahlung speichert das Zahlungsmittel ---
it('requests off-session saving and customer creation for abo checkout sessions', function () {
[$order, $shoppingUser] = stripeAboOrderBootstrap();
$captured = null;
stripeAboMockClient(Session::constructFrom([
'id' => 'cs_abo_1',
'object' => 'checkout.session',
'url' => 'https://checkout.stripe.com/c/pay/cs_abo_1',
'status' => 'open',
'livemode' => false,
]), $captured);
app(StripePaymentService::class)->initiateCheckoutPayment(
$shoppingUser,
$order,
8900,
'EUR',
'aref123',
'https://checkout.test/transaction/status/success/aref123',
'https://checkout.test/transaction/status/cancel/aref123',
);
$params = $captured[2];
// Seit SE1 hängt der Checkout einen benannten Customer an, statt Stripe
// einen namenlosen anlegen zu lassen (customer_creation).
expect($params['customer'])->toBe('cus_abo_checkout')
->and($params)->not->toHaveKey('customer_creation')
->and($params['payment_intent_data']['setup_future_usage'])->toBe('off_session');
});
it('does not request off-session saving for one-time checkout sessions', function () {
[$order, $shoppingUser] = stripeAboOrderBootstrap(['is_abo' => 0, 'abo_interval' => 0]);
$captured = null;
stripeAboMockClient(Session::constructFrom([
'id' => 'cs_once_1',
'object' => 'checkout.session',
'url' => 'https://checkout.stripe.com/c/pay/cs_once_1',
'status' => 'open',
'livemode' => false,
]), $captured);
app(StripePaymentService::class)->initiateCheckoutPayment(
$shoppingUser,
$order,
8900,
'EUR',
'oref123',
'https://s.test/ok',
'https://s.test/cancel',
);
$params = $captured[2];
expect($params)->not->toHaveKey('customer_creation')
->and($params['customer'])->toBe('cus_abo_checkout')
->and($params['payment_intent_data'])->not->toHaveKey('setup_future_usage');
});
// --- S4.2: Abo-Anlage über den Webhook-Paid-Pfad ---
it('creates the user abo with stripe fields from the webhook paid path', function () {
[$order] = stripeAboOrderBootstrap();
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sa-ref-'.uniqid(),
'amount' => 8900,
'currency' => 'EUR',
'is_abo' => true,
'abo_interval' => 5,
'mode' => 'test',
]);
handleStripeAboEvent('payment_intent.succeeded', [
'id' => 'pi_abo_paid',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount_received' => 8900,
'customer' => 'cus_new_1',
'payment_method' => 'pm_new_1',
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id],
], 'evt_abo_paid');
$abo = $order->fresh()->getUserAbo();
expect($order->fresh()->paid)->toBeTruthy()
->and($abo)->not->toBeNull()
->and($abo->payment_provider)->toBe('stripe')
->and($abo->stripe_customer_id)->toBe('cus_new_1')
->and($abo->stripe_payment_method_id)->toBe('pm_new_1')
->and((int) $abo->payone_userid)->toBe(0)
->and($payment->fresh()->stripe_customer_id)->toBe('cus_new_1');
});
it('defers abo creation on browser return until stripe ids arrived via webhook', function () {
[$order] = stripeAboOrderBootstrap();
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sa-ref-'.uniqid(),
'amount' => 8900,
'currency' => 'EUR',
'is_abo' => true,
'abo_interval' => 5,
'mode' => 'test',
]);
// Browser-Return läuft vor dem Webhook: noch keine Stripe-IDs an der Zahlung
AboHelper::createNewAbo($payment);
expect(UserAboOrder::where('shopping_order_id', $order->id)->exists())->toBeFalse();
// Webhook liefert die IDs und legt das Abo vollständig an
handleStripeAboEvent('payment_intent.succeeded', [
'id' => 'pi_abo_late',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount_received' => 8900,
'customer' => 'cus_late_1',
'payment_method' => 'pm_late_1',
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id],
], 'evt_abo_late');
$abo = $order->fresh()->getUserAbo();
expect($abo)->not->toBeNull()
->and($abo->stripe_payment_method_id)->toBe('pm_late_1');
});
it('never creates a new abo for recurring cron charges even when the webhook wins the race', function () {
$userAbo = stripeAboUserAbo();
[$order] = stripeAboOrderBootstrap();
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sa-ref-'.uniqid(),
'amount' => 8900,
'currency' => 'EUR',
'is_abo' => true,
'abo_interval' => 5,
'mode' => 'test',
'user_abo_id' => $userAbo->id,
]);
$abosBefore = UserAbo::count();
handleStripeAboEvent('payment_intent.succeeded', [
'id' => 'pi_recurring',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount_received' => 8900,
'customer' => 'cus_abo_1',
'payment_method' => 'pm_abo_1',
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id, 'user_abo_id' => (string) $userAbo->id],
], 'evt_recurring');
expect($order->fresh()->paid)->toBeTruthy()
->and(UserAbo::count())->toBe($abosBefore);
});
// --- S4 / §7.2 Nr. 3: Recovery-Zahlung zieht das Abo auf Stripe um ---
it('advances a payone abo to stripe after a recovery payment via webhook', function () {
$userAbo = stripeAboUserAbo([
'payment_provider' => 'payone',
'payone_userid' => 900500,
'clearingtype' => 'cc',
'stripe_customer_id' => null,
'stripe_payment_method_id' => null,
'status' => 3,
]);
[$order] = stripeAboOrderBootstrap(['recovery_user_abo_id' => $userAbo->id]);
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sa-ref-'.uniqid(),
'amount' => 8900,
'currency' => 'EUR',
'is_abo' => true,
'abo_interval' => 5,
'mode' => 'test',
]);
handleStripeAboEvent('payment_intent.succeeded', [
'id' => 'pi_recovery',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount_received' => 8900,
'customer' => 'cus_rec_1',
'payment_method' => 'pm_rec_1',
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id],
], 'evt_recovery');
$fresh = $userAbo->fresh();
expect($order->fresh()->paid)->toBeTruthy()
->and($fresh->payment_provider)->toBe('stripe')
->and($fresh->stripe_customer_id)->toBe('cus_rec_1')
->and($fresh->stripe_payment_method_id)->toBe('pm_rec_1')
->and($fresh->status)->toBe(2)
->and(UserAbo::count())->toBe(1)
->and(UserAboOrder::where('shopping_order_id', $order->id)->where('paid', true)->exists())->toBeTrue();
});
// --- S4.3/S4.5: Wiederkehrende Abbuchung off-session ---
it('charges a stripe abo off-session with an idempotency key and records the transaction', function () {
$userAbo = stripeAboUserAbo();
[$order, $shoppingUser] = stripeAboOrderBootstrap();
$captured = null;
stripeAboMockClient(PaymentIntent::constructFrom([
'id' => 'pi_charge_1',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount' => 8900,
'customer' => 'cus_abo_1',
'payment_method' => 'pm_abo_1',
'livemode' => false,
]), $captured);
$result = app(StripePaymentService::class)->chargeAbo($userAbo, $order, $shoppingUser, 8900, 'EUR');
$params = $captured[2];
$opts = $captured[3] ?? null;
$payment = $result->shoppingPayment->fresh();
expect($result->success)->toBeTrue()
->and($result->status)->toBe('APPROVED')
->and($result->transactionId)->toBe('pi_charge_1')
->and($params['customer'])->toBe('cus_abo_1')
->and($params['payment_method'])->toBe('pm_abo_1')
->and($params['off_session'])->toBeTrue()
->and($params['confirm'])->toBeTrue()
->and($params['metadata']['user_abo_id'])->toBe((string) $userAbo->id)
->and($opts['idempotency_key'] ?? null)->toBe('abo-'.$userAbo->id.'-order-'.$order->id)
->and($payment->payment_provider)->toBe('stripe')
->and($payment->user_abo_id)->toBe($userAbo->id)
->and($payment->external_reference)->toBe('pi_charge_1')
->and(PaymentTransaction::where('shopping_payment_id', $payment->id)->where('request', 'authorization')->exists())->toBeTrue();
});
it('maps a card decline to an error result with customer message', function () {
$userAbo = stripeAboUserAbo();
[$order, $shoppingUser] = stripeAboOrderBootstrap();
stripeAboMockClient(CardException::factory(
'Your card was declined.',
402,
null,
['error' => ['code' => 'card_declined', 'message' => 'Your card was declined.', 'decline_code' => 'generic_decline', 'type' => 'card_error']],
));
$result = app(StripePaymentService::class)->chargeAbo($userAbo, $order, $shoppingUser, 8900, 'EUR');
expect($result->success)->toBeFalse()
->and($result->status)->toBe('ERROR')
->and($result->errorMessage)->toContain('card_declined')
->and($result->customerMessage)->toBe(__('payment.payment_error_reasons.card_declined'))
->and(PaymentTransaction::where('shopping_payment_id', $result->shoppingPayment->id)->where('errorcode', 'card_declined')->exists())->toBeTrue();
});
it('fails without touching stripe when the abo has no stored payment method', function () {
$userAbo = stripeAboUserAbo(['stripe_payment_method_id' => null]);
[$order, $shoppingUser] = stripeAboOrderBootstrap();
$result = app(StripePaymentService::class)->chargeAbo($userAbo, $order, $shoppingUser, 8900, 'EUR');
expect($result->success)->toBeFalse()
->and($result->status)->toBe('ERROR')
->and($result->customerMessage)->toBe(__('payment.stripe_abo_payment_method_missing'))
->and($result->shoppingPayment)->toBeNull();
});
// --- S4.4: Provider-Dispatch im Cron ---
it('dispatches the cron payment of a stripe abo through the stripe gateway', function () {
$userAbo = stripeAboUserAbo();
[$order, $shoppingUser] = stripeAboOrderBootstrap();
stripeAboMockClient(PaymentIntent::constructFrom([
'id' => 'pi_cron_1',
'object' => 'payment_intent',
'status' => 'succeeded',
'livemode' => false,
]));
$userOrder = new UserMakeOrder($userAbo);
$reflection = new ReflectionClass($userOrder);
foreach (['shopping_user' => $shoppingUser, 'shopping_order' => $order] as $property => $value) {
$prop = $reflection->getProperty($property);
$prop->setAccessible(true);
$prop->setValue($userOrder, $value);
}
$response = $userOrder->makePayment();
expect($response['status'])->toBe('APPROVED')
->and($response['provider'])->toBe('stripe')
->and($response['txid'])->toBe('pi_cron_1')
->and($userOrder->getShoppingPayment()->payment_provider)->toBe('stripe');
});
it('returns an error response for a declined cron charge so the cron sets abo status 3', function () {
$userAbo = stripeAboUserAbo();
[$order, $shoppingUser] = stripeAboOrderBootstrap();
stripeAboMockClient(CardException::factory(
'Authentication required.',
402,
null,
['error' => ['code' => 'authentication_required', 'message' => 'Authentication required.', 'type' => 'card_error']],
));
$userOrder = new UserMakeOrder($userAbo);
$reflection = new ReflectionClass($userOrder);
foreach (['shopping_user' => $shoppingUser, 'shopping_order' => $order] as $property => $value) {
$prop = $reflection->getProperty($property);
$prop->setAccessible(true);
$prop->setValue($userOrder, $value);
}
$response = $userOrder->makePayment();
expect($response['status'])->toBe('ERROR')
->and($response['customermessage'])->toBe(__('payment.stripe_abo_authentication_required'));
});
// --- Webhook-Race: Cron-Transaktion noch nicht committet ---
it('requests a stripe retry when our payment is not yet visible and processes it later', function () {
[$order] = stripeAboOrderBootstrap();
$object = [
'id' => 'pi_race',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount_received' => 8900,
'customer' => 'cus_abo_1',
'payment_method' => 'pm_abo_1',
'livemode' => false,
'metadata' => ['shopping_payment_id' => '999999'],
];
$first = handleStripeAboEvent('payment_intent.succeeded', $object, 'evt_race');
expect($first->statusCode)->toBe(503)
->and($first->state)->toBe('payment_not_yet_visible')
->and(StripeWebhookEvent::whereEventId('evt_race')->first()->processed_at)->toBeNull();
// Zahlung wird sichtbar (Cron-Commit), Stripe stellt erneut zu
ShoppingPayment::forceCreate([
'id' => 999999,
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sa-ref-race',
'amount' => 8900,
'currency' => 'EUR',
'mode' => 'test',
'user_abo_id' => stripeAboUserAbo()->id,
]);
$second = handleStripeAboEvent('payment_intent.succeeded', $object, 'evt_race');
expect($second->statusCode)->toBe(200)
->and($second->state)->toBe('processed')
->and($order->fresh()->paid)->toBeTruthy();
});
it('processes pending stored events via the maintenance command once the payment is visible', function () {
[$order] = stripeAboOrderBootstrap();
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sa-ref-'.uniqid(),
'amount' => 8900,
'currency' => 'EUR',
'is_abo' => true,
'abo_interval' => 5,
'mode' => 'test',
'user_abo_id' => stripeAboUserAbo()->id,
]);
// Wie nach einem 503: Event liegt gespeichert, aber unverarbeitet im Store
StripeWebhookEvent::create([
'event_id' => 'evt_pending_cmd',
'type' => 'payment_intent.succeeded',
'payload' => [
'id' => 'pi_pending_cmd',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount_received' => 8900,
'customer' => 'cus_abo_1',
'payment_method' => 'pm_abo_1',
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id],
],
]);
// Nicht zuordenbares Event bleibt liegen
StripeWebhookEvent::create([
'event_id' => 'evt_still_missing',
'type' => 'payment_intent.succeeded',
'payload' => [
'id' => 'pi_still_missing',
'object' => 'payment_intent',
'status' => 'succeeded',
'livemode' => false,
'metadata' => ['shopping_payment_id' => '888888'],
],
]);
$this->artisan('stripe:process-pending-events')->assertExitCode(0);
expect($order->fresh()->paid)->toBeTruthy()
->and(StripeWebhookEvent::whereEventId('evt_pending_cmd')->first()->processed_at)->not->toBeNull()
->and(StripeWebhookEvent::whereEventId('evt_still_missing')->first()->processed_at)->toBeNull();
});
it('still acknowledges foreign events without our metadata', function () {
$result = handleStripeAboEvent('payment_intent.succeeded', [
'id' => 'pi_foreign',
'object' => 'payment_intent',
'status' => 'succeeded',
'livemode' => false,
'metadata' => [],
], 'evt_foreign');
expect($result->statusCode)->toBe(200)
->and(StripeWebhookEvent::whereEventId('evt_foreign')->first()->processed_at)->not->toBeNull();
});
// --- S4.6: Anzeige ---
it('labels stripe abos from the stored payment method summary', function () {
$userAbo = stripeAboUserAbo([
'carddata' => ['type' => 'card', 'brand' => 'visa', 'last4' => '4242', 'exp' => '04/2031', 'wallet' => null],
]);
expect($userAbo->getPaymentType())->toContain('Visa')
->and($userAbo->getPaymentType())->toContain('4242')
->and(UserAbo::formatMaskedCard($userAbo->carddata))->toBe('Visa •••• 4242 (04/2031)');
});
it('falls back to the stripe type label for masked cards without last4', function () {
expect(UserAbo::formatMaskedCard(['type' => 'paypal']))->toBe(__('payment.paypal'))
->and(UserAbo::formatMaskedCard(null))->toBeNull();
});