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:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user