Files
ladill-events/app/Services/Billing/BillingClient.php
T
isaacclad 3d5ffa593d
Deploy Ladill Events / deploy (push) Successful in 48s
feat(messaging): suite-channel email/SMS and entitlement sync
Prefer platform suite messaging (channel=suite) for attendee email, fall back
to Bird integrations; sync suite plan entitlements on subscribe/renew.
2026-07-16 11:24:21 +00:00

236 lines
8.1 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();
}
/**
* Write suite messaging plan entitlement (SoT for included email/SMS allowances).
*
* @param array{period_starts_at?: string, period_ends_at?: string|null, source?: string, metadata?: array} $options
*/
public function syncSuiteEntitlement(
string $publicId,
string $plan,
string $status,
string $reference,
array $options = [],
): bool {
if (! $this->configured()) {
return false;
}
$app = (string) config('billing.service', 'events');
$starts = $options['period_starts_at'] ?? now()->toIso8601String();
$ends = array_key_exists('period_ends_at', $options)
? $options['period_ends_at']
: now()->addDays(30)->toIso8601String();
try {
$res = Http::withToken($this->token())
->acceptJson()
->timeout(15)
->post($this->base().'/suite-entitlements', array_filter([
'user' => $publicId,
'app' => $app,
'plan' => $plan,
'status' => $status,
'period_starts_at' => $starts,
'period_ends_at' => $ends,
'source' => $options['source'] ?? 'wallet_debit',
'reference' => $reference,
'metadata' => $options['metadata'] ?? null,
], static fn ($v) => $v !== null));
if ($res->failed()) {
Log::warning('Events suite entitlement sync failed', [
'user' => $publicId,
'status' => $res->status(),
'body' => $res->body(),
]);
return false;
}
return true;
} catch (\Throwable $e) {
Log::warning('Events suite entitlement sync error', ['error' => $e->getMessage()]);
return false;
}
}
}