mivita/tests/Unit/Services/StripeMetadataBuilderTest.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

123 lines
3.9 KiB
PHP

<?php
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Services\Payment\Stripe\StripeMetadataBuilder;
/**
* Phase SE2 (dev/2026-08-26-stripe/): Der Builder ist die einzige Quelle für
* die Metadaten und die description, die an Stripe gehen. Er arbeitet rein auf
* Modell-Attributen — kein DB-Zugriff, daher Unit-Test ohne RefreshDatabase.
*/
function metadataOrder(array $attributes = []): ShoppingOrder
{
$order = new ShoppingOrder(array_merge(['mode' => 'live'], $attributes));
$order->id = $attributes['id'] ?? 4711;
$order->setRelation('user_invoice', null);
return $order;
}
function metadataShoppingUser(array $attributes = []): ShoppingUser
{
$shoppingUser = new ShoppingUser(array_merge([
'billing_firstname' => 'Erika',
'billing_lastname' => 'Mustermann',
], $attributes));
$shoppingUser->id = 99;
return $shoppingUser;
}
function metadataPayment(): ShoppingPayment
{
$payment = new ShoppingPayment(['reference' => 'mref123']);
$payment->id = 555;
return $payment;
}
it('builds the full metadata set for a checkout', function () {
$metadata = StripeMetadataBuilder::forCheckout(
metadataOrder(),
metadataPayment(),
metadataShoppingUser(['number' => 1234, 'member_id' => 77]),
'mref123',
);
expect($metadata)->toBe([
'shopping_order_id' => '4711',
'shopping_payment_id' => '555',
'reference' => 'mref123',
'mode' => 'live',
'order_type' => 'shop',
'customer_name' => 'Erika Mustermann',
'customer_number' => '1234',
'member_id' => '77',
]);
});
it('flags abo and recovery orders by order_type', function () {
expect(StripeMetadataBuilder::forCheckout(
metadataOrder(['is_abo' => true]), metadataPayment(), metadataShoppingUser(), 'r1'
)['order_type'])->toBe('abo');
$recovery = StripeMetadataBuilder::forCheckout(
metadataOrder(['recovery_user_abo_id' => 42]), metadataPayment(), metadataShoppingUser(), 'r2'
);
expect($recovery['order_type'])->toBe('abo_recovery')
->and($recovery['user_abo_id'])->toBe('42');
});
it('names the person first and the company in brackets', function () {
$metadata = StripeMetadataBuilder::forCheckout(
metadataOrder(),
metadataPayment(),
metadataShoppingUser(['billing_company' => 'Muster GmbH']),
'mref123',
);
expect($metadata['customer_name'])->toBe('Erika Mustermann (Muster GmbH)');
});
it('uses the company alone when no person name is stored', function () {
$metadata = StripeMetadataBuilder::forCheckout(
metadataOrder(),
metadataPayment(),
metadataShoppingUser(['billing_firstname' => null, 'billing_lastname' => null, 'billing_company' => 'Muster GmbH']),
'mref123',
);
expect($metadata['customer_name'])->toBe('Muster GmbH');
});
it('drops empty values so no null strings reach the reports', function () {
$metadata = StripeMetadataBuilder::sanitize([
'a' => null,
'b' => '',
'c' => 0,
'd' => 'wert',
'e' => 12,
]);
expect($metadata)->toBe(['d' => 'wert', 'e' => '12']);
});
it('truncates values to the stripe metadata limit', function () {
$metadata = StripeMetadataBuilder::sanitize(['lang' => str_repeat('x', 700)]);
expect(mb_strlen($metadata['lang']))->toBe(StripeMetadataBuilder::MAX_VALUE_LENGTH);
});
it('builds a searchable description with order number, reference and name', function () {
$description = StripeMetadataBuilder::description(metadataOrder(), 'mref123', metadataShoppingUser());
expect($description)->toBe('Bestellung #4711 · mref123 · Erika Mustermann');
});
it('works without a shopping user', function () {
expect(StripeMetadataBuilder::description(metadataOrder(), 'mref123', null))
->toBe('Bestellung #4711 · mref123');
});