Gate payment gateway behind Pro and Business plans.
Deploy Ladill POS / deploy (push) Successful in 1m9s
Deploy Ladill POS / deploy (push) Successful in 1m9s
Free accounts keep cash sales only; connecting a gateway and card/MoMo checkout require a paid plan, with settings upsell copy for free users.
This commit is contained in:
@@ -53,6 +53,7 @@ class SettingsController extends Controller
|
|||||||
'tables' => $this->scopeToLocation($request, PosTable::owned($owner))
|
'tables' => $this->scopeToLocation($request, PosTable::owned($owner))
|
||||||
->orderBy('area')->orderBy('position')->orderBy('label')->get(),
|
->orderBy('area')->orderBy('position')->orderBy('label')->get(),
|
||||||
'gateway' => $this->gateway->settingFor($account),
|
'gateway' => $this->gateway->settingFor($account),
|
||||||
|
'canUsePaymentGateway' => $this->subscriptions->canUsePaymentGateway($account),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +145,7 @@ class SettingsController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Upsert merchant gateway credentials for the acting account.
|
* Upsert merchant gateway credentials for the acting account.
|
||||||
* Blank secret fields keep the previously stored encrypted values.
|
* Blank secret fields keep the previously stored encrypted values.
|
||||||
|
* Free plan cannot connect or change gateway credentials.
|
||||||
*/
|
*/
|
||||||
protected function persistGateway(Request $request, User $user): void
|
protected function persistGateway(Request $request, User $user): void
|
||||||
{
|
{
|
||||||
@@ -160,6 +162,11 @@ class SettingsController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->subscriptions->canUsePaymentGateway($user)) {
|
||||||
|
// Ignore gateway fields on free plan (other settings may still save).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$attributes = [
|
$attributes = [
|
||||||
'provider' => $provider !== '' ? $provider : $existing->provider,
|
'provider' => $provider !== '' ? $provider : $existing->provider,
|
||||||
'is_active' => $request->boolean('gateway_is_active'),
|
'is_active' => $request->boolean('gateway_is_active'),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Services\Payments;
|
|||||||
|
|
||||||
use App\Models\PaymentGatewaySetting;
|
use App\Models\PaymentGatewaySetting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Pos\SubscriptionService;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@ class MerchantGatewayService
|
|||||||
|
|
||||||
public function isConfigured(User|string $owner): bool
|
public function isConfigured(User|string $owner): bool
|
||||||
{
|
{
|
||||||
|
if (! $this->ownerMayUseGateway($owner)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return (bool) $this->settingFor($owner)?->isConfigured();
|
return (bool) $this->settingFor($owner)?->isConfigured();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +67,10 @@ class MerchantGatewayService
|
|||||||
|
|
||||||
protected function requireConfigured(User|string $owner): PaymentGatewaySetting
|
protected function requireConfigured(User|string $owner): PaymentGatewaySetting
|
||||||
{
|
{
|
||||||
|
if (! $this->ownerMayUseGateway($owner)) {
|
||||||
|
throw new RuntimeException('Connecting your own payment gateway requires a paid plan. Upgrade to Pro or Business.');
|
||||||
|
}
|
||||||
|
|
||||||
$setting = $this->settingFor($owner);
|
$setting = $this->settingFor($owner);
|
||||||
if (! $setting?->isConfigured()) {
|
if (! $setting?->isConfigured()) {
|
||||||
throw new RuntimeException('Connect Paystack, Flutterwave, or Hubtel in Settings before accepting online payments.');
|
throw new RuntimeException('Connect Paystack, Flutterwave, or Hubtel in Settings before accepting online payments.');
|
||||||
@@ -70,6 +79,19 @@ class MerchantGatewayService
|
|||||||
return $setting;
|
return $setting;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function ownerMayUseGateway(User|string $owner): bool
|
||||||
|
{
|
||||||
|
$user = $owner instanceof User
|
||||||
|
? $owner
|
||||||
|
: User::query()->where('public_id', (string) $owner)->first();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(SubscriptionService::class)->canUsePaymentGateway($user);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $metadata
|
* @param array<string, mixed> $metadata
|
||||||
* @return array{checkout_url: string, reference: string, provider: string}
|
* @return array{checkout_url: string, reference: string, provider: string}
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ class SubscriptionService
|
|||||||
return $this->hasPaidPlan($user);
|
return $this->hasPaidPlan($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Own payment gateway (Paystack / Flutterwave / Hubtel) requires Pro or Business. */
|
||||||
|
public function canUsePaymentGateway(User $user): bool
|
||||||
|
{
|
||||||
|
return $this->hasPaidPlan($user);
|
||||||
|
}
|
||||||
|
|
||||||
public function productCount(string $ownerRef, bool $restaurant): int
|
public function productCount(string $ownerRef, bool $restaurant): int
|
||||||
{
|
{
|
||||||
if ($restaurant) {
|
if ($restaurant) {
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ return [
|
|||||||
|
|
||||||
'upgrade_banner' => [
|
'upgrade_banner' => [
|
||||||
'title' => 'Unlock POS Pro or Business',
|
'title' => 'Unlock POS Pro or Business',
|
||||||
'description' => 'Unlimited registers & catalog, stock tracking & ecosystem sync — from GHS 790/mo.',
|
'description' => 'Your own payment gateway, unlimited catalog, multi-branch & team — from GHS 790/mo.',
|
||||||
'route' => 'pos.pro.index',
|
'route' => 'pos.pro.index',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -123,43 +123,51 @@
|
|||||||
|
|
||||||
<div class="border-t border-slate-100 pt-4">
|
<div class="border-t border-slate-100 pt-4">
|
||||||
<p class="text-sm font-semibold text-slate-900">Payment gateway</p>
|
<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>
|
<p class="mt-1 text-xs text-slate-400">Connect Paystack, Flutterwave, or Hubtel. Online checkouts go 100% to you — 0% Ladill fee. Pro & Business plans only.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
@if ($canUsePaymentGateway ?? false)
|
||||||
<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>
|
<div>
|
||||||
<label class="text-sm font-medium text-slate-700">Public key / Merchant account</label>
|
<label class="text-sm font-medium text-slate-700">Provider</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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-slate-700">Secret / API key</label>
|
<label class="text-sm font-medium text-slate-700">Webhook secret (optional)</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">
|
<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>
|
</div>
|
||||||
</div>
|
<label class="flex items-start gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
<div>
|
<input type="hidden" name="gateway_is_active" value="0">
|
||||||
<label class="text-sm font-medium text-slate-700">Webhook secret (optional)</label>
|
<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">
|
||||||
<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">
|
<span>
|
||||||
</div>
|
<span class="block text-sm font-medium text-slate-800">Gateway enabled</span>
|
||||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
<span class="mt-0.5 block text-xs text-slate-500">Required for card / MoMo checkouts on the register.</span>
|
||||||
<input type="hidden" name="gateway_is_active" value="0">
|
</span>
|
||||||
<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">
|
</label>
|
||||||
<span>
|
@if ($gateway?->isConfigured())
|
||||||
<span class="block text-sm font-medium text-slate-800">Gateway enabled</span>
|
<p class="rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-800">Gateway connected ({{ ucfirst($gateway->provider) }}).</p>
|
||||||
<span class="mt-0.5 block text-xs text-slate-500">Required for card / MoMo checkouts on the register.</span>
|
@else
|
||||||
</span>
|
<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>
|
||||||
</label>
|
@endif
|
||||||
@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
|
@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>
|
<div class="rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-900">
|
||||||
|
<p class="font-medium">Your own payment gateway is a Pro feature</p>
|
||||||
|
<p class="mt-1 text-indigo-800/80">Upgrade to Pro or Business to connect Paystack, Flutterwave, or Hubtel and take card / MoMo payments with 0% Ladill fee. Cash sales stay free.</p>
|
||||||
|
<a href="{{ route('pos.pro.index') }}" class="mt-3 inline-flex text-sm font-semibold text-indigo-700 hover:text-indigo-900">View plans →</a>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
use App\Models\Pos\ProSubscription;
|
use App\Models\Pos\ProSubscription;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Pos\SubscriptionService;
|
use App\Services\Pos\SubscriptionService;
|
||||||
@@ -16,6 +17,7 @@ class PosProTest extends TestCase
|
|||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
config([
|
config([
|
||||||
'billing.api_url' => 'https://ladill.com/api/billing',
|
'billing.api_url' => 'https://ladill.com/api/billing',
|
||||||
'billing.api_key' => 'pos-billing-key',
|
'billing.api_key' => 'pos-billing-key',
|
||||||
@@ -49,6 +51,62 @@ class PosProTest extends TestCase
|
|||||||
$this->assertFalse(app(SubscriptionService::class)->canUseRestaurantMode($this->user()));
|
$this->assertFalse(app(SubscriptionService::class)->canUseRestaurantMode($this->user()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_free_user_cannot_use_payment_gateway(): void
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$svc = app(SubscriptionService::class);
|
||||||
|
|
||||||
|
$this->assertFalse($svc->canUsePaymentGateway($user));
|
||||||
|
$this->assertFalse(app(\App\Services\Payments\MerchantGatewayService::class)->isConfigured($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pro_user_can_use_payment_gateway_when_configured(): void
|
||||||
|
{
|
||||||
|
Http::fake(['*/debit' => Http::response(['id' => 1], 201)]);
|
||||||
|
$user = $this->user();
|
||||||
|
$svc = app(SubscriptionService::class);
|
||||||
|
$svc->subscribe($user);
|
||||||
|
|
||||||
|
\App\Models\PaymentGatewaySetting::create([
|
||||||
|
'owner_ref' => $user->public_id,
|
||||||
|
'provider' => \App\Models\PaymentGatewaySetting::PROVIDER_PAYSTACK,
|
||||||
|
'public_key' => 'pk_test',
|
||||||
|
'secret_key' => 'sk_test',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($svc->canUsePaymentGateway($user));
|
||||||
|
$this->assertTrue(app(\App\Services\Payments\MerchantGatewayService::class)->isConfigured($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_free_user_settings_ignores_gateway_credentials(): void
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
\App\Models\PosLocation::create([
|
||||||
|
'owner_ref' => $user->public_id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'currency' => 'GHS',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('pos.settings.update'), [
|
||||||
|
'name' => 'Main',
|
||||||
|
'currency' => 'GHS',
|
||||||
|
'service_style' => 'retail',
|
||||||
|
'printer_paper_mm' => 80,
|
||||||
|
'gateway_provider' => \App\Models\PaymentGatewaySetting::PROVIDER_PAYSTACK,
|
||||||
|
'gateway_public_key' => 'pk_free_blocked',
|
||||||
|
'gateway_secret_key' => 'sk_free_blocked',
|
||||||
|
'gateway_is_active' => '1',
|
||||||
|
])
|
||||||
|
->assertRedirect()
|
||||||
|
->assertSessionHas('success');
|
||||||
|
|
||||||
|
$this->assertNull(
|
||||||
|
\App\Models\PaymentGatewaySetting::query()->where('owner_ref', $user->public_id)->first()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_subscribe_enterprise_charges_wallet_and_activates(): void
|
public function test_subscribe_enterprise_charges_wallet_and_activates(): void
|
||||||
{
|
{
|
||||||
Http::fake(['*/debit' => Http::response(['id' => 1], 201)]);
|
Http::fake(['*/debit' => Http::response(['id' => 1], 201)]);
|
||||||
|
|||||||
Reference in New Issue
Block a user