From 3ea9fdfe33257b1ac7920261768d8418db73d14b Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 15 Jul 2026 01:26:22 +0000 Subject: [PATCH] Replace Ladill Pay POS checkouts with merchant gateways. Register Card/MoMo payments through merchant Paystack, Flutterwave, or Hubtel settings so takings settle 100% to the business. Co-authored-by: Cursor --- .../Controllers/Pos/SettingsController.php | 17 ++ app/Models/PaymentGatewaySetting.php | 50 +++ app/Services/Afia/AfiaService.php | 6 +- .../Payments/MerchantGatewayService.php | 288 ++++++++++++++++++ app/Services/Pos/PosSaleService.php | 102 +++---- config/ladill_launcher.php | 3 - ..._create_payment_gateway_settings_table.php | 28 ++ resources/views/partials/afia.blade.php | 4 +- resources/views/pos/settings.blade.php | 41 +++ resources/views/pos/tickets/show.blade.php | 2 +- 10 files changed, 473 insertions(+), 68 deletions(-) create mode 100644 app/Models/PaymentGatewaySetting.php create mode 100644 app/Services/Payments/MerchantGatewayService.php create mode 100644 database/migrations/2026_07_15_010000_create_payment_gateway_settings_table.php diff --git a/app/Http/Controllers/Pos/SettingsController.php b/app/Http/Controllers/Pos/SettingsController.php index e604b50..abdbf3b 100644 --- a/app/Http/Controllers/Pos/SettingsController.php +++ b/app/Http/Controllers/Pos/SettingsController.php @@ -6,13 +6,16 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\Pos\Concerns\ScopesToAccount; use App\Models\PosLocation; use App\Models\PosMember; +use App\Models\PaymentGatewaySetting; use App\Models\PosTable; use App\Services\Import\CrmProductImportService; use App\Services\Import\MerchantCatalogImportService; +use App\Services\Payments\MerchantGatewayService; use App\Services\Pos\PosLocationService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; +use Illuminate\Validation\Rule; use Illuminate\View\View; use RuntimeException; @@ -23,6 +26,7 @@ class SettingsController extends Controller public function __construct( private PosLocationService $locations, private \App\Services\Pos\SubscriptionService $subscriptions, + private MerchantGatewayService $gateway, ) {} public function index(Request $request): View @@ -47,6 +51,7 @@ class SettingsController extends Controller 'merchantImportEnabled' => (bool) config('pos.merchant_import_enabled', true), 'tables' => $this->scopeToLocation($request, PosTable::owned($owner)) ->orderBy('area')->orderBy('position')->orderBy('label')->get(), + 'gateway' => $this->gateway->settingFor($account), ]); } @@ -62,6 +67,16 @@ class SettingsController extends Controller 'remove_receipt_logo' => ['sometimes', 'boolean'], 'printer_paper_mm' => ['required', 'in:58,80'], 'printer_auto_print' => ['sometimes', 'boolean'], + 'gateway_provider' => ['nullable', Rule::in([ + PaymentGatewaySetting::PROVIDER_PAYSTACK, + PaymentGatewaySetting::PROVIDER_FLUTTERWAVE, + PaymentGatewaySetting::PROVIDER_HUBTEL, + '', + ])], + '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'], ]); $user = ladill_account() ?? $request->user(); @@ -98,6 +113,8 @@ class SettingsController extends Controller 'receipt_logo_path' => $location->receipt_logo_path, ]); + $this->persistGateway($request, $user); + return back()->with('success', 'Settings saved.'); } diff --git a/app/Models/PaymentGatewaySetting.php b/app/Models/PaymentGatewaySetting.php new file mode 100644 index 0000000..7c34791 --- /dev/null +++ b/app/Models/PaymentGatewaySetting.php @@ -0,0 +1,50 @@ + 'encrypted', + 'secret_key' => 'encrypted', + 'webhook_secret' => 'encrypted', + 'is_active' => 'boolean', + 'metadata' => 'array', + ]; + } + + 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); + } +} diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php index 82d6aea..37a0187 100644 --- a/app/Services/Afia/AfiaService.php +++ b/app/Services/Afia/AfiaService.php @@ -91,17 +91,17 @@ class AfiaService return <<public_id : $owner; + + return PaymentGatewaySetting::query()->where('owner_ref', $ownerRef)->first(); + } + + public function isConfigured(User|string $owner): bool + { + return (bool) $this->settingFor($owner)?->isConfigured(); + } + + /** + * @param array $metadata + * @return array{checkout_url: string, reference: string, provider: string} + */ + public function initializeCheckout( + User|string $owner, + int $amountMinor, + string $currency, + string $email, + string $callbackUrl, + string $reference, + array $metadata = [], + ): array { + $setting = $this->requireConfigured($owner); + $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(User|string $owner, string $reference): array + { + $setting = $this->requireConfigured($owner); + + 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(User|string $owner): PaymentGatewaySetting + { + $setting = $this->settingFor($owner); + if (! $setting?->isConfigured()) { + throw new RuntimeException('Connect Paystack, Flutterwave, or Hubtel in Settings before accepting online payments.'); + } + + return $setting; + } + + /** + * @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/app/Services/Pos/PosSaleService.php b/app/Services/Pos/PosSaleService.php index 3f42d9a..80d1a4a 100644 --- a/app/Services/Pos/PosSaleService.php +++ b/app/Services/Pos/PosSaleService.php @@ -10,14 +10,14 @@ use App\Models\PosSaleLine; use App\Models\PosSaleLineModifier; use App\Models\PosTable; use App\Models\User; -use App\Services\Pay\PayClient; +use App\Services\Payments\MerchantGatewayService; use Illuminate\Support\Str; use RuntimeException; class PosSaleService { public function __construct( - private PayClient $pay, + private MerchantGatewayService $gateway, private PosTimelineService $timeline, ) {} @@ -281,7 +281,7 @@ class PosSaleService } /** - * Start a Ladill Pay checkout for part (or all) of a ticket's balance. + * Start a Card / MoMo checkout for part (or all) of a ticket's balance. * * @return array{payment: PosPayment, checkout_url: string} */ @@ -300,48 +300,39 @@ class PosSaleService 'status' => PosPayment::STATUS_PENDING, ]); - $payOrder = $this->pay->createCheckout([ - 'merchant' => $merchant->public_id, - 'fee_tier' => 'sales', - 'source_service' => 'pos', - 'source_ref' => 'payment:'.$payment->id, - 'callback_url' => route('pos.payments.callback'), - 'customer_name' => $sale->customer_name, - 'customer_email' => $sale->customer_email, - 'customer_phone' => $sale->customer_phone, - 'line_items' => [[ - 'name' => 'Ticket '.$sale->reference.' payment', - 'unit_price_minor' => $amount, - 'quantity' => 1, - ]], - 'metadata' => ['pos_sale_id' => $sale->id, 'pos_payment_id' => $payment->id], - ]); - - $checkoutUrl = (string) ($payOrder['checkout_url'] ?? ''); - if ($checkoutUrl === '') { - throw new RuntimeException('Could not start checkout. Please try again.'); - } + $reference = 'POSP-'.strtoupper(Str::random(16)); + $checkout = $this->gateway->initializeCheckout( + $merchant, + $amount, + (string) ($sale->currency ?? 'GHS'), + (string) ($sale->customer_email ?: $merchant->email ?: 'payer@example.com'), + route('pos.payments.callback'), + $reference, + ['title' => 'Ticket '.$sale->reference.' payment', 'pos_sale_id' => $sale->id, 'pos_payment_id' => $payment->id], + ); $payment->forceFill([ - 'pay_order_id' => $payOrder['id'] ?? null, - 'payment_reference' => $payOrder['reference'] ?? null, + 'payment_reference' => $checkout['reference'], ])->save(); - return ['payment' => $payment, 'checkout_url' => $checkoutUrl]; + return ['payment' => $payment, 'checkout_url' => $checkout['checkout_url']]; } public function completePayPayment(string $reference): PosPayment { $payment = PosPayment::where('payment_reference', $reference) ->where('status', PosPayment::STATUS_PENDING) + ->with('sale') ->firstOrFail(); - $payOrder = $this->pay->verify($reference); + $result = $this->gateway->verify($payment->owner_ref, $reference); + if (! $result['paid']) { + throw new RuntimeException('Payment was not completed.'); + } $payment->forceFill([ 'status' => PosPayment::STATUS_PAID, - 'pay_order_id' => $payOrder['id'] ?? $payment->pay_order_id, - 'amount_minor' => (int) ($payOrder['amount_minor'] ?? $payment->amount_minor), + 'amount_minor' => (int) ($result['amount_minor'] ?: $payment->amount_minor), 'paid_at' => now(), ])->save(); @@ -505,36 +496,27 @@ class PosSaleService $sale->load('lines'); $callbackUrl = route('pos.sales.callback', $sale); - - $payOrder = $this->pay->createCheckout([ - 'merchant' => $merchant->public_id, - 'fee_tier' => 'sales', - 'source_service' => 'pos', - 'source_ref' => (string) $sale->id, - 'callback_url' => $callbackUrl, - 'customer_name' => $sale->customer_name, - 'customer_email' => $sale->customer_email, - 'customer_phone' => $sale->customer_phone, - 'line_items' => $sale->lines->map(fn (PosSaleLine $line) => [ - 'name' => $line->name, - 'unit_price_minor' => $line->unit_price_minor, - 'quantity' => $line->quantity, - ])->all(), - 'metadata' => [ - 'pos_sale_id' => $sale->id, - 'pos_reference' => $sale->reference, - ], - ]); - - $checkoutUrl = (string) ($payOrder['checkout_url'] ?? ''); - if ($checkoutUrl === '') { - throw new RuntimeException('Could not start checkout. Please try again.'); + $reference = 'POSS-'.strtoupper(Str::random(16)); + $amount = (int) $sale->balanceMinor(); + if ($amount < 1) { + $amount = (int) $sale->total_minor; } + $checkout = $this->gateway->initializeCheckout( + $merchant, + $amount, + (string) ($sale->currency ?? 'GHS'), + (string) ($sale->customer_email ?: $merchant->email ?: 'payer@example.com'), + $callbackUrl, + $reference, + ['title' => 'POS '.$sale->reference, 'pos_sale_id' => $sale->id, 'pos_reference' => $sale->reference], + ); + + $checkoutUrl = $checkout['checkout_url']; + $sale->forceFill([ 'payment_method' => PosSale::METHOD_PAY, - 'pay_order_id' => $payOrder['id'] ?? null, - 'payment_reference' => $payOrder['reference'] ?? null, + 'payment_reference' => $checkout['reference'], ])->save(); return [ @@ -570,12 +552,14 @@ class PosSaleService ->where('status', PosSale::STATUS_PENDING) ->firstOrFail(); - $payOrder = $this->pay->verify($paymentReference); + $result = $this->gateway->verify($sale->owner_ref, $paymentReference); + if (! $result['paid']) { + throw new RuntimeException('Payment was not completed.'); + } $sale->forceFill([ 'status' => PosSale::STATUS_PAID, - 'total_minor' => (int) ($payOrder['amount_minor'] ?? $sale->total_minor), - 'pay_order_id' => $payOrder['id'] ?? $sale->pay_order_id, + 'total_minor' => (int) ($result['amount_minor'] ?: $sale->total_minor), 'paid_at' => now(), ])->save(); diff --git a/config/ladill_launcher.php b/config/ladill_launcher.php index 2a0d785..93ad902 100644 --- a/config/ladill_launcher.php +++ b/config/ladill_launcher.php @@ -18,10 +18,7 @@ $root = config('app.platform_domain', 'ladill.com'); return [ 'apps' => [ - ['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'], ['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'], - ['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'], - ['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'], ['name' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woomanager.svg'], ['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'], ['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'], diff --git a/database/migrations/2026_07_15_010000_create_payment_gateway_settings_table.php b/database/migrations/2026_07_15_010000_create_payment_gateway_settings_table.php new file mode 100644 index 0000000..cc0e382 --- /dev/null +++ b/database/migrations/2026_07_15_010000_create_payment_gateway_settings_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('owner_ref', 64)->unique(); + $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('payment_gateway_settings'); + } +}; diff --git a/resources/views/partials/afia.blade.php b/resources/views/partials/afia.blade.php index f5953aa..fb597fb 100644 --- a/resources/views/partials/afia.blade.php +++ b/resources/views/partials/afia.blade.php @@ -1,8 +1,8 @@ @php - $afiaGreeting = "Hi, I'm Afia 👋 Ask me about taking a sale, charging by cash or Ladill Pay, managing products, or your sales history…"; + $afiaGreeting = "Hi, I'm Afia 👋 Ask me about taking a sale, charging by cash or Card / MoMo, managing products, or your sales history…"; $afiaSuggestions = [ 'How do I take a sale?', - 'How do I charge with Ladill Pay?', + 'How do I charge with Card / MoMo?', 'How do I add a product?', 'How do I import products from CRM?', ]; diff --git a/resources/views/pos/settings.blade.php b/resources/views/pos/settings.blade.php index 65004bc..1fd34b4 100644 --- a/resources/views/pos/settings.blade.php +++ b/resources/views/pos/settings.blade.php @@ -79,6 +79,47 @@ Opens the receipt print dialog when you record a cash payment. +
+

Payment gateway

+

Connect Paystack, Flutterwave, or Hubtel. Online checkouts go 100% to you — 0% Ladill fee.

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + @if ($gateway?->isConfigured()) +

Gateway connected ({{ ucfirst($gateway->provider) }}).

+ @else +

Online checkouts stay disabled until a gateway is connected.

+ @endif +
diff --git a/resources/views/pos/tickets/show.blade.php b/resources/views/pos/tickets/show.blade.php index eecaa6e..de87ffb 100644 --- a/resources/views/pos/tickets/show.blade.php +++ b/resources/views/pos/tickets/show.blade.php @@ -146,7 +146,7 @@ + class="btn-primary disabled:opacity-40">Card / MoMo

Pay the full balance, or a partial amount to split the bill.