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();
}
}
}
+1
View File
@@ -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 {
+13
View File
@@ -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>
+77
View File
@@ -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>
+3
View File
@@ -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
View File
@@ -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');
});
+64
View File
@@ -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);
}
}