Commit Graph

1921 Commits

Author SHA1 Message Date
Darko Gjorgjijoski
3b2aa1ca2e fix(config): make the application timezone configurable (#703)
Fixes InvoiceShelf/docker#64 — recurring invoices and scheduled tasks
running on the wrong timezone.

Two bugs compounded so that no supported value had any effect:

config/app.php had no timezone key at all, so Laravel's own fallback won
— and that fallback is a literal 'UTC' string, not an env() lookup
(vendor/laravel/framework/config/app.php). APP_TIMEZONE has therefore
been inert since the config was slimmed, despite being shipped in
.env.example.

inject.sh wrote a bare TIMEZONE key into .env, which nothing reads, so
setting the documented container variable also did nothing. It now
writes APP_TIMEZONE, and accepts either name so existing compose files
keep working without edits.

Verified against a built image: TIMEZONE=Europe/Berlin now yields
APP_TIMEZONE=Europe/Berlin in .env and config('app.timezone') ==
Europe/Berlin, where before both spellings left it on UTC.
2026-07-29 12:23:20 +02:00
Darko Gjorgjijoski
ae035498c6 fix(docker): recreate storage directories on container start (#702)
Fixes InvoiceShelf/docker#75 and #69, and gives InvoiceShelf/docker#77
and #63 an actionable error instead of a cryptic one.

storage/framework/{cache,sessions,views}, storage/logs and storage/app
hold no tracked content — only .gitignore stubs — so nothing guarantees
they exist inside a mounted volume. Docker seeds a named volume from the
image exactly once, when the volume is empty, and never again: a volume
created by an older image keeps whatever it had through every subsequent
upgrade. When those directories are absent Laravel dies at boot with
"Please provide a valid cache path", because config/view.php resolves its
compiled path with realpath(), which returns false for a missing
directory. The sqlite branch also cannot place its database.

Reproduced against a locally built image: deleting storage/framework from
a named volume fails the container with exactly that message, and passes
with this change.

The chown is guarded on being root. The image runs as www-data (uid 82),
where chown of a foreign-owned file is EPERM and, under `set -e`, would
stop the container from starting at all — which is the likely reason it
was dropped from this tree previously. Guarding it keeps the benefit for
anyone running as root without that failure mode.

A mount the container genuinely cannot write to is not something the
entrypoint can fix, so it now says so and names the remedy, rather than
letting the failure surface later as a Laravel stack trace.
2026-07-29 12:20:28 +02:00
Darko Gjorgjijoski
552da3ca84 feat(devenv): make the Gotenberg compose stacks work out of the box (#699)
Choosing Gotenberg in ./devenv started the `pdf` sidecar and configured
nothing else, so the app still defaulted to dompdf. Pointing it at the
sidecar by hand then hit the SSRF guard, because `pdf` resolves to a
private address on the compose network — the failure #691 fixed, hit
from inside our own dev environment.

The three gotenberg compose files now set PDF_DRIVER, GOTENBERG_HOST and
GOTENBERG_ALLOWED_PRIVATE_HOST on php-fpm, so the stack renders through
the sidecar with no .env editing at all. The serversideup pool config
already sets `clear_env = no`, so these reach the workers; verified by
generating a real invoice PDF end to end (24967 bytes, %PDF-1.4).

Setting the environment in compose rather than writing to .env keeps the
devenv script from mutating a developer's own file — it does not touch
.env today, and the values belong to the compose file the developer
selected. Non-Docker setups have the same keys documented in .env.example.

devenv now prints what it configured, including that the compose file
exempts that one host from the SSRF guard, since a security control being
relaxed should not be silent.
2026-07-29 11:08:24 +02:00
csoscd
f8cfb6cd33 fix: allow Gotenberg to reach private/Docker-internal hosts (#691)
* fix: allow Gotenberg to reach private/Docker-internal hosts (Issue #688)

The SSRF guard introduced in #664/#671 correctly blocks arbitrary
private URLs, but also prevents legitimate use-cases where Gotenberg
runs alongside InvoiceShelf in a Docker Compose network (e.g. the
default http://pdf:3000 service name resolves to a private IP).

Add a `gotenberg_allow_private_host` setting (env:
GOTENBERG_ALLOW_PRIVATE_HOST, default false) that:
- skips PrivateNetworkGuard in GotenbergPdfDriver
- skips PublicHttpUrl validation in PDFConfigurationRequest
- exposes a clearly-warned toggle in the admin PDF settings UI
- is persisted to the settings table and loaded via AppConfigProvider

A disabled guard is safe for controlled private networks (Docker
Compose, LAN); it must never be enabled for untrusted hosts. The UI
surfaces a prominent warning to communicate this constraint.

Closes #688

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(gotenberg): scope the private-host exemption to a declared host

Reshapes the escape hatch from a boolean admin setting into an
environment-declared host allowlist.

The driver streams the upstream response body back as the PDF, so a
mis-set Gotenberg host is full-response SSRF — pointed at a link-local
metadata endpoint it returns cloud credentials. A blanket "allow
private" switch left that reachable: gotenberg_host stays editable from
the admin UI, so any install that enabled the switch to run a sidecar
could have the host repointed at an internal service. The population the
flag existed to serve was exactly the population it failed to protect.

GOTENBERG_ALLOWED_PRIVATE_HOST now names the single host that may skip
the guard. Only that exact value is exempt; every other private target
stays blocked. GotenbergHostPolicy owns the comparison so the save-time
rule and the runtime driver guard cannot drift, and normalises case,
trailing slash and surrounding whitespace on both sides.

Being env-only also drops the settings-table key, the AppConfigProvider
branch and the whole admin UI surface — the toggle there could not be
switched on in any case, since BaseSwitchSection has no slot and was
passed no v-model, so the child BaseSwitch was discarded and the value
never changed from false.

Restores the gotenberg_margins validation rule, which the previous
revision replaced rather than added alongside.

Tests cover both directions, including that declaring one private host
does not exempt another; sabotaging the policy to always exempt fails 16
of the 22.

Co-authored-by: csoscd <csoscd@users.noreply.github.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Darko Gjorgjijoski <dg@darkog.com>
Co-authored-by: csoscd <csoscd@users.noreply.github.com>
2026-07-29 10:57:52 +02:00
Darko Gjorgjijoski
01da03624d fix(seeder): assign unique_hash to seeded documents (#698)
RealisticDemoSeeder builds invoices, payments and estimates with
Model::create(), which bypasses both paths that normally set unique_hash
— the factories set it directly, and InvoiceService and friends encode it
from the id after insert. Nothing assigned it here, so every seeded
document had it NULL.

The PDF routes bind on that column, so the frontend built
`/invoices/pdf/` with an empty segment. That 404s, and the only symptom
is "Unable to load document preview" in the UI with nothing written to
the log, which makes it a genuinely slow thing to track down. Anyone who
seeds realistic demo data and opens a document hits it.

Production is unaffected: documents created through the app go through
the service layer, which assigns the hash. Existing seeded databases
need a backfill, encoding each id the same way the services do.

Uses Hashids, as the services do, rather than the factories' str_random,
so demo data matches what the app itself would have produced.
2026-07-29 10:41:45 +02:00
Darko Gjorgjijoski
9a5731106e fix(ui): stop depending on secure-context APIs over plain HTTP (#697)
crypto.randomUUID() and navigator.clipboard are both [SecureContext]-
gated, so neither exists on a plain-HTTP origin that isn't localhost.
That covers the dev host (http://invoiceshelf.test) and any self-hosted
install reached over a hostname or LAN IP — a large share of them.

generateClientId() called crypto.randomUUID() unguarded. It runs during
Pinia store construction via the invoice, estimate and recurring-invoice
stub factories, so on those origins it threw a TypeError before the
store existed and took the document screens down with it. The value is
only a placeholder identity for a row that has no server id yet — the
server assigns the real one on save, which is why DocumentItem and
DocumentTax type it `number | string`. It never needed randomness, so
it is now a session counter: no crypto, no fallback branch, works
everywhere.

PaymentDropdown.copyPdfUrl() had a textarea fallback attached with
.catch(), which cannot fire — on a non-secure origin navigator.clipboard
is undefined, so `.writeText` throws on property access before any
promise exists. Test up front instead, matching the guard the invoice
and estimate dropdowns already use.
2026-07-29 10:31:36 +02:00
Darko Gjorgjijoski
403a4d6722 test(pdf): cover every stock PDF template, and silence the PHP 8.5 PDO deprecation (#696)
* test(pdf): render every stock template through the real pdf routes

Blade templates reference PHP classes as plain strings, so a namespace
move leaves them dangling without Pint, the IDE, or CI noticing — which
is how #695 shipped a fatal ImageUtils reference in all seven stock
templates and left it there for three months.

Renders each invoice, estimate and payment template end-to-end through
the pdf routes with a company logo attached, since every template guards
the logo behind `@if ($logo)` and the fallback branch never reaches
ImageUtils. One extra assertion checks the rendered markup actually
carries the base64 data URI, so a template that silently drops the logo
fails too rather than emitting a valid but logo-less PDF.

Template names are globbed off disk rather than hardcoded, so a new
stock template is covered as soon as it lands.

* fix(config): resolve the mysql SSL CA attribute per PHP version

PHP 8.5 deprecated PDO::MYSQL_ATTR_SSL_CA in favour of
Pdo\Mysql::ATTR_SSL_CA, so every test in the suite was reported as
deprecated rather than passed — noise that would hide a real one.

Pdo\Mysql does not exist before 8.5 and this package supports ^8.4, so
the constant is resolved at runtime; the untaken ternary branch is never
looked up, which keeps 8.4 working. The lookup stays behind the
extension_loaded() check because neither name is defined when pdo_mysql
is missing.

Verified against both runtimes: with MYSQL_ATTR_SSL_CA set, 8.4 resolves
to attribute 1009 and 8.5 to 1008 — each version's own value, matching
what the previous code produced there.
2026-07-28 19:58:34 +02:00
Lukas Selch
bf1c4d85cf fix: update ImageUtils namespace in PDF blade templates (#695)
The ImageUtils class was moved from App\Services\Pdf to App\Support\Pdf
but the blade templates were not updated to reflect this change.

This caused 'Class App\Services\Pdf\ImageUtils not found' errors when
generating PDFs for invoices, estimates, and payments.

Updated namespace in 7 blade template files:
- invoice1.blade.php
- invoice2.blade.php
- invoice3.blade.php
- estimate1.blade.php
- estimate2.blade.php
- estimate3.blade.php
- payment.blade.php
2026-07-28 19:39:06 +02:00
Darko Gjorgjijoski
d0391b9cfc ci: auto-register published release on the website updater (#693)
Append a step to the release_artifact_build job that POSTs the freshly built
InvoiceShelf.zip + metadata to the website updater's /api/releases endpoint
(Bearer WEBSITE_RELEASE_TOKEN) right after the asset upload, so deployed installs
are offered the release automatically instead of a manual kubectl+tinker import.

- Runs only on release events; skips with a warning if WEBSITE_RELEASE_TOKEN is unset
- Channel derived from the prerelease flag / "-" tag suffix (GA->stable, pre->insider)
- min_php + extensions read from config/installer.php; release fields passed via env
  to avoid shell injection from the release body
- Idempotent (the endpoint upserts per version)


Claude-Session: https://claude.ai/code/session_012tpgisKcrC4D4mCbGTeTKz

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 14:33:38 +02:00
Darko Gjorgjijoski
c6cd8f2b66 Update security policy with bot reporting rule
Added a hard rule for bot or LLM vulnerability reports.
2026-06-30 21:19:46 +02:00
Darko Gjorgjijoski
209a766c8c ci(docker): authenticate composer against GitHub in the image build
Multi-arch builds run composer (incl. the merge-plugin's update) twice and exhausted GitHub's unauthenticated API rate limit, failing with 'Could not authenticate against github.com'. Pass the Actions token as a build secret and feed it to composer via COMPOSER_AUTH (build-time only, never in the image).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3.0.0-alpha.1
2026-06-15 00:32:02 +02:00
gdarko
87f13cde77 docs: regenerate OpenAPI spec [skip ci] 2026-06-14 21:47:40 +00:00
Darko Gjorgjijoski
2af8bfee22 docs(security): drop third-party cc from the security policy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:12 +02:00
Darko Gjorgjijoski
3ca4027871 fix(documents): repair inline add-item modal on invoice/estimate create
Render a single shared ItemModal (was one per row, so stacked dialogs closed each other), validate the modal's own local form (vuelidate did not track the shared store object in the persistent modal), surface save errors, and carry the typed item name into the new-item form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:12 +02:00
Darko Gjorgjijoski
9e496102d4 feat(updater): disable the in-app updater in containerized installs
The Docker image already injects CONTAINERIZED=true; consume it via config('invoiceshelf.containerized'), expose it on /app/version, block the update endpoints + console command, and show a 'docker compose pull' panel instead of the updater. Adds missing i18n keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:11 +02:00
Darko Gjorgjijoski
c04b84e5b5 ci(docker): release-driven tags, drop nightly cron, add 3.x pre-release channel
Remove the scheduled nightly/alpha builds; gate :latest on a single LATEST_MAJOR; publish :beta/:next for pre-releases; keep a transitional :nightly alias on stable. Also fix the production Dockerfile so a stale host public/build can no longer clobber the freshly built frontend (reorder COPY, ignore public/build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:11 +02:00
Darko Gjorgjijoski
d3202b8b2a fix(members): scope member view & update to the acting company
Member view/update bound the target user by global id and authorized only that the requester owns their active company, not that the target belonged to it. Bind the route model under the members param and require shared company membership in UserPolicy so an owner of one company can no longer read or modify users of another.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:11 +02:00
Darko Gjorgjijoski
8d929ec09d feat(api): generate OpenAPI spec with Scramble for api-docs.invoiceshelf.com (#685)
Auto-generate an OpenAPI 3.1 spec from the v1 API's FormRequests and Resources
(no annotations) for publishing at api-docs.invoiceshelf.com as a static
Swagger UI site.

- config/scramble.php: scope to api/v1, version from version.md, clean
  placeholder server, export to public/openapi.json
- ScrambleServiceProvider: advertise Bearer (Sanctum) auth; add the required
  `company` tenancy header only to routes using the `company` middleware
- OpenApiDocumentationTest: assert spec shape, auth scheme, company-header gating
- .github/workflows/openapi.yml: export + commit spec on release, notify the
  api-docs site to rebuild
- public/openapi.json: generated seed spec (184 paths)
- dedoc/scramble added as a dev-only dependency

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:51:54 +02:00
Darko Gjorgjijoski
421c385fa7 chore: drop Laravel Boost; make AGENTS.md the canonical agent doc (#684)
- Remove laravel/boost (+ its deps laravel/mcp, laravel/roster) and Boost's
  .cursor/ skills + MCP registration. No version sweep (composer remove).
- AGENTS.md is now the hand-maintained source of truth (was Boost-generated):
  folds in the CLAUDE.md content + a Conventions section codifying the migration
  FK rule (unsignedInteger for INT-PK refs, not foreignId — MySQL error 3780)
  and the MySQL/PostgreSQL/SQLite cross-DB requirement.
- CLAUDE.md / GEMINI.md / .github/copilot-instructions.md are now gitignored
  symlinks to AGENTS.md, created by bin/ai-docs.php (composer run ai-docs, also
  wired into post-autoload-dump).
2026-06-12 21:54:09 +02:00
Darko Gjorgjijoski
a2aa74aa8a fix(migrations): align ai_conversations company_id/user_id with INT pk (#683)
Follow-up to #618. The ai_conversations table (added after #618) used
foreignId() (BIGINT) for company_id/user_id, which mismatches the INT
UNSIGNED users.id / companies.id and breaks MySQL FK creation (error 3780)
on the v2->v3 upgrade. Use plain unsignedInteger, matching the codebase's
no-DB-FK convention for these columns. conversation_id stays foreignId — it
references the BIGINT ai_conversations.id and its delete cascade is intentional
(and covered by AiChatFlowTest).
2026-06-12 15:19:21 +02:00
Darko Gjorgjijoski
217deb0bf9 feat: localize country names in PDFs via symfony/intl (#681)
Ports #639 to 3.x — the original targets the now feature-frozen 2.x line.
Address::country_name now resolves the localized country name for the
current app locale via Symfony\Component\Intl\Countries, falling back to
the stored name on lookup failure. Adds symfony/intl + a unit test.

Co-authored-by: Lukas Selch <selchlukas@icloud.com>
2026-06-12 14:43:41 +02:00
mchev
acbb1f040c fix(migrations): align FK column types with users/companies id on MySQL (#618)
Laravel foreignId() creates BIGINT UNSIGNED columns, but users.id and
companies.id use increments() (INT UNSIGNED). MySQL 8 rejects foreign keys
when referencing and referenced column types differ (error 3780).

Use unsignedInteger for impersonation_logs admin_id/user_id and for
company_invitations company_id, user_id, and invited_by. Keep foreignId
for role_id since roles.id is bigIncrements.

This fixes upgrades from v2 on MySQL when running v3.0 migrations.

Made-with: Cursor
2026-06-12 14:43:27 +02:00
Darko Gjorgjijoski
c13aba948d fix(deps): bump marked, dompurify, postcss to patched versions (#680)
npm advisories Dependabot surfaced on 3.x once it scanned the pnpm lock:
- marked 18.0.0 -> 18.0.5 (high)
- dompurify 3.3.3 -> 3.4.9 (4 advisories)
- postcss 8.5.8 -> 8.5.15 (transitive — added a pnpm override, like brace-expansion)

Lockfile + version-floor bumps; no app code changes.
2026-06-12 14:22:29 +02:00
Darko Gjorgjijoski
1aba43aca1 fix(deps): bump vulnerable composer dependencies to patched versions (#679)
3.x is now the default branch, so Dependabot re-surfaced the symfony/guzzle
advisories (composer.lock was never bumped on v3). Same fix as v2's #674:
- laravel/framework -> 13.15.0 (CVE-2026-48019, CRLF in the email rule)
- symfony/{mime,http-kernel,mailer,routing,yaml,polyfill-intl-idn} -> patched
- guzzlehttp/psr7 -> 2.11.0 (host-confusion + CRLF advisories)
composer audit clean.
2026-06-12 14:04:30 +02:00
Darko Gjorgjijoski
e48212b18a build: migrate frontend tooling to pnpm (v3) (#678)
* build: migrate frontend tooling to pnpm (v3)

Rebuilds the stale #673 on current 3.x so it doesn't revert #657's test
split, the Node-24 action bumps, or composer-install@4.0.0.

- package.json: packageManager pnpm@11.6.0; drop dead 'resolutions'
- pnpm-workspace.yaml: nodeLinker hoisted, allowBuilds vue-demi,
  overrides brace-expansion (replaces resolutions)
- pnpm-lock.yaml generated via 'pnpm import' from yarn.lock (keeps the
  resolved versions, incl. vite 8.0.3 / rolldown rc.12); yarn.lock removed
- docker.yaml + release.yaml: pnpm/action-setup@v6 + cache pnpm + pnpm
  install/build (action versions and the #657 split left intact; check.yaml
  needs no change — its test job is PHP-only after #657)
- 3 Dockerfiles: node:24 + corepack + pnpm install --frozen-lockfile && pnpm build
- Makefile, composer 'dev' script, CLAUDE.md, .gitignore -> pnpm

* fix(deps): pin vite to 8.0.5 (security)

Now that 3.x is the default branch, Dependabot flags vite <8.0.5. Pin to
8.0.5 (the patched version), which keeps rolldown 1.0.0-rc.12 — still
below 8.0.15 where the broken rolldown 1.0.3 (the init_runtime_dom_esm_bundler
chunk regression) starts, so the build stays clean. Mirrors v2's #674.
2026-06-12 14:04:16 +02:00
Darko Gjorgjijoski
4ab62b98c6 ci: speed up PHP test jobs (disable Xdebug, drop frontend build, run parallel) (#657)
* ci: speed up the test job (disable Xdebug, drop frontend build, run parallel)

The `tests` job in check.yaml carried three sources of wasted wall-clock,
none of which it actually used:

- `coverage: xdebug` loaded Xdebug into every PHP process, but no step ever
  passes `--coverage` — so it was pure tax (~2-3x slower execution). Switch
  to `coverage: none`. If coverage is wanted later, use pcov + `--coverage`.
- The job ran `npm install` + `npm run build` before the PHP tests. The
  feature suite is API/JSON only (49/56 feature files use getJson/assertJson)
  and nothing renders the Vite blade, so the built assets are never needed.
  Drop the Node/Vite steps; release & docker workflows still build assets.
- Tests ran single-process. brianium/paratest is already installed and the
  runner has 4 cores, so run `php artisan test --parallel`.

Validated locally: full suite passes in parallel (exit 0), including
repeated runs of the two filesystem-writing module tests — no races.

docker.yaml carries the same pattern but only runs on release/nightly cron,
so it is left for a follow-up.

* ci: apply the same test-job speedups to docker.yaml

The release/nightly `tests` job in docker.yaml carried the identical waste
that check.yaml had: Xdebug loaded but never used for coverage, an
unnecessary frontend build before the PHP tests, and serial execution.

Mirror the check.yaml fix: coverage: none, drop the Node/Vite steps
(the suite is API/JSON and the separate release_artifact_build job builds
its own assets), and run php artisan test --parallel.

* ci: run module-scaffolding tests serially under --parallel

The Modules/* tests (module:make ScaffoldProbe + modules_statuses.json
toggles) mutate shared on-disk module state. paratest isolates the DB
per worker but NOT the filesystem, so concurrent workers boot with
ScaffoldProbe enabled and fatal on the un-autoloaded ServiceProvider
(31 failures). Tag them 'modules' (Pest group on Feature/Company/Modules)
and split CI: parallel --exclude-group=modules, then serial --group=modules.

* ci: stub Vite in tests + bump all actions to Node 24 versions

Part A (fixes #657): the customer-portal entrypoint test renders the SPA
shell (app.blade.php → @vite). With the frontend build dropped from CI
there's no manifest, so it 500'd (ViteManifestNotFoundException). Call
$this->withoutVite() in TestCase::setUp() so SPA-shell renders work
without a built manifest; the build stays dropped.

Part B: bump every Node-20 action to its node24 release — checkout v4->v6,
setup-node v4->v6, paths-filter v3->v4, cancel-workflow-action 0.12.1->0.13.1,
softprops/action-gh-release v2->v3, docker/{setup-buildx v3->v4, login v3->v4,
metadata v5->v6, build-push v5->v7}. setup-php@v2, ramsey/composer-install@v2
(composite) and svenstaro/upload-release-action@v2 are already node24.

* ci: bump ramsey/composer-install v2 -> 4.0.0 (node24 internal cache)

composer-install@v2 is composite but internally calls actions/cache@v3
(Node 20), which still trips the deprecation. 4.0.0 uses actions/cache
v5.0.3 (Node 24) and keeps the composer-options input we use.
2026-06-12 12:35:10 +02:00
Darko Gjorgjijoski
e41ee8083d fix(documents): preserve edited line-item description (#658)
The per-line item description typed into the row's textarea was never
saved. BaseItemSelect's description field pushes edits out only via an
`update:description` emit, but DocumentItemRow (the single shared row used
by invoices, estimates, and recurring invoices) wired only `@search` and
`@select` — so the emit was dropped and `form.items[index].description`
never updated. On submit the field was lost; a catalog item's description
also reverted on the next re-render.

Capture the event and route it through the existing `updateItemAttribute`
store updater, mirroring how name/quantity/price/discount already sync.
One fix covers all three document types since they share the row.

Also add the missing `items.*.description` nullable rule to
RecurringInvoiceRequest for parity with the invoice/estimate requests.

The backend already persisted and returned description correctly; this was
purely a dropped frontend event.
2026-06-12 11:27:56 +02:00
Darko Gjorgjijoski
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.
2026-06-12 11:27:39 +02:00
Darko Gjorgjijoski
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.
2026-06-12 11:02:23 +02:00
Darko Gjorgjijoski
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.
2026-06-12 11:02:19 +02:00
Darko Gjorgjijoski
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.
2026-06-12 11:02:15 +02:00
Darko Gjorgjijoski
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.
2026-06-12 11:02:11 +02:00
Darko Gjorgjijoski
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.
2026-06-12 11:02:08 +02:00
Darko Gjorgjijoski
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.
2026-06-11 11:11:12 +02:00
Darko Gjorgjijoski
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.
2026-06-11 11:11:12 +02:00
Darko Gjorgjijoski
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.
2026-06-11 09:10:57 +02:00
Darko Gjorgjijoski
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 (6d1816bd). `pint --test` checks the whole tree and runs on any
PR that touches PHP, so these failed CI here. Auto-fixed with Pint
(braces_position, no_unused_imports, single_line_empty_body) so the check
goes green.

* build(deps): pull invoiceshelf/modules ^3.0.3 via VCS, restore registerAiDriver()

The 3.0.3 release adds Registry::registerAiDriver() (the method DriverRegistryProvider
and AiDriverFactoryTest call). Packagist has the package frozen, so resolve it directly
from the canonical GitHub repo via a composer VCS repository and require ^3.0.3 (the tag
exists; the freeze is Packagist-side only).

Now that the method ships, restore DriverRegistryProvider to Registry::registerAiDriver()
— reverting the temporary generic registerDriver('ai', ...) workaround — so it matches the
package's intended API and the existing tests. The provider is now net-identical to 3.x.

Verified: php artisan package:discover boots clean; the AI suite (incl. the previously
failing AiDriverFactoryTest) passes.

* test: provision modules_statuses.json in the test bootstrap

The Modules/HelloWorld integration test needs the module enabled at the nwidart
level, read from storage/app/modules_statuses.json at app boot. That file is
gitignored (created locally by `module:make`), so it's absent on CI and fresh
clones — HelloWorld stays disabled and the 5 integration tests fail with 404s.

Provision it (only if missing) in tests/Pest.php before any test boots the app,
so CI matches a local dev environment. Full suite: 462 pass.

* fix(test): enable HelloWorld via a committed modules_statuses.json

The Modules/HelloWorld integration test needs the module enabled at the nwidart
level — read from storage/app/modules_statuses.json by FileActivator. That file
is gitignored, so it's absent in CI / fresh clones, leaving HelloWorld disabled
and the 5 integration tests failing with 404s.

The previous tests/Pest.php provisioning (60a7f0d6) only worked under
`./vendor/bin/pest`. CI runs `php artisan test`, which boots the console app
first; FileActivator reads and caches the (absent) statuses at construction
BEFORE Pest.php runs, so test-runtime provisioning is too late. The file must
exist before any boot.

Commit the file via a storage/app/.gitignore negation, and revert the
ineffective Pest.php hack. Prod-safe: Modules/ is gitignored and not copied by
release.yaml, so a phantom "HelloWorld: true" status is ignored by nwidart (no
such module on disk). bootstrap/cache/modules.php is gitignored (absent in CI),
so nothing overrides the committed file.

Verified with `php artisan test --filter=HelloWorld` (the CI command) and the
full suite: 462 pass; pint clean.

* fix(test): commit the Modules/HelloWorld sample module

HelloWorldIntegrationTest exercises Modules/HelloWorld end-to-end, but Modules/
was gitignored (/Modules), so the module was absent from the repo and from CI —
the 5 integration tests 404'd, and the committed modules_statuses.json merely
enabled a module that wasn't there.

Track Modules/HelloWorld (the test fixture) via a `/Modules/*` + `!HelloWorld`
negation. Not shipped to prod (release.yaml omits Modules/). Now the module is
present, autoloaded (merge-plugin), and enabled (statuses file), so its provider
boots and the menu/settings/routes register.

* build: drop the boost:update post-update-cmd hook (breaks CI)

laravel/boost gates its commands to the local environment, so `php artisan
boost:update` fails in CI ("There are no commands defined in the boost
namespace"), making composer's post-update-cmd return exit 1.

It only began failing the build once Modules/HelloWorld/composer.json was
committed: the wikimedia merge-plugin then runs composer's update path on a
plain `composer install`, triggering post-update-cmd. Drop the auto-update
hook (run `boost:update` manually when needed); vendor:publish stays.
2026-06-05 14:57:06 +02:00
Darko Gjorgjijoski
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
2026-06-05 00:01:09 +02:00
Darko Gjorgjijoski
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.
2026-04-11 21:54:39 +02:00
Darko Gjorgjijoski
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.
2026-04-11 21:11:14 +02:00
Darko Gjorgjijoski
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 &amp; 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.
2026-04-11 21:08:43 +02:00
Darko Gjorgjijoski
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.
2026-04-11 21:00:44 +02:00
Darko Gjorgjijoski
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.
2026-04-11 20:58:41 +02:00
Darko Gjorgjijoski
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.
2026-04-11 20:54:32 +02:00
Darko Gjorgjijoski
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.
2026-04-11 20:31:54 +02:00
Darko Gjorgjijoski
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`.
2026-04-11 20:22:09 +02:00
Darko Gjorgjijoski
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).
2026-04-12 11:00:00 +02:00
Darko Gjorgjijoski
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.
2026-04-12 08:00:00 +02:00
Darko Gjorgjijoski
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.
2026-04-11 22:00:00 +02:00
Darko Gjorgjijoski
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 947d00a9 (Documents -> Document).

**Integrations/ExchangeRate/ -> ExchangeRate/.** The Integrations/ umbrella was both plural AND introduced an extra layer of nesting no other Support subdir had (Pdf/, Hashids/, Module/, Update/ are all one level deep). Dropping the umbrella fixes both problems in one move and matches the existing shape. When AI providers eventually land, they follow the same pattern as Support/Pdf/: a sibling subdir at Support/Ai/, not buried under an umbrella.

**Formatters/ -> Formatting/.** Formatter (singular noun) would have been awkward; Formatting (gerund describing the capability) reads naturally as a namespace segment. The subdir holds DateFormatter, TimeFormatter, TimeZones — classes whose common thread is 'things that do formatting', which the gerund captures better than either the singular or plural noun form.

10 files renamed, 4 consumers updated (DriverRegistryProvider, ExchangeRateProviderService, FormatsController, ExchangeRateDriverFactoryTest). 350 tests pass, Pint clean.
2026-04-11 18:00:00 +02:00