diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..263707bb
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,40 @@
+# AGENTS.md — core-api
+
+## Repo purpose
+The foundational Laravel package (`fleetbase/core-api`) providing models, services, abstractions, helpers, and the extension contract used by every Fleetbase backend extension. Imported by `fleetbase/api` via composer (either from Packagist or as a local path).
+
+## What this repo owns
+- `src/Models/` — base models (Organization, User, etc.)
+- `src/Http/Controllers/` — internal controllers used by the console
+- `src/Support/` — `Utils`, `EnvironmentMapper`, `Str` expansions, etc.
+- `src/Expansions/` — Laravel macro registrations
+- `src/Notifications/`, `src/Mail/`, `src/Events/`, `src/Listeners/`
+- The extension service provider contract
+
+## What this repo must not modify
+- Anything that breaks public method signatures of widely-used helpers (`Utils`, `Str` expansions). These are called by every extension.
+- The extension contract — adding required methods is a breaking change for every downstream extension.
+
+## Framework conventions
+- Laravel 10+, PHP 8.0+
+- PSR-4 autoload under `Fleetbase\\`
+- Notifications via Laravel's notification system
+- Eloquent + activity log via `spatie/laravel-activitylog`
+
+## Test / build commands
+- This package is consumed by `fleetbase/api`. To test changes: edit here, then in the application container run `composer update fleetbase/core-api` (requires path repository in `api/composer.json`).
+- `vendor/bin/phpunit`
+
+## Known sharp edges
+- **`Str::domain($url)` at `src/Expansions/Str.php:53`** crashes on hosts with no `.` (e.g. `localhost`). Workaround in `fleetbase/api/.env`: set `MAIL_FROM_ADDRESS`. **If you fix this here, also remove the workaround.**
+- `EnvironmentMapper.php` has dozens of nested AWS/SQS/SES key mappings. Don't refactor without a clear need.
+- `Utils::getDefaultMailFromAddress()` is the caller of the buggy `Str::domain` — start here when fixing the upstream bug.
+
+## Read first
+- `~/fleetbase-project/docs/project-description.md`
+- `~/fleetbase-project/docs/repo-map.md`
+- `~/fleetbase-project/docs/ai-rules-laravel.md`
+- `~/fleetbase-project/docs/ai-rules-workspace.md`
+
+## Boost gate
+This repo IS host-cloned (unlike `fleetbase/api`), so Boost outputs would land in a place future agents can read. Before first edit: `composer require laravel/boost --dev && php artisan boost:install` from a **real terminal** (the installer is interactive and crashes on `docker compose exec -T`). Then commit.
diff --git a/composer.json b/composer.json
index 52dc9673..d7fa9263 100644
--- a/composer.json
+++ b/composer.json
@@ -63,9 +63,10 @@
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.34.1",
"nunomaduro/collision": "^7.0",
+ "orchestra/testbench": "^8.0",
"pestphp/pest": "^2.33.2",
"phpstan/phpstan": "^1.10.38",
- "symfony/var-dumper": "^5.4.29"
+ "symfony/var-dumper": "^5.4.29|^6.2"
},
"autoload": {
"psr-4": {
diff --git a/migrations/2026_04_13_000001_create_document_queue_items_table.php b/migrations/2026_04_13_000001_create_document_queue_items_table.php
new file mode 100644
index 00000000..cc2ad838
--- /dev/null
+++ b/migrations/2026_04_13_000001_create_document_queue_items_table.php
@@ -0,0 +1,48 @@
+increments('id');
+ $table->string('uuid', 191)->nullable()->unique();
+ $table->string('public_id', 191)->nullable()->unique();
+ $table->uuid('company_uuid')->index();
+ $table->char('file_uuid', 36)->nullable();
+
+ $table->string('source', 20)->default('manual'); // manual, email, edi, api
+ $table->string('document_type', 30)->default('unknown'); // carrier_invoice, bol, pod, rate_confirmation, insurance_cert, customs, other, unknown
+ $table->string('status', 20)->default('received');
+ // received, processing, parsed, matched, needs_review, failed
+
+ $table->longText('raw_content')->nullable();
+ $table->json('parsed_data')->nullable();
+
+ $table->char('matched_order_uuid', 36)->nullable()->index();
+ $table->char('matched_shipment_uuid', 36)->nullable()->index();
+ $table->char('matched_carrier_invoice_uuid', 36)->nullable();
+ $table->decimal('match_confidence', 4, 2)->nullable(); // 0.00 to 1.00
+ $table->string('match_method', 30)->nullable(); // pro_number, bol_number, carrier_date, manual
+
+ $table->text('error_message')->nullable();
+ $table->timestamp('processed_at')->nullable();
+
+ $table->json('meta')->nullable();
+ $table->softDeletes();
+ $table->timestamp('created_at')->nullable()->index();
+ $table->timestamp('updated_at')->nullable();
+
+ $table->foreign('company_uuid')->references('uuid')->on('companies');
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('document_queue_items');
+ }
+};
diff --git a/migrations/2026_04_13_100000_add_hierarchy_to_companies_table.php b/migrations/2026_04_13_100000_add_hierarchy_to_companies_table.php
new file mode 100644
index 00000000..a53c161a
--- /dev/null
+++ b/migrations/2026_04_13_100000_add_hierarchy_to_companies_table.php
@@ -0,0 +1,41 @@
+char('parent_company_uuid', 36)->nullable()->index()->after('uuid');
+ $table->enum('company_type', ['platform', 'organization', 'client'])
+ ->default('organization')
+ ->after('parent_company_uuid');
+ $table->boolean('is_client')->default(false)->index()->after('company_type');
+ $table->string('client_code', 50)->nullable()->after('is_client');
+ $table->json('client_settings')->nullable()->after('client_code');
+
+ $table->foreign('parent_company_uuid')
+ ->references('uuid')
+ ->on('companies')
+ ->nullOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('companies', function (Blueprint $table) {
+ $table->dropForeign(['parent_company_uuid']);
+ $table->dropIndex(['parent_company_uuid']);
+ $table->dropIndex(['is_client']);
+ $table->dropColumn([
+ 'parent_company_uuid',
+ 'company_type',
+ 'is_client',
+ 'client_code',
+ 'client_settings',
+ ]);
+ });
+ }
+};
diff --git a/migrations/2026_04_13_100100_add_access_level_and_default_to_company_users_table.php b/migrations/2026_04_13_100100_add_access_level_and_default_to_company_users_table.php
new file mode 100644
index 00000000..082ae065
--- /dev/null
+++ b/migrations/2026_04_13_100100_add_access_level_and_default_to_company_users_table.php
@@ -0,0 +1,25 @@
+enum('access_level', ['full', 'read_only', 'financial', 'operations'])
+ ->default('full')
+ ->after('external');
+ $table->boolean('is_default')->default(false)->index()->after('access_level');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('company_users', function (Blueprint $table) {
+ $table->dropIndex(['is_default']);
+ $table->dropColumn(['access_level', 'is_default']);
+ });
+ }
+};
diff --git a/migrations/2026_04_13_100300_seed_existing_companies_as_organizations.php b/migrations/2026_04_13_100300_seed_existing_companies_as_organizations.php
new file mode 100644
index 00000000..55e7abfc
--- /dev/null
+++ b/migrations/2026_04_13_100300_seed_existing_companies_as_organizations.php
@@ -0,0 +1,70 @@
+whereNull('parent_company_uuid')
+ ->where(function ($q) {
+ $q->where('company_type', '!=', 'organization')
+ ->orWhereNull('company_type');
+ })
+ ->update([
+ 'company_type' => 'organization',
+ 'is_client' => false,
+ ]);
+
+ // 2. Backfill is_default on company_users pivot using the tie-breaker:
+ // the pivot row matching users.company_uuid wins.
+ $users = DB::table('users')
+ ->whereNotNull('company_uuid')
+ ->select('uuid', 'company_uuid')
+ ->cursor();
+
+ foreach ($users as $user) {
+ $pivot = DB::table('company_users')
+ ->where('user_uuid', $user->uuid)
+ ->where('company_uuid', $user->company_uuid)
+ ->first();
+
+ if ($pivot) {
+ // Only update when not already correct — avoids churn on re-run.
+ DB::table('company_users')
+ ->where('id', $pivot->id)
+ ->where('is_default', false)
+ ->update(['is_default' => true]);
+ } else {
+ // No pivot row yet for user+company — insert one, marked default.
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(),
+ 'user_uuid' => $user->uuid,
+ 'company_uuid' => $user->company_uuid,
+ 'status' => 'active',
+ 'is_default' => true,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ // Demote any OTHER pivot rows for this user that are still marked default.
+ DB::table('company_users')
+ ->where('user_uuid', $user->uuid)
+ ->where('company_uuid', '!=', $user->company_uuid)
+ ->where('is_default', true)
+ ->update(['is_default' => false]);
+ }
+ }
+
+ public function down(): void
+ {
+ // No-op: this is a data seed, not a schema change. Reversing it
+ // would require restoring the pre-seed state which we don't retain.
+ }
+};
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 368f6bdd..18a81fcc 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -13,4 +13,15 @@
./src
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Http/Controllers/Api/v1/DocumentQueueController.php b/src/Http/Controllers/Api/v1/DocumentQueueController.php
new file mode 100644
index 00000000..cf5ce664
--- /dev/null
+++ b/src/Http/Controllers/Api/v1/DocumentQueueController.php
@@ -0,0 +1,106 @@
+validate([
+ 'file' => 'required|file',
+ 'document_type' => 'nullable|string',
+ ]);
+
+ $uploadedFile = $request->file('file');
+
+ // Persist the file via the existing File model
+ $path = "document-queue/" . session('company') . "/" . uniqid('doc_') . '_' . $uploadedFile->getClientOriginalName();
+ $file = File::createFromUpload($uploadedFile, $path);
+
+ if (!$file) {
+ return response()->apiError('Failed to store uploaded file.');
+ }
+
+ $item = DocumentQueueItem::create([
+ 'company_uuid' => session('company'),
+ 'file_uuid' => $file->uuid,
+ 'source' => DocumentQueueItem::SOURCE_MANUAL,
+ 'document_type' => $validated['document_type'] ?? DocumentQueueItem::TYPE_UNKNOWN,
+ 'status' => DocumentQueueItem::STATUS_RECEIVED,
+ ]);
+
+ return response()->json(['data' => $item->load('file')]);
+ }
+
+ /**
+ * POST /document-queue/{id}/process
+ * Synchronously process an item through the full ingestion pipeline.
+ */
+ public function process(string $id)
+ {
+ $item = DocumentQueueItem::findRecordOrFail($id);
+ $processed = app(DocumentIngestionService::class)->process($item);
+
+ return response()->json(['data' => $processed->load('file')]);
+ }
+
+ /**
+ * POST /document-queue/{id}/reprocess
+ * Re-run the pipeline. Useful after fixing AI key, PDF tooling, or fixing match.
+ */
+ public function reprocess(string $id)
+ {
+ $item = DocumentQueueItem::findRecordOrFail($id);
+
+ // Reset to received state (preserve raw_content if present)
+ $item->update([
+ 'status' => DocumentQueueItem::STATUS_RECEIVED,
+ 'error_message' => null,
+ ]);
+
+ $processed = app(DocumentIngestionService::class)->process($item);
+
+ return response()->json(['data' => $processed->load('file')]);
+ }
+
+ /**
+ * POST /document-queue/{id}/manual-match
+ * Manually associate a queue item with an order or shipment.
+ */
+ public function manualMatch(string $id, Request $request)
+ {
+ $item = DocumentQueueItem::findRecordOrFail($id);
+
+ $validated = $request->validate([
+ 'order_uuid' => 'nullable|string',
+ 'shipment_uuid' => 'nullable|string',
+ ]);
+
+ $item->update([
+ 'matched_order_uuid' => $validated['order_uuid'] ?? null,
+ 'matched_shipment_uuid' => $validated['shipment_uuid'] ?? null,
+ 'match_confidence' => 1.00,
+ 'match_method' => 'manual',
+ 'status' => DocumentQueueItem::STATUS_MATCHED,
+ ]);
+
+ return response()->json(['data' => $item->fresh()]);
+ }
+}
diff --git a/src/Http/Controllers/CompanySettingsController.php b/src/Http/Controllers/CompanySettingsController.php
new file mode 100644
index 00000000..f63b5da7
--- /dev/null
+++ b/src/Http/Controllers/CompanySettingsController.php
@@ -0,0 +1,61 @@
+resolveCompany($request);
+ $this->authorizeCompanyAccess($request, $company);
+
+ return response()->json([
+ 'settings' => CompanySettingsResolver::forCompany($company->uuid)->all(),
+ ]);
+ }
+
+ public function update(CompanySettingsUpdateRequest $request): JsonResponse
+ {
+ $company = $this->resolveCompany($request);
+ $this->authorizeCompanyAccess($request, $company);
+
+ $resolver = CompanySettingsResolver::forCompany($company->uuid);
+
+ foreach ($request->input('settings', []) as $key => $value) {
+ $resolver->set((string) $key, $value);
+ }
+
+ return response()->json([
+ 'settings' => $resolver->all(),
+ ]);
+ }
+
+ private function resolveCompany(Request $request): Company
+ {
+ $company = $request->attributes->get('company');
+
+ if (!$company instanceof Company && app()->bound('companyContext')) {
+ $candidate = app('companyContext');
+ if ($candidate instanceof Company) {
+ $company = $candidate;
+ }
+ }
+
+ abort_unless($company instanceof Company, 403);
+
+ return $company;
+ }
+
+ private function authorizeCompanyAccess(Request $request, Company $company): void
+ {
+ $user = $request->user();
+ abort_unless($user !== null, 403);
+ abort_unless($user->canAccessCompany($company->uuid), 403);
+ }
+}
diff --git a/src/Http/Controllers/Internal/v1/ClientCompanyController.php b/src/Http/Controllers/Internal/v1/ClientCompanyController.php
new file mode 100644
index 00000000..c778f818
--- /dev/null
+++ b/src/Http/Controllers/Internal/v1/ClientCompanyController.php
@@ -0,0 +1,135 @@
+
+ * AND is_client = true
+ *
+ * Out-of-scope targets deliberately return 404 (not 403) so that we
+ * do not leak the existence of client companies belonging to other
+ * organizations.
+ */
+class ClientCompanyController extends Controller
+{
+ public function index(Request $request): JsonResponse
+ {
+ $org = $this->resolveOrg($request);
+
+ $clients = Company::where('parent_company_uuid', $org->uuid)
+ ->where('is_client', true)
+ ->orderBy('name')
+ ->get();
+
+ return response()->json(['clients' => $clients]);
+ }
+
+ public function store(ClientCompanyRequest $request): JsonResponse
+ {
+ $org = $this->resolveOrg($request);
+
+ // Only payload-sanitized fields flow in. Tenancy-critical
+ // fields (parent_company_uuid, company_type, is_client) are
+ // server-controlled and IGNORED from the payload.
+ $client = Company::create([
+ 'name' => $request->input('name'),
+ 'client_code' => $request->input('client_code'),
+ 'client_settings' => $request->input('client_settings'),
+ 'parent_company_uuid' => $org->uuid,
+ 'company_type' => 'client',
+ 'is_client' => true,
+ ]);
+
+ return response()->json(['client' => $client->fresh()], 201);
+ }
+
+ public function show(Request $request, string $uuid): JsonResponse
+ {
+ $org = $this->resolveOrg($request);
+ $client = $this->findScopedClient($org, $uuid);
+
+ return response()->json(['client' => $client]);
+ }
+
+ public function update(ClientCompanyRequest $request, string $uuid): JsonResponse
+ {
+ $org = $this->resolveOrg($request);
+ $client = $this->findScopedClient($org, $uuid);
+
+ // Strict whitelist. Tenancy/identity fields
+ // (parent_company_uuid, company_type, is_client, uuid,
+ // public_id, company_users, owner_uuid, etc.) are explicitly
+ // NOT included.
+ $client->update($request->only(['name', 'client_code', 'client_settings']));
+
+ return response()->json(['client' => $client->fresh()]);
+ }
+
+ public function destroy(Request $request, string $uuid): JsonResponse
+ {
+ $org = $this->resolveOrg($request);
+ $client = $this->findScopedClient($org, $uuid);
+ $client->delete();
+
+ return response()->json(null, 204);
+ }
+
+ /**
+ * Resolve the active organization for this request.
+ *
+ * The context binding is populated by
+ * `CompanyContextResolver` (the `fleetbase.company.context`
+ * middleware). If missing — or if the resolved company is NOT
+ * an organization (for example, a client company somehow bound
+ * as context) — this hard-fails with 403.
+ */
+ private function resolveOrg(Request $request): Company
+ {
+ $company = $request->attributes->get('company');
+ if (!$company instanceof Company) {
+ $company = app()->bound('companyContext') ? app('companyContext') : null;
+ }
+
+ abort_unless($company instanceof Company && $company->isOrganization(), 403);
+
+ return $company;
+ }
+
+ /**
+ * Fetch a client company by uuid, verifying:
+ * - uuid is a well-formed UUID string
+ * - record exists
+ * - is_client = true
+ * - parent_company_uuid matches the resolved org
+ *
+ * Any failed invariant yields 404 — never 403 — so that
+ * cross-org existence is not leaked.
+ */
+ private function findScopedClient(Company $org, string $uuid): Company
+ {
+ abort_unless(Str::isUuid($uuid), 404);
+
+ $client = Company::where('uuid', $uuid)
+ ->where('parent_company_uuid', $org->uuid)
+ ->where('is_client', true)
+ ->first();
+
+ abort_unless($client, 404);
+
+ return $client;
+ }
+}
diff --git a/src/Http/Controllers/Internal/v1/CompanyContextController.php b/src/Http/Controllers/Internal/v1/CompanyContextController.php
new file mode 100644
index 00000000..e88a2224
--- /dev/null
+++ b/src/Http/Controllers/Internal/v1/CompanyContextController.php
@@ -0,0 +1,149 @@
+` per request; the middleware
+ * (`fleetbase.company.context`) resolves + binds it to
+ * `$request->attributes->get('company')` and `app('companyContext')`. After
+ * the request ends, that binding is cleared.
+ *
+ * Consequently:
+ * - `current` reads ONLY what the middleware already resolved for THIS
+ * request. No fallback lookup, no DB write, no rebind.
+ * - `switch` validates a target UUID against the user's pivot access and
+ * echoes back the company shape. It does NOT mutate session, the
+ * container, request attributes, or the database. The Ember client uses
+ * the 200/403 response to decide whether to send the new UUID via
+ * `X-Company-Context` on subsequent requests.
+ */
+class CompanyContextController extends Controller
+{
+ /**
+ * Return the company resolved by CompanyContextResolver middleware for
+ * THIS request. No additional lookup; no mutation.
+ */
+ public function current(Request $request): JsonResponse
+ {
+ $this->guardClient($request);
+
+ $company = $this->resolvedCompany($request);
+ abort_unless($company instanceof Company, 403);
+
+ return response()->json([
+ 'company' => $this->shape($company),
+ ]);
+ }
+
+ /**
+ * Validation oracle. Validates that the target company UUID is real
+ * and accessible by the user, then echoes back the company info.
+ *
+ * Crucially: this endpoint does NOT mutate any state.
+ * - No session writes
+ * - No app()->instance() rebinding
+ * - No request attribute changes
+ * - No DB writes
+ */
+ public function switch(Request $request): JsonResponse
+ {
+ $this->guardClient($request);
+
+ $user = $request->user();
+ abort_unless($user, 403);
+
+ $uuid = $request->input('company_uuid');
+
+ // Validate format BEFORE any DB hit.
+ if (!is_string($uuid) || !Str::isUuid($uuid)) {
+ return $this->forbid();
+ }
+
+ // Access check (pivot membership).
+ if (!$user->canAccessCompany($uuid)) {
+ return $this->forbid();
+ }
+
+ // Resolve the company. Null => forbid (don't leak existence).
+ $company = Company::where('uuid', $uuid)->first();
+ if (!$company instanceof Company) {
+ return $this->forbid();
+ }
+
+ // Defense-in-depth: never echo back a client company to a non-client
+ // request (the client guardrail above already handled it, but cheap
+ // insurance — canAccessCompany is the primary gate).
+ if ($company->isClient() && !$this->resolvedCompany($request)?->isOrganization()) {
+ return $this->forbid();
+ }
+
+ return response()->json([
+ 'company' => $this->shape($company),
+ ]);
+ }
+
+ /**
+ * If the resolved (current) company is a client, the user is a
+ * client-role actor — 403 immediately. The middleware already enforces
+ * this; this is defense-in-depth.
+ */
+ private function guardClient(Request $request): void
+ {
+ $resolved = $this->resolvedCompany($request);
+ if ($resolved instanceof Company && $resolved->isClient()) {
+ abort(403);
+ }
+ }
+
+ /**
+ * Read the middleware-resolved company. Request attribute first, then
+ * container fallback (matches ScopedToCompanyContext trait's order).
+ */
+ private function resolvedCompany(Request $request): ?Company
+ {
+ $candidate = $request->attributes->get('company');
+ if ($candidate instanceof Company) {
+ return $candidate;
+ }
+
+ if (app()->bound('companyContext')) {
+ $bound = app('companyContext');
+ if ($bound instanceof Company) {
+ return $bound;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Minimal safe response shape — uuid, name, company_type. Don't leak
+ * internals like client_settings or stripe_id.
+ */
+ private function shape(Company $company): array
+ {
+ return [
+ 'uuid' => $company->uuid,
+ 'name' => $company->name,
+ 'company_type' => $company->company_type,
+ ];
+ }
+
+ private function forbid(): JsonResponse
+ {
+ return response()->json(['error' => 'Access denied to this company context'], 403);
+ }
+}
diff --git a/src/Http/Middleware/CompanyContextResolver.php b/src/Http/Middleware/CompanyContextResolver.php
new file mode 100644
index 00000000..a5f000ab
--- /dev/null
+++ b/src/Http/Middleware/CompanyContextResolver.php
@@ -0,0 +1,95 @@
+user();
+
+ // No auth → this middleware is a no-op. Other middleware (auth:sanctum)
+ // handles guests/rejection separately.
+ if (!$user) {
+ return $next($request);
+ }
+
+ // Client hard-guardrail: clients cannot use org-level routes.
+ $default = $user->defaultCompany();
+ if ($default && $default->isClient()) {
+ return $this->forbid();
+ }
+
+ $header = $request->header('X-Company-Context');
+ $header = is_string($header) ? trim($header) : $header;
+
+ if ($header !== null && $header !== '') {
+ // Validate UUID format before any DB hit.
+ if (!Str::isUuid($header)) {
+ return $this->forbid();
+ }
+
+ // Access check via pivot.
+ if (!$user->canAccessCompany($header)) {
+ return $this->forbid();
+ }
+
+ // Resolve the company record. Null => treat as forbidden (the pivot
+ // row exists but the company was soft-deleted or hard-removed).
+ $company = Company::where('uuid', $header)->first();
+ if (!$company) {
+ return $this->forbid();
+ }
+
+ // Defense-in-depth: if somehow the pivoted company is itself a
+ // client AND the user's default is NOT (unusual), still forbid —
+ // the guardrail check above already blocked client users, so
+ // this shouldn't fire, but it's cheap insurance.
+ // (Comment only; behavior is handled by earlier guard.)
+
+ $this->bind($request, $company);
+
+ return $next($request);
+ }
+
+ // No header → fallback to user's default company.
+ if (!$default) {
+ return $this->forbid();
+ }
+
+ $this->bind($request, $default);
+
+ return $next($request);
+ }
+
+ /**
+ * Clean up the container instance after the response is sent so long-running
+ * workers (Octane / Swoole) don't leak context across requests.
+ */
+ public function terminate(Request $request, $response): void
+ {
+ if (app()->bound('companyContext')) {
+ app()->forgetInstance('companyContext');
+ }
+ }
+
+ private function bind(Request $request, Company $company): void
+ {
+ $request->attributes->set('company', $company);
+ app()->instance('companyContext', $company);
+ }
+
+ private function forbid(): Response
+ {
+ return response()->json(
+ ['error' => 'Access denied to this company context'],
+ 403
+ );
+ }
+}
diff --git a/src/Http/Middleware/CompanyContextSelfResolver.php b/src/Http/Middleware/CompanyContextSelfResolver.php
new file mode 100644
index 00000000..1c36feed
--- /dev/null
+++ b/src/Http/Middleware/CompanyContextSelfResolver.php
@@ -0,0 +1,83 @@
+user();
+
+ // Auth middleware handles 401s.
+ if (!$user) {
+ return $next($request);
+ }
+
+ $header = $request->header('X-Company-Context');
+ $header = is_string($header) ? trim($header) : $header;
+
+ if ($header !== null && $header !== '') {
+ if (!Str::isUuid($header)) {
+ return $this->forbid();
+ }
+
+ if (!$user->canAccessCompany($header)) {
+ return $this->forbid();
+ }
+
+ $company = Company::where('uuid', $header)->first();
+ if (!$company instanceof Company) {
+ return $this->forbid();
+ }
+
+ $this->bind($request, $company);
+ return $next($request);
+ }
+
+ // No header → fall back to user's default.
+ $default = $user->defaultCompany();
+ if (!$default instanceof Company) {
+ return $this->forbid();
+ }
+
+ $this->bind($request, $default);
+ return $next($request);
+ }
+
+ public function terminate(Request $request, $response): void
+ {
+ if (app()->bound('companyContext')) {
+ app()->forgetInstance('companyContext');
+ }
+ }
+
+ private function bind(Request $request, Company $company): void
+ {
+ $request->attributes->set('company', $company);
+ app()->instance('companyContext', $company);
+ }
+
+ private function forbid(): Response
+ {
+ return response()->json(
+ ['error' => 'Access denied to this company context'],
+ 403
+ );
+ }
+}
diff --git a/src/Http/Requests/ClientCompanyRequest.php b/src/Http/Requests/ClientCompanyRequest.php
new file mode 100644
index 00000000..8ceb64c0
--- /dev/null
+++ b/src/Http/Requests/ClientCompanyRequest.php
@@ -0,0 +1,29 @@
+ ['required', 'string', 'max:255'],
+ 'client_code' => ['nullable', 'string', 'max:50'],
+ 'client_settings' => ['nullable', 'array'],
+ ];
+ }
+}
diff --git a/src/Http/Requests/CompanySettingsUpdateRequest.php b/src/Http/Requests/CompanySettingsUpdateRequest.php
new file mode 100644
index 00000000..15c8c4f8
--- /dev/null
+++ b/src/Http/Requests/CompanySettingsUpdateRequest.php
@@ -0,0 +1,64 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'settings' => ['required', 'array'],
+ ];
+ }
+
+ public function withValidator(Validator $validator): void
+ {
+ $validator->after(function (Validator $v) {
+ $settings = $this->input('settings');
+ if (!is_array($settings)) {
+ return; // base rule will flag it
+ }
+
+ // Reject indexed (list) arrays — must be associative.
+ if (array_is_list($settings) && !empty($settings)) {
+ $v->errors()->add('settings', 'settings must be an associative map of keys to values');
+ return;
+ }
+
+ foreach ($settings as $key => $value) {
+ if (!is_string($key) || $key === '') {
+ $v->errors()->add('settings', 'all settings keys must be non-empty strings');
+ continue;
+ }
+
+ if (!$this->isJsonSerializable($value)) {
+ $v->errors()->add("settings.{$key}", 'value must be scalar, null, or a JSON-serializable array');
+ }
+ }
+ });
+ }
+
+ private function isJsonSerializable($value): bool
+ {
+ if ($value === null || is_scalar($value)) {
+ return true;
+ }
+ if (is_array($value)) {
+ foreach ($value as $v) {
+ if (!$this->isJsonSerializable($v)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/Http/Resources/DocumentQueueItem.php b/src/Http/Resources/DocumentQueueItem.php
new file mode 100644
index 00000000..4e9b7121
--- /dev/null
+++ b/src/Http/Resources/DocumentQueueItem.php
@@ -0,0 +1,32 @@
+ $this->when(Http::isInternalRequest(), $this->id, $this->public_id),
+ 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid),
+ 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id),
+ 'source' => $this->source,
+ 'document_type' => $this->document_type,
+ 'status' => $this->status,
+ 'matched_order_uuid' => $this->when(Http::isInternalRequest(), $this->matched_order_uuid),
+ 'matched_shipment_uuid' => $this->when(Http::isInternalRequest(), $this->matched_shipment_uuid),
+ 'matched_carrier_invoice_uuid' => $this->when(Http::isInternalRequest(), $this->matched_carrier_invoice_uuid),
+ 'match_confidence' => $this->match_confidence,
+ 'match_method' => $this->match_method,
+ 'parsed_data' => $this->parsed_data,
+ 'error_message' => $this->error_message,
+ 'processed_at' => $this->processed_at,
+ 'file' => $this->whenLoaded('file'),
+ 'meta' => $this->meta,
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ ];
+ }
+}
diff --git a/src/Models/Company.php b/src/Models/Company.php
index 5b072fb9..af797045 100644
--- a/src/Models/Company.php
+++ b/src/Models/Company.php
@@ -100,6 +100,11 @@ class Company extends Model
'trial_ends_at',
'onboarding_completed_at',
'onboarding_completed_by_uuid',
+ 'parent_company_uuid',
+ 'company_type',
+ 'is_client',
+ 'client_code',
+ 'client_settings',
];
/**
@@ -126,6 +131,8 @@ class Company extends Model
'meta' => Json::class,
'trial_ends_at' => 'datetime',
'onboarding_completed_at' => 'datetime',
+ 'is_client' => 'boolean',
+ 'client_settings' => 'array',
];
/**
@@ -534,4 +541,71 @@ public function getCompanyUserPivot(string|User $user): ?CompanyUser
return CompanyUser::where(['company_uuid' => $this->uuid, 'user_uuid' => $id])->first();
}
+
+ /**
+ * Parent organization this client company belongs to.
+ */
+ public function parentCompany()
+ {
+ return $this->belongsTo(Company::class, 'parent_company_uuid', 'uuid');
+ }
+
+ /**
+ * Client companies owned by this organization.
+ */
+ public function clientCompanies()
+ {
+ return $this->hasMany(Company::class, 'parent_company_uuid', 'uuid');
+ }
+
+ /**
+ * Scope: only client companies.
+ */
+ public function scopeClients($query)
+ {
+ return $query->where('is_client', true);
+ }
+
+ /**
+ * Scope: only organization (parent) companies.
+ */
+ public function scopeOrganizations($query)
+ {
+ return $query->where('company_type', 'organization');
+ }
+
+ /**
+ * Whether this company is a client (flagged via column or type).
+ */
+ public function isClient(): bool
+ {
+ return (bool) $this->is_client || $this->company_type === 'client';
+ }
+
+ /**
+ * Whether this company is an organization (parent-level).
+ */
+ public function isOrganization(): bool
+ {
+ return $this->company_type === 'organization';
+ }
+
+ /**
+ * UUIDs this company's context can access.
+ * - Organization: self + all direct client children.
+ * - Client (or any non-organization): self only.
+ */
+ public function getAccessibleCompanyUuids(): array
+ {
+ $uuids = [$this->uuid];
+
+ if ($this->isOrganization()) {
+ $uuids = array_merge(
+ $uuids,
+ $this->clientCompanies()->pluck('uuid')->toArray()
+ );
+ }
+
+ return array_values(array_unique($uuids));
+ }
}
diff --git a/src/Models/CompanyUser.php b/src/Models/CompanyUser.php
index 41d85a40..2bc200ab 100644
--- a/src/Models/CompanyUser.php
+++ b/src/Models/CompanyUser.php
@@ -33,6 +33,8 @@ class CompanyUser extends Model
'user_uuid',
'status',
'external',
+ 'access_level',
+ 'is_default',
];
/**
@@ -41,7 +43,8 @@ class CompanyUser extends Model
* @var array
*/
protected $casts = [
- 'external' => 'boolean',
+ 'external' => 'boolean',
+ 'is_default' => 'boolean',
];
/**
diff --git a/src/Models/Concerns/ScopedToCompanyContext.php b/src/Models/Concerns/ScopedToCompanyContext.php
new file mode 100644
index 00000000..14daa204
--- /dev/null
+++ b/src/Models/Concerns/ScopedToCompanyContext.php
@@ -0,0 +1,82 @@
+where(...)->get();
+ */
+trait ScopedToCompanyContext
+{
+ /**
+ * Filter a query to the currently resolved company.
+ * Returns an empty result set when no company context is bound.
+ */
+ public function scopeInCompanyContext($query)
+ {
+ $company = $this->resolveCompanyContext();
+ $column = $this->getTable() . '.company_uuid';
+
+ if ($company instanceof Company) {
+ return $query->where($column, $company->uuid);
+ }
+
+ // No context bound → fail closed. Always-false predicate that the
+ // query builder + any database can evaluate cheaply.
+ return $query->whereRaw('1 = 0');
+ }
+
+ /**
+ * Resolve the active Company from the Request or container. Returns null
+ * when no context is bound.
+ */
+ protected function resolveCompanyContext(): ?Company
+ {
+ // Prefer the Request attribute — tied to the current request and
+ // guaranteed not to bleed across lifecycles.
+ if (app()->bound('request')) {
+ $request = app('request');
+ if (isset($request->attributes)
+ && $request->attributes instanceof \Symfony\Component\HttpFoundation\ParameterBag
+ && $request->attributes->has('company')) {
+ $candidate = $request->attributes->get('company');
+ if ($candidate instanceof Company) {
+ return $candidate;
+ }
+ }
+ }
+
+ // Fall back to the container instance (useful for queue jobs).
+ if (app()->bound('companyContext')) {
+ $candidate = app('companyContext');
+ if ($candidate instanceof Company) {
+ return $candidate;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Models/DocumentQueueItem.php b/src/Models/DocumentQueueItem.php
new file mode 100644
index 00000000..4460acb2
--- /dev/null
+++ b/src/Models/DocumentQueueItem.php
@@ -0,0 +1,98 @@
+ Json::class,
+ 'meta' => Json::class,
+ 'match_confidence' => 'decimal:2',
+ 'processed_at' => 'datetime',
+ ];
+
+ public function company()
+ {
+ return $this->belongsTo(Company::class, 'company_uuid', 'uuid');
+ }
+
+ public function file()
+ {
+ return $this->belongsTo(File::class, 'file_uuid', 'uuid');
+ }
+
+ public function matchedOrder()
+ {
+ return $this->belongsTo(\Fleetbase\FleetOps\Models\Order::class, 'matched_order_uuid', 'uuid');
+ }
+
+ public function matchedShipment()
+ {
+ return $this->belongsTo(\Fleetbase\FleetOps\Models\Shipment::class, 'matched_shipment_uuid', 'uuid');
+ }
+
+ public function matchedCarrierInvoice()
+ {
+ return $this->belongsTo(\Fleetbase\Ledger\Models\CarrierInvoice::class, 'matched_carrier_invoice_uuid', 'uuid');
+ }
+
+ public function scopeNeedsReview($query)
+ {
+ return $query->where('status', self::STATUS_NEEDS_REVIEW);
+ }
+
+ public function scopeForReprocessing($query)
+ {
+ return $query->whereIn('status', [self::STATUS_RECEIVED, self::STATUS_NEEDS_REVIEW, self::STATUS_FAILED]);
+ }
+}
diff --git a/src/Models/User.php b/src/Models/User.php
index 881c7344..7b92174d 100644
--- a/src/Models/User.php
+++ b/src/Models/User.php
@@ -1400,4 +1400,54 @@ public function syncProperty(string $property, Model $model): bool
return $synced;
}
+
+ /**
+ * The user's default company.
+ *
+ * Primary: the company linked via a company_users pivot row where
+ * is_default = true.
+ *
+ * Fallback: the legacy users.company_uuid association (the `company()`
+ * BelongsTo) — preserves behavior for users who predate the is_default
+ * backfill.
+ *
+ * Returns null if neither is set (e.g. a user with company_uuid = null
+ * and no pivot rows).
+ */
+ public function defaultCompany(): ?Company
+ {
+ $defaultPivot = $this->companyUsers()
+ ->where('is_default', true)
+ ->first();
+
+ if ($defaultPivot) {
+ return Company::where('uuid', $defaultPivot->company_uuid)->first();
+ }
+
+ return $this->company;
+ }
+
+ /**
+ * Distinct UUIDs of all companies this user has access to via the
+ * company_users pivot. Does NOT include the legacy company_uuid
+ * fallback — accessibility is defined by explicit pivot rows only.
+ */
+ public function accessibleCompanyUuids(): array
+ {
+ return $this->companyUsers()
+ ->pluck('company_uuid')
+ ->unique()
+ ->values()
+ ->toArray();
+ }
+
+ /**
+ * Whether the user has a company_users pivot row for the given company.
+ */
+ public function canAccessCompany(string $companyUuid): bool
+ {
+ return $this->companyUsers()
+ ->where('company_uuid', $companyUuid)
+ ->exists();
+ }
}
diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php
index e3330a81..6615f47b 100644
--- a/src/Providers/CoreServiceProvider.php
+++ b/src/Providers/CoreServiceProvider.php
@@ -145,6 +145,9 @@ public function register()
$this->app->singleton(\Fleetbase\Services\TemplateRenderService::class, function ($app) {
return new \Fleetbase\Services\TemplateRenderService();
});
+
+ // register document ingestion service (BUILD-08)
+ $this->app->singleton(\Fleetbase\Services\DocumentIngestionService::class);
}
/**
@@ -301,6 +304,16 @@ public function registerMiddleware(): void
}
}
+ $this->app['router']->aliasMiddleware(
+ 'fleetbase.company.context',
+ \Fleetbase\Http\Middleware\CompanyContextResolver::class
+ );
+
+ $this->app['router']->aliasMiddleware(
+ 'fleetbase.company.context.self',
+ \Fleetbase\Http\Middleware\CompanyContextSelfResolver::class
+ );
+
foreach ($this->middleware as $group => $middlewares) {
foreach ($middlewares as $middleware) {
$this->app->router->pushMiddlewareToGroup($group, $middleware);
diff --git a/src/Services/DocumentIngestionService.php b/src/Services/DocumentIngestionService.php
new file mode 100644
index 00000000..d55aa9c5
--- /dev/null
+++ b/src/Services/DocumentIngestionService.php
@@ -0,0 +1,544 @@
+update(['status' => DocumentQueueItem::STATUS_PROCESSING]);
+
+ try {
+ // Step 1: Extract text
+ $text = $this->extractText($item);
+ if ($text !== null) {
+ $item->raw_content = $text;
+ $item->save();
+ } else {
+ // Text extraction failed safely — preserve item for review
+ $item->update([
+ 'status' => DocumentQueueItem::STATUS_NEEDS_REVIEW,
+ 'error_message' => $item->error_message ?: 'Text extraction unavailable for this file type',
+ 'processed_at' => now(),
+ ]);
+ return $item->fresh();
+ }
+
+ // Step 2: Classify
+ if ($item->document_type === DocumentQueueItem::TYPE_UNKNOWN) {
+ $classification = $this->classifyDocument($text, $item->file?->original_filename);
+ $item->document_type = $classification;
+ $item->save();
+ }
+
+ // Step 3: Parse structured data (only for carrier invoices for now)
+ $parsedData = null;
+ if ($item->document_type === DocumentQueueItem::TYPE_CARRIER_INVOICE) {
+ $parsedData = $this->parseStructuredData($text, $item->document_type);
+ if ($parsedData) {
+ $item->update([
+ 'parsed_data' => $parsedData,
+ 'status' => DocumentQueueItem::STATUS_PARSED,
+ ]);
+ } else {
+ // AI unavailable or parse failed — mark for review, preserve raw text
+ $item->update([
+ 'status' => DocumentQueueItem::STATUS_NEEDS_REVIEW,
+ 'error_message' => 'Structured parsing unavailable or failed — manual review required',
+ 'processed_at' => now(),
+ ]);
+ return $item->fresh();
+ }
+ } else {
+ // Non-invoice docs: mark parsed without structured extraction
+ $item->update(['status' => DocumentQueueItem::STATUS_PARSED]);
+ }
+
+ // Step 4: Auto-match
+ if ($parsedData) {
+ $match = $this->autoMatch($item, $parsedData);
+ if ($match) {
+ $item->update([
+ 'matched_order_uuid' => $match['order_uuid'] ?? null,
+ 'matched_shipment_uuid' => $match['shipment_uuid'] ?? null,
+ 'match_confidence' => $match['confidence'],
+ 'match_method' => $match['method'],
+ 'status' => DocumentQueueItem::STATUS_MATCHED,
+ ]);
+ }
+ }
+
+ // Step 5: Conditionally create CarrierInvoice — only if safe
+ if ($item->document_type === DocumentQueueItem::TYPE_CARRIER_INVOICE && $parsedData) {
+ $invoice = $this->createCarrierInvoiceFromParsed($item, $parsedData);
+ if (!$invoice) {
+ // Could not create invoice safely — stays in matched/needs_review state
+ $item->update([
+ 'status' => DocumentQueueItem::STATUS_NEEDS_REVIEW,
+ 'error_message' => $item->error_message ?: 'Invoice creation skipped: insufficient confidence or missing required fields',
+ ]);
+ }
+ }
+
+ $item->update(['processed_at' => now()]);
+
+ } catch (\Throwable $e) {
+ Log::error('DocumentIngestionService::process failed', [
+ 'queue_item_uuid' => $item->uuid,
+ 'exception' => $e->getMessage(),
+ ]);
+ $item->update([
+ 'status' => DocumentQueueItem::STATUS_FAILED,
+ 'error_message' => substr($e->getMessage(), 0, 1000),
+ 'processed_at' => now(),
+ ]);
+ }
+
+ return $item->fresh();
+ }
+
+ /**
+ * Extract raw text from the attached file.
+ * Supports text/plain, text/csv natively. PDF requires spatie/pdf-to-text.
+ * Returns null if extraction not available; sets error_message on the item.
+ */
+ public function extractText(DocumentQueueItem $item): ?string
+ {
+ $file = $item->file;
+ if (!$file) {
+ $item->error_message = 'No file attached to queue item';
+ return null;
+ }
+
+ $contentType = strtolower((string) $file->content_type);
+
+ // Plain text and CSV — read directly from storage
+ if (in_array($contentType, ['text/plain', 'text/csv', 'text/tab-separated-values'])
+ || str_starts_with($contentType, 'text/')) {
+ try {
+ return Storage::disk($file->disk ?? 'local')->get($file->path);
+ } catch (\Throwable $e) {
+ $item->error_message = 'Failed to read text file: ' . $e->getMessage();
+ return null;
+ }
+ }
+
+ // PDF — only if spatie/pdf-to-text is available AND pdftotext binary is present
+ if ($contentType === 'application/pdf') {
+ if (!class_exists(\Spatie\PdfToText\Pdf::class)) {
+ $item->error_message = 'PDF parsing unavailable: spatie/pdf-to-text not installed';
+ return null;
+ }
+
+ try {
+ $disk = Storage::disk($file->disk ?? 'local');
+ $tempPath = $disk->path($file->path);
+
+ // If the disk does not expose a local path (e.g., S3), download to temp first
+ if (!is_string($tempPath) || !file_exists($tempPath)) {
+ $contents = $disk->get($file->path);
+ $tempPath = tempnam(sys_get_temp_dir(), 'docq_') . '.pdf';
+ file_put_contents($tempPath, $contents);
+ }
+
+ $text = (new \Spatie\PdfToText\Pdf())->setPdf($tempPath)->text();
+ return $text;
+ } catch (\Throwable $e) {
+ $item->error_message = 'PDF text extraction failed: ' . substr($e->getMessage(), 0, 500);
+ return null;
+ }
+ }
+
+ $item->error_message = "Unsupported content type for text extraction: {$contentType}";
+ return null;
+ }
+
+ /**
+ * Classify document type. Heuristics first, AI fallback if available.
+ */
+ public function classifyDocument(string $text, ?string $filename = null): string
+ {
+ $textLower = strtolower(substr($text, 0, 5000));
+ $filenameLower = strtolower((string) $filename);
+
+ // Heuristic-first classification — fast, deterministic, no API cost
+ $heuristics = [
+ DocumentQueueItem::TYPE_CARRIER_INVOICE => ['invoice', 'inv #', 'invoice #', 'invoice number', 'amount due', 'balance due'],
+ DocumentQueueItem::TYPE_BOL => ['bill of lading', 'bol number', 'b/l number'],
+ DocumentQueueItem::TYPE_POD => ['proof of delivery', 'received by', 'delivery receipt', 'pod'],
+ DocumentQueueItem::TYPE_RATE_CONFIRMATION => ['rate confirmation', 'rate con', 'load confirmation', 'tender confirmation'],
+ DocumentQueueItem::TYPE_INSURANCE_CERT => ['certificate of insurance', 'coi', 'liability coverage'],
+ DocumentQueueItem::TYPE_CUSTOMS => ['customs declaration', 'commercial invoice', 'shipper export declaration'],
+ ];
+
+ foreach ($heuristics as $type => $keywords) {
+ foreach ($keywords as $keyword) {
+ if (str_contains($textLower, $keyword) || str_contains($filenameLower, str_replace(' ', '_', $keyword))) {
+ return $type;
+ }
+ }
+ }
+
+ // Heuristic miss — try AI if available
+ if ($this->isAiAvailable()) {
+ $aiResult = $this->classifyWithAi($text);
+ if ($aiResult) {
+ return $aiResult;
+ }
+ }
+
+ // No deterministic match and no AI — return unknown rather than guessing
+ return DocumentQueueItem::TYPE_UNKNOWN;
+ }
+
+ /**
+ * Use Claude API to classify document type.
+ */
+ protected function classifyWithAi(string $text): ?string
+ {
+ $allowedTypes = [
+ DocumentQueueItem::TYPE_CARRIER_INVOICE,
+ DocumentQueueItem::TYPE_BOL,
+ DocumentQueueItem::TYPE_POD,
+ DocumentQueueItem::TYPE_RATE_CONFIRMATION,
+ DocumentQueueItem::TYPE_INSURANCE_CERT,
+ DocumentQueueItem::TYPE_CUSTOMS,
+ DocumentQueueItem::TYPE_OTHER,
+ ];
+
+ $prompt = "Classify this freight document as ONE of: " . implode(', ', $allowedTypes)
+ . ".\n\nReturn ONLY the classification, nothing else.\n\nDocument text:\n"
+ . substr($text, 0, 4000);
+
+ try {
+ $response = Http::timeout(30)
+ ->withHeaders([
+ 'x-api-key' => config('services.anthropic.key'),
+ 'anthropic-version' => '2023-06-01',
+ 'content-type' => 'application/json',
+ ])
+ ->post('https://api.anthropic.com/v1/messages', [
+ 'model' => config('services.anthropic.classification_model', 'claude-haiku-4-5-20251001'),
+ 'max_tokens' => 50,
+ 'messages' => [['role' => 'user', 'content' => $prompt]],
+ ]);
+
+ if (!$response->successful()) {
+ return null;
+ }
+
+ $classification = trim(strtolower($response->json('content.0.text', '')));
+ return in_array($classification, $allowedTypes) ? $classification : null;
+ } catch (\Throwable $e) {
+ Log::warning('AI classification failed', ['error' => $e->getMessage()]);
+ return null;
+ }
+ }
+
+ /**
+ * Parse structured data from document text. Currently focused on carrier invoices.
+ * Returns null if AI is unavailable or parsing fails — never fabricates data.
+ */
+ public function parseStructuredData(string $text, string $docType): ?array
+ {
+ if ($docType !== DocumentQueueItem::TYPE_CARRIER_INVOICE) {
+ return null; // Only invoice parsing supported in this build
+ }
+
+ if (!$this->isAiAvailable()) {
+ return null;
+ }
+
+ $prompt = <<withHeaders([
+ 'x-api-key' => config('services.anthropic.key'),
+ 'anthropic-version' => '2023-06-01',
+ 'content-type' => 'application/json',
+ ])
+ ->post('https://api.anthropic.com/v1/messages', [
+ 'model' => config('services.anthropic.parse_model', 'claude-sonnet-4-6'),
+ 'max_tokens' => 2000,
+ 'messages' => [['role' => 'user', 'content' => $prompt]],
+ ]);
+
+ if (!$response->successful()) {
+ return null;
+ }
+
+ $jsonText = $response->json('content.0.text', '');
+ $jsonText = preg_replace('/```json?\s*|\s*```/', '', (string) $jsonText);
+ $parsed = json_decode(trim($jsonText), true);
+
+ if (!is_array($parsed)) {
+ return null;
+ }
+
+ return $parsed;
+ } catch (\Throwable $e) {
+ Log::warning('AI structured parse failed', ['error' => $e->getMessage()]);
+ return null;
+ }
+ }
+
+ /**
+ * Match a parsed document to an order/shipment.
+ * Priority: PRO number → BOL number → carrier + pickup date.
+ */
+ public function autoMatch(DocumentQueueItem $item, ?array $parsedData): ?array
+ {
+ if (!$parsedData) {
+ return null;
+ }
+
+ $companyUuid = $item->company_uuid;
+
+ // Try PRO number first (highest confidence)
+ if ($proNumber = $parsedData['pro_number'] ?? null) {
+ // Check shipments
+ if (class_exists(\Fleetbase\FleetOps\Models\Shipment::class)) {
+ $shipment = \Fleetbase\FleetOps\Models\Shipment::where('company_uuid', $companyUuid)
+ ->where('pro_number', $proNumber)
+ ->first();
+ if ($shipment) {
+ $orderUuid = null;
+ try {
+ $orderUuid = $shipment->orders()->first()?->uuid;
+ } catch (\Throwable $e) {
+ // relationship missing or empty — fine
+ }
+ return [
+ 'shipment_uuid' => $shipment->uuid,
+ 'order_uuid' => $orderUuid,
+ 'confidence' => 0.95,
+ 'method' => 'pro_number',
+ ];
+ }
+ }
+
+ // Check orders by meta.pro_number (legacy path)
+ if (class_exists(\Fleetbase\FleetOps\Models\Order::class)) {
+ $order = \Fleetbase\FleetOps\Models\Order::where('company_uuid', $companyUuid)
+ ->where('meta->pro_number', $proNumber)
+ ->first();
+ if ($order) {
+ return [
+ 'order_uuid' => $order->uuid,
+ 'confidence' => 0.90,
+ 'method' => 'pro_number',
+ ];
+ }
+ }
+ }
+
+ // Try BOL number
+ if ($bolNumber = $parsedData['bol_number'] ?? null) {
+ if (class_exists(\Fleetbase\FleetOps\Models\Shipment::class)) {
+ $shipment = \Fleetbase\FleetOps\Models\Shipment::where('company_uuid', $companyUuid)
+ ->where('bol_number', $bolNumber)
+ ->first();
+ if ($shipment) {
+ $orderUuid = null;
+ try {
+ $orderUuid = $shipment->orders()->first()?->uuid;
+ } catch (\Throwable $e) {}
+ return [
+ 'shipment_uuid' => $shipment->uuid,
+ 'order_uuid' => $orderUuid,
+ 'confidence' => 0.90,
+ 'method' => 'bol_number',
+ ];
+ }
+ }
+ }
+
+ // Try carrier name + pickup date (lower confidence)
+ $carrierName = $parsedData['carrier_name'] ?? null;
+ $pickupDate = $parsedData['pickup_date'] ?? null;
+ if ($carrierName && $pickupDate && class_exists(\Fleetbase\FleetOps\Models\Vendor::class)) {
+ $vendor = \Fleetbase\FleetOps\Models\Vendor::where('company_uuid', $companyUuid)
+ ->where('name', 'LIKE', "%{$carrierName}%")
+ ->first();
+
+ if ($vendor && class_exists(\Fleetbase\FleetOps\Models\Shipment::class)) {
+ $shipment = \Fleetbase\FleetOps\Models\Shipment::where('company_uuid', $companyUuid)
+ ->where('vendor_uuid', $vendor->uuid)
+ ->whereDate('planned_pickup_at', $pickupDate)
+ ->first();
+
+ if ($shipment) {
+ $orderUuid = null;
+ try {
+ $orderUuid = $shipment->orders()->first()?->uuid;
+ } catch (\Throwable $e) {}
+ return [
+ 'shipment_uuid' => $shipment->uuid,
+ 'order_uuid' => $orderUuid,
+ 'confidence' => 0.70,
+ 'method' => 'carrier_date',
+ ];
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Create a CarrierInvoice from parsed data — ONLY if safe.
+ *
+ * Safety conditions ALL must be met:
+ * - document_type is carrier_invoice
+ * - parsed_data has total_amount and either invoice_number or pro_number
+ * - match_confidence >= MIN_INVOICE_CREATION_CONFIDENCE (or vendor resolvable)
+ * - vendor is resolvable from carrier_name
+ */
+ public function createCarrierInvoiceFromParsed(DocumentQueueItem $item, array $parsedData): ?\Fleetbase\Ledger\Models\CarrierInvoice
+ {
+ // Required class check
+ if (!class_exists(\Fleetbase\Ledger\Models\CarrierInvoice::class)) {
+ return null;
+ }
+
+ // Must be carrier invoice
+ if ($item->document_type !== DocumentQueueItem::TYPE_CARRIER_INVOICE) {
+ return null;
+ }
+
+ // Confidence threshold for safe auto-creation
+ if ($item->match_confidence !== null
+ && (float) $item->match_confidence < DocumentQueueItem::MIN_INVOICE_CREATION_CONFIDENCE) {
+ return null;
+ }
+
+ // Required fields
+ $totalAmount = $parsedData['total_amount'] ?? null;
+ if (!$totalAmount) {
+ return null;
+ }
+
+ // Resolve vendor — required for CarrierInvoice
+ $carrierName = $parsedData['carrier_name'] ?? null;
+ if (!$carrierName || !class_exists(\Fleetbase\FleetOps\Models\Vendor::class)) {
+ return null;
+ }
+
+ $vendor = \Fleetbase\FleetOps\Models\Vendor::where('company_uuid', $item->company_uuid)
+ ->where('name', 'LIKE', "%{$carrierName}%")
+ ->first();
+
+ if (!$vendor) {
+ return null;
+ }
+
+ // Idempotency: don't create if already created from this item
+ if ($item->matched_carrier_invoice_uuid) {
+ return \Fleetbase\Ledger\Models\CarrierInvoice::where('uuid', $item->matched_carrier_invoice_uuid)->first();
+ }
+
+ try {
+ $invoice = \Fleetbase\Ledger\Models\CarrierInvoice::create([
+ 'company_uuid' => $item->company_uuid,
+ 'vendor_uuid' => $vendor->uuid,
+ 'order_uuid' => $item->matched_order_uuid,
+ 'shipment_uuid' => $item->matched_shipment_uuid,
+ 'invoice_number' => $parsedData['invoice_number'] ?? null,
+ 'pro_number' => $parsedData['pro_number'] ?? null,
+ 'bol_number' => $parsedData['bol_number'] ?? null,
+ 'invoiced_amount' => $totalAmount,
+ 'invoice_date' => $parsedData['invoice_date'] ?? null,
+ 'pickup_date' => $parsedData['pickup_date'] ?? null,
+ 'delivery_date' => $parsedData['delivery_date'] ?? null,
+ 'source' => $item->source,
+ 'status' => 'pending',
+ 'received_at' => now(),
+ 'file_uuid' => $item->file_uuid,
+ ]);
+
+ // Create line items if present
+ foreach ($parsedData['line_items'] ?? [] as $lineItem) {
+ $invoice->items()->create([
+ 'charge_type' => $lineItem['charge_type'] ?? 'other',
+ 'description' => $lineItem['description'] ?? null,
+ 'invoiced_amount' => $lineItem['amount'] ?? 0,
+ 'quantity' => $lineItem['quantity'] ?? null,
+ 'rate' => $lineItem['rate'] ?? null,
+ ]);
+ }
+
+ // Link back to the queue item
+ $item->update([
+ 'matched_carrier_invoice_uuid' => $invoice->uuid,
+ ]);
+
+ return $invoice;
+ } catch (\Throwable $e) {
+ Log::error('CarrierInvoice creation from queue item failed', [
+ 'queue_item_uuid' => $item->uuid,
+ 'error' => $e->getMessage(),
+ ]);
+ return null;
+ }
+ }
+
+ /**
+ * Check if the Anthropic API key is configured.
+ */
+ protected function isAiAvailable(): bool
+ {
+ return !empty(config('services.anthropic.key'));
+ }
+}
diff --git a/src/Support/CompanySettingsResolver.php b/src/Support/CompanySettingsResolver.php
new file mode 100644
index 00000000..14c20735
--- /dev/null
+++ b/src/Support/CompanySettingsResolver.php
@@ -0,0 +1,159 @@
+companyUuid = $companyUuid;
+ $this->parentCompanyUuid = $parentCompanyUuid;
+ }
+
+ public static function forCompany(string $companyUuid): self
+ {
+ $company = Company::where('uuid', $companyUuid)->first();
+ $parentUuid = $company?->parent_company_uuid ?: null;
+
+ return new self($companyUuid, $parentUuid);
+ }
+
+ /**
+ * Resolution order: company override -> parent org value -> default tree -> caller default.
+ */
+ public function get(string $key, $default = null)
+ {
+ $sentinel = new \stdClass();
+
+ // 1. Company-specific value wins.
+ $ownValue = Setting::lookup($this->companyKey($this->companyUuid, $key), $sentinel);
+ if ($ownValue !== $sentinel) {
+ return $ownValue;
+ }
+
+ // 2. Parent-org value (inheritance, only for clients with a parent).
+ if ($this->parentCompanyUuid) {
+ $parentValue = Setting::lookup($this->companyKey($this->parentCompanyUuid, $key), $sentinel);
+ if ($parentValue !== $sentinel) {
+ return $parentValue;
+ }
+ }
+
+ // 3. Default from the defaults tree.
+ $defaultFromTree = data_get(static::defaults(), $key);
+ if ($defaultFromTree !== null) {
+ return $defaultFromTree;
+ }
+
+ // 4. Caller-provided default.
+ return $default;
+ }
+
+ public function set(string $key, $value): self
+ {
+ Setting::configure($this->companyKey($this->companyUuid, $key), $value);
+ return $this;
+ }
+
+ /**
+ * Full merged settings tree: defaults <- parent <- own.
+ */
+ public function all(): array
+ {
+ $tree = static::defaults();
+
+ if ($this->parentCompanyUuid) {
+ $tree = static::mergeDeep($tree, $this->readCompanyTree($this->parentCompanyUuid));
+ }
+
+ $tree = static::mergeDeep($tree, $this->readCompanyTree($this->companyUuid));
+
+ return $tree;
+ }
+
+ public static function defaults(): array
+ {
+ return [
+ 'billing' => [
+ 'default_payment_terms_days' => 30,
+ 'default_billing_frequency' => 'per_shipment',
+ 'invoice_number_prefix' => 'INV',
+ 'invoice_number_next' => 1,
+ 'default_charge_template_uuid' => null,
+ 'default_currency' => 'USD',
+ ],
+ 'tendering' => [
+ 'default_method' => 'email',
+ 'default_expiration_hours' => 4,
+ 'auto_waterfall' => true,
+ 'check_call_stale_hours' => 6,
+ ],
+ 'documents' => [
+ 'auto_request_pod_on_delivery' => true,
+ 'pod_due_days' => 3,
+ 'required_documents' => ['bol', 'pod'],
+ ],
+ 'pay_files' => [
+ 'default_format' => 'csv',
+ 'default_frequency' => 'weekly',
+ 'default_day_of_week' => 1,
+ 'default_recipients' => [],
+ 'default_payment_method' => 'ach',
+ ],
+ 'fuel' => [
+ 'auto_update_eia' => true,
+ 'manual_override_price' => null,
+ 'update_day' => 'monday',
+ ],
+ 'audit' => [
+ 'default_tolerance_percent' => 2.0,
+ 'default_tolerance_amount' => 50.00,
+ 'auto_audit_on_receive' => true,
+ ],
+ ];
+ }
+
+ /**
+ * Read every `company.{uuid}.*` setting row for this company and rebuild
+ * into a nested array via dot-notation keys.
+ */
+ protected function readCompanyTree(string $companyUuid): array
+ {
+ $prefix = "company.{$companyUuid}.";
+ $rows = \DB::table('settings')
+ ->where('key', 'like', "{$prefix}%")
+ ->get(['key', 'value']);
+
+ $tree = [];
+ foreach ($rows as $row) {
+ $shortKey = substr($row->key, strlen($prefix));
+ $decoded = is_string($row->value) ? json_decode($row->value, true) : $row->value;
+ data_set($tree, $shortKey, $decoded);
+ }
+
+ return $tree;
+ }
+
+ protected static function mergeDeep(array $base, array $override): array
+ {
+ foreach ($override as $k => $v) {
+ if (is_array($v) && isset($base[$k]) && is_array($base[$k])) {
+ $base[$k] = static::mergeDeep($base[$k], $v);
+ } else {
+ $base[$k] = $v;
+ }
+ }
+ return $base;
+ }
+
+ protected function companyKey(string $companyUuid, string $key): string
+ {
+ return "company.{$companyUuid}.{$key}";
+ }
+}
diff --git a/src/Traits/HasComments.php b/src/Traits/HasComments.php
new file mode 100644
index 00000000..f1594d67
--- /dev/null
+++ b/src/Traits/HasComments.php
@@ -0,0 +1,26 @@
+hasMany(Comment::class, 'subject_uuid')
+ ->whereNull('parent_comment_uuid')
+ ->latest();
+ }
+
+ /**
+ * Get top-level comments with author and nested replies eager-loaded.
+ */
+ public function topLevelComments()
+ {
+ return $this->comments()->with('replies.author', 'author');
+ }
+}
diff --git a/src/routes.php b/src/routes.php
index 50c1a695..c3cacf43 100644
--- a/src/routes.php
+++ b/src/routes.php
@@ -21,6 +21,62 @@
function ($router) {
$router->get('/', 'Controller@hello');
+ /*
+ |--------------------------------------------------------------------------
+ | Multi-tenant Org-scoped Routes
+ |--------------------------------------------------------------------------
+ |
+ | Org-scoped CRUD endpoints protected by auth:sanctum and the
+ | company context middleware. The context middleware resolves the
+ | active company (org) from the X-Company-Context header or the
+ | caller's default company and blocks client-role users up-front.
+ */
+ $router->prefix('v1/companies/clients')
+ ->middleware(['auth:sanctum', 'fleetbase.company.context'])
+ ->group(function ($router) {
+ $router->get('/', 'Internal\v1\ClientCompanyController@index');
+ $router->post('/', 'Internal\v1\ClientCompanyController@store');
+ $router->get('{uuid}', 'Internal\v1\ClientCompanyController@show');
+ $router->put('{uuid}', 'Internal\v1\ClientCompanyController@update');
+ $router->patch('{uuid}', 'Internal\v1\ClientCompanyController@update');
+ $router->delete('{uuid}', 'Internal\v1\ClientCompanyController@destroy');
+ });
+
+ /*
+ |--------------------------------------------------------------------------
+ | Multi-tenant Company Context (stateless)
+ |--------------------------------------------------------------------------
+ |
+ | Read the middleware-resolved company for the current request, or
+ | validate a proposed switch target. Switch is a validation oracle
+ | only — it does not mutate server state; the Ember client uses the
+ | response to decide whether to send the new UUID via the
+ | X-Company-Context header on subsequent requests.
+ */
+ $router->prefix('v1/companies')
+ ->middleware(['auth:sanctum', 'fleetbase.company.context'])
+ ->group(function ($router) {
+ $router->get('current-context', 'Internal\v1\CompanyContextController@current');
+ $router->post('switch-context', 'Internal\v1\CompanyContextController@switch');
+ });
+
+ /*
+ |--------------------------------------------------------------------------
+ | Company Settings (resolved inheritance tree)
+ |--------------------------------------------------------------------------
+ |
+ | Read and write the active company's settings. Writes are strictly
+ | scoped to the active company — parent keyspace is never written.
+ | Delegates 100% to CompanySettingsResolver (no merge logic here).
+ */
+ $router->prefix('v1/company-settings')
+ ->middleware(['auth:sanctum', 'fleetbase.company.context.self'])
+ ->group(function ($router) {
+ $router->get('current', 'CompanySettingsController@current');
+ $router->put('current', 'CompanySettingsController@update');
+ $router->patch('current', 'CompanySettingsController@update');
+ });
+
/*
|--------------------------------------------------------------------------
| Public/Consumable Routes
@@ -76,6 +132,19 @@ function ($router) {
$router->delete('{id}', 'CommentController@delete');
}
);
+ // ----------------------------------------------------------------
+ // Document Queue
+ // ----------------------------------------------------------------
+ $router->group(['prefix' => 'document-queue'], function () use ($router) {
+ $router->get('/', 'DocumentQueueController@queryRecord');
+ $router->get('{id}', 'DocumentQueueController@findRecord');
+ $router->put('{id}', 'DocumentQueueController@updateRecord');
+ $router->delete('{id}', 'DocumentQueueController@deleteRecord');
+ $router->post('upload', 'DocumentQueueController@upload');
+ $router->post('{id}/process', 'DocumentQueueController@process');
+ $router->post('{id}/reprocess', 'DocumentQueueController@reprocess');
+ $router->post('{id}/manual-match', 'DocumentQueueController@manualMatch');
+ });
});
/*
diff --git a/tests/Feature/MultiTenant/ClientCompanyControllerTest.php b/tests/Feature/MultiTenant/ClientCompanyControllerTest.php
new file mode 100644
index 00000000..4dde8583
--- /dev/null
+++ b/tests/Feature/MultiTenant/ClientCompanyControllerTest.php
@@ -0,0 +1,389 @@
+set('broadcasting.default', 'null');
+ config()->set('broadcasting.connections.null', ['driver' => 'null']);
+});
+
+/*
+|--------------------------------------------------------------------------
+| Fixtures
+|--------------------------------------------------------------------------
+|
+| Mirrors the DB::table() fixture pattern used by the other MultiTenant
+| feature tests. No Eloquent writes in the fixtures themselves so the
+| controller's Company::create() path is exercised end-to-end in tests
+| that specifically cover it.
+*/
+
+function ccMakeOrg(array $o = []): string
+{
+ $uuid = (string) Str::uuid();
+ DB::table('companies')->insert(array_merge([
+ 'uuid' => $uuid,
+ 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => 'Org ' . substr($uuid, 0, 4),
+ 'company_type' => 'organization',
+ 'is_client' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ], $o));
+
+ return $uuid;
+}
+
+function ccMakeClient(string $parentUuid, array $o = []): string
+{
+ $uuid = (string) Str::uuid();
+ DB::table('companies')->insert(array_merge([
+ 'uuid' => $uuid,
+ 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => 'Client ' . substr($uuid, 0, 4),
+ 'company_type' => 'client',
+ 'is_client' => true,
+ 'parent_company_uuid' => $parentUuid,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ], $o));
+
+ return $uuid;
+}
+
+function ccMakeUserForCompany(string $companyUuid, bool $isDefault = true): string
+{
+ $uuid = (string) Str::uuid();
+ DB::table('users')->insert([
+ 'uuid' => $uuid,
+ 'public_id' => 'u_' . substr($uuid, 0, 8),
+ 'company_uuid' => $companyUuid,
+ 'name' => 'User ' . substr($uuid, 0, 4),
+ 'email' => 'u-' . substr($uuid, 0, 8) . '@x.test',
+ 'password' => 'x',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(),
+ 'user_uuid' => $uuid,
+ 'company_uuid' => $companyUuid,
+ 'status' => 'active',
+ 'external' => false,
+ 'access_level' => 'full',
+ 'is_default' => $isDefault,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ return $uuid;
+}
+
+// ---------------------------------------------------------------------------
+// 1. Listing returns only clients for the resolved org.
+// ---------------------------------------------------------------------------
+test('org user can list only their own client companies', function () {
+ $orgUuid = ccMakeOrg();
+ $clientA = ccMakeClient($orgUuid, ['name' => 'Alpha']);
+ $clientB = ccMakeClient($orgUuid, ['name' => 'Bravo']);
+ $userUuid = ccMakeUserForCompany($orgUuid);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')->getJson('/v1/companies/clients');
+
+ $response->assertStatus(200);
+ $uuids = collect($response->json('clients'))->pluck('uuid')->all();
+ expect($uuids)->toContain($clientA, $clientB)->toHaveCount(2);
+});
+
+// ---------------------------------------------------------------------------
+// 2. Listing scopes clients to caller's org — never sees sibling-org clients.
+// ---------------------------------------------------------------------------
+test('org user cannot see client companies belonging to another org', function () {
+ $orgA = ccMakeOrg();
+ $orgB = ccMakeOrg();
+ $aClient = ccMakeClient($orgA, ['name' => 'A-Client']);
+ $bClient = ccMakeClient($orgB, ['name' => 'B-Client']);
+
+ $userUuid = ccMakeUserForCompany($orgA);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')->getJson('/v1/companies/clients');
+ $response->assertStatus(200);
+
+ $uuids = collect($response->json('clients'))->pluck('uuid')->all();
+ expect($uuids)->toContain($aClient);
+ expect($uuids)->not->toContain($bClient);
+});
+
+// ---------------------------------------------------------------------------
+// 3. Create produces a client under the resolved org.
+// ---------------------------------------------------------------------------
+test('org user can create a client company under their current org', function () {
+ $orgUuid = ccMakeOrg();
+ $userUuid = ccMakeUserForCompany($orgUuid);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/clients', [
+ 'name' => 'Acme Client',
+ 'client_code' => 'ACME-01',
+ ]);
+
+ $response->assertStatus(201);
+ $client = $response->json('client');
+ expect($client['name'])->toBe('Acme Client');
+ expect($client['client_code'])->toBe('ACME-01');
+ expect($client['parent_company_uuid'])->toBe($orgUuid);
+ expect((bool) $client['is_client'])->toBeTrue();
+ expect($client['company_type'])->toBe('client');
+});
+
+// ---------------------------------------------------------------------------
+// 4. Payload cannot redirect parent_company_uuid / company_type / is_client.
+// ---------------------------------------------------------------------------
+test('created company is attached to the resolved org, not caller-controlled foreign org input', function () {
+ $orgUuid = ccMakeOrg();
+ $foreignOrg = ccMakeOrg();
+ $userUuid = ccMakeUserForCompany($orgUuid);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/clients', [
+ 'name' => 'Override Attempt',
+ 'parent_company_uuid' => $foreignOrg, // MUST be ignored
+ 'company_type' => 'organization', // MUST be ignored
+ 'is_client' => false, // MUST be ignored
+ ]);
+
+ $response->assertStatus(201);
+ $created = $response->json('client');
+ expect($created['parent_company_uuid'])->toBe($orgUuid);
+ expect($created['company_type'])->toBe('client');
+ expect((bool) $created['is_client'])->toBeTrue();
+
+ // Also confirm by DB lookup (belt and braces).
+ $row = DB::table('companies')->where('uuid', $created['uuid'])->first();
+ expect($row->parent_company_uuid)->toBe($orgUuid);
+ expect($row->company_type)->toBe('client');
+ expect((bool) $row->is_client)->toBeTrue();
+});
+
+// ---------------------------------------------------------------------------
+// 5. Show — in-scope client.
+// ---------------------------------------------------------------------------
+test('org user can show a client company under their org', function () {
+ $orgUuid = ccMakeOrg();
+ $client = ccMakeClient($orgUuid, ['name' => 'Showable']);
+ $userUuid = ccMakeUserForCompany($orgUuid);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')->getJson('/v1/companies/clients/' . $client);
+ $response->assertStatus(200);
+ expect($response->json('client.uuid'))->toBe($client);
+ expect($response->json('client.name'))->toBe('Showable');
+});
+
+// ---------------------------------------------------------------------------
+// 6. Show — cross-tenant target is 404 (not 403 — don't leak existence).
+// ---------------------------------------------------------------------------
+test('org user cannot show an out-of-scope client company — 404', function () {
+ $orgA = ccMakeOrg();
+ $orgB = ccMakeOrg();
+ $bClient = ccMakeClient($orgB); // belongs to a different org
+ $userUuid = ccMakeUserForCompany($orgA);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')->getJson('/v1/companies/clients/' . $bClient);
+ $response->assertStatus(404);
+});
+
+// ---------------------------------------------------------------------------
+// 7. Update happy-path.
+// ---------------------------------------------------------------------------
+test('org user can update an in-scope client company', function () {
+ $orgUuid = ccMakeOrg();
+ $client = ccMakeClient($orgUuid, ['name' => 'Before', 'client_code' => 'OLD']);
+ $userUuid = ccMakeUserForCompany($orgUuid);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')
+ ->putJson('/v1/companies/clients/' . $client, [
+ 'name' => 'After',
+ 'client_code' => 'NEW',
+ ]);
+
+ $response->assertStatus(200);
+ expect($response->json('client.name'))->toBe('After');
+ expect($response->json('client.client_code'))->toBe('NEW');
+
+ $row = DB::table('companies')->where('uuid', $client)->first();
+ expect($row->name)->toBe('After');
+ expect($row->client_code)->toBe('NEW');
+});
+
+// ---------------------------------------------------------------------------
+// 8. Update — payload CANNOT alter tenancy fields.
+// ---------------------------------------------------------------------------
+test('update cannot alter protected tenancy fields', function () {
+ $orgA = ccMakeOrg();
+ $orgB = ccMakeOrg();
+ $client = ccMakeClient($orgA, ['name' => 'Locked', 'client_code' => 'X']);
+ $userUuid = ccMakeUserForCompany($orgA);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')
+ ->putJson('/v1/companies/clients/' . $client, [
+ 'name' => 'Locked v2',
+ 'parent_company_uuid' => $orgB, // must be ignored
+ 'company_type' => 'organization', // must be ignored
+ 'is_client' => false, // must be ignored
+ ]);
+
+ $response->assertStatus(200);
+
+ $row = DB::table('companies')->where('uuid', $client)->first();
+ expect($row->parent_company_uuid)->toBe($orgA);
+ expect($row->company_type)->toBe('client');
+ expect((bool) $row->is_client)->toBeTrue();
+ expect($row->name)->toBe('Locked v2'); // whitelisted field did change
+});
+
+// ---------------------------------------------------------------------------
+// 9. Delete — in-scope.
+// ---------------------------------------------------------------------------
+test('org user can delete an in-scope client company', function () {
+ $orgUuid = ccMakeOrg();
+ $client = ccMakeClient($orgUuid);
+ $userUuid = ccMakeUserForCompany($orgUuid);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')
+ ->deleteJson('/v1/companies/clients/' . $client);
+
+ $response->assertStatus(204);
+ // Company model uses soft deletes — assert the row is soft-deleted
+ // (deleted_at populated) OR entirely gone.
+ $stillVisible = Company::where('uuid', $client)->exists();
+ expect($stillVisible)->toBeFalse();
+});
+
+// ---------------------------------------------------------------------------
+// 10. Delete — cross-tenant target is 404.
+// ---------------------------------------------------------------------------
+test('org user cannot delete an out-of-scope client company — 404', function () {
+ $orgA = ccMakeOrg();
+ $orgB = ccMakeOrg();
+ $bClient = ccMakeClient($orgB);
+ $userUuid = ccMakeUserForCompany($orgA);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')
+ ->deleteJson('/v1/companies/clients/' . $bClient);
+
+ $response->assertStatus(404);
+
+ // Record must still exist.
+ expect(DB::table('companies')->where('uuid', $bClient)->exists())->toBeTrue();
+});
+
+// ---------------------------------------------------------------------------
+// 11. Client-role user is blocked on every verb.
+// ---------------------------------------------------------------------------
+test('client-role authenticated user gets 403 on all 5 endpoints', function () {
+ $orgUuid = ccMakeOrg();
+ $clientCo = ccMakeClient($orgUuid);
+ $userUuid = ccMakeUserForCompany($clientCo); // default = client company
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $someUuid = (string) Str::uuid();
+
+ $calls = [
+ ['getJson', '/v1/companies/clients'],
+ ['postJson', '/v1/companies/clients', ['name' => 'X']],
+ ['getJson', '/v1/companies/clients/' . $someUuid],
+ ['putJson', '/v1/companies/clients/' . $someUuid, ['name' => 'X']],
+ ['deleteJson', '/v1/companies/clients/' . $someUuid],
+ ];
+
+ foreach ($calls as $call) {
+ [$method, $path] = $call;
+ $payload = $call[2] ?? [];
+
+ $response = $this->actingAs($user, 'sanctum')->{$method}($path, $payload);
+ expect($response->getStatusCode())->toBe(403);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// 12. Missing company context -> 403 (user has no pivot + no legacy column).
+// ---------------------------------------------------------------------------
+test('missing company context returns 403', function () {
+ $orgUuid = ccMakeOrg();
+ $userUuid = ccMakeUserForCompany($orgUuid);
+ // Strip every source of default/accessible company.
+ DB::table('company_users')->where('user_uuid', $userUuid)->delete();
+ DB::table('users')->where('uuid', $userUuid)->update(['company_uuid' => null]);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')->getJson('/v1/companies/clients');
+ $response->assertStatus(403);
+});
+
+// ---------------------------------------------------------------------------
+// 13. Malformed UUID in show path -> 404 (no route-model bypass).
+// ---------------------------------------------------------------------------
+test('route model binding cannot bypass org-boundary — invalid UUID returns 404', function () {
+ $orgUuid = ccMakeOrg();
+ $userUuid = ccMakeUserForCompany($orgUuid);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')->getJson('/v1/companies/clients/garbage');
+ $response->assertStatus(404);
+});
+
+// ---------------------------------------------------------------------------
+// 14. A non-client company under the same org is NOT exposed by this controller.
+// ---------------------------------------------------------------------------
+test('non-client companies (even under same org) are not exposed by this controller — 404', function () {
+ $orgUuid = ccMakeOrg();
+ // Insert a sibling non-client company flagged under the same parent.
+ $nonClientUuid = (string) Str::uuid();
+ DB::table('companies')->insert([
+ 'uuid' => $nonClientUuid,
+ 'public_id' => 'co_' . substr($nonClientUuid, 0, 8),
+ 'name' => 'Not A Client',
+ 'company_type' => 'organization',
+ 'is_client' => false,
+ 'parent_company_uuid' => $orgUuid,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $userUuid = ccMakeUserForCompany($orgUuid);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ // Show → 404
+ $this->actingAs($user, 'sanctum')
+ ->getJson('/v1/companies/clients/' . $nonClientUuid)
+ ->assertStatus(404);
+
+ // Index also excludes it.
+ $list = $this->actingAs($user, 'sanctum')->getJson('/v1/companies/clients');
+ $list->assertStatus(200);
+ $uuids = collect($list->json('clients'))->pluck('uuid')->all();
+ expect($uuids)->not->toContain($nonClientUuid);
+});
diff --git a/tests/Feature/MultiTenant/CompaniesHierarchySchemaTest.php b/tests/Feature/MultiTenant/CompaniesHierarchySchemaTest.php
new file mode 100644
index 00000000..410f184f
--- /dev/null
+++ b/tests/Feature/MultiTenant/CompaniesHierarchySchemaTest.php
@@ -0,0 +1,22 @@
+toBeTrue();
+ expect(Schema::hasColumn('companies', 'company_type'))->toBeTrue();
+ expect(Schema::hasColumn('companies', 'is_client'))->toBeTrue();
+ expect(Schema::hasColumn('companies', 'client_code'))->toBeTrue();
+ expect(Schema::hasColumn('companies', 'client_settings'))->toBeTrue();
+});
+
+test('parent_company_uuid is nullable and indexed', function () {
+ $col = collect(Schema::getColumns('companies'))->firstWhere('name', 'parent_company_uuid');
+ expect($col)->not->toBeNull();
+ expect($col['nullable'])->toBeTrue();
+});
+
+test('company_type defaults to organization', function () {
+ $col = collect(Schema::getColumns('companies'))->firstWhere('name', 'company_type');
+ expect($col['default'])->toContain('organization');
+});
diff --git a/tests/Feature/MultiTenant/CompanyContextControllerTest.php b/tests/Feature/MultiTenant/CompanyContextControllerTest.php
new file mode 100644
index 00000000..b365577e
--- /dev/null
+++ b/tests/Feature/MultiTenant/CompanyContextControllerTest.php
@@ -0,0 +1,354 @@
+set('broadcasting.default', 'null');
+ config()->set('broadcasting.connections.null', ['driver' => 'null']);
+});
+
+/*
+|--------------------------------------------------------------------------
+| Fixtures (DB::table inserts, mirroring ClientCompanyControllerTest style)
+|--------------------------------------------------------------------------
+*/
+
+function ctxMakeOrg(array $o = []): string
+{
+ $uuid = (string) Str::uuid();
+ DB::table('companies')->insert(array_merge([
+ 'uuid' => $uuid,
+ 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => 'Org ' . substr($uuid, 0, 4),
+ 'company_type' => 'organization',
+ 'is_client' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ], $o));
+
+ return $uuid;
+}
+
+function ctxMakeClient(string $parentUuid, array $o = []): string
+{
+ $uuid = (string) Str::uuid();
+ DB::table('companies')->insert(array_merge([
+ 'uuid' => $uuid,
+ 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => 'Client ' . substr($uuid, 0, 4),
+ 'company_type' => 'client',
+ 'is_client' => true,
+ 'parent_company_uuid' => $parentUuid,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ], $o));
+
+ return $uuid;
+}
+
+function ctxMakeUserForCompany(string $companyUuid, bool $isDefault = true): string
+{
+ $uuid = (string) Str::uuid();
+ DB::table('users')->insert([
+ 'uuid' => $uuid,
+ 'public_id' => 'u_' . substr($uuid, 0, 8),
+ 'company_uuid' => $companyUuid,
+ 'name' => 'User ' . substr($uuid, 0, 4),
+ 'email' => 'u-' . substr($uuid, 0, 8) . '@x.test',
+ 'password' => 'x',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(),
+ 'user_uuid' => $uuid,
+ 'company_uuid' => $companyUuid,
+ 'status' => 'active',
+ 'external' => false,
+ 'access_level' => 'full',
+ 'is_default' => $isDefault,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ return $uuid;
+}
+
+function ctxAddPivot(string $userUuid, string $companyUuid): void
+{
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(),
+ 'user_uuid' => $userUuid,
+ 'company_uuid' => $companyUuid,
+ 'status' => 'active',
+ 'external' => false,
+ 'access_level' => 'full',
+ 'is_default' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+}
+
+// ---------------------------------------------------------------------------
+// 1. current-context returns the resolved company when X-Company-Context is sent.
+// ---------------------------------------------------------------------------
+test('current-context returns the resolved company when X-Company-Context header is sent', function () {
+ $orgUuid = ctxMakeOrg(['name' => 'Acme Org']);
+ $userUuid = ctxMakeUserForCompany($orgUuid);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')
+ ->withHeaders(['X-Company-Context' => $orgUuid])
+ ->getJson('/v1/companies/current-context');
+
+ $response->assertStatus(200);
+ expect($response->json('company.uuid'))->toBe($orgUuid);
+ expect($response->json('company.name'))->toBe('Acme Org');
+ expect($response->json('company.company_type'))->toBe('organization');
+});
+
+// ---------------------------------------------------------------------------
+// 2. current-context reflects middleware resolution — target != default.
+// ---------------------------------------------------------------------------
+test('current-context reflects middleware resolution (no extra DB lookup)', function () {
+ // User has two orgs via pivot. Default is orgA. Send X-Company-Context=orgB.
+ // current-context MUST return orgB (the middleware-resolved value), NOT orgA.
+ $orgA = ctxMakeOrg(['name' => 'Alpha']);
+ $orgB = ctxMakeOrg(['name' => 'Bravo']);
+ $userUuid = ctxMakeUserForCompany($orgA); // default = orgA
+ ctxAddPivot($userUuid, $orgB);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')
+ ->withHeaders(['X-Company-Context' => $orgB])
+ ->getJson('/v1/companies/current-context');
+
+ $response->assertStatus(200);
+ expect($response->json('company.uuid'))->toBe($orgB); // not orgA
+ expect($response->json('company.name'))->toBe('Bravo');
+});
+
+// ---------------------------------------------------------------------------
+// 3. switch-context with valid accessible UUID returns the company shape.
+// ---------------------------------------------------------------------------
+test('switch-context accepts valid UUID with access and returns the company shape', function () {
+ $orgA = ctxMakeOrg(['name' => 'Alpha']);
+ $orgB = ctxMakeOrg(['name' => 'Bravo']);
+ $userUuid = ctxMakeUserForCompany($orgA);
+ ctxAddPivot($userUuid, $orgB);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/switch-context', ['company_uuid' => $orgB]);
+
+ $response->assertStatus(200);
+ expect($response->json('company.uuid'))->toBe($orgB);
+ expect($response->json('company.name'))->toBe('Bravo');
+ expect($response->json('company.company_type'))->toBe('organization');
+});
+
+// ---------------------------------------------------------------------------
+// 4. switch-context returns 403 for malformed UUID.
+// ---------------------------------------------------------------------------
+test('switch-context returns 403 for malformed UUID', function () {
+ $orgUuid = ctxMakeOrg();
+ $userUuid = ctxMakeUserForCompany($orgUuid);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/switch-context', ['company_uuid' => 'not-a-uuid']);
+
+ $response->assertStatus(403);
+ expect($response->json('error'))->toBe('Access denied to this company context');
+});
+
+// ---------------------------------------------------------------------------
+// 5. switch-context returns 403 for valid UUID with no pivot access.
+// ---------------------------------------------------------------------------
+test('switch-context returns 403 for valid UUID with no pivot access', function () {
+ $orgA = ctxMakeOrg();
+ $orgB = ctxMakeOrg(); // real company, but user has no pivot
+ $userUuid = ctxMakeUserForCompany($orgA);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/switch-context', ['company_uuid' => $orgB]);
+
+ $response->assertStatus(403);
+ expect($response->json('error'))->toBe('Access denied to this company context');
+});
+
+// ---------------------------------------------------------------------------
+// 6. switch-context returns 403 for dangling pivot (no company row).
+// ---------------------------------------------------------------------------
+test('switch-context returns 403 for valid UUID, valid pivot, but non-existent company (dangling pivot)', function () {
+ $orgA = ctxMakeOrg();
+ $userUuid = ctxMakeUserForCompany($orgA);
+ $danglingUuid = (string) Str::uuid();
+ // Dangling pivot: the user has pivot access, but the company row doesn't exist.
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(),
+ 'user_uuid' => $userUuid,
+ 'company_uuid' => $danglingUuid,
+ 'status' => 'active',
+ 'external' => false,
+ 'access_level' => 'full',
+ 'is_default' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/switch-context', ['company_uuid' => $danglingUuid]);
+
+ $response->assertStatus(403);
+ expect($response->json('error'))->toBe('Access denied to this company context');
+});
+
+// ---------------------------------------------------------------------------
+// 7. client-role user gets 403 on current-context.
+// ---------------------------------------------------------------------------
+test('client-role user gets 403 on current-context', function () {
+ $orgUuid = ctxMakeOrg();
+ $client = ctxMakeClient($orgUuid);
+ $userUuid = ctxMakeUserForCompany($client); // default = client company
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')->getJson('/v1/companies/current-context');
+
+ $response->assertStatus(403);
+});
+
+// ---------------------------------------------------------------------------
+// 8. client-role user gets 403 on switch-context (payload not validated).
+// ---------------------------------------------------------------------------
+test('client-role user gets 403 on switch-context', function () {
+ $orgUuid = ctxMakeOrg();
+ $client = ctxMakeClient($orgUuid);
+ $userUuid = ctxMakeUserForCompany($client); // default = client company
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ // Any payload — malformed or well-formed — must return 403 because the
+ // caller is client-role. The middleware blocks it before the controller.
+ $response = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/switch-context', ['company_uuid' => $orgUuid]);
+
+ $response->assertStatus(403);
+});
+
+// ---------------------------------------------------------------------------
+// 9. switch-context does NOT mutate any state.
+// ---------------------------------------------------------------------------
+test('switch-context does NOT mutate any state', function () {
+ $orgA = ctxMakeOrg(['name' => 'Alpha']);
+ $orgB = ctxMakeOrg(['name' => 'Bravo']);
+ $userUuid = ctxMakeUserForCompany($orgA); // default = orgA
+ ctxAddPivot($userUuid, $orgB);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ // Call switch-context with orgB as target, NO X-Company-Context header.
+ $switchResponse = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/switch-context', ['company_uuid' => $orgB]);
+
+ $switchResponse->assertStatus(200);
+ expect($switchResponse->json('company.uuid'))->toBe($orgB);
+
+ // Subsequent request WITHOUT X-Company-Context must still resolve to orgA
+ // (the user's default). If switch-context had mutated state (session, DB,
+ // or default company), this would return orgB.
+ $currentResponse = $this->actingAs($user, 'sanctum')
+ ->getJson('/v1/companies/current-context');
+
+ $currentResponse->assertStatus(200);
+ expect($currentResponse->json('company.uuid'))->toBe($orgA); // unchanged
+ expect($currentResponse->json('company.name'))->toBe('Alpha');
+
+ // Belt-and-braces: the pivot default row is untouched.
+ $defaultPivot = DB::table('company_users')
+ ->where('user_uuid', $userUuid)
+ ->where('is_default', true)
+ ->first();
+ expect($defaultPivot->company_uuid)->toBe($orgA);
+});
+
+// ---------------------------------------------------------------------------
+// 10. response shape contains only safe fields — no internal/client data.
+// ---------------------------------------------------------------------------
+test('response shape contains only safe fields (uuid, name, company_type) — no client_settings or internal data', function () {
+ $orgUuid = ctxMakeOrg(['name' => 'Clean Org']);
+ // Populate some extra fields that should NOT be echoed back.
+ DB::table('companies')->where('uuid', $orgUuid)->update([
+ 'stripe_id' => 'cus_secret_should_not_leak',
+ 'phone' => '+1-555-SECRET',
+ ]);
+ $userUuid = ctxMakeUserForCompany($orgUuid);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = $this->actingAs($user, 'sanctum')->getJson('/v1/companies/current-context');
+ $response->assertStatus(200);
+
+ $company = $response->json('company');
+ expect(array_keys($company))->toEqualCanonicalizing(['uuid', 'name', 'company_type']);
+ expect($company)->not->toHaveKey('client_settings');
+ expect($company)->not->toHaveKey('stripe_id');
+ expect($company)->not->toHaveKey('phone');
+});
+
+// ---------------------------------------------------------------------------
+// 11. repeated switch-context calls return identical responses (deterministic).
+// ---------------------------------------------------------------------------
+test('repeated switch-context calls return identical responses (deterministic)', function () {
+ $orgA = ctxMakeOrg(['name' => 'Alpha']);
+ $orgB = ctxMakeOrg(['name' => 'Bravo']);
+ $userUuid = ctxMakeUserForCompany($orgA);
+ ctxAddPivot($userUuid, $orgB);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $first = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/switch-context', ['company_uuid' => $orgB]);
+ $second = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/switch-context', ['company_uuid' => $orgB]);
+ $third = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/switch-context', ['company_uuid' => $orgB]);
+
+ $first->assertStatus(200);
+ $second->assertStatus(200);
+ $third->assertStatus(200);
+
+ expect($first->json())->toEqual($second->json());
+ expect($second->json())->toEqual($third->json());
+});
+
+// ---------------------------------------------------------------------------
+// 12. switch-context does not leak existence — same 403 for "no pivot" and "non-existent".
+// ---------------------------------------------------------------------------
+test('switch-context does not leak existence of cross-tenant companies — same 403 shape for "no pivot" and "non-existent"', function () {
+ $orgA = ctxMakeOrg();
+ $orgB = ctxMakeOrg(); // real, but no pivot for the user
+ $userUuid = ctxMakeUserForCompany($orgA);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $nonExistentUuid = (string) Str::uuid();
+
+ $noPivot = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/switch-context', ['company_uuid' => $orgB]);
+ $nonexistent = $this->actingAs($user, 'sanctum')
+ ->postJson('/v1/companies/switch-context', ['company_uuid' => $nonExistentUuid]);
+
+ expect($noPivot->getStatusCode())->toBe(403);
+ expect($nonexistent->getStatusCode())->toBe(403);
+
+ // Bodies are byte-identical — no information leakage differentiating the two.
+ expect($noPivot->json())->toEqual($nonexistent->json());
+});
diff --git a/tests/Feature/MultiTenant/CompanyContextResolverTest.php b/tests/Feature/MultiTenant/CompanyContextResolverTest.php
new file mode 100644
index 00000000..875c6d66
--- /dev/null
+++ b/tests/Feature/MultiTenant/CompanyContextResolverTest.php
@@ -0,0 +1,287 @@
+insert(array_merge([
+ 'uuid' => $uuid, 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => 'Org ' . substr($uuid, 0, 4),
+ 'company_type' => 'organization', 'is_client' => false,
+ 'created_at' => now(), 'updated_at' => now(),
+ ], $o));
+ return $uuid;
+}
+
+function makeClientCompany(string $parentUuid, array $o = []): string
+{
+ $uuid = (string) Str::uuid();
+ DB::table('companies')->insert(array_merge([
+ 'uuid' => $uuid, 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => 'Client ' . substr($uuid, 0, 4),
+ 'company_type' => 'client', 'is_client' => true,
+ 'parent_company_uuid' => $parentUuid,
+ 'created_at' => now(), 'updated_at' => now(),
+ ], $o));
+ return $uuid;
+}
+
+function makeUserForCompany(string $companyUuid, bool $isDefault = true): string
+{
+ $uuid = (string) Str::uuid();
+ DB::table('users')->insert([
+ 'uuid' => $uuid, 'public_id' => 'u_' . substr($uuid, 0, 8),
+ 'company_uuid' => $companyUuid,
+ 'name' => 'User ' . substr($uuid, 0, 4),
+ 'email' => 'u-' . substr($uuid, 0, 8) . '@x.test',
+ 'password' => 'x',
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(), 'user_uuid' => $uuid,
+ 'company_uuid' => $companyUuid, 'status' => 'active',
+ 'external' => false, 'access_level' => 'full',
+ 'is_default' => $isDefault,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+ return $uuid;
+}
+
+function addPivot(string $userUuid, string $companyUuid, bool $isDefault = false): void
+{
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(), 'user_uuid' => $userUuid,
+ 'company_uuid' => $companyUuid, 'status' => 'active',
+ 'external' => false, 'access_level' => 'full',
+ 'is_default' => $isDefault,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+}
+
+function runMiddleware(User $user, ?string $header): \Symfony\Component\HttpFoundation\Response
+{
+ $request = Request::create('/test');
+ if ($header !== null) {
+ $request->headers->set('X-Company-Context', $header);
+ }
+ $request->setUserResolver(fn () => $user);
+
+ $mw = new CompanyContextResolver();
+ return $mw->handle($request, fn ($r) => response('ok'));
+}
+
+afterEach(function () {
+ if (app()->bound('companyContext')) {
+ app()->forgetInstance('companyContext');
+ }
+});
+
+test('unauthenticated request passes through — no-op', function () {
+ $request = Request::create('/test');
+ $mw = new CompanyContextResolver();
+ $response = $mw->handle($request, fn ($r) => response('ok'));
+
+ expect($response->getContent())->toBe('ok');
+ expect($request->attributes->has('company'))->toBeFalse();
+});
+
+test('valid header with access resolves correct company and binds it', function () {
+ $orgUuid = makeOrgCompany();
+ $clientA = makeClientCompany($orgUuid);
+ $userUuid = makeUserForCompany($orgUuid); // org-level default
+ addPivot($userUuid, $clientA); // pivot grants access
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runMiddleware($user, $clientA);
+
+ expect($response->getContent())->toBe('ok');
+ expect(app('companyContext')->uuid)->toBe($clientA);
+});
+
+test('no header falls back to defaultCompany and binds it', function () {
+ $orgUuid = makeOrgCompany();
+ $userUuid = makeUserForCompany($orgUuid);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runMiddleware($user, null);
+
+ expect($response->getContent())->toBe('ok');
+ expect(app('companyContext')->uuid)->toBe($orgUuid);
+});
+
+test('invalid UUID format in header returns 403 (no DB hit)', function () {
+ $orgUuid = makeOrgCompany();
+ $userUuid = makeUserForCompany($orgUuid);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runMiddleware($user, 'not-a-uuid');
+
+ expect($response->getStatusCode())->toBe(403);
+ expect(app()->bound('companyContext'))->toBeFalse();
+});
+
+test('user without access to requested company returns 403', function () {
+ $orgUuid = makeOrgCompany();
+ $otherCompany = makeOrgCompany(); // user has no pivot for this
+ $userUuid = makeUserForCompany($orgUuid);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runMiddleware($user, $otherCompany);
+
+ expect($response->getStatusCode())->toBe(403);
+ expect(app()->bound('companyContext'))->toBeFalse();
+});
+
+test('requested company that does not exist (pivot dangles) returns 403', function () {
+ $orgUuid = makeOrgCompany();
+ $ghostUuid = (string) Str::uuid();
+ $userUuid = makeUserForCompany($orgUuid);
+ addPivot($userUuid, $ghostUuid); // pivot to a company that does not exist
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runMiddleware($user, $ghostUuid);
+
+ expect($response->getStatusCode())->toBe(403);
+});
+
+test('no header AND no defaultCompany returns 403', function () {
+ // User with no pivot rows, no legacy company_uuid set.
+ $orgUuid = makeOrgCompany();
+ $userUuid = makeUserForCompany($orgUuid);
+ // Strip legacy and pivot entirely.
+ DB::table('company_users')->where('user_uuid', $userUuid)->delete();
+ DB::table('users')->where('uuid', $userUuid)->update(['company_uuid' => null]);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runMiddleware($user, null);
+
+ expect($response->getStatusCode())->toBe(403);
+ expect(app()->bound('companyContext'))->toBeFalse();
+});
+
+test('client-role user is blocked unconditionally with 403', function () {
+ $orgUuid = makeOrgCompany();
+ $clientUuid = makeClientCompany($orgUuid);
+ $clientUserUuid = makeUserForCompany($clientUuid); // default company is client
+
+ $user = User::where('uuid', $clientUserUuid)->firstOrFail();
+
+ // Even if the client user passes a "valid" header to their own company —
+ // still 403 per the hard-guardrail.
+ $response = runMiddleware($user, $clientUuid);
+ expect($response->getStatusCode())->toBe(403);
+
+ // No header → also 403.
+ $response = runMiddleware($user, null);
+ expect($response->getStatusCode())->toBe(403);
+
+ // Arbitrary uuid → 403 (never reaches access check).
+ $response = runMiddleware($user, (string) Str::uuid());
+ expect($response->getStatusCode())->toBe(403);
+});
+
+test('header name is case-insensitive per HTTP spec', function () {
+ $orgUuid = makeOrgCompany();
+ $clientA = makeClientCompany($orgUuid);
+ $userUuid = makeUserForCompany($orgUuid);
+ addPivot($userUuid, $clientA);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ // Symfony HeaderBag normalizes to lowercase internally, but the public API
+ // accepts any case. Prove our middleware works with mixed-case headers.
+ $request = Request::create('/test');
+ $request->headers->set('x-company-context', $clientA); // lowercase
+ $request->setUserResolver(fn () => $user);
+
+ $mw = new CompanyContextResolver();
+ $response = $mw->handle($request, fn ($r) => response('ok'));
+
+ expect($response->getContent())->toBe('ok');
+ expect(app('companyContext')->uuid)->toBe($clientA);
+});
+
+test('UUID value is accepted regardless of letter case', function () {
+ $orgUuid = makeOrgCompany();
+ $clientA = makeClientCompany($orgUuid);
+ $userUuid = makeUserForCompany($orgUuid);
+ addPivot($userUuid, $clientA);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $response = runMiddleware($user, strtoupper($clientA));
+ // DB uuids are stored lowercase; canAccessCompany's where clause is
+ // case-sensitive on most MySQL collations. We expect EITHER:
+ // - a 200 (middleware normalized) OR
+ // - a 403 (middleware is strict on case, so only the stored form works)
+ // Either behavior is defensible; assert that the result is deterministic
+ // and that if it was accepted, the bound company has the stored uuid.
+ $status = $response->getStatusCode();
+ expect($status === 200 || $status === 403)->toBeTrue();
+ if ($status === 200) {
+ expect(app('companyContext')->uuid)->toBe($clientA); // stored (lowercase) form
+ }
+});
+
+test('empty-string header falls back to defaultCompany (treated as absent)', function () {
+ $orgUuid = makeOrgCompany();
+ $userUuid = makeUserForCompany($orgUuid);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runMiddleware($user, '');
+
+ expect($response->getContent())->toBe('ok');
+ expect(app('companyContext')->uuid)->toBe($orgUuid);
+});
+
+test('whitespace-only header falls back to defaultCompany (treated as absent)', function () {
+ $orgUuid = makeOrgCompany();
+ $userUuid = makeUserForCompany($orgUuid);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runMiddleware($user, ' ');
+
+ expect($response->getContent())->toBe('ok');
+ expect(app('companyContext')->uuid)->toBe($orgUuid);
+});
+
+test('request attributes binding also set (not just container instance)', function () {
+ $orgUuid = makeOrgCompany();
+ $userUuid = makeUserForCompany($orgUuid);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $request = Request::create('/test');
+ $request->setUserResolver(fn () => $user);
+
+ $mw = new CompanyContextResolver();
+ $response = $mw->handle($request, function ($r) {
+ expect($r->attributes->get('company')?->uuid)->toBe($r->user()->defaultCompany()->uuid);
+ return response('ok');
+ });
+
+ expect($response->getContent())->toBe('ok');
+});
+
+test('terminate() forgets the container instance (prevents cross-request leak)', function () {
+ $orgUuid = makeOrgCompany();
+ $userUuid = makeUserForCompany($orgUuid);
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ $request = Request::create('/test');
+ $request->setUserResolver(fn () => $user);
+ $mw = new CompanyContextResolver();
+ $response = $mw->handle($request, fn ($r) => response('ok'));
+
+ expect(app()->bound('companyContext'))->toBeTrue();
+
+ $mw->terminate($request, $response);
+
+ expect(app()->bound('companyContext'))->toBeFalse();
+});
diff --git a/tests/Feature/MultiTenant/CompanyHierarchyRelationsTest.php b/tests/Feature/MultiTenant/CompanyHierarchyRelationsTest.php
new file mode 100644
index 00000000..ce8979e8
--- /dev/null
+++ b/tests/Feature/MultiTenant/CompanyHierarchyRelationsTest.php
@@ -0,0 +1,107 @@
+insert(array_merge([
+ 'uuid' => $uuid,
+ 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => 'Fixture Co ' . substr($uuid, 0, 4),
+ 'company_type' => 'organization',
+ 'is_client' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ], $attributes));
+
+ return Company::where('uuid', $uuid)->firstOrFail();
+}
+
+test('parent company has many client companies, and clients know their parent', function () {
+ $parent = makeCompany(['company_type' => 'organization']);
+ $child = makeCompany([
+ 'company_type' => 'client',
+ 'is_client' => true,
+ 'parent_company_uuid' => $parent->uuid,
+ ]);
+
+ expect($parent->clientCompanies->pluck('uuid')->toArray())->toContain($child->uuid);
+ expect($child->parentCompany->uuid)->toBe($parent->uuid);
+});
+
+test('isClient and isOrganization predicates', function () {
+ $org = makeCompany(['company_type' => 'organization']);
+ $client = makeCompany(['company_type' => 'client', 'is_client' => true]);
+
+ expect($org->isOrganization())->toBeTrue();
+ expect($org->isClient())->toBeFalse();
+ expect($client->isClient())->toBeTrue();
+ expect($client->isOrganization())->toBeFalse();
+});
+
+test('isClient returns true when either column signals client state', function () {
+ // Predicate should tolerate either signal (is_client column OR company_type='client')
+ $onlyFlag = new Company(['company_type' => 'organization', 'is_client' => true]);
+ $onlyType = new Company(['company_type' => 'client', 'is_client' => false]);
+ $neither = new Company(['company_type' => 'organization', 'is_client' => false]);
+
+ expect($onlyFlag->isClient())->toBeTrue();
+ expect($onlyType->isClient())->toBeTrue();
+ expect($neither->isClient())->toBeFalse();
+});
+
+test('getAccessibleCompanyUuids returns self plus children for organization', function () {
+ $parent = makeCompany(['company_type' => 'organization']);
+ $childA = makeCompany(['company_type' => 'client', 'is_client' => true, 'parent_company_uuid' => $parent->uuid]);
+ $childB = makeCompany(['company_type' => 'client', 'is_client' => true, 'parent_company_uuid' => $parent->uuid]);
+
+ $uuids = $parent->getAccessibleCompanyUuids();
+
+ expect($uuids)->toContain($parent->uuid, $childA->uuid, $childB->uuid);
+ expect(count($uuids))->toBe(3);
+});
+
+test('getAccessibleCompanyUuids for a client returns only self', function () {
+ $parent = makeCompany(['company_type' => 'organization']);
+ $client = makeCompany(['company_type' => 'client', 'is_client' => true, 'parent_company_uuid' => $parent->uuid]);
+
+ expect($client->getAccessibleCompanyUuids())->toBe([$client->uuid]);
+});
+
+test('getAccessibleCompanyUuids for a lone organization with no children returns only self', function () {
+ $org = makeCompany(['company_type' => 'organization']);
+
+ expect($org->getAccessibleCompanyUuids())->toBe([$org->uuid]);
+});
+
+test('scopeClients and scopeOrganizations filter correctly', function () {
+ $orgCount = Company::organizations()->count();
+ $clientCount = Company::clients()->count();
+
+ makeCompany(['company_type' => 'organization']);
+ makeCompany(['company_type' => 'client', 'is_client' => true]);
+ makeCompany(['company_type' => 'client', 'is_client' => true]);
+
+ expect(Company::organizations()->count())->toBe($orgCount + 1);
+ expect(Company::clients()->count())->toBe($clientCount + 2);
+});
+
+test('client_settings is castable to array when JSON is stored', function () {
+ $company = makeCompany([
+ 'company_type' => 'client',
+ 'is_client' => true,
+ 'client_settings' => json_encode(['foo' => 'bar', 'nested' => ['baz' => 1]]),
+ ]);
+
+ expect($company->client_settings)->toBeArray();
+ expect($company->client_settings['foo'])->toBe('bar');
+ expect($company->client_settings['nested']['baz'])->toBe(1);
+});
diff --git a/tests/Feature/MultiTenant/CompanyUserAccessTest.php b/tests/Feature/MultiTenant/CompanyUserAccessTest.php
new file mode 100644
index 00000000..bb330a58
--- /dev/null
+++ b/tests/Feature/MultiTenant/CompanyUserAccessTest.php
@@ -0,0 +1,151 @@
+insert([
+ 'uuid' => $companyUuid,
+ 'public_id' => 'co_' . substr($companyUuid, 0, 8),
+ 'name' => 'Pair Co ' . substr($companyUuid, 0, 4),
+ 'company_type' => 'organization',
+ 'is_client' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ DB::table('users')->insert([
+ 'uuid' => $userUuid,
+ 'public_id' => 'u_' . substr($userUuid, 0, 8),
+ 'company_uuid' => $companyUuid,
+ 'name' => 'Pair User',
+ 'email' => 'pair-' . substr($userUuid, 0, 8) . '@example.test',
+ 'password' => 'x',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ return [$companyUuid, $userUuid];
+}
+
+test('access_level and is_default are mass-assignable via create()', function () {
+ [$companyUuid, $userUuid] = makeCompanyUserPair();
+
+ $pivot = CompanyUser::create([
+ 'user_uuid' => $userUuid,
+ 'company_uuid' => $companyUuid,
+ 'access_level' => 'financial',
+ 'is_default' => true,
+ ]);
+
+ expect($pivot->access_level)->toBe('financial');
+ expect($pivot->is_default)->toBeTrue();
+ expect($pivot->user_uuid)->toBe($userUuid);
+ expect($pivot->company_uuid)->toBe($companyUuid);
+});
+
+test('is_default is cast to a real PHP boolean, not int or string', function () {
+ [$companyUuid, $userUuid] = makeCompanyUserPair();
+
+ // Insert via query builder so we control the raw storage shape,
+ // then read through Eloquent and verify the cast.
+ $pivotUuid = (string) Str::uuid();
+ DB::table('company_users')->insert([
+ 'uuid' => $pivotUuid,
+ 'user_uuid' => $userUuid,
+ 'company_uuid' => $companyUuid,
+ 'status' => 'active',
+ 'external' => false,
+ 'access_level' => 'operations',
+ 'is_default' => 1, // raw integer in DB
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $pivot = CompanyUser::where('uuid', $pivotUuid)->firstOrFail();
+
+ expect($pivot->is_default)->toBeTrue();
+ expect($pivot->is_default)->toBeBool(); // real bool, not (bool)1 surrogate
+ expect(gettype($pivot->is_default))->toBe('boolean');
+});
+
+test('is_default false cast returns real boolean false', function () {
+ [$companyUuid, $userUuid] = makeCompanyUserPair();
+
+ $pivotUuid = (string) Str::uuid();
+ DB::table('company_users')->insert([
+ 'uuid' => $pivotUuid,
+ 'user_uuid' => $userUuid,
+ 'company_uuid' => $companyUuid,
+ 'status' => 'active',
+ 'external' => false,
+ 'access_level' => 'full',
+ 'is_default' => 0,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $pivot = CompanyUser::where('uuid', $pivotUuid)->firstOrFail();
+
+ expect($pivot->is_default)->toBeFalse();
+ expect($pivot->is_default)->toBeBool();
+});
+
+test('existing pivot behavior preserved: user and company relations still work', function () {
+ [$companyUuid, $userUuid] = makeCompanyUserPair();
+
+ $pivot = CompanyUser::create([
+ 'user_uuid' => $userUuid,
+ 'company_uuid' => $companyUuid,
+ ]);
+
+ expect($pivot->user)->not->toBeNull();
+ expect($pivot->user->uuid)->toBe($userUuid);
+ expect($pivot->company)->not->toBeNull();
+ expect($pivot->company->uuid)->toBe($companyUuid);
+});
+
+test('existing pivot behavior preserved: external and status still work with defaults', function () {
+ [$companyUuid, $userUuid] = makeCompanyUserPair();
+
+ $pivot = CompanyUser::create([
+ 'user_uuid' => $userUuid,
+ 'company_uuid' => $companyUuid,
+ ]);
+
+ // status has a mutator defaulting to 'active'; external has a DB default of false.
+ $fresh = $pivot->fresh();
+ expect($fresh->status)->toBe('active');
+ expect($fresh->external)->toBeFalse();
+});
+
+test('access_level defaults to full when not provided', function () {
+ [$companyUuid, $userUuid] = makeCompanyUserPair();
+
+ $pivot = CompanyUser::create([
+ 'user_uuid' => $userUuid,
+ 'company_uuid' => $companyUuid,
+ ]);
+
+ expect($pivot->fresh()->access_level)->toBe('full');
+});
+
+test('is_default defaults to false when not provided', function () {
+ [$companyUuid, $userUuid] = makeCompanyUserPair();
+
+ $pivot = CompanyUser::create([
+ 'user_uuid' => $userUuid,
+ 'company_uuid' => $companyUuid,
+ ]);
+
+ expect($pivot->fresh()->is_default)->toBeFalse();
+});
diff --git a/tests/Feature/MultiTenant/CompanyUsersSchemaTest.php b/tests/Feature/MultiTenant/CompanyUsersSchemaTest.php
new file mode 100644
index 00000000..6770eb04
--- /dev/null
+++ b/tests/Feature/MultiTenant/CompanyUsersSchemaTest.php
@@ -0,0 +1,23 @@
+toBeTrue();
+ expect(Schema::hasColumn('company_users', 'is_default'))->toBeTrue();
+});
+
+test('is_default defaults to false', function () {
+ $col = collect(Schema::getColumns('company_users'))->firstWhere('name', 'is_default');
+ expect($col)->not->toBeNull();
+ // Drivers report boolean defaults inconsistently: MySQL/MariaDB return "'0'"
+ // (literal quotes), SQLite returns "0", Postgres may return "false". Normalize
+ // by stripping surrounding single quotes before casting to bool.
+ $raw = trim((string) $col['default'], "'");
+ expect((bool) $raw)->toBeFalse();
+});
+
+test('access_level defaults to full', function () {
+ $col = collect(Schema::getColumns('company_users'))->firstWhere('name', 'access_level');
+ expect($col['default'])->toContain('full');
+});
diff --git a/tests/Feature/MultiTenant/MiddlewareAliasRegistrationTest.php b/tests/Feature/MultiTenant/MiddlewareAliasRegistrationTest.php
new file mode 100644
index 00000000..df73d91a
--- /dev/null
+++ b/tests/Feature/MultiTenant/MiddlewareAliasRegistrationTest.php
@@ -0,0 +1,10 @@
+getMiddleware();
+
+ expect($aliases)->toHaveKey('fleetbase.company.context');
+ expect($aliases['fleetbase.company.context'])->toBe(\Fleetbase\Http\Middleware\CompanyContextResolver::class);
+});
diff --git a/tests/Feature/MultiTenant/ModelsHaveScopedTraitTest.php b/tests/Feature/MultiTenant/ModelsHaveScopedTraitTest.php
new file mode 100644
index 00000000..69f1ab4d
--- /dev/null
+++ b/tests/Feature/MultiTenant/ModelsHaveScopedTraitTest.php
@@ -0,0 +1,43 @@
+toBeTrue(
+ "Expected {$path} to be mounted into the test container. " .
+ "Run this test with the fleetops and ledger volume mounts."
+ );
+
+ $source = file_get_contents($path);
+
+ // 1. The import statement exists.
+ expect($source)->toContain('use Fleetbase\Models\Concerns\ScopedToCompanyContext;');
+
+ // 2. The trait is actually used inside the class body.
+ expect($source)->toMatch('/class\s+\w+\s+extends[^{]*\{[\s\S]*use\s+[\w\\\\,\s]*ScopedToCompanyContext/');
+ });
+}
diff --git a/tests/Feature/MultiTenant/ScopedToCompanyContextTest.php b/tests/Feature/MultiTenant/ScopedToCompanyContextTest.php
new file mode 100644
index 00000000..1fc1b4e8
--- /dev/null
+++ b/tests/Feature/MultiTenant/ScopedToCompanyContextTest.php
@@ -0,0 +1,234 @@
+insert([
+ 'uuid' => $uuid, 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => $name, 'company_type' => 'organization', 'is_client' => false,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+ return $uuid;
+}
+
+/**
+ * Fetch a Company Eloquent instance by uuid (read-only, no observer fires).
+ */
+function fixtureCompanyModel(string $uuid): Company
+{
+ return Company::where('uuid', $uuid)->firstOrFail();
+}
+
+beforeEach(function () {
+ // Ephemeral fixture table — exists only for this suite.
+ Schema::dropIfExists('scope_fixture_rows');
+ Schema::create('scope_fixture_rows', function ($t) {
+ $t->increments('id');
+ $t->char('company_uuid', 36)->index();
+ $t->string('name');
+ $t->softDeletes();
+ $t->timestamps();
+ });
+});
+
+afterEach(function () {
+ Schema::dropIfExists('scope_fixture_rows');
+ if (app()->bound('companyContext')) {
+ app()->forgetInstance('companyContext');
+ }
+ if (app()->bound('request')) {
+ $req = app('request');
+ if (isset($req->attributes)
+ && $req->attributes instanceof \Symfony\Component\HttpFoundation\ParameterBag) {
+ $req->attributes->remove('company');
+ }
+ }
+});
+
+test('with company context bound via container, rows for that company are returned', function () {
+ $uuidA = fixtureCompany('A');
+ $uuidB = fixtureCompany('B');
+
+ ScopeFixtureRow::create(['company_uuid' => $uuidA, 'name' => 'keep']);
+ ScopeFixtureRow::create(['company_uuid' => $uuidB, 'name' => 'drop']);
+
+ app()->instance('companyContext', fixtureCompanyModel($uuidA));
+
+ $names = ScopeFixtureRow::inCompanyContext()->pluck('name')->toArray();
+
+ expect($names)->toBe(['keep']);
+});
+
+test('rows for other companies are excluded', function () {
+ $uuidA = fixtureCompany('A');
+ $uuidB = fixtureCompany('B');
+ $uuidC = fixtureCompany('C');
+
+ ScopeFixtureRow::create(['company_uuid' => $uuidA, 'name' => 'a1']);
+ ScopeFixtureRow::create(['company_uuid' => $uuidA, 'name' => 'a2']);
+ ScopeFixtureRow::create(['company_uuid' => $uuidB, 'name' => 'b1']);
+ ScopeFixtureRow::create(['company_uuid' => $uuidC, 'name' => 'c1']);
+
+ app()->instance('companyContext', fixtureCompanyModel($uuidA));
+
+ $names = ScopeFixtureRow::inCompanyContext()->orderBy('name')->pluck('name')->toArray();
+
+ expect($names)->toBe(['a1', 'a2']);
+});
+
+test('with NO company context bound, result set is empty (fail-closed)', function () {
+ $uuidA = fixtureCompany('A');
+ $uuidB = fixtureCompany('B');
+
+ ScopeFixtureRow::create(['company_uuid' => $uuidA, 'name' => 'a']);
+ ScopeFixtureRow::create(['company_uuid' => $uuidB, 'name' => 'b']);
+
+ // Explicitly ensure no binding is present.
+ expect(app()->bound('companyContext'))->toBeFalse();
+
+ $rows = ScopeFixtureRow::inCompanyContext()->get();
+
+ expect($rows->count())->toBe(0);
+});
+
+test('soft-deleted rows are excluded whether or not context is bound', function () {
+ $uuidA = fixtureCompany('A');
+
+ $live = ScopeFixtureRow::create(['company_uuid' => $uuidA, 'name' => 'live']);
+ $dead = ScopeFixtureRow::create(['company_uuid' => $uuidA, 'name' => 'dead']);
+ $dead->delete();
+
+ app()->instance('companyContext', fixtureCompanyModel($uuidA));
+
+ $names = ScopeFixtureRow::inCompanyContext()->pluck('name')->toArray();
+
+ expect($names)->toBe(['live']);
+});
+
+test('repeated queries in the same request remain correctly scoped', function () {
+ $uuidA = fixtureCompany('A');
+ $uuidB = fixtureCompany('B');
+
+ ScopeFixtureRow::create(['company_uuid' => $uuidA, 'name' => 'a']);
+ ScopeFixtureRow::create(['company_uuid' => $uuidB, 'name' => 'b']);
+
+ app()->instance('companyContext', fixtureCompanyModel($uuidA));
+
+ $first = ScopeFixtureRow::inCompanyContext()->pluck('name')->toArray();
+ $second = ScopeFixtureRow::inCompanyContext()->pluck('name')->toArray();
+ $third = ScopeFixtureRow::inCompanyContext()->count();
+
+ expect($first)->toBe(['a']);
+ expect($second)->toBe(['a']);
+ expect($third)->toBe(1);
+});
+
+test('request attribute binding is honored', function () {
+ $uuidA = fixtureCompany('A');
+ $uuidB = fixtureCompany('B');
+
+ ScopeFixtureRow::create(['company_uuid' => $uuidA, 'name' => 'keep']);
+ ScopeFixtureRow::create(['company_uuid' => $uuidB, 'name' => 'drop']);
+
+ // Use the real app request and set the attribute.
+ app('request')->attributes->set('company', fixtureCompanyModel($uuidA));
+
+ // No container binding — proves the request path alone is sufficient.
+ expect(app()->bound('companyContext'))->toBeFalse();
+
+ $names = ScopeFixtureRow::inCompanyContext()->pluck('name')->toArray();
+
+ expect($names)->toBe(['keep']);
+});
+
+test('request attribute takes precedence over container instance', function () {
+ $uuidA = fixtureCompany('A');
+ $uuidB = fixtureCompany('B');
+
+ ScopeFixtureRow::create(['company_uuid' => $uuidA, 'name' => 'from-request']);
+ ScopeFixtureRow::create(['company_uuid' => $uuidB, 'name' => 'from-container']);
+
+ // Request attribute points at A; container instance points at B. Request wins.
+ app('request')->attributes->set('company', fixtureCompanyModel($uuidA));
+ app()->instance('companyContext', fixtureCompanyModel($uuidB));
+
+ $names = ScopeFixtureRow::inCompanyContext()->pluck('name')->toArray();
+
+ expect($names)->toBe(['from-request']);
+});
+
+test('non-Company value in container binding falls through to empty (defensive)', function () {
+ $uuidA = fixtureCompany('A');
+ ScopeFixtureRow::create(['company_uuid' => $uuidA, 'name' => 'a']);
+
+ // Someone accidentally bound a non-Company value. The scope must not leak.
+ app()->instance('companyContext', 'not-a-company');
+
+ $rows = ScopeFixtureRow::inCompanyContext()->get();
+
+ expect($rows->count())->toBe(0);
+});
+
+test('no database writes or observer side effects occur during scoped queries', function () {
+ $uuidA = fixtureCompany('A');
+ ScopeFixtureRow::create(['company_uuid' => $uuidA, 'name' => 'a']);
+
+ app()->instance('companyContext', fixtureCompanyModel($uuidA));
+
+ // Baseline counts
+ $companyCountBefore = DB::table('companies')->count();
+ $pivotCountBefore = DB::table('company_users')->count();
+
+ // Run several scoped queries.
+ ScopeFixtureRow::inCompanyContext()->get();
+ ScopeFixtureRow::inCompanyContext()->count();
+ ScopeFixtureRow::inCompanyContext()->where('name', 'a')->first();
+
+ // Writes (other than the fixture create above) should not have occurred.
+ expect(DB::table('companies')->count())->toBe($companyCountBefore);
+ expect(DB::table('company_users')->count())->toBe($pivotCountBefore);
+});
+
+test('cross-tenant leakage is impossible when middleware is omitted', function () {
+ $uuidA = fixtureCompany('A');
+ $uuidB = fixtureCompany('B');
+ $uuidC = fixtureCompany('C');
+
+ ScopeFixtureRow::create(['company_uuid' => $uuidA, 'name' => 'a']);
+ ScopeFixtureRow::create(['company_uuid' => $uuidB, 'name' => 'b']);
+ ScopeFixtureRow::create(['company_uuid' => $uuidC, 'name' => 'c']);
+
+ // Simulate "middleware forgot to run" — no binding of any kind.
+ expect(app()->bound('companyContext'))->toBeFalse();
+ expect(app('request')->attributes->has('company'))->toBeFalse();
+
+ $rows = ScopeFixtureRow::inCompanyContext()->get();
+
+ // The whole point: zero, not three.
+ expect($rows->count())->toBe(0);
+});
diff --git a/tests/Feature/MultiTenant/SeedExistingCompaniesTest.php b/tests/Feature/MultiTenant/SeedExistingCompaniesTest.php
new file mode 100644
index 00000000..14b65f46
--- /dev/null
+++ b/tests/Feature/MultiTenant/SeedExistingCompaniesTest.php
@@ -0,0 +1,161 @@
+not->toBeFalse();
+ $migration = require $path;
+ $migration->up();
+}
+
+test('every no-parent company is set to company_type organization after seed', function () {
+ // RefreshDatabase has already run all migrations including the seed.
+ $violators = DB::table('companies')
+ ->whereNull('parent_company_uuid')
+ ->where(function ($q) {
+ $q->where('company_type', '!=', 'organization')
+ ->orWhereNull('company_type');
+ })
+ ->count();
+
+ expect($violators)->toBe(0);
+});
+
+test('every user with a company_uuid has exactly one is_default pivot row', function () {
+ // Seed a test fixture: user with a matching company_users pivot.
+ $userUuid = (string) Str::uuid();
+ $companyUuid = (string) Str::uuid();
+
+ DB::table('companies')->insert([
+ 'uuid' => $companyUuid,
+ 'public_id' => 'co_' . substr($companyUuid, 0, 8),
+ 'name' => 'Fixture Co',
+ 'company_type' => 'organization',
+ 'is_client' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ DB::table('users')->insert([
+ 'uuid' => $userUuid,
+ 'public_id' => 'u_' . substr($userUuid, 0, 8),
+ 'company_uuid' => $companyUuid,
+ 'name' => 'Fixture User',
+ 'email' => 'fixture-' . substr($userUuid, 0, 8) . '@example.test',
+ 'password' => 'x',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(),
+ 'user_uuid' => $userUuid,
+ 'company_uuid' => $companyUuid,
+ 'status' => 'active',
+ 'is_default' => false, // will be flipped by re-running up()
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ runSeedMigrationUp();
+
+ $defaults = DB::table('company_users')
+ ->where('user_uuid', $userUuid)
+ ->where('is_default', true)
+ ->count();
+
+ expect($defaults)->toBe(1);
+});
+
+test('re-running up() is idempotent — no state change, no duplicate rows', function () {
+ $userUuid = (string) Str::uuid();
+ $companyUuid = (string) Str::uuid();
+
+ DB::table('companies')->insert([
+ 'uuid' => $companyUuid,
+ 'public_id' => 'co_' . substr($companyUuid, 0, 8),
+ 'name' => 'Idempotent Co',
+ 'company_type' => 'organization',
+ 'is_client' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ DB::table('users')->insert([
+ 'uuid' => $userUuid,
+ 'public_id' => 'u_' . substr($userUuid, 0, 8),
+ 'company_uuid' => $companyUuid,
+ 'name' => 'Idempotent User',
+ 'email' => 'idem-' . substr($userUuid, 0, 8) . '@example.test',
+ 'password' => 'x',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ // First run creates the pivot.
+ runSeedMigrationUp();
+ $afterFirst = DB::table('company_users')
+ ->where('user_uuid', $userUuid)
+ ->get()
+ ->toArray();
+ expect(count($afterFirst))->toBe(1);
+ expect((bool) $afterFirst[0]->is_default)->toBeTrue();
+
+ // Second run must be a no-op.
+ runSeedMigrationUp();
+ $afterSecond = DB::table('company_users')
+ ->where('user_uuid', $userUuid)
+ ->get()
+ ->toArray();
+ expect(count($afterSecond))->toBe(1); // no duplicate insert
+ expect($afterSecond[0]->id)->toBe($afterFirst[0]->id); // same row
+ expect((bool) $afterSecond[0]->is_default)->toBeTrue();
+});
+
+test('no user ever has more than one is_default pivot row after seed', function () {
+ $userUuid = (string) Str::uuid();
+ $companyA = (string) Str::uuid();
+ $companyB = (string) Str::uuid();
+
+ foreach ([$companyA, $companyB] as $companyUuid) {
+ DB::table('companies')->insert([
+ 'uuid' => $companyUuid,
+ 'public_id' => 'co_' . substr($companyUuid, 0, 8),
+ 'name' => 'Multi Co ' . substr($companyUuid, 0, 4),
+ 'company_type' => 'organization',
+ 'is_client' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+ DB::table('users')->insert([
+ 'uuid' => $userUuid,
+ 'public_id' => 'u_' . substr($userUuid, 0, 8),
+ 'company_uuid' => $companyA,
+ 'name' => 'Multi User',
+ 'email' => 'multi-' . substr($userUuid, 0, 8) . '@example.test',
+ 'password' => 'x',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ // Give user pivot rows for BOTH companies, both marked default (bad state).
+ DB::table('company_users')->insert([
+ ['uuid' => (string) Str::uuid(), 'user_uuid' => $userUuid, 'company_uuid' => $companyA, 'status' => 'active', 'is_default' => true, 'created_at' => now(), 'updated_at' => now()],
+ ['uuid' => (string) Str::uuid(), 'user_uuid' => $userUuid, 'company_uuid' => $companyB, 'status' => 'active', 'is_default' => true, 'created_at' => now(), 'updated_at' => now()],
+ ]);
+
+ runSeedMigrationUp();
+
+ $defaults = DB::table('company_users')
+ ->where('user_uuid', $userUuid)
+ ->where('is_default', true)
+ ->get();
+
+ expect($defaults->count())->toBe(1);
+ expect($defaults->first()->company_uuid)->toBe($companyA); // default matches users.company_uuid
+});
diff --git a/tests/Feature/MultiTenant/UserCompanyAccessTest.php b/tests/Feature/MultiTenant/UserCompanyAccessTest.php
new file mode 100644
index 00000000..348516e6
--- /dev/null
+++ b/tests/Feature/MultiTenant/UserCompanyAccessTest.php
@@ -0,0 +1,190 @@
+insert(array_merge([
+ 'uuid' => $uuid,
+ 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => 'Co ' . substr($uuid, 0, 4),
+ 'company_type' => 'organization',
+ 'is_client' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ], $overrides));
+
+ return $uuid;
+}
+
+/**
+ * Insert a user via query builder and return its uuid.
+ */
+function makeUserRow(string $companyUuid, array $overrides = []): string
+{
+ $uuid = (string) Str::uuid();
+ DB::table('users')->insert(array_merge([
+ 'uuid' => $uuid,
+ 'public_id' => 'u_' . substr($uuid, 0, 8),
+ 'company_uuid' => $companyUuid,
+ 'name' => 'User ' . substr($uuid, 0, 4),
+ 'email' => 'user-' . substr($uuid, 0, 8) . '@example.test',
+ 'password' => 'x',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ], $overrides));
+
+ return $uuid;
+}
+
+/**
+ * Insert a company_users pivot row via query builder.
+ */
+function makePivot(string $userUuid, string $companyUuid, bool $isDefault = false, string $accessLevel = 'full'): void
+{
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(),
+ 'user_uuid' => $userUuid,
+ 'company_uuid' => $companyUuid,
+ 'status' => 'active',
+ 'external' => false,
+ 'access_level' => $accessLevel,
+ 'is_default' => $isDefault,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+}
+
+test('defaultCompany returns the company linked via pivot is_default', function () {
+ $companyA = makeCompanyRow(['name' => 'A']);
+ $companyB = makeCompanyRow(['name' => 'B']);
+ $userUuid = makeUserRow($companyA); // legacy company = A
+
+ makePivot($userUuid, $companyA, isDefault: false);
+ makePivot($userUuid, $companyB, isDefault: true); // pivot-default = B
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $default = $user->defaultCompany();
+
+ expect($default)->not->toBeNull();
+ expect($default->uuid)->toBe($companyB); // pivot wins over legacy
+});
+
+test('defaultCompany falls back to users.company_uuid when no pivot is_default exists', function () {
+ $companyA = makeCompanyRow(['name' => 'Legacy Home']);
+ $userUuid = makeUserRow($companyA);
+
+ // Pivot row exists but NOT marked default.
+ makePivot($userUuid, $companyA, isDefault: false);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $default = $user->defaultCompany();
+
+ expect($default)->not->toBeNull();
+ expect($default->uuid)->toBe($companyA); // fell back to legacy
+});
+
+test('defaultCompany returns null when user has no pivots and no legacy company_uuid', function () {
+ // users.company_uuid is nullable per the base migration, so we can null it out
+ // directly via query builder after insert. No pivot rows + NULL legacy → defaultCompany() must be null.
+ $placeholder = makeCompanyRow(['name' => 'Placeholder']);
+ $userUuid = makeUserRow($placeholder);
+
+ // Null the legacy pointer so the company() BelongsTo returns null.
+ DB::table('users')->where('uuid', $userUuid)->update(['company_uuid' => null]);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ expect($user->defaultCompany())->toBeNull();
+});
+
+test('canAccessCompany returns true only for companies with a pivot row', function () {
+ $accessible = makeCompanyRow(['name' => 'Accessible']);
+ $forbidden = makeCompanyRow(['name' => 'Forbidden']);
+ $userUuid = makeUserRow($accessible);
+
+ makePivot($userUuid, $accessible);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ expect($user->canAccessCompany($accessible))->toBeTrue();
+ expect($user->canAccessCompany($forbidden))->toBeFalse();
+});
+
+test('canAccessCompany does NOT count the legacy users.company_uuid if no pivot exists', function () {
+ // Strict accessibility semantics: legacy column alone is not enough.
+ $legacyOnly = makeCompanyRow(['name' => 'Legacy Only']);
+ $userUuid = makeUserRow($legacyOnly); // users.company_uuid set, no pivot
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ expect($user->canAccessCompany($legacyOnly))->toBeFalse();
+});
+
+test('accessibleCompanyUuids includes all distinct pivot companies', function () {
+ $a = makeCompanyRow(['name' => 'A']);
+ $b = makeCompanyRow(['name' => 'B']);
+ $c = makeCompanyRow(['name' => 'C']);
+ $userUuid = makeUserRow($a);
+
+ makePivot($userUuid, $a);
+ makePivot($userUuid, $b);
+ makePivot($userUuid, $c);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $uuids = $user->accessibleCompanyUuids();
+
+ expect($uuids)->toContain($a, $b, $c);
+ expect(count($uuids))->toBe(3);
+});
+
+test('accessibleCompanyUuids returns no duplicates even if the same company is pivoted twice', function () {
+ $a = makeCompanyRow(['name' => 'Dup']);
+ $userUuid = makeUserRow($a);
+
+ // Two pivot rows for the same (user, company). Unusual but not forbidden
+ // by the current schema (no unique constraint on user_uuid+company_uuid).
+ makePivot($userUuid, $a);
+ makePivot($userUuid, $a);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $uuids = $user->accessibleCompanyUuids();
+
+ expect(count($uuids))->toBe(1);
+ expect($uuids[0])->toBe($a);
+});
+
+test('existing User relations preserved: company() BelongsTo and companyUsers() HasMany still work', function () {
+ $home = makeCompanyRow(['name' => 'Home']);
+ $alt = makeCompanyRow(['name' => 'Alt']);
+ $userUuid = makeUserRow($home);
+
+ makePivot($userUuid, $home);
+ makePivot($userUuid, $alt);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+
+ // BelongsTo — legacy single-company pointer unchanged.
+ expect($user->company)->not->toBeNull();
+ expect($user->company->uuid)->toBe($home);
+
+ // HasMany pivot rows — underlying relation used by all three new helpers.
+ $pivotCompanyUuids = $user->companyUsers->pluck('company_uuid')->toArray();
+ expect($pivotCompanyUuids)->toContain($home, $alt);
+ expect(count($pivotCompanyUuids))->toBe(2);
+
+ // HasManyThrough companies() is still callable (no regression to the relation's
+ // existence/type), even though its upstream join definition is a known separate
+ // issue outside Task 7's scope. Asserting it doesn't throw is the regression check.
+ $companiesRelation = $user->companies();
+ expect($companiesRelation)->toBeInstanceOf(\Illuminate\Database\Eloquent\Relations\HasManyThrough::class);
+});
diff --git a/tests/Feature/Settings/CompanyContextSelfResolverTest.php b/tests/Feature/Settings/CompanyContextSelfResolverTest.php
new file mode 100644
index 00000000..f1112122
--- /dev/null
+++ b/tests/Feature/Settings/CompanyContextSelfResolverTest.php
@@ -0,0 +1,179 @@
+insert([
+ 'uuid' => $uuid, 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => 'Org', 'company_type' => 'organization', 'is_client' => false,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+ return $uuid;
+}
+
+function seedClientCompany(string $parentUuid): string
+{
+ $uuid = (string) Str::uuid();
+ DB::table('companies')->insert([
+ 'uuid' => $uuid, 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => 'Client', 'company_type' => 'client', 'is_client' => true,
+ 'parent_company_uuid' => $parentUuid,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+ return $uuid;
+}
+
+function seedUserForCompany(string $companyUuid, bool $isDefault = true): string
+{
+ $uuid = (string) Str::uuid();
+ DB::table('users')->insert([
+ 'uuid' => $uuid, 'public_id' => 'u_' . substr($uuid, 0, 8),
+ 'company_uuid' => $companyUuid, 'name' => 'U',
+ 'email' => 'u-' . substr($uuid, 0, 8) . '@x.test',
+ 'password' => 'x',
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(), 'user_uuid' => $uuid,
+ 'company_uuid' => $companyUuid, 'status' => 'active',
+ 'external' => false, 'access_level' => 'full',
+ 'is_default' => $isDefault,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+ return $uuid;
+}
+
+function grantPivot(string $userUuid, string $companyUuid): void
+{
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(), 'user_uuid' => $userUuid,
+ 'company_uuid' => $companyUuid, 'status' => 'active',
+ 'external' => false, 'access_level' => 'full', 'is_default' => false,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+}
+
+function runSelfResolver(User $user, ?string $header): \Symfony\Component\HttpFoundation\Response
+{
+ $request = Request::create('/test');
+ if ($header !== null) {
+ $request->headers->set('X-Company-Context', $header);
+ }
+ $request->setUserResolver(fn () => $user);
+
+ return (new CompanyContextSelfResolver())->handle($request, fn ($r) => response('ok'));
+}
+
+afterEach(function () {
+ if (app()->bound('companyContext')) {
+ app()->forgetInstance('companyContext');
+ }
+});
+
+test('unauthenticated request passes through', function () {
+ $request = Request::create('/test');
+ $response = (new CompanyContextSelfResolver())->handle($request, fn ($r) => response('ok'));
+ expect($response->getContent())->toBe('ok');
+});
+
+test('client user without header resolves to own client company (no hard-block)', function () {
+ $parent = seedOrgCompany();
+ $client = seedClientCompany($parent);
+ $userUuid = seedUserForCompany($client);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runSelfResolver($user, null);
+
+ expect($response->getContent())->toBe('ok');
+ expect(app('companyContext')->uuid)->toBe($client);
+});
+
+test('client user CANNOT target an unauthorized company via header', function () {
+ $parent = seedOrgCompany();
+ $client = seedClientCompany($parent);
+ $sibling = seedClientCompany($parent);
+ $userUuid = seedUserForCompany($client);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runSelfResolver($user, $sibling);
+
+ expect($response->getStatusCode())->toBe(403);
+});
+
+test('org user with header targeting an accessible client resolves to that client', function () {
+ $orgUuid = seedOrgCompany();
+ $client = seedClientCompany($orgUuid);
+ $userUuid = seedUserForCompany($orgUuid);
+ grantPivot($userUuid, $client);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runSelfResolver($user, $client);
+
+ expect($response->getContent())->toBe('ok');
+ expect(app('companyContext')->uuid)->toBe($client);
+});
+
+test('org user with header targeting an inaccessible company returns 403', function () {
+ $orgUuid = seedOrgCompany();
+ $unrelated = seedOrgCompany();
+ $userUuid = seedUserForCompany($orgUuid);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runSelfResolver($user, $unrelated);
+
+ expect($response->getStatusCode())->toBe(403);
+});
+
+test('invalid UUID header returns 403 (no DB hit)', function () {
+ $orgUuid = seedOrgCompany();
+ $userUuid = seedUserForCompany($orgUuid);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runSelfResolver($user, 'garbage-not-a-uuid');
+
+ expect($response->getStatusCode())->toBe(403);
+});
+
+test('valid UUID pointing to a non-existent company returns 403 (dangling pivot)', function () {
+ $orgUuid = seedOrgCompany();
+ $ghost = (string) Str::uuid();
+ $userUuid = seedUserForCompany($orgUuid);
+ grantPivot($userUuid, $ghost); // pivot exists but company does not
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runSelfResolver($user, $ghost);
+
+ expect($response->getStatusCode())->toBe(403);
+});
+
+test('empty header falls back to defaultCompany()', function () {
+ $orgUuid = seedOrgCompany();
+ $userUuid = seedUserForCompany($orgUuid);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runSelfResolver($user, '');
+
+ expect($response->getContent())->toBe('ok');
+ expect(app('companyContext')->uuid)->toBe($orgUuid);
+});
+
+test('user with no pivot rows and no legacy company_uuid returns 403', function () {
+ $orgUuid = seedOrgCompany();
+ $userUuid = seedUserForCompany($orgUuid);
+ DB::table('company_users')->where('user_uuid', $userUuid)->delete();
+ DB::table('users')->where('uuid', $userUuid)->update(['company_uuid' => null]);
+
+ $user = User::where('uuid', $userUuid)->firstOrFail();
+ $response = runSelfResolver($user, null);
+
+ expect($response->getStatusCode())->toBe(403);
+});
diff --git a/tests/Feature/Settings/CompanySettingsControllerTest.php b/tests/Feature/Settings/CompanySettingsControllerTest.php
new file mode 100644
index 00000000..9bc4361d
--- /dev/null
+++ b/tests/Feature/Settings/CompanySettingsControllerTest.php
@@ -0,0 +1,202 @@
+ 'null']);
+});
+
+function makeOrgContext(): array
+{
+ $orgUuid = (string) Str::uuid();
+ DB::table('companies')->insert([
+ 'uuid' => $orgUuid, 'public_id' => 'co_' . substr($orgUuid, 0, 8),
+ 'name' => 'Org ' . substr($orgUuid, 0, 4),
+ 'company_type' => 'organization', 'is_client' => false,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+
+ $userUuid = (string) Str::uuid();
+ DB::table('users')->insert([
+ 'uuid' => $userUuid, 'public_id' => 'u_' . substr($userUuid, 0, 8),
+ 'company_uuid' => $orgUuid, 'name' => 'Op',
+ 'email' => 'op-' . substr($userUuid, 0, 8) . '@x.test',
+ 'password' => 'x',
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(),
+ 'user_uuid' => $userUuid, 'company_uuid' => $orgUuid,
+ 'status' => 'active', 'external' => false, 'access_level' => 'full', 'is_default' => true,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+
+ return [$orgUuid, User::where('uuid', $userUuid)->firstOrFail()];
+}
+
+function makeClientUnderParent(string $parentUuid): array
+{
+ $clientUuid = (string) Str::uuid();
+ DB::table('companies')->insert([
+ 'uuid' => $clientUuid, 'public_id' => 'co_' . substr($clientUuid, 0, 8),
+ 'name' => 'Client',
+ 'company_type' => 'client', 'is_client' => true,
+ 'parent_company_uuid' => $parentUuid,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+
+ $userUuid = (string) Str::uuid();
+ DB::table('users')->insert([
+ 'uuid' => $userUuid, 'public_id' => 'u_' . substr($userUuid, 0, 8),
+ 'company_uuid' => $clientUuid, 'name' => 'Client User',
+ 'email' => 'cu-' . substr($userUuid, 0, 8) . '@x.test',
+ 'password' => 'x',
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+
+ DB::table('company_users')->insert([
+ 'uuid' => (string) Str::uuid(),
+ 'user_uuid' => $userUuid, 'company_uuid' => $clientUuid,
+ 'status' => 'active', 'external' => false, 'access_level' => 'full', 'is_default' => true,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+
+ return [$clientUuid, User::where('uuid', $userUuid)->firstOrFail()];
+}
+
+test('GET /current returns resolved settings (defaults when nothing stored)', function () {
+ [$orgUuid, $user] = makeOrgContext();
+
+ $this->actingAs($user, 'sanctum');
+ $response = $this->getJson('/v1/company-settings/current');
+
+ $response->assertOk();
+ $response->assertJsonPath('settings.billing.default_currency', 'USD');
+ $response->assertJsonPath('settings.tendering.default_expiration_hours', 4);
+});
+
+test('GET /current reflects client override on top of parent inheritance', function () {
+ [$parentUuid, $parentUser] = makeOrgContext();
+ [$clientUuid, $clientUser] = makeClientUnderParent($parentUuid);
+
+ Setting::configure("company.{$parentUuid}.billing.default_payment_terms_days", 60);
+ Setting::configure("company.{$clientUuid}.billing.default_payment_terms_days", 15);
+
+ $this->actingAs($clientUser, 'sanctum');
+ $response = $this->getJson('/v1/company-settings/current');
+
+ $response->assertOk();
+ $response->assertJsonPath('settings.billing.default_payment_terms_days', 15);
+});
+
+test('GET /current falls back to parent when client has no own value', function () {
+ [$parentUuid, $parentUser] = makeOrgContext();
+ [$clientUuid, $clientUser] = makeClientUnderParent($parentUuid);
+
+ Setting::configure("company.{$parentUuid}.billing.default_payment_terms_days", 60);
+
+ $this->actingAs($clientUser, 'sanctum');
+ $response = $this->getJson('/v1/company-settings/current');
+
+ $response->assertOk();
+ $response->assertJsonPath('settings.billing.default_payment_terms_days', 60);
+});
+
+test('PUT /current updates only the active company and round-trips', function () {
+ [$orgUuid, $user] = makeOrgContext();
+
+ $this->actingAs($user, 'sanctum');
+ $this->putJson('/v1/company-settings/current', [
+ 'settings' => [
+ 'billing.default_payment_terms_days' => 45,
+ 'audit.auto_audit_on_receive' => false,
+ ],
+ ])->assertOk();
+
+ $fresh = $this->getJson('/v1/company-settings/current')->json();
+ expect($fresh['settings']['billing']['default_payment_terms_days'])->toBe(45);
+ expect($fresh['settings']['audit']['auto_audit_on_receive'])->toBeFalse();
+ expect($fresh['settings']['billing']['default_currency'])->toBe('USD'); // preserved default
+});
+
+test('PUT /current does NOT write to parent under any circumstance', function () {
+ [$parentUuid, $parentUser] = makeOrgContext();
+ [$clientUuid, $clientUser] = makeClientUnderParent($parentUuid);
+
+ $this->actingAs($clientUser, 'sanctum');
+ $this->putJson('/v1/company-settings/current', [
+ 'settings' => ['billing.default_payment_terms_days' => 99],
+ ])->assertOk();
+
+ expect(Setting::lookup("company.{$parentUuid}.billing.default_payment_terms_days", null))->toBeNull();
+ expect(Setting::lookup("company.{$clientUuid}.billing.default_payment_terms_days", null))->toBe(99);
+});
+
+test('PATCH /current behaves identically to PUT /current', function () {
+ [$orgUuid, $user] = makeOrgContext();
+
+ $this->actingAs($user, 'sanctum');
+ $this->patchJson('/v1/company-settings/current', [
+ 'settings' => ['billing.invoice_number_prefix' => 'PATCH'],
+ ])->assertOk();
+
+ $fresh = $this->getJson('/v1/company-settings/current')->json();
+ expect($fresh['settings']['billing']['invoice_number_prefix'])->toBe('PATCH');
+});
+
+test('settings are strictly tenant-scoped — org A never sees org B writes', function () {
+ [$orgA, $userA] = makeOrgContext();
+ [$orgB, $userB] = makeOrgContext();
+
+ $this->actingAs($userA, 'sanctum');
+ $this->putJson('/v1/company-settings/current', [
+ 'settings' => ['billing.invoice_number_prefix' => 'ACME'],
+ ])->assertOk();
+
+ $this->actingAs($userB, 'sanctum');
+ $response = $this->getJson('/v1/company-settings/current')->json();
+ expect($response['settings']['billing']['invoice_number_prefix'])->toBe('INV');
+});
+
+test('unauthenticated request returns 401', function () {
+ $this->getJson('/v1/company-settings/current')->assertStatus(401);
+});
+
+test('PUT with no settings key returns 422', function () {
+ [$orgUuid, $user] = makeOrgContext();
+ $this->actingAs($user, 'sanctum');
+
+ $this->putJson('/v1/company-settings/current', [])->assertStatus(422);
+});
+
+test('PUT with non-array settings value returns 422', function () {
+ [$orgUuid, $user] = makeOrgContext();
+ $this->actingAs($user, 'sanctum');
+
+ $this->putJson('/v1/company-settings/current', ['settings' => 'not-an-array'])
+ ->assertStatus(422);
+});
+
+test('PUT with indexed array under settings returns 422', function () {
+ [$orgUuid, $user] = makeOrgContext();
+ $this->actingAs($user, 'sanctum');
+
+ $this->putJson('/v1/company-settings/current', ['settings' => ['a', 'b', 'c']])
+ ->assertStatus(422);
+});
+
+test('PUT with numeric (non-string) key in settings is rejected', function () {
+ [$orgUuid, $user] = makeOrgContext();
+ $this->actingAs($user, 'sanctum');
+
+ $this->putJson('/v1/company-settings/current', [
+ 'settings' => [
+ 'billing.default_currency' => 'EUR',
+ 0 => 'junk',
+ ],
+ ])->assertStatus(422);
+});
diff --git a/tests/Feature/Settings/CompanySettingsResolverTest.php b/tests/Feature/Settings/CompanySettingsResolverTest.php
new file mode 100644
index 00000000..ad6cfc4c
--- /dev/null
+++ b/tests/Feature/Settings/CompanySettingsResolverTest.php
@@ -0,0 +1,94 @@
+insert([
+ 'uuid' => $uuid, 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => 'Org ' . substr($uuid, 0, 4),
+ 'company_type' => 'organization', 'is_client' => false,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+ return $uuid;
+}
+
+function makeClient(string $parentUuid): string
+{
+ $uuid = (string) Str::uuid();
+ DB::table('companies')->insert([
+ 'uuid' => $uuid, 'public_id' => 'co_' . substr($uuid, 0, 8),
+ 'name' => 'Client ' . substr($uuid, 0, 4),
+ 'company_type' => 'client', 'is_client' => true,
+ 'parent_company_uuid' => $parentUuid,
+ 'created_at' => now(), 'updated_at' => now(),
+ ]);
+ return $uuid;
+}
+
+test('resolver returns default when no stored setting exists', function () {
+ $org = makeOrg();
+ $resolver = CompanySettingsResolver::forCompany($org);
+
+ expect($resolver->get('billing.default_payment_terms_days'))->toBe(30);
+ expect($resolver->get('tendering.default_expiration_hours'))->toBe(4);
+ expect($resolver->get('audit.default_tolerance_percent'))->toBe(2.0);
+});
+
+test('resolver returns stored company value over default', function () {
+ $org = makeOrg();
+ Setting::configure("company.{$org}.billing.default_payment_terms_days", 45);
+
+ $resolver = CompanySettingsResolver::forCompany($org);
+ expect($resolver->get('billing.default_payment_terms_days'))->toBe(45);
+});
+
+test('client company inherits parent values when unset', function () {
+ $parent = makeOrg();
+ $client = makeClient($parent);
+ Setting::configure("company.{$parent}.billing.default_payment_terms_days", 60);
+
+ $resolver = CompanySettingsResolver::forCompany($client);
+ expect($resolver->get('billing.default_payment_terms_days'))->toBe(60);
+});
+
+test('client company override wins over parent value', function () {
+ $parent = makeOrg();
+ $client = makeClient($parent);
+ Setting::configure("company.{$parent}.billing.default_payment_terms_days", 60);
+ Setting::configure("company.{$client}.billing.default_payment_terms_days", 15);
+
+ $resolver = CompanySettingsResolver::forCompany($client);
+ expect($resolver->get('billing.default_payment_terms_days'))->toBe(15);
+});
+
+test('set() persists via Setting::configure with company-prefixed key', function () {
+ $org = makeOrg();
+ $resolver = CompanySettingsResolver::forCompany($org);
+ $resolver->set('billing.invoice_number_prefix', 'INVX');
+
+ expect(Setting::lookup("company.{$org}.billing.invoice_number_prefix", null))->toBe('INVX');
+});
+
+test('all() returns merged settings with inheritance and defaults', function () {
+ $parent = makeOrg();
+ $client = makeClient($parent);
+ Setting::configure("company.{$parent}.billing.default_payment_terms_days", 60);
+ Setting::configure("company.{$client}.tendering.default_expiration_hours", 8);
+
+ $all = CompanySettingsResolver::forCompany($client)->all();
+
+ expect($all['billing']['default_payment_terms_days'])->toBe(60); // inherited
+ expect($all['tendering']['default_expiration_hours'])->toBe(8); // override
+ expect($all['audit']['default_tolerance_percent'])->toBe(2.0); // default
+});
+
+test('defaults() returns the full default tree', function () {
+ $defaults = CompanySettingsResolver::defaults();
+ expect($defaults)->toHaveKeys(['billing', 'tendering', 'documents', 'pay_files', 'fuel', 'audit']);
+ expect($defaults['billing']['default_currency'])->toBe('USD');
+});
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 00000000..9ef8cb58
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,20 @@
+toBeTrue()` and must not be
+| wrapped in a DB-refreshing lifecycle.
+|
+*/
+
+uses(
+ \Fleetbase\Tests\TestCase::class,
+ \Illuminate\Foundation\Testing\RefreshDatabase::class,
+)->in('Feature/MultiTenant', 'Feature/Settings');
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 00000000..f46cfe99
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,277 @@
+
+ */
+ protected array $sqliteIncompatibleMigrations = [
+ '2024_01_01_000002_improve_transaction_items_table',
+ '2025_08_28_045009_noramlize_uuid_foreign_key_columns',
+ ];
+
+ /**
+ * Register service providers required by Fleetbase core-api tests.
+ *
+ * Sanctum is registered first so that its `personal_access_tokens`
+ * migration runs before the Fleetbase `fix_personal_access_tokens`
+ * migration that alters that table.
+ *
+ * @param \Illuminate\Foundation\Application $app
+ * @return array
+ */
+ protected function getPackageProviders($app)
+ {
+ return [
+ \Laravel\Sanctum\SanctumServiceProvider::class,
+ \Spatie\Permission\PermissionServiceProvider::class,
+ \Fleetbase\Providers\CoreServiceProvider::class,
+ ];
+ }
+
+ /**
+ * Define environment setup for the Testbench Laravel application.
+ *
+ * @param \Illuminate\Foundation\Application $app
+ * @return void
+ */
+ protected function defineEnvironment($app)
+ {
+ $app['config']->set('database.default', 'sqlite');
+ $app['config']->set('database.connections.sqlite', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'prefix' => '',
+ 'foreign_key_constraints' => true,
+ ]);
+
+ // Mark the environment as `testing` so CoreServiceProvider::scheduleCommands()
+ // and ::pingTelemetry() short-circuit instead of trying to boot the
+ // scheduler / phone home.
+ $app['config']->set('app.env', 'testing');
+
+ // Fleetbase config defaults that CoreServiceProvider or downstream code
+ // may read. Keep these minimal and sensible for a test environment.
+ $app['config']->set('fleetbase.api.version', 'test');
+ $app['config']->set('fleetbase.console.host', 'http://localhost');
+ $app['config']->set('fleetbase.instance_id', 'test-instance');
+ $app['config']->set('fleetbase.connection.sandbox', 'sandbox');
+
+ // Disable external services that might be hit incidentally.
+ $app['config']->set('cache.default', 'array');
+ $app['config']->set('queue.default', 'sync');
+ $app['config']->set('session.driver', 'array');
+ $app['config']->set('mail.default', 'array');
+
+ // Spatie laravel-permission: keep teams off for the core test
+ // bootstrap; individual tests can override if needed. Fleetbase's
+ // own permission config rebinds `model_morph_key` to `model_uuid`,
+ // but because Spatie's PermissionServiceProvider is registered
+ // first (so Sanctum's migration table is present before
+ // Fleetbase's `fix_personal_access_tokens` migration runs), its
+ // defaults win in `mergeConfigFrom`. Force the Fleetbase values
+ // explicitly so the custom `create_permissions_table` migration
+ // creates `model_uuid` columns as it does in production.
+ $app['config']->set('permission.teams', false);
+ $app['config']->set('permission.column_names.model_morph_key', 'model_uuid');
+ $app['config']->set('permission.column_names.team_foreign_key', 'team_id');
+
+ // Install a DATE_FORMAT() polyfill on every SQLite connection the
+ // moment it is established, so migrations running during
+ // RefreshDatabase see it before any `UPDATE ... DATE_FORMAT(...)`
+ // statement fires.
+ $app['events']->listen(ConnectionEstablished::class, function (ConnectionEstablished $event) {
+ $connection = $event->connection;
+ if ($connection->getDriverName() !== 'sqlite') {
+ return;
+ }
+
+ $pdo = $connection->getPdo();
+ if (! method_exists($pdo, 'sqliteCreateFunction')) {
+ return;
+ }
+
+ $pdo->sqliteCreateFunction('DATE_FORMAT', static function ($datetime, $format) {
+ if ($datetime === null) {
+ return null;
+ }
+ $timestamp = is_numeric($datetime) ? (int) $datetime : strtotime((string) $datetime);
+ if ($timestamp === false) {
+ return null;
+ }
+ $map = [
+ '%Y' => 'Y', '%y' => 'y',
+ '%m' => 'm', '%c' => 'n',
+ '%d' => 'd', '%e' => 'j',
+ '%H' => 'H', '%h' => 'h', '%i' => 'i', '%s' => 's',
+ '%p' => 'A',
+ ];
+
+ return date(strtr((string) $format, $map), $timestamp);
+ }, 2);
+ });
+
+ // Swap in a filtering migrator that skips known MySQL-only migrations
+ // so migrate:fresh can complete against SQLite. We pull the resolver
+ // out of the default migrator via reflection because Migrator exposes
+ // getRepository() and getFilesystem() but not its connection resolver.
+ $skip = $this->sqliteIncompatibleMigrations;
+ $app->extend('migrator', function (Migrator $migrator) use ($skip) {
+ $resolverRef = new \ReflectionProperty(Migrator::class, 'resolver');
+ $resolverRef->setAccessible(true);
+ $resolver = $resolverRef->getValue($migrator);
+
+ $eventsRef = new \ReflectionProperty(Migrator::class, 'events');
+ $eventsRef->setAccessible(true);
+ $events = $eventsRef->getValue($migrator);
+
+ $filtered = new class(
+ $migrator->getRepository(),
+ $resolver,
+ $migrator->getFilesystem(),
+ $events,
+ $skip,
+ ) extends Migrator {
+ /** @var array */
+ protected array $skipNames;
+
+ public function __construct($repository, $resolver, $files, $events, array $skipNames)
+ {
+ parent::__construct($repository, $resolver, $files, $events);
+ $this->skipNames = $skipNames;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getMigrationFiles($paths)
+ {
+ return array_filter(
+ parent::getMigrationFiles($paths),
+ fn (string $_file, string $name) => ! in_array($name, $this->skipNames, true),
+ ARRAY_FILTER_USE_BOTH,
+ );
+ }
+ };
+
+ // Carry over any paths already registered (e.g. by CoreServiceProvider::boot).
+ foreach ($migrator->paths() as $path) {
+ $filtered->path($path);
+ }
+
+ return $filtered;
+ });
+ }
+
+ /**
+ * Boot the Testbench application and install two test-only shims that
+ * keep Fleetbase's production models runnable against an in-memory
+ * SQLite database without pulling in the full Fleetbase stack.
+ *
+ * Shim 1 — `responsecache` noop binding:
+ * Fleetbase's base Eloquent Model mixes in the ClearsHttpCache trait,
+ * which registers HttpCacheObserver on every save event. That
+ * observer resolves the `responsecache` container alias from Spatie's
+ * ResponseCacheServiceProvider, which is intentionally NOT registered
+ * in this Testbench bootstrap (we don't want response-cache plumbing
+ * in tests). Without this binding the first Model::create() throws
+ * `BindingResolutionException: Target class [responsecache] does not
+ * exist`. The noop stand-in satisfies the observer without side
+ * effects.
+ *
+ * Shim 2 — alias the `mysql` connection to sqlite:
+ * `Fleetbase\Models\User` extends Authenticatable (not Fleetbase's
+ * base Model), so it never receives the constructor override that
+ * rewrites `$connection` to the test env's sqlite. Any query that
+ * resolves `$user->getConnectionName() === 'mysql'` would otherwise
+ * die with `PDOException: Connection refused` trying to reach
+ * `127.0.0.1:3306`. Point the `mysql` connection name at the already
+ * booted sqlite connection in the DB manager so relation traversal
+ * and CompanyUser::create(['user_uuid' => ...]) work.
+ *
+ * Must run from setUp() (not defineEnvironment) so that `$this->app`
+ * is fully booted and the DB manager has an active sqlite connection
+ * to alias against.
+ *
+ * @return void
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ // Shim 1: noop `responsecache` binding for ClearsHttpCache observer.
+ $this->app->singleton('responsecache', function () {
+ return new class {
+ public function clear(array $tags = []): self
+ {
+ return $this;
+ }
+
+ public function __call($name, $arguments)
+ {
+ return $this;
+ }
+ };
+ });
+
+ // Shim 2: alias the `mysql` connection to the booted sqlite
+ // connection so User and other Authenticatable-extending models
+ // with hardcoded `$connection = 'mysql'` resolve against sqlite.
+ $default = \Illuminate\Support\Facades\DB::connection();
+ $manager = $this->app->make('db');
+ $ref = new \ReflectionProperty($manager, 'connections');
+ $ref->setAccessible(true);
+ $connections = $ref->getValue($manager);
+ $connections['mysql'] = $default;
+ $ref->setValue($manager, $connections);
+ }
+}