mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 21:00:58 +00:00
f007daa65bb6b33d2c4df0bdf3a8148e55c7e4f4
1894
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f007daa65b |
feat(brand): adopt the redesigned InvoiceShelf logo (#660)
- MainLogo.vue: new 'receipt + check' mark + InvoiceShelf wordmark lockup (monochrome, inherits the existing light/dark colour props — works in the header, auth, installation, sidebar, loader, and customer portal). - Favicons + PWA icons regenerated from the new mark (16/32/180/192/512/150 + favicon.ico); safari-pinned-tab.svg redrawn to the new mark; mask-icon colour #5851d8 -> #4a3dff. - New brand SVG assets (logo-mark + variants + lockup) added under static/img; logo-gray.png (public-invoice footer) regenerated as the mono mark. |
||
|
|
84524ce247 |
fix(security): recompute document totals server-side (GHSA-8c69) (#672)
v3 port. Invoice/estimate/recurring creation and update accepted total, sub_total, tax and due_amount straight from the request with no recalculation, letting a client persist financial totals that don't match the line items (and corrupt the invoice update due-amount/paid-amount logic which keyed off the client total). - Adds App\Support\DocumentTotals (mirrors the front-end calc) trusting only price/quantity/discounts/tax-line amounts. - getInvoicePayload/getEstimatePayload/getRecurringInvoicePayload override the client totals; the shared DocumentItemService::createItems recomputes each item total; InvoiceService::update keys its due-amount logic off the recomputed total. Adds DocumentTotals unit tests + a feature test proving a tampered invoice total is ignored; existing create/update tests no longer assert the now server-authoritative derived totals. |
||
|
|
99ea898e88 |
fix(security): block SSRF via the Gotenberg host setting (GHSA-mfxg) (#671)
v3 port. The Gotenberg PDF driver was missed when the SSRF guards were added to the AI, exchange-rate and file-disk drivers: gotenberg_host was validated only with 'url', and the driver POSTs the rendered HTML to it. Reuses the existing infrastructure (consistency with the other drivers): - Wires App\Rules\PublicHttpUrl into the gotenberg_host validation rule. - Adds PrivateNetworkGuard::assertAllowed() in GotenbergPdfDriver before the outbound call (covers env/seed/stale config + DNS rebinding). Adds a unit test asserting the gotenberg_host rule rejects private/loopback/ link-local addresses and allows a public one. |
||
|
|
ca6dd57bf9 |
fix(security): block ORDER BY SQL injection via orderByField (GHSA-cp8p) (#670)
v3 port. orderByField/orderBy were passed straight into Eloquent's orderBy() in every model's scopeWhereOrder (and Invoice::scopeApplyFilters), allowing arbitrary SQL in the ORDER BY clause. Adds App\Support\SafeOrderBy::apply() (plain/table-qualified column identifier only, asc/desc clamp) and routes all 10 model sort sinks through it. Aliased sorts (e.g. estimates by customers.name) stay valid. Adds unit tests for injection rejection, plain + aliased columns, direction clamp. |
||
|
|
e56b6b4fe5 |
fix(security): harden public EmailLog token endpoints (GHSA-73q7) (#669)
v3 port. Customer PDF controllers resolved the target document by raw mailable_id ignoring mailable_type, and skipped expiry on the JSON endpoints. - Resolve via $emailLog->mailable + assert the expected type (404) to close cross-type disclosure. - Enforce isExpired() (403) on every public path incl. the JSON endpoints. - Harden EmailLog::isExpired() against a null/unresolvable mailable. Adds tests for cross-type 404, JSON-path expiry 403, and the valid path. |
||
|
|
c6a00df120 |
fix(security): enforce company scope on notes, doc-convert, and member bulk-delete (#668)
v3 port of the v2 authorization fixes. - Notes IDOR (GHSA-85wc): NotePolicy checks the note's company_id and NotesController passes the bound model to authorize() on show/update/destroy. - Estimate<->Invoice convert IDOR (GHSA-j2vg): EstimatesController::convertToInvoice and InvoicesController::convertToEstimate authorize 'view' on the source document before creating the target. - Member bulk-delete (GHSA-wxrv): MembersController scopes ids via User::whereCompany() before MemberService::delete. Adds feature tests for cross-company 403s + same-company happy paths. |
||
|
|
f3ab0f22fc |
chore(frontend): fix ESLint, add Pint+ESLint pre-commit hook, centralize v-html
Fix the broken ESLint setup: add vue-eslint-parser and @typescript-eslint/parser and wire the TS parser into eslint.config.mjs so .ts and <script lang=ts> parse (was failing outright). Clear the resulting backlog to a clean 0/0 baseline — fix genuine issues, relax two intentional-pattern rules (multi-word-component-names, no-required-prop-with-default). Add a committed .githooks/pre-commit (enabled via core.hooksPath, auto-set by the prepare script) that runs Pint on staged PHP and ESLint --max-warnings 0 on staged resources/scripts JS/TS/Vue, blocking on failure. Add composer/npm lint scripts and document the gate in CLAUDE.md. Replace every scattered v-html with a single audited BaseSanitizedHtml component that DOMPurify-sanitizes its input (new utils/markdown.ts sanitizeHtml), so server/registry-provided HTML is actually sanitized and vue/no-v-html stays enabled everywhere but one reviewed sink. |
||
|
|
1e8b113cc9 |
refactor(frontend): drop unused deps, moment→date-fns, guid→crypto.randomUUID
Remove 10 unused/redundant frontend dependencies (@stripe/stripe-js, maska, @vuelidate/components, path, mini-svg-data-uri, @types/node, @tailwindcss/forms, brace-expansion [kept only as a resolutions pin], moment, guid). Replace moment with the already-present date-fns via a shared utils/date-range.ts (used by the 4 report views), and guid with native crypto.randomUUID(). Add tsconfig ignoreDeprecations so the vue-tsc typecheck script can run. |
||
|
|
f07b41fe55 |
fix(security): patch 7 vulnerable frontend deps on the v3 line (#654)
Clears all 7 npm advisories surfaced by `yarn audit` (3 high, 4 moderate -> 0). These are not surfaced by Dependabot since 3.x is not the default branch. - axios 1.14.0 -> 1.17.0 (high; was exact-pinned) - vite 8.0.3 -> 8.0.16 (high) - marked 18.0.1 -> 18.0.5 (high; AI-chat markdown parser) - dompurify 3.3.3 -> 3.4.8 (moderate; AI-chat HTML sanitizer) - postcss 8.5.8 -> 8.5.15 (moderate) - follow-redirects 1.15.11 -> 1.16.0 (moderate) - brace-expansion 5.0.5 -> 5.0.6 (moderate; dev-only transitive under eslint, pinned via resolutions) Updates the tracked yarn.lock; `yarn build` verified. |
||
|
|
b73dcb5278 |
fix: boot app + AI driver registration on invoiceshelf/modules 3.0.3 (via VCS) (#655)
* fix(deps): require invoiceshelf/modules ^3.0.2 (adds registerExchangeRateDriver)
DriverRegistryProvider::registerExchangeRateDrivers() calls
Registry::registerExchangeRateDriver(), which only exists in
invoiceshelf/modules >= 3.0.2. The constraint (^3.0) and the committed
lock (3.0.1) allowed/pinned versions without it, so a fresh
`composer install` (CI, Docker, new clones) boots into:
Call to undefined method InvoiceShelf\Modules\Registry::registerExchangeRateDriver()
Pin to ^3.0.2 and update the lock so every install gets a version that
has the method. `php artisan package:discover` verified clean.
* fix(ai): register the AI driver via the generic Registry::registerDriver('ai', ...)
registerAiDriver() is a convenience wrapper that is NOT present in any
published invoiceshelf/modules release (only the generic registerDriver()
and registerExchangeRateDriver() ship), so DriverRegistryProvider crashed
app boot on a clean composer install:
Call to undefined method InvoiceShelf\Modules\Registry::registerAiDriver()
Use registerDriver('ai', 'openrouter', ...) instead -- it stores under the
'ai' type exactly like the wrapper would, and AiConfigurationService /
AiDriverFactory read it back via allDrivers('ai') / driverMeta('ai', ...).
Verified by clean-reinstalling invoiceshelf/modules (no local patch) and
running `php artisan package:discover` -> boots clean.
* style: fix pre-existing Pint violations in backup services
BackupService.php and BackupConfigurationFactory.php (untouched by this
PR's boot fix) carried style violations from an earlier domain-reorg
refactor (
|
||
|
|
ac2a8ca939 |
fix(security): gate AI tools by user ability and block admin-URL SSRF
The AI chat assistant scoped tool queries by company but ignored the per-user Bouncer abilities the rest of the app enforces, so any `use ai` holder could read customers, invoices, payments, and company financials their role couldn't otherwise see. Each AiTool now declares a required ability (entity-aligned); the registry hides unauthorized tools from the model and refuses to execute them as a backstop. Separately, admin/owner-supplied URLs were fetched server-side with no guard against private/reserved targets (SSRF): the AI base URL, the CurrencyConverter "DEDICATED" exchange-rate URL, and S3/Spaces file-disk endpoints. A shared PrivateNetworkGuard now backs a PublicHttpUrl validation rule (save-time) and runtime guards in each driver. - AiTool::requiredAbility() + mapping across all 12 tools - AiToolRegistry filters schemas() by ability and re-checks in execute() - PrivateNetworkGuard / BlockedUrlException / PublicHttpUrl rule (new) - Rule wired into AI config (service + 3 controllers), exchange-rate, and file-disk endpoints; runtime guards in OpenRouterDriver, CurrencyConverterDriver, and FileDiskService - Tests for ability filtering, the guard, the rule, and 422 rejections |
||
|
|
3df2a01832 |
feat(ai): add customer/item/expense ranking tools to the chat assistant
Adds three new read-only tools the chat LLM can call to answer "who/what did the most X" questions that previously fell through the cracks: - rank_top_customers — ranks customers by invoiced_total, paid_total, invoice_count, or outstanding_balance over a named time period - rank_top_items — ranks catalog items by quantity_sold or revenue - rank_expense_categories — ranks expense categories by total spend All three share a new ResolvesPeriod trait that centralizes the period-name → [start, end] logic. GetCompanyStatsTool is refactored onto the same trait (identical public schema — the 'all_time' option is only exposed on the new ranking tools, where an unbounded window makes sense; stats over "all time" collapses every record into one giant bucket and is rarely useful). Each tool follows the existing pattern: snake_case name, one-sentence description tuned for LLM tool selection, JSON-schema parameters with injected company scoping (never trusting LLM-supplied company IDs), and JSON-encodable output. outstanding_balance on the customer tool explicitly ignores the period param since it's a current-state snapshot. Multi-company scoping tests lock down the session-authoritative boundary on every new tool. Per-metric ordering tests verify the aggregate queries actually rank correctly, and an ad-hoc-item exclusion test verifies rank_top_items skips invoice lines where item_id is null (free-typed entries that have no catalog row to rank by id). 15 new tests added (tests/Feature/Ai/Tools/); test suite grows from 398 to 413 passing. LLM tool count goes from 9 to 12 — the model will discover the new tools automatically via the function-calling schema with no prompt changes required. |
||
|
|
1f9aebdcfd |
style(ai): align chat drawer top bars to a single 48px row
The conversation-list sidebar header and the chat panel header lived in separate columns with different heights — the "+ New conversation" wrapper was ~60px (p-3 + py-2 button) while the "AI Assistant" header was ~44px (p-3 + small icon/text). The resulting staircase looked unintentional. Pins both to h-12 (48px) so they form a single unified top bar across the drawer. Shrinks the "+ New conversation" button to text-xs / py-1 / px-2 so it fits the tighter height without clipping, and switches its alignment to center to match the compacter footprint. |
||
|
|
5b53a7c283 |
refactor(ai): move chat + text-gen prompts into resources/ai/prompts/
Extracts the two inline LLM prompts (AiAssistantService's chat system
prompt and AiTextGenerationService's writing preamble) out of PHP
heredocs and into plain-markdown template files under resources/ai/
prompts/. Each file can now be edited without opening a service
class, without wrestling with PHP string interpolation, and with
proper markdown syntax highlighting in editors.
A tiny PromptLoader helper at app/Support/Ai/PromptLoader.php reads
the file and does {{placeholder}} substitution via strtr() — no
Blade, because Blade's {{ $var }} HTML-escapes ampersands and quotes,
which is wrong for LLM prompts (a company called "Smith & Co" would
be sent as "Smith & Co"). Missing templates throw RuntimeException
so they fail loud during development.
Pure refactor: no prompt wording changes. Existing AI feature tests
(AiChatFlowTest, AiGenerationTest) pass unchanged — they assert on
message structure via ScriptedAiDriver, not on prompt content. Three
new unit tests in PromptLoaderTest lock the helper's contract:
placeholder substitution, no-var loading, missing-file error.
|
||
|
|
f66d0f80b8 |
feat(ai): encourage emoji + richer markdown in chat system prompt
The assistant's output looked perfectly structured — headings, bold, lists — but had zero visual cues. Sonnet 4.6 defaults to restrained prose and won't decorate without a nudge. Users reviewing a list of invoices or an overdue summary benefit from status icons (✅ paid, ⚠️ overdue, 📝 draft) scanning across many records. Adds an emoji guideline to buildSystemPrompt() with a vocabulary mapped to the domain model's statuses (paid / partially paid / overdue / draft / sent / viewed / declined) plus totals, dates, stats and tips. Capped at one emoji per bullet so responses don't turn into decoration soup. Also expands the Markdown rule to explicitly mention headings, bold, bullet lists and tables — the model was already producing these but the prompt only vaguely said "Format in Markdown", which left tables off the table (pun intended) when comparing multiple records. |
||
|
|
02704a4b20 |
feat(ai): render assistant chat messages as sanitized markdown
The AI chat drawer was rendering assistant responses as plain text, so code blocks, lists, tables and inline formatting came through as literal asterisks and backticks — noisy and hard to scan. Adds a shared renderMarkdown() helper in resources/scripts/utils/ markdown.ts that parses GFM markdown via marked and sanitizes the result with DOMPurify before handing it to Vue's v-html. AiChatMessage uses the helper for assistant messages only; user messages stay as plain text since markdown syntax in their own typed input would be surprising. Assistant bubbles get the Tailwind `prose prose-sm` classes from the already-enabled @tailwindcss/typography plugin so headings, lists and code blocks inherit sensible defaults without per-element styling. Security: DOMPurify runs in its default browser profile, which strips <script>, event handlers, javascript: URLs and every other XSS vector. The AI provider isn't a trusted source — it can echo arbitrary user input and tool-call results from the database — so sanitization is non-negotiable even though the immediate source is our own backend. |
||
|
|
0da640c0df |
feat(ai): default to Claude Sonnet 4.6 / Haiku 4.5, refresh model list
Sets the default AI chat model to anthropic/claude-sonnet-4.6 and the default text-generation (WYSIWYG writing) model to anthropic/claude- haiku-4.5 across all three layers where defaults live: the backend hydrateDefaults() fallback in AiConfigurationService, the frontend createDefaults() in AiConfigurationForm, and the docblock example in AiTextGenerationService. Refreshes the DriverRegistryProvider suggested-model list to only include recent models from Anthropic (Sonnet 4.6, Haiku 4.5, Opus 4.6), OpenAI (GPT-5.4, GPT-5.4 mini), Google (Gemini 3.1 Pro preview, Gemini 3.1 Flash Lite preview) and Z.AI (GLM 5.1, GLM 4.7 Flash). Drops GPT-4o, Claude 3.5, Gemini 1.5 and Llama 3.3. The underlying config still accepts any OpenRouter model ID, so the suggested list is purely a UX surface — existing companies with a custom ai_chat_model retain their value untouched. |
||
|
|
a01771ddf4 |
fix(auth): redirect to login on 401 instead of hanging on bootstrap
When the Sanctum session/token expires, /api/v1/bootstrap returns 401 Unauthenticated, CompanyLayout's initializeLayout() throws, and isAppLoaded stays false — leaving the user on a spinning loader with no way out but a hard refresh to /login. Adds a response interceptor to the main axios client that catches any 401, clears stale auth state (auth.token, selectedCompany, isAdminMode), and navigates to /login?next=<original-path> so the user lands back where they were after re-auth. Exempts /login, /logout, /sanctum/csrf- cookie (where a 401 is a legitimate form/flow signal, not a session expiry), and guards against redirect loops via a module-level flag that collapses concurrent 401s into a single navigation. Also bails out on the login route itself, on /installation, and on customer- portal routes (which already have their own handling in the router guard). LoginView reads the ?next query param on successful login (sanitized to same-origin paths only, rejecting protocol-relative and absolute URLs so a crafted link can never open-redirect) and redirects there, falling back to /admin/dashboard. The router is imported dynamically inside the interceptor to break the client → router → guards → stores → client circular that a top-level import would create. Vite bundles the dynamic import into the main chunk, so it's free at runtime. |
||
|
|
6aca53786b |
chore(dev): add RealisticDemoSeeder for local AI testing
Creates 8 customers with addresses, 12 catalog items, 6 expense categories,
35 invoices (mix of DRAFT/SENT/VIEWED/PAID/PARTIALLY_PAID/UNPAID with
4 overdue), 17 payments covering the PAID and PARTIALLY_PAID invoices,
8 estimates, and 15 expenses — all time-distributed over the last 6
months so the AI chat assistant's get_company_stats, list_recent_payments,
and list_overdue_invoices tools return meaningfully different results
across query periods.
Dev-only: not wired into DatabaseSeeder, not in the test path (the
existing lightweight DemoSeeder stays there unchanged to keep the suite
fast). Runnable only via explicit:
php artisan db:seed --class=RealisticDemoSeeder --force
The seeder is idempotent — re-running wipes the previously seeded
records before regenerating, so you can iterate without manually
truncating the database.
Records are created directly via Model::create() rather than through
the existing InvoiceFactory / InvoiceItemFactory / ItemFactory, which
have bugs that make them unsuitable for realistic seeding:
- Both invoice factories unconditionally cascade-create a
RecurringInvoice on every invoice, generating junk recurring rows.
- ItemFactory sets creator_id to User->company_id, which doesn't
exist as a column on users (creator_id must be a user id).
- All three hardcode User::find(1)->companies()->first()->id,
coupling factories to a specific seeding order.
Those factory fixes are a separate follow-up if desired.
Item prices and all monetary values are stored in cents, matching the
rest of the app (the frontend divides by 100 on display). A $250
consulting hour is `price = 25000`.
|
||
|
|
b761ea9931 |
feat(ai): Phase 3 — text generation popup on WYSIWYG editors
Third and final phase of the AI feature. A SparklesIcon button is added to every Tiptap WYSIWYG editor (invoice notes, email body compose, note templates — ~6 places where RichEditor is used) that opens a modal with a prompt input, optional 'use current content as context' toggle, preview area, and Insert / Replace / Regenerate actions.
**Backend (thin)** — AiTextGenerationService is stateless: resolve config → check text_generation_enabled → instantiate driver → call textCompletion() with a system-prompt-wrapped user instruction. The system prompt is terse and opinionated: 'Return only the requested text. No preamble, no explanation, no markdown code fences.' When context is provided, it's included as a separate framed block ('Context (current content the user is working with):') so the model knows it's operating on existing copy.
**GenerationController** — POST /api/v1/ai/generate with {prompt, context?}. Validates prompt required (max 4000 chars) and context optional (max 20000 chars). Rate-limited via the same 'ai' RateLimiter from Phase 2 (30/min per user/company). Gated by 'use ai' Bouncer ability + AiConfigurationService resolution. Returns {text} on success or {error, message} with 422 on any AiException.
**Frontend modal (AiTextGenerationModal.vue)** — mounted globally in CompanyLayout when bootstrap reports ai.enabled && text_generation_enabled. Uses the existing modalStore pattern: self-registers on componentName='AiTextGenerationModal'. Modal state includes prompt, useContext toggle, generatedText preview. Callers (currently RichEditor) pass onInsert/onReplace callbacks via modalStore.data; the modal invokes them with the final text and closes — it knows nothing about tiptap or ProseMirror.
**RichEditor integration** — the Sparkles toolbar button is pushed onto the existing editorButtons ref at setup time, gated on globalStore.ai.enabled && text_generation_enabled. The button opens the modal with the editor's current getHTML() as context and callbacks that use the tiptap chain API: insertContent for Insert, selectAll().deleteSelection().insertContent for Replace. No reactivity on the flag check — it's set once at bootstrap and doesn't change during a session.
**Tests** (7 new) — AiGenerationTest with a dedicated TextGenDriver test double that tracks the exact prompt passed to textCompletion(). Covers: happy path, context inclusion/omission, AI globally disabled rejection, text_generation role disabled rejection, prompt/context length validation, response whitespace trimming.
395 tests pass (was 388, +7 new). Pint clean. npm run build clean. The AI feature is now complete end-to-end: provider configuration (Phase 1), chat assistant with DB tool-calling (Phase 2), and text generation popup (Phase 3).
|
||
|
|
e861fc1fc1 |
feat(ai): Phase 2 — chat assistant with tool-calling
Second phase of the AI feature. Users can now open a slide-in chat drawer from the SiteHeader and ask natural-language questions about their company's invoices, customers, payments, and expenses. The LLM invokes pre-defined read-only tool functions (scoped to the current company at execute time) to fetch data and synthesize answers.
**Database** — new ai_conversations and ai_messages tables. Messages are stored in OpenAI's chat format so AiAssistantService serializes a conversation into an API request with zero translation. Columns: role, content, tool_call_id, tool_calls JSON, model, tokens_in, tokens_out. Conversations are scoped (company_id, user_id) — one user's chats are invisible to everyone else, even inside the same company. Foreign-key cascade deletes.
**Tool infrastructure** — AiTool abstract base + AiToolRegistry singleton (registered in a new AiServiceProvider). The base class enforces the security rule: every tool's execute() receives companyId and userId as injected parameters; tools' JSON schemas NEVER include a company_id field. An LLM physically cannot pass a company_id and escape tenancy. Modules can register their own tools by resolving the registry from their own ServiceProvider::boot().
**Nine built-in tools**: search_invoices, get_invoice, search_customers, get_customer, list_recent_payments, list_overdue_invoices, get_company_stats (aggregates for named periods), search_items, list_expense_categories. All read-only; no mutations. Each returns JSON-encodable data the LLM can parse.
**AiAssistantService orchestration loop** — the heart of Phase 2. Flow: persist user message → build payload from system prompt + recent history (40-message window) + new user message → call driver.chatCompletion with tools → if tool_calls, execute each one via the registry (with injected scope), persist tool result, loop → if plain text, persist and return. Hard cap at 5 iterations to prevent runaway LLMs. System prompt pins the assistant to this company's data and forbids mutation.
**Controllers + policy + rate limit** — POST /api/v1/ai/chat runs the orchestration loop. GET/PATCH/DELETE /api/v1/ai/conversations for CRUD. AiConversationPolicy enforces user_id+company_id match on every action. A new 'ai' RateLimiter in RouteServiceProvider throttles to 30 req/min per (user, company). New 'use ai' Gate defined in AppServiceProvider returns true for any authenticated user — the per-company kill-switch still goes through AiConfigurationService::resolveForCompany.
**Frontend** — new features/company/ai/ folder with a Pinia store (ai-chat.store.ts) holding drawer state, current conversation, messages, and loading flags. AiChatDrawer.vue is a slide-in panel teleported to <body>, mounted globally in CompanyLayout.vue when bootstrap reports ai.enabled && ai.chat_enabled. Sub-components: AiChatMessage (user bubbles vs assistant bubbles), AiChatMessageInput (Enter submits, Shift+Enter newline), AiChatConversationList (sidebar with 'new chat' button, rename, delete). A SparklesIcon button in SiteHeader toggles the drawer.
**Driver test double** — tests use a ScriptedAiDriver registered via AiDriverFactory::register('scripted', ...) that returns pre-queued AiChatResponse objects. Feature tests cover: happy path (new conversation + message persistence), tool-call loop (multi-round-trip with search_invoices), runaway-loop cap, driver-throws path, ai_enabled=NO rejection, chat role disabled rejection, per-user conversation visibility, cross-user policy enforcement, cascade delete.
388 tests pass (was 372, +16 new). Pint clean. npm run build clean. Phase 3 (WYSIWYG text generation popup) is the remaining follow-up.
|
||
|
|
c7fab5d52f |
feat(ai): Phase 1 — provider configuration, installer step, admin + company settings
Foundation for the AI chatbot + text generation feature. Phase 1 is infrastructure only: driver plumbing, configuration storage with encrypted API keys, global vs per-company resolution, admin + company UI pages, and an optional installer wizard step. The chat assistant and text-generation WYSIWYG integration come in later phases.
**Driver plumbing (app/Support/Ai/)** — AiDriver abstract, AiDriverFactory, AiException, AiChatResponse DTO, OpenRouterDriver concrete implementation. OpenRouter is the OpenAI-compatible aggregator that unlocks hundreds of models behind one API key and one request shape — ideal as the default v1 driver. Drivers are extensible the same way exchange rate drivers are: the module Registry's generic registerDriver('ai', ...) machinery plus a typed Registry::registerAiDriver() convenience wrapper (shipped in the upstream invoiceshelf/modules package in a paired commit).
**AiConfigurationService** — mirrors MailConfigurationService shape but with one deliberate deviation: API keys are encrypted at the service layer via Crypt::encryptString before persistence. OpenRouter bearer tokens have much bigger blast radius than SMTP passwords. Same settings / company_settings tables, same global-vs-per-company pattern, same use_custom_ai_config override toggle. Resolution order: global ai_enabled must be YES, then the company either overrides via use_custom_ai_config=YES (and can opt out with ai_enabled=NO inside the override) or inherits the global config.
**Controllers** — Admin/Settings/AiConfigurationController (global CRUD + driver list + test connection), Company/Settings/CompanyAiConfigurationController (per-company override + test), Setup/AiConfigurationController (installer wizard step, skippable with explicit ai_enabled=NO). API key is always masked as '********' in GET responses — the frontend submits the placeholder back on save and the backend preserves the stored value.
**Installer wizard** — new optional step 7 'AI' between Mail and Account. Default OFF with a Skip button. MailView.vue now routes to installation.ai instead of installation.account; installation.ai then routes to installation.account. Step order comment updated in routes.ts.
**Admin + Company settings pages** — AdminAiConfigView (no toggle, always global) and AiConfigView (with use_custom_ai_config BaseSwitchSection that auto-saves OFF). Both share AiConfigurationForm which renders the driver selector, API key input with show/hide, driver-specific config_fields (base_url for OpenRouter), and per-role enable toggles with free-text model inputs backed by a datalist of suggested models from driver metadata.
**Bootstrap endpoint** — adds an ai block to the response: { enabled, chat_enabled, text_generation_enabled }. All three are booleans resolved through AiConfigurationService::resolveForCompany(). Never leaks the API key. Frontend feature flags read from bootstrapData.ai to decide whether to show Phase 2/3 UI.
**Bouncer ability** 'manage ai config' added to SettingsPolicy, gated on isSuperAdmin() (same pattern as manage email config, manage pdf config).
**Tests** (22 new) — Unit: AiDriverFactory resolves built-in + Registry-contributed drivers, rejects unknown, merges availableDrivers. AiConfigurationService: encryption round-trip, resolution order (3 cases: global off, inherit global, override with company key, override with opt-out), makeDriver null/instance cases, listDrivers metadata. Feature: admin save + read with api key masking, preserve-on-placeholder behavior, company toggle ON/OFF semantics, bootstrap ai flags reflect resolution, company opt-out path.
372 tests pass (was 350, +22). Pint clean. npm run build clean. Phase 2 (chat assistant + tool calling) and Phase 3 (WYSIWYG text generation popup) are separate follow-up commits — this one is the foundation only.
|
||
|
|
47907f9bf3 |
refactor(support): flatten Integrations umbrella and rename Formatters to Formatting
Two plural outliers were the only directories in Services/ and Support/ that didn't follow the singular naming convention we normalized in commit
|
||
|
|
f657b53215 |
refactor(services): split driver infrastructure out of Services into Support
Services/Integrations/ExchangeRate/ and Services/Pdf/ were both mostly Support-shaped: interfaces, abstract classes, static factories, concrete adapter drivers, DTOs, and exceptions — infrastructure that doesn't carry business logic. They only each had one real DI-injected service mixed in. This commit applies the same Services=DI-business-logic / Support=stateless-plumbing rule we've been using throughout the reorg: **Moved to Support/Integrations/ExchangeRate/** (7 files): ExchangeRateDriver (abstract), ExchangeRateDriverFactory (static), ExchangeRateException, and the four concrete drivers (CurrencyConverter, CurrencyFreak, CurrencyLayer, OpenExchangeRate). These are HTTP adapters over third-party currency APIs — same shape as the Hashids library wrapper classes already in Support. **Moved to Support/Pdf/** (6 files, merging with existing Pdf utilities): PdfDriver (interface), PdfDriverFactory (static), PdfService (static facade), GotenbergPdfDriver, GotenbergPdfResponse (DTO), ResponseStream (interface). The Support/Pdf/ dir now contains the full PDF rendering subsystem — drivers + sanitizer + template/image utilities. **Promoted to Services/ root** (the real DI services): ExchangeRateProviderService (CRUD for ExchangeRateProvider model) and FontService (font package install/download orchestration). Both are proper DI services — instance methods, model writes, HTTP side effects. Services/Integrations/ and Services/Pdf/ are now empty and deleted. Services/ holds only DI-injected classes; Support/ holds all the plumbing. 17 files renamed (git detects 90-99% similarity), 4 consumer files updated (DriverRegistryProvider, PdfServiceProvider, ExchangeRateProviderController, FontController, GeneratesPdfTrait, test). 350 tests pass, Pint clean. |
||
|
|
4c3d809f89 |
refactor(support): group Bouncer, Media, Setup files into subdirs
Finishes the Support/ consolidation pass: the three remaining root-level files get grouped into purpose-named subdirs, matching the shape the Pdf/, Hashids/, Update/, and Module/ subdirs took in the earlier sweep. - BouncerDefaultScope → Support/Bouncer/ (Bouncer-specific authorization scope) - CustomPathGenerator → Support/Media/ (Spatie MediaLibrary path generator — media config references it from config/media-library.php) - InstallWizardAuth → Support/Setup/ (folded into the existing Setup/ subdir alongside EnvironmentManager, FilePermissionChecker, InstallUtils, RequirementsChecker — it's install-wizard-flow state) 4 consumer files updated (AppServiceProvider, LoginController, UseInstallWizardTokenAuth middleware, config/media-library.php). app/Support/ root is now completely empty of standalone PHP files except helpers.php. |
||
|
|
8cc4a6fa98 |
refactor(services): move ModuleInstaller to app/Support/Module
ModuleInstaller has the same shape as Updater (moved in
|
||
|
|
7cf72b9f1d |
refactor(services): move Updater to app/Support/Update
Updater is a pure static procedural class — all eight public methods (checkForUpdate, download, unzip, copyFiles, deleteFiles, cleanStaleFiles, migrateUpdate, finishUpdate) are static, there's no constructor, no DI, no instance state. It's stateless self-update plumbing, same character as the Setup/ helpers that moved to Support/ in commit
|
||
|
|
947d00a9f1 |
refactor(services): Documents→Document + ExchangeRate→Integrations/ExchangeRate
Two follow-ups to the Services reorg that landed in
|
||
|
|
6d1816bd1b |
refactor: reorganize app/Services and app/Support by domain
The app/Services/ directory had grown into 22 flat files at the root plus 7 uneven subdirectories — finding anything required scrolling through an alphabetical mix of small CRUD services, infrastructure drivers, and install-time utilities. This commit groups services by domain, folds Backup into a new Storage namespace, and moves framework-infrastructure and install-time helpers out of Services and into Support where they belong. New Services layout: Documents/ (Invoice, Estimate, RecurringInvoice, Payment, Expense, Transaction, DocumentItem, SerialNumber, Currency — matches the 'Documents' navigation group); Company/ (Company, Member, Invitation); Mail/ (MailConfiguration, CompanyMailConfig); Storage/ (FileDisk, plus Backup folded in). ExchangeRateProviderService moves next to its drivers in ExchangeRate/; FontService moves into Pdf/ where it belongs. CustomerService, ItemService, CustomFieldService stay at the Services root as standalone single-file domains. Moves to Support/: Hashids/ (library wrapper — not business logic); Setup/ (one-shot install-time utilities — stateless helpers); Pdf/ (ImageUtils, PdfTemplateUtils, plus the existing PdfHtmlSanitizer consolidated into the same subdir). These are all framework infrastructure and stateless utilities — the 'service' label never really fit them. Namespace declarations in 29 moved files updated to match new paths. 62 consumer files (controllers, other services, tests, database factories, seeders, routes, bootstrap/providers.php) have their use statements rewritten via a literal-string replacement script — no regex meant no risk of half-matching. Three Documents services needed an explicit 'use App\Services\Mail\CompanyMailConfigService' added because the same-namespace short reference they relied on no longer resolves after the split. Verified: composer dump-autoload, 350 tests pass (850 assertions), vendor/bin/pint clean, npm run build succeeds. |
||
|
|
5c11147e95 |
feat(settings): allow Danger Zone for any owner regardless of company count
Removes three layered gates that kept the Danger Zone completely hidden unless the current user had more than one company: 1. SettingsLayoutView's showDangerZone computed no longer checks companies.length > 1 — just is_owner. 2. DangerZoneView drops the v-if that wrapped the delete button with the same check. 3. Admin\\CompaniesController::destroy() drops the companies_count <= 1 early-return that was enforcing the rule server-side (translation key You_cannot_delete_all_companies was inline in the controller, not in lang files or tests, so nothing else needs cleanup). The reasoning behind the old gate was that a user with zero companies would be stranded. That's a misread of how the app degrades: /admin/no-company already exists as a graceful fallback view, and the user can create a fresh company from there to recover. Hiding the entire delete flow just to avoid that fallback UX was overkill — the name-confirmation modal already prevents accidental deletion. |
||
|
|
31a2a66127 |
refactor(modules): move Modules into Company Settings as Module Configuration
The per-company Modules management page moves off its own top-level sidebar slot (which sat in the Admin group alongside Members/Reports/Settings) and into a new Module Configuration entry inside Company Settings, alongside Tax Types, Payment Modes, Mail Configuration, etc. That's where every other 'configure how the company behaves' surface lives — the Modules page is a configuration surface, not a primary working area. The label is deliberately 'Module Configuration' rather than 'Module Settings' because the latter collides with the existing per-module ModuleSettingsModal concept (the modal that opens when a user clicks an installed module's gear icon). Keeping the two names distinct means 'Module Configuration' unambiguously refers to the list of installed modules, and 'Module Settings' continues to mean the per-module schema form. CompanyModulesIndexView is stripped of its standalone BasePage / BasePageHeader / BaseBreadcrumb wrappers — as a child of SettingsLayoutView it would have rendered a double header — and re-wrapped in BaseSettingCard, matching TaxTypesView and every other settings-child view. The module grid tightens from lg:grid-cols-2 xl:grid-cols-3 down to lg:grid-cols-2 since the settings sidebar eats 240px of horizontal real estate. Routes consolidate: features/company/modules/routes.ts is deleted; the new settings.modules child route lives inside the settings routes file directly, alongside the rest. Top-level redirects are kept for the legacy /admin/modules and /admin/modules/:slug/settings URLs so existing bookmarks still resolve. ModuleRoutesConfigTest is re-pointed at settings/routes.ts and asserts the settings.modules route is owner-only. Module-contributed sidebar entries (those registered via Registry::registerMenu()) are NOT moved. Modules that want top-level navigation visibility keep it; only the meta management page moves. This mirrors WordPress/Discourse conventions where plugin pages stay in the main navigation but the 'Plugins' admin screen itself lives under Settings. |
||
|
|
e44657bf7e |
feat(exchange-rate): make providers extendible via module Registry
Exchange rate providers are now pluggable via the module Registry. The four built-in drivers (currency_converter, currency_freak, currency_layer, open_exchange_rate) move from a static config array into App\\Providers\\DriverRegistryProvider, which calls Registry::registerExchangeRateDriver() for each during app boot with metadata the frontend needs: label (i18n key), website (help-text URL), and config_fields (schema for driver-specific driver_config JSON).
The Currency Converter's server-type selector and dedicated URL field — previously hardcoded in ExchangeRateProviderModal.vue — are now just another config_fields entry with a visible_when rule that shows the URL input only when type=DEDICATED. Any module that wants to ship a custom driver gets the same treatment for free: declare config_fields in the registration, and the host app's modal renders them automatically.
ExchangeRateDriverFactory::make() falls back to Registry::driverMeta() when a name isn't in the local built-in map, and availableDrivers() merges both sources. ConfigController handles the exchange_rate_drivers key specially by mapping Registry::allDrivers('exchange_rate') to enriched option objects, so the config-file route still works for every other key. The static exchange_rate_drivers + currency_converter_servers arrays in config/invoiceshelf.php are deleted.
Unit tests cover the new Registry::register/flushDrivers, the factory merging built-ins with Registry-contributed drivers, and the factory rejecting unknown names. A feature test exercises the end-to-end /api/v1/config?key=exchange_rate_drivers response shape.
NOTE: this commit depends on invoiceshelf/modules package commit e44d951 which adds the Registry driver API. The package needs to be released and pinned in composer.json before a fresh composer install on this commit will work.
|
||
|
|
112cc56922 |
chore(infra): default mail driver to sendmail and expose Vue runtime
Mail DEFAULT_DRIVER changes from smtp to sendmail; DRIVER_ORDER is reshuffled so sendmail is the head of the list on fresh installs. This matches what most self-hosted installs already have working out of the box — SMTP requires provider credentials the typical user doesn't have set up yet. The mail config description is rewritten to drop the 'Laravel' framework reference and to explicitly tell unsure users to leave it on sendmail.
SiteApi::get() now catches GuzzleException (the broader interface) and returns null on network failure instead of bubbling the exception object — callers were treating a non-array return as 'marketplace unavailable' anyway, so null is the correct shape.
main.ts exposes the Vue runtime on window.__invoiceshelf_vue so module JS (compiled against the host's Vue install) can call createApp / defineComponent without re-bundling Vue. invoiceshelf.css adds Tailwind source globs for Modules/**/*.{js,ts,vue,blade.php} so module-contributed classes are picked up by the host CSS pipeline.
Installation wizard PreferencesView was already in the tree waiting for the API field rename (date_formats, time_zones, fiscal_years, languages) that landed in setting.service.ts; this commit catches both sides up together.
|
||
|
|
7885bf9d11 |
feat(menu): priority-sorted menu groups, user-menu items, sidebar appearance toggle
Every main_menu entry moves from numeric group (1/2/3) to string-based group + group_label + priority. Groups now carry their own i18n label and child entries are sorted by an explicit priority field instead of config-array order, so module-contributed menu items can slot into any existing group at any position.
BootstrapController merges module-registered menu items into main_menu (previously they lived in a separate module_menu response key) and introduces a user_menu response key for items modules want to place in the avatar dropdown. The global store follows suit: moduleMenu becomes userMenu, menuGroups is a computed that sorts by priority, and hasActiveModules drops out.
New admin Appearance setting page with a single toggle for whether sidebar group labels render — so instances that prefer a compact sidebar can hide the Documents/Administration/Modules headings without losing the grouping itself. CompanyLayout watches route meta and re-bootstraps when the admin-mode flag flips so the sidebar repaints with the right menu on navigation across the admin boundary.
Test suites updated: module menu merging is asserted against main_menu (name: 'module-{slug}') rather than the old module_menu response; HelloWorldIntegrationTest verifies the schema translation path; CompanyModulesIndexTest covers the display_name attachment.
|
||
|
|
345bfde306 |
feat(modules): translated display names and inline settings modal
CompanyModulesController attaches a translated display_name to each module before returning the list. ModuleSettingsController gains a translateSchema() helper that resolves section titles and field labels against the host app's i18n store before sending the schema to the frontend, so module authors can keep their 'my_module::settings.field' keys and users still see localized strings. Per-module settings now open in an inline ModuleSettingsModal rather than routing to a standalone page. The modal reuses BaseSchemaForm for rendering, so the whole interaction takes place in-context next to the module card the user clicked — no navigation, no loss of place. CompanyModuleCard displays the translated display_name instead of the raw slug and emits open-settings with the module payload; the parent view hands that to the modal store. |
||
|
|
3d79fe1abc |
feat(modules): redesign admin marketplace cards and detail view
ModuleCard moves badges to the top-right, shows a cover placeholder when art is missing, and drops the rating/pricing chrome that was never populated by the marketplace. ModuleDetailView splits into a hero row (cover + module info, two-thirds width) plus a sticky action card on the right (one-third) so install/update/purchase buttons stay visible when scrolling long descriptions. ModuleIndexView promotes the marketplace API token form to a persistent card at the top of the page and adds an authenticated/premium status pill so super-admins can see whether the current token unlocks premium listings. The tabs and empty state were reorganized so 'installed' and 'marketplace' feel like peers. The admin modules store tracks marketplace auth status, adds checkApiToken() and setApiToken() methods, and unifies the install-request shape into ModuleInstallPayload so both the free and paid install buttons route through the same code path. |
||
|
|
23d1476870 |
refactor(modules): marketplace install flow with checksum validation
Rewires module installation to use slug + version + checksum_sha256 instead of the opaque module identifier. ModuleInstaller splits marketplace token handling out of install() into helpers, adopts structured error responses, and validates the downloaded archive's SHA-256 against the marketplace manifest before unpacking. ModuleResource is simplified to accept an already-loaded installed-module instance rather than fetching it from state, exposes access_tier and checksum fields, and drops the auto-disable-on-unpurchased side effect that was bleeding write logic into a read resource. UnzipUpdateRequest accepts a nullable module with a conditional module_name field so the same endpoint serves both app and module updates. ModulesPolicy::manageModules now short-circuits for super-admins so administration flows (token validation, store state) are not blocked on a company-scoped ability. Two new feature tests cover both the authorization bypass and ModuleResource serialization. |
||
|
|
2af31d0e5f |
Rename local_public disk back to public and store avatars/logos on public disk
The public disk was accidentally removed during the Laravel 11 upgrade and re-added as local_public in the FileDisk refactor. Restoring the standard Laravel name avoids breaking Spatie MediaLibrary expectations and simplifies the v2-to-v3 upgrade path. User avatars and company logos now explicitly use the public disk via registerMediaCollections(), keeping them web-accessible while the default media disk remains private for sensitive documents like PDFs and receipts. The v3 upgrade migration renames the system disk entry and any alpha media records from local_public back to public. |
||
|
|
42ce99eeba |
Show common currencies first in dropdowns and default to USD in install wizard
Currency dropdowns now display the most-traded currencies (USD, EUR, GBP, JPY, CAD, AUD, CHF, CNY, INR, BRL) at the top, followed by the rest alphabetically. The install wizard defaults to USD instead of EUR and formats currency names as "USD - US Dollar" for consistency with the rest of the app. |
||
|
|
9174254165 | Refactor install wizard and mail configuration | ||
|
|
1d2cca5837 |
feat(marketplace): make base_url env-configurable for local testing
The marketplace client (App\Services\Module\ModuleInstaller) and the updater
(App\Services\Update\Updater) both build their Guzzle base URI from
config('invoiceshelf.base_url') via the App\Traits\SiteApi::getRemote()
helper. The value was hardcoded to 'https://invoiceshelf.com', which made
local development against a self-hosted invoiceshelf/website checkout
impossible without editing the config file by hand.
Wrap it in env('INVOICESHELF_BASE_URL', 'https://invoiceshelf.com') so a
.env entry can repoint both the marketplace and the updater at any host —
e.g. http://invoiceshelf-website.test, http://localhost:8000, or a staging
deployment. The default fallback stays https://invoiceshelf.com so
production behavior is unchanged.
Document the new variable in .env.example, commented out so it has no
effect unless explicitly enabled.
The marketplace and updater are tied to the same base URL by design — a
local dev instance pointed at a local marketplace probably also wants to
test against the local release-API, so they should move together.
|
||
|
|
93b04a0c2a |
test(modules): integration tests for company surfaces and stub generator
End-to-end coverage for the new module APIs and the custom module:make
stubs shipped from invoiceshelf/modules. Each test file is hermetic — uses
\InvoiceShelf\Modules\Registry::flush() in setup/teardown to prevent
cross-test contamination, and ModuleMakeStubTest cleans up generated test
artifacts (the throwaway scaffold directory and the storage statuses entry).
- CompanyModulesIndexTest: 4 tests covering only-enabled-modules filter,
has_settings flag computed against the real Registry, menu inclusion, and
the empty-state response.
- ModuleSettingsControllerTest: 7 tests covering 404 for unregistered slug,
show schema + defaults round-trip, persistence with the
module.{slug}.{key} prefix, missing-required-field rejection, unknown-key
silent-drop, update 404, and per-company isolation (the load-bearing
multi-tenancy guarantee).
- BootstrapModuleMenuTest: 3 tests covering Registry-driven module_menu
population on the company-context bootstrap branch, the empty default
when nothing is registered, and the absence of module_menu on the
super-admin-mode branch.
- ModuleMakeStubTest: 3 tests that actually run
Artisan::call('module:make', ['name' => ['ScaffoldProbe']]) against a
throwaway module name and assert the generated ServiceProvider contains
use InvoiceShelf\Modules\Registry, the generated composer.json requires
invoiceshelf/modules: ^3.0, and starter lang/en/{menu,settings}.php exist.
Validates that the custom stubs shipped from the package are picked up
via Stub::setBasePath().
|
||
|
|
7743c2e126 |
feat(modules): dynamic sidebar group rendering active modules
The sidebar gains a new section that lists each currently-activated module as a direct shortcut to its settings page. This is the always-visible companion to the company-context Active Modules index — both surface the same set of modules, but the index is the catalog landing page and the sidebar group is the per-module quick access. - BootstrapController returns module_menu populated from \InvoiceShelf\Modules\Registry::allMenu(), but only on the company-context branch — not on the super-admin branch (lines 53-69), since super admins don't see the dynamic group. Because nwidart only boots service providers for currently-activated modules, the registry naturally contains only active modules at request time, no extra filtering needed. - bootstrap.service.ts BootstrapResponse type extended with module_menu?: ModuleMenuItem[]; new ModuleMenuItem interface (title/link/icon) — shaped distinctly from MenuItem because module entries use namespaced i18n keys and don't carry group/ability metadata. - global.store.ts exposes a moduleMenu ref + a hasActiveModules computed. - SiteSidebar.vue appends a new "Modules" section after the existing menuGroups output, in both the mobile (Dialog) and desktop branches. The section is hidden when hasActiveModules is false. Uses the modules.sidebar.section_title i18n key added in the previous commit. |
||
|
|
e6eeacb6d4 |
feat(modules): company-context module surfaces and schema-driven settings
Adds the read-only company "Active Modules" index page (lists every
instance-activated module with a Settings shortcut) and the schema-driven
settings framework (generic BaseSchemaForm.vue renderer + per-company
persistence in CompanySetting). Bundled because they share the same
routes/api.php edit and the index page's Settings button targets the
settings page.
Backend:
- CompanyModulesController::index() returns every Module::enabled = true row
with a kebab-case slug (via Str::kebab()) and a has_settings flag computed
from \InvoiceShelf\Modules\Registry::settingsFor(). nwidart stores module
names in PascalCase ("HelloWorld") but URLs and registry keys use kebab
("hello-world") — the controller normalizes so module authors can call
Registry::registerSettings('hello-world') naturally without thinking
about the storage format.
- ModuleSettingsController::show(\$slug) returns the registered Schema +
per-company values from CompanySetting (defaults flow through when nothing
has been saved yet). update(\$slug) builds Laravel validator rules from
the Schema's per-field rules arrays — with type-rule fallbacks for
switch -> boolean, number -> numeric, multiselect -> array — silently
drops unknown keys, and persists via CompanySetting::setSettings() under
the module.{slug}.{key} prefix. Activation is instance-global, but
settings are per-company: two companies on the same instance can
configure the same activated module differently.
- routes/api.php mounts GET /api/v1/company-modules at the root of the
company API group and GET/PUT /api/v1/modules/{slug}/settings inside the
existing modules prefix.
Frontend:
- BaseSchemaForm.vue is the central new component — a generic schema-driven
form renderer that maps schema fields to BaseInput / BaseTextarea /
BaseSwitch / BaseMultiselect by type, and builds Vuelidate rules
dynamically from each field's rules array (supports required, email, url,
numeric, min:N, max:N). New fields are added by extending the type ->
component map.
- CompanyModulesIndexView.vue fetches /company-modules and renders a card
grid (with empty/loading states); CompanyModuleCard.vue is the per-row
component with the Settings button. ModuleSettingsView.vue fetches
/modules/{slug}/settings, hands {schema, values} to BaseSchemaForm, and
posts back on submit.
- Company-context routes.ts is rebuilt after the previous commit relocated
the marketplace browser away. It now declares modules.index +
modules.settings, both gated by manage-module ability.
- New api/services/{companyModules,moduleSettings}.service.ts thin clients.
- lang/en.json adds modules.index.{description,empty_title,empty_description},
modules.settings.{title,open,saved,not_found,none}, and
modules.sidebar.section_title. The sidebar key is added here even though
the dynamic sidebar rendering lands in the next commit — keeping all i18n
additions in one file edit avoids hunk-splitting lang/en.json.
|
||
|
|
84725b2dfa |
feat(modules): relocate marketplace browser to super-admin context
The module marketplace browser UI (ModuleIndexView, ModuleDetailView,
ModuleCard, the four-step installer store) was filed under
features/company/modules/ only by historical accident — it's authorized via
the manage modules ability (super-admin-only) and conceptually belongs in the
admin context, not the company context.
- Move features/company/modules/{store.ts, views/ModuleIndexView.vue,
views/ModuleDetailView.vue, components/ModuleCard.vue} to
features/admin/modules/.
- Update hardcoded /admin/modules/... paths in the moved files to
/admin/administration/modules/... so the breadcrumbs and ModuleCard
navigation target the new admin-context routes.
- Tighten the four-step installer's silent catch {} blocks in the moved
store.ts: errors were being swallowed, now they dispatch through the
global notification store instead.
- New features/admin/modules/routes.ts declares admin.modules.index +
admin.modules.view as children of /admin/administration with
meta.isSuperAdmin: true.
- features/admin/{index,routes}.ts re-export and mount the relocated routes.
- config/invoiceshelf.php gains a new AdminModules entry in admin_menu
pointing at /admin/administration/modules with super_admin_only: true.
- The dev-gated navigation.modules entry in main_menu is replaced (not
deleted) with a non-gated entry pointing at the new company-context
Active Modules index page that lands in the next commit. The
ability is set to manage modules so non-owners can't see it.
The new company-context Active Modules index, schema-driven settings page,
and dynamic sidebar group are introduced in subsequent commits.
|
||
|
|
b2b7a07e0c |
refactor(modules): migrate asset registry from app/Services to invoiceshelf/modules package
The vestigial App\Services\Module\Module static class — with its unused
\$scripts / \$styles / \$settings registries — never had any of its helpers
wired up. The new InvoiceShelf\Modules\Registry shipped from the
invoiceshelf/modules package supersedes it cleanly: same static-array surface
(\$menu, \$settings, \$scripts, \$styles), but lives outside the host app so
third-party modules can depend on it without importing v3-app internals.
Three consumers in the host app are migrated to the new namespace:
- ScriptController and StyleController (the HTTP endpoints that serve
module-registered JS/CSS assets at /modules/scripts/{name} and
/modules/styles/{name}) now look up paths via Registry::scriptFor() and
Registry::styleFor() instead of Arr::get(ModuleFacade::all*(), \$name).
Also tightens type hints — Request import + Response return type.
- resources/views/app.blade.php iterates Registry::allStyles() /
Registry::allScripts() to inject module-supplied <link>/<script> tags into
the main layout. Same Akaunting-style asset injection mechanism, just
reading from the new namespace.
Both Module and ModuleFacade are deleted — they had no remaining callers
after this migration.
|
||
|
|
61e1efd81b |
chore(deps): upgrade nwidart/laravel-modules to v13 via invoiceshelf/modules ^3.0
Pulls invoiceshelf/modules ^3.0 from packagist — a thin extension package on top of current upstream nwidart/laravel-modules ^13.0 — replacing the stale 2021-era invoiceshelf/modules ^1.0 fork that bundled its own copy of nwidart. The host app's autoloader now resolves Nwidart\Modules\* from vendor/nwidart/laravel-modules and InvoiceShelf\Modules\* from vendor/invoiceshelf/modules. Existing imports of Nwidart\Modules\Facades\Module keep working unchanged. config/modules.php is republished from upstream v13 with two InvoiceShelf-specific overrides: - activators.file.statuses-file kept at storage/app/modules_statuses.json so existing installations don't lose track of which modules are enabled when the config is republished (upstream v13 defaults to base_path()). - New lang/menu and lang/settings entries in stubs.files / stubs.replacements that pair with the custom module:make stubs shipped from the package. Wires wikimedia/composer-merge-plugin (a transitive dependency of nwidart) so each module's nested composer.json autoload mapping is merged into the host autoloader at composer dump-autoload time. This is what makes a module generated via php artisan module:make MyModule actually loadable. The plugin is added to allow-plugins and configured via extra.merge-plugin.include. Drops the stale Modules\\: Modules/ PSR-4 fallback root from autoload — it didn't match nwidart's app/-prefixed module layout and was always broken for generated modules. |
||
|
|
1fb5886d06 |
Sanitize PDF address fields against SSRF in getFormattedString chokepoint
Closes the residual surface from the three published SSRF advisories (GHSA-pc5v-8xwc-v9xq, GHSA-38hf-fq8x-q49r, GHSA-q9wx-ggwq-mcgh / CVE-2026-34365 to 34367) that the original 2.2.0 fix only covered for the Notes field. The same blade templates render company/billing/shipping address fields with {!! !!} via Invoice/Estimate/Payment::getCompanyAddress(), getCustomerBillingAddress(), getCustomerShippingAddress() — and those flow through GeneratesPdfTrait::getFormattedString() which did not call PdfHtmlSanitizer.
Customer-controlled fields (name, street, phone, custom-field values) are substituted into address templates via getFieldsArray() without HTML-escaping, so a malicious customer name like "Acme <img src='http://attacker/probe'>" reaches Dompdf as raw HTML through the address path. Today this is blocked only by the secondary defense of dompdf's enable_remote=false; if a self-hoster sets DOMPDF_ENABLE_REMOTE=true for legitimate remote logos, the address surface immediately re-opens.
Move PdfHtmlSanitizer::sanitize() into the chokepoint at GeneratesPdfTrait::getFormattedString() so all four sinks — notes plus the three address fields, on all three models — get the same treatment via a single call site. v3.0's models (Invoice, Estimate, Payment) already had the simpler getNotes() shape (no per-method PdfHtmlSanitizer wrapper), so the trait edit alone is sufficient — no model edits required on this branch. Verified getFormattedString() is only called from PDF code paths (no email body callers, which use strtr() directly).
This is the v3.0 counterpart to master's f387e751. Re-implemented directly on v3.0 instead of cherry-picked because the import-block divergence from the larger v3.0 refactor produced four merge conflicts that were noisier than just porting the chokepoint change manually.
Extends tests/Unit/PdfHtmlSanitizerTest.php with three new cases covering the address-template scenario, iframe/link tag stripping, and on* event handler removal. All 8 tests pass via vendor/bin/pest tests/Unit/PdfHtmlSanitizerTest.php.
|
||
|
|
bd756f2365 |
Rewrite README with v3.0 roadmap
Polishes the intro wording, removes the Mobile Apps section (the source repo is referenced from invoiceshelf.com instead) and the Credits section, and rebuilds the Roadmap as a single flat checklist that mixes shipped items with in-flight v3.0 work. Shipped v3.0 items added: decoupled system settings from company settings, proper multi-tenancy system, company member invitations with custom roles, dark mode, full TypeScript refactor of the frontend, improved backend architecture, security hardening. In-flight v3.0 items (bold + (v3.0) marker so they jump out as the active milestone): reworked installation wizard, Module Directory, rewritten Payments module. The two longer-term items (Stripe integration, improved template system) stay at the bottom unchanged. |
||
|
|
999ff3e977 |
Auto-update invoice due date when invoice date changes
Port of master's
|