diff --git a/README.md b/README.md index 99a2f82..11843ca 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/Http/Controllers/Api/BillController.php b/app/Http/Controllers/Api/BillController.php index 1c5c0bd..2839d6f 100644 --- a/app/Http/Controllers/Api/BillController.php +++ b/app/Http/Controllers/Api/BillController.php @@ -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), diff --git a/app/Http/Controllers/Care/BillController.php b/app/Http/Controllers/Care/BillController.php index 04dc7a5..016d970 100644 --- a/app/Http/Controllers/Care/BillController.php +++ b/app/Http/Controllers/Care/BillController.php @@ -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'); diff --git a/app/Http/Controllers/Care/IntegrationsController.php b/app/Http/Controllers/Care/IntegrationsController.php index 247b862..106ac00 100644 --- a/app/Http/Controllers/Care/IntegrationsController.php +++ b/app/Http/Controllers/Care/IntegrationsController.php @@ -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.'); + } } diff --git a/app/Models/Payment.php b/app/Models/Payment.php index abc73b8..9a1864c 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -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; + } } diff --git a/app/Models/PaymentGatewaySetting.php b/app/Models/PaymentGatewaySetting.php new file mode 100644 index 0000000..f538f43 --- /dev/null +++ b/app/Models/PaymentGatewaySetting.php @@ -0,0 +1,72 @@ + '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 */ + public static function providers(): array + { + return [ + self::PROVIDER_PAYSTACK, + self::PROVIDER_FLUTTERWAVE, + self::PROVIDER_HUBTEL, + ]; + } +} diff --git a/app/Services/Care/BillService.php b/app/Services/Care/BillService.php index f35cda7..7eea235 100644 --- a/app/Services/Care/BillService.php +++ b/app/Services/Care/BillService.php @@ -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)) { diff --git a/app/Services/Care/PlanService.php b/app/Services/Care/PlanService.php index 12e694f..602c211 100644 --- a/app/Services/Care/PlanService.php +++ b/app/Services/Care/PlanService.php @@ -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', []); diff --git a/app/Services/Care/ReportService.php b/app/Services/Care/ReportService.php index 9480d47..a862564 100644 --- a/app/Services/Care/ReportService.php +++ b/app/Services/Care/ReportService.php @@ -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) { diff --git a/app/Services/Payments/MerchantGatewayService.php b/app/Services/Payments/MerchantGatewayService.php new file mode 100644 index 0000000..7bedd2d --- /dev/null +++ b/app/Services/Payments/MerchantGatewayService.php @@ -0,0 +1,306 @@ +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 $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} + */ + 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 $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} + */ + 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 $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} + */ + 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 $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} + */ + 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, + ]; + } +} diff --git a/config/care.php b/config/care.php index 2213270..86995c6 100644 --- a/config/care.php +++ b/config/care.php @@ -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', diff --git a/database/migrations/2026_07_16_100000_create_care_payment_gateway_settings_table.php b/database/migrations/2026_07_16_100000_create_care_payment_gateway_settings_table.php new file mode 100644 index 0000000..022f0a4 --- /dev/null +++ b/database/migrations/2026_07_16_100000_create_care_payment_gateway_settings_table.php @@ -0,0 +1,29 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_16_100100_add_gateway_fields_to_care_payments.php b/database/migrations/2026_07_16_100100_add_gateway_fields_to_care_payments.php new file mode 100644 index 0000000..f7bec46 --- /dev/null +++ b/database/migrations/2026_07_16_100100_add_gateway_fields_to_care_payments.php @@ -0,0 +1,27 @@ +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']); + }); + } +}; diff --git a/resources/views/care/bills/payment-return.blade.php b/resources/views/care/bills/payment-return.blade.php new file mode 100644 index 0000000..7dcca0f --- /dev/null +++ b/resources/views/care/bills/payment-return.blade.php @@ -0,0 +1,27 @@ + + + + + + Confirming payment + + + +

Confirming payment…

+ + + diff --git a/resources/views/care/bills/show.blade.php b/resources/views/care/bills/show.blade.php index 60c1079..b9ba2ff 100644 --- a/resources/views/care/bills/show.blade.php +++ b/resources/views/care/bills/show.blade.php @@ -81,17 +81,52 @@
@csrf - +
+ +
+

Collect online

+

+ Card or mobile money via your connected gateway + @if ($gatewayConfigured && $gatewayProvider) + ({{ $gatewayLabels[$gatewayProvider] ?? ucfirst($gatewayProvider) }}) + @endif + — 0% Ladill fee. +

+ @if ($gatewayConfigured) +
+ @csrf + + +
+ @else +

+ Connect Paystack, Flutterwave, or Hubtel in + Integrations + to collect online payments. +

+ @endif +
@endif

Payments

@forelse ($bill->payments as $payment) -

{{ $money($payment->amount_minor) }} · {{ $paymentMethods[$payment->method] ?? $payment->method }} · {{ $payment->paid_at->format('d M Y') }}

+ @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 +

{{ $money($payment->amount_minor) }} · {{ $methodLabel }}{{ $statusSuffix }} · {{ $when }}@if($payment->reference) · {{ $payment->reference }}@endif

@empty

No payments recorded.

@endforelse diff --git a/resources/views/care/integrations/edit.blade.php b/resources/views/care/integrations/edit.blade.php index 06e02b3..ad3227b 100644 --- a/resources/views/care/integrations/edit.blade.php +++ b/resources/views/care/integrations/edit.blade.php @@ -1,9 +1,10 @@ - +

- Configure SMS + Configure messaging and payment gateways for encounter billing. + Use SMS and email - credentials for outbound messaging. + for outbound messages; connect Paystack, Flutterwave, or Hubtel so bill payments land in your account (0% Ladill fee).

@if (! $canManage) @@ -124,6 +125,88 @@ @endif @endif + + + @if ($canUsePaymentGateway ?? false) + @if ($gateway?->isConfigured()) +
+ Connected · {{ $gatewayLabels[$gateway->provider] ?? ucfirst($gateway->provider) }} + @if (! $gateway->is_active) + · disabled + @endif +
+ @else +
+ Online checkout stays disabled until a gateway is connected. +
+ @endif + + @if ($canManage) +
+ @csrf +
+ + +
+
+
+ + +

Hubtel: merchant account number. Paystack/Flutterwave: public key.

+
+
+ + +
+
+
+ + +
+ +
+ +
+
+ @if ($gateway) +
+ + + + + +
+ @endif + @endif + @else +
+

Your own payment gateway is a Pro feature

+

Upgrade to Care Pro or Enterprise to connect Paystack, Flutterwave, or Hubtel for encounter bill payments with 0% Ladill fee.

+ View plans → +
+ @endif +

diff --git a/resources/views/care/pro/index.blade.php b/resources/views/care/pro/index.blade.php index 45d8267..729e016 100644 --- a/resources/views/care/pro/index.blade.php +++ b/resources/views/care/pro/index.blade.php @@ -101,6 +101,7 @@

  • Full clinical workflows
  • Laboratory & pharmacy
  • Encounter billing
  • +
  • Your own Paystack / Flutterwave / Hubtel
  • Ladill Queue integration
  • Unlimited branches (billed per branch)
  • diff --git a/routes/web.php b/routes/web.php index 4895804..039c55c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/CarePaymentGatewayTest.php b/tests/Feature/CarePaymentGatewayTest.php new file mode 100644 index 0000000..bd51858 --- /dev/null +++ b/tests/Feature/CarePaymentGatewayTest.php @@ -0,0 +1,271 @@ +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; + }); + } +}