diff --git a/app/Actions/StartCustomerProvisioning.php b/app/Actions/StartCustomerProvisioning.php new file mode 100644 index 0000000..d1e03db --- /dev/null +++ b/app/Actions/StartCustomerProvisioning.php @@ -0,0 +1,64 @@ +where('stripe_event_id', $event['id'])->exists()) { + return null; // already processed + } + + try { + [$order, $run] = DB::transaction(function () use ($event) { + $customer = Customer::query()->firstOrCreate( + ['email' => $event['email']], + ['name' => $event['name'] ?? $event['email'], 'stripe_customer_id' => $event['stripe_customer_id'] ?? null], + ); + + $order = Order::create([ + 'customer_id' => $customer->id, + 'plan' => $event['plan'], + 'amount_cents' => $event['amount_cents'], + 'currency' => $event['currency'], + 'datacenter' => $event['datacenter'], + 'stripe_event_id' => $event['id'], + 'status' => 'paid', + ]); + + $run = ProvisioningRun::create([ + 'subject_type' => Order::class, + 'subject_id' => $order->id, + 'pipeline' => 'customer', + 'status' => ProvisioningRun::STATUS_PENDING, + 'current_step' => 0, + 'context' => [], + ]); + + return [$order, $run]; + }); + } catch (UniqueConstraintViolationException) { + return null; // concurrent duplicate webhook + } + + AdvanceRunJob::dispatch($run->uuid); // after commit + + return $order; + } +} diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php new file mode 100644 index 0000000..6476279 --- /dev/null +++ b/app/Http/Controllers/StripeWebhookController.php @@ -0,0 +1,72 @@ +getContent(); + $secret = (string) config('services.stripe.webhook_secret'); + + if (filled($secret) && ! $this->signatureValid($payload, (string) $request->header('Stripe-Signature'), $secret)) { + abort(400, 'invalid signature'); + } + + $event = json_decode($payload, true) ?: []; + $type = $event['type'] ?? ''; + + if (! in_array($type, ['checkout.session.completed', 'payment_intent.succeeded'], true)) { + return response()->json(['ignored' => true]); + } + + $object = $event['data']['object'] ?? []; + $meta = $object['metadata'] ?? []; + + $action->fromStripeEvent([ + 'id' => (string) ($event['id'] ?? ''), + 'email' => $object['customer_details']['email'] ?? $object['customer_email'] ?? ($meta['email'] ?? 'unknown@example.com'), + 'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null), + 'stripe_customer_id' => $object['customer'] ?? null, + 'plan' => $meta['plan'] ?? 'start', + 'datacenter' => $meta['datacenter'] ?? 'fsn', + 'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0), + 'currency' => strtoupper($object['currency'] ?? 'eur'), + ]); + + return response()->json(['ok' => true]); + } + + /** Verify Stripe's `t=…,v1=…` signature: HMAC-SHA256 of "{t}.{payload}". */ + private function signatureValid(string $payload, string $header, string $secret): bool + { + if (blank($header)) { + return false; + } + + $parts = []; + foreach (explode(',', $header) as $segment) { + [$key, $value] = array_pad(explode('=', $segment, 2), 2, ''); + $parts[$key] = $value; + } + + $timestamp = $parts['t'] ?? null; + $signature = $parts['v1'] ?? null; + if ($timestamp === null || $signature === null) { + return false; + } + + $expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret); + + return hash_equals($expected, $signature); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 725857a..c9f032f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -16,6 +16,9 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->alias([ 'admin' => \App\Http\Middleware\EnsureAdmin::class, ]); + + // Stripe posts server-to-server with its own signature (no CSRF token). + $middleware->validateCsrfTokens(except: ['webhooks/stripe']); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/config/services.php b/config/services.php index 6a90eb8..580a3b6 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,8 @@ return [ ], ], + 'stripe' => [ + 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + ], + ]; diff --git a/routes/web.php b/routes/web.php index 2315313..70f314b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ view('landing'))->name('home'); +// Stripe webhook — paid order → customer provisioning run (CSRF-exempt, signed). +Route::post('/webhooks/stripe', StripeWebhookController::class)->name('webhooks.stripe'); + // Public legal pages (placeholders — replace with real content before launch). Route::prefix('legal')->name('legal.')->group(function () { Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum'); diff --git a/tests/Feature/Provisioning/StripeWebhookTest.php b/tests/Feature/Provisioning/StripeWebhookTest.php new file mode 100644 index 0000000..9aec8e9 --- /dev/null +++ b/tests/Feature/Provisioning/StripeWebhookTest.php @@ -0,0 +1,80 @@ + $id, + 'type' => 'checkout.session.completed', + 'data' => ['object' => [ + 'customer' => 'cus_1', + 'customer_details' => ['email' => 'kunde@example.com', 'name' => 'Kanzlei Berger'], + 'amount_total' => 4900, + 'currency' => 'eur', + 'metadata' => ['plan' => $plan, 'datacenter' => 'fsn'], + ]], + ]; +} + +it('creates a customer, order and run from a paid checkout', function () { + Queue::fake(); + + $this->postJson(route('webhooks.stripe'), stripeEvent())->assertOk(); + + $order = Order::query()->where('stripe_event_id', 'evt_1')->first(); + expect($order)->not->toBeNull() + ->and($order->plan)->toBe('team') + ->and($order->status)->toBe('paid') + ->and(ProvisioningRun::query()->where('subject_id', $order->id)->where('pipeline', 'customer')->exists())->toBeTrue(); + Queue::assertPushed(AdvanceRunJob::class, 1); +}); + +it('is idempotent — a duplicate webhook starts only one run', function () { + Queue::fake(); + + $this->postJson(route('webhooks.stripe'), stripeEvent('evt_dup'))->assertOk(); + $this->postJson(route('webhooks.stripe'), stripeEvent('evt_dup'))->assertOk(); + + expect(Order::query()->where('stripe_event_id', 'evt_dup')->count())->toBe(1); + Queue::assertPushed(AdvanceRunJob::class, 1); +}); + +it('ignores unrelated event types', function () { + Queue::fake(); + + $this->postJson(route('webhooks.stripe'), ['id' => 'evt_x', 'type' => 'invoice.paid', 'data' => ['object' => []]]) + ->assertOk() + ->assertJson(['ignored' => true]); + + Queue::assertNothingPushed(); +}); + +it('rejects an invalid signature when a secret is configured', function () { + config()->set('services.stripe.webhook_secret', 'whsec_test'); + + $this->postJson(route('webhooks.stripe'), stripeEvent('evt_sig'), ['Stripe-Signature' => 't=1,v1=deadbeef']) + ->assertStatus(400); +}); + +it('accepts a valid signature', function () { + Queue::fake(); + config()->set('services.stripe.webhook_secret', 'whsec_test'); + + $payload = json_encode(stripeEvent('evt_ok')); + $timestamp = 1700000000; + $signature = hash_hmac('sha256', $timestamp.'.'.$payload, 'whsec_test'); + + $this->call( + 'POST', + route('webhooks.stripe'), + [], [], [], + ['CONTENT_TYPE' => 'application/json', 'HTTP_STRIPE_SIGNATURE' => "t={$timestamp},v1={$signature}"], + $payload, + )->assertOk(); + + expect(Order::query()->where('stripe_event_id', 'evt_ok')->exists())->toBeTrue(); +});