Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
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
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\PlatformSetting;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
|
||||
class PaystackService
|
||||
{
|
||||
public function publicKey(): string
|
||||
{
|
||||
return (string) $this->settingValue('paystack_public_key', config('services.paystack.public_key', ''));
|
||||
}
|
||||
|
||||
public function initializeTransaction(array $payload): array
|
||||
{
|
||||
return $this->initializeTransactionWithCredentials($payload);
|
||||
}
|
||||
|
||||
public function initializeTransactionWithCredentials(array $payload, ?string $secretKey = null, ?string $baseUrl = null): array
|
||||
{
|
||||
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
|
||||
->post('/transaction/initialize', $payload);
|
||||
|
||||
return $this->extractData($response, 'Failed to initialize Paystack transaction.');
|
||||
}
|
||||
|
||||
public function verifyTransaction(string $reference): array
|
||||
{
|
||||
return $this->verifyTransactionWithCredentials($reference);
|
||||
}
|
||||
|
||||
public function verifyTransactionWithCredentials(string $reference, ?string $secretKey = null, ?string $baseUrl = null): array
|
||||
{
|
||||
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
|
||||
->get('/transaction/verify/' . urlencode($reference));
|
||||
|
||||
return $this->extractData($response, 'Failed to verify Paystack transaction.');
|
||||
}
|
||||
|
||||
public function verifyWebhookSignature(string $rawBody, ?string $signature): bool
|
||||
{
|
||||
$secret = (string) $this->settingValue('paystack_webhook_secret', config('services.paystack.webhook_secret'));
|
||||
if ($secret === '') {
|
||||
$secret = (string) $this->settingValue('paystack_secret_key', config('services.paystack.secret_key'));
|
||||
}
|
||||
if ($secret === '' || !$signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$computed = hash_hmac('sha512', $rawBody, $secret);
|
||||
return hash_equals($computed, $signature);
|
||||
}
|
||||
|
||||
protected function request(?string $secretKey = null, ?string $baseUrl = null)
|
||||
{
|
||||
$resolvedBaseUrl = rtrim((string) ($baseUrl ?: $this->settingValue('paystack_base_url', config('services.paystack.base_url', 'https://api.paystack.co'))), '/');
|
||||
$secret = $this->normalizeSecretKey(
|
||||
(string) ($secretKey ?: $this->settingValue('paystack_secret_key', config('services.paystack.secret_key')))
|
||||
);
|
||||
|
||||
return Http::baseUrl($resolvedBaseUrl)
|
||||
->withToken($secret)
|
||||
->acceptJson()
|
||||
->asJson();
|
||||
}
|
||||
|
||||
protected function settingValue(string $key, mixed $fallback = null): mixed
|
||||
{
|
||||
try {
|
||||
if (!Schema::hasTable('platform_settings')) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$setting = PlatformSetting::query()
|
||||
->where('key', $key)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
return $setting ? ($setting->value['value'] ?? $fallback) : $fallback;
|
||||
} catch (\Throwable) {
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
protected function extractData(Response $response, string $message): array
|
||||
{
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (!is_array($json) || !($json['status'] ?? false)) {
|
||||
throw new RuntimeException((string) ($json['message'] ?? $message));
|
||||
}
|
||||
|
||||
$data = $json['data'] ?? null;
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List supported banks / mobile money providers.
|
||||
* @param string $country e.g. 'ghana', 'nigeria'
|
||||
* @param string $type 'nuban', 'mobile_money', or '' for all
|
||||
*/
|
||||
public function listBanks(string $country = 'ghana', string $type = '', ?string $currency = null): array
|
||||
{
|
||||
$query = ['perPage' => 200];
|
||||
if ($currency !== null && $currency !== '') {
|
||||
$query['currency'] = strtoupper($currency);
|
||||
} else {
|
||||
$query['country'] = $country;
|
||||
}
|
||||
if ($type !== '') {
|
||||
$query['type'] = $type;
|
||||
}
|
||||
$response = $this->request()->get('/bank', $query);
|
||||
$json = $response->json();
|
||||
return is_array($json['data'] ?? null) ? $json['data'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a transfer recipient (bank account or mobile money).
|
||||
* @param array{type: string, name: string, account_number: string, bank_code: string, currency?: string} $payload
|
||||
*/
|
||||
public function createTransferRecipient(array $payload): array
|
||||
{
|
||||
$response = $this->request()->post('/transferrecipient', $payload);
|
||||
return $this->extractData($response, 'Failed to create transfer recipient.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a transfer to a recipient.
|
||||
* @param array{source: string, amount: int, recipient: string, reason?: string, reference?: string} $payload
|
||||
*/
|
||||
public function initiateTransfer(array $payload): array
|
||||
{
|
||||
$response = $this->request()->post('/transfer', $payload);
|
||||
return $this->extractData($response, 'Failed to initiate transfer.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a transfer by reference.
|
||||
*/
|
||||
public function verifyTransfer(string $reference): array
|
||||
{
|
||||
$response = $this->request()->get('/transfer/verify/' . urlencode($reference));
|
||||
return $this->extractData($response, 'Failed to verify transfer.');
|
||||
}
|
||||
|
||||
protected function normalizeSecretKey(string $secret): string
|
||||
{
|
||||
$normalized = trim($secret);
|
||||
|
||||
if (str_starts_with(strtolower($normalized), 'bearer ')) {
|
||||
$normalized = trim(substr($normalized, 7));
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user