Add Paystack, Flutterwave, and Hubtel for encounter billing.
Deploy Ladill Care / deploy (push) Successful in 1m0s
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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', []);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user