diff --git a/.env.example b/.env.example index 2dd537d..3a704cd 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,8 @@ LADILL_SSO_CLIENT_ID= LADILL_SSO_CLIENT_SECRET= BILLING_API_URL=https://ladill.com/api/billing +# Same-server: set to 127.0.0.1 to skip public hairpin latency on billing calls +BILLING_API_FORCE_IP=127.0.0.1 BILLING_API_KEY_QR= IDENTITY_API_URL=https://ladill.com/api diff --git a/app/Http/Controllers/WalletBalanceController.php b/app/Http/Controllers/WalletBalanceController.php index 29d4267..ba3646c 100644 --- a/app/Http/Controllers/WalletBalanceController.php +++ b/app/Http/Controllers/WalletBalanceController.php @@ -5,7 +5,6 @@ namespace App\Http\Controllers; use App\Services\Billing\BillingClient; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; /** * Lightweight wallet balance for the avatar dropdown widget. @@ -16,15 +15,10 @@ class WalletBalanceController extends Controller { $publicId = (string) $request->user()->public_id; - $minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) { - try { - return $billing->balanceMinor($publicId); - } catch (\Throwable) { - return null; - } - }); - - if ($minor === null) { + // BillingClient already short-caches balances; avoid a second long cache layer. + try { + $minor = $billing->balanceMinor($publicId); + } catch (\Throwable) { return response()->json(['available' => false]); } diff --git a/app/Services/Billing/BillingClient.php b/app/Services/Billing/BillingClient.php index 546fc33..7b1ce74 100644 --- a/app/Services/Billing/BillingClient.php +++ b/app/Services/Billing/BillingClient.php @@ -2,16 +2,22 @@ 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; Bird only consumes it. Amounts are integer minor units; the user is + * 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. - * Contract validated in the monolith (smtp-extraction-runbook ยง4-A). + * + * 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'), '/'); @@ -22,9 +28,46 @@ class BillingClient 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 = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().$path, $query); + $res = $this->client()->get($this->base().$path, $query); $res->throw(); return (array) $res->json(); @@ -32,7 +75,11 @@ class BillingClient public function balanceMinor(string $publicId): int { - return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0); + return (int) Cache::remember( + $this->balanceCacheKey($publicId), + self::BALANCE_CACHE_TTL_SECONDS, + fn () => (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0), + ); } /** @@ -41,7 +88,7 @@ class BillingClient */ public function topup(string $publicId, float $amount, string $returnUrl): string { - $res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/topup', [ + $res = $this->client()->timeout(15)->post($this->base().'/topup', [ 'user' => $publicId, 'amount' => $amount, 'return_url' => $returnUrl, @@ -53,7 +100,8 @@ class BillingClient public function canAfford(string $publicId, int $amountMinor): bool { - return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false); + // 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 @@ -62,12 +110,15 @@ class BillingClient } /** - * Debit the wallet. Returns true on success, false on insufficient balance - * (HTTP 402). Idempotent by $reference. + * 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): bool + public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): ?array { - $res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([ + $res = $this->client()->post($this->base().'/debit', array_filter([ 'user' => $publicId, 'amount_minor' => $amountMinor, 'service' => $service, @@ -78,16 +129,40 @@ class BillingClient ], static fn ($v) => $v !== null)); if ($res->status() === 402) { - return false; - } - $res->throw(); + $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 true; + 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 = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/credit', array_filter([ + $res = $this->client()->post($this->base().'/credit', array_filter([ 'user' => $publicId, 'amount_minor' => $amountMinor, 'service' => $service, @@ -98,6 +173,27 @@ class BillingClient ], static fn ($v) => $v !== null)); $res->throw(); - return (array) $res->json(); + $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; } } diff --git a/app/Services/Qr/QrCodeManagerService.php b/app/Services/Qr/QrCodeManagerService.php index 1d48075..3e0cd4c 100644 --- a/app/Services/Qr/QrCodeManagerService.php +++ b/app/Services/Qr/QrCodeManagerService.php @@ -36,6 +36,8 @@ class QrCodeManagerService { $wallet = $this->walletFor($user); + // Fast path: one cached balance read. Debit is still authoritative (402 if + // funds are short). Avoids a second can-afford HTTP round-trip. if (! $wallet->canCreateQr()) { throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.'); } @@ -44,7 +46,8 @@ class QrCodeManagerService $validated = $this->payloadValidator->validateForCreate($type, $data); $style = QrStyleDefaults::merge($data['style'] ?? null); - return DB::transaction(function () use ($user, $wallet, $data, $type, $validated, $style) { + // Persist the code first (short DB transaction โ€” no remote billing/image work). + $qrCode = DB::transaction(function () use ($user, $data, $type, $validated, $style) { $documentId = null; $content = $validated['content']; @@ -175,7 +178,7 @@ class QrCodeManagerService $payload['wifi_encoded'] = \App\Support\Qr\QrWifiPayload::encode($content); } - $qrCode = QrCode::create([ + return QrCode::create([ 'user_id' => $user->id, 'short_code' => $shortCode, 'type' => $type, @@ -186,12 +189,20 @@ class QrCodeManagerService 'is_active' => true, 'destination_updated_at' => now(), ]); - - $this->billing->debitForQrCreation($wallet, $qrCode); - $this->imageGenerator->generateAndStore($qrCode); - - return $qrCode->fresh(['document']); }); + + try { + $this->billing->debitForQrCreation($wallet, $qrCode); + } catch (RuntimeException $e) { + // Roll back the unpaid code so a failed debit cannot leave a free QR. + $qrCode->delete(); + throw $e; + } + + // Image render is local CPU work โ€” keep it outside the DB transaction. + $this->imageGenerator->generateAndStore($qrCode); + + return $qrCode->fresh(['document']); } /** @@ -331,9 +342,10 @@ class QrCodeManagerService $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['itinerary_cover'], 'itinerary-covers'); } + $previousStyle = $style; + if (isset($data['style']) && is_array($data['style'])) { $style = QrStyleDefaults::merge(array_merge($style, $data['style'])); - $regenerate = true; } if (($data['logo'] ?? null) instanceof UploadedFile) { @@ -341,11 +353,16 @@ class QrCodeManagerService $regenerate = true; } - if (($data['remove_logo'] ?? false) && $style['logo_path']) { + if (($data['remove_logo'] ?? false) && ($style['logo_path'] ?? null)) { $style['logo_path'] = null; $regenerate = true; } + // Only re-render the PNG/SVG when style actually changed (forms always post style[]). + if (! $regenerate && $this->styleChanged($previousStyle, $style)) { + $regenerate = true; + } + $qrCode->destination_url = $destinationUrl; // Preserve frozen keys (e.g. wifi_encoded) so the printed WiFi code stays put. $payload = (array) ($qrCode->payload ?? []); @@ -361,6 +378,20 @@ class QrCodeManagerService return $qrCode->fresh(['document']); } + /** + * @param array $before + * @param array $after + */ + private function styleChanged(array $before, array $after): bool + { + $before = QrStyleDefaults::merge($before); + $after = QrStyleDefaults::merge($after); + ksort($before); + ksort($after); + + return $before !== $after; + } + /** * Inject uploaded item images into the sections array. * Form field: item_images[sectionIndex][itemIndex] (UploadedFile) diff --git a/app/Services/Qr/QrWalletBillingService.php b/app/Services/Qr/QrWalletBillingService.php index 5969f3d..365fb14 100644 --- a/app/Services/Qr/QrWalletBillingService.php +++ b/app/Services/Qr/QrWalletBillingService.php @@ -7,7 +7,6 @@ use App\Models\QrTransaction; use App\Models\QrWallet; use App\Models\User; use App\Services\Billing\BillingClient; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use RuntimeException; @@ -39,36 +38,37 @@ class QrWalletBillingService $user = $wallet->user; $reference = 'QR-DEBIT-'.strtoupper(Str::random(12)); - return DB::transaction(function () use ($wallet, $user, $qrCode, $price, $priceMinor, $reference) { - $ok = $this->billing->debit( - $user->public_id, - $priceMinor, - 'qr', - 'qr_create', - $reference, - $qrCode->id, - sprintf('Created QR code: %s', $qrCode->label), - ); + $result = $this->billing->debit( + $user->public_id, + $priceMinor, + 'qr', + 'qr_create', + $reference, + $qrCode->id, + sprintf('Created QR code: %s', $qrCode->label), + ); - if (! $ok) { - throw new RuntimeException('Insufficient wallet balance.'); - } + if ($result === null) { + throw new RuntimeException('Insufficient wallet balance.'); + } - $wallet->increment('qr_codes_total'); - $balanceAfter = $this->billing->balanceMinor($user->public_id) / 100; + $wallet->increment('qr_codes_total'); - return QrTransaction::create([ - 'user_id' => $wallet->user_id, - 'qr_wallet_id' => $wallet->id, - 'qr_code_id' => $qrCode->id, - 'type' => QrTransaction::TYPE_DEBIT, - 'amount_ghs' => round($price, 4), - 'balance_after_ghs' => $balanceAfter, - 'reference' => $reference, - 'status' => 'completed', - 'description' => sprintf('Created QR code: %s', $qrCode->label), - 'metadata' => ['qr_code_id' => $qrCode->id], - ]); - }); + $balanceAfter = isset($result['balance_after_minor']) + ? ((int) $result['balance_after_minor']) / 100 + : $this->billing->balanceMinor($user->public_id) / 100; + + return QrTransaction::create([ + 'user_id' => $wallet->user_id, + 'qr_wallet_id' => $wallet->id, + 'qr_code_id' => $qrCode->id, + 'type' => QrTransaction::TYPE_DEBIT, + 'amount_ghs' => round($price, 4), + 'balance_after_ghs' => $balanceAfter, + 'reference' => $reference, + 'status' => 'completed', + 'description' => sprintf('Created QR code: %s', $qrCode->label), + 'metadata' => ['qr_code_id' => $qrCode->id], + ]); } } diff --git a/config/billing.php b/config/billing.php index 8e4c100..f7a06d3 100644 --- a/config/billing.php +++ b/config/billing.php @@ -3,6 +3,11 @@ return [ 'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'), 'api_key' => env('BILLING_API_KEY_QR'), + // Same-server hairpin: force billing DNS to this IP (e.g. 127.0.0.1) to skip + // public network/TLS lag. Leave empty to use normal DNS. + 'force_ip' => env('BILLING_API_FORCE_IP', ''), + 'timeout' => (int) env('BILLING_API_TIMEOUT', 10), + 'connect_timeout' => (int) env('BILLING_API_CONNECT_TIMEOUT', 2), 'service' => 'qr', 'wallet_balance_route' => 'wallet.balance', 'currency' => 'GHS', diff --git a/tests/Unit/BillingClientDebitTest.php b/tests/Unit/BillingClientDebitTest.php new file mode 100644 index 0000000..4419d4a --- /dev/null +++ b/tests/Unit/BillingClientDebitTest.php @@ -0,0 +1,69 @@ + 'https://ladill.com/api/billing', + 'billing.api_key' => 'test-key', + 'billing.force_ip' => '', + ]); + + Http::fake([ + 'https://ladill.com/api/billing/debit' => Http::response([ + 'transaction_id' => 1, + 'reference' => 'R1', + 'type' => 'debit', + 'amount_minor' => 500, + 'balance_after_minor' => 1200, + 'service' => 'qr', + ], 201), + 'https://ladill.com/api/billing/balance*' => Http::response([ + 'balance_minor' => 9999, + 'currency' => 'GHS', + ]), + ]); + + Cache::flush(); + $client = app(BillingClient::class); + + $result = $client->debit('user-1', 500, 'qr', 'qr_create', 'REF-1', 9, 'test'); + + $this->assertIsArray($result); + $this->assertSame(1200, $result['balance_after_minor']); + // Should use debit response cache, not hit balance endpoint. + $this->assertSame(1200, $client->balanceMinor('user-1')); + Http::assertSentCount(1); + } + + public function test_can_afford_uses_balance_not_extra_endpoint(): void + { + config([ + 'billing.api_url' => 'https://ladill.com/api/billing', + 'billing.api_key' => 'test-key', + 'billing.force_ip' => '', + ]); + + Http::fake([ + 'https://ladill.com/api/billing/balance*' => Http::response([ + 'balance_minor' => 500, + 'currency' => 'GHS', + ]), + ]); + + Cache::flush(); + $client = app(BillingClient::class); + + $this->assertTrue($client->canAfford('user-1', 500)); + $this->assertFalse($client->canAfford('user-1', 501)); + Http::assertSentCount(1); + } +}