Deploy Ladill QR Plus / deploy (push) Successful in 1m45s
CURLOPT_RESOLVE was not reliably faster; rewrite the billing base URL to the forced IP and send the original Host header instead.
216 lines
6.8 KiB
PHP
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;
|
|
}
|
|
}
|