Phase 9E (Backbone): Stripe-Produkt-Sync und Webhook-Verarbeitung mit STR-Spiegelung
- billing:sync-stripe-plans: legt die Tarife als Netto-Produkte/Preise
(tax_behavior exclusive, EUR) in Stripe an und pflegt die IDs zurueck
nach plans; idempotent, --dry-run. Gegen Stripe Test-Mode ausgefuehrt —
alle 4 Tiers verknuepft.
- ProcessStripeWebhook (Listener auf Cashier WebhookReceived):
- invoice.payment_succeeded -> Spiegelung in den lokalen STR-Kreis
(fortlaufende Nummer via InvoiceNumberGenerator, Adress-Snapshot
bevorzugt aus dem Stripe-Payload inkl. lokaler USt-ID, Status paid,
idempotent gegen doppelte Zustellung)
- checkout.session.completed -> markiert den referenzierten
single_purchases-Datensatz als bezahlt (Metadata single_purchase_id)
- CASHIER_CURRENCY=eur (+ Locale de_DE); Cashier-Webhook-Route aktiv
- Doku: Billing-Referenz §7 + Phase-9-Plan (9E-Backbone) aktualisiert
Offen fuer 9E-Rest: Checkout-Flows (Abo + Einmalkauf), Webhook-Endpoint
im Stripe-Dashboard + STRIPE_WEBHOOK_SECRET, Slot-Logik auf
Plan-Kontingent (fachliche Frage: Grandfathered = unbegrenzt?).
Tests: StripeWebhookProcessingTest (7, inkl. Event-Wiring).
Suite: 497 passed, 4 skipped. Pint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
62e6b7e70f
commit
38fab64e10
5 changed files with 400 additions and 6 deletions
143
tests/Feature/Billing/StripeWebhookProcessingTest.php
Normal file
143
tests/Feature/Billing/StripeWebhookProcessingTest.php
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\InvoiceStatus;
|
||||
use App\Enums\SinglePurchaseStatus;
|
||||
use App\Listeners\ProcessStripeWebhook;
|
||||
use App\Models\BillingAddress;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
function stripeInvoicePayload(User $user, array $overrides = []): array
|
||||
{
|
||||
return [
|
||||
'type' => 'invoice.payment_succeeded',
|
||||
'data' => [
|
||||
'object' => array_replace_recursive([
|
||||
'id' => 'in_test_123',
|
||||
'customer' => $user->stripe_id,
|
||||
'subtotal' => 4900,
|
||||
'tax' => 931,
|
||||
'total' => 5831,
|
||||
'currency' => 'eur',
|
||||
'customer_name' => 'Alpha GmbH',
|
||||
'customer_address' => [
|
||||
'line1' => 'Beispielweg 1',
|
||||
'line2' => null,
|
||||
'postal_code' => '10115',
|
||||
'city' => 'Berlin',
|
||||
'country' => 'DE',
|
||||
],
|
||||
], $overrides),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
test('a paid stripe invoice is mirrored into the STR circle', function () {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
|
||||
|
||||
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user)));
|
||||
|
||||
$invoice = Invoice::sole();
|
||||
expect($invoice->number)->toBe('STR-00001');
|
||||
expect($invoice->user_id)->toBe($user->id);
|
||||
expect($invoice->status)->toBe(InvoiceStatus::Paid);
|
||||
expect($invoice->amount_cents)->toBe(4900);
|
||||
expect($invoice->tax_cents)->toBe(931);
|
||||
expect($invoice->total_cents)->toBe(5831);
|
||||
expect($invoice->stripe_invoice_id)->toBe('in_test_123');
|
||||
expect($invoice->invoiceBillingAddress->city)->toBe('Berlin');
|
||||
expect($invoice->invoiceBillingAddress->name)->toBe('Alpha GmbH');
|
||||
});
|
||||
|
||||
test('duplicate webhook deliveries do not create a second invoice', function () {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
|
||||
$listener = app(ProcessStripeWebhook::class);
|
||||
|
||||
$listener->handle(new WebhookReceived(stripeInvoicePayload($user)));
|
||||
$listener->handle(new WebhookReceived(stripeInvoicePayload($user)));
|
||||
|
||||
expect(Invoice::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('the snapshot falls back to the local billing address including vat id', function () {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
|
||||
BillingAddress::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Lokal GmbH',
|
||||
'country_code' => 'AT',
|
||||
'vat_id' => 'ATU12345678',
|
||||
]);
|
||||
|
||||
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user, [
|
||||
'customer_name' => null,
|
||||
'customer_address' => null,
|
||||
])));
|
||||
|
||||
$snapshot = Invoice::sole()->invoiceBillingAddress;
|
||||
expect($snapshot->name)->toBe('Lokal GmbH');
|
||||
expect($snapshot->country_code)->toBe('AT');
|
||||
expect($snapshot->vat_id)->toBe('ATU12345678');
|
||||
});
|
||||
|
||||
test('an unknown stripe customer is skipped without an invoice', function () {
|
||||
User::factory()->create(['stripe_id' => 'cus_other']);
|
||||
|
||||
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload(
|
||||
new User(['name' => 'Fremd']),
|
||||
['customer' => 'cus_unknown'],
|
||||
)));
|
||||
|
||||
expect(Invoice::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('checkout completion marks the referenced single purchase as paid', function () {
|
||||
$purchase = SinglePurchase::factory()->create();
|
||||
|
||||
app(ProcessStripeWebhook::class)->handle(new WebhookReceived([
|
||||
'type' => 'checkout.session.completed',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'cs_test_1',
|
||||
'payment_intent' => 'pi_test_1',
|
||||
'metadata' => ['single_purchase_id' => (string) $purchase->id],
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$fresh = $purchase->fresh();
|
||||
expect($fresh->status)->toBe(SinglePurchaseStatus::Paid);
|
||||
expect($fresh->paid_at)->not->toBeNull();
|
||||
expect($fresh->stripe_checkout_session_id)->toBe('cs_test_1');
|
||||
expect($fresh->stripe_payment_intent_id)->toBe('pi_test_1');
|
||||
});
|
||||
|
||||
test('an already paid purchase is not touched again', function () {
|
||||
$purchase = SinglePurchase::factory()->paid()->create([
|
||||
'stripe_payment_intent_id' => 'pi_original',
|
||||
]);
|
||||
|
||||
app(ProcessStripeWebhook::class)->handle(new WebhookReceived([
|
||||
'type' => 'checkout.session.completed',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'cs_test_2',
|
||||
'payment_intent' => 'pi_new',
|
||||
'metadata' => ['single_purchase_id' => (string) $purchase->id],
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
expect($purchase->fresh()->stripe_payment_intent_id)->toBe('pi_original');
|
||||
});
|
||||
|
||||
test('the listener is wired to the cashier webhook event', function () {
|
||||
Event::fake();
|
||||
|
||||
Event::assertListening(
|
||||
WebhookReceived::class,
|
||||
ProcessStripeWebhook::class,
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue