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.
200 lines
6.3 KiB
PHP
200 lines
6.3 KiB
PHP
<?php
|
|
|
|
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; 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.
|
|
*
|
|
* 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'), '/');
|
|
}
|
|
|
|
private function token(): string
|
|
{
|
|
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 = $this->client()->get($this->base().$path, $query);
|
|
$res->throw();
|
|
|
|
return (array) $res->json();
|
|
}
|
|
|
|
public function balanceMinor(string $publicId): int
|
|
{
|
|
return (int) Cache::remember(
|
|
$this->balanceCacheKey($publicId),
|
|
self::BALANCE_CACHE_TTL_SECONDS,
|
|
fn () => (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Start a Paystack checkout to top up the central wallet; returns the
|
|
* checkout URL. Backs the in-app two-step "Add funds" modal.
|
|
*/
|
|
public function topup(string $publicId, float $amount, string $returnUrl): string
|
|
{
|
|
$res = $this->client()->timeout(15)->post($this->base().'/topup', [
|
|
'user' => $publicId,
|
|
'amount' => $amount,
|
|
'return_url' => $returnUrl,
|
|
]);
|
|
$res->throw();
|
|
|
|
return (string) ($res->json()['checkout_url'] ?? '');
|
|
}
|
|
|
|
public function canAfford(string $publicId, int $amountMinor): bool
|
|
{
|
|
// 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
|
|
{
|
|
return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]);
|
|
}
|
|
|
|
/**
|
|
* 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): ?array
|
|
{
|
|
$res = $this->client()->post($this->base().'/debit', array_filter([
|
|
'user' => $publicId,
|
|
'amount_minor' => $amountMinor,
|
|
'service' => $service,
|
|
'source' => $source,
|
|
'reference' => $reference,
|
|
'service_id' => $serviceId,
|
|
'description' => $description,
|
|
], static fn ($v) => $v !== null));
|
|
|
|
if ($res->status() === 402) {
|
|
$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 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 = $this->client()->post($this->base().'/credit', array_filter([
|
|
'user' => $publicId,
|
|
'amount_minor' => $amountMinor,
|
|
'service' => $service,
|
|
'source' => $source,
|
|
'reference' => $reference,
|
|
'service_id' => $serviceId,
|
|
'description' => $description,
|
|
], static fn ($v) => $v !== 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;
|
|
}
|
|
|
|
public function forgetBalance(string $publicId): void
|
|
{
|
|
Cache::forget($this->balanceCacheKey($publicId));
|
|
}
|
|
|
|
private function balanceCacheKey(string $publicId): string
|
|
{
|
|
return 'billing:balance_minor:'.$publicId;
|
|
}
|
|
}
|