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|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 */ 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; } }