Add POS Pro subscription billing and free-tier limits.
Deploy Ladill POS / deploy (push) Successful in 43s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
$middleware->alias([
|
||||
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
|
||||
'pro' => \App\Http\Middleware\EnsurePro::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
||||
@@ -9,4 +9,17 @@ return [
|
||||
'kitchen_api_keys' => array_filter([
|
||||
'merchant' => env('KITCHEN_API_KEY_MERCHANT'),
|
||||
]),
|
||||
|
||||
'pro' => [
|
||||
'enabled' => filter_var(env('POS_PRO_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
|
||||
'price_minor' => (int) env('POS_PRO_PRICE_MINOR', 7900),
|
||||
'currency' => env('POS_PRO_CURRENCY', 'GHS'),
|
||||
'period_days' => (int) env('POS_PRO_PERIOD_DAYS', 30),
|
||||
'grace_days' => (int) env('POS_PRO_GRACE_DAYS', 3),
|
||||
],
|
||||
|
||||
'free' => [
|
||||
'max_locations' => (int) env('POS_FREE_MAX_LOCATIONS', 1),
|
||||
'max_products' => (int) env('POS_FREE_MAX_PRODUCTS', 50),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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('pos_pro_subscriptions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
|
||||
$table->string('status', 16)->default('active');
|
||||
$table->unsignedInteger('price_minor');
|
||||
$table->char('currency', 3)->default('GHS');
|
||||
$table->boolean('auto_renew')->default(true);
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('current_period_end')->nullable();
|
||||
$table->timestamp('last_charged_at')->nullable();
|
||||
$table->timestamp('canceled_at')->nullable();
|
||||
$table->string('last_reference')->nullable();
|
||||
$table->text('last_error')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status', 'current_period_end']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('pos_pro_subscriptions');
|
||||
}
|
||||
};
|
||||
@@ -45,5 +45,18 @@
|
||||
</svg>
|
||||
<span class="flex-1 truncate">Settings</span>
|
||||
</a>
|
||||
|
||||
@php $proActive = request()->routeIs('pos.pro.*'); @endphp
|
||||
@if(!empty($isPro))
|
||||
<a href="{{ route('pos.pro.index') }}" class="group mt-0.5 flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $proActive ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
<svg class="h-[18px] w-[18px] shrink-0 text-amber-500" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.25c.3 0 .58.18.7.46l2.36 5.5 5.96.5a.75.75 0 0 1 .43 1.31l-4.53 3.9 1.36 5.83a.75.75 0 0 1-1.12.81L12 17.77l-5.16 3a.75.75 0 0 1-1.12-.81l1.36-5.83-4.53-3.9a.75.75 0 0 1 .43-1.31l5.96-.5 2.36-5.5c.12-.28.4-.46.7-.46Z"/></svg>
|
||||
<span>POS Pro</span>
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ route('pos.pro.index') }}" class="group mt-0.5 flex items-center gap-3 rounded-lg bg-gradient-to-r from-indigo-600 to-emerald-500 px-3 py-2 text-[13px] font-semibold text-white shadow-sm transition hover:opacity-95">
|
||||
<svg class="h-[18px] w-[18px] shrink-0" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.25c.3 0 .58.18.7.46l2.36 5.5 5.96.5a.75.75 0 0 1 .43 1.31l-4.53 3.9 1.36 5.83a.75.75 0 0 1-1.12.81L12 17.77l-5.16 3a.75.75 0 0 1-1.12-.81l1.36-5.83-4.53-3.9a.75.75 0 0 1 .43-1.31l5.96-.5 2.36-5.5c.12-.28.4-.46.7-.46Z"/></svg>
|
||||
<span>Upgrade to Pro</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<x-app-layout title="Pro" heading="Ladill POS Pro">
|
||||
@php
|
||||
$price = number_format($priceMinor / 100, 2);
|
||||
$live = $subscription && $subscription->entitled();
|
||||
@endphp
|
||||
|
||||
<div class="mx-auto max-w-2xl">
|
||||
@if(session('upsell'))
|
||||
<div class="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ session('upsell') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-500 px-6 py-7 text-white">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-md bg-white/20 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide">Pro</span>
|
||||
@if($live)<span class="rounded-md bg-emerald-400/90 px-2 py-0.5 text-[11px] font-semibold text-emerald-950">Active</span>@endif
|
||||
</div>
|
||||
<h1 class="mt-3 text-2xl font-bold">Ladill POS Pro</h1>
|
||||
<p class="mt-1 text-sm text-white/80">Scale your counter with unlimited products, restaurant mode, and ecosystem sync.</p>
|
||||
<p class="mt-4 text-3xl font-bold">{{ $currency }} {{ $price }}<span class="text-base font-medium text-white/70"> / month</span></p>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-6">
|
||||
<ul class="grid gap-3 sm:grid-cols-2">
|
||||
@foreach([
|
||||
'Unlimited products',
|
||||
'Restaurant mode & kitchen display',
|
||||
'CRM & Merchant catalog import',
|
||||
'Stock tracking',
|
||||
'Invoice, CRM & Accounting sync',
|
||||
'Multi-location ready',
|
||||
] as $feature)
|
||||
<li class="flex items-start gap-2 text-sm text-slate-700">
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
|
||||
<span>{{ $feature }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 border-t border-slate-100 pt-6">
|
||||
@if(! $gatingActive)
|
||||
<p class="rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-500">Pro is currently included for all accounts.</p>
|
||||
@elseif($live)
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-sm text-slate-600">
|
||||
@if($subscription->status === 'canceled')
|
||||
Auto-renew off — Pro stays active until <strong>{{ $subscription->current_period_end->format('d M Y') }}</strong>.
|
||||
@else
|
||||
Renews on <strong>{{ $subscription->current_period_end->format('d M Y') }}</strong> from your Ladill wallet.
|
||||
@endif
|
||||
</div>
|
||||
@if($subscription->auto_renew)
|
||||
<form method="post" action="{{ route('pos.pro.cancel') }}" onsubmit="return confirm('Turn off auto-renew? You keep Pro until the period ends.');">
|
||||
@csrf
|
||||
<button class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">Cancel auto-renew</button>
|
||||
</form>
|
||||
@else
|
||||
<form method="post" action="{{ route('pos.pro.subscribe') }}">
|
||||
@csrf
|
||||
<button class="btn-primary">Resume subscription</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<form method="post" action="{{ route('pos.pro.subscribe') }}">
|
||||
@csrf
|
||||
<button class="btn-primary w-full sm:w-auto">
|
||||
Subscribe — {{ $currency }} {{ $price }}/mo from wallet
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-3 text-xs text-slate-400">Billed monthly from your Ladill wallet. Cancel anytime; you keep Pro until the period ends.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('pos:pro-renew')->dailyAt('02:50')->withoutOverlapping();
|
||||
|
||||
+31
-26
@@ -5,7 +5,7 @@ use App\Http\Controllers\Pos\AiController;
|
||||
use App\Http\Controllers\Pos\DashboardController;
|
||||
use App\Http\Controllers\Pos\KitchenController;
|
||||
use App\Http\Controllers\Pos\MenuController;
|
||||
use App\Http\Controllers\Pos\ProductController;
|
||||
use App\Http\Controllers\Pos\ProController;
|
||||
use App\Http\Controllers\Pos\RegisterController;
|
||||
use App\Http\Controllers\Pos\SaleController;
|
||||
use App\Http\Controllers\Pos\SettingsController;
|
||||
@@ -59,31 +59,32 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
Route::delete('/sales/{sale}', [SaleController::class, 'destroy'])->name('pos.sales.destroy');
|
||||
|
||||
// Restaurant mode — floor, open tickets (tabs), and the kitchen display.
|
||||
Route::get('/floor', [TableController::class, 'index'])->name('pos.floor');
|
||||
Route::get('/floor/tables/{table}/qr', [TableController::class, 'qr'])->name('pos.tables.qr');
|
||||
Route::post('/tickets', [TicketController::class, 'open'])->name('pos.tickets.open');
|
||||
Route::get('/tickets/{sale}', [TicketController::class, 'show'])->name('pos.tickets.show');
|
||||
Route::post('/tickets/{sale}/lines', [TicketController::class, 'addLine'])->name('pos.tickets.lines.add');
|
||||
Route::patch('/tickets/{sale}/lines/{line}', [TicketController::class, 'updateLine'])->name('pos.tickets.lines.update');
|
||||
Route::delete('/tickets/{sale}/lines/{line}', [TicketController::class, 'removeLine'])->name('pos.tickets.lines.remove');
|
||||
Route::post('/tickets/{sale}/send', [TicketController::class, 'sendToKitchen'])->name('pos.tickets.send');
|
||||
Route::post('/tickets/{sale}/settle', [TicketController::class, 'settle'])->name('pos.tickets.settle');
|
||||
Route::middleware('pro')->group(function () {
|
||||
Route::get('/floor', [TableController::class, 'index'])->name('pos.floor');
|
||||
Route::get('/floor/tables/{table}/qr', [TableController::class, 'qr'])->name('pos.tables.qr');
|
||||
Route::post('/tickets', [TicketController::class, 'open'])->name('pos.tickets.open');
|
||||
Route::get('/tickets/{sale}', [TicketController::class, 'show'])->name('pos.tickets.show');
|
||||
Route::post('/tickets/{sale}/lines', [TicketController::class, 'addLine'])->name('pos.tickets.lines.add');
|
||||
Route::patch('/tickets/{sale}/lines/{line}', [TicketController::class, 'updateLine'])->name('pos.tickets.lines.update');
|
||||
Route::delete('/tickets/{sale}/lines/{line}', [TicketController::class, 'removeLine'])->name('pos.tickets.lines.remove');
|
||||
Route::post('/tickets/{sale}/send', [TicketController::class, 'sendToKitchen'])->name('pos.tickets.send');
|
||||
Route::post('/tickets/{sale}/settle', [TicketController::class, 'settle'])->name('pos.tickets.settle');
|
||||
|
||||
Route::get('/kitchen', [KitchenController::class, 'index'])->name('pos.kitchen');
|
||||
Route::get('/kitchen/feed', [KitchenController::class, 'feed'])->name('pos.kitchen.feed');
|
||||
Route::get('/kitchen/stream', [KitchenController::class, 'stream'])->name('pos.kitchen.stream');
|
||||
Route::post('/kitchen/lines/{line}/bump', [KitchenController::class, 'bump'])->name('pos.kitchen.bump');
|
||||
Route::get('/kitchen', [KitchenController::class, 'index'])->name('pos.kitchen');
|
||||
Route::get('/kitchen/feed', [KitchenController::class, 'feed'])->name('pos.kitchen.feed');
|
||||
Route::get('/kitchen/stream', [KitchenController::class, 'stream'])->name('pos.kitchen.stream');
|
||||
Route::post('/kitchen/lines/{line}/bump', [KitchenController::class, 'bump'])->name('pos.kitchen.bump');
|
||||
|
||||
// Menu depth — categories, kitchen stations, and modifier groups/options.
|
||||
Route::get('/menu', [MenuController::class, 'index'])->name('pos.menu');
|
||||
Route::post('/menu/categories', [MenuController::class, 'storeCategory'])->name('pos.menu.categories.store');
|
||||
Route::delete('/menu/categories/{category}', [MenuController::class, 'destroyCategory'])->name('pos.menu.categories.destroy');
|
||||
Route::post('/menu/stations', [MenuController::class, 'storeStation'])->name('pos.menu.stations.store');
|
||||
Route::delete('/menu/stations/{station}', [MenuController::class, 'destroyStation'])->name('pos.menu.stations.destroy');
|
||||
Route::post('/menu/groups', [MenuController::class, 'storeGroup'])->name('pos.menu.groups.store');
|
||||
Route::delete('/menu/groups/{group}', [MenuController::class, 'destroyGroup'])->name('pos.menu.groups.destroy');
|
||||
Route::post('/menu/groups/{group}/modifiers', [MenuController::class, 'storeModifier'])->name('pos.menu.modifiers.store');
|
||||
Route::delete('/menu/modifiers/{modifier}', [MenuController::class, 'destroyModifier'])->name('pos.menu.modifiers.destroy');
|
||||
Route::get('/menu', [MenuController::class, 'index'])->name('pos.menu');
|
||||
Route::post('/menu/categories', [MenuController::class, 'storeCategory'])->name('pos.menu.categories.store');
|
||||
Route::delete('/menu/categories/{category}', [MenuController::class, 'destroyCategory'])->name('pos.menu.categories.destroy');
|
||||
Route::post('/menu/stations', [MenuController::class, 'storeStation'])->name('pos.menu.stations.store');
|
||||
Route::delete('/menu/stations/{station}', [MenuController::class, 'destroyStation'])->name('pos.menu.stations.destroy');
|
||||
Route::post('/menu/groups', [MenuController::class, 'storeGroup'])->name('pos.menu.groups.store');
|
||||
Route::delete('/menu/groups/{group}', [MenuController::class, 'destroyGroup'])->name('pos.menu.groups.destroy');
|
||||
Route::post('/menu/groups/{group}/modifiers', [MenuController::class, 'storeModifier'])->name('pos.menu.modifiers.store');
|
||||
Route::delete('/menu/modifiers/{modifier}', [MenuController::class, 'destroyModifier'])->name('pos.menu.modifiers.destroy');
|
||||
});
|
||||
|
||||
Route::get('/products', [ProductController::class, 'index'])->name('pos.products.index');
|
||||
Route::get('/products/import', [ProductController::class, 'importForm'])->name('pos.products.import.form');
|
||||
@@ -97,10 +98,14 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
|
||||
Route::get('/settings', [SettingsController::class, 'index'])->name('pos.settings');
|
||||
Route::put('/settings', [SettingsController::class, 'update'])->name('pos.settings.update');
|
||||
Route::post('/settings/import-crm', [SettingsController::class, 'importCrm'])->name('pos.settings.import-crm');
|
||||
Route::post('/settings/import-merchant', [SettingsController::class, 'importMerchant'])->name('pos.settings.import-merchant');
|
||||
Route::post('/settings/import-crm', [SettingsController::class, 'importCrm'])->middleware('pro')->name('pos.settings.import-crm');
|
||||
Route::post('/settings/import-merchant', [SettingsController::class, 'importMerchant'])->middleware('pro')->name('pos.settings.import-merchant');
|
||||
Route::post('/settings/tables', [SettingsController::class, 'storeTable'])->name('pos.settings.tables.store');
|
||||
Route::delete('/settings/tables/{table}', [SettingsController::class, 'destroyTable'])->name('pos.settings.tables.destroy');
|
||||
|
||||
Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('pos.wallet');
|
||||
|
||||
Route::get('/pro', [ProController::class, 'index'])->name('pos.pro.index');
|
||||
Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('pos.pro.subscribe');
|
||||
Route::post('/pro/cancel', [ProController::class, 'cancel'])->name('pos.pro.cancel');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Pos\ProSubscription;
|
||||
use App\Models\User;
|
||||
use App\Services\Pos\SubscriptionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PosProTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config([
|
||||
'billing.api_url' => 'https://ladill.com/api/billing',
|
||||
'billing.api_key' => 'pos-billing-key',
|
||||
'pos.pro.enabled' => true,
|
||||
'pos.pro.price_minor' => 7900,
|
||||
]);
|
||||
}
|
||||
|
||||
private function user(): User
|
||||
{
|
||||
return User::factory()->create(['public_id' => 'usr_'.uniqid()]);
|
||||
}
|
||||
|
||||
public function test_subscribe_charges_wallet_and_activates(): void
|
||||
{
|
||||
Http::fake(['*/debit' => Http::response(['id' => 1], 201)]);
|
||||
$user = $this->user();
|
||||
$svc = app(SubscriptionService::class);
|
||||
|
||||
[$ok] = $svc->subscribe($user);
|
||||
|
||||
$this->assertTrue($ok);
|
||||
$this->assertTrue($svc->isPro($user));
|
||||
Http::assertSent(fn ($r) => str_contains($r->url(), '/debit') && $r['service'] === 'pos');
|
||||
}
|
||||
|
||||
public function test_free_user_cannot_use_restaurant_mode(): void
|
||||
{
|
||||
$this->assertFalse(app(SubscriptionService::class)->canUseRestaurantMode($this->user()));
|
||||
}
|
||||
|
||||
public function test_renew_suspends_after_grace_when_unpaid(): void
|
||||
{
|
||||
Http::fake(['*/debit' => Http::response(['balance_minor' => 0], 402)]);
|
||||
$user = $this->user();
|
||||
$sub = ProSubscription::create([
|
||||
'user_id' => $user->id, 'status' => 'active', 'price_minor' => 7900,
|
||||
'auto_renew' => true, 'started_at' => now()->subDays(40),
|
||||
'current_period_end' => now()->subDays(5),
|
||||
]);
|
||||
|
||||
app(SubscriptionService::class)->renewIfDue($sub->fresh());
|
||||
|
||||
$this->assertSame('past_due', $sub->fresh()->status);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user