167 lines
7.8 KiB
PHP
167 lines
7.8 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Actions\ApplyStripeBillingEvent;
|
|
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,
|
|
ApplyStripeBillingEvent $billing,
|
|
): 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'] ?? '';
|
|
|
|
// 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),
|
|
// How much of that total was the one-off setup fee, which rode along
|
|
// as a second line item on the session. From OUR metadata rather than
|
|
// from Stripe's line items: this is the figure we asked for, we asked
|
|
// for it once, and reading it back from the amounts would mean
|
|
// guessing which line was which.
|
|
//
|
|
// Clamped to the total. A negative or oversized value could only come
|
|
// from a session that was not ours, and the package's charge is derived
|
|
// by subtracting this — a wild figure there would put a negative price
|
|
// on the contract's own line.
|
|
'setup_fee_cents' => max(0, min(
|
|
(int) ($object['amount_total'] ?? $object['amount'] ?? 0),
|
|
(int) ($meta['setup_fee_cents'] ?? 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;
|
|
}
|
|
}
|