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'] ?? ''; // The billing cycle is Stripe's: once a contract exists, they decide // when it renews, when a payment failed, and when it has ended. Handled // before the checkout branch because none of these are checkouts. $applied = $billing->dispatch($event); if ($applied !== false) { if ($applied === null) { // Either already applied, or about a contract that does not // exist yet — a checkout takes a moment to become one, and // Stripe does not deliver in order. Holding it costs a row and // saves a cancellation we would otherwise never hear about // again; a replay is a no-op if it was simply a duplicate. $billing->hold($event); } // 2xx either way. Stripe retrying would not change the answer, and // anything worth replaying is now held rather than dropped. return response()->json(['handled' => $type, 'applied' => $applied !== null]); } // 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, // The handle every later billing event arrives with. Without it an // invoice.paid cannot be matched to the contract it renews. 'stripe_subscription_id' => is_string($object['subscription'] ?? null) ? $object['subscription'] : null, // What a refund would have to be issued against, kept at the only // moment Stripe tells us. A subscription checkout charges through // an invoice and usually reports no PaymentIntent at all, so both // are taken and App\Actions\WithdrawContract uses whichever it has. 'stripe_payment_intent_id' => is_string($object['payment_intent'] ?? null) ? $object['payment_intent'] : null, 'stripe_invoice_id' => is_string($object['invoice'] ?? null) ? $object['invoice'] : null, // The consumer's express request that the service begin inside the // fourteen-day withdrawal window. Set on the session metadata by the // checkout; anything other than the exact string is a no, because a // consent that can be produced by a typo is not a consent. // // Nothing decides anything on it now — a withdrawal refunds the // whole amount either way — so it is carried as a record of what was // agreed rather than as a gate. See App\Models\Order. 'immediate_start_consent' => ($meta['immediate_start'] ?? null) === '1', '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; } }