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