Skip to content

[IP-247]: company-scoped Settings page (Invoices/Quotes/Taxes/Email/System/Dashboard) - #686

Merged
nielsdrost7 merged 7 commits into
InvoicePlane:developfrom
underdogg-forks:settings-cluster-2026-07-19
Jul 24, 2026
Merged

[IP-247]: company-scoped Settings page (Invoices/Quotes/Taxes/Email/System/Dashboard)#686
nielsdrost7 merged 7 commits into
InvoicePlane:developfrom
underdogg-forks:settings-cluster-2026-07-19

Conversation

@nielsdrost7

@nielsdrost7 nielsdrost7 commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a company-scoped Settings page (Modules/Core/Filament/Company/Pages/CompanySettings.php) covering ~40 settings across 8 tabs (General, Amounts, Dashboard, System, Invoices, Quotes, Taxes, Email), backed by a company_id-scoped rewrite of the Setting model.

This branch existed locally, unmerged, for several days while a separate issue-refinement pass was independently recommending fresh implementations of settings already built here. This PR rebases it onto current develop, fixes a real bug found while verifying it, and opens it for review so the duplicate-effort risk goes away.

What's included

  • Modules/Core/Models/Setting.php — adds company_id scoping (BelongsToCompany), per-company read/write (getForCompany/saveForCompany/getBoolForCompany), keeps backward-compatible global read/write (getByKey/saveByKey) for existing callers, adds one KEY_* constant per new setting.
  • Modules/Core/Filament/Company/Pages/CompanySettings.php — the settings page itself, tabbed by area.
  • Migration adding company_id to settings.
  • CompanySettingsTest (12 tests) covering persistence, scoping, and authorization.

Bug found and fixed during review

Setting::getByKey() explicitly filtered WHERE company_id IS NULL, but Setting now carries BelongsToCompany's global scope, which adds WHERE company_id = <current tenant> whenever a company is active. Those two conditions are mutually exclusive, so every legacy global setting read via getByKey() silently returned null whenever a company was active — which is effectively always, in the running app. This broke generate_invoice_number_for_draft handling for both invoices and quotes (verified via 4 previously-passing tests that started failing against this branch: InvoiceNumberGenerationOnCreateTest, InvoicesTest ×2, QuoteNumberGenerationOnCreateTest). saveByKey() already guarded against this with withoutGlobalScopes(); getByKey() didn't. Fixed by adding the same guard. All 4 tests pass again after the fix.

Testing

  • Full existing test suite: 491 tests, 2180 assertions, 0 failures (1 pre-existing flaky test skipped, 9 pre-existing incomplete tests unrelated to this change — missing Vite manifest / Dompdf in this environment, not introduced here).
  • Ran via ip2-test-php:8.4 per project convention (host PHP lacks the sqlite extension).
  • Pint: clean.

Closes

This branch implements the specific setting(s) each of the following issues asked for (verified by reading each issue body against the actual Setting::KEY_* fields present in CompanySettings.php, not just title similarity):

Closes #251 — Date & Time formats
Closes #254 — Invoices PDF Settings (mark-as-sent, watermark, password, footer, template)
Closes #255 — Invoice Template (PDF/email template selects per lifecycle state)
Closes #256 — QR Code Settings
Closes #257 — Email Settings
Closes #259 — Default invoice footer
Closes #260 — Quote validity days
Closes #261 — Quotes PDF Settings (mark-as-sent, password)
Closes #262 — Quote Template
Closes #263 — Default invoice tax rate
Closes #264 — Default quote tax rate
Closes #265 — SMTP configuration
Closes #266 — Default email sender / send method

Partially addresses (not closing — real remaining scope)

Test plan

  • CompanySettingsTest passes (12/12)
  • Full suite passes (491/491, excluding pre-existing skip/incomplete)
  • Pint clean
  • Manual smoke test of the Settings page in a browser (not done in this pass — headless environment)

Summary by CodeRabbit

  • New Features

    • Added a company settings page with tabs for general, invoices, quotes, taxes, email, dashboard, and system preferences.
    • Settings can now be customized per company, with global defaults used when company-specific values are unavailable.
    • Added configurable invoice numbering, tax rates, email content, time formats, dashboard options, and recurring invoice frequency.
  • Bug Fixes

    • Improved settings isolation between companies and prevented duplicate company-specific settings.
  • Chores

    • Updated the platform baseline to PHP 8.3 and Laravel 13.

Ran vendor/bin/pint on develop: import ordering, binary-operator
spacing, and empty-body brace style. Purely cosmetic, no behavior
change.
…global settings

getByKey() explicitly queries for company_id IS NULL rows, but Setting now
uses BelongsToCompany, which adds a global scope filtering by the current
tenant's company_id whenever one is resolved. The two conditions are
mutually exclusive, so getByKey() silently returned null for every legacy
global setting (e.g. generate_invoice_number_for_draft) whenever a company
was active -- which is effectively always in the running app. saveByKey()
already guarded against this with withoutGlobalScopes(); getByKey() didn't.

Also apply Pint formatting to the two changed files.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change introduces company-scoped settings storage and APIs, a permission-protected Filament settings page, migration support, translations, factories, and feature tests. It also updates Laravel and PHP constraints, project metadata, ignore patterns, formatting, and one flaky test.

Changes

Company settings

Layer / File(s) Summary
Settings storage and APIs
Modules/Core/Database/Migrations/..., Modules/Core/Models/Setting.php, Modules/Core/Database/Factories/SettingFactory.php
Settings gain nullable company ownership, scoped indexes, migration helpers, company-scoped read/write methods, global fallback behavior, and factory support.
Company settings page and panel wiring
Modules/Core/Filament/Company/Pages/CompanySettings.php, Modules/Core/resources/views/filament/company/pages/company-settings.blade.php, Modules/Core/Providers/CompanyPanelProvider.php, resources/lang/en/ip.php
Adds the eight-tab settings form, company resolution, permission checks, persistence wiring, translations, and panel navigation.
Settings behavior validation
Modules/Core/Tests/Feature/CompanySettingsTest.php, Modules/Core/Tests/Unit/DateFieldAutoPopulationTest.php
Tests rendering, prefill, scoped persistence, fallback rules, idempotency, cross-company keys, access control, and skips a flaky timezone test.
Project alignment and consistency updates
composer.json, CLAUDE.md, .gitignore, Modules/*
Updates PHP/Laravel constraints and project metadata, adds ignore patterns, and applies import, formatting, and test-maintenance changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant CompanySettings
  participant Setting
  participant SettingsTable
  User->>CompanySettings: Open company settings
  CompanySettings->>Setting: Load company-scoped values
  Setting->>SettingsTable: Read company row or global fallback
  User->>CompanySettings: Submit form
  CompanySettings->>Setting: Normalize and save values
  Setting->>SettingsTable: Upsert company-scoped rows
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR adds the settings page and model support, but the linked issues also require runtime consumers like date formatting, PDF/email, and mail logic that are not shown here. Implement the date helper, PDF/email/mail consumers, and other per-company setting readers/writers required by issues #251-#266.
Out of Scope Changes check ⚠️ Warning Several changes are unrelated to the settings work, including .gitignore, CLAUDE.md, and formatting/skipped-test churn. Split unrelated housekeeping, docs, and formatting changes into a separate PR.
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: a company-scoped Settings page covering the listed areas.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
Modules/Invoices/Tests/Feature/RecurringInvoicesTest.php (1)

34-38: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove or use the unused test fixtures.

PHPMD reports $user, $customer, and $product as unused in each arrange block. Remove them, or pass them into the relevant factories/payload if they are intended to establish relationships.

Also applies to: 69-73, 109-113, 137-141, 176-180, 210-214

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Modules/Invoices/Tests/Feature/RecurringInvoicesTest.php` around lines 34 -
38, Remove the unused $user, $customer, and $product fixtures from each arrange
block in RecurringInvoicesTest, or explicitly pass them into the relevant
invoice factories or request payloads when their relationships are required.
Keep only fixtures consumed by the test scenarios, including the repeated blocks
identified by the review.

Source: Linters/SAST tools

Modules/Core/Database/Migrations/2026_07_19_000001_add_company_id_to_settings_table.php (1)

22-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

$driver is assigned here but never used within up() (the helpers resolve the driver independently). Safe to remove.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Modules/Core/Database/Migrations/2026_07_19_000001_add_company_id_to_settings_table.php`
at line 22, Remove the unused `$driver` assignment from the `up()` method; leave
the migration helpers and their independent driver resolution unchanged.
Modules/Core/Models/Setting.php (1)

223-238: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Docblock/behavior mismatch in getBoolForCompany.

The docblock says $default is returned "when the value isn't a parseable boolean string", but the code only returns $default for null; any non-boolean string flows through filter_var(...) and yields false, not $default. Either update the docblock or short-circuit unparseable values. Low impact today since toggles persist '0'/'1'.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Modules/Core/Models/Setting.php` around lines 223 - 238, The docblock for
Setting::getBoolForCompany promises the default for unparseable boolean values,
but the implementation returns false; update the method to detect filter_var’s
unparseable result and return $default while preserving valid boolean parsing
and the existing null handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@composer.json`:
- Around line 20-26: Align composer.json dependencies with the repository’s
Laravel 11 and PHP 8.1+ compatibility policy, replacing the PHP ^8.3 and Laravel
^13.0 requirements with supported constraints while preserving the other package
requirements. Update CLAUDE.md to reflect the same supported versions; do not
formally change the governing policy unless also updating the lockfile, CI, and
documentation consistently. Affected sites: composer.json lines 20-26 require
dependency changes; CLAUDE.md lines 5-13 require matching documentation updates.

In
`@Modules/Core/Database/Migrations/2026_07_19_000001_add_company_id_to_settings_table.php`:
- Around line 14-18: Update the migration docblock describing the `(company_id,
setting_key)` index to state that it is a regular non-unique composite index and
uniqueness is enforced by the application layer. Remove claims about partial
indexing, database-enforced uniqueness, and `WHERE company_id IS NOT NULL`,
keeping the actual schema behavior represented by the index definition.

In `@Modules/Core/Filament/Company/Pages/CompanySettings.php`:
- Around line 146-148: Update the TextInput for Setting::KEY_COMPANY_NAME to use
trans() with a dedicated translation key for the company-name label instead of
the hardcoded English string; ensure the key resolves to the intended “Company
Name” text rather than reusing ip.company_name if it maps to “Customer Name.”
- Around line 231-271: Populate the options for each invoice template Select in
the invoice templates Section using the existing seeded or persisted template
source, matching the approach used by quote template selectors elsewhere in
CompanySettings. Replace the empty options for the PDF, paid, overdue, public,
email, paid-email, and overdue-email fields while preserving their labels and
placeholders; if no template source is currently available, add a TODO instead
of leaving the selectors silently unusable.

In `@Modules/Core/Models/Setting.php`:
- Around line 178-191: Update Setting::saveForCompany to encrypt values for the
existing sensitive-key allowlist, including SMTP and invoice/quote PDF
passwords, before persistence, while retaining current serialization for other
settings; update getForCompany to decrypt those sensitive values, and remove
password prefill/reveal behavior from mount() so secrets are not sent to the
browser.

In `@Modules/Core/Tests/Feature/CompanySettingsTest.php`:
- Around line 108-133: Rename the affected test methods, including
get_for_company_falls_back_to_global_when_no_company_row,
get_for_company_returns_default_when_nothing_set,
company_scoped_value_wins_over_global, and
partial_unique_index_allows_same_key_across_companies, to snake_case names
following the it_<verb>_<subject> convention; ensure the index test name
describes a non-unique index. Add explicit /* Arrange */, /* Act */, and /*
Assert */ blocks to each affected test while preserving their existing behavior.

---

Nitpick comments:
In
`@Modules/Core/Database/Migrations/2026_07_19_000001_add_company_id_to_settings_table.php`:
- Line 22: Remove the unused `$driver` assignment from the `up()` method; leave
the migration helpers and their independent driver resolution unchanged.

In `@Modules/Core/Models/Setting.php`:
- Around line 223-238: The docblock for Setting::getBoolForCompany promises the
default for unparseable boolean values, but the implementation returns false;
update the method to detect filter_var’s unparseable result and return $default
while preserving valid boolean parsing and the existing null handling.

In `@Modules/Invoices/Tests/Feature/RecurringInvoicesTest.php`:
- Around line 34-38: Remove the unused $user, $customer, and $product fixtures
from each arrange block in RecurringInvoicesTest, or explicitly pass them into
the relevant invoice factories or request payloads when their relationships are
required. Keep only fixtures consumed by the test scenarios, including the
repeated blocks identified by the review.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: abe0f6b2-a70f-4821-b7e7-bd30d1186416

📥 Commits

Reviewing files that changed from the base of the PR and between 16c779b and 82b3152.

📒 Files selected for processing (22)
  • .gitignore
  • CLAUDE.md
  • Modules/Core/Database/Factories/CompanyUserFactory.php
  • Modules/Core/Database/Factories/CustomFieldValueFactory.php
  • Modules/Core/Database/Factories/NoteFactory.php
  • Modules/Core/Database/Factories/SettingFactory.php
  • Modules/Core/Database/Migrations/2026_07_19_000001_add_company_id_to_settings_table.php
  • Modules/Core/Filament/Company/Pages/CompanySettings.php
  • Modules/Core/Models/Setting.php
  • Modules/Core/Providers/CompanyPanelProvider.php
  • Modules/Core/Tests/Feature/CompanySettingsTest.php
  • Modules/Core/Tests/Feature/Seeders/RoleHasPermissionsSeederTest.php
  • Modules/Core/Tests/Unit/DateFieldAutoPopulationTest.php
  • Modules/Core/resources/views/filament/company/pages/company-settings.blade.php
  • Modules/Expenses/Observers/ExpenseObserver.php
  • Modules/Invoices/Tests/Feature/InvoiceDuplicateNumberPreventionTest.php
  • Modules/Invoices/Tests/Feature/RecurringInvoicesTest.php
  • Modules/Payments/Observers/PaymentObserver.php
  • Modules/Quotes/Tests/Feature/QuoteDuplicateNumberPreventionTest.php
  • Modules/Quotes/Tests/Unit/QuoteModelTest.php
  • composer.json
  • resources/lang/en/ip.php
💤 Files with no reviewable changes (2)
  • Modules/Invoices/Tests/Feature/InvoiceDuplicateNumberPreventionTest.php
  • Modules/Quotes/Tests/Feature/QuoteDuplicateNumberPreventionTest.php

Comment thread composer.json
Comment on lines +20 to +26
"php": "^8.3",
"awcodes/mason": "^3.1",
"doctrine/dbal": ">=4.4",
"dompdf/dompdf": "^3.1",
"filament/actions": ">=5.6",
"filament/filament": ">=5.6",
"laravel/framework": ">=12.46",
"laravel/framework": "^13.0",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n '"php"|"laravel/framework"' composer.json
rg -n 'Laravel (11|13)|PHP (8\.1|8\.3)' CLAUDE.md
composer validate --strict
composer check-platform-reqs

Repository: InvoicePlane/InvoicePlane-v2

Length of output: 1397


Align the manifest and documentation with the Laravel 11/PHP 8.1+ compatibility policy.

composer.json declares PHP ^8.3 and Laravel ^13.0, while the repository’s **/*.{php,json} requirement mandates Laravel 11 with PHP 8.1+. Keep CLAUDE.md matching the supported versions, or formally update the governing compatibility policy, lockfile, CI, and documentation together.

📍 Affects 2 files
  • composer.json#L20-L26 (this comment)
  • CLAUDE.md#L5-L13
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@composer.json` around lines 20 - 26, Align composer.json dependencies with
the repository’s Laravel 11 and PHP 8.1+ compatibility policy, replacing the PHP
^8.3 and Laravel ^13.0 requirements with supported constraints while preserving
the other package requirements. Update CLAUDE.md to reflect the same supported
versions; do not formally change the governing policy unless also updating the
lockfile, CI, and documentation consistently. Affected sites: composer.json
lines 20-26 require dependency changes; CLAUDE.md lines 5-13 require matching
documentation updates.

Source: Coding guidelines

Comment on lines +14 to +18
* The unique index is a *partial* index (Postgres / MariaDB / SQLite all
* support `WHERE`): the `(company_id, setting_key)` pair is unique only
* when `company_id IS NOT NULL`. Two companies may each have their own
* `currency_code`; a global `currency_code` and a company-scoped one
* may coexist.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Docblock describes a partial UNIQUE index that the migration never creates.

Lines 46–56 create a plain, non-unique composite index (->index([...])) and the inline comment states uniqueness is enforced only at the application layer. This docblock claims a WHERE company_id IS NOT NULL partial unique index, which contradicts the actual schema and could mislead maintainers into assuming DB-level uniqueness. Please align the docblock with the soft-constraint reality.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Modules/Core/Database/Migrations/2026_07_19_000001_add_company_id_to_settings_table.php`
around lines 14 - 18, Update the migration docblock describing the `(company_id,
setting_key)` index to state that it is a regular non-unique composite index and
uniqueness is enforced by the application layer. Remove claims about partial
indexing, database-enforced uniqueness, and `WHERE company_id IS NOT NULL`,
keeping the actual schema behavior represented by the index definition.

Comment on lines +146 to +148
TextInput::make(Setting::KEY_COMPANY_NAME)
->label('Company Name')
->maxLength(255),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use trans() for this label.

->label('Company Name') hardcodes an English string; every other field here uses trans('ip....'). As per coding guidelines ("Use trans() function for internationalization"), route it through a translation key (note ip.company_name currently resolves to "Customer Name", so a distinct key may be needed).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Modules/Core/Filament/Company/Pages/CompanySettings.php` around lines 146 -
148, Update the TextInput for Setting::KEY_COMPANY_NAME to use trans() with a
dedicated translation key for the company-name label instead of the hardcoded
English string; ensure the key resolves to the intended “Company Name” text
rather than reusing ip.company_name if it maps to “Customer Name.”

Source: Coding guidelines

Comment on lines +231 to +271
Section::make(trans('ip.invoice_templates'))->columns(2)->schema([
Select::make(Setting::KEY_INVOICE_PDF_TEMPLATE)
->label(trans('ip.default_pdf_template'))
->options([])
->placeholder(trans('ip.none')),

Select::make(Setting::KEY_INVOICE_PAID_PDF_TEMPLATE)
->label(trans('ip.pdf_template_paid'))
->options([])
->placeholder(trans('ip.none')),

Select::make(Setting::KEY_INVOICE_OVERDUE_PDF_TEMPLATE)
->label(trans('ip.pdf_template_overdue'))
->options([])
->placeholder(trans('ip.none')),

Select::make(Setting::KEY_INVOICE_PUBLIC_TEMPLATE)
->label(trans('ip.default_public_template'))
->options([])
->placeholder(trans('ip.none')),

Select::make(Setting::KEY_INVOICE_EMAIL_TEMPLATE)
->label(trans('ip.default_email_template'))
->options([])
->placeholder(trans('ip.none')),

Select::make(Setting::KEY_INVOICE_PAID_EMAIL_TEMPLATE)
->label(trans('ip.email_template_paid'))
->options([])
->placeholder(trans('ip.none')),

Select::make(Setting::KEY_INVOICE_OVERDUE_EMAIL_TEMPLATE)
->label(trans('ip.email_template_overdue'))
->options([])
->placeholder(trans('ip.none')),

Textarea::make(Setting::KEY_INVOICE_PDF_FOOTER)
->label(trans('ip.pdf_invoice_footer'))
->rows(3)
->columnSpanFull(),
]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Repo files matching CompanySettings.php:\n'
fd -a 'CompanySettings\.php$' . || true

file="$(fd 'CompanySettings\.php$' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
  printf '\nFile: %s\n' "$file"
  wc -l "$file"
  sed -n '200,340p' "$file" | nl -ba -v200
fi

printf '\nSearch for template option sourcing / methods:\n'
rg -n "KEY_INVOICE_(PDF|EMAIL|PUBLIC|PAID|OVERDUE)_TEMPLATE|KEY_QUOTE|template|Template|templates|getOptions|options\(" -S . --glob '*.php' --glob '*.json' \
  | head -n 200 || true

Repository: InvoicePlane/InvoicePlane-v2

Length of output: 450


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='./Modules/Core/Filament/Company/Pages/CompanySettings.php'
printf 'CompanySettings.php lines 1-80:\n'
sed -n '1,80p' "$file"

printf '\nTemplate-related keys and options in CompanySettings.php:\n'
rg -n "KEY_INVOICE_(PDF|EMAIL|PUBLIC|PAID|OVERDUE)_TEMPLATE|KEY_QUOTE|template|templates|options\(|getOptions" "$file"
printf '\nLines 220-340:\n'
sed -n '220,340p' "$file"

printf '\nTemplate option/model definitions in repository:\n'
rg -n "KEY_INVOICE_(PDF|EMAIL|PUBLIC|PAID|OVERDUE)_TEMPLATE|KEY_QUOTE|Invoice.*Template|Quote.*Template|template|templates|getOptions|options\(" -S . --glob '*.php' --glob '*.json' \
  | sed -n '1,240p'

Repository: InvoicePlane/InvoicePlane-v2

Length of output: 43115


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Email template seeder:\n'
sed -n '1,90p' ./Modules/Core/Database/Seeders/EmailTemplatesSeeder.php

printf '\nEmailTemplate model outline and source relevant methods:\n'
sed -n '1,120p' ./Modules/Core/Models/EmailTemplate.php

printf '\nInvoice HTML helper relevant source:\n'
sed -n '1,80p' ./Modules/Core/Support/HTML.php

printf '\nQuote HTML helper relevant source:\n'
sed -n '30,70p' ./Modules/Core/Support/HTML.php

printf '\nInvoice template references in invoice views/routes:\n'
rg -n "invoice_template|pdf_invoice_template|invoice-pdf|templates\\.invoices|HTML::invoice|HTML::quote|quote_template|templates\\.quotes" -S . --glob '*.php' --glob '*.blade.php' | sed -n '1,220p'

Repository: InvoicePlane/InvoicePlane-v2

Length of output: 10832


Fill the template selector options.

The invoice and quote template selects all call ->options([]), so admins can’t choose a template and these settings will remain empty. Use the seeded/persisted templates here, or add a TODO if this is intentionally deferred.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Modules/Core/Filament/Company/Pages/CompanySettings.php` around lines 231 -
271, Populate the options for each invoice template Select in the invoice
templates Section using the existing seeded or persisted template source,
matching the approach used by quote template selectors elsewhere in
CompanySettings. Replace the empty options for the PDF, paid, overdue, public,
email, paid-email, and overdue-email fields while preserving their labels and
placeholders; if no template source is currently available, add a TODO instead
of leaving the selectors silently unusable.

Comment on lines +178 to +191
public static function saveForCompany(int $companyId, string $key, mixed $value): void
{
$setting = self::query()
->withoutGlobalScopes()
->where('company_id', $companyId)
->where('setting_key', $key)
->first()
?? new self();

$setting->company_id = $companyId;
$setting->setting_key = $key;
$setting->setting_value = is_scalar($value) || $value === null ? (string) $value : json_encode($value);
$setting->save();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Sensitive settings (SMTP/PDF passwords) are persisted in plaintext.

saveForCompany stores every value as a raw string, including KEY_SMTP_PASSWORD, KEY_INVOICE_PDF_PASSWORD, and KEY_QUOTE_PDF_PASSWORD. The linked issues call for encrypted SMTP credentials. Because mount() also prefills these into form state, the plaintext secrets are additionally shipped to the browser on every page load and revealed via ->revealable().

Consider encrypting known-sensitive keys on write (e.g. Crypt::encryptString(...) gated on a sensitive-key allowlist) and decrypting in getForCompany, and avoid prefilling password fields.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Modules/Core/Models/Setting.php` around lines 178 - 191, Update
Setting::saveForCompany to encrypt values for the existing sensitive-key
allowlist, including SMTP and invoice/quote PDF passwords, before persistence,
while retaining current serialization for other settings; update getForCompany
to decrypt those sensitive values, and remove password prefill/reveal behavior
from mount() so secrets are not sent to the browser.

Comment on lines +108 to +133
#[Test]
#[Group('per-company')]
public function get_for_company_falls_back_to_global_when_no_company_row(): void
{
/* Arrange */
Setting::saveByKey('legacy_key', 'global-value');

/* Assert */
$this->assertSame('global-value', Setting::getForCompany($this->company->id, 'legacy_key'));
}

#[Test]
#[Group('per-company')]
public function get_for_company_returns_default_when_nothing_set(): void
{
$this->assertSame('fallback', Setting::getForCompany($this->company->id, 'unrelated_key', 'fallback'));
}

#[Test]
#[Group('per-company')]
public function get_for_company_company_only_skips_global_fallback(): void
{
Setting::saveByKey('legacy_key', 'global-value');

$this->assertNull(Setting::getForCompany($this->company->id, 'legacy_key', null, true));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Test method naming/AAA don't match guidelines.

Methods like get_for_company_falls_back_to_global_when_no_company_row, get_for_company_returns_default_when_nothing_set, company_scoped_value_wins_over_global, and partial_unique_index_allows_same_key_across_companies don't follow the it_<verb>_<subject> pattern, and a few of these lack the /* Arrange */ /* Act */ /* Assert */ blocks. Also partial_unique_index_... is misleading since the migration creates a non-unique index. As per coding guidelines ("Use snake_case method names in tests following the pattern it_<verb>_<subject>" and structure tests with AAA blocks).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Modules/Core/Tests/Feature/CompanySettingsTest.php` around lines 108 - 133,
Rename the affected test methods, including
get_for_company_falls_back_to_global_when_no_company_row,
get_for_company_returns_default_when_nothing_set,
company_scoped_value_wins_over_global, and
partial_unique_index_allows_same_key_across_companies, to snake_case names
following the it_<verb>_<subject> convention; ensure the index test name
describes a non-unique index. Add explicit /* Arrange */, /* Act */, and /*
Assert */ blocks to each affected test while preserving their existing behavior.

Source: Coding guidelines

@nielsdrost7 nielsdrost7 changed the title feat: company-scoped Settings page (Invoices/Quotes/Taxes/Email/System/Dashboard) [IP-247]: company-scoped Settings page (Invoices/Quotes/Taxes/Email/System/Dashboard) Jul 24, 2026
@nielsdrost7
nielsdrost7 merged commit decacfa into InvoicePlane:develop Jul 24, 2026
1 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment