Add Paystack, Flutterwave, and Hubtel for encounter billing.
Deploy Ladill Care / deploy (push) Successful in 1m0s

Clinics on Pro/Enterprise can connect their own gateway in Integrations and collect card or MoMo on bills with 0% Ladill fee, while cash payments and balance totals stay correct for pending checkouts.
This commit is contained in:
isaacclad
2026-07-16 10:24:28 +00:00
parent 068c5aa63f
commit 7a0a7fcb88
19 changed files with 1283 additions and 31 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ clinical record is scoped to a platform account (`owner_ref`) and organization.
- **Consultations** — vitals, diagnoses, investigations, prescriptions
- **Laboratory** — test catalog, sample collection, results, approval
- **Pharmacy** — dispensing queue, drug inventory, batch stock tracking
- **Billing** — visit invoices, line items, payments, print view
- **Billing** — visit invoices, line items, cash/MoMo payments, and online checkout via your own Paystack / Flutterwave / Hubtel (Pro)
- **Reports** — patients, appointments, lab, finance, clinical (CSV export)
- **Admin** — branches, departments, practitioners, members, audit log
+3 -1
View File
@@ -58,12 +58,14 @@ class BillController extends Controller
$this->authorizeAbility($request, 'payments.manage');
$this->authorizeBill($request, $bill);
$manualMethods = array_keys(collect(config('care.payment_methods', []))->except(['gateway'])->all());
$payment = $this->bills->recordPayment(
$bill,
$this->ownerRef($request),
$request->validate([
'amount_minor' => ['required', 'integer', 'min:1'],
'method' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.payment_methods')))],
'method' => ['required', 'string', 'in:'.implode(',', $manualMethods)],
'reference' => ['nullable', 'string', 'max:100'],
]),
$this->ownerRef($request),
+78 -1
View File
@@ -8,9 +8,11 @@ use App\Models\Bill;
use App\Models\Visit;
use App\Services\Care\BillService;
use App\Services\Care\OrganizationResolver;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class BillController extends Controller
{
@@ -18,6 +20,7 @@ class BillController extends Controller
public function __construct(
protected BillService $bills,
protected MerchantGatewayService $gateway,
) {}
public function index(Request $request): View
@@ -62,14 +65,26 @@ class BillController extends Controller
->can($this->member($request), 'bills.manage');
$canPay = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'payments.manage');
$organization = $this->organization($request);
$gatewayConfigured = $this->gateway->isConfigured($organization);
$gateway = $this->gateway->settingFor($organization);
// Manual record form excludes gateway method (online checkout is separate).
$manualMethods = collect(config('care.payment_methods', []))
->except(['gateway'])
->all();
return view('care.bills.show', [
'bill' => $bill,
'statuses' => config('care.bill_statuses'),
'lineTypes' => config('care.bill_line_types'),
'paymentMethods' => config('care.payment_methods'),
'manualPaymentMethods' => $manualMethods,
'canManage' => $canManage,
'canPay' => $canPay,
'gatewayConfigured' => $gatewayConfigured,
'gatewayProvider' => $gateway?->provider,
'gatewayLabels' => config('care.payment_gateways'),
]);
}
@@ -119,12 +134,14 @@ class BillController extends Controller
$this->authorizeAbility($request, 'payments.manage');
$this->authorizeBill($request, $bill);
$manualMethods = array_keys(collect(config('care.payment_methods', []))->except(['gateway'])->all());
$this->bills->recordPayment(
$bill,
$this->ownerRef($request),
$request->validate([
'amount_minor' => ['required', 'integer', 'min:1'],
'method' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.payment_methods')))],
'method' => ['required', 'string', 'in:'.implode(',', $manualMethods)],
'reference' => ['nullable', 'string', 'max:100'],
'notes' => ['nullable', 'string', 'max:500'],
]),
@@ -134,6 +151,66 @@ class BillController extends Controller
return back()->with('success', 'Payment recorded.');
}
public function startGatewayPayment(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'payments.manage');
$this->authorizeBill($request, $bill);
$validated = $request->validate([
'amount_minor' => ['nullable', 'integer', 'min:1'],
]);
$amount = (int) ($validated['amount_minor'] ?? $bill->balance_minor);
try {
$result = $this->bills->startGatewayPayment(
$bill->loadMissing('patient'),
$this->organization($request),
$this->ownerRef($request),
$amount,
$request->user(),
$this->ownerRef($request),
);
} catch (RuntimeException|\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()->away($result['checkout_url']);
}
public function paymentCallback(Request $request): RedirectResponse|View
{
$reference = trim((string) (
$request->query('reference')
?: $request->query('tx_ref')
?: $request->query('clientReference')
?: ''
));
if ($reference === '') {
return view('care.bills.payment-return', [
'redirect' => route('care.bills.index'),
]);
}
try {
$payment = $this->bills->completeGatewayPayment($reference);
} catch (\Throwable) {
session()->flash('error', 'Payment could not be verified. If the customer was charged, re-check the bill or contact support with reference '.$reference.'.');
return view('care.bills.payment-return', [
'redirect' => route('care.bills.index'),
]);
}
$bill = $payment->bill;
session()->flash('success', 'Online payment recorded for '.$bill->invoice_number.'.');
return view('care.bills.payment-return', [
'redirect' => route('care.bills.show', $bill),
]);
}
public function void(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
@@ -4,21 +4,29 @@ namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\PaymentGatewaySetting;
use App\Services\Care\AuditLogger;
use App\Services\Care\CarePermissions;
use App\Services\Care\PlanService;
use App\Services\Messaging\CustomerSmsClient;
use App\Services\Messaging\MessagingCredentialsService;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class IntegrationsController extends Controller
{
use ScopesToAccount;
public function edit(Request $request, MessagingCredentialsService $credentials): View
{
public function edit(
Request $request,
MessagingCredentialsService $credentials,
MerchantGatewayService $gateway,
PlanService $plans,
): View {
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$canManage = app(CarePermissions::class)->can($this->member($request), 'settings.manage');
@@ -27,6 +35,9 @@ class IntegrationsController extends Controller
'organization' => $organization,
'canManage' => $canManage,
'credential' => $credentials->forOrganization($organization),
'gateway' => $gateway->settingFor($organization),
'canUsePaymentGateway' => $plans->canUsePaymentGateway($organization),
'gatewayLabels' => config('care.payment_gateways'),
]);
}
@@ -149,4 +160,93 @@ class IntegrationsController extends Controller
return response()->json($result['data'] ?? []);
}
public function saveGateway(Request $request, PlanService $plans): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
if (! $plans->canUsePaymentGateway($organization)) {
return back()->with('error', 'Connecting your own payment gateway requires Care Pro or Enterprise.');
}
$data = $request->validate([
'gateway_provider' => ['required', 'string', Rule::in(PaymentGatewaySetting::providers())],
'gateway_public_key' => ['nullable', 'string', 'max:2000'],
'gateway_secret_key' => ['nullable', 'string', 'max:2000'],
'gateway_webhook_secret' => ['nullable', 'string', 'max:2000'],
'gateway_is_active' => ['nullable', 'boolean'],
]);
$existing = PaymentGatewaySetting::query()
->where('organization_id', $organization->id)
->first();
$publicKey = trim((string) ($data['gateway_public_key'] ?? ''));
$secretKey = trim((string) ($data['gateway_secret_key'] ?? ''));
$webhookSecret = trim((string) ($data['gateway_webhook_secret'] ?? ''));
$attributes = [
'owner_ref' => $this->ownerRef($request),
'provider' => $data['gateway_provider'],
'is_active' => $request->boolean('gateway_is_active', true),
];
// Blank fields keep previously stored encrypted credentials.
if ($publicKey !== '') {
$attributes['public_key'] = $publicKey;
}
if ($secretKey !== '') {
$attributes['secret_key'] = $secretKey;
}
if ($webhookSecret !== '') {
$attributes['webhook_secret'] = $webhookSecret;
}
if ($existing === null && $secretKey === '') {
return back()->withInput()->with('error', 'Secret / API key is required to connect a payment gateway.');
}
if ($data['gateway_provider'] === PaymentGatewaySetting::PROVIDER_HUBTEL && $publicKey === '' && ! $existing?->public_key) {
return back()->withInput()->with('error', 'Hubtel requires a merchant account number (public key field).');
}
PaymentGatewaySetting::query()->updateOrCreate(
['organization_id' => $organization->id],
$attributes,
);
AuditLogger::record(
$this->ownerRef($request),
'integrations.gateway_connected',
$organization->id,
$this->ownerRef($request),
\App\Models\Organization::class,
$organization->id,
['provider' => $data['gateway_provider']],
);
return back()->with('success', 'Payment gateway saved.');
}
public function disconnectGateway(Request $request, PlanService $plans): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
PaymentGatewaySetting::query()
->where('organization_id', $organization->id)
->delete();
AuditLogger::record(
$this->ownerRef($request),
'integrations.gateway_disconnected',
$organization->id,
$this->ownerRef($request),
\App\Models\Organization::class,
$organization->id,
);
return back()->with('success', 'Payment gateway disconnected.');
}
}
+30 -1
View File
@@ -11,11 +11,27 @@ class Payment extends Model
{
use BelongsToOwner;
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_FAILED = 'failed';
public const METHOD_CASH = 'cash';
public const METHOD_MOMO = 'momo';
public const METHOD_CARD = 'card';
public const METHOD_OTHER = 'other';
public const METHOD_GATEWAY = 'gateway';
protected $table = 'care_payments';
protected $fillable = [
'uuid', 'owner_ref', 'bill_id', 'amount_minor', 'method',
'reference', 'paid_at', 'recorded_by', 'notes',
'status', 'gateway_provider', 'reference', 'paid_at', 'recorded_by', 'notes',
];
protected function casts(): array
@@ -29,6 +45,9 @@ class Payment extends Model
if (! $payment->uuid) {
$payment->uuid = (string) Str::uuid();
}
if (! $payment->status) {
$payment->status = self::STATUS_PAID;
}
});
}
@@ -41,4 +60,14 @@ class Payment extends Model
{
return $this->belongsTo(Bill::class, 'bill_id');
}
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isPaid(): bool
{
return $this->status === self::STATUS_PAID;
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PaymentGatewaySetting extends Model
{
use BelongsToOwner;
public const PROVIDER_PAYSTACK = 'paystack';
public const PROVIDER_FLUTTERWAVE = 'flutterwave';
public const PROVIDER_HUBTEL = 'hubtel';
protected $table = 'care_payment_gateway_settings';
protected $fillable = [
'owner_ref',
'organization_id',
'provider',
'public_key',
'secret_key',
'webhook_secret',
'is_active',
'metadata',
];
protected function casts(): array
{
return [
'public_key' => 'encrypted',
'secret_key' => 'encrypted',
'webhook_secret' => 'encrypted',
'is_active' => 'boolean',
'metadata' => 'array',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function isConfigured(): bool
{
if (! $this->is_active) {
return false;
}
$secret = trim((string) $this->secret_key);
return $secret !== '' && in_array($this->provider, [
self::PROVIDER_PAYSTACK,
self::PROVIDER_FLUTTERWAVE,
self::PROVIDER_HUBTEL,
], true);
}
/** @return list<string> */
public static function providers(): array
{
return [
self::PROVIDER_PAYSTACK,
self::PROVIDER_FLUTTERWAVE,
self::PROVIDER_HUBTEL,
];
}
}
+191 -20
View File
@@ -8,15 +8,20 @@ use App\Models\InvestigationRequest;
use App\Models\Organization;
use App\Models\Payment;
use App\Models\Prescription;
use App\Models\User;
use App\Models\Visit;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;
class BillService
{
public function __construct(
protected InvoiceNumberGenerator $invoices,
protected MerchantGatewayService $gateway,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
@@ -224,31 +229,15 @@ class BillService
'owner_ref' => $ownerRef,
'bill_id' => $bill->id,
'amount_minor' => $amount,
'method' => $data['method'] ?? 'cash',
'method' => $data['method'] ?? Payment::METHOD_CASH,
'status' => Payment::STATUS_PAID,
'reference' => $data['reference'] ?? null,
'paid_at' => $data['paid_at'] ?? now(),
'recorded_by' => $actorRef,
'notes' => $data['notes'] ?? null,
]);
$bill->refresh();
$paid = (int) $bill->payments()->sum('amount_minor');
$balance = max(0, $bill->total_minor - $paid);
$status = Bill::STATUS_OPEN;
if ($paid > 0 && $balance > 0) {
$status = Bill::STATUS_PARTIAL;
} elseif ($balance === 0 && $bill->total_minor > 0) {
$status = Bill::STATUS_PAID;
} elseif ($paid >= $bill->total_minor) {
$status = Bill::STATUS_PAID;
}
$bill->update([
'amount_paid_minor' => $paid,
'balance_minor' => $balance,
'status' => $status,
]);
$this->syncPaymentTotals($bill);
AuditLogger::record($ownerRef, 'payment.recorded', $bill->organization_id, $actorRef, Payment::class, $payment->id, [
'bill_id' => $bill->id,
@@ -258,6 +247,155 @@ class BillService
return $payment;
}
/**
* Start an online checkout (Paystack / Flutterwave / Hubtel) against the bill balance.
*
* @return array{payment: Payment, checkout_url: string, reference: string, provider: string}
*/
public function startGatewayPayment(
Bill $bill,
Organization $organization,
string $ownerRef,
int $amountMinor,
?User $actor = null,
?string $actorRef = null,
): array {
if ($bill->status === Bill::STATUS_VOID) {
throw new InvalidArgumentException('Cannot pay a void bill.');
}
if ($bill->balance_minor <= 0) {
throw new InvalidArgumentException('This bill is already fully paid.');
}
$amount = max(1, min($amountMinor, (int) $bill->balance_minor));
$email = trim((string) ($bill->patient?->email ?? ''));
if ($email === '' || ! str_contains($email, '@')) {
$email = trim((string) ($actor?->email ?? ''));
}
if ($email === '' || ! str_contains($email, '@')) {
$email = 'care+'.($organization->id).'@checkout.ladill.local';
}
$reference = 'CARE-'.strtoupper(Str::random(16));
$setting = $this->gateway->settingFor($organization);
$payment = Payment::create([
'owner_ref' => $ownerRef,
'bill_id' => $bill->id,
'amount_minor' => $amount,
'method' => Payment::METHOD_GATEWAY,
'status' => Payment::STATUS_PENDING,
'gateway_provider' => $setting?->provider,
'reference' => $reference,
'paid_at' => now(), // overwritten when verified; pending rows are excluded from totals
'recorded_by' => $actorRef,
'notes' => 'Online checkout initiated',
]);
try {
$checkout = $this->gateway->initializeCheckout(
$organization,
$amount,
(string) config('care.billing.currency', 'GHS'),
$email,
route('care.bills.payments.callback'),
$reference,
[
'title' => 'Invoice '.$bill->invoice_number,
'bill_id' => $bill->id,
'bill_uuid' => $bill->uuid,
'payment_id' => $payment->id,
'organization_id' => $organization->id,
'channel' => 'care_encounter',
],
);
} catch (RuntimeException $e) {
$payment->update(['status' => Payment::STATUS_FAILED, 'notes' => $e->getMessage()]);
throw $e;
}
$payment->update([
'reference' => $checkout['reference'],
'gateway_provider' => $checkout['provider'],
]);
AuditLogger::record($ownerRef, 'payment.checkout_started', $bill->organization_id, $actorRef, Payment::class, $payment->id, [
'bill_id' => $bill->id,
'amount_minor' => $amount,
'provider' => $checkout['provider'],
'reference' => $checkout['reference'],
]);
return [
'payment' => $payment->fresh(),
'checkout_url' => $checkout['checkout_url'],
'reference' => $checkout['reference'],
'provider' => $checkout['provider'],
];
}
public function completeGatewayPayment(string $reference): Payment
{
$existingPaid = Payment::query()
->where('reference', $reference)
->where('status', Payment::STATUS_PAID)
->where('method', Payment::METHOD_GATEWAY)
->with(['bill'])
->first();
if ($existingPaid) {
return $existingPaid;
}
$payment = Payment::query()
->where('reference', $reference)
->where('status', Payment::STATUS_PENDING)
->where('method', Payment::METHOD_GATEWAY)
->with(['bill.organization', 'bill.patient'])
->firstOrFail();
$bill = $payment->bill;
$organization = $bill->organization ?? Organization::query()->findOrFail($bill->organization_id);
$result = $this->gateway->verify($organization, $reference);
if (! $result['paid']) {
$payment->update([
'status' => Payment::STATUS_FAILED,
'notes' => 'Gateway reported payment incomplete.',
]);
throw new RuntimeException('Payment was not completed.');
}
$verifiedAmount = (int) ($result['amount_minor'] ?: $payment->amount_minor);
if ($verifiedAmount <= 0) {
$verifiedAmount = (int) $payment->amount_minor;
}
$payment->update([
'status' => Payment::STATUS_PAID,
'amount_minor' => $verifiedAmount,
'paid_at' => now(),
'gateway_provider' => $result['provider'] ?? $payment->gateway_provider,
'notes' => 'Online payment verified',
]);
$this->syncPaymentTotals($bill);
AuditLogger::record($payment->owner_ref, 'payment.recorded', $bill->organization_id, $payment->recorded_by, Payment::class, $payment->id, [
'bill_id' => $bill->id,
'amount_minor' => $verifiedAmount,
'provider' => $payment->gateway_provider,
'reference' => $reference,
'channel' => 'gateway',
]);
return $payment->fresh(['bill']);
}
public function void(Bill $bill, string $ownerRef, ?string $actorRef = null): Bill
{
if ($bill->status === Bill::STATUS_PAID) {
@@ -275,7 +413,7 @@ class BillService
$bill->refresh();
$subtotal = (int) $bill->lineItems()->sum('total_minor');
$total = max(0, $subtotal - $bill->discount_minor + $bill->tax_minor);
$paid = (int) $bill->payments()->sum('amount_minor');
$paid = $this->paidSum($bill);
$balance = max(0, $total - $paid);
$status = $bill->status;
@@ -298,6 +436,39 @@ class BillService
]);
}
protected function syncPaymentTotals(Bill $bill): void
{
$bill->refresh();
$paid = $this->paidSum($bill);
$balance = max(0, $bill->total_minor - $paid);
$status = Bill::STATUS_OPEN;
if ($paid > 0 && $balance > 0) {
$status = Bill::STATUS_PARTIAL;
} elseif ($balance === 0 && $bill->total_minor > 0) {
$status = Bill::STATUS_PAID;
} elseif ($paid >= $bill->total_minor) {
$status = Bill::STATUS_PAID;
}
if ($bill->status === Bill::STATUS_VOID) {
$status = Bill::STATUS_VOID;
}
$bill->update([
'amount_paid_minor' => $paid,
'balance_minor' => $balance,
'status' => $status,
]);
}
protected function paidSum(Bill $bill): int
{
return (int) $bill->payments()
->where('status', Payment::STATUS_PAID)
->sum('amount_minor');
}
protected function assertEditable(Bill $bill): void
{
if (in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) {
+6
View File
@@ -53,6 +53,12 @@ class PlanService
return $this->hasPaidPlan($organization);
}
/** Own payment gateway (Paystack / Flutterwave / Hubtel) for encounter billing requires Pro or Enterprise. */
public function canUsePaymentGateway(Organization $organization): bool
{
return $this->hasPaidPlan($organization) && $this->hasFeature($organization, 'billing');
}
public function hasFeature(Organization $organization, string $feature): bool
{
$features = config('care.plans.'.$this->planKey($organization).'.features', []);
+2
View File
@@ -49,6 +49,7 @@ class ReportService
->count();
$revenueToday = Payment::owned($ownerRef)
->where('status', Payment::STATUS_PAID)
->whereHas('bill', function (Builder $q) use ($organizationId, $branchId) {
$q->where('organization_id', $organizationId);
if ($branchId) {
@@ -162,6 +163,7 @@ class ReportService
->where('status', '!=', Bill::STATUS_VOID);
$payments = Payment::owned($ownerRef)
->where('status', Payment::STATUS_PAID)
->whereHas('bill', function (Builder $q) use ($organizationId, $branchId) {
$q->where('organization_id', $organizationId);
if ($branchId) {
@@ -0,0 +1,306 @@
<?php
namespace App\Services\Payments;
use App\Models\Organization;
use App\Models\PaymentGatewaySetting;
use App\Services\Care\PlanService;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class MerchantGatewayService
{
public function __construct(
protected PlanService $plans,
) {}
public function settingFor(Organization $organization): ?PaymentGatewaySetting
{
return PaymentGatewaySetting::query()
->where('organization_id', $organization->id)
->first();
}
public function isConfigured(Organization $organization): bool
{
if (! $this->organizationMayUseGateway($organization)) {
return false;
}
return (bool) $this->settingFor($organization)?->isConfigured();
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
public function initializeCheckout(
Organization $organization,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata = [],
): array {
$setting = $this->requireConfigured($organization);
$currency = strtoupper($currency ?: 'GHS');
return match ($setting->provider) {
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
default => throw new RuntimeException('Unsupported payment provider.'),
};
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
public function verify(Organization $organization, string $reference): array
{
$setting = $this->requireConfigured($organization);
return match ($setting->provider) {
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackVerify($setting, $reference),
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveVerify($setting, $reference),
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelVerify($setting, $reference),
default => throw new RuntimeException('Unsupported payment provider.'),
};
}
protected function requireConfigured(Organization $organization): PaymentGatewaySetting
{
if (! $this->organizationMayUseGateway($organization)) {
throw new RuntimeException('Connecting your own payment gateway requires Care Pro or Enterprise.');
}
$setting = $this->settingFor($organization);
if (! $setting?->isConfigured()) {
throw new RuntimeException('Connect Paystack, Flutterwave, or Hubtel in Integrations before collecting online payments.');
}
return $setting;
}
public function organizationMayUseGateway(Organization $organization): bool
{
return $this->plans->canUsePaymentGateway($organization);
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function paystackInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->post('https://api.paystack.co/transaction/initialize', [
'email' => $email !== '' ? $email : 'payer@example.com',
'amount' => $amountMinor,
'currency' => $currency,
'reference' => $reference,
'callback_url' => $callbackUrl,
'metadata' => $metadata,
]);
if (! $response->successful() || ! ($response->json('status') ?? false)) {
throw new RuntimeException($response->json('message') ?: 'Paystack could not start checkout.');
}
$url = (string) $response->json('data.authorization_url');
if ($url === '') {
throw new RuntimeException('Paystack did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => (string) ($response->json('data.reference') ?: $reference),
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function paystackVerify(PaymentGatewaySetting $setting, string $reference): array
{
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->get('https://api.paystack.co/transaction/verify/'.rawurlencode($reference));
$data = (array) ($response->json('data') ?? []);
$paid = ($response->json('status') ?? false)
&& strtolower((string) ($data['status'] ?? '')) === 'success';
return [
'paid' => $paid,
'amount_minor' => (int) ($data['amount'] ?? 0),
'reference' => (string) ($data['reference'] ?? $reference),
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
'raw' => $data,
];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function flutterwaveInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->post('https://api.flutterwave.com/v3/payments', [
'tx_ref' => $reference,
'amount' => round($amountMinor / 100, 2),
'currency' => $currency,
'redirect_url' => $callbackUrl,
'customer' => [
'email' => $email !== '' ? $email : 'payer@example.com',
],
'customizations' => [
'title' => (string) ($metadata['title'] ?? 'Payment'),
],
'meta' => $metadata,
]);
if (! $response->successful() || ($response->json('status') ?? '') !== 'success') {
throw new RuntimeException($response->json('message') ?: 'Flutterwave could not start checkout.');
}
$url = (string) $response->json('data.link');
if ($url === '') {
throw new RuntimeException('Flutterwave did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function flutterwaveVerify(PaymentGatewaySetting $setting, string $reference): array
{
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->get('https://api.flutterwave.com/v3/transactions/verify_by_reference', [
'tx_ref' => $reference,
]);
$data = (array) ($response->json('data') ?? []);
$paid = ($response->json('status') ?? '') === 'success'
&& strtolower((string) ($data['status'] ?? '')) === 'successful';
$amountMajor = (float) ($data['amount'] ?? 0);
return [
'paid' => $paid,
'amount_minor' => (int) round($amountMajor * 100),
'reference' => (string) ($data['tx_ref'] ?? $reference),
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
'raw' => $data,
];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function hubtelInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
// Hubtel: public_key = merchant account number, secret_key = API key (client secret),
// webhook_secret optionally stores client id for Basic auth as "clientId:clientSecret".
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
if (! str_contains($auth, ':')) {
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
}
$response = Http::withBasicAuth(...explode(':', $auth, 2))
->acceptJson()
->timeout(20)
->post('https://payproxyapi.hubtel.com/items/initiate', [
'totalAmount' => round($amountMinor / 100, 2),
'description' => (string) ($metadata['title'] ?? 'Payment'),
'callbackUrl' => $callbackUrl,
'returnUrl' => $callbackUrl,
'merchantAccountNumber' => (string) $setting->public_key,
'cancellationUrl' => $callbackUrl,
'clientReference' => $reference,
]);
if (! $response->successful()) {
throw new RuntimeException($response->json('message') ?: 'Hubtel could not start checkout.');
}
$url = (string) ($response->json('data.checkoutUrl') ?? $response->json('data.checkoutDirectUrl') ?? '');
if ($url === '') {
throw new RuntimeException('Hubtel did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function hubtelVerify(PaymentGatewaySetting $setting, string $reference): array
{
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
if (! str_contains($auth, ':')) {
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
}
$response = Http::withBasicAuth(...explode(':', $auth, 2))
->acceptJson()
->timeout(20)
->get('https://api-txnstatus.hubtel.com/transactions/'.$setting->public_key.'/status', [
'clientReference' => $reference,
]);
$data = (array) ($response->json('data') ?? $response->json() ?? []);
$status = strtolower((string) ($data['status'] ?? $data['transactionStatus'] ?? ''));
$paid = in_array($status, ['success', 'successful', 'paid', 'completed'], true);
$amountMajor = (float) ($data['amount'] ?? $data['totalAmount'] ?? 0);
return [
'paid' => $paid,
'amount_minor' => (int) round($amountMajor * 100),
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
'raw' => $data,
];
}
}
+10
View File
@@ -78,6 +78,9 @@ return [
'bill.line_item_added' => 'Bill line item added',
'bill.voided' => 'Bill voided',
'payment.recorded' => 'Payment recorded',
'payment.checkout_started' => 'Online payment checkout started',
'integrations.gateway_connected' => 'Payment gateway connected',
'integrations.gateway_disconnected' => 'Payment gateway disconnected',
'drug.created' => 'Drug added',
'drug.updated' => 'Drug updated',
'drug.batch_received' => 'Drug stock received',
@@ -199,9 +202,16 @@ return [
'cash' => 'Cash',
'momo' => 'Mobile Money',
'card' => 'Card',
'gateway' => 'Online (gateway)',
'other' => 'Other',
],
'payment_gateways' => [
'paystack' => 'Paystack',
'flutterwave' => 'Flutterwave',
'hubtel' => 'Hubtel',
],
'report_types' => [
'patients' => 'Patients',
'appointments' => 'Appointments',
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('care_payment_gateway_settings', function (Blueprint $table) {
$table->id();
$table->string('owner_ref')->index();
$table->foreignId('organization_id')->unique()->constrained('care_organizations')->cascadeOnDelete();
$table->string('provider', 32); // paystack|flutterwave|hubtel
$table->text('public_key')->nullable();
$table->text('secret_key')->nullable();
$table->text('webhook_secret')->nullable();
$table->boolean('is_active')->default(true);
$table->json('metadata')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('care_payment_gateway_settings');
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('care_payments', function (Blueprint $table) {
$table->string('status', 20)->default('paid')->after('method'); // pending|paid|failed
$table->string('gateway_provider', 32)->nullable()->after('status');
$table->index(['reference']);
$table->index(['bill_id', 'status']);
});
}
public function down(): void
{
Schema::table('care_payments', function (Blueprint $table) {
$table->dropIndex(['care_payments_reference_index']);
$table->dropIndex(['care_payments_bill_id_status_index']);
$table->dropColumn(['status', 'gateway_provider']);
});
}
};
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Confirming payment</title>
<style>
body { font-family: system-ui, sans-serif; background: #f8fafc; color: #0f172a; display: grid; place-items: center; min-height: 100vh; margin: 0; }
p { font-size: 0.95rem; color: #475569; }
</style>
</head>
<body>
<p>Confirming payment…</p>
<script>
(function () {
var url = @json($redirect);
try {
if (window.top && window.top !== window.self) {
window.top.location.replace(url);
return;
}
} catch (e) {}
window.location.replace(url);
})();
</script>
</body>
</html>
+37 -2
View File
@@ -81,17 +81,52 @@
<form method="POST" action="{{ route('care.bills.payments.store', $bill) }}" class="mt-4 space-y-3">
@csrf
<input type="number" name="amount_minor" value="{{ $bill->balance_minor }}" min="1" required class="w-full rounded-lg border-slate-300 text-sm">
<select name="method" class="w-full rounded-lg border-slate-300 text-sm">@foreach ($paymentMethods as $k => $v)<option value="{{ $k }}">{{ $v }}</option>@endforeach</select>
<select name="method" class="w-full rounded-lg border-slate-300 text-sm">@foreach ($manualPaymentMethods as $k => $v)<option value="{{ $k }}">{{ $v }}</option>@endforeach</select>
<input type="text" name="reference" placeholder="Reference" class="w-full rounded-lg border-slate-300 text-sm">
<button type="submit" class="btn-primary w-full">Record payment</button>
</form>
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase text-slate-500">Collect online</h2>
<p class="mt-1 text-xs text-slate-500">
Card or mobile money via your connected gateway
@if ($gatewayConfigured && $gatewayProvider)
({{ $gatewayLabels[$gatewayProvider] ?? ucfirst($gatewayProvider) }})
@endif
0% Ladill fee.
</p>
@if ($gatewayConfigured)
<form method="POST" action="{{ route('care.bills.payments.gateway', $bill) }}" class="mt-4 space-y-3">
@csrf
<input type="number" name="amount_minor" value="{{ $bill->balance_minor }}" min="1" max="{{ $bill->balance_minor }}" required class="w-full rounded-lg border-slate-300 text-sm" aria-label="Amount (minor units)">
<button type="submit" class="btn-primary w-full">Open checkout</button>
</form>
@else
<p class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
Connect Paystack, Flutterwave, or Hubtel in
<a href="{{ route('care.integrations') }}" class="font-medium underline">Integrations</a>
to collect online payments.
</p>
@endif
</section>
@endif
<section class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase text-slate-500">Payments</h2>
@forelse ($bill->payments as $payment)
<p class="mt-2 text-sm">{{ $money($payment->amount_minor) }} · {{ $paymentMethods[$payment->method] ?? $payment->method }} · {{ $payment->paid_at->format('d M Y') }}</p>
@php
$methodLabel = $payment->method === \App\Models\Payment::METHOD_GATEWAY
? ($gatewayLabels[$payment->gateway_provider] ?? 'Online')
: ($paymentMethods[$payment->method] ?? $payment->method);
$statusSuffix = $payment->status === \App\Models\Payment::STATUS_PAID
? ''
: ' · '.ucfirst($payment->status);
$when = $payment->isPaid()
? ($payment->paid_at?->format('d M Y') ?? $payment->created_at->format('d M Y'))
: $payment->created_at->format('d M Y');
@endphp
<p class="mt-2 text-sm">{{ $money($payment->amount_minor) }} · {{ $methodLabel }}{{ $statusSuffix }} · {{ $when }}@if($payment->reference)<span class="text-slate-400"> · {{ $payment->reference }}</span>@endif</p>
@empty
<p class="mt-2 text-sm text-slate-400">No payments recorded.</p>
@endforelse
@@ -1,9 +1,10 @@
<x-app-layout title="Integrations">
<x-settings.page title="Messaging integrations">
<x-settings.page title="Integrations">
<p class="text-sm text-slate-600">
Configure <a href="https://sms.ladill.com" target="_blank" rel="noopener" class="text-teal-700 underline">SMS</a>
Configure messaging and payment gateways for encounter billing.
Use <a href="https://sms.ladill.com" target="_blank" rel="noopener" class="text-teal-700 underline">SMS</a>
and <a href="https://bird.ladill.com" target="_blank" rel="noopener" class="text-teal-700 underline">email</a>
credentials for outbound messaging.
for outbound messages; connect Paystack, Flutterwave, or Hubtel so bill payments land in your account (0% Ladill fee).
</p>
@if (! $canManage)
@@ -124,6 +125,88 @@
@endif
@endif
</x-settings.card>
<x-settings.card title="Payment gateway" description="Encounter bill payments via your own Paystack, Flutterwave, or Hubtel account — 0% Ladill platform fee. Pro &amp; Enterprise only.">
@if ($canUsePaymentGateway ?? false)
@if ($gateway?->isConfigured())
<div class="mb-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
Connected · {{ $gatewayLabels[$gateway->provider] ?? ucfirst($gateway->provider) }}
@if (! $gateway->is_active)
· <span class="font-medium text-amber-800">disabled</span>
@endif
</div>
@else
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
Online checkout stays disabled until a gateway is connected.
</div>
@endif
@if ($canManage)
<form method="POST" action="{{ route('care.integrations.gateway.save') }}" class="space-y-4">
@csrf
<div>
<label class="block text-sm font-medium text-slate-700">Provider</label>
<select name="gateway_provider" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">Select a provider</option>
@foreach ($gatewayLabels as $value => $label)
<option value="{{ $value }}" @selected(old('gateway_provider', $gateway?->provider) === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700">Public key / Merchant account</label>
<input type="text" name="gateway_public_key" value="{{ old('gateway_public_key') }}"
placeholder="{{ $gateway?->public_key ? '•••• saved — leave blank to keep' : 'pk_live_… or Hubtel account #' }}"
class="mt-1 w-full rounded-lg border-slate-300 font-mono text-sm" autocomplete="off">
<p class="mt-1 text-xs text-slate-500">Hubtel: merchant account number. Paystack/Flutterwave: public key.</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Secret / API key</label>
<input type="password" name="gateway_secret_key" value=""
placeholder="{{ $gateway?->secret_key ? '•••• saved — leave blank to keep' : 'sk_live_…' }}"
class="mt-1 w-full rounded-lg border-slate-300 font-mono text-sm" autocomplete="new-password">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Webhook secret / Hubtel client auth (optional)</label>
<input type="password" name="gateway_webhook_secret" value=""
placeholder="{{ $gateway?->webhook_secret ? '•••• saved — leave blank to keep' : 'Optional · Hubtel: clientId:clientSecret' }}"
class="mt-1 w-full rounded-lg border-slate-300 font-mono text-sm" autocomplete="new-password">
</div>
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
<input type="hidden" name="gateway_is_active" value="0">
<input type="checkbox" name="gateway_is_active" value="1" @checked(old('gateway_is_active', $gateway?->is_active ?? true)) class="rounded border-slate-300 text-teal-600 focus:ring-teal-500">
Gateway enabled for encounter billing
</label>
<div class="flex flex-wrap gap-2">
<button type="submit" class="btn-primary">Save gateway</button>
</div>
</form>
@if ($gateway)
<div class="mt-3">
<x-confirm-dialog
name="disconnect-gateway"
title="Disconnect payment gateway?"
message="Online checkout for bills will stop until credentials are connected again."
:action="route('care.integrations.gateway.disconnect')"
confirm-label="Disconnect"
>
<x-slot:trigger>
<button type="button" class="text-sm text-rose-700 hover:underline">Disconnect</button>
</x-slot:trigger>
</x-confirm-dialog>
</div>
@endif
@endif
@else
<div class="rounded-lg border border-teal-100 bg-teal-50 px-4 py-3 text-sm text-teal-900">
<p class="font-medium">Your own payment gateway is a Pro feature</p>
<p class="mt-1 text-teal-800/80">Upgrade to Care Pro or Enterprise to connect Paystack, Flutterwave, or Hubtel for encounter bill payments with 0% Ladill fee.</p>
<a href="{{ route('care.pro.index') }}" class="mt-3 inline-flex text-sm font-semibold text-teal-700 hover:text-teal-900">View plans </a>
</div>
@endif
</x-settings.card>
</div>
<p class="mt-6 text-sm text-slate-500">
+1
View File
@@ -101,6 +101,7 @@
<li>Full clinical workflows</li>
<li>Laboratory & pharmacy</li>
<li>Encounter billing</li>
<li>Your own Paystack / Flutterwave / Hubtel</li>
<li>Ladill Queue integration</li>
<li>Unlimited branches (billed per branch)</li>
</ul>
+4
View File
@@ -115,11 +115,13 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/bills', [BillController::class, 'index'])->name('care.bills.index');
Route::post('/visits/{visit}/bill', [BillController::class, 'generate'])->name('care.bills.generate');
Route::get('/bills/payments/callback', [BillController::class, 'paymentCallback'])->name('care.bills.payments.callback');
Route::get('/bills/{bill}', [BillController::class, 'show'])->name('care.bills.show');
Route::get('/bills/{bill}/print', [BillController::class, 'print'])->name('care.bills.print');
Route::post('/bills/{bill}/line-items', [BillController::class, 'addLineItem'])->name('care.bills.line-items.store');
Route::post('/bills/{bill}/adjustments', [BillController::class, 'applyAdjustments'])->name('care.bills.adjustments');
Route::post('/bills/{bill}/payments', [BillController::class, 'recordPayment'])->name('care.bills.payments.store');
Route::post('/bills/{bill}/payments/gateway', [BillController::class, 'startGatewayPayment'])->name('care.bills.payments.gateway');
Route::post('/bills/{bill}/void', [BillController::class, 'void'])->name('care.bills.void');
Route::get('/pharmacy/drugs', [DrugController::class, 'index'])->name('care.pharmacy.drugs.index');
@@ -148,6 +150,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::post('/integrations/bird', [IntegrationsController::class, 'saveBird'])->name('care.integrations.bird.save');
Route::post('/integrations/bird/disconnect', [IntegrationsController::class, 'disconnectBird'])->name('care.integrations.bird.disconnect');
Route::post('/integrations/sms/senders', [IntegrationsController::class, 'previewSenders'])->name('care.integrations.sms.senders');
Route::post('/integrations/gateway', [IntegrationsController::class, 'saveGateway'])->name('care.integrations.gateway.save');
Route::post('/integrations/gateway/disconnect', [IntegrationsController::class, 'disconnectGateway'])->name('care.integrations.gateway.disconnect');
Route::get('/pro', [ProController::class, 'index'])->name('care.pro.index');
Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('care.pro.subscribe');
+271
View File
@@ -0,0 +1,271 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Bill;
use App\Models\Branch;
use App\Models\Consultation;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Payment;
use App\Models\PaymentGatewaySetting;
use App\Models\User;
use App\Models\Visit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class CarePaymentGatewayTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Visit $visit;
protected Bill $bill;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'test-user-gateway',
'name' => 'Gateway Admin',
'email' => 'gateway@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Gateway Clinic',
'slug' => 'gateway-clinic',
'timezone' => 'UTC',
'settings' => ['onboarded' => true, 'plan' => 'pro', 'plan_expires_at' => now()->addMonth()->toIso8601String()],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'hospital_admin',
]);
$branch = Branch::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
$patient = Patient::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $branch->id,
'patient_number' => 'LC-2026-00099',
'first_name' => 'Ama',
'last_name' => 'Mensah',
'email' => 'ama@example.com',
]);
$this->visit = Visit::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'status' => Visit::STATUS_IN_PROGRESS,
'checked_in_at' => now(),
]);
Consultation::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $this->user->public_id,
'visit_id' => $this->visit->id,
'patient_id' => $patient->id,
'status' => Consultation::STATUS_COMPLETED,
'started_at' => now()->subHour(),
'completed_at' => now(),
]);
$this->actingAs($this->user)
->post(route('care.bills.generate', $this->visit));
$this->bill = Bill::firstOrFail();
}
public function test_admin_can_save_paystack_gateway(): void
{
$this->actingAs($this->user)
->post(route('care.integrations.gateway.save'), [
'gateway_provider' => 'paystack',
'gateway_public_key' => 'pk_test_care',
'gateway_secret_key' => 'sk_test_care',
'gateway_is_active' => '1',
])
->assertRedirect();
$this->assertDatabaseHas('care_payment_gateway_settings', [
'organization_id' => $this->organization->id,
'provider' => 'paystack',
'is_active' => 1,
]);
$setting = PaymentGatewaySetting::query()->where('organization_id', $this->organization->id)->first();
$this->assertNotNull($setting);
$this->assertTrue($setting->isConfigured());
$this->assertSame('sk_test_care', $setting->secret_key);
}
public function test_free_plan_cannot_save_gateway(): void
{
$this->organization->update([
'settings' => ['onboarded' => true, 'plan' => 'free'],
]);
$this->actingAs($this->user)
->post(route('care.integrations.gateway.save'), [
'gateway_provider' => 'paystack',
'gateway_secret_key' => 'sk_test',
'gateway_is_active' => '1',
])
->assertRedirect()
->assertSessionHas('error');
$this->assertDatabaseMissing('care_payment_gateway_settings', [
'organization_id' => $this->organization->id,
]);
}
public function test_gateway_checkout_and_callback_records_payment(): void
{
PaymentGatewaySetting::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
'public_key' => 'pk_test',
'secret_key' => 'sk_test',
'is_active' => true,
]);
Http::fake([
'https://api.paystack.co/transaction/initialize' => Http::response([
'status' => true,
'data' => [
'authorization_url' => 'https://checkout.paystack.com/care-test',
'reference' => 'CARE-TEST-REF-001',
],
], 200),
'https://api.paystack.co/transaction/verify/*' => Http::response([
'status' => true,
'data' => [
'status' => 'success',
'amount' => $this->bill->balance_minor,
'reference' => 'CARE-TEST-REF-001',
],
], 200),
]);
$balance = $this->bill->balance_minor;
$this->actingAs($this->user)
->post(route('care.bills.payments.gateway', $this->bill), [
'amount_minor' => $balance,
])
->assertRedirect('https://checkout.paystack.com/care-test');
$pending = Payment::query()->where('bill_id', $this->bill->id)->first();
$this->assertNotNull($pending);
$this->assertSame(Payment::STATUS_PENDING, $pending->status);
$this->assertSame(Payment::METHOD_GATEWAY, $pending->method);
$this->assertSame('CARE-TEST-REF-001', $pending->reference);
// Pending must not reduce balance.
$this->bill->refresh();
$this->assertSame($balance, $this->bill->balance_minor);
$this->actingAs($this->user)
->get(route('care.bills.payments.callback', ['reference' => 'CARE-TEST-REF-001']))
->assertOk();
$pending->refresh();
$this->assertSame(Payment::STATUS_PAID, $pending->status);
$this->bill->refresh();
$this->assertSame(Bill::STATUS_PAID, $this->bill->status);
$this->assertSame(0, $this->bill->balance_minor);
Http::assertSent(fn ($request) => str_contains($request->url(), 'paystack.co/transaction/initialize')
&& $request->hasHeader('Authorization', 'Bearer sk_test'));
}
public function test_flutterwave_initialize_uses_major_units(): void
{
PaymentGatewaySetting::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
'public_key' => 'FLWPUBK_TEST',
'secret_key' => 'FLWSECK_TEST',
'is_active' => true,
]);
Http::fake([
'https://api.flutterwave.com/v3/payments' => Http::response([
'status' => 'success',
'data' => ['link' => 'https://checkout.flutterwave.com/v3/hosted/pay/test'],
], 200),
]);
$this->actingAs($this->user)
->post(route('care.bills.payments.gateway', $this->bill), [
'amount_minor' => 5000,
])
->assertRedirect('https://checkout.flutterwave.com/v3/hosted/pay/test');
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'flutterwave.com')) {
return false;
}
$data = $request->data();
return (float) ($data['amount'] ?? 0) === 50.0
&& $request->hasHeader('Authorization', 'Bearer FLWSECK_TEST');
});
}
public function test_hubtel_initialize_uses_basic_auth(): void
{
PaymentGatewaySetting::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
'public_key' => '201XXXX',
'secret_key' => 'hubtel-secret',
'is_active' => true,
]);
Http::fake([
'https://payproxyapi.hubtel.com/items/initiate' => Http::response([
'data' => ['checkoutUrl' => 'https://pay.hubtel.com/checkout/test'],
], 200),
]);
$this->actingAs($this->user)
->post(route('care.bills.payments.gateway', $this->bill), [
'amount_minor' => 2500,
])
->assertRedirect('https://pay.hubtel.com/checkout/test');
Http::assertSent(function ($request) {
return str_contains($request->url(), 'hubtel.com')
&& ($request->data()['merchantAccountNumber'] ?? null) === '201XXXX'
&& (float) ($request->data()['totalAmount'] ?? 0) === 25.0;
});
}
}