103 lines
3.8 KiB
PHP
103 lines
3.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Stripe;
|
|
|
|
use App\Services\Secrets\SecretVault;
|
|
use App\Support\OperatingMode;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Read-only answers to "does this key actually work, and what is it wired to?"
|
|
*
|
|
* Separate from StripeClient, which exists to create and archive things. A key
|
|
* that is saved but wrong is worse than no key, because the failure surfaces
|
|
* later, somewhere else, and usually to a customer — so it can be checked at
|
|
* the moment it is entered.
|
|
*
|
|
* Reports the account and the LIVE/TEST mode, because the single most expensive
|
|
* mistake here is pasting a test key into a live installation, or the reverse,
|
|
* and both look identical in a form.
|
|
*/
|
|
final class StripeCheck
|
|
{
|
|
/** @return array<string, mixed> */
|
|
public function run(?string $candidate = null): array
|
|
{
|
|
$secret = $candidate ?: app(SecretVault::class)->get('stripe.secret');
|
|
|
|
if (blank($secret)) {
|
|
return ['ok' => false, 'reason' => 'missing'];
|
|
}
|
|
|
|
try {
|
|
$account = Http::withToken($secret)->acceptJson()->timeout(15)
|
|
->get($this->url('account'));
|
|
} catch (Throwable) {
|
|
return ['ok' => false, 'reason' => 'unreachable'];
|
|
}
|
|
|
|
if ($account->status() === 401) {
|
|
return ['ok' => false, 'reason' => 'rejected'];
|
|
}
|
|
|
|
if (! $account->successful()) {
|
|
return ['ok' => false, 'reason' => 'error', 'status' => $account->status()];
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'account' => $account->json('id'),
|
|
'business' => $account->json('settings.dashboard.display_name') ?: $account->json('business_profile.name'),
|
|
// Stripe does not label the key; the mode is inferred from the
|
|
// prefix, which is the only thing that is true of both key kinds.
|
|
// Asked of OperatingMode, not spelled out again here: the same
|
|
// question decides which slot the migration files a key into and
|
|
// whether the readiness page reports a key sitting in the wrong
|
|
// one. Unrecognisable (null) stays false, exactly as before.
|
|
'live' => OperatingMode::ofStripeKey($secret) === OperatingMode::Live,
|
|
'restricted' => str_starts_with($secret, 'rk_'),
|
|
'webhooks' => $this->webhooks($secret),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The configured webhook endpoints and which events each one subscribes to.
|
|
*
|
|
* This is the part nobody can see from the outside: a key can be perfectly
|
|
* valid while the endpoint listens for the wrong five events, and nothing
|
|
* fails until a payment is not recorded.
|
|
*
|
|
* @return array<int, array<string, mixed>>|null null when the key may not read them
|
|
*/
|
|
private function webhooks(string $secret): ?array
|
|
{
|
|
try {
|
|
$response = Http::withToken($secret)->acceptJson()->timeout(15)
|
|
->get($this->url('webhook_endpoints'), ['limit' => 10]);
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
|
|
if (! $response->successful()) {
|
|
// A restricted key legitimately cannot list these. Saying "unknown"
|
|
// beats reporting "none configured", which would send someone
|
|
// hunting for a problem that is not there.
|
|
return null;
|
|
}
|
|
|
|
return collect($response->json('data', []))
|
|
->map(fn (array $endpoint) => [
|
|
'url' => $endpoint['url'] ?? '—',
|
|
'status' => $endpoint['status'] ?? 'unknown',
|
|
'events' => $endpoint['enabled_events'] ?? [],
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private function url(string $path): string
|
|
{
|
|
return rtrim((string) config('services.stripe.api_base', 'https://api.stripe.com/v1'), '/').'/'.$path;
|
|
}
|
|
}
|