Speed up QR create/update by cutting billing round-trips.
Deploy Ladill QR Plus / deploy (push) Successful in 2m2s
Deploy Ladill QR Plus / deploy (push) Successful in 2m2s
Create was making three sequential public HTTPS billing calls (~400ms each) while holding a DB transaction open for image generation. Use loopback DNS forcing, cache balances briefly, return balance from debit, regenerate images only when style changes, and keep remote work outside the DB transaction.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, mixed>|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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, mixed> $before
|
||||
* @param array<string, mixed> $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)
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BillingClientDebitTest extends TestCase
|
||||
{
|
||||
public function test_debit_returns_payload_and_caches_balance(): 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/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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user