Files
ladill-qr-plus/app/Services/Billing/BillingClient.php
T
isaacclad 5bab0fedaf
Deploy Ladill QR Plus / deploy (push) Successful in 1m45s
Use Host-header loopback for billing force_ip.
CURLOPT_RESOLVE was not reliably faster; rewrite the billing base URL to the
forced IP and send the original Host header instead.
2026-07-16 21:28:06 +00:00

216 lines
6.8 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 configuredBase(): string
{
return rtrim((string) config('billing.api_url'), '/');
}
/**
* Public billing URL rewritten to a same-host IP when force_ip is set.
* Example: https://ladill.com/api/billing + 127.0.0.1
* → https://127.0.0.1/api/billing (Host: ladill.com)
*/
private function base(): string
{
$base = $this->configuredBase();
$forceIp = trim((string) config('billing.force_ip', ''));
if ($forceIp === '') {
return $base;
}
$parts = parse_url($base);
if (! is_array($parts) || empty($parts['host'])) {
return $base;
}
$scheme = $parts['scheme'] ?? 'https';
$path = $parts['path'] ?? '';
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
return $scheme.'://'.$forceIp.$port.$path;
}
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->configuredBase(), PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return $pending;
}
$pending = $pending->withHeaders(['Host' => $host]);
// Loopback TLS cert is issued for the public host, not the IP.
if (in_array($forceIp, ['127.0.0.1', '::1', 'localhost'], true)
|| filter_var($forceIp, FILTER_VALIDATE_IP)) {
$pending = $pending->withOptions(['verify' => false]);
}
return $pending;
}
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;
}
}