Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.00% covered (warning)
72.00%
54 / 75
66.67% covered (warning)
66.67%
10 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
FeatureService
72.00% covered (warning)
72.00%
54 / 75
66.67% covered (warning)
66.67%
10 / 15
89.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 disabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 allEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 all
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 registryByGroup
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isAllowedByEdition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMany
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
9.02
 applyPreset
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 presets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 forget
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 resolved
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 dependencyChain
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 routeIsBlocked
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 menuKeyHidden
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3namespace App\Services\Feature;
4
5use App\Services\Setting\SettingService;
6use App\Services\Stock\LocationInventoryService;
7use Illuminate\Support\Str;
8
9/**
10 * Reads the feature flags. One instance per request.
11 *
12 *   feature('coupons')            // bool
13 *   feature()->enabled('coupons')
14 *   feature()->allEnabled()       // ['inventory_tracking', ...]
15 *
16 * Values live in settings group "features"; the registry + defaults are
17 * config/features.php. A flag also reports false if the licence edition
18 * (Phase 13) does not allow it — for now every edition allows everything.
19 */
20class FeatureService
21{
22    /** @var array<string, bool>|null */
23    private ?array $resolved = null;
24
25    public function __construct(private readonly SettingService $settings) {}
26
27    public function enabled(string $key): bool
28    {
29        return $this->resolved()[$key] ?? false;
30    }
31
32    public function disabled(string $key): bool
33    {
34        return ! $this->enabled($key);
35    }
36
37    /** @return string[] */
38    public function allEnabled(): array
39    {
40        return array_keys(array_filter($this->resolved()));
41    }
42
43    public function all(): array
44    {
45        return $this->resolved();
46    }
47
48    /** The registry, grouped, for the Features settings page. */
49    public function registryByGroup(): array
50    {
51        $out = [];
52        foreach (config('features', []) as $key => $def) {
53            $out[$def['group'] ?? 'Other'][$key] = $def + ['key' => $key, 'enabled' => $this->enabled($key)];
54        }
55
56        return $out;
57    }
58
59    public function isAllowedByEdition(string $key): bool
60    {
61        return (bool) config("features.{$key}.available", true);
62    }
63
64    /**
65     * Persist a set of flags (from the Features form or a preset), resolving
66     * dependencies: turning a flag on turns on everything it depends_on.
67     *
68     * @param  array<string, bool>  $values
69     */
70    public function setMany(array $values): void
71    {
72        $multiWarehouseWasEnabled = $this->enabled('multi_warehouse');
73        $registry = config('features', []);
74        $final = [];
75
76        foreach ($values as $key => $on) {
77            if (! isset($registry[$key])) {
78                continue;
79            }
80            $final[$key] = (bool) $on && $this->isAllowedByEdition($key);
81        }
82
83        // cascade dependencies for anything switched on
84        foreach ($final as $key => $on) {
85            if ($on) {
86                foreach ($this->dependencyChain($key) as $dep) {
87                    $final[$dep] = true;
88                }
89            }
90        }
91
92        $this->settings->setMany('features', $final);
93        $this->resolved = null;
94        if (! $multiWarehouseWasEnabled && $this->enabled('multi_warehouse')) {
95            app(LocationInventoryService::class)->reconcileAllToAggregate();
96        }
97    }
98
99    /**
100     * Apply a preset from config/feature_presets.php: turn its `enable` list
101     * on and every other registered flag off (dependencies still cascade).
102     */
103    public function applyPreset(string $preset): void
104    {
105        $enable = config("feature_presets.{$preset}.enable");
106        abort_if($enable === null, 404, "Unknown feature preset [{$preset}]");
107
108        $values = [];
109        foreach (array_keys(config('features', [])) as $key) {
110            $values[$key] = in_array($key, $enable, true);
111        }
112
113        $this->setMany($values);
114    }
115
116    public function presets(): array
117    {
118        return config('feature_presets', []);
119    }
120
121    public function forget(): void
122    {
123        $this->resolved = null;
124        $this->settings->forget();
125    }
126
127    /* -------------------------------------------------------------------- */
128
129    /** @return array<string, bool> key => enabled (dependencies applied) */
130    private function resolved(): array
131    {
132        if ($this->resolved !== null) {
133            return $this->resolved;
134        }
135
136        $registry = config('features', []);
137        $stored = $this->settings->all('features'); // key => bool
138
139        $map = [];
140        foreach ($registry as $key => $def) {
141            $on = array_key_exists($key, $stored)
142                ? (bool) $stored[$key]
143                : (bool) ($def['default'] ?? false);
144
145            $map[$key] = $on && $this->isAllowedByEdition($key);
146        }
147
148        // a flag is only truly on if its whole dependency chain is on
149        foreach ($map as $key => $on) {
150            if ($on) {
151                foreach ($this->dependencyChain($key) as $dep) {
152                    if (! ($map[$dep] ?? false)) {
153                        $map[$key] = false;
154                        break;
155                    }
156                }
157            }
158        }
159
160        return $this->resolved = $map;
161    }
162
163    /** @return string[] all flags this one (transitively) depends on */
164    private function dependencyChain(string $key, array $seen = []): array
165    {
166        $deps = config("features.{$key}.depends_on", []);
167        foreach ($deps as $dep) {
168            if (! in_array($dep, $seen, true)) {
169                $seen[] = $dep;
170                $seen = array_merge($seen, $this->dependencyChain($dep, $seen));
171            }
172        }
173
174        return array_values(array_unique($seen));
175    }
176
177    /**
178     * Does a route name fall under a disabled feature's `routes` prefixes?
179     * Used by the feature: middleware and menu filtering.
180     */
181    public function routeIsBlocked(?string $routeName): bool
182    {
183        if (! $routeName) {
184            return false;
185        }
186
187        foreach (config('features', []) as $key => $def) {
188            if ($this->enabled($key)) {
189                continue;
190            }
191            foreach ($def['routes'] ?? [] as $prefix) {
192                if (Str::startsWith($routeName, $prefix)) {
193                    return true;
194                }
195            }
196        }
197
198        return false;
199    }
200
201    /** Is a menu key hidden because its feature is off? Accepts "chat.*" style patterns. */
202    public function menuKeyHidden(?string $menuKey): bool
203    {
204        if (! $menuKey) {
205            return false;
206        }
207
208        foreach (config('features', []) as $key => $def) {
209            if ($this->enabled($key)) {
210                continue;
211            }
212            foreach ($def['menu'] ?? [] as $pattern) {
213                if (Str::is($pattern, $menuKey)) {
214                    return true;
215                }
216            }
217        }
218
219        return false;
220    }
221}