Updates CLAUDE.md to reflect the actual current state of the codebase: rewrites the Frontend section to point at main.ts / features-folder layout / @ alias (the previous text still pointed at the deleted v1 main.js / admin-router.js / admin/stores paths), expands Backend Patterns with the FileDisk + disk-assignments model and the per-user 'default' language sentinel, and adds two new sections — PDF Font System (the on-demand Font Packages mechanism plus the two non-obvious dompdf constraints around variable fonts and font-family fallback) and CSS Theme Tokens (the @theme inline registration model, all currently-defined token categories, the [data-theme=dark] attribute switch, the two-step ritual for adding a new token, and the no-hardcoded-values convention). Cleans up two scripts-v2 leftovers from the rename refactor: resources/css/invoiceshelf.css had four @source directives — two pointing at the long-deleted scripts-v2/ directory and one pointing at scripts/**/*.js (the new directory has zero .js files, only .vue and .ts), collapsed down to the two correct ones. CONTRIBUTING.md still pointed contributors at 'patterns in resources/scripts-v2/' — fixed to resources/scripts/.
11 KiB
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
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
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
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/inroutes/api.php, grouped withauth:sanctum,company, andbouncermiddleware - Web:
routes/web.phpserves PDF endpoints, auth pages, and catch-all SPA routes (/admin/{vue?},/{company:slug}/customer/{vue?})
Frontend
- Vue 3 + TypeScript + Pinia + vue-router + Tailwind v4 (
@tailwindcss/vite) - Entry point:
resources/scripts/main.ts(single Vite input) - Feature-folder layout under
resources/scripts/features/{admin,auth,company,customer-portal,...}— each feature owns its ownroutes.ts,views/,components/ - Shared layers:
resources/scripts/{api,stores,components,composables,layouts,plugins,utils,types,config} - Path aliases:
@→resources/(so most imports look like@/scripts/api/client,@/scripts/stores/global.store);$fonts→resources/static/fonts;$images→resources/static/img. There is no@v2alias — that was retired when the legacy v1 SPA was deleted. - i18n:
lang/*.jsonare dynamically imported byresources/scripts/plugins/i18n.ts. Locale-code → filename mismatches (e.g.pt_BR→pt-br.json) live inLOCALE_FILE_MAP. English is statically bundled; other locales lazy-load. Only editlang/en.jsondirectly — other locales are Crowdin-sourced. - Vite dev server expects the
invoiceshelf.testhostname (configured invite.config.js)
CSS Theme Tokens
The styling system uses Tailwind v4 with CSS custom properties as the source of truth — colors are not configured in JS, they live in CSS and are exposed to Tailwind via the @theme directive. Two files own this:
resources/css/themes.css— defines every color as a CSS custom property on:root(light) and[data-theme="dark"](dark). This is where you change actual values.resources/css/invoiceshelf.css— has an@theme inline { ... }block that registers each custom property as a Tailwind theme token (e.g.--color-heading: var(--color-heading);), making it available as utility classes (bg-heading,text-heading,border-heading, etc.). The block also uses the legacy@theme { --spacing-88: 22rem; --font-base: Poppins, sans-serif; }for non-color tokens.
Token categories defined today:
primary-{50…950}— brand color scalesurface,surface-secondary,surface-tertiary,surface-muted— background depth tiersheading,body,muted,subtle— text emphasis tiersline-{light,default,strong}— bordershover,hover-strong— hover backgroundsheader-from,header-to— fixed header gradient stops (not dark-mode-aware)btn-primary,btn-primary-hover— button colors (fixed, always bold)status-{yellow,green,blue,red,purple}— status badge text colorsalert-{warning,error,success}-{bg,text}— alert variants
Dark mode is toggled via the [data-theme="dark"] attribute on the <html> element. The same custom-property names get redefined under that selector — components do not need dark: variants or conditional logic, they just reference the semantic tokens and the right value is picked up automatically.
Adding a new color token is a two-step ritual:
- Add the custom property to both
:rootand[data-theme="dark"]inthemes.css - Add a matching
--color-X: var(--color-X);line inside the@theme inlineblock ininvoiceshelf.css
After that the token is usable as bg-X / text-X / border-X in Vue templates and as var(--color-X) in raw CSS. Skip step 2 and the value exists at the CSS level but Tailwind utility classes won't be generated.
Convention — never hardcode hex/rgb values in components. Use the semantic tokens: text-heading not text-gray-900, bg-surface not bg-white, border-line-default not border-gray-300. Hardcoded values won't follow dark-mode flips and will diverge from the rest of the app over time. There are no exceptions in the project — even the auth pages (which sit outside the admin chrome) use the same bg-surface / text-heading / border-line-default vocabulary as BaseCard, just composed differently.
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: Pluggable driver —
dompdf(default, viaGeneratesPdfTrait) orgotenberg(headless Chromium). Driver chosen per company through the PDF Generation admin settings page. - Email: Mailable classes with
EmailLogtracking. Mail driver is configurable globally and may be overridden per-company. - File storage: Spatie MediaLibrary backed by the FileDisk model — admins create named disk entries (local / S3 / Dropbox / DigitalOcean Spaces) and assign them to purposes (
media_storage,pdf_storage,backup_storage) in Admin → File Disks → Disk Assignments. New uploads go to the assigned disk; existing files stay where they were and requirephp artisan media:secureto migrate. - Serial numbers:
SerialNumberService - Company settings:
CompanySettingmodel (key-value per company) - User settings: User-level preferences (notably
language) stored as JSON viasetSettings(). The sentinel value'default'means "inherit the company-level setting" — used for the per-user language preference so promoting/inviting members doesn't freeze a copy of the inviter's language.
PDF Font System
PDFs ship with bundled Noto Sans (Latin / Greek / Cyrillic) as the default face. Non-Latin scripts come from on-demand Font Packages managed in Admin → Font Packages and defined in FontService::FONT_PACKAGES (app/Services/FontService.php). Currently shipped packages: noto-sans (bundled, marker only), noto-sans-{sc,tc,jp,kr} (CJK), noto-sans-hebrew, noto-naskh-arabic (covers ar/fa/ur), noto-sans-devanagari (hi), sarabun (Thai). GeneratesPdfTrait::ensureFontsForLocale() synchronously installs the matching package on the first PDF render for a given company language.
Two non-obvious constraints when extending the font system:
- dompdf's PHP-Font-Lib does not parse variable fonts (
fvar/gvartables). Any new package must source static TTF files — Google Fonts' main repo ships variable fonts and produces empty boxes. Reliable static-TTF sources used today:openmaptiles/fontsfor non-CJK Noto scripts,life888888/cjk-fonts-ttffor the CJK packages,google/fonts/ofl/sarabunfor Thai. - dompdf does not glyph-fall-back through the
font-familychain — it uses the first font for ALL characters. So locale-specific packages must be the primary font for that locale, not a fallback. Selection happens inFontService::getFontFamilyForLocale(). This is also why a Latin-locale company with a Hebrew customer name will still render boxes for the Hebrew text — solving that needs Gotenberg or a custom mid-render font-switching pass.
The bundled NotoSans is also surfaced as a bundled: true package entry (no download URL, files served from resources/static/fonts/ instead of storage/fonts/) so it appears alongside the on-demand packages in the admin UI with a "Bundled" pill instead of an Install button.
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.
Service Pattern
All business logic must live in Service classes (app/Services/), not in Models or Controllers. Controllers are thin — they authorize, call the service, and return a response. Models only contain relationships, scopes, accessors, mutators, and constants. Services are injected via constructor injection.
Testing (TDD)
InvoiceShelf follows TDD development style:
- Feature tests (
tests/Feature/) — test API routes end-to-end (HTTP requests, responses, database assertions) - Unit tests (
tests/Unit/) — test service classes and business logic in isolation - Write tests before or alongside implementation. Every new feature or bug fix must have tests.
Code Conventions
- PHP: snake_case, constructor property promotion, explicit return types, PHPDoc blocks over inline comments
- TS / Vue: camelCase,
<script setup lang="ts">, prefer Composition API + Pinia stores over component-local state for anything cross-cutting - Always check sibling files for patterns before creating new ones
- Use
config()helper, neverenv()outside config files - Every change must have tests
- Run
vendor/bin/pint --dirty --format agentafter modifying PHP files - After editing
lang/en.jsonor any file underresources/scripts/, rebuild vianpm run build— the bundled chunks (including locale chunks) are content-hashed by Vite, so the browser will pick them up on hard refresh
CI Pipeline
GitHub Actions (check.yaml): runs Pint style check, then builds frontend and runs Pest tests on PHP 8.4.