From d66e656b9c0d79b976d00504e944ae68591890de Mon Sep 17 00:00:00 2001 From: isaacclad Date: Tue, 30 Jun 2026 01:55:38 +0000 Subject: [PATCH] Add Business tier and Paystack prepaid billing for POS. Co-authored-by: Cursor --- app/Http/Controllers/Pos/ProController.php | 99 +++++++++++- app/Models/Pos/ProSubscription.php | 6 +- app/Providers/AppServiceProvider.php | 7 +- app/Services/Pos/BillingClient.php | 48 ++++++ app/Services/Pos/SubscriptionService.php | 133 +++++++++++++--- config/pos.php | 7 + ...dd_plan_to_pos_pro_subscriptions_table.php | 26 ++++ resources/views/partials/sidebar.blade.php | 4 +- resources/views/pos/pro/index.blade.php | 143 ++++++++++-------- routes/web.php | 3 + tests/Feature/PosProTest.php | 17 +++ 11 files changed, 403 insertions(+), 90 deletions(-) create mode 100644 database/migrations/2026_06_30_120000_add_plan_to_pos_pro_subscriptions_table.php diff --git a/app/Http/Controllers/Pos/ProController.php b/app/Http/Controllers/Pos/ProController.php index d4aef31..32f5511 100644 --- a/app/Http/Controllers/Pos/ProController.php +++ b/app/Http/Controllers/Pos/ProController.php @@ -3,6 +3,9 @@ namespace App\Http\Controllers\Pos; use App\Http\Controllers\Controller; +use App\Models\Pos\ProSubscription; +use App\Models\User; +use App\Services\Pos\BillingClient; use App\Services\Pos\SubscriptionService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -15,12 +18,18 @@ class ProController extends Controller public function index(Request $request): View { $user = ladill_account() ?? $request->user(); + $planKey = $this->subscriptions->planKey($user); return view('pos.pro.index', [ 'subscription' => $this->subscriptions->subscriptionFor($user), - 'isPro' => $this->subscriptions->isPro($user), + 'planKey' => $planKey, + 'isPro' => $planKey === ProSubscription::PLAN_PRO, + 'isEnterprise' => $planKey === ProSubscription::PLAN_ENTERPRISE, + 'hasPaidPlan' => $this->subscriptions->hasPaidPlan($user), 'gatingActive' => $this->subscriptions->gatingActive(), - 'priceMinor' => $this->subscriptions->priceMinor(), + 'proPriceMinor' => $this->subscriptions->priceMinor(), + 'enterprisePriceMinor' => $this->subscriptions->enterprisePriceMinor(), + 'prepaidMonths' => (array) config('pos.prepaid_months', [6, 12, 24]), 'currency' => (string) config('pos.pro.currency', 'GHS'), ]); } @@ -32,6 +41,92 @@ class ProController extends Controller return redirect()->route('pos.pro.index')->with($ok ? 'success' : 'error', $message); } + public function subscribeEnterprise(Request $request): RedirectResponse + { + [$ok, $message] = $this->subscriptions->subscribeEnterprise(ladill_account() ?? $request->user()); + + return redirect()->route('pos.pro.index')->with($ok ? 'success' : 'error', $message); + } + + public function subscribePrepaid(Request $request, BillingClient $billing): RedirectResponse + { + $validated = $request->validate([ + 'plan' => ['required', 'in:pro,enterprise'], + 'months' => ['required', 'integer', 'in:6,12,24'], + ]); + + $user = ladill_account() ?? $request->user(); + if ($this->subscriptions->hasPaidPlan($user)) { + return redirect()->route('pos.pro.index') + ->with('success', 'You already have an active subscription.'); + } + + $plan = $validated['plan']; + $months = (int) $validated['months']; + $monthlyMinor = $plan === 'enterprise' + ? $this->subscriptions->enterprisePriceMinor() + : $this->subscriptions->priceMinor(); + + try { + $checkout = $billing->initiatePlanCheckout( + $user, + $plan, + $months, + $monthlyMinor * $months, + route('pos.pro.paystack.callback'), + ['user_id' => $user->id], + ); + } catch (\Throwable) { + return redirect()->route('pos.pro.index') + ->with('error', 'Could not start Paystack checkout. Please try again.'); + } + + $url = trim((string) ($checkout['checkout_url'] ?? '')); + if ($url === '') { + return redirect()->route('pos.pro.index') + ->with('error', 'Paystack did not return a checkout URL.'); + } + + return redirect()->away($url); + } + + public function paystackCallback(Request $request, BillingClient $billing): RedirectResponse + { + $reference = (string) $request->query('reference', ''); + if ($reference === '') { + return redirect()->route('pos.pro.index')->with('error', 'Missing payment reference.'); + } + + try { + $result = $billing->verifyPlanCheckout($reference); + } catch (\Throwable) { + return redirect()->route('pos.pro.index') + ->with('error', 'Could not verify payment. Contact support with reference: '.$reference); + } + + if (! ($result['paid'] ?? false)) { + return redirect()->route('pos.pro.index') + ->with('error', 'Payment was not completed.'); + } + + $metadata = (array) ($result['metadata'] ?? []); + $user = User::query()->find((int) ($metadata['user_id'] ?? 0)); + $account = ladill_account() ?? $request->user(); + if (! $user || ! $account || $user->id !== $account->id) { + return redirect()->route('pos.pro.index') + ->with('error', 'Account not found for this payment.'); + } + + $plan = (string) ($result['plan'] ?? 'pro'); + $months = (int) ($result['months'] ?? 0); + $this->subscriptions->activatePrepaid($user, $plan, $months); + + $label = $plan === 'enterprise' ? 'POS Business' : 'POS Pro'; + + return redirect()->route('pos.pro.index') + ->with('success', "{$label} is active for {$months} months."); + } + public function cancel(Request $request): RedirectResponse { [$ok, $message] = $this->subscriptions->cancel(ladill_account() ?? $request->user()); diff --git a/app/Models/Pos/ProSubscription.php b/app/Models/Pos/ProSubscription.php index 67c9e41..24fafa7 100644 --- a/app/Models/Pos/ProSubscription.php +++ b/app/Models/Pos/ProSubscription.php @@ -14,10 +14,14 @@ class ProSubscription extends Model public const STATUS_PAST_DUE = 'past_due'; + public const PLAN_PRO = 'pro'; + + public const PLAN_ENTERPRISE = 'enterprise'; + protected $table = 'pos_pro_subscriptions'; protected $fillable = [ - 'user_id', 'status', 'price_minor', 'currency', 'auto_renew', + 'user_id', 'status', 'plan', 'price_minor', 'currency', 'auto_renew', 'started_at', 'current_period_end', 'last_charged_at', 'canceled_at', 'last_reference', 'last_error', ]; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 507df17..1f37066 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -30,9 +30,14 @@ class AppServiceProvider extends ServiceProvider $restaurant = PosLocation::owned((string) $account->public_id) ->where('service_style', PosLocation::STYLE_RESTAURANT) ->exists(); - $view->with('isPro', app(SubscriptionService::class)->isPro($account)); + $svc = app(SubscriptionService::class); + $view->with('isPro', $svc->isPro($account)); + $view->with('hasPaidPlan', $svc->hasPaidPlan($account)); + $view->with('isEnterprise', $svc->isEnterprise($account)); } else { $view->with('isPro', false); + $view->with('hasPaidPlan', false); + $view->with('isEnterprise', false); } $view->with('posRestaurant', $restaurant); }); diff --git a/app/Services/Pos/BillingClient.php b/app/Services/Pos/BillingClient.php index 6b3d0a5..cc46e9e 100644 --- a/app/Services/Pos/BillingClient.php +++ b/app/Services/Pos/BillingClient.php @@ -49,4 +49,52 @@ class BillingClient return ['ok' => true, 'insufficient' => false, 'balance_minor' => null, 'error' => null]; } + + /** + * @param array $metadata + * @return array{checkout_url: string, reference: string} + */ + public function initiatePlanCheckout( + User $user, + string $plan, + int $months, + int $amountMinor, + string $returnUrl, + array $metadata = [], + ): array { + $res = Http::withToken((string) config('billing.api_key')) + ->acceptJson()->timeout(15) + ->post(rtrim((string) config('billing.api_url'), '/').'/plan-checkout', [ + 'user' => $user->public_id, + 'app' => (string) config('billing.service', 'pos'), + 'plan' => $plan, + 'months' => $months, + 'amount_minor' => $amountMinor, + 'return_url' => $returnUrl, + 'metadata' => $metadata, + ]); + $res->throw(); + + return [ + 'checkout_url' => (string) $res->json('checkout_url'), + 'reference' => (string) $res->json('reference'), + ]; + } + + /** @return array */ + public function verifyPlanCheckout(string $reference): array + { + $res = Http::withToken((string) config('billing.api_key')) + ->acceptJson()->timeout(15) + ->post(rtrim((string) config('billing.api_url'), '/').'/plan-checkout/verify', [ + 'reference' => $reference, + ]); + + if ($res->status() === 422) { + return ['paid' => false, 'error' => $res->json('error')]; + } + $res->throw(); + + return $res->json(); + } } diff --git a/app/Services/Pos/SubscriptionService.php b/app/Services/Pos/SubscriptionService.php index cf5e479..819478b 100644 --- a/app/Services/Pos/SubscriptionService.php +++ b/app/Services/Pos/SubscriptionService.php @@ -21,7 +21,12 @@ class SubscriptionService public function priceMinor(): int { - return (int) config('pos.pro.price_minor', 7900); + return (int) config('pos.plans.pro.price_minor', config('pos.pro.price_minor', 7900)); + } + + public function enterprisePriceMinor(): int + { + return (int) config('pos.plans.enterprise.price_minor', 24900); } public function subscriptionFor(User $user): ?ProSubscription @@ -29,7 +34,23 @@ class SubscriptionService return ProSubscription::where('user_id', $user->id)->first(); } - public function isPro(User $user): bool + public function planKey(User $user): string + { + if (! $this->gatingActive()) { + return ProSubscription::PLAN_PRO; + } + + $sub = $this->subscriptionFor($user); + if (! $sub || ! $sub->entitled()) { + return 'free'; + } + + return $sub->plan === ProSubscription::PLAN_ENTERPRISE + ? ProSubscription::PLAN_ENTERPRISE + : ProSubscription::PLAN_PRO; + } + + public function hasPaidPlan(User $user): bool { if (! $this->gatingActive()) { return true; @@ -38,6 +59,16 @@ class SubscriptionService return (bool) $this->subscriptionFor($user)?->entitled(); } + public function isEnterprise(User $user): bool + { + return $this->planKey($user) === ProSubscription::PLAN_ENTERPRISE; + } + + public function isPro(User $user): bool + { + return $this->hasPaidPlan($user); + } + public function productCount(string $ownerRef, bool $restaurant): int { if ($restaurant) { @@ -84,41 +115,56 @@ class SubscriptionService { $existing = $this->subscriptionFor($user); if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) { - return [true, 'You are already on Ladill POS Pro.']; + return [true, 'You already have an active subscription.']; } - $price = $this->priceMinor(); - $reference = 'pos-pro-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6); - $result = $this->billing->charge($user, $price, $reference, 'Ladill POS Pro — monthly subscription'); - - if (! $result['ok']) { - if ($result['insufficient']) { - $bal = number_format(((int) $result['balance_minor']) / 100, 2); - - return [false, "Your wallet balance (GHS {$bal}) is too low. Top up, then subscribe."]; - } - - return [false, $result['error'] ?? 'Could not start your subscription. Please try again.']; + if ($existing?->plan === ProSubscription::PLAN_ENTERPRISE) { + return $this->subscribeEnterprise($user); } - $periodEnd = Carbon::now()->addDays((int) config('pos.pro.period_days', 30)); + return $this->activateWalletPlan($user, ProSubscription::PLAN_PRO, $this->priceMinor(), 'Ladill POS Pro — monthly subscription', 'Welcome to Ladill POS Pro!'); + } + + /** @return array{0:bool,1:string} */ + public function subscribeEnterprise(User $user): array + { + $existing = $this->subscriptionFor($user); + if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) { + return [true, 'You already have an active subscription.']; + } + + return $this->activateWalletPlan( + $user, + ProSubscription::PLAN_ENTERPRISE, + $this->enterprisePriceMinor(), + 'Ladill POS Business — monthly subscription', + 'Welcome to Ladill POS Business!', + ); + } + + public function activatePrepaid(User $user, string $plan, int $months): void + { + $existing = $this->subscriptionFor($user); + $price = $plan === ProSubscription::PLAN_ENTERPRISE + ? $this->enterprisePriceMinor() + : $this->priceMinor(); + ProSubscription::updateOrCreate( ['user_id' => $user->id], [ + 'plan' => $plan, 'status' => ProSubscription::STATUS_ACTIVE, 'price_minor' => $price, 'currency' => (string) config('pos.pro.currency', 'GHS'), - 'auto_renew' => true, + 'auto_renew' => false, 'started_at' => $existing?->started_at ?? now(), - 'current_period_end' => $periodEnd, + 'current_period_end' => Carbon::now()->addMonths($months), 'last_charged_at' => now(), 'canceled_at' => null, - 'last_reference' => $reference, + 'last_reference' => null, 'last_error' => null, ], ); - - return [true, 'Welcome to Ladill POS Pro! Your subscription is active.']; } /** @return array{0:bool,1:string} */ @@ -137,7 +183,7 @@ class SubscriptionService $until = optional($sub->current_period_end)->format('d M Y'); - return [true, "Auto-renew is off. You keep Pro until {$until}."]; + return [true, "Auto-renew is off. You keep access until {$until}."]; } public function renewIfDue(ProSubscription $sub): void @@ -154,8 +200,9 @@ class SubscriptionService return; } - $reference = 'pos-pro-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6); - $result = $this->billing->charge($user, $sub->price_minor, $reference, 'Ladill POS Pro — renewal'); + $planLabel = $sub->plan === ProSubscription::PLAN_ENTERPRISE ? 'Business' : 'Pro'; + $reference = 'pos-'.$sub->plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6); + $result = $this->billing->charge($user, $sub->price_minor, $reference, "Ladill POS {$planLabel} — renewal"); if ($result['ok']) { $sub->forceFill([ @@ -175,4 +222,42 @@ class SubscriptionService $sub->forceFill(['last_error' => $result['error']])->save(); } } + + /** @return array{0:bool,1:string} */ + private function activateWalletPlan(User $user, string $plan, int $price, string $description, string $successMessage): array + { + $existing = $this->subscriptionFor($user); + $reference = 'pos-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6); + $result = $this->billing->charge($user, $price, $reference, $description); + + if (! $result['ok']) { + if ($result['insufficient']) { + $bal = number_format(((int) $result['balance_minor']) / 100, 2); + + return [false, "Your wallet balance (GHS {$bal}) is too low. Top up, then subscribe."]; + } + + return [false, $result['error'] ?? 'Could not start your subscription. Please try again.']; + } + + $periodEnd = Carbon::now()->addDays((int) config('pos.pro.period_days', 30)); + ProSubscription::updateOrCreate( + ['user_id' => $user->id], + [ + 'plan' => $plan, + 'status' => ProSubscription::STATUS_ACTIVE, + 'price_minor' => $price, + 'currency' => (string) config('pos.pro.currency', 'GHS'), + 'auto_renew' => true, + 'started_at' => $existing?->started_at ?? now(), + 'current_period_end' => $periodEnd, + 'last_charged_at' => now(), + 'canceled_at' => null, + 'last_reference' => $reference, + 'last_error' => null, + ], + ); + + return [true, $successMessage.' Your subscription is active.']; + } } diff --git a/config/pos.php b/config/pos.php index 2b6a921..6aae3d5 100644 --- a/config/pos.php +++ b/config/pos.php @@ -18,6 +18,13 @@ return [ 'grace_days' => (int) env('POS_PRO_GRACE_DAYS', 3), ], + 'plans' => [ + 'pro' => ['price_minor' => (int) env('POS_PRO_PRICE_MINOR', 7900)], + 'enterprise' => ['price_minor' => (int) env('POS_ENTERPRISE_PRICE_MINOR', 24900)], + ], + + 'prepaid_months' => [6, 12, 24], + 'free' => [ 'max_locations' => (int) env('POS_FREE_MAX_LOCATIONS', 1), 'max_products' => (int) env('POS_FREE_MAX_PRODUCTS', 50), diff --git a/database/migrations/2026_06_30_120000_add_plan_to_pos_pro_subscriptions_table.php b/database/migrations/2026_06_30_120000_add_plan_to_pos_pro_subscriptions_table.php new file mode 100644 index 0000000..47a0db4 --- /dev/null +++ b/database/migrations/2026_06_30_120000_add_plan_to_pos_pro_subscriptions_table.php @@ -0,0 +1,26 @@ +string('plan', 16)->default('pro')->after('status'); + } + }); + } + + public function down(): void + { + Schema::table('pos_pro_subscriptions', function (Blueprint $table) { + if (Schema::hasColumn('pos_pro_subscriptions', 'plan')) { + $table->dropColumn('plan'); + } + }); + } +}; diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index e6b43d0..2fb8b30 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -47,10 +47,10 @@ @php $proActive = request()->routeIs('pos.pro.*'); @endphp - @if(!empty($isPro)) + @if (! empty($hasPaidPlan)) - POS Pro + {{ ! empty($isEnterprise) ? 'POS Business' : 'POS Pro' }} @else diff --git a/resources/views/pos/pro/index.blade.php b/resources/views/pos/pro/index.blade.php index 25e746e..500437f 100644 --- a/resources/views/pos/pro/index.blade.php +++ b/resources/views/pos/pro/index.blade.php @@ -1,74 +1,97 @@ - + @php - $price = number_format($priceMinor / 100, 2); + $proPrice = number_format($proPriceMinor / 100, 0); + $enterprisePrice = number_format($enterprisePriceMinor / 100, 0); $live = $subscription && $subscription->entitled(); @endphp -
- @if(session('upsell')) -
{{ session('upsell') }}
+
+ @if (session('success')) +
{{ session('success') }}
+ @endif + @if (session('error')) +
{{ session('error') }}
+ @endif + @if (session('upsell')) +
{{ session('upsell') }}
@endif -
-
-
- Pro - @if($live)Active@endif +
+

Choose your POS plan

+

Pay monthly from your Ladill wallet, or prepay 6/12/24 months via Paystack.

+ @if ($gatingActive && ! $live) +
+ + @foreach ($prepaidMonths as $months) + + @endforeach
-

Ladill POS Pro

-

Scale your counter with unlimited products, restaurant mode, and ecosystem sync.

-

{{ $currency }} {{ $price }} / month

+ @endif +
+ + @if ($live) +
+
+
+ {{ $isEnterprise ? 'Business' : 'Pro' }} active until {{ $subscription->current_period_end->format('d M Y') }} +
+ @if ($subscription->auto_renew) +
@csrf
+ @elseif ($subscription->status === 'canceled') +
@csrf
+ @endif +
+
+ @endif + +
+
+

Free

+

GHS 0

+
    +
  • 50 products
  • +
  • 1 location
  • +
  • Core checkout
  • +
+ @if ($planKey === 'free')

Current plan

@endif
-
-
    - @foreach([ - 'Unlimited products', - 'Restaurant mode & kitchen display', - 'CRM & Merchant catalog import', - 'Stock tracking', - 'Invoice, CRM & Accounting sync', - 'Multi-location ready', - ] as $feature) -
  • - - {{ $feature }} -
  • - @endforeach +
    +

    Pro

    +

    {{ $currency }} {{ $proPrice }}/mo

    +
      +
    • Unlimited products
    • +
    • Restaurant mode
    • +
    • Catalog import
    +
    + @if ($planKey === 'pro') +

    Current plan

    + @elseif ($gatingActive && ! $hasPaidPlan) +
    @csrf
    + @foreach ($prepaidMonths as $months) +
    @csrf
    + @endforeach + @endif +
    +
    -
    - @if(! $gatingActive) -

    Pro is currently included for all accounts.

    - @elseif($live) -
    -
    - @if($subscription->status === 'canceled') - Auto-renew off — Pro stays active until {{ $subscription->current_period_end->format('d M Y') }}. - @else - Renews on {{ $subscription->current_period_end->format('d M Y') }} from your Ladill wallet. - @endif -
    - @if($subscription->auto_renew) -
    - @csrf - -
    - @else -
    - @csrf - -
    - @endif -
    - @else -
    - @csrf - -
    -

    Billed monthly from your Ladill wallet. Cancel anytime; you keep Pro until the period ends.

    +
    +

    Business

    +

    {{ $currency }} {{ $enterprisePrice }}/mo

    +
      +
    • Everything in Pro
    • +
    • Multi-location
    • +
    • Advanced reporting
    • +
    +
    + @if ($planKey === 'enterprise') +

    Current plan

    + @elseif ($gatingActive && ! $hasPaidPlan) +
    @csrf
    + @foreach ($prepaidMonths as $months) +
    @csrf
    + @endforeach @endif
    diff --git a/routes/web.php b/routes/web.php index 16c4fe9..3593c9a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -107,5 +107,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/pro', [ProController::class, 'index'])->name('pos.pro.index'); Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('pos.pro.subscribe'); + Route::post('/pro/subscribe-enterprise', [ProController::class, 'subscribeEnterprise'])->name('pos.pro.subscribe-enterprise'); + Route::post('/pro/subscribe-prepaid', [ProController::class, 'subscribePrepaid'])->name('pos.pro.subscribe-prepaid'); + Route::get('/pro/paystack/callback', [ProController::class, 'paystackCallback'])->name('pos.pro.paystack.callback'); Route::post('/pro/cancel', [ProController::class, 'cancel'])->name('pos.pro.cancel'); }); diff --git a/tests/Feature/PosProTest.php b/tests/Feature/PosProTest.php index 072b023..a003dd3 100644 --- a/tests/Feature/PosProTest.php +++ b/tests/Feature/PosProTest.php @@ -21,6 +21,8 @@ class PosProTest extends TestCase 'billing.api_key' => 'pos-billing-key', 'pos.pro.enabled' => true, 'pos.pro.price_minor' => 7900, + 'pos.plans.pro.price_minor' => 7900, + 'pos.plans.enterprise.price_minor' => 24900, ]); } @@ -47,6 +49,21 @@ class PosProTest extends TestCase $this->assertFalse(app(SubscriptionService::class)->canUseRestaurantMode($this->user())); } + public function test_subscribe_enterprise_charges_wallet_and_activates(): void + { + Http::fake(['*/debit' => Http::response(['id' => 1], 201)]); + $user = $this->user(); + $svc = app(SubscriptionService::class); + + [$ok] = $svc->subscribeEnterprise($user); + + $this->assertTrue($ok); + $this->assertTrue($svc->isEnterprise($user)); + $sub = ProSubscription::where('user_id', $user->id)->first(); + $this->assertSame('enterprise', $sub->plan); + $this->assertSame(24900, $sub->price_minor); + } + public function test_renew_suspends_after_grace_when_unpaid(): void { Http::fake(['*/debit' => Http::response(['balance_minor' => 0], 402)]);