Add POS Pro subscription billing and free-tier limits.
Deploy Ladill POS / deploy (push) Successful in 43s

Wallet-backed Pro unlocks unlimited products, restaurant mode, catalog imports, and ecosystem sync features.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-30 00:51:35 +00:00
co-authored by Cursor
parent 68255786e1
commit f800f0c1ca
17 changed files with 635 additions and 29 deletions
@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Models\Pos\ProSubscription;
use App\Services\Pos\SubscriptionService;
use Illuminate\Console\Command;
class RenewProSubscriptionsCommand extends Command
{
protected $signature = 'pos:pro-renew';
protected $description = 'Charge the Ladill wallet for due POS Pro subscriptions.';
public function handle(SubscriptionService $subscriptions): int
{
$due = ProSubscription::where('status', ProSubscription::STATUS_ACTIVE)
->where('auto_renew', true)
->where('current_period_end', '<=', now())
->get();
$this->info("Renewing {$due->count()} due subscription(s).");
$due->each(fn (ProSubscription $sub) => $subscriptions->renewIfDue($sub));
return self::SUCCESS;
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Pos;
use App\Http\Controllers\Controller;
use App\Services\Pos\SubscriptionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProController extends Controller
{
public function __construct(private readonly SubscriptionService $subscriptions) {}
public function index(Request $request): View
{
$user = ladill_account() ?? $request->user();
return view('pos.pro.index', [
'subscription' => $this->subscriptions->subscriptionFor($user),
'isPro' => $this->subscriptions->isPro($user),
'gatingActive' => $this->subscriptions->gatingActive(),
'priceMinor' => $this->subscriptions->priceMinor(),
'currency' => (string) config('pos.pro.currency', 'GHS'),
]);
}
public function subscribe(Request $request): RedirectResponse
{
[$ok, $message] = $this->subscriptions->subscribe(ladill_account() ?? $request->user());
return redirect()->route('pos.pro.index')->with($ok ? 'success' : 'error', $message);
}
public function cancel(Request $request): RedirectResponse
{
[$ok, $message] = $this->subscriptions->cancel(ladill_account() ?? $request->user());
return redirect()->route('pos.pro.index')->with($ok ? 'success' : 'error', $message);
}
}
+11 -1
View File
@@ -29,6 +29,8 @@ class ProductController extends Controller
{
use ScopesToAccount;
public function __construct(private readonly \App\Services\Pos\SubscriptionService $subscriptions) {}
public function index(Request $request): View
{
if ($this->isRestaurant($request)) {
@@ -66,7 +68,15 @@ class ProductController extends Controller
public function store(Request $request): RedirectResponse
{
if ($this->isRestaurant($request)) {
$user = ladill_account() ?? $request->user();
$restaurant = $this->isRestaurant($request);
if (! $this->subscriptions->canAddProduct($user, $this->ownerRef($request), $restaurant)) {
return redirect()->route('pos.pro.index')
->with('upsell', 'You have reached the free plan limit of '.(int) config('pos.free.max_products', 50).' products. Upgrade to Pro for an unlimited catalog.');
}
if ($restaurant) {
$product = PosProduct::create([...$this->localValidated($request), 'owner_ref' => $this->ownerRef($request)]);
$product->modifierGroups()->sync($this->groupIds($request));
@@ -18,7 +18,10 @@ class SettingsController extends Controller
{
use ScopesToAccount;
public function __construct(private PosLocationService $locations) {}
public function __construct(
private PosLocationService $locations,
private \App\Services\Pos\SubscriptionService $subscriptions,
) {}
public function index(Request $request): View
{
@@ -41,6 +44,12 @@ class SettingsController extends Controller
'receipt_footer' => ['nullable', 'string', 'max:1000'],
]);
$user = ladill_account() ?? $request->user();
if ($data['service_style'] === 'restaurant' && ! $this->subscriptions->canUseRestaurantMode($user)) {
return redirect()->route('pos.pro.index')
->with('upsell', 'Restaurant mode is part of Ladill POS Pro.');
}
$location = $this->locations->ensureDefault($this->ownerRef($request));
$location->update([
'name' => $data['name'],
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use App\Services\Pos\SubscriptionService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsurePro
{
public function __construct(private readonly SubscriptionService $subscriptions) {}
public function handle(Request $request, Closure $next): Response
{
$user = ladill_account() ?? $request->user();
if ($user && $this->subscriptions->isPro($user)) {
return $next($request);
}
if ($request->expectsJson()) {
return response()->json(['message' => 'This feature requires Ladill POS Pro.'], 402);
}
return redirect()->route('pos.pro.index')
->with('upsell', 'That feature is part of Ladill POS Pro — subscribe to unlock it.');
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models\Pos;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProSubscription extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_CANCELED = 'canceled';
public const STATUS_PAST_DUE = 'past_due';
protected $table = 'pos_pro_subscriptions';
protected $fillable = [
'user_id', 'status', 'price_minor', 'currency', 'auto_renew',
'started_at', 'current_period_end', 'last_charged_at', 'canceled_at',
'last_reference', 'last_error',
];
protected $casts = [
'auto_renew' => 'boolean',
'price_minor' => 'integer',
'started_at' => 'datetime',
'current_period_end' => 'datetime',
'last_charged_at' => 'datetime',
'canceled_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function entitled(): bool
{
return $this->status !== self::STATUS_PAST_DUE
&& $this->current_period_end !== null
&& $this->current_period_end->isFuture();
}
}
+6 -1
View File
@@ -3,6 +3,7 @@
namespace App\Providers;
use App\Models\PosLocation;
use App\Services\Pos\SubscriptionService;
use Illuminate\Support\ServiceProvider;
use App\Support\MobileTopbar;
use Illuminate\Support\Facades\View;
@@ -23,11 +24,15 @@ class AppServiceProvider extends ServiceProvider
// Expose whether the signed-in account runs in restaurant mode so the
// sidebar can surface the Floor + Kitchen nav only when relevant.
View::composer('partials.sidebar', function ($view) {
$account = ladill_account();
$restaurant = false;
if ($account = ladill_account()) {
if ($account) {
$restaurant = PosLocation::owned((string) $account->public_id)
->where('service_style', PosLocation::STYLE_RESTAURANT)
->exists();
$view->with('isPro', app(SubscriptionService::class)->isPro($account));
} else {
$view->with('isPro', false);
}
$view->with('posRestaurant', $restaurant);
});
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Services\Pos;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class BillingClient
{
public function configured(): bool
{
return (string) config('billing.api_url', '') !== '' && (string) config('billing.api_key', '') !== '';
}
/**
* @return array{ok: bool, insufficient: bool, balance_minor: ?int, error: ?string}
*/
public function charge(User $user, int $amountMinor, string $reference, string $description): array
{
if (! $this->configured()) {
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing is not configured.'];
}
try {
$res = Http::withToken((string) config('billing.api_key'))
->acceptJson()->timeout(20)
->post(rtrim((string) config('billing.api_url'), '/').'/debit', [
'user' => $user->public_id,
'amount_minor' => $amountMinor,
'service' => 'pos',
'source' => 'subscription',
'reference' => $reference,
'description' => $description,
]);
} catch (\Throwable $e) {
Log::warning('POS BillingClient: charge failed', ['user' => $user->public_id, 'error' => $e->getMessage()]);
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Could not reach the billing service.'];
}
if ($res->status() === 402) {
return ['ok' => false, 'insufficient' => true, 'balance_minor' => (int) $res->json('balance_minor', 0), 'error' => 'Insufficient wallet balance.'];
}
if ($res->failed()) {
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing error ('.$res->status().').'];
}
return ['ok' => true, 'insufficient' => false, 'balance_minor' => null, 'error' => null];
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace App\Services\Pos;
use App\Models\Pos\ProSubscription;
use App\Models\PosLocation;
use App\Models\PosProduct;
use App\Models\User;
use App\Services\Crm\CrmClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class SubscriptionService
{
public function __construct(private readonly BillingClient $billing) {}
public function gatingActive(): bool
{
return (bool) config('pos.pro.enabled', true) && $this->billing->configured();
}
public function priceMinor(): int
{
return (int) config('pos.pro.price_minor', 7900);
}
public function subscriptionFor(User $user): ?ProSubscription
{
return ProSubscription::where('user_id', $user->id)->first();
}
public function isPro(User $user): bool
{
if (! $this->gatingActive()) {
return true;
}
return (bool) $this->subscriptionFor($user)?->entitled();
}
public function productCount(string $ownerRef, bool $restaurant): int
{
if ($restaurant) {
return PosProduct::owned($ownerRef)->count();
}
try {
$response = CrmClient::for($ownerRef)->products(['type' => 'product', 'per_page' => 1]);
return (int) ($response['meta']['total'] ?? count((array) ($response['data'] ?? [])));
} catch (\Throwable) {
return 0;
}
}
public function canAddProduct(User $user, string $ownerRef, bool $restaurant): bool
{
if ($this->isPro($user)) {
return true;
}
$limit = (int) config('pos.free.max_products', 50);
return $this->productCount($ownerRef, $restaurant) < $limit;
}
public function canUseRestaurantMode(User $user): bool
{
return $this->isPro($user);
}
public function canImportCatalog(User $user): bool
{
return $this->isPro($user);
}
public function locationCount(string $ownerRef): int
{
return PosLocation::owned($ownerRef)->count();
}
/** @return array{0:bool,1:string} */
public function subscribe(User $user): array
{
$existing = $this->subscriptionFor($user);
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
return [true, 'You are already on Ladill POS Pro.'];
}
$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.'];
}
$periodEnd = Carbon::now()->addDays((int) config('pos.pro.period_days', 30));
ProSubscription::updateOrCreate(
['user_id' => $user->id],
[
'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, 'Welcome to Ladill POS Pro! Your subscription is active.'];
}
/** @return array{0:bool,1:string} */
public function cancel(User $user): array
{
$sub = $this->subscriptionFor($user);
if (! $sub || ! $sub->entitled()) {
return [false, 'You do not have an active subscription.'];
}
$sub->forceFill([
'status' => ProSubscription::STATUS_CANCELED,
'auto_renew' => false,
'canceled_at' => now(),
])->save();
$until = optional($sub->current_period_end)->format('d M Y');
return [true, "Auto-renew is off. You keep Pro until {$until}."];
}
public function renewIfDue(ProSubscription $sub): void
{
if (! $sub->auto_renew || $sub->status !== ProSubscription::STATUS_ACTIVE) {
return;
}
if ($sub->current_period_end && $sub->current_period_end->isFuture()) {
return;
}
$user = $sub->user;
if (! $user) {
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');
if ($result['ok']) {
$sub->forceFill([
'current_period_end' => Carbon::now()->addDays((int) config('pos.pro.period_days', 30)),
'last_charged_at' => now(),
'last_reference' => $reference,
'last_error' => null,
])->save();
return;
}
$graceEnd = optional($sub->current_period_end)->addDays((int) config('pos.pro.grace_days', 3));
if ($graceEnd && $graceEnd->isPast()) {
$sub->forceFill(['status' => ProSubscription::STATUS_PAST_DUE, 'last_error' => $result['error']])->save();
} else {
$sub->forceFill(['last_error' => $result['error']])->save();
}
}
}