diff --git a/app/Console/Commands/RenewProSubscriptionsCommand.php b/app/Console/Commands/RenewProSubscriptionsCommand.php new file mode 100644 index 0000000..3584c15 --- /dev/null +++ b/app/Console/Commands/RenewProSubscriptionsCommand.php @@ -0,0 +1,27 @@ +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; + } +} diff --git a/app/Http/Controllers/Pos/ProController.php b/app/Http/Controllers/Pos/ProController.php new file mode 100644 index 0000000..d4aef31 --- /dev/null +++ b/app/Http/Controllers/Pos/ProController.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/app/Http/Controllers/Pos/ProductController.php b/app/Http/Controllers/Pos/ProductController.php index 314a76d..1d5fd10 100644 --- a/app/Http/Controllers/Pos/ProductController.php +++ b/app/Http/Controllers/Pos/ProductController.php @@ -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)); diff --git a/app/Http/Controllers/Pos/SettingsController.php b/app/Http/Controllers/Pos/SettingsController.php index 70cb9c4..d552193 100644 --- a/app/Http/Controllers/Pos/SettingsController.php +++ b/app/Http/Controllers/Pos/SettingsController.php @@ -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'], diff --git a/app/Http/Middleware/EnsurePro.php b/app/Http/Middleware/EnsurePro.php new file mode 100644 index 0000000..294427d --- /dev/null +++ b/app/Http/Middleware/EnsurePro.php @@ -0,0 +1,29 @@ +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.'); + } +} diff --git a/app/Models/Pos/ProSubscription.php b/app/Models/Pos/ProSubscription.php new file mode 100644 index 0000000..67c9e41 --- /dev/null +++ b/app/Models/Pos/ProSubscription.php @@ -0,0 +1,45 @@ + '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(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9b6ed26..507df17 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); }); diff --git a/app/Services/Pos/BillingClient.php b/app/Services/Pos/BillingClient.php new file mode 100644 index 0000000..6b3d0a5 --- /dev/null +++ b/app/Services/Pos/BillingClient.php @@ -0,0 +1,52 @@ +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]; + } +} diff --git a/app/Services/Pos/SubscriptionService.php b/app/Services/Pos/SubscriptionService.php new file mode 100644 index 0000000..cf5e479 --- /dev/null +++ b/app/Services/Pos/SubscriptionService.php @@ -0,0 +1,178 @@ +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(); + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 8e19c16..97dd748 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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 { diff --git a/config/pos.php b/config/pos.php index b3b0def..2b6a921 100644 --- a/config/pos.php +++ b/config/pos.php @@ -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), + ], ]; diff --git a/database/migrations/2026_06_30_100000_create_pos_pro_subscriptions_table.php b/database/migrations/2026_06_30_100000_create_pos_pro_subscriptions_table.php new file mode 100644 index 0000000..9333d49 --- /dev/null +++ b/database/migrations/2026_06_30_100000_create_pos_pro_subscriptions_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 0d0493e..e6b43d0 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -45,5 +45,18 @@ Settings + + @php $proActive = request()->routeIs('pos.pro.*'); @endphp + @if(!empty($isPro)) + + + POS Pro + + @else + + + Upgrade to Pro + + @endif diff --git a/resources/views/pos/pro/index.blade.php b/resources/views/pos/pro/index.blade.php new file mode 100644 index 0000000..25e746e --- /dev/null +++ b/resources/views/pos/pro/index.blade.php @@ -0,0 +1,77 @@ + + @php + $price = number_format($priceMinor / 100, 2); + $live = $subscription && $subscription->entitled(); + @endphp + +
+ @if(session('upsell')) +
{{ session('upsell') }}
+ @endif + +
+
+
+ Pro + @if($live)Active@endif +
+

Ladill POS Pro

+

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

+

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

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

+ @endif +
+
+
+
+
diff --git a/routes/console.php b/routes/console.php index 3c9adf1..e0afadf 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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(); diff --git a/routes/web.php b/routes/web.php index 1ae4961..16c4fe9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); }); diff --git a/tests/Feature/PosProTest.php b/tests/Feature/PosProTest.php new file mode 100644 index 0000000..072b023 --- /dev/null +++ b/tests/Feature/PosProTest.php @@ -0,0 +1,64 @@ + '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); + } +}