75 lines
2.8 KiB
PHP
75 lines
2.8 KiB
PHP
<?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) ?: [];
|
|
$object = $event['data']['object'] ?? [];
|
|
|
|
// Handle exactly ONE canonical event (a card checkout also emits
|
|
// payment_intent.succeeded with a different id) and only when actually
|
|
// paid — async methods complete the session while still unpaid.
|
|
if (($event['type'] ?? '') !== 'checkout.session.completed' || ($object['payment_status'] ?? null) !== 'paid') {
|
|
return response()->json(['ignored' => true]);
|
|
}
|
|
|
|
$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);
|
|
}
|
|
}
|