feat(engine-b): Stripe webhook -> idempotent customer provisioning intake

Signed webhook (HMAC verify when secret set), CSRF-exempt route. Paid checkout
creates customer+order+run (stripe_event_id unique => duplicate webhooks start
one run) and dispatches. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 11:52:46 +02:00
parent 446da58061
commit 65ba3d6588
6 changed files with 227 additions and 0 deletions

View File

@ -0,0 +1,64 @@
<?php
namespace App\Actions;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
/**
* Turns a paid Stripe event into a customer + order + provisioning run. The
* order's unique stripe_event_id is the idempotency key: a duplicate webhook
* never starts a second run.
*/
class StartCustomerProvisioning
{
/**
* @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,currency:string} $event
*/
public function fromStripeEvent(array $event): ?Order
{
if (Order::query()->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;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Actions\StartCustomerProvisioning;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Stripe webhook: verifies the signature (when a secret is configured) and turns
* a paid checkout into a provisioning run. Idempotent via the order's
* stripe_event_id Stripe retries a webhook until it gets a 2xx.
*/
class StripeWebhookController extends Controller
{
public function __invoke(Request $request, StartCustomerProvisioning $action): JsonResponse
{
$payload = $request->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);
}
}

View File

@ -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(

View File

@ -35,4 +35,8 @@ return [
],
],
'stripe' => [
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
];

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\StripeWebhookController;
use App\Livewire\Admin;
use App\Livewire\Auth\Login;
use App\Livewire\Auth\TwoFactorChallenge;
@ -13,6 +14,9 @@ use Illuminate\Support\Facades\Route;
Route::get('/', fn () => 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');

View File

@ -0,0 +1,80 @@
<?php
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Facades\Queue;
function stripeEvent(string $id = 'evt_1', string $plan = 'team'): array
{
return [
'id' => $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();
});