From cebf0d2e61b45b53b1859417d6bddc2242e81358 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Tue, 7 Jul 2026 01:14:08 +0000 Subject: [PATCH] Add Woo Manager Pro with multi-store switching and free-tier limits. Free: 1 store and 20 products. Pro/Business mirrors Invoice pricing (GHS 49/149) with wallet renewals, Paystack prepay, session-based store switcher, and scoped UI. Co-authored-by: Cursor --- .../Commands/RenewProSubscriptionsCommand.php | 38 +++ .../Controllers/Woo/CategoryController.php | 103 ++----- .../Woo/Concerns/ResolvesWooContext.php | 38 +++ .../Woo/ConnectWordPressController.php | 19 +- .../Controllers/Woo/DashboardController.php | 60 +++- app/Http/Controllers/Woo/OrderController.php | 32 +- app/Http/Controllers/Woo/ProController.php | 144 +++++++++ .../Controllers/Woo/ProductController.php | 126 ++++---- app/Http/Controllers/Woo/StoreController.php | 28 +- .../Controllers/Woo/StoreSwitchController.php | 25 ++ app/Models/Woo/ProSubscription.php | 49 +++ app/Providers/AppServiceProvider.php | 15 +- app/Services/Woo/BillingClient.php | 100 ++++++ app/Services/Woo/CatalogSyncService.php | 14 +- app/Services/Woo/StoreSyncService.php | 2 +- app/Services/Woo/SubscriptionService.php | 290 ++++++++++++++++++ app/Support/CurrentWooStore.php | 58 ++++ config/woo.php | 26 ++ ...000_create_woo_pro_subscriptions_table.php | 35 +++ resources/views/partials/sidebar.blade.php | 13 + .../views/partials/store-switcher.blade.php | 32 ++ resources/views/partials/topbar.blade.php | 2 + .../views/partials/upgrade-banner.blade.php | 2 +- resources/views/woo/categories/form.blade.php | 10 +- .../views/woo/categories/index.blade.php | 25 +- resources/views/woo/dashboard.blade.php | 15 +- resources/views/woo/orders/index.blade.php | 21 +- resources/views/woo/pro/index.blade.php | 101 ++++++ resources/views/woo/products/form.blade.php | 10 +- resources/views/woo/products/index.blade.php | 54 ++-- resources/views/woo/stores/index.blade.php | 26 +- routes/console.php | 3 + routes/web.php | 11 +- tests/Feature/WooProTest.php | 76 +++++ 34 files changed, 1340 insertions(+), 263 deletions(-) create mode 100644 app/Console/Commands/RenewProSubscriptionsCommand.php create mode 100644 app/Http/Controllers/Woo/Concerns/ResolvesWooContext.php create mode 100644 app/Http/Controllers/Woo/ProController.php create mode 100644 app/Http/Controllers/Woo/StoreSwitchController.php create mode 100644 app/Models/Woo/ProSubscription.php create mode 100644 app/Services/Woo/BillingClient.php create mode 100644 app/Services/Woo/SubscriptionService.php create mode 100644 app/Support/CurrentWooStore.php create mode 100644 database/migrations/2026_07_07_010000_create_woo_pro_subscriptions_table.php create mode 100644 resources/views/partials/store-switcher.blade.php create mode 100644 resources/views/woo/pro/index.blade.php create mode 100644 tests/Feature/WooProTest.php diff --git a/app/Console/Commands/RenewProSubscriptionsCommand.php b/app/Console/Commands/RenewProSubscriptionsCommand.php new file mode 100644 index 0000000..44e112d --- /dev/null +++ b/app/Console/Commands/RenewProSubscriptionsCommand.php @@ -0,0 +1,38 @@ +gatingActive()) { + $this->info('Woo Pro gating is disabled or billing is not configured.'); + + return self::SUCCESS; + } + + $due = ProSubscription::query() + ->where('status', ProSubscription::STATUS_ACTIVE) + ->where('auto_renew', true) + ->whereNotNull('current_period_end') + ->where('current_period_end', '<=', now()) + ->get(); + + foreach ($due as $subscription) { + $subscriptions->renewIfDue($subscription); + } + + $this->info('Processed '.$due->count().' subscription(s).'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Woo/CategoryController.php b/app/Http/Controllers/Woo/CategoryController.php index f5f79fb..447f58d 100644 --- a/app/Http/Controllers/Woo/CategoryController.php +++ b/app/Http/Controllers/Woo/CategoryController.php @@ -3,8 +3,8 @@ namespace App\Http\Controllers\Woo; use App\Http\Controllers\Controller; +use App\Http\Controllers\Woo\Concerns\ResolvesWooContext; use App\Models\WooCategory; -use App\Models\WooStore; use App\Services\Woo\CatalogSyncService; use App\Services\Woo\CatalogWriteService; use Illuminate\Http\RedirectResponse; @@ -13,6 +13,8 @@ use Illuminate\View\View; class CategoryController extends Controller { + use ResolvesWooContext; + public function __construct( private CatalogWriteService $write, private CatalogSyncService $sync, @@ -20,105 +22,84 @@ class CategoryController extends Controller public function index(Request $request): View { - $user = $request->user(); - $storeFilter = $request->query('store'); + $store = $this->currentStore($request); $search = trim((string) $request->query('q', '')); - $storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id'); - $categories = WooCategory::query() - ->whereIn('woo_store_id', $storeIds) - ->when($storeFilter, fn ($q) => $q->where('woo_store_id', $storeFilter)) + ->when($store, fn ($q) => $q->where('woo_store_id', $store->id)) + ->when(! $store, fn ($q) => $q->whereRaw('1 = 0')) ->when($search !== '', fn ($q) => $q->where('name', 'like', '%'.$search.'%')) ->with('store') ->orderBy('name') ->paginate(30) ->withQueryString(); - $stores = WooStore::query()->where('user_id', $user->id)->orderBy('site_name')->get(); - - return view('woo.categories.index', compact('categories', 'stores', 'storeFilter', 'search')); + return view('woo.categories.index', compact('categories', 'search', 'store')); } public function create(Request $request): View { - $stores = $this->activeStores($request); - $storeId = (int) $request->query('store', $stores->first()?->id ?? 0); - $parentCategories = $storeId > 0 - ? WooCategory::query()->where('woo_store_id', $storeId)->orderBy('name')->get() - : collect(); + $store = $this->currentStoreOrFail($request); return view('woo.categories.form', [ - 'category' => new WooCategory, - 'stores' => $stores, - 'parentCategories' => $parentCategories, - 'selectedStoreId' => $storeId, + 'category' => new WooCategory(['woo_store_id' => $store->id]), + 'currentStore' => $store, + 'parentCategories' => $store->categories()->orderBy('name')->get(), ]); } public function store(Request $request): RedirectResponse { - $validated = $this->validateCategory($request); - $store = $this->authorizedStore($request, (int) $validated['woo_store_id']); + $store = $this->currentStoreOrFail($request); + $validated = $this->validateCategory($request, $store); $this->write->createCategory($store, $validated); - return redirect()->route('woo.categories.index', ['store' => $store->id]) + return redirect()->route('woo.categories.index') ->with('success', 'Category saved.'); } public function edit(Request $request, WooCategory $category): View { - $category->load('store'); - abort_if($category->store?->user_id !== $request->user()->id, 403); - - $stores = $this->activeStores($request); - $parentCategories = $category->store - ? $category->store->categories() - ->where('external_id', '!=', $category->external_id) - ->orderBy('name') - ->get() - : collect(); + $store = $this->currentStoreOrFail($request); + abort_if($category->woo_store_id !== $store->id, 404); return view('woo.categories.form', [ 'category' => $category, - 'stores' => $stores, - 'parentCategories' => $parentCategories, - 'selectedStoreId' => $category->woo_store_id, + 'currentStore' => $store, + 'parentCategories' => $store->categories() + ->where('external_id', '!=', $category->external_id) + ->orderBy('name') + ->get(), ]); } public function update(Request $request, WooCategory $category): RedirectResponse { - $category->load('store'); - abort_if($category->store?->user_id !== $request->user()->id, 403); + $store = $this->currentStoreOrFail($request); + abort_if($category->woo_store_id !== $store->id, 404); - $validated = $this->validateCategory($request); + $validated = $this->validateCategory($request, $store); $this->write->updateCategory($category, $validated); - return redirect()->route('woo.categories.index', ['store' => $category->woo_store_id]) + return redirect()->route('woo.categories.index') ->with('success', 'Category updated.'); } public function destroy(Request $request, WooCategory $category): RedirectResponse { - $category->load('store'); - abort_if($category->store?->user_id !== $request->user()->id, 403); + $store = $this->currentStoreOrFail($request); + abort_if($category->woo_store_id !== $store->id, 404); - $storeId = $category->woo_store_id; $this->write->deleteCategory($category); - return redirect()->route('woo.categories.index', ['store' => $storeId]) + return redirect()->route('woo.categories.index') ->with('success', 'Category removed.'); } public function sync(Request $request): RedirectResponse { - $validated = $request->validate([ - 'store' => ['required', 'integer'], - ]); - - $store = $this->authorizedStore($request, (int) $validated['store']); + $store = $this->currentStoreOrFail($request); $count = $this->sync->syncCategories($store); return back()->with('success', sprintf( @@ -129,33 +110,15 @@ class CategoryController extends Controller } /** @return array */ - private function validateCategory(Request $request): array + private function validateCategory(Request $request, $store): array { - return $request->validate([ - 'woo_store_id' => ['required', 'integer'], + return array_merge($request->validate([ 'name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:10000'], 'parent_external_id' => ['nullable', 'integer', 'min:0'], + ]), [ + 'woo_store_id' => $store->id, ]); } - - private function authorizedStore(Request $request, int $storeId): WooStore - { - return WooStore::query() - ->where('user_id', $request->user()->id) - ->whereKey($storeId) - ->where('status', WooStore::STATUS_ACTIVE) - ->firstOrFail(); - } - - /** @return \Illuminate\Support\Collection */ - private function activeStores(Request $request) - { - return WooStore::query() - ->where('user_id', $request->user()->id) - ->where('status', WooStore::STATUS_ACTIVE) - ->orderBy('site_name') - ->get(); - } } diff --git a/app/Http/Controllers/Woo/Concerns/ResolvesWooContext.php b/app/Http/Controllers/Woo/Concerns/ResolvesWooContext.php new file mode 100644 index 0000000..86ca774 --- /dev/null +++ b/app/Http/Controllers/Woo/Concerns/ResolvesWooContext.php @@ -0,0 +1,38 @@ +user(); + } + + protected function currentStore(Request $request): ?WooStore + { + return app(CurrentWooStore::class)->resolve($this->accountUser($request)); + } + + protected function currentStoreOrFail(Request $request): WooStore + { + $store = $this->currentStore($request); + abort_if(! $store, 404, 'Connect a WooCommerce store to continue.'); + + return $store; + } + + protected function authorizedStore(Request $request, int $storeId): WooStore + { + return WooStore::query() + ->where('user_id', $this->accountUser($request)->id) + ->whereKey($storeId) + ->where('status', WooStore::STATUS_ACTIVE) + ->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Woo/ConnectWordPressController.php b/app/Http/Controllers/Woo/ConnectWordPressController.php index 9af23ec..5162901 100644 --- a/app/Http/Controllers/Woo/ConnectWordPressController.php +++ b/app/Http/Controllers/Woo/ConnectWordPressController.php @@ -3,16 +3,24 @@ namespace App\Http\Controllers\Woo; use App\Http\Controllers\Controller; +use App\Http\Controllers\Woo\Concerns\ResolvesWooContext; +use App\Http\Controllers\Woo\Concerns\ResolvesWooContext; use App\Models\WooStore; use App\Services\Woo\InstallTokenService; +use App\Services\Woo\SubscriptionService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Str; +use App\Support\CurrentWooStore; use Illuminate\View\View; class ConnectWordPressController extends Controller { - public function __construct(private InstallTokenService $tokens) {} + use ResolvesWooContext; + + public function __construct( + private InstallTokenService $tokens, + private SubscriptionService $subscriptions, + ) {} public function start(Request $request): RedirectResponse|View { @@ -56,6 +64,11 @@ class ConnectWordPressController extends Controller $siteUrl = (string) ($pending['site_url'] ?? ''); $returnUrl = (string) ($pending['return_url'] ?? ''); + if (! $this->subscriptions->canConnectStore($user, $siteUrl)) { + return redirect()->route('woo.pro.index') + ->with('upsell', 'Free plan includes '.(int) config('woo.free.max_stores', 1).' store. Upgrade to Pro to connect more WooCommerce sites.'); + } + $store = WooStore::query()->updateOrCreate( [ 'user_id' => $user->id, @@ -71,6 +84,8 @@ class ConnectWordPressController extends Controller $issued = $this->tokens->issue($store); $separator = str_contains($returnUrl, '?') ? '&' : '?'; + session([CurrentWooStore::SESSION_KEY => $store->id]); + return redirect()->away($returnUrl.$separator.http_build_query([ 'ladill_connect' => '1', 'install_token' => $issued['token'], diff --git a/app/Http/Controllers/Woo/DashboardController.php b/app/Http/Controllers/Woo/DashboardController.php index 5ca8bd6..3eb4edc 100644 --- a/app/Http/Controllers/Woo/DashboardController.php +++ b/app/Http/Controllers/Woo/DashboardController.php @@ -3,38 +3,64 @@ namespace App\Http\Controllers\Woo; use App\Http\Controllers\Controller; +use App\Http\Controllers\Woo\Concerns\ResolvesWooContext; +use App\Models\WooCategory; use App\Models\WooOrder; use App\Models\WooProduct; -use App\Models\WooCategory; use App\Models\WooStore; +use App\Services\Woo\SubscriptionService; use Illuminate\Http\Request; use Illuminate\View\View; class DashboardController extends Controller { + use ResolvesWooContext; + + public function __construct(private SubscriptionService $subscriptions) {} + public function index(Request $request): View { - $user = $request->user(); - $storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id'); + $user = $this->accountUser($request); + $store = $this->currentStore($request); + $storeId = $store?->id; $stats = [ 'stores' => WooStore::query()->where('user_id', $user->id)->where('status', WooStore::STATUS_ACTIVE)->count(), - 'orders_new' => WooOrder::query()->whereIn('woo_store_id', $storeIds)->where('fulfillment_status', WooOrder::FULFILLMENT_NEW)->count(), - 'orders_open' => WooOrder::query()->whereIn('woo_store_id', $storeIds)->whereNotIn('fulfillment_status', [ - WooOrder::FULFILLMENT_DELIVERED, - WooOrder::FULFILLMENT_CANCELLED, - ])->count(), - 'products' => WooProduct::query()->whereIn('woo_store_id', $storeIds)->count(), - 'categories' => WooCategory::query()->whereIn('woo_store_id', $storeIds)->count(), + 'orders_new' => $storeId + ? WooOrder::query()->where('woo_store_id', $storeId)->where('fulfillment_status', WooOrder::FULFILLMENT_NEW)->count() + : 0, + 'orders_open' => $storeId + ? WooOrder::query()->where('woo_store_id', $storeId)->whereNotIn('fulfillment_status', [ + WooOrder::FULFILLMENT_DELIVERED, + WooOrder::FULFILLMENT_CANCELLED, + ])->count() + : 0, + 'products' => $storeId + ? WooProduct::query()->where('woo_store_id', $storeId)->count() + : 0, + 'categories' => $storeId + ? WooCategory::query()->where('woo_store_id', $storeId)->count() + : 0, ]; - $recent = WooOrder::query() - ->whereIn('woo_store_id', $storeIds) - ->with('store') - ->latest('created_at') - ->limit(8) - ->get(); + $recent = $storeId + ? WooOrder::query() + ->where('woo_store_id', $storeId) + ->with('store') + ->latest('created_at') + ->limit(8) + ->get() + : collect(); - return view('woo.dashboard', compact('stats', 'recent')); + return view('woo.dashboard', [ + 'stats' => $stats, + 'recent' => $recent, + 'currentStore' => $store, + 'hasPaidPlan' => $this->subscriptions->hasPaidPlan($user), + 'productCount' => $this->subscriptions->productCount($user), + 'productLimit' => $this->subscriptions->isPro($user) ? null : (int) config('woo.free.max_products', 20), + 'storeCount' => $this->subscriptions->connectedStoreCount($user), + 'storeLimit' => $this->subscriptions->isPro($user) ? null : (int) config('woo.free.max_stores', 1), + ]); } } diff --git a/app/Http/Controllers/Woo/OrderController.php b/app/Http/Controllers/Woo/OrderController.php index 53ee8c8..3cd99e6 100644 --- a/app/Http/Controllers/Woo/OrderController.php +++ b/app/Http/Controllers/Woo/OrderController.php @@ -3,8 +3,8 @@ namespace App\Http\Controllers\Woo; use App\Http\Controllers\Controller; +use App\Http\Controllers\Woo\Concerns\ResolvesWooContext; use App\Models\WooOrder; -use App\Models\WooStore; use App\Services\Woo\FulfillmentSyncService; use App\Services\Woo\OrderSyncService; use Illuminate\Http\RedirectResponse; @@ -14,6 +14,8 @@ use Illuminate\View\View; class OrderController extends Controller { + use ResolvesWooContext; + public function __construct( private FulfillmentSyncService $sync, private OrderSyncService $orderSync, @@ -21,15 +23,12 @@ class OrderController extends Controller public function index(Request $request): View { - $user = $request->user(); + $store = $this->currentStore($request); $search = trim((string) $request->query('q', '')); - $storeFilter = $request->query('store'); - - $storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id'); $orders = WooOrder::query() - ->whereIn('woo_store_id', $storeIds) - ->when($storeFilter, fn ($q) => $q->where('woo_store_id', $storeFilter)) + ->when($store, fn ($q) => $q->where('woo_store_id', $store->id)) + ->when(! $store, fn ($q) => $q->whereRaw('1 = 0')) ->when($search !== '', function ($query) use ($search) { $like = '%'.$search.'%'; $query->where(function ($inner) use ($like) { @@ -43,15 +42,13 @@ class OrderController extends Controller ->paginate(20) ->withQueryString(); - $stores = WooStore::query()->where('user_id', $user->id)->orderBy('site_name')->get(); - - return view('woo.orders.index', compact('orders', 'stores', 'search', 'storeFilter')); + return view('woo.orders.index', compact('orders', 'search', 'store')); } public function updateStatus(Request $request, WooOrder $order): RedirectResponse { - $order->load('store'); - abort_if($order->store?->user_id !== $request->user()->id, 403); + $store = $this->currentStoreOrFail($request); + abort_if($order->woo_store_id !== $store->id, 404); $validated = $request->validate([ 'fulfillment_status' => ['required', Rule::in(array_keys(WooOrder::FULFILLMENT_STATUSES))], @@ -65,16 +62,7 @@ class OrderController extends Controller public function sync(Request $request): RedirectResponse { - $validated = $request->validate([ - 'store' => ['required', 'integer'], - ]); - - $store = WooStore::query() - ->where('user_id', $request->user()->id) - ->whereKey((int) $validated['store']) - ->where('status', WooStore::STATUS_ACTIVE) - ->firstOrFail(); - + $store = $this->currentStoreOrFail($request); $count = $this->orderSync->syncOrders($store); return back()->with('success', sprintf( diff --git a/app/Http/Controllers/Woo/ProController.php b/app/Http/Controllers/Woo/ProController.php new file mode 100644 index 0000000..8a984ae --- /dev/null +++ b/app/Http/Controllers/Woo/ProController.php @@ -0,0 +1,144 @@ +accountUser($request); + $planKey = $this->subscriptions->planKey($user); + + return view('woo.pro.index', [ + 'subscription' => $this->subscriptions->subscriptionFor($user), + 'planKey' => $planKey, + 'isPro' => $planKey === ProSubscription::PLAN_PRO, + 'isEnterprise' => $planKey === ProSubscription::PLAN_ENTERPRISE, + 'hasPaidPlan' => $this->subscriptions->hasPaidPlan($user), + 'gatingActive' => $this->subscriptions->gatingActive(), + 'proPriceMinor' => $this->subscriptions->priceMinor(), + 'enterprisePriceMinor' => $this->subscriptions->enterprisePriceMinor(), + 'prepaidMonths' => (array) config('woo.prepaid_months', [6, 12, 24]), + 'currency' => (string) config('woo.pro.currency', 'GHS'), + 'storeCount' => $this->subscriptions->connectedStoreCount($user), + 'storeLimit' => $this->subscriptions->maxStores($user), + 'productCount' => $this->subscriptions->productCount($user), + 'productLimit' => min($this->subscriptions->maxProducts($user), (int) config('woo.free.max_products', 20)), + ]); + } + + public function subscribe(Request $request): RedirectResponse + { + [$ok, $message] = $this->subscriptions->subscribe($this->accountUser($request)); + + return redirect()->route('woo.pro.index')->with($ok ? 'success' : 'error', $message); + } + + public function subscribeEnterprise(Request $request): RedirectResponse + { + [$ok, $message] = $this->subscriptions->subscribeEnterprise($this->accountUser($request)); + + return redirect()->route('woo.pro.index')->with($ok ? 'success' : 'error', $message); + } + + public function subscribePrepaid(Request $request, BillingClient $billing): RedirectResponse + { + $validated = $request->validate([ + 'plan' => ['required', 'in:pro,enterprise'], + 'months' => ['required', 'integer', 'in:6,12,24'], + ]); + + $user = $this->accountUser($request); + if ($this->subscriptions->hasPaidPlan($user)) { + return redirect()->route('woo.pro.index') + ->with('success', 'You already have an active subscription.'); + } + + $plan = $validated['plan']; + $months = (int) $validated['months']; + $monthlyMinor = $plan === 'enterprise' + ? $this->subscriptions->enterprisePriceMinor() + : $this->subscriptions->priceMinor(); + + try { + $checkout = $billing->initiatePlanCheckout( + $user, + $plan, + $months, + $monthlyMinor * $months, + route('woo.pro.paystack.callback'), + ['user_id' => $user->id], + ); + } catch (\Throwable) { + return redirect()->route('woo.pro.index') + ->with('error', 'Could not start Paystack checkout. Please try again.'); + } + + $url = trim((string) ($checkout['checkout_url'] ?? '')); + if ($url === '') { + return redirect()->route('woo.pro.index') + ->with('error', 'Paystack did not return a checkout URL.'); + } + + return redirect()->away($url); + } + + public function paystackCallback(Request $request, BillingClient $billing): RedirectResponse + { + $reference = (string) $request->query('reference', ''); + if ($reference === '') { + return redirect()->route('woo.pro.index')->with('error', 'Missing payment reference.'); + } + + try { + $result = $billing->verifyPlanCheckout($reference); + } catch (\Throwable) { + return redirect()->route('woo.pro.index') + ->with('error', 'Could not verify payment. Contact support with reference: '.$reference); + } + + if (! ($result['paid'] ?? false)) { + return redirect()->route('woo.pro.index') + ->with('error', 'Payment was not completed.'); + } + + $metadata = (array) ($result['metadata'] ?? []); + $user = User::query()->find((int) ($metadata['user_id'] ?? 0)); + $account = $this->accountUser($request); + if (! $user || ! $account || $user->id !== $account->id) { + return redirect()->route('woo.pro.index') + ->with('error', 'Account not found for this payment.'); + } + + $plan = (string) ($result['plan'] ?? 'pro'); + $months = (int) ($result['months'] ?? 0); + $this->subscriptions->activatePrepaid($user, $plan, $months); + + $label = $plan === 'enterprise' ? 'Woo Manager Business' : 'Woo Manager Pro'; + + return redirect()->route('woo.pro.index') + ->with('success', "{$label} is active for {$months} months."); + } + + public function cancel(Request $request): RedirectResponse + { + [$ok, $message] = $this->subscriptions->cancel($this->accountUser($request)); + + return redirect()->route('woo.pro.index')->with($ok ? 'success' : 'error', $message); + } +} diff --git a/app/Http/Controllers/Woo/ProductController.php b/app/Http/Controllers/Woo/ProductController.php index ac3d51d..14e6d00 100644 --- a/app/Http/Controllers/Woo/ProductController.php +++ b/app/Http/Controllers/Woo/ProductController.php @@ -3,11 +3,12 @@ namespace App\Http\Controllers\Woo; use App\Http\Controllers\Controller; +use App\Http\Controllers\Woo\Concerns\ResolvesWooContext; use App\Models\WooProduct; -use App\Models\WooStore; use App\Services\Woo\CatalogSyncService; use App\Services\Woo\CatalogWriteService; use App\Services\Woo\ProductMediaService; +use App\Services\Woo\SubscriptionService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -15,24 +16,25 @@ use Illuminate\View\View; class ProductController extends Controller { + use ResolvesWooContext; + public function __construct( private CatalogWriteService $write, private CatalogSyncService $sync, private ProductMediaService $media, + private SubscriptionService $subscriptions, ) {} public function index(Request $request): View { - $user = $request->user(); + $user = $this->accountUser($request); + $store = $this->currentStore($request); $search = trim((string) $request->query('q', '')); - $storeFilter = $request->query('store'); $statusFilter = $request->query('status'); - $storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id'); - $products = WooProduct::query() - ->whereIn('woo_store_id', $storeIds) - ->when($storeFilter, fn ($q) => $q->where('woo_store_id', $storeFilter)) + ->when($store, fn ($q) => $q->where('woo_store_id', $store->id)) + ->when(! $store, fn ($q) => $q->whereRaw('1 = 0')) ->when($statusFilter, fn ($q) => $q->where('status', $statusFilter)) ->when($search !== '', function ($query) use ($search) { $like = '%'.$search.'%'; @@ -46,82 +48,101 @@ class ProductController extends Controller ->paginate(20) ->withQueryString(); - $stores = WooStore::query()->where('user_id', $user->id)->orderBy('site_name')->get(); - - return view('woo.products.index', compact('products', 'stores', 'search', 'storeFilter', 'statusFilter')); + return view('woo.products.index', [ + 'products' => $products, + 'currentStore' => $store, + 'search' => $search, + 'statusFilter' => $statusFilter, + 'productCount' => $this->subscriptions->productCount($user), + 'productLimit' => $this->subscriptions->isPro($user) ? null : (int) config('woo.free.max_products', 20), + ]); } - public function create(Request $request): View + public function create(Request $request): View|RedirectResponse { - $stores = $this->activeStores($request); + $user = $this->accountUser($request); + $store = $this->currentStoreOrFail($request); + + if (! $this->subscriptions->canCreateProduct($user)) { + return redirect()->route('woo.pro.index') + ->with('upsell', 'You have reached the free plan limit of '.(int) config('woo.free.max_products', 20).' products. Upgrade to Pro for unlimited products.'); + } return view('woo.products.form', [ - 'product' => new WooProduct(['status' => WooProduct::STATUS_DRAFT]), - 'stores' => $stores, - 'categories' => collect(), + 'product' => new WooProduct(['status' => WooProduct::STATUS_DRAFT, 'woo_store_id' => $store->id]), + 'currentStore' => $store, + 'categories' => $store->categories()->orderBy('name')->get(), ]); } public function store(Request $request): RedirectResponse { - $validated = $this->validateProduct($request); - $store = $this->authorizedStore($request, (int) $validated['woo_store_id']); + $user = $this->accountUser($request); + $store = $this->currentStoreOrFail($request); + + if (! $this->subscriptions->canCreateProduct($user)) { + return redirect()->route('woo.pro.index') + ->with('upsell', 'You have reached the free plan limit of '.(int) config('woo.free.max_products', 20).' products. Upgrade to Pro for unlimited products.'); + } + + $validated = $this->validateProduct($request, $store); $validated['images'] = $this->media->buildImagesFromRequest($request, $store); $this->write->createProduct($store, $validated); - return redirect()->route('woo.products.index', ['store' => $store->id]) + return redirect()->route('woo.products.index') ->with('success', 'Product saved.'); } public function edit(Request $request, WooProduct $product): View { - $product->load('store'); - abort_if($product->store?->user_id !== $request->user()->id, 403); + $store = $this->currentStoreOrFail($request); + abort_if($product->woo_store_id !== $store->id, 404); - $stores = $this->activeStores($request); - $categories = $product->store - ? $product->store->categories()->orderBy('name')->get() - : collect(); - - return view('woo.products.form', compact('product', 'stores', 'categories')); + return view('woo.products.form', [ + 'product' => $product, + 'currentStore' => $store, + 'categories' => $store->categories()->orderBy('name')->get(), + ]); } public function update(Request $request, WooProduct $product): RedirectResponse { - $product->load('store'); - abort_if($product->store?->user_id !== $request->user()->id, 403); + $store = $this->currentStoreOrFail($request); + abort_if($product->woo_store_id !== $store->id, 404); - $validated = $this->validateProduct($request, $product); - $validated['images'] = $this->media->buildImagesFromRequest($request, $product->store); + $validated = $this->validateProduct($request, $store, $product); + $validated['images'] = $this->media->buildImagesFromRequest($request, $store); $this->write->updateProduct($product, $validated); - return redirect()->route('woo.products.index', ['store' => $product->woo_store_id]) + return redirect()->route('woo.products.index') ->with('success', 'Product updated.'); } public function sync(Request $request): RedirectResponse { - $validated = $request->validate([ - 'store' => ['required', 'integer'], - ]); + $store = $this->currentStoreOrFail($request); + $user = $this->accountUser($request); + $counts = $this->sync->syncAll($store, $user); - $store = $this->authorizedStore($request, (int) $validated['store']); - $counts = $this->sync->syncAll($store); - - return back()->with('success', sprintf( + $message = sprintf( 'Synced %d categories and %d products from %s.', $counts['categories'], $counts['products'], $store->site_name ?? $store->site_url, - )); + ); + + if (! $this->subscriptions->isPro($user) && $this->subscriptions->productCount($user) >= (int) config('woo.free.max_products', 20)) { + $message .= ' Free plan is limited to '.(int) config('woo.free.max_products', 20).' products — upgrade to Pro to sync your full catalog.'; + } + + return back()->with('success', $message); } /** @return array */ - private function validateProduct(Request $request, ?WooProduct $product = null): array + private function validateProduct(Request $request, $store, ?WooProduct $product = null): array { - return $request->validate([ - 'woo_store_id' => ['required', 'integer'], + return array_merge($request->validate([ 'name' => ['required', 'string', 'max:255'], 'sku' => ['nullable', 'string', 'max:120'], 'status' => ['required', Rule::in(array_keys(WooProduct::STATUSES))], @@ -141,25 +162,8 @@ class ProductController extends Controller 'gallery_uploads.*' => ['image', 'max:10240'], 'gallery_image_srcs' => ['nullable', 'array'], 'gallery_image_srcs.*' => ['url', 'max:2000'], + ]), [ + 'woo_store_id' => $store->id, ]); } - - private function authorizedStore(Request $request, int $storeId): WooStore - { - return WooStore::query() - ->where('user_id', $request->user()->id) - ->whereKey($storeId) - ->where('status', WooStore::STATUS_ACTIVE) - ->firstOrFail(); - } - - /** @return \Illuminate\Support\Collection */ - private function activeStores(Request $request) - { - return WooStore::query() - ->where('user_id', $request->user()->id) - ->where('status', WooStore::STATUS_ACTIVE) - ->orderBy('site_name') - ->get(); - } } diff --git a/app/Http/Controllers/Woo/StoreController.php b/app/Http/Controllers/Woo/StoreController.php index beafd43..1b04c42 100644 --- a/app/Http/Controllers/Woo/StoreController.php +++ b/app/Http/Controllers/Woo/StoreController.php @@ -3,29 +3,51 @@ namespace App\Http\Controllers\Woo; use App\Http\Controllers\Controller; +use App\Http\Controllers\Woo\Concerns\ResolvesWooContext; use App\Models\WooStore; +use App\Services\Woo\SubscriptionService; +use App\Support\CurrentWooStore; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; class StoreController extends Controller { + use ResolvesWooContext; + + public function __construct( + private SubscriptionService $subscriptions, + private CurrentWooStore $currentStore, + ) {} + public function index(Request $request): View { + $user = $this->accountUser($request); $stores = WooStore::query() - ->where('user_id', $request->user()->id) + ->where('user_id', $user->id) ->latest('updated_at') ->get(); - return view('woo.stores.index', compact('stores')); + return view('woo.stores.index', [ + 'stores' => $stores, + 'currentStore' => $this->currentStore->resolve($user), + 'hasPaidPlan' => $this->subscriptions->hasPaidPlan($user), + 'storeCount' => $this->subscriptions->connectedStoreCount($user), + 'storeLimit' => $this->subscriptions->maxStores($user), + 'canConnectMore' => $this->subscriptions->canConnectStore($user), + ]); } public function destroy(Request $request, WooStore $store): RedirectResponse { - abort_if($store->user_id !== $request->user()->id, 403); + abort_if($store->user_id !== $this->accountUser($request)->id, 403); $store->update(['status' => WooStore::STATUS_DISCONNECTED]); + if ((int) session(CurrentWooStore::SESSION_KEY) === $store->id) { + session()->forget(CurrentWooStore::SESSION_KEY); + } + return back()->with('success', 'Store disconnected. Install the Ladill plugin again to reconnect.'); } } diff --git a/app/Http/Controllers/Woo/StoreSwitchController.php b/app/Http/Controllers/Woo/StoreSwitchController.php new file mode 100644 index 0000000..df699e4 --- /dev/null +++ b/app/Http/Controllers/Woo/StoreSwitchController.php @@ -0,0 +1,25 @@ +validate([ + 'store' => ['required', 'integer'], + ]); + + $stores->switch($this->accountUser($request), (int) $validated['store']); + + return redirect()->back(); + } +} diff --git a/app/Models/Woo/ProSubscription.php b/app/Models/Woo/ProSubscription.php new file mode 100644 index 0000000..b7f55d3 --- /dev/null +++ b/app/Models/Woo/ProSubscription.php @@ -0,0 +1,49 @@ + '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 70a18d2..b86a83f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,10 @@ namespace App\Providers; +use App\Services\Woo\SubscriptionService; +use App\Support\CurrentWooStore; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Facades\View; class AppServiceProvider extends ServiceProvider { @@ -13,6 +16,16 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { - // + View::composer(['partials.sidebar', 'partials.topbar'], function ($view) { + $account = ladill_account() ?? auth()->user(); + $svc = app(SubscriptionService::class); + $stores = app(CurrentWooStore::class); + + $view->with('isPro', $account ? $svc->isPro($account) : false); + $view->with('hasPaidPlan', $account ? $svc->hasPaidPlan($account) : false); + $view->with('isEnterprise', $account ? $svc->isEnterprise($account) : false); + $view->with('wooActiveStores', $account ? $stores->activeStores($account) : collect()); + $view->with('currentWooStore', $account ? $stores->resolve($account) : null); + }); } } diff --git a/app/Services/Woo/BillingClient.php b/app/Services/Woo/BillingClient.php new file mode 100644 index 0000000..dccdb3b --- /dev/null +++ b/app/Services/Woo/BillingClient.php @@ -0,0 +1,100 @@ +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' => (string) config('billing.service', 'woo'), + 'source' => 'subscription', + 'reference' => $reference, + 'description' => $description, + ]); + } catch (\Throwable $e) { + Log::warning('Woo 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]; + } + + /** + * @param array $metadata + * @return array{checkout_url: string, reference: string} + */ + public function initiatePlanCheckout( + User $user, + string $plan, + int $months, + int $amountMinor, + string $returnUrl, + array $metadata = [], + ): array { + $res = Http::withToken((string) config('billing.api_key')) + ->acceptJson()->timeout(15) + ->post(rtrim((string) config('billing.api_url'), '/').'/plan-checkout', [ + 'user' => $user->public_id, + 'app' => (string) config('billing.service', 'woo'), + 'plan' => $plan, + 'months' => $months, + 'amount_minor' => $amountMinor, + 'return_url' => $returnUrl, + 'metadata' => $metadata, + ]); + $res->throw(); + + return [ + 'checkout_url' => (string) $res->json('checkout_url'), + 'reference' => (string) $res->json('reference'), + ]; + } + + /** @return array */ + public function verifyPlanCheckout(string $reference): array + { + $res = Http::withToken((string) config('billing.api_key')) + ->acceptJson()->timeout(15) + ->post(rtrim((string) config('billing.api_url'), '/').'/plan-checkout/verify', [ + 'reference' => $reference, + ]); + + if ($res->status() === 422) { + return ['paid' => false, 'error' => $res->json('error')]; + } + $res->throw(); + + return $res->json(); + } +} diff --git a/app/Services/Woo/CatalogSyncService.php b/app/Services/Woo/CatalogSyncService.php index d9c0bd6..38449fd 100644 --- a/app/Services/Woo/CatalogSyncService.php +++ b/app/Services/Woo/CatalogSyncService.php @@ -2,6 +2,7 @@ namespace App\Services\Woo; +use App\Models\User; use App\Models\WooStore; class CatalogSyncService @@ -10,13 +11,14 @@ class CatalogSyncService private PluginClient $client, private ProductIngestService $products, private CategoryIngestService $categories, + private SubscriptionService $subscriptions, ) {} - public function syncAll(WooStore $store): array + public function syncAll(WooStore $store, ?User $user = null): array { return [ 'categories' => $this->syncCategories($store), - 'products' => $this->syncProducts($store), + 'products' => $this->syncProducts($store, $user), ]; } @@ -44,7 +46,7 @@ class CatalogSyncService return $count; } - public function syncProducts(WooStore $store): int + public function syncProducts(WooStore $store, ?User $user = null): int { $response = $this->client->get($store, 'products', ['per_page' => 100]); if (! is_array($response)) { @@ -61,6 +63,12 @@ class CatalogSyncService if (! is_array($item)) { continue; } + + $externalId = (int) ($item['id'] ?? 0); + if ($user && ! $this->subscriptions->canIngestProduct($user, $store, $externalId)) { + continue; + } + $this->products->ingest($store, $item); $count++; } diff --git a/app/Services/Woo/StoreSyncService.php b/app/Services/Woo/StoreSyncService.php index c3953bf..40e236c 100644 --- a/app/Services/Woo/StoreSyncService.php +++ b/app/Services/Woo/StoreSyncService.php @@ -22,7 +22,7 @@ class StoreSyncService ]; try { - $catalogCounts = $this->catalog->syncAll($store); + $catalogCounts = $this->catalog->syncAll($store, $store->user); $counts['categories'] = $catalogCounts['categories']; $counts['products'] = $catalogCounts['products']; } catch (\Throwable $e) { diff --git a/app/Services/Woo/SubscriptionService.php b/app/Services/Woo/SubscriptionService.php new file mode 100644 index 0000000..40751ba --- /dev/null +++ b/app/Services/Woo/SubscriptionService.php @@ -0,0 +1,290 @@ +billing->configured(); + } + + public function priceMinor(): int + { + return (int) config('woo.plans.pro.price_minor', config('woo.pro.price_minor', 4900)); + } + + public function enterprisePriceMinor(): int + { + return (int) config('woo.plans.enterprise.price_minor', 14900); + } + + public function subscriptionFor(User $user): ?ProSubscription + { + return ProSubscription::where('user_id', $user->id)->first(); + } + + public function planKey(User $user): string + { + if (! $this->gatingActive()) { + return ProSubscription::PLAN_PRO; + } + + $sub = $this->subscriptionFor($user); + if (! $sub || ! $sub->entitled()) { + return 'free'; + } + + return $sub->plan === ProSubscription::PLAN_ENTERPRISE + ? ProSubscription::PLAN_ENTERPRISE + : ProSubscription::PLAN_PRO; + } + + public function hasPaidPlan(User $user): bool + { + if (! $this->gatingActive()) { + return true; + } + + return (bool) $this->subscriptionFor($user)?->entitled(); + } + + public function isEnterprise(User $user): bool + { + return $this->planKey($user) === ProSubscription::PLAN_ENTERPRISE; + } + + public function isPro(User $user): bool + { + return $this->hasPaidPlan($user); + } + + public function maxStores(User $user): int + { + return $this->isPro($user) ? PHP_INT_MAX : (int) config('woo.free.max_stores', 1); + } + + public function maxProducts(User $user): int + { + return $this->isPro($user) ? PHP_INT_MAX : (int) config('woo.free.max_products', 20); + } + + public function connectedStoreCount(User $user): int + { + return WooStore::query() + ->where('user_id', $user->id) + ->whereIn('status', [WooStore::STATUS_ACTIVE, WooStore::STATUS_PENDING]) + ->count(); + } + + public function productCount(User $user): int + { + $storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id'); + + return WooProduct::query()->whereIn('woo_store_id', $storeIds)->count(); + } + + public function canConnectStore(User $user, ?string $siteUrl = null): bool + { + if ($this->isPro($user)) { + return true; + } + + if ($siteUrl !== null) { + $existing = WooStore::query() + ->where('user_id', $user->id) + ->where('site_url', $siteUrl) + ->whereIn('status', [WooStore::STATUS_ACTIVE, WooStore::STATUS_PENDING]) + ->exists(); + + if ($existing) { + return true; + } + } + + return $this->connectedStoreCount($user) < $this->maxStores($user); + } + + public function canCreateProduct(User $user): bool + { + return $this->productCount($user) < $this->maxProducts($user); + } + + public function canIngestProduct(User $user, WooStore $store, int $externalId): bool + { + if ($this->isPro($user)) { + return true; + } + + $exists = WooProduct::query() + ->where('woo_store_id', $store->id) + ->where('external_id', $externalId) + ->exists(); + + if ($exists) { + return true; + } + + return $this->canCreateProduct($user); + } + + /** @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 already have an active subscription.']; + } + + if ($existing?->plan === ProSubscription::PLAN_ENTERPRISE) { + return $this->subscribeEnterprise($user); + } + + return $this->activateWalletPlan($user, ProSubscription::PLAN_PRO, $this->priceMinor(), 'Ladill Woo Manager Pro — monthly subscription', 'Welcome to Ladill Woo Manager Pro!'); + } + + /** @return array{0:bool,1:string} */ + public function subscribeEnterprise(User $user): array + { + $existing = $this->subscriptionFor($user); + if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) { + return [true, 'You already have an active subscription.']; + } + + return $this->activateWalletPlan( + $user, + ProSubscription::PLAN_ENTERPRISE, + $this->enterprisePriceMinor(), + 'Ladill Woo Manager Business — monthly subscription', + 'Welcome to Ladill Woo Manager Business!', + ); + } + + public function activatePrepaid(User $user, string $plan, int $months): void + { + $existing = $this->subscriptionFor($user); + $price = $plan === ProSubscription::PLAN_ENTERPRISE + ? $this->enterprisePriceMinor() + : $this->priceMinor(); + + ProSubscription::updateOrCreate( + ['user_id' => $user->id], + [ + 'plan' => $plan, + 'status' => ProSubscription::STATUS_ACTIVE, + 'price_minor' => $price, + 'currency' => (string) config('woo.pro.currency', 'GHS'), + 'auto_renew' => false, + 'started_at' => $existing?->started_at ?? now(), + 'current_period_end' => Carbon::now()->addMonths($months), + 'last_charged_at' => now(), + 'canceled_at' => null, + 'last_reference' => null, + 'last_error' => null, + ], + ); + } + + /** @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 access 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; + } + + $planLabel = $sub->plan === ProSubscription::PLAN_ENTERPRISE ? 'Business' : 'Pro'; + $reference = 'woo-'.$sub->plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6); + $result = $this->billing->charge($user, $sub->price_minor, $reference, "Ladill Woo Manager {$planLabel} — renewal"); + + if ($result['ok']) { + $sub->forceFill([ + 'current_period_end' => Carbon::now()->addDays((int) config('woo.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('woo.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(); + } + } + + /** @return array{0:bool,1:string} */ + private function activateWalletPlan(User $user, string $plan, int $price, string $description, string $successMessage): array + { + $existing = $this->subscriptionFor($user); + $reference = 'woo-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6); + $result = $this->billing->charge($user, $price, $reference, $description); + + if (! $result['ok']) { + if ($result['insufficient']) { + $bal = number_format(((int) $result['balance_minor']) / 100, 2); + + return [false, "Your wallet balance (GHS {$bal}) is too low. Top up, then subscribe."]; + } + + return [false, $result['error'] ?? 'Could not start your subscription. Please try again.']; + } + + $periodEnd = Carbon::now()->addDays((int) config('woo.pro.period_days', 30)); + ProSubscription::updateOrCreate( + ['user_id' => $user->id], + [ + 'plan' => $plan, + 'status' => ProSubscription::STATUS_ACTIVE, + 'price_minor' => $price, + 'currency' => (string) config('woo.pro.currency', 'GHS'), + 'auto_renew' => true, + 'started_at' => $existing?->started_at ?? now(), + 'current_period_end' => $periodEnd, + 'last_charged_at' => now(), + 'canceled_at' => null, + 'last_reference' => $reference, + 'last_error' => null, + ], + ); + + return [true, $successMessage.' Your subscription is active.']; + } +} diff --git a/app/Support/CurrentWooStore.php b/app/Support/CurrentWooStore.php new file mode 100644 index 0000000..8e09f06 --- /dev/null +++ b/app/Support/CurrentWooStore.php @@ -0,0 +1,58 @@ +where('user_id', $user->id) + ->where('status', WooStore::STATUS_ACTIVE) + ->orderBy('site_name') + ->orderBy('site_url') + ->get(); + } + + public function resolve(?User $user): ?WooStore + { + if (! $user) { + return null; + } + + $stores = $this->activeStores($user); + if ($stores->isEmpty()) { + return null; + } + + $selectedId = (int) session(self::SESSION_KEY, 0); + $store = $stores->firstWhere('id', $selectedId); + if ($store) { + return $store; + } + + $first = $stores->first(); + session([self::SESSION_KEY => $first->id]); + + return $first; + } + + public function switch(User $user, int $storeId): WooStore + { + $store = WooStore::query() + ->where('user_id', $user->id) + ->whereKey($storeId) + ->where('status', WooStore::STATUS_ACTIVE) + ->firstOrFail(); + + session([self::SESSION_KEY => $store->id]); + + return $store; + } +} diff --git a/config/woo.php b/config/woo.php index 13323c3..63d3dcc 100644 --- a/config/woo.php +++ b/config/woo.php @@ -14,4 +14,30 @@ return [ 'product_category.updated', 'product_category.deleted', ], + + 'pro' => [ + 'enabled' => filter_var(env('WOO_PRO_ENABLED', true), FILTER_VALIDATE_BOOLEAN), + 'price_minor' => (int) env('WOO_PRO_PRICE_MINOR', env('INVOICE_PRO_PRICE_MINOR', 4900)), + 'currency' => env('WOO_PRO_CURRENCY', 'GHS'), + 'period_days' => (int) env('WOO_PRO_PERIOD_DAYS', 30), + 'grace_days' => (int) env('WOO_PRO_GRACE_DAYS', 3), + ], + + 'plans' => [ + 'pro' => ['price_minor' => (int) env('WOO_PRO_PRICE_MINOR', env('INVOICE_PRO_PRICE_MINOR', 4900))], + 'enterprise' => ['price_minor' => (int) env('WOO_ENTERPRISE_PRICE_MINOR', env('INVOICE_ENTERPRISE_PRICE_MINOR', 14900))], + ], + + 'prepaid_months' => [6, 12, 24], + + 'free' => [ + 'max_stores' => (int) env('WOO_FREE_MAX_STORES', 1), + 'max_products' => (int) env('WOO_FREE_MAX_PRODUCTS', 20), + ], + + 'upgrade_banner' => [ + 'title' => 'Unlock Woo Manager Pro or Business', + 'description' => 'Multiple stores and unlimited products — from GHS 49/mo.', + 'route' => 'woo.pro.index', + ], ]; diff --git a/database/migrations/2026_07_07_010000_create_woo_pro_subscriptions_table.php b/database/migrations/2026_07_07_010000_create_woo_pro_subscriptions_table.php new file mode 100644 index 0000000..a5e70fb --- /dev/null +++ b/database/migrations/2026_07_07_010000_create_woo_pro_subscriptions_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('status', 16)->default('active'); + $table->string('plan', 16)->default('pro'); + $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('woo_pro_subscriptions'); + } +}; diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 6918106..6cffb6c 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -35,5 +35,18 @@ Settings + + @php $proActive = request()->routeIs('woo.pro.*'); @endphp + @if (! empty($hasPaidPlan)) + + + {{ ! empty($isEnterprise) ? 'Woo Business' : 'Woo Pro' }} + + @else + + + Upgrade to Pro + + @endif diff --git a/resources/views/partials/store-switcher.blade.php b/resources/views/partials/store-switcher.blade.php new file mode 100644 index 0000000..1f29215 --- /dev/null +++ b/resources/views/partials/store-switcher.blade.php @@ -0,0 +1,32 @@ +@if(($wooActiveStores ?? collect())->count() > 0) + +@endif diff --git a/resources/views/partials/topbar.blade.php b/resources/views/partials/topbar.blade.php index 99a419a..ad6f4f1 100644 --- a/resources/views/partials/topbar.blade.php +++ b/resources/views/partials/topbar.blade.php @@ -9,6 +9,8 @@ + @include('partials.store-switcher') + {{-- Desktop: search (customers) --}}