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
parent
446da58061
commit
65ba3d6588
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'admin' => \App\Http\Middleware\EnsureAdmin::class,
|
'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 {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,8 @@ return [
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'stripe' => [
|
||||||
|
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\StripeWebhookController;
|
||||||
use App\Livewire\Admin;
|
use App\Livewire\Admin;
|
||||||
use App\Livewire\Auth\Login;
|
use App\Livewire\Auth\Login;
|
||||||
use App\Livewire\Auth\TwoFactorChallenge;
|
use App\Livewire\Auth\TwoFactorChallenge;
|
||||||
|
|
@ -13,6 +14,9 @@ use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', fn () => view('landing'))->name('home');
|
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).
|
// Public legal pages (placeholders — replace with real content before launch).
|
||||||
Route::prefix('legal')->name('legal.')->group(function () {
|
Route::prefix('legal')->name('legal.')->group(function () {
|
||||||
Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum');
|
Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum');
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue