111 lines
4.4 KiB
PHP
111 lines
4.4 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 (blank($secret)) {
|
|
// Fail closed: an unconfigured secret must not authorize provisioning
|
|
// outside local/testing.
|
|
abort_unless(app()->environment('local', 'testing'), 400, 'Stripe webhook secret not configured');
|
|
} elseif (! $this->signatureValid($payload, (string) $request->header('Stripe-Signature'), $secret)) {
|
|
abort(400, 'invalid signature');
|
|
}
|
|
|
|
$event = json_decode($payload, true) ?: [];
|
|
$object = $event['data']['object'] ?? [];
|
|
$type = $event['type'] ?? '';
|
|
|
|
// Paid triggers: a synchronous checkout (completed + paid) OR an async
|
|
// method clearing later (async_payment_succeeded). Ignore everything else,
|
|
// including the still-unpaid completed event for async methods.
|
|
$paid = ($type === 'checkout.session.completed' && ($object['payment_status'] ?? null) === 'paid')
|
|
|| $type === 'checkout.session.async_payment_succeeded';
|
|
if (! $paid) {
|
|
return response()->json(['ignored' => true]);
|
|
}
|
|
|
|
$meta = $object['metadata'] ?? [];
|
|
|
|
// A real email is required — never merge unrelated customers under a
|
|
// manufactured address or send credentials into the void.
|
|
$email = $object['customer_details']['email'] ?? $object['customer_email'] ?? ($meta['email'] ?? null);
|
|
if (blank($email)) {
|
|
return response()->json(['ignored' => 'no_customer_email']);
|
|
}
|
|
|
|
$action->fromStripeEvent([
|
|
// Deduplicate on the checkout session id (stable across the completed
|
|
// and async-succeeded events for one purchase), not the event id.
|
|
'id' => (string) ($object['id'] ?? $event['id'] ?? ''),
|
|
'email' => $email,
|
|
'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null),
|
|
'stripe_customer_id' => $object['customer'] ?? null,
|
|
'plan' => $meta['plan'] ?? 'start',
|
|
// What the customer was actually shown. Absent on a session created
|
|
// before phase 5 put it there, and then the version on sale applies.
|
|
'plan_version_id' => isset($meta['plan_version_id']) ? (int) $meta['plan_version_id'] : null,
|
|
'datacenter' => $meta['datacenter'] ?? 'fsn',
|
|
'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0),
|
|
'currency' => strtoupper($object['currency'] ?? 'eur'),
|
|
]);
|
|
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
|
|
/** Stripe's default replay tolerance, in seconds. */
|
|
private const SIGNATURE_TOLERANCE = 300;
|
|
|
|
/** 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;
|
|
}
|
|
|
|
$timestamp = null;
|
|
$signatures = [];
|
|
foreach (explode(',', $header) as $segment) {
|
|
[$key, $value] = array_pad(explode('=', $segment, 2), 2, '');
|
|
if ($key === 't') {
|
|
$timestamp = $value;
|
|
} elseif ($key === 'v1') {
|
|
$signatures[] = $value; // keep every v1 (secret rotation sends several)
|
|
}
|
|
}
|
|
|
|
if ($timestamp === null || $signatures === []) {
|
|
return false;
|
|
}
|
|
|
|
// Reject replays outside the tolerance window.
|
|
if (abs(time() - (int) $timestamp) > self::SIGNATURE_TOLERANCE) {
|
|
return false;
|
|
}
|
|
|
|
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
|
|
|
|
foreach ($signatures as $signature) {
|
|
if (hash_equals($expected, $signature)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|