diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..6ba2a11e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +InvoiceShelf is an open-source invoicing and expense tracking application built with Laravel 13 (PHP 8.4) and Vue 3. It supports multi-company tenancy, customer portals, recurring invoices, and PDF generation. + +## Common Commands + +### Development +```bash +composer run dev # Starts PHP server, queue listener, log tail, and Vite dev server concurrently +npm run dev # Vite dev server only +npm run build # Production frontend build +``` + +### Testing +```bash +php artisan test --compact # Run all tests +php artisan test --compact --filter=testName # Run specific test +./vendor/bin/pest --stop-on-failure # Run via Pest directly +make test # Makefile shortcut +``` + +Tests use SQLite in-memory DB, configured in `phpunit.xml`. Tests seed via `DatabaseSeeder` + `DemoSeeder` in `beforeEach`. Authenticate with `Sanctum::actingAs()` and set the `company` header. + +### Code Style +```bash +vendor/bin/pint --dirty --format agent # Fix style on modified PHP files +vendor/bin/pint --test # Check style without fixing (CI uses this) +``` + +### Artisan Generators +Always use `php artisan make:*` with `--no-interaction` to create new files (models, controllers, migrations, tests, etc.). + +## Architecture + +### Multi-Tenancy +Every major model has a `company_id` foreign key. The `CompanyMiddleware` sets the active company from the `company` request header. Bouncer authorization is scoped to the company level via `DefaultScope` (`app/Bouncer/Scopes/DefaultScope.php`). + +### Authentication +Three guards: `web` (session), `api` (Sanctum tokens for `/api/v1/`), `customer` (session for customer portal). API routes use `auth:sanctum` middleware; customer portal uses `auth:customer`. + +### Routing +- **API**: All endpoints under `/api/v1/` in `routes/api.php`, grouped with `auth:sanctum`, `company`, and `bouncer` middleware +- **Web**: `routes/web.php` serves PDF endpoints, auth pages, and catch-all SPA routes (`/admin/{vue?}`, `/{company:slug}/customer/{vue?}`) + +### Frontend +- Entry point: `resources/scripts/main.js` +- Vue Router: `resources/scripts/admin/admin-router.js` (admin), `resources/scripts/customer/customer-router.js` (customer portal) +- State: Pinia stores in `resources/scripts/admin/stores/` +- Path aliases: `@` = `resources/`, `$fonts`, `$images` for static assets +- Vite dev server expects `invoiceshelf.test` hostname + +### Backend Patterns +- **Authorization**: Silber/Bouncer with policies in `app/Policies/`. Controllers use `$this->authorize()`. +- **Validation**: Form Request classes, never inline validation +- **API responses**: Eloquent API Resources in `app/Http/Resources/` +- **PDF generation**: DomPDF (`GeneratesPdfTrait`) or Gotenberg +- **Email**: Mailable classes with `EmailLog` tracking +- **File storage**: Spatie MediaLibrary, supports local/S3/Dropbox +- **Serial numbers**: `SerialNumberFormatter` service +- **Company settings**: `CompanySetting` model (key-value per company) + +### Database +Supports MySQL, PostgreSQL, and SQLite. Prefer Eloquent over raw queries. Use `Model::query()` instead of `DB::`. Use eager loading to prevent N+1 queries. + +## Code Conventions + +- PHP: snake_case, constructor property promotion, explicit return types, PHPDoc blocks over inline comments +- JS: camelCase +- Always check sibling files for patterns before creating new ones +- Use `config()` helper, never `env()` outside config files +- Every change must have tests (feature tests preferred over unit tests) +- Run `vendor/bin/pint --dirty --format agent` after modifying PHP files + +## CI Pipeline + +GitHub Actions (`check.yaml`): runs Pint style check, then builds frontend and runs Pest tests on PHP 8.4. diff --git a/app/Console/Commands/ResetApp.php b/app/Console/Commands/ResetApp.php index 6724286f..2e9036ba 100644 --- a/app/Console/Commands/ResetApp.php +++ b/app/Console/Commands/ResetApp.php @@ -24,7 +24,7 @@ class ResetApp extends Command * * @var string */ - protected $description = 'Clean database, database_created and public/storage folder'; + protected $description = 'Clean database and public/storage folder'; /** * Create a new command instance. diff --git a/app/Http/Controllers/V1/Admin/Settings/MailConfigurationController.php b/app/Http/Controllers/V1/Admin/Settings/MailConfigurationController.php index ee13d402..0be5349e 100755 --- a/app/Http/Controllers/V1/Admin/Settings/MailConfigurationController.php +++ b/app/Http/Controllers/V1/Admin/Settings/MailConfigurationController.php @@ -165,11 +165,11 @@ class MailConfigurationController extends Controller switch ($driver) { case 'smtp': $MailData = array_merge($MailData, [ - 'mail_host' => $mailSettings['mail_host'] ?? '', - 'mail_port' => $mailSettings['mail_port'] ?? '', - 'mail_username' => $mailSettings['mail_username'] ?? '', - 'mail_password' => $mailSettings['mail_password'] ?? '', - 'mail_encryption' => $mailSettings['mail_encryption'] ?? 'none', + 'mail_host' => $mailSettings['mail_host'] ?? config('mail.mailers.smtp.host', ''), + 'mail_port' => $mailSettings['mail_port'] ?? config('mail.mailers.smtp.port', ''), + 'mail_username' => $mailSettings['mail_username'] ?? config('mail.mailers.smtp.username', ''), + 'mail_password' => $mailSettings['mail_password'] ?? config('mail.mailers.smtp.password', ''), + 'mail_encryption' => $mailSettings['mail_encryption'] ?? config('mail.mailers.smtp.encryption', 'none'), 'mail_scheme' => $mailSettings['mail_scheme'] ?? '', 'mail_url' => $mailSettings['mail_url'] ?? '', 'mail_timeout' => $mailSettings['mail_timeout'] ?? '', diff --git a/app/Http/Controllers/V1/Installation/DatabaseConfigurationController.php b/app/Http/Controllers/V1/Installation/DatabaseConfigurationController.php index a977055e..446a1e31 100644 --- a/app/Http/Controllers/V1/Installation/DatabaseConfigurationController.php +++ b/app/Http/Controllers/V1/Installation/DatabaseConfigurationController.php @@ -48,8 +48,9 @@ class DatabaseConfigurationController extends Controller public function getDatabaseEnvironment(Request $request) { $databaseData = []; + $connection = $request->connection ?? config('database.default'); - switch ($request->connection) { + switch ($connection) { case 'sqlite': $databaseData = [ 'database_connection' => 'sqlite', diff --git a/app/Http/Controllers/V1/Installation/FinishController.php b/app/Http/Controllers/V1/Installation/FinishController.php index a7405bd2..8e7f4e1c 100644 --- a/app/Http/Controllers/V1/Installation/FinishController.php +++ b/app/Http/Controllers/V1/Installation/FinishController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers\V1\Installation; use App\Http\Controllers\Controller; -use App\Space\InstallUtils; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -16,10 +15,6 @@ class FinishController extends Controller */ public function __invoke(Request $request) { - if (! InstallUtils::createDbMarker()) { - \Log::error('Install: Unable to create db marker.'); - } - return response()->json(['success' => true]); } } diff --git a/app/Http/Controllers/V1/Installation/OnboardingWizardController.php b/app/Http/Controllers/V1/Installation/OnboardingWizardController.php index 954d86f4..7630420c 100644 --- a/app/Http/Controllers/V1/Installation/OnboardingWizardController.php +++ b/app/Http/Controllers/V1/Installation/OnboardingWizardController.php @@ -17,7 +17,7 @@ class OnboardingWizardController extends Controller */ public function getStep(Request $request) { - if (! InstallUtils::dbMarkerExists()) { + if (! InstallUtils::isDbCreated()) { return response()->json([ 'profile_complete' => 0, 'profile_language' => 'en', diff --git a/app/Http/Middleware/InstallationMiddleware.php b/app/Http/Middleware/InstallationMiddleware.php index 0386a453..d8900199 100644 --- a/app/Http/Middleware/InstallationMiddleware.php +++ b/app/Http/Middleware/InstallationMiddleware.php @@ -17,7 +17,11 @@ class InstallationMiddleware */ public function handle(Request $request, Closure $next): Response { - if (! InstallUtils::isDbCreated() || Setting::getSetting('profile_complete') !== 'COMPLETED') { + try { + if (! InstallUtils::isDbCreated() || Setting::getSetting('profile_complete') !== 'COMPLETED') { + return redirect('/installation'); + } + } catch (\Exception $e) { return redirect('/installation'); } diff --git a/app/Http/Middleware/RedirectIfInstalled.php b/app/Http/Middleware/RedirectIfInstalled.php index 0a788da4..23cad525 100644 --- a/app/Http/Middleware/RedirectIfInstalled.php +++ b/app/Http/Middleware/RedirectIfInstalled.php @@ -17,9 +17,13 @@ class RedirectIfInstalled */ public function handle(Request $request, Closure $next): Response { - if (InstallUtils::dbMarkerExists()) { - if (Setting::getSetting('profile_complete') === 'COMPLETED') { - return redirect('login'); + if (InstallUtils::isDbCreated()) { + try { + if (Setting::getSetting('profile_complete') === 'COMPLETED') { + return redirect('login'); + } + } catch (\Exception $e) { + // Settings table may not exist yet during installation } } diff --git a/app/Space/InstallUtils.php b/app/Space/InstallUtils.php index 7b2944e1..c618983a 100644 --- a/app/Space/InstallUtils.php +++ b/app/Space/InstallUtils.php @@ -5,8 +5,6 @@ namespace App\Space; use App\Models\Setting; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\File; -use Illuminate\Support\Facades\Log; -use League\Flysystem\FilesystemException; class InstallUtils { @@ -17,7 +15,7 @@ class InstallUtils */ public static function isDbCreated() { - return self::dbMarkerExists() && self::tableExists('users'); + return self::tableExists('users'); } /** @@ -44,54 +42,6 @@ class InstallUtils return $cache[$table]; } - /** - * Check if database created marker exists - * - * @return bool - */ - public static function dbMarkerExists() - { - try { - return \Storage::disk('local')->has('database_created'); - } catch (FilesystemException $e) { - Log::error('Unable to verify db marker: '.$e->getMessage()); - } - - return false; - } - - /** - * Creates the database marker - * - * @return bool - */ - public static function createDbMarker() - { - try { - return \Storage::disk('local')->put('database_created', time()); - } catch (\Exception $e) { - Log::error('Unable to create db marker: '.$e->getMessage()); - } - - return false; - } - - /** - * Deletes the database marker - * - * @return bool - */ - public static function deleteDbMarker() - { - try { - return \Storage::disk('local')->delete('database_created'); - } catch (\Exception $e) { - Log::error('Unable to delete db marker: '.$e->getMessage()); - } - - return false; - } - /** * Set the app version * diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index 6b334147..199c8446 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -8,7 +8,6 @@ use App\Models\CompanySetting; use App\Models\Customer; use App\Models\Setting; use App\Models\User; -use App\Space\InstallUtils; use Illuminate\Database\Seeder; use Silber\Bouncer\BouncerFacade; @@ -71,8 +70,5 @@ class DemoSeeder extends Seeder // Mark profile setup as complete Setting::setSetting('profile_complete', 'COMPLETED'); - - // Create installation marker - InstallUtils::createDbMarker(); } } diff --git a/resources/scripts/admin/views/installation/Step3DatabaseConfig.vue b/resources/scripts/admin/views/installation/Step3DatabaseConfig.vue index b2a86821..d9832570 100644 --- a/resources/scripts/admin/views/installation/Step3DatabaseConfig.vue +++ b/resources/scripts/admin/views/installation/Step3DatabaseConfig.vue @@ -15,7 +15,7 @@