Replace Ladill Pay POS checkouts with merchant gateways.
Deploy Ladill POS / deploy (push) Successful in 30s
Deploy Ladill POS / deploy (push) Successful in 30s
Register Card/MoMo payments through merchant Paystack, Flutterwave, or Hubtel settings so takings settle 100% to the business. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentGatewaySetting extends Model
|
||||
{
|
||||
public const PROVIDER_PAYSTACK = 'paystack';
|
||||
|
||||
public const PROVIDER_FLUTTERWAVE = 'flutterwave';
|
||||
|
||||
public const PROVIDER_HUBTEL = 'hubtel';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref',
|
||||
'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 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);
|
||||
}
|
||||
}
|
||||
@@ -91,17 +91,17 @@ class AfiaService
|
||||
|
||||
return <<<PROMPT
|
||||
You are Afia, the assistant inside Ladill POS (pos.ladill.com).
|
||||
Help users run the in-store register — taking sales, charging by cash or Ladill Pay, managing the product catalog, and reading their sales history. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
|
||||
Help users run the in-store register — taking sales, charging by cash or Card / MoMo, managing the product catalog, and reading their sales history. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
|
||||
|
||||
What Ladill POS does and where things live:
|
||||
- Overview (Dashboard): today's takings, number of sales, active products, and recent sales.
|
||||
- Register: the point-of-sale screen — add catalog products or a quick custom amount to the cart, then charge by cash or Ladill Pay (MoMo/card).
|
||||
- Register: the point-of-sale screen — add catalog products or a quick custom amount to the cart, then charge by cash or Card / MoMo (MoMo/card).
|
||||
- Products: the local catalog (name, SKU, price). Products can also be imported from Ladill CRM in Settings.
|
||||
- Sales: history of completed sales with receipts; a paid sale can be turned into a Ladill Invoice.
|
||||
- Settings: store details, default currency, and importing products from CRM or the Merchant storefront.
|
||||
|
||||
Rules:
|
||||
- Only answer questions about Ladill POS — the register, sales, products, checkout (cash/Ladill Pay), receipts, and settings.
|
||||
- Only answer questions about Ladill POS — the register, sales, products, checkout (cash/Card / MoMo), receipts, and settings.
|
||||
- If asked about contacts, invoices, domains, hosting, email mailboxes, or QR codes, briefly say those live in other Ladill apps and suggest the app launcher.
|
||||
- Never invent figures (takings, counts) — use the user context below or tell them where to check.
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\PaymentGatewaySetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class MerchantGatewayService
|
||||
{
|
||||
public function settingFor(User|string $owner): ?PaymentGatewaySetting
|
||||
{
|
||||
$ownerRef = $owner instanceof User ? (string) $owner->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<string, mixed> $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<string, mixed>}
|
||||
*/
|
||||
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<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string, provider: string}
|
||||
*/
|
||||
protected function paystackInitialize(
|
||||
PaymentGatewaySetting $setting,
|
||||
int $amountMinor,
|
||||
string $currency,
|
||||
string $email,
|
||||
string $callbackUrl,
|
||||
string $reference,
|
||||
array $metadata,
|
||||
): array {
|
||||
$response = Http::withToken((string) $setting->secret_key)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->post('https://api.paystack.co/transaction/initialize', [
|
||||
'email' => $email !== '' ? $email : 'payer@example.com',
|
||||
'amount' => $amountMinor,
|
||||
'currency' => $currency,
|
||||
'reference' => $reference,
|
||||
'callback_url' => $callbackUrl,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
if (! $response->successful() || ! ($response->json('status') ?? false)) {
|
||||
throw new RuntimeException($response->json('message') ?: 'Paystack could not start checkout.');
|
||||
}
|
||||
|
||||
$url = (string) $response->json('data.authorization_url');
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('Paystack did not return a checkout URL.');
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $url,
|
||||
'reference' => (string) ($response->json('data.reference') ?: $reference),
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
|
||||
*/
|
||||
protected function paystackVerify(PaymentGatewaySetting $setting, string $reference): array
|
||||
{
|
||||
$response = Http::withToken((string) $setting->secret_key)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->get('https://api.paystack.co/transaction/verify/'.rawurlencode($reference));
|
||||
|
||||
$data = (array) ($response->json('data') ?? []);
|
||||
$paid = ($response->json('status') ?? false)
|
||||
&& strtolower((string) ($data['status'] ?? '')) === 'success';
|
||||
|
||||
return [
|
||||
'paid' => $paid,
|
||||
'amount_minor' => (int) ($data['amount'] ?? 0),
|
||||
'reference' => (string) ($data['reference'] ?? $reference),
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string, provider: string}
|
||||
*/
|
||||
protected function flutterwaveInitialize(
|
||||
PaymentGatewaySetting $setting,
|
||||
int $amountMinor,
|
||||
string $currency,
|
||||
string $email,
|
||||
string $callbackUrl,
|
||||
string $reference,
|
||||
array $metadata,
|
||||
): array {
|
||||
$response = Http::withToken((string) $setting->secret_key)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->post('https://api.flutterwave.com/v3/payments', [
|
||||
'tx_ref' => $reference,
|
||||
'amount' => round($amountMinor / 100, 2),
|
||||
'currency' => $currency,
|
||||
'redirect_url' => $callbackUrl,
|
||||
'customer' => [
|
||||
'email' => $email !== '' ? $email : 'payer@example.com',
|
||||
],
|
||||
'customizations' => [
|
||||
'title' => (string) ($metadata['title'] ?? 'Payment'),
|
||||
],
|
||||
'meta' => $metadata,
|
||||
]);
|
||||
|
||||
if (! $response->successful() || ($response->json('status') ?? '') !== 'success') {
|
||||
throw new RuntimeException($response->json('message') ?: 'Flutterwave could not start checkout.');
|
||||
}
|
||||
|
||||
$url = (string) $response->json('data.link');
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('Flutterwave did not return a checkout URL.');
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $url,
|
||||
'reference' => $reference,
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
|
||||
*/
|
||||
protected function flutterwaveVerify(PaymentGatewaySetting $setting, string $reference): array
|
||||
{
|
||||
$response = Http::withToken((string) $setting->secret_key)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->get('https://api.flutterwave.com/v3/transactions/verify_by_reference', [
|
||||
'tx_ref' => $reference,
|
||||
]);
|
||||
|
||||
$data = (array) ($response->json('data') ?? []);
|
||||
$paid = ($response->json('status') ?? '') === 'success'
|
||||
&& strtolower((string) ($data['status'] ?? '')) === 'successful';
|
||||
$amountMajor = (float) ($data['amount'] ?? 0);
|
||||
|
||||
return [
|
||||
'paid' => $paid,
|
||||
'amount_minor' => (int) round($amountMajor * 100),
|
||||
'reference' => (string) ($data['tx_ref'] ?? $reference),
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string, provider: string}
|
||||
*/
|
||||
protected function hubtelInitialize(
|
||||
PaymentGatewaySetting $setting,
|
||||
int $amountMinor,
|
||||
string $currency,
|
||||
string $email,
|
||||
string $callbackUrl,
|
||||
string $reference,
|
||||
array $metadata,
|
||||
): array {
|
||||
// Hubtel: public_key = merchant account number, secret_key = API key (client secret),
|
||||
// webhook_secret optionally stores client id for Basic auth as "clientId:clientSecret".
|
||||
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
|
||||
if (! str_contains($auth, ':')) {
|
||||
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
|
||||
}
|
||||
|
||||
$response = Http::withBasicAuth(...explode(':', $auth, 2))
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->post('https://payproxyapi.hubtel.com/items/initiate', [
|
||||
'totalAmount' => round($amountMinor / 100, 2),
|
||||
'description' => (string) ($metadata['title'] ?? 'Payment'),
|
||||
'callbackUrl' => $callbackUrl,
|
||||
'returnUrl' => $callbackUrl,
|
||||
'merchantAccountNumber' => (string) $setting->public_key,
|
||||
'cancellationUrl' => $callbackUrl,
|
||||
'clientReference' => $reference,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException($response->json('message') ?: 'Hubtel could not start checkout.');
|
||||
}
|
||||
|
||||
$url = (string) ($response->json('data.checkoutUrl') ?? $response->json('data.checkoutDirectUrl') ?? '');
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('Hubtel did not return a checkout URL.');
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $url,
|
||||
'reference' => $reference,
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
|
||||
*/
|
||||
protected function hubtelVerify(PaymentGatewaySetting $setting, string $reference): array
|
||||
{
|
||||
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
|
||||
if (! str_contains($auth, ':')) {
|
||||
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
|
||||
}
|
||||
|
||||
$response = Http::withBasicAuth(...explode(':', $auth, 2))
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->get('https://api-txnstatus.hubtel.com/transactions/'.$setting->public_key.'/status', [
|
||||
'clientReference' => $reference,
|
||||
]);
|
||||
|
||||
$data = (array) ($response->json('data') ?? $response->json() ?? []);
|
||||
$status = strtolower((string) ($data['status'] ?? $data['transactionStatus'] ?? ''));
|
||||
$paid = in_array($status, ['success', 'successful', 'paid', 'completed'], true);
|
||||
$amountMajor = (float) ($data['amount'] ?? $data['totalAmount'] ?? 0);
|
||||
|
||||
return [
|
||||
'paid' => $paid,
|
||||
'amount_minor' => (int) round($amountMajor * 100),
|
||||
'reference' => $reference,
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -10,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();
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('payment_gateway_settings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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?',
|
||||
];
|
||||
|
||||
@@ -79,6 +79,47 @@
|
||||
<span class="mt-0.5 block text-xs text-slate-500">Opens the receipt print dialog when you record a cash payment.</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<p class="text-sm font-semibold text-slate-900">Payment gateway</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Connect Paystack, Flutterwave, or Hubtel. Online checkouts go 100% to you — 0% Ladill fee.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Provider</label>
|
||||
<select name="gateway_provider" class="mt-1.5 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="">Select a provider</option>
|
||||
<option value="paystack" @selected(old('gateway_provider', $gateway?->provider ?? null) === 'paystack')>Paystack</option>
|
||||
<option value="flutterwave" @selected(old('gateway_provider', $gateway?->provider ?? null) === 'flutterwave')>Flutterwave</option>
|
||||
<option value="hubtel" @selected(old('gateway_provider', $gateway?->provider ?? null) === 'hubtel')>Hubtel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Public key / Merchant account</label>
|
||||
<input type="text" name="gateway_public_key" value="{{ old('gateway_public_key') }}" placeholder="{{ ($gateway?->public_key ?? null) ? '•••• saved — leave blank to keep' : 'pk_live_… or Hubtel account number' }}" class="mt-1.5 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Secret / API key</label>
|
||||
<input type="password" name="gateway_secret_key" value="" placeholder="{{ ($gateway?->secret_key ?? null) ? '•••• saved — leave blank to keep' : 'sk_live_…' }}" class="mt-1.5 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Webhook secret (optional)</label>
|
||||
<input type="password" name="gateway_webhook_secret" value="" placeholder="{{ ($gateway?->webhook_secret ?? null) ? '•••• saved — leave blank to keep' : 'Optional' }}" class="mt-1.5 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" autocomplete="new-password">
|
||||
</div>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<input type="hidden" name="gateway_is_active" value="0">
|
||||
<input type="checkbox" name="gateway_is_active" value="1" @checked(old('gateway_is_active', $gateway?->is_active ?? true)) class="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Gateway enabled</span>
|
||||
<span class="mt-0.5 block text-xs text-slate-500">Required for card / MoMo checkouts on the register.</span>
|
||||
</span>
|
||||
</label>
|
||||
@if ($gateway?->isConfigured())
|
||||
<p class="rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-800">Gateway connected ({{ ucfirst($gateway->provider) }}).</p>
|
||||
@else
|
||||
<p class="rounded-xl bg-amber-50 px-3 py-2 text-sm text-amber-800">Online checkouts stay disabled until a gateway is connected.</p>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn-primary">Save settings</button>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<button name="payment_method" value="cash" :disabled="lines.length === 0"
|
||||
class="rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50 disabled:opacity-40">Cash</button>
|
||||
<button name="payment_method" value="pay" :disabled="lines.length === 0"
|
||||
class="btn-primary disabled:opacity-40">Ladill Pay</button>
|
||||
class="btn-primary disabled:opacity-40">Card / MoMo</button>
|
||||
</div>
|
||||
<p class="text-center text-[11px] text-slate-400">Pay the full balance, or a partial amount to split the bill.</p>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user