Deploy Ladill Events / deploy (push) Successful in 42s
Cut ticket checkouts off Ladill Pay, settle to merchant gateways at 0% platform fee, and mirror Invoice freemium pricing (GHS 49 / 149). Co-authored-by: Cursor <cursoragent@cursor.com>
180 lines
6.2 KiB
PHP
180 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use App\Models\User;
|
|
|
|
/**
|
|
* 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
|
|
* identified by public_id. Authenticates with the per-consumer config('billing.service') key.
|
|
* Contract validated in the monolith (smtp-extraction-runbook §4-A).
|
|
*/
|
|
class BillingClient
|
|
{
|
|
private function base(): string
|
|
{
|
|
return rtrim((string) config('billing.api_url'), '/');
|
|
}
|
|
|
|
private function token(): string
|
|
{
|
|
return (string) (config('billing.api_key') ?? '');
|
|
}
|
|
|
|
private function get(string $path, array $query): array
|
|
{
|
|
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().$path, $query);
|
|
$res->throw();
|
|
|
|
return (array) $res->json();
|
|
}
|
|
|
|
public function balanceMinor(string $publicId): int
|
|
{
|
|
return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0);
|
|
}
|
|
|
|
public function canAfford(string $publicId, int $amountMinor): bool
|
|
{
|
|
return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false);
|
|
}
|
|
|
|
public function serviceLedger(string $publicId, string $service): array
|
|
{
|
|
return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]);
|
|
}
|
|
|
|
/**
|
|
* Debit the wallet. Returns true on success, false on insufficient balance
|
|
* (HTTP 402). Idempotent by $reference.
|
|
*/
|
|
public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): bool
|
|
{
|
|
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->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) {
|
|
return false;
|
|
}
|
|
$res->throw();
|
|
|
|
return true;
|
|
}
|
|
|
|
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([
|
|
'user' => $publicId,
|
|
'amount_minor' => $amountMinor,
|
|
'service' => $service,
|
|
'source' => $source,
|
|
'reference' => $reference,
|
|
'service_id' => $serviceId,
|
|
'description' => $description,
|
|
], static fn ($v) => $v !== null));
|
|
$res->throw();
|
|
|
|
return (array) $res->json();
|
|
}
|
|
|
|
public function configured(): bool
|
|
{
|
|
return $this->base() !== '' && $this->token() !== '';
|
|
}
|
|
|
|
/**
|
|
* @return array{ok: bool, insufficient: bool, balance_minor: ?int, error: ?string}
|
|
*/
|
|
public function charge(User $user, int $amountMinor, string $reference, string $description): array
|
|
{
|
|
if (! $this->configured()) {
|
|
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing is not configured.'];
|
|
}
|
|
|
|
try {
|
|
$res = Http::withToken($this->token())
|
|
->acceptJson()->timeout(20)
|
|
->post($this->base().'/debit', [
|
|
'user' => $user->public_id,
|
|
'amount_minor' => $amountMinor,
|
|
'service' => (string) config('billing.service', 'events'),
|
|
'source' => 'subscription',
|
|
'reference' => $reference,
|
|
'description' => $description,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('Events BillingClient: charge failed', ['user' => $user->public_id, 'error' => $e->getMessage()]);
|
|
|
|
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Could not reach the billing service.'];
|
|
}
|
|
|
|
if ($res->status() === 402) {
|
|
return ['ok' => false, 'insufficient' => true, 'balance_minor' => (int) $res->json('balance_minor', 0), 'error' => 'Insufficient wallet balance.'];
|
|
}
|
|
|
|
if ($res->failed()) {
|
|
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing error ('.$res->status().').'];
|
|
}
|
|
|
|
return ['ok' => true, 'insufficient' => false, 'balance_minor' => null, 'error' => null];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $metadata
|
|
* @return array{checkout_url: string, reference: string}
|
|
*/
|
|
public function initiatePlanCheckout(
|
|
User $user,
|
|
string $plan,
|
|
int $months,
|
|
int $amountMinor,
|
|
string $returnUrl,
|
|
array $metadata = [],
|
|
): array {
|
|
$res = Http::withToken($this->token())
|
|
->acceptJson()->timeout(15)
|
|
->post($this->base().'/plan-checkout', [
|
|
'user' => $user->public_id,
|
|
'app' => (string) config('billing.service', 'events'),
|
|
'plan' => $plan,
|
|
'months' => $months,
|
|
'amount_minor' => $amountMinor,
|
|
'return_url' => $returnUrl,
|
|
'metadata' => $metadata,
|
|
]);
|
|
$res->throw();
|
|
|
|
return [
|
|
'checkout_url' => (string) $res->json('checkout_url'),
|
|
'reference' => (string) $res->json('reference'),
|
|
];
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
public function verifyPlanCheckout(string $reference): array
|
|
{
|
|
$res = Http::withToken($this->token())
|
|
->acceptJson()->timeout(15)
|
|
->post($this->base().'/plan-checkout/verify', [
|
|
'reference' => $reference,
|
|
]);
|
|
|
|
if ($res->status() === 422) {
|
|
return ['paid' => false, 'error' => $res->json('error')];
|
|
}
|
|
$res->throw();
|
|
|
|
return $res->json();
|
|
}
|
|
}
|