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=
|
LADILL_SSO_CLIENT_SECRET=
|
||||||
|
|
||||||
BILLING_API_URL=https://ladill.com/api/billing
|
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=
|
BILLING_API_KEY_QR=
|
||||||
|
|
||||||
IDENTITY_API_URL=https://ladill.com/api
|
IDENTITY_API_URL=https://ladill.com/api
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace App\Http\Controllers;
|
|||||||
use App\Services\Billing\BillingClient;
|
use App\Services\Billing\BillingClient;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight wallet balance for the avatar dropdown widget.
|
* Lightweight wallet balance for the avatar dropdown widget.
|
||||||
@@ -16,15 +15,10 @@ class WalletBalanceController extends Controller
|
|||||||
{
|
{
|
||||||
$publicId = (string) $request->user()->public_id;
|
$publicId = (string) $request->user()->public_id;
|
||||||
|
|
||||||
$minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) {
|
// BillingClient already short-caches balances; avoid a second long cache layer.
|
||||||
try {
|
try {
|
||||||
return $billing->balanceMinor($publicId);
|
$minor = $billing->balanceMinor($publicId);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($minor === null) {
|
|
||||||
return response()->json(['available' => false]);
|
return response()->json(['available' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,22 @@
|
|||||||
|
|
||||||
namespace App\Services\Billing;
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client for the platform Billing HTTP API — the one UserWallet lives on the
|
* 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.
|
* 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
|
class BillingClient
|
||||||
{
|
{
|
||||||
|
private const BALANCE_CACHE_TTL_SECONDS = 8;
|
||||||
|
|
||||||
private function base(): string
|
private function base(): string
|
||||||
{
|
{
|
||||||
return rtrim((string) config('billing.api_url'), '/');
|
return rtrim((string) config('billing.api_url'), '/');
|
||||||
@@ -22,9 +28,46 @@ class BillingClient
|
|||||||
return (string) (config('billing.api_key') ?? '');
|
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
|
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();
|
$res->throw();
|
||||||
|
|
||||||
return (array) $res->json();
|
return (array) $res->json();
|
||||||
@@ -32,7 +75,11 @@ class BillingClient
|
|||||||
|
|
||||||
public function balanceMinor(string $publicId): int
|
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
|
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,
|
'user' => $publicId,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
'return_url' => $returnUrl,
|
'return_url' => $returnUrl,
|
||||||
@@ -53,7 +100,8 @@ class BillingClient
|
|||||||
|
|
||||||
public function canAfford(string $publicId, int $amountMinor): bool
|
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
|
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
|
* Debit the wallet. Returns the API payload on success (includes
|
||||||
* (HTTP 402). Idempotent by $reference.
|
* 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,
|
'user' => $publicId,
|
||||||
'amount_minor' => $amountMinor,
|
'amount_minor' => $amountMinor,
|
||||||
'service' => $service,
|
'service' => $service,
|
||||||
@@ -78,16 +129,40 @@ class BillingClient
|
|||||||
], static fn ($v) => $v !== null));
|
], static fn ($v) => $v !== null));
|
||||||
|
|
||||||
if ($res->status() === 402) {
|
if ($res->status() === 402) {
|
||||||
return false;
|
$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();
|
$res->throw();
|
||||||
|
$payload = (array) $res->json();
|
||||||
|
|
||||||
return true;
|
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
|
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,
|
'user' => $publicId,
|
||||||
'amount_minor' => $amountMinor,
|
'amount_minor' => $amountMinor,
|
||||||
'service' => $service,
|
'service' => $service,
|
||||||
@@ -98,6 +173,27 @@ class BillingClient
|
|||||||
], static fn ($v) => $v !== null));
|
], static fn ($v) => $v !== null));
|
||||||
$res->throw();
|
$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);
|
$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()) {
|
if (! $wallet->canCreateQr()) {
|
||||||
throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.');
|
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);
|
$validated = $this->payloadValidator->validateForCreate($type, $data);
|
||||||
$style = QrStyleDefaults::merge($data['style'] ?? null);
|
$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;
|
$documentId = null;
|
||||||
$content = $validated['content'];
|
$content = $validated['content'];
|
||||||
|
|
||||||
@@ -175,7 +178,7 @@ class QrCodeManagerService
|
|||||||
$payload['wifi_encoded'] = \App\Support\Qr\QrWifiPayload::encode($content);
|
$payload['wifi_encoded'] = \App\Support\Qr\QrWifiPayload::encode($content);
|
||||||
}
|
}
|
||||||
|
|
||||||
$qrCode = QrCode::create([
|
return QrCode::create([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'short_code' => $shortCode,
|
'short_code' => $shortCode,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
@@ -186,12 +189,20 @@ class QrCodeManagerService
|
|||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'destination_updated_at' => now(),
|
'destination_updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
$this->billing->debitForQrCreation($wallet, $qrCode);
|
$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);
|
$this->imageGenerator->generateAndStore($qrCode);
|
||||||
|
|
||||||
return $qrCode->fresh(['document']);
|
return $qrCode->fresh(['document']);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,9 +342,10 @@ class QrCodeManagerService
|
|||||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['itinerary_cover'], 'itinerary-covers');
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['itinerary_cover'], 'itinerary-covers');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$previousStyle = $style;
|
||||||
|
|
||||||
if (isset($data['style']) && is_array($data['style'])) {
|
if (isset($data['style']) && is_array($data['style'])) {
|
||||||
$style = QrStyleDefaults::merge(array_merge($style, $data['style']));
|
$style = QrStyleDefaults::merge(array_merge($style, $data['style']));
|
||||||
$regenerate = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($data['logo'] ?? null) instanceof UploadedFile) {
|
if (($data['logo'] ?? null) instanceof UploadedFile) {
|
||||||
@@ -341,11 +353,16 @@ class QrCodeManagerService
|
|||||||
$regenerate = true;
|
$regenerate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($data['remove_logo'] ?? false) && $style['logo_path']) {
|
if (($data['remove_logo'] ?? false) && ($style['logo_path'] ?? null)) {
|
||||||
$style['logo_path'] = null;
|
$style['logo_path'] = null;
|
||||||
$regenerate = true;
|
$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;
|
$qrCode->destination_url = $destinationUrl;
|
||||||
// Preserve frozen keys (e.g. wifi_encoded) so the printed WiFi code stays put.
|
// Preserve frozen keys (e.g. wifi_encoded) so the printed WiFi code stays put.
|
||||||
$payload = (array) ($qrCode->payload ?? []);
|
$payload = (array) ($qrCode->payload ?? []);
|
||||||
@@ -361,6 +378,20 @@ class QrCodeManagerService
|
|||||||
return $qrCode->fresh(['document']);
|
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.
|
* Inject uploaded item images into the sections array.
|
||||||
* Form field: item_images[sectionIndex][itemIndex] (UploadedFile)
|
* Form field: item_images[sectionIndex][itemIndex] (UploadedFile)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use App\Models\QrTransaction;
|
|||||||
use App\Models\QrWallet;
|
use App\Models\QrWallet;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Billing\BillingClient;
|
use App\Services\Billing\BillingClient;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -39,8 +38,7 @@ class QrWalletBillingService
|
|||||||
$user = $wallet->user;
|
$user = $wallet->user;
|
||||||
$reference = 'QR-DEBIT-'.strtoupper(Str::random(12));
|
$reference = 'QR-DEBIT-'.strtoupper(Str::random(12));
|
||||||
|
|
||||||
return DB::transaction(function () use ($wallet, $user, $qrCode, $price, $priceMinor, $reference) {
|
$result = $this->billing->debit(
|
||||||
$ok = $this->billing->debit(
|
|
||||||
$user->public_id,
|
$user->public_id,
|
||||||
$priceMinor,
|
$priceMinor,
|
||||||
'qr',
|
'qr',
|
||||||
@@ -50,12 +48,15 @@ class QrWalletBillingService
|
|||||||
sprintf('Created QR code: %s', $qrCode->label),
|
sprintf('Created QR code: %s', $qrCode->label),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $ok) {
|
if ($result === null) {
|
||||||
throw new RuntimeException('Insufficient wallet balance.');
|
throw new RuntimeException('Insufficient wallet balance.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$wallet->increment('qr_codes_total');
|
$wallet->increment('qr_codes_total');
|
||||||
$balanceAfter = $this->billing->balanceMinor($user->public_id) / 100;
|
|
||||||
|
$balanceAfter = isset($result['balance_after_minor'])
|
||||||
|
? ((int) $result['balance_after_minor']) / 100
|
||||||
|
: $this->billing->balanceMinor($user->public_id) / 100;
|
||||||
|
|
||||||
return QrTransaction::create([
|
return QrTransaction::create([
|
||||||
'user_id' => $wallet->user_id,
|
'user_id' => $wallet->user_id,
|
||||||
@@ -69,6 +70,5 @@ class QrWalletBillingService
|
|||||||
'description' => sprintf('Created QR code: %s', $qrCode->label),
|
'description' => sprintf('Created QR code: %s', $qrCode->label),
|
||||||
'metadata' => ['qr_code_id' => $qrCode->id],
|
'metadata' => ['qr_code_id' => $qrCode->id],
|
||||||
]);
|
]);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
return [
|
return [
|
||||||
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
|
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
|
||||||
'api_key' => env('BILLING_API_KEY_QR'),
|
'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',
|
'service' => 'qr',
|
||||||
'wallet_balance_route' => 'wallet.balance',
|
'wallet_balance_route' => 'wallet.balance',
|
||||||
'currency' => 'GHS',
|
'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