Speed up QR create/update by cutting billing round-trips.
Deploy Ladill QR Plus / deploy (push) Successful in 2m2s

Create was making three sequential public HTTPS billing calls (~400ms each)
while holding a DB transaction open for image generation. Use loopback DNS
forcing, cache balances briefly, return balance from debit, regenerate images
only when style changes, and keep remote work outside the DB transaction.
This commit is contained in:
isaacclad
2026-07-16 21:25:16 +00:00
parent d83a314bee
commit 02e6778ccf
7 changed files with 261 additions and 64 deletions
+112 -16
View File
@@ -2,16 +2,22 @@
namespace App\Services\Billing;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Client for the platform Billing HTTP API the one UserWallet lives on the
* platform; Bird only consumes it. Amounts are integer minor units; the user is
* platform; QR Plus only consumes it. Amounts are integer minor units; the user is
* identified by public_id. Authenticates with the per-consumer config('billing.service') key.
* Contract validated in the monolith (smtp-extraction-runbook §4-A).
*
* When BILLING_API_FORCE_IP is set (e.g. 127.0.0.1), requests resolve the billing
* host to that IP so same-server apps avoid a public hairpin (~400ms TLS each call).
*/
class BillingClient
{
private const BALANCE_CACHE_TTL_SECONDS = 8;
private function base(): string
{
return rtrim((string) config('billing.api_url'), '/');
@@ -22,9 +28,46 @@ class BillingClient
return (string) (config('billing.api_key') ?? '');
}
private function client(): PendingRequest
{
$pending = Http::withToken($this->token())
->acceptJson()
->timeout((int) config('billing.timeout', 10))
->connectTimeout((int) config('billing.connect_timeout', 2));
$forceIp = trim((string) config('billing.force_ip', ''));
if ($forceIp === '') {
return $pending;
}
$host = parse_url($this->base(), PHP_URL_HOST);
$scheme = parse_url($this->base(), PHP_URL_SCHEME) ?: 'https';
$port = parse_url($this->base(), PHP_URL_PORT);
if (! is_int($port) || $port <= 0) {
$port = $scheme === 'http' ? 80 : 443;
}
if (! is_string($host) || $host === '') {
return $pending;
}
$options = [
'curl' => [
CURLOPT_RESOLVE => [sprintf('%s:%d:%s', $host, $port, $forceIp)],
],
];
// Loopback TLS often uses the public cert; skip verify only for forced local IP.
if (in_array($forceIp, ['127.0.0.1', '::1', 'localhost'], true)) {
$options['verify'] = false;
}
return $pending->withOptions($options);
}
private function get(string $path, array $query): array
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().$path, $query);
$res = $this->client()->get($this->base().$path, $query);
$res->throw();
return (array) $res->json();
@@ -32,7 +75,11 @@ class BillingClient
public function balanceMinor(string $publicId): int
{
return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0);
return (int) Cache::remember(
$this->balanceCacheKey($publicId),
self::BALANCE_CACHE_TTL_SECONDS,
fn () => (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0),
);
}
/**
@@ -41,7 +88,7 @@ class BillingClient
*/
public function topup(string $publicId, float $amount, string $returnUrl): string
{
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/topup', [
$res = $this->client()->timeout(15)->post($this->base().'/topup', [
'user' => $publicId,
'amount' => $amount,
'return_url' => $returnUrl,
@@ -53,7 +100,8 @@ class BillingClient
public function canAfford(string $publicId, int $amountMinor): bool
{
return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false);
// Prefer a single balance lookup (cached) over a dedicated can-afford round-trip.
return $this->balanceMinor($publicId) >= $amountMinor;
}
public function serviceLedger(string $publicId, string $service): array
@@ -62,12 +110,15 @@ class BillingClient
}
/**
* Debit the wallet. Returns true on success, false on insufficient balance
* (HTTP 402). Idempotent by $reference.
* Debit the wallet. Returns the API payload on success (includes
* balance_after_minor), or null on insufficient balance (HTTP 402).
* Idempotent by $reference.
*
* @return array<string, mixed>|null
*/
public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): bool
public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): ?array
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
$res = $this->client()->post($this->base().'/debit', array_filter([
'user' => $publicId,
'amount_minor' => $amountMinor,
'service' => $service,
@@ -78,16 +129,40 @@ class BillingClient
], static fn ($v) => $v !== null));
if ($res->status() === 402) {
return false;
}
$res->throw();
$this->forgetBalance($publicId);
if (is_numeric($res->json('balance_minor'))) {
Cache::put(
$this->balanceCacheKey($publicId),
(int) $res->json('balance_minor'),
self::BALANCE_CACHE_TTL_SECONDS,
);
}
return true;
return null;
}
$res->throw();
$payload = (array) $res->json();
if (isset($payload['balance_after_minor'])) {
Cache::put(
$this->balanceCacheKey($publicId),
(int) $payload['balance_after_minor'],
self::BALANCE_CACHE_TTL_SECONDS,
);
} else {
$this->forgetBalance($publicId);
}
return $payload;
}
/**
* @return array<string, mixed>
*/
public function credit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): array
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/credit', array_filter([
$res = $this->client()->post($this->base().'/credit', array_filter([
'user' => $publicId,
'amount_minor' => $amountMinor,
'service' => $service,
@@ -98,6 +173,27 @@ class BillingClient
], static fn ($v) => $v !== null));
$res->throw();
return (array) $res->json();
$payload = (array) $res->json();
if (isset($payload['balance_after_minor'])) {
Cache::put(
$this->balanceCacheKey($publicId),
(int) $payload['balance_after_minor'],
self::BALANCE_CACHE_TTL_SECONDS,
);
} else {
$this->forgetBalance($publicId);
}
return $payload;
}
public function forgetBalance(string $publicId): void
{
Cache::forget($this->balanceCacheKey($publicId));
}
private function balanceCacheKey(string $publicId): string
{
return 'billing:balance_minor:'.$publicId;
}
}