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([
|
||||
'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(
|
||||
|
|
|
|||
|
|
@ -35,4 +35,8 @@ return [
|
|||
],
|
||||
],
|
||||
|
||||
'stripe' => [
|
||||
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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