45 Commits
Author SHA1 Message Date
awkawb 8f831f21c5 package.nix: updated invoiceninja to v5.13.53 2026-11-03 13:58:30 -05:00
awkawbandClaude Sonnet 5 badcdd2566 justfile: add update target to automate version bumps; push ui to cachix
push-cache was only printing the main invoiceninja package's out path, so
the ui derivation (exposed via passthru.ui) never actually reached the
cachix cache even though it gets built as a dependency.

The new update target (scripts/update-versions.sh) checks GitHub for the
latest invoiceninja release and the latest invoiceninja/ui tag, bumps
whichever changed in package.nix, and iteratively rebuilds -- parsing
each hash-mismatch error Nix reports for a fixed-output derivation -- to
fill in the correct src/npmDeps/vendor hashes automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-22 17:35:06 -04:00
awkawbandClaude Sonnet 5 37abda5f39 package: update invoiceninja to v5.13.50, ui to 18.10.2026.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-22 17:35:00 -04:00
awkawbandClaude Sonnet 5 4e544f6e46 flake: add cachix binary cache
Adds nixConfig substituter/key for the invoiceninja-nixos cachix cache,
a `just push-cache` target to build and push the package and automated
test to it, and exposes the UI frontend build as a passthru output
(`invoiceninja.ui`) so it can be built and pushed independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-22 15:59:29 -04:00
awkawbandClaude Sonnet 5 0f1f3f6675 package: update invoiceninja to v5.13.48, ui to 06.10.2026.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-15 19:20:39 -04:00
awkawbandClaude Sonnet 5 2c9915699d nixos-module: run db migrations on every activation, not just once
The .initial-migration marker file prevented `migrate --force` from ever
running again after the first activation, so migrations shipped by a later
invoiceninja version upgrade would never apply to an already-deployed
instance. `migrate` is idempotent (Laravel tracks applied migrations in the
DB), so just run it unconditionally on every activation instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 13:15:00 -04:00
awkawbandClaude Sonnet 5 bd112541e5 nixos-module: use playwright's chromium-headless-shell instead of pkgs.chromium for PDF generation
snappdf only ever shells out to `$SNAPPDF_CHROMIUM_PATH --headless --print-to-pdf=...`,
so the full browser UI/GPU stack pkgs.chromium ships is unnecessary. Google's official
"Chrome for Testing" headless-only build (packaged by nixpkgs' playwright-driver,
selected via `browsers.override` with every other browser/ffmpeg disabled) does the
same job for a ~1.45GB smaller closure (431MB vs 1.88GB), needs no allowUnfree, and
PDF generation is noticeably faster in the VM test (~2.5s vs ~10s per request).

Drops the now-unnecessary allowUnfree wiring from the interactive VM and automated
test configs, which existed solely to build pkgs.chromium.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 18:19:38 -04:00
awkawbandClaude Sonnet 5 b883e04303 package: strip composer-repository store path from runtime closure
composer.lock and vendor/composer/installed.json record each package's
"dist.url" as the store path of the build-time composer-repository
mirror, which drags that whole ~570MB fixed-output derivation into the
runtime closure even though it's just install provenance -- neither
Laravel's package manifest (name/extra only) nor Composer's runtime
InstalledVersions (reads installed.php, not this file) consult it.
Cuts the package closure from ~1.75GB to ~1.18GB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 17:39:14 -04:00
awkawbandClaude Sonnet 5 8216a723f0 tests: add client/invoice/payment end-to-end check
Extracts small api_get/api_post helpers to cut the repeated curl/header/
json.loads boilerplate, then uses them for a new subtest that creates a
client, invoices it, marks the invoice sent, and pays it in full - checking
the invoice comes back paid with a zero balance. Runs after the PDF preview
check, since PreviewController::blankEntity only builds its mock PDF when
the company has no invoices/invitations yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 17:15:26 -04:00
awkawbandClaude Sonnet 5 ef08e0d7fc package: update invoiceninja to v5.13.43, ui to 28.09.2026.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 20:28:11 -04:00
awkawbandClaude Sonnet 5 330ec8c503 tests: fix APP_URL port mismatch in the interactive VM
`just boot-vm` forwards the guest's port 80 to host port 8080
(QEMU_NET_OPTS in the justfile), but the backend builds absolute URLs
(e.g. the stored company logo path returned by the upload API) from
APP_URL, which defaults to plain "http://localhost" with no port. The
SPA's own relative URLs are fine - they inherit whatever port the
browser is already on - but any backend-generated absolute URL then
points at host port 80, where nothing is listening, instead of the
forwarded 8080. That gave a silent net::ERR_CONNECTION_REFUSED for the
logo image specifically, even though the upload itself succeeded.

Verified directly: with APP_URL overridden to "http://localhost:8080",
a freshly uploaded logo's URL is
"http://localhost:8080/storage/.../....png" instead of missing the
port. Automated test checks are unaffected (checks derivation hash
unchanged) since they curl the guest's own localhost directly, with no
host-side port remap involved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 16:47:23 -04:00
awkawbandClaude Sonnet 5 b1fe86e9d5 tests: give the interactive VM enough RAM to not OOM under real use
Root-caused a "logo upload hangs, connection refused in browser
console" report: the qemu-vm module's defaults (1 core, 1024MB) are
too tight for this stack (MySQL + Redis + PHP-FPM + chromium for PDF
generation, each PDF request spawning a full multi-process browser
instance) under real concurrent load, and this VM boots with
panic_on_oom - so exhausting memory doesn't degrade gracefully, the
whole VM goes down and every connection starts refusing.

Reproduced directly: at the 1024MB default, even 2 concurrent PDF
generation requests triggered `redis-server invoked oom-killer` and a
kernel panic. Bumped to 2 cores / 2560MB, which comfortably survives
that same load (~1.4GB still free afterward) - verified via the exact
concurrent-request scenario that crashed the 1024MB config, not just
absence of errors.

virtualisation.cores/memorySize aren't options on a plain nixosSystem
in this nixpkgs (only nixosTest nodes get the qemu-vm module
automatically), so this also adds an explicit import of it.

Documented the underlying resource cost (chromium is a full browser
per PDF request, pm.max_children=8 means up to 8 can run concurrently)
in CLAUDE.md as an operational note for real deployments, not just
this test VM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 16:36:22 -04:00
awkawbandClaude Sonnet 5 ccf4d01a34 tests: add end-to-end PDF generation check
Exercises the chromium/snappdf pipeline via POST /api/v1/preview with
no entity, which falls back to PdfMock's self-contained mock PDF -
verified this needs no client/invoice test data, since a fresh account
has neither. Asserts the response starts with the %PDF magic bytes.

This was previously the biggest untested path in the module: an actual
binary runtime dependency (chromium) rather than just config, and the
kind of thing that silently breaks in a minimal NixOS closure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:50:24 -04:00
awkawbandClaude Sonnet 5 fd055f0cf9 nixos-module: remove nginx recommendedProxySettings, keep recommendedOptimisation
recommendedProxySettings only adds proxy_* directives (proxy_redirect,
proxy_connect_timeout, proxy_set_header Connection, etc.), which only
take effect on locations using proxy_pass. This vhost only ever uses
fastcgi_pass for PHP, so the setting was inert here.

recommendedOptimisation (sendfile/tcp_nopush/tcp_nodelay/keepalive)
does apply regardless of proxy_pass vs fastcgi_pass, so it's kept.
Verified via the full automated test suite (both proxy backends).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:33:43 -04:00
awkawbandClaude Sonnet 5 7d7d717745 nixos-module: work around upstream language-list caching bug
Migration 2025_08_31_224356_additional_languages_af_ca_id.php runs
during `migrate`, before db:seed's LanguageSeeder has populated the
other 42 rows of the languages table, and does
Cache::forever('languages', Language::all()) - permanently caching
just the 3 languages (Afrikaans, Catalan, Indonesian) it sees at that
moment. StaticServiceProvider checks this cache key before ever
querying the DB again, so every fresh install's language dropdown
(e.g. the "Welcome to Invoice Ninja" company setup wizard) was stuck
showing only those 3 entries, in their own native-language spelling
(ctrans() translates each name using the active locale).

Adds an unconditional `invoiceninja-manage cache:forget languages` to
invoiceninja-data-setup.service, and a regression test asserting
/api/v1/statics returns more than 40 languages. Verified directly:
before the fix, a fresh account's statics call returned exactly 3
languages; after, it returns all 45.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:17:22 -04:00
awkawbandClaude Sonnet 5 d7fea37e25 nixos-module: fix caddy Host-header matching for 127.0.0.1/[::1]
Caddy strictly matches vhosts on the exact Host header, unlike nginx
where a single vhost acts as the default regardless of Host. With
hostname = "localhost" (the local-dev default), visiting the VM via
http://127.0.0.1:PORT instead of http://localhost:PORT sent a Host
header matching no vhost, and Caddy silently returned an empty 200 OK
- a genuine white screen with no error anywhere (not in the console,
not in the network tab).

Adds serverAliases for 127.0.0.1:80 and [::1]:80 to the caddy vhost
when hostname is "localhost", so all three ways of reaching the local
VM resolve to the same site block. Verified directly: before this
fix, curl with `Host: 127.0.0.1` got an empty response while `Host:
localhost` got the full app shell; after, all three (localhost,
127.0.0.1, [::1]) return the same 200 response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 14:37:12 -04:00
awkawbandClaude Sonnet 5 a71ccc87bb nixos-module: remove proxy-level CORS headers
Laravel's own CORS middleware already sends a complete, correct set of
Access-Control-* headers on every response. The nginx and caddy vhost
configs were additionally setting a second, static set of the same
headers, producing duplicates with conflicting values (e.g. two
Access-Control-Allow-Methods headers, one "*" and one the app's real
list). Browsers reject responses with duplicate/conflicting CORS
headers - curl-based checks never noticed since only browsers enforce
CORS header validation, but it broke real browser usage (login/API
calls failing silently).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 14:35:53 -04:00
awkawbandClaude Sonnet 5 4c97391a33 tests: exercise both nginx and caddy proxy backends
Parameterizes tests/common.nix and tests/default.nix on proxyServer
(default "nginx"), giving flake.nix two nixosConfigurations
(test-nginx/test-caddy) and `just build-vm [proxy]` a way to pick
between them for interactive testing.

The automated nixosTest now boots one node per proxy backend and runs
the full assertion suite (factored into check_invoiceninja()) against
each, so a regression in either nginx or caddy's config gets caught.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 14:33:50 -04:00
awkawbandClaude Sonnet 5 fef9406b63 nixos-module: add scheduler timer for artisan schedule:run
Invoice Ninja relies on Laravel's scheduler for recurring invoices,
scheduled reports, and other internal cron-driven jobs. Without a
systemd unit invoking `artisan schedule:run` periodically, the app's
dashboard reports "cron jobs are not enabled" after login.

Adds invoiceninja-scheduler.timer (every minute, matching upstream's
documented cron cadence) and its oneshot service, and extends the
automated test to assert the timer is active and schedule:run succeeds.

Note: the app's own is_scheduler_running flag only flips on a 5-minute
wall-clock boundary (app/Console/Kernel.php's everyFiveMinutes() job),
so the dashboard warning can take a few minutes to clear after boot -
confirmed working via the "Refresh Data" button in that same modal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:06:00 -04:00
awkawbandClaude Sonnet 5 2c0a48b899 Add automated NixOS VM test and CLAUDE.md
Adds a pkgs.testers.nixosTest (checks.<system>.invoiceninja) that boots
the module end-to-end and checks that mysql/redis/phpfpm/queue-worker/
nginx come up, the one-time migrate/seed/admin-create setup completed,
the app shell and /login page are served, invoiceninja-manage works,
and the seeded admin can log in via the API.

Factors the shared services.invoiceninja config used by both the
interactive VM and the new test into tests/common.nix, and satisfies
NixOS's fileSystems/bootloader assertions in the interactive VM config
so `nix flake check` evaluates nixosConfigurations.test cleanly (the
qemu-vm module already overrides both when actually building
config.system.build.vm, so this only affects the toplevel-eval path).

Also adds a `just test` recipe, a CLAUDE.md codebase overview, and a
.gitignore for the result/nixos.qcow2 build artifacts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:10:41 -04:00
awkawb d7633e66ce flake.lock: updated nixpkgs input 2026-08-14 16:18:30 -04:00
awkawb f6e5c15afd flake.nix: updated nixpkgs input to latest NixOS release 2026-08-14 16:18:03 -04:00
awkawb df90f25778 tests/default.nix: removed user test, added password declaration for root user
This makes testing/debugging easier by allowing to login as root
directly. The previous test system configuration added a step to this
process by forcing devs to login as test and use sudo to escalate to
root.
2026-08-14 16:15:55 -04:00
awkawb 554f73d477 tests/default.nix: removed system.stateVersion declaration
Having the declaration of system.stateVersion just adds more
maintainance for devs. For example, changing the nixpkgs value in
flake.nix requires a change of system.stateVersion in tests/default.nix.
2026-08-14 16:15:55 -04:00
awkawb 5de2563c0f flake.lock: updated flake inputs 2026-05-25 19:12:41 -04:00
awkawb b37f121f06 flake.nix: updated nixpkgs input to version 25.11 2026-05-25 19:11:06 -04:00
awkawb 878134dd4b nixos-module/invoiceninja.nix: update nginx to work with Invoice Ninja 2026-05-25 19:09:49 -04:00
awkawb 808b9a7fe9 Replaced Makefile with justfile 2026-05-25 19:02:53 -04:00
awkawb 6b0f38d59e tests/default.nix: updated system.stateVersion 2026-05-25 19:02:21 -04:00
awkawb fd776bb9b7 package.nix: updated to build Invoice Ninja with React frontend 2026-05-25 19:01:26 -04:00
awkawb a42d01edd3 package.nix: bumped version to 5.12.13 2025-08-13 18:34:59 -04:00
awkawb 1df11adbde Merge branch 'master' of gitea.awkawb.cloud:awkawb/invoiceninja-nixos 2025-03-01 11:11:58 -05:00
awkawb e8b208accb flake.lock: updated flake inputs 2025-02-24 17:32:32 -05:00
awkawb 295b299fdd package.nix: bumped version to 5.11.41 2025-02-24 17:29:51 -05:00
awkawb d898e26cb1 package.nix: bumped version to 5.11.24 2025-01-14 16:54:51 -05:00
awkawb 7b2072e5b0 package.nix: remove unneeded move and link statements 2025-01-02 16:55:02 -05:00
awkawb 611709cb62 nixos-module/invoiceninja.nix: changed nixos module definition for nginx to allow local development, removed unneeded link in data setup service 2025-01-02 16:53:58 -05:00
awkawb 730715014e tests/default.nix: modified to reflect package name change 2025-01-02 16:22:09 -05:00
awkawb 4bddba21da flake.nix: fixed attribute name 2025-01-01 16:15:10 -05:00
awkawb 8549d2f23d Makefile: build-vm now reflects the repo being a flake 2025-01-01 12:19:25 -05:00
awkawb 91d8d8a908 flake.nix: modified to reflect package name change, add nixos config for development 2025-01-01 12:17:55 -05:00
awkawb 2b98375f9b tests/default.nix: added stateVersion, added import of nixos module, fixed typo 2025-01-01 12:12:59 -05:00
awkawb 4cb4a1c05d Package is now 'invoiceninja', modified code to reflect name change, changed nixos module definition for caddy to allow local development 2025-01-01 12:11:18 -05:00
awkawb 14976fdae9 Added tests directory for testing in vm 2024-12-30 10:19:29 -05:00
awkawb 1e48f190da Removed unused files and directories 2024-12-28 16:19:55 -05:00
20 changed files with 912 additions and 360 deletions
+2
View File
@@ -0,0 +1,2 @@
/result
/nixos.qcow2
-11
View File
@@ -1,11 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2024.07.04] 2024-07-04
- Initial release
+155
View File
@@ -0,0 +1,155 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
A Nix flake that packages [Invoice Ninja](https://github.com/invoiceninja/invoiceninja) (a PHP/Laravel app with a
separate React frontend) and provides a NixOS module (`services.invoiceninja`) to deploy it as a full service:
PHP-FPM pool, nginx or Caddy reverse proxy, MariaDB, Redis, a queue-worker systemd unit, and a one-shot
migration/setup unit.
There is no application source code here — this is packaging/deployment code only. All behavior changes come from
editing Nix expressions.
## Commands
This project uses `just` (see `justfile`) and Nix directly.
- `just build-vm [proxy]` — cleans previous artifacts and builds the test VM (`nixosConfigurations.test-<proxy>`)
via `nom build`. `proxy` is `nginx` (default) or `caddy`.
- `just boot-vm` — boots the built VM (`result/bin/run-nixos-vm`) in the current terminal. Serves Invoice Ninja on
`localhost:8080` (see `QEMU_NET_OPTS` in the `justfile`) with root login `toor`.
- `just clean` — removes `result`/`nixos.qcow2` and kills any running `qemu` process.
- `just test` — runs the automated test suite (`nix flake check -L`).
- `nix build .#invoiceninja` — build just the Invoice Ninja package.
- `nix build .#checks.x86_64-linux.invoiceninja -L` — run only the automated VM test, with build/test log output.
- `nix flake check` — evaluate the flake (module options, overlay, package derivation) and run the automated test.
### `tests/`
- `common.nix` — the shared `services.invoiceninja` config used by both the interactive VM and the automated test:
imports the module and points at the checked-in test fixtures (`invoice_ninja_test_password`, `test.env`). It's a
function taking `{ proxyServer ? "nginx" }` so callers can pick which proxy backend to test.
- `default.nix` — the interactive VM config (`nixosConfigurations.test-nginx`/`test-caddy` in `flake.nix`), used
for manual, click-around verification via `just build-vm [proxy]` / `just boot-vm`. It's also a function taking
`{ proxyServer ? "nginx" }`, forwarded straight through to `common.nix`, so `flake.nix` can instantiate one
`nixosConfigurations` entry per proxy backend. Adds the qemu-guest profile and a root login. It also sets a
dummy `fileSystems."/"` and disables `boot.loader.grub` purely to satisfy NixOS's built-in assertions when
`nix flake check` evaluates
`config.system.build.toplevel` directly — the qemu-vm module overrides both with a tmpfs root and direct kernel
boot when actually building `config.system.build.vm` (what `just build-vm` uses), so these values are otherwise
unused. Also raises `virtualisation.cores`/`memorySize` to 2/2560 (qemu-vm's defaults, 1 core/1024MB, are too
tight for real interactive use — see the module architecture notes below on PDF generation's memory cost).
- `nixos-test.nix` — the automated test (`pkgs.testers.nixosTest`, exposed as `checks.<system>.invoiceninja` in
`flake.nix`). Builds its own `pkgs` externally (needed regardless of config, since `nixosTest` itself is called
as `pkgs.testers.nixosTest`) and boots two nodes from `common.nix` — one with `proxyServer = "nginx"`, one with
`proxyServer = "caddy"` — so both proxy backends get exercised. The shared
assertion suite (factored into a `check_invoiceninja(machine, proxy_unit)` function in the test script) runs
against each node: waits for mysql/redis/phpfpm/queue-worker/proxy to be active, checks the
`invoiceninja-data-setup` marker files (`.db-seeded`, `.admin-created`) to confirm the
one-time setup actually completed (not just that the oneshot unit exited), curls `/` for the app shell, exercises
the `invoiceninja-manage` wrapper, logs in via `/api/v1/login` using the test admin credentials as an
end-to-end check that migrations/seeding/account creation all worked, checks the full language list is
available (see the `cache:forget languages` note above), and exercises PDF generation end-to-end (POSTs to
`/api/v1/preview` with no entity, which falls back to a self-contained mock PDF built via the same
chromium/snappdf pipeline real invoices use, and checks for the `%PDF` magic bytes — no client/invoice setup
needed). This is the only automated test in the repo — there is no separate unit-test suite.
## Architecture
### `package.nix`
Builds Invoice Ninja in two stages:
1. **`ui`** — the separate `invoiceninja/ui` React frontend repo, built with `buildNpmPackage`. Patches
`.env.example` to disable test mode and swaps in `vite.config.ts.react` before building.
2. **Main package** — built with `php82.buildComposerProject` from the `invoiceninja/invoiceninja` PHP repo. The
`ui` build's `dist/` output is copied into `public/` before the PHP build (`preConfigure`), and
`public/index.html` is copied to `resources/views/react/index.blade.php` so Laravel serves the React shell.
Both source fetches (`fetchFromGitHub`) run their lockfiles through `npm-lockfile-fix` in `postFetch` to patch
missing integrity fields — required for `fetchNpmDeps`/`buildNpmPackage` to work with these upstream lockfiles.
Because Laravel needs writable directories that can't live in the read-only Nix store, `postInstall` renames
`bootstrap` → `bootstrap-static` and `storage` → `storage-static` inside the derivation, then symlinks
`$out/.env`, `$out/storage`, `$out/public/storage`, and `$out/bootstrap` to paths under the module's `dataDir` /
`runtimeDir`. This means **the package output is only fully functional when paired with the NixOS module** (or
something else that populates those runtime paths the same way) — building the package alone will produce
dangling symlinks.
When bumping the Invoice Ninja or UI version in `package.nix`, all four hashes (`src.hash`, `uiSrc.hash`,
`ui`'s `npmDepsHash`, and the main package's `vendorHash`/`npmDeps.hash`) generally need to be recomputed —
expect Nix to report the correct hash on a failed build (or use `nix-prefetch`/`lib.fakeHash` workflow).
### `nixos-module/`
- `default.nix` — the flake's `nixosModules` output. Exposes the `invoiceninja` module and a small
`overlayNixpkgsForThisInstance` module that applies the flake's overlay (registers `pkgs.invoiceninja`) to
whatever nixpkgs instance imports it.
- `invoiceninja.nix` — the actual `services.invoiceninja` module. Key structural points:
- `cfg.settings` is a submodule with a `freeformType` (`pkgs.formats.keyValue`), so any Laravel `.env` key can be
set even if not explicitly declared as an option; explicitly declared options (APP_ENV, MAIL_MAILER,
SESSION_DRIVER, etc.) exist mainly to add type-checking/enums and sensible computed defaults (e.g.
SESSION_DRIVER/QUEUE_CONNECTION/CACHE_DRIVER default to redis → database → file depending on what's enabled).
- **Secrets are deliberately kept out of `cfg.settings`/the Nix store.** `secretFile` (required) and
`adminAccount.passwordFile` (required) are consumed via systemd `LoadCredential` in the
`invoiceninja-data-setup` service, not written into the generated `.env` derivation. The final `.env` is
assembled at activation time by concatenating the Nix-store-rendered non-secret settings with the secret file
contents directly on disk in `dataDir`.
- `invoiceninja-data-setup.service` (oneshot, runs on every activation) does all first-run and idempotent setup:
clears bootstrap/opcode cache, rebuilds `.env`, rsyncs static storage into `dataDir`, symlinks the runtime
`app.php`, runs `migrate --force` unconditionally on every activation (safe/idempotent — Laravel tracks
applied migrations in the DB and only runs new ones; this must NOT be marker-gated, since a later invoiceninja
version upgrade can ship new migrations that need to run on an already-migrated install), and guards the
remaining one-time actions (`db:seed`, admin account creation) behind marker files (`.db-seeded`,
`.admin-created`) in `dataDir`. `phpfpm-invoiceninja.service` and
`invoiceninja-queue-worker.service` both depend on it. It also unconditionally runs
`invoiceninja-manage cache:forget languages` on every activation, working around an upstream bug: migration
`2025_08_31_224356_additional_languages_af_ca_id.php` runs during `migrate` (before `db:seed`'s
`LanguageSeeder` has populated the other 42 rows) and does `Cache::forever('languages', Language::all())`,
permanently caching just the 3 languages it sees at that moment — without this, the language dropdown in
the app stays stuck at 3 entries forever, since `StaticServiceProvider` checks that cache key before ever
querying the DB again.
- `invoiceninja-scheduler.timer`/`.service` run `artisan schedule:run` every minute — this is what drives
Invoice Ninja's own internal cron-scheduled jobs (recurring invoices, scheduled reports, etc.); without it the
app's dashboard reports "cron jobs are not enabled". Laravel's scheduler is just a dispatcher, so this one
timer is sufficient regardless of how many scheduled tasks Invoice Ninja itself defines.
- `invoiceninja-manage` is a generated wrapper script (`invoiceninja-manage <artisan-args>`) that `cd`s into the
package output and runs `php artisan` as the configured service user (via `sudo` if invoked as another user).
It's added to `environment.systemPackages`, so it's the normal way to run artisan commands
(`migrate`, `queue:work`, `ninja:create-account`, etc.) by hand on a deployed system.
- Both `nginx` and `caddy` proxy backends are supported (`proxy.server`), each independently duplicating the
PHP-FPM passthrough config — if changing one, check whether the other needs the same change. Neither sets
CORS headers itself: Laravel's own CORS middleware already sends a complete set on every response, and an
earlier version of this module additionally set a second, static set of `Access-Control-*` headers at the
proxy level, which browsers reject as conflicting duplicates (server-to-server/curl checks don't notice,
since only browsers enforce CORS header validation).
- Caddy matches vhosts on the exact `Host` header, unlike nginx (where a single vhost acts as the default
regardless of `Host`). When `hostname == "localhost"`, the caddy vhost adds `serverAliases` for
`127.0.0.1:80`/`[::1]:80` too — otherwise visiting the VM via the IP literal instead of `localhost` matches no
vhost and Caddy silently returns an empty `200 OK` (a blank page with no error anywhere).
- A headless browser is required at runtime for PDF generation (`PDF_GENERATOR=snappdf`,
`SNAPPDF_CHROMIUM_PATH`) and is added to the PHP-FPM and data-setup services' `path`. This is
`nixpkgs.playwright-driver`'s `chromium-headless-shell` component (Google's official "Chrome for Testing"
headless-only build, selected via `playwright-driver.browsers.override { ... }` with every other
browser/ffmpeg disabled) rather than `pkgs.chromium` — snappdf only ever shells out to
`$SNAPPDF_CHROMIUM_PATH --headless --print-to-pdf=...`, so the full browser UI/GPU stack `pkgs.chromium` ships
is dead weight; the headless-shell closure is ~1.45GB smaller and needs no `allowUnfree`. Each PDF request
still launches a full instance of the underlying browser engine (not a lightweight renderer), and PHP-FPM's
default `pm.max_children = 8` means up to 8 of these can run concurrently — on a memory-constrained deployment
this can exhaust RAM (confirmed: even a couple of concurrent PDF requests OOM-killed a 1024MB VM). Size
deployments accordingly, or lower `phpfpm.settings."pm.max_children"`.
### `flake.nix`
Standard flake shape: `overlays.default` registers the package, `packages.<system>.invoiceninja` exposes it,
`nixosModules` re-exports `nixos-module/default.nix`, and `nixosConfigurations.test-nginx`/`test-caddy` each build
the VM defined in `tests/default.nix` with the corresponding `proxyServer`. Only `x86_64-linux` is currently
supported.
## Conventions
- Nix files: 2-space indentation (see `.editorconfig`); Unix line endings; final newline required.
- Prefer `lib.mkDefault`/`lib.mkIf`/`lib.mkMerge` for module config so downstream users can override cleanly —
this is used pervasively in `invoiceninja.nix` and should be followed for new options/config.
-47
View File
@@ -1,47 +0,0 @@
####
# Target definitions
####
# Disable echoing of target recipe commands
# Comment this for debugging
.SILENT:
# Run target recipes in one shell invocation
.ONESHELL:
# Since all targets are phony, all targets should be listed here
# One target per line
.PHONY: boot-vm \
build-vm \
clean \
format-nix-files \
help
# Show the help text
help:
egrep -h '\s##\s' $(MAKEFILE_LIST) \
| sort \
| awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
format-nix-files: ## Format nix files using the nixpkgs-fmt tool
find . -name "*.nix" -exec nixpkgs-fmt {} \;
clean: ## Clean build artifacts and shutdown running virtual machines
rm result > /dev/null 2>&1
rm nixos.qcow2 > /dev/null 2>&1
pkill qemu
exit 0
build-vm: clean ## Build virtual machine for testing
nixos-rebuild build-vm \
-I nixpkgs=http://nixos.org/channels/nixos-24.05/nixexprs.tar.xz \
-I nixos-config=./tests/test-config.nix
boot-vm: ## Run virtual machine in current terminal
QEMU_KERNEL_PARAMS=console=ttyS0 \
QEMU_NET_OPTS=hostfwd=tcp::8080-:80 \
./result/bin/run-nixos-vm \
-nographic; \
reset
-13
View File
@@ -1,13 +0,0 @@
diff --git a/app/Console/Commands/CreateAccount.php b/app/Console/Commands/CreateAccount.php
index 228f8e8283..1ff3c54a61 100644
--- a/app/Console/Commands/CreateAccount.php
+++ b/app/Console/Commands/CreateAccount.php
@@ -79,7 +79,7 @@ class CreateAccount extends Command
$company->save();
$account->default_company_id = $company->id;
- $account->set_react_as_default_ap = true;
+ $account->set_react_as_default_ap = false;
$account->save();
$email = $this->option('email') ?? 'admin@example.com';
Generated
+25 -53
View File
@@ -3,15 +3,15 @@
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "edolstra",
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
@@ -24,11 +24,11 @@
]
},
"locked": {
"lastModified": 1719994518,
"narHash": "sha256-pQMhCCHyQGRzdfAkdJ4cIWiw+JNuWsTX7f0ZYSyz0VY=",
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "9227223f6d922fee3c7b190b2cc238a99527bbb7",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
@@ -42,24 +42,17 @@
"flake-compat": [
"nix"
],
"gitignore": [
"nix"
],
"nixpkgs": [
"nix",
"nixpkgs"
],
"nixpkgs-stable": [
"nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1721042469,
"narHash": "sha256-6FPUl7HVtvRHCCBQne7Ylp4p+dpP3P/OYuzjztZ4s70=",
"lastModified": 1783008725,
"narHash": "sha256-jGiy6+sxjNWXSjp25uoJuNfyH9zBK1PEDY0lVoL4ibQ=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "f451c19376071a90d8c58ab1a953c6e9840527fd",
"rev": "bca82caa46d5ec0f5d422c61fb1e30bc51313cbe",
"type": "github"
},
"original": {
@@ -68,39 +61,21 @@
"type": "github"
}
},
"libgit2": {
"flake": false,
"locked": {
"lastModified": 1715853528,
"narHash": "sha256-J2rCxTecyLbbDdsyBWn9w7r3pbKRMkI9E7RvRgAqBdY=",
"owner": "libgit2",
"repo": "libgit2",
"rev": "36f7e21ad757a3dacc58cf7944329da6bc1d6e96",
"type": "github"
},
"original": {
"owner": "libgit2",
"ref": "v1.8.1",
"repo": "libgit2",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"git-hooks-nix": "git-hooks-nix",
"libgit2": "libgit2",
"nixpkgs": "nixpkgs",
"nixpkgs-23-11": "nixpkgs-23-11",
"nixpkgs-regression": "nixpkgs-regression"
},
"locked": {
"lastModified": 1735389339,
"narHash": "sha256-JfQXQL0MysQSfvbw7xHto9YbqZ1VQLFgus+c4KYt6xg=",
"lastModified": 1786478010,
"narHash": "sha256-/9wXGp+aLObxqQuOGhDOnbdV0QCRDHH8uSh/4URKVAg=",
"owner": "NixOS",
"repo": "nix",
"rev": "8a3fc27f1b63a08ac983ee46435a56cf49ebaf4a",
"rev": "24b65e35d9d4c9d64c1bd5844a35f2a779356d63",
"type": "github"
},
"original": {
@@ -110,18 +85,15 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1723688146,
"narHash": "sha256-sqLwJcHYeWLOeP/XoLwAtYjr01TISlkOfz+NG82pbdg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c3d4ac725177c030b1e289015989da2ad9d56af0",
"type": "github"
"lastModified": 1783148766,
"narHash": "sha256-H9+N+GFtsbVC8ZniHliChM7ndizxtqVZs6bnGOLM3WQ=",
"rev": "a50de1b7d8a586adc18d2395c19de7d6058e6030",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/26.05/nixos-26.05.4193.a50de1b7d8a5/nixexprs.tar.xz"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
"type": "tarball",
"url": "https://channels.nixos.org/nixos-26.05/nixexprs.tar.xz"
}
},
"nixpkgs-23-11": {
@@ -158,16 +130,16 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1735264675,
"narHash": "sha256-MgdXpeX2GuJbtlBrH9EdsUeWl/yXEubyvxM1G+yO4Ak=",
"lastModified": 1786430034,
"narHash": "sha256-Vux08kA5PICwS2sViCMfwVLAHNoH8TkKAeBo25LjpMI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d49da4c08359e3c39c4e27c74ac7ac9b70085966",
"rev": "70cc4559b10a6062b05ff1af17e0add065ccaed9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.11",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
+26 -8
View File
@@ -1,7 +1,14 @@
{
description = "An Invoice Ninja package and a module which can be added to a NixOS configuration";
description = "An Invoice Ninja package and module.";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
nixConfig = {
extra-substituters = [ "https://invoiceninja-nixos.cachix.org" ];
extra-trusted-public-keys = [
"invoiceninja-nixos.cachix.org-1:TmRZaji8FFVjjIw9KZmvLrxMxfP+JDThOyOX04z526A="
];
};
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs =
{ self, nixpkgs, nix, }:
@@ -18,19 +25,30 @@
overlays = overlayList;
}
);
in
rec {
nixosConfigurations = {
test-nginx = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ (import ./tests/default.nix { proxyServer = "nginx"; }) ];
};
test-caddy = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ (import ./tests/default.nix { proxyServer = "caddy"; }) ];
};
};
# A Nixpkgs overlay that provides a 'Invoice Ninja' package.
overlays.default = final: prev: { invoice-ninja = final.callPackage ./package.nix { }; };
overlays.default = final: prev: { invoiceninja = final.callPackage ./package.nix { }; };
packages = forEachSystem (system: {
invoice-ninja = pkgsBySystem.${system}.invoice-ninja;
default = pkgsBySystem.${system}.invoice-ninja;
invoiceninja = pkgsBySystem.${system}.invoiceninja;
default = pkgsBySystem.${system}.invoiceninja;
});
checks = forEachSystem (system: {
invoiceninja = import ./tests/nixos-test.nix { inherit system; nixpkgsPath = nixpkgs; };
});
nixosModules = import ./nixos-module { overlays = overlayList; };
};
}
+52
View File
@@ -0,0 +1,52 @@
set quiet := true
# Passed to QEMU in boot-vm
export QEMU_KERNEL_PARAMS := "console=ttyS0"
export QEMU_NET_OPTS := "hostfwd=tcp::8080-:80"
export QEMU_OPTS := "-nographic"
[private]
_default:
just --list
[doc('Clean build artifacts and shutdown running virtual machines')]
[group('maintenance')]
clean:
#!/usr/bin/env bash
rm result > /dev/null 2>&1
rm nixos.qcow2 > /dev/null 2>&1
pkill qemu
exit 0
[doc('Build virtual machine for testing (proxy: nginx or caddy)')]
[group('main')]
build-vm proxy="nginx": clean
nom build ".#nixosConfigurations.test-{{proxy}}.config.system.build.vm"
[doc('Run virtual machine in current terminal')]
[group('main')]
boot-vm:
#!/usr/bin/env bash
[ -x result/bin/run-nixos-vm ] && \
./result/bin/run-nixos-vm
reset
[doc('Run the automated NixOS VM test')]
[group('main')]
test:
nix flake check -L
[doc('Update invoiceninja and ui to their latest upstream versions, recomputing hashes')]
[group('maintenance')]
update:
./scripts/update-versions.sh
[doc('Build the package and automated test, then push results to the invoiceninja-nixos cachix cache')]
[group('maintenance')]
push-cache:
nix build --print-out-paths --no-link \
".#invoiceninja" \
".#invoiceninja.ui" \
".#checks.x86_64-linux.invoiceninja" \
| cachix push invoiceninja-nixos
+1 -1
View File
@@ -1,7 +1,7 @@
{ overlays }:
{
invoice-ninja = import ./invoice-ninja.nix;
invoiceninja = import ./invoiceninja.nix;
overlayNixpkgsForThisInstance =
{ pkgs, ... }: {
nixpkgs = {
@@ -1,21 +1,20 @@
{
config,
lib,
modulesPath,
options,
pkgs,
...
{ config
, lib
, modulesPath
, options
, pkgs
, ...
}:
let
cfg = config.services.invoice-ninja;
cfg = config.services.invoiceninja;
user = cfg.user;
group = cfg.group;
invoice-ninja = pkgs.callPackage ../package.nix {
invoiceninja = pkgs.callPackage ../package.nix {
inherit (cfg) dataDir runtimeDir;
};
configFormat = pkgs.formats.keyValue { };
configFile = pkgs.writeText "invoice-ninja-env" (lib.generators.toKeyValue { } cfg.settings);
configFile = pkgs.writeText "invoiceninja-env" (lib.generators.toKeyValue { } cfg.settings);
# PHP environment
phpPackage = cfg.phpPackage.buildEnv {
@@ -48,13 +47,27 @@ let
extraConfig = "memory_limit = 1024M";
};
# Chromium is required for PDF invoice generation
chromium = pkgs.chromium;
# A headless browser is required for PDF invoice generation (PDF_GENERATOR=snappdf). snappdf
# just shells out to `$SNAPPDF_CHROMIUM_PATH --headless --print-to-pdf=... <url>`, so a full
# browser UI/GPU stack isn't needed -- Google's own "Chrome for Testing" headless-only build
# (as packaged by nixpkgs' playwright-driver) does the same job for a ~1.45GB smaller closure
# than pkgs.chromium, with no allowUnfree requirement.
chromiumHeadlessShellBrowsers = pkgs.playwright-driver.browsers.override {
withChromium = false;
withFirefox = false;
withWebkit = false;
withFfmpeg = false;
withChromiumHeadlessShell = true;
};
chromium = pkgs.runCommand "chromium-headless-shell" { } ''
mkdir -p $out/bin
ln -s ${chromiumHeadlessShellBrowsers}/chromium_headless_shell-*/chrome-headless-shell-linux64/chrome-headless-shell $out/bin/chromium
'';
extraPrograms = [ chromium ];
# Management script
invoice-ninja-manage = pkgs.writeShellScriptBin "invoice-ninja-manage" ''
cd ${invoice-ninja}
invoiceninja-manage = pkgs.writeShellScriptBin "invoiceninja-manage" ''
cd ${invoiceninja}
sudo=exec
if [[ "$USER" != ${user} ]]; then
sudo='exec /run/wrappers/bin/sudo -u ${user}'
@@ -63,10 +76,10 @@ let
'';
in
{
options.services.invoice-ninja = {
enable = lib.mkEnableOption "invoice-ninja";
options.services.invoiceninja = {
enable = lib.mkEnableOption "invoiceninja";
package = lib.mkPackageOption pkgs "invoice-ninja" { };
package = lib.mkPackageOption pkgs "invoiceninja" { };
phpPackage = lib.mkPackageOption pkgs "php82" { };
@@ -94,7 +107,7 @@ in
'';
};
msmtp.accounts.invoice-ninja = lib.mkOption {
msmtp.accounts.invoiceninja = lib.mkOption {
type = lib.types.attrs;
default = { };
example = {
@@ -107,9 +120,9 @@ in
passwordeval = "cat /secrets/password.txt";
};
description = ''
Define the msmtp configuration for an invoice-ninja account which
Define the msmtp configuration for an invoiceninja account which
will be used by Invoice Ninja to send email message when
`config.services.invoice-ninja.settings.MAIL_MAILER` is `sendmail`.
`config.services.invoiceninja.settings.MAIL_MAILER` is `sendmail`.
It is advised to use the `passwordeval` setting to read the password
from a secret file to avoid having it written in the world-readable
@@ -125,18 +138,18 @@ in
dataDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/invoice-ninja";
default = "/var/lib/invoiceninja";
description = ''
State directory of the `invoice-ninja` user which holds
State directory of the `invoiceninja` user which holds
the application's state and data.
'';
};
runtimeDir = lib.mkOption {
type = lib.types.str;
default = "/run/invoice-ninja";
default = "/run/invoiceninja";
description = ''
Rutime directory of the `invoice-ninja` user which holds
Rutime directory of the `invoiceninja` user which holds
the application's caches and temporary files.
'';
};
@@ -207,7 +220,7 @@ in
options = {
APP_NAME = lib.mkOption {
type = lib.types.str;
default = "Invoice Ninja";
default = ''"Invoice Ninja"'';
description = "Your application name - used in client portal title banner";
};
APP_DEBUG = lib.mkOption {
@@ -256,13 +269,13 @@ in
};
MAIL_SENDMAIL_PATH = lib.mkOption {
type = lib.types.str;
default = ''"/run/wrappers/bin/sendmail -t -a invoice-ninja"'';
default = ''"/run/wrappers/bin/sendmail -t -a invoiceninja"'';
description = ''
Path to sendmail along with arguments for Invoice Ninja to use when using sendmail
as mail transport agent.
:: note
The default value will work with the `msmtp.accounts.invoice-ninja` setting. Only
The default value will work with the `msmtp.accounts.invoiceninja` setting. Only
change if you know what your doing.
::
'';
@@ -294,7 +307,7 @@ in
default =
if cfg.redis.createLocally then "redis" else (if cfg.database.createLocally then "database" else "file");
defaultText = lib.literalExpression ''
if config.services.invoice-ninja.redis.enable
if config.services.invoiceninja.redis.enable
then "redis"
else (if config.services.invoie-ninja.database.createLocally then "database" else "file")
'';
@@ -317,7 +330,7 @@ in
default =
if cfg.redis.createLocally then "redis" else (if cfg.database.createLocally then "database" else "sync");
defaultText = lib.literalExpression ''
if config.services.invoice-ninja.redis.enable
if config.services.invoiceninja.redis.enable
then "redis"
else (if config.services.invoie-ninja.database.createLocally then "database" else "sync")
'';
@@ -329,7 +342,7 @@ in
"redis"
];
default = if cfg.redis.createLocally then "redis" else "file";
defaultText = lib.literalExpression ''if config.services.invoice-ninja.redis.enable then "redis" else "file"'';
defaultText = lib.literalExpression ''if config.services.invoiceninja.redis.enable then "redis" else "file"'';
description = "Laravel cache driver for Invoice Ninja to use.";
};
};
@@ -337,7 +350,7 @@ in
};
adminAccount = {
createAdmin = lib.mkOption {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
@@ -347,7 +360,7 @@ in
};
email = lib.mkOption {
type = lib.types.str;
default = "example@email.com";
default = "admin@email.com";
description = "Email address of the first (admin) account for this Invoice Ninja installation";
};
passwordFile = lib.mkOption {
@@ -423,14 +436,14 @@ in
users.groups.invoiceninja = lib.mkIf (cfg.group == "invoiceninja") { };
environment.systemPackages = [ invoice-ninja-manage ] ++ extraPrograms;
environment.systemPackages = [ invoiceninja-manage ] ++ extraPrograms;
programs.msmtp = lib.mkIf (cfg.settings.MAIL_MAILER == "sendmail") {
inherit (cfg.msmtp) accounts;
enable = true;
};
services.invoice-ninja.settings = lib.mkMerge [
services.invoiceninja.settings = lib.mkMerge [
({
APP_URL = lib.mkDefault (
if (cfg.hostname == "localhost") then ("http://" + cfg.hostname) else ("https://" + cfg.hostname)
@@ -452,12 +465,12 @@ in
DB_USERNAME = lib.mkDefault user;
})
(lib.mkIf cfg.redis.createLocally {
REDIS_HOST = lib.mkDefault config.services.redis.servers.invoice-ninja.bind;
REDIS_PORT = lib.mkDefault config.services.redis.servers.invoice-ninja.port;
REDIS_HOST = lib.mkDefault config.services.redis.servers.invoiceninja.bind;
REDIS_PORT = lib.mkDefault config.services.redis.servers.invoiceninja.port;
})
];
services.phpfpm.pools.invoice-ninja = {
services.phpfpm.pools.invoiceninja = {
inherit user group phpPackage;
settings = {
@@ -474,7 +487,7 @@ in
'';
};
services.redis.servers.invoice-ninja = lib.mkIf cfg.redis.createLocally {
services.redis.servers.invoiceninja = lib.mkIf cfg.redis.createLocally {
enable = true;
port = 6379;
};
@@ -485,9 +498,8 @@ in
services.nginx = lib.mkIf (cfg.proxy.server == "nginx") {
enable = true;
recommendedTlsSettings = true;
recommendedTlsSettings = (if (cfg.hostname == "localhost") then false else true);
recommendedGzipSettings = true;
recommendedProxySettings = true;
recommendedOptimisation = true;
clientMaxBodySize = cfg.maxUploadSize;
@@ -495,32 +507,38 @@ in
virtualHosts."${cfg.hostname}" = lib.mkMerge [
cfg.proxy.nginxConfig
{
root = lib.mkForce "${invoice-ninja}/public";
addSSL = lib.mkForce true;
enableACME = lib.mkForce true;
root = lib.mkForce "${invoiceninja}/public";
addSSL = lib.mkForce (if (cfg.hostname == "localhost") then false else true);
enableACME = lib.mkForce (if (cfg.hostname == "localhost") then false else true);
locations = {
# Handle Laravel Routes
"/".tryFiles = "$uri $uri/ /index.php?$query_string";
"/".extraConfig = ''
if (!-e $request_filename) {
rewrite ^(.+)$ /index.php?q=$1 last;
}
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' '*';
add_header 'Access-Control-Max-Age' 0;
add_header 'Content-Length' 0;
add_header 'Access-Control-Allow-Headers' 'X-API-COMPANY-KEY,X-API-SECRET,X-API-TOKEN,X-API-PASSWORD,DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Disposition,Content-Type,Range,X-CSRF-TOKEN,X-XSRF-TOKEN,X-LIVEWIRE';
add_header 'Access-Control-Expose-Headers' 'X-APP-VERSION,X-MINIMUM-CLIENT-VERSION,X-CSRF-TOKEN,X-XSRF-TOKEN,X-LIVEWIRE';
add_header 'Access-Control-Allow-Credentials' false;
'';
"~ \\.php$".extraConfig = "return 403;";
"= /index.php".extraConfig = ''
fastcgi_pass unix:${config.services.phpfpm.pools.invoice-ninja.socket};
# PHP Processing
"~ \\.php$".extraConfig = ''
include ${config.services.nginx.package}/conf/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $request_filename;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${config.services.phpfpm.pools.invoiceninja.socket};
fastcgi_index index.php;
'';
# Security: Deny access to hidden files
"~ /\\.ht".extraConfig = "deny all;";
# Static Files Caching
"~* \\.(jpg|jpeg|png|gif|ico|css|js)$".extraConfig = ''
expires 1y;
add_header Cache-Control "public, immutable";
'';
};
extraConfig = ''
index index.html index.htm index.php;
index index.php index.html index.htm;
error_page 404 /index.php;
if (!-e $request_filename) {
rewrite ^(.+)$ /index.php?q= last;
}
'';
}
(lib.mkIf (cfg.hostname != "localhost") {
@@ -533,30 +551,37 @@ in
users.users."${config.services.caddy.user}" = lib.mkIf (cfg.proxy.server == "caddy") {
extraGroups = [ cfg.group ];
};
services.caddy = lib.mkIf (cfg.proxy.server == "caddy") {
services.caddy =
let
proto_hostname = (
if (cfg.hostname == "localhost")
then (cfg.hostname + ":80")
else (cfg.hostname + ":443")
);
in
lib.mkIf (cfg.proxy.server == "caddy") {
enable = true;
globalConfig = lib.mkIf (cfg.hostname == "localhost") ''
auto_https disable_redirects
'';
virtualHosts."${cfg.hostname}" = lib.mkMerge [
virtualHosts."${proto_hostname}" = lib.mkMerge [
cfg.proxy.caddyConfig
{
hostName = lib.mkForce cfg.hostname;
hostName = lib.mkForce proto_hostname;
# Caddy matches vhosts on the exact Host header, unlike nginx's single-vhost-is-default
# behavior. For local dev, accept requests to 127.0.0.1/[::1] too - otherwise visiting
# via the IP literal instead of "localhost" hits no vhost and gets an empty 200 response
# (a silent white screen, no error anywhere).
serverAliases = lib.mkIf (cfg.hostname == "localhost") [
"127.0.0.1:80"
"[::1]:80"
];
extraConfig = ''
encode zstd gzip
root * ${invoice-ninja}/public
php_fastcgi unix/${config.services.phpfpm.pools.invoice-ninja.socket}
try_files {path} /index.html
header {
Access-Control-Allow-Origin "*"
Access-Control-Allow-Methods "*"
Access-Control-Max-Age "0"
Access-Control-Allow-Headers "X-API-COMPANY-KEY,X-API-SECRET,X-API-TOKEN,X-API-PASSWORD,DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Disposition,Content-Type,Range,X-CSRF-TOKEN,X-XSRF-TOKEN,X-LIVEWIRE"
Access-Control-Expose-Headers "X-APP-VERSION,X-MINIMUM-CLIENT-VERSION,X-CSRF-TOKEN,X-XSRF-TOKEN,X-LIVEWIRE"
Access-Control-Allow-Credentials false
}
root ${invoiceninja}/public
php_fastcgi unix/${config.services.phpfpm.pools.invoiceninja.socket}
file_server
'';
}
@@ -577,33 +602,59 @@ in
];
};
systemd.services.phpfpm-invoice-ninja.after = [ "invoice-ninja-data-setup.service" ];
systemd.services.phpfpm-invoice-ninja.requires = [
"invoice-ninja-data-setup.service"
systemd.services.phpfpm-invoiceninja.after = [ "invoiceninja-data-setup.service" ];
systemd.services.phpfpm-invoiceninja.requires = [
"invoiceninja-data-setup.service"
] ++ lib.optional cfg.database.createLocally "mysql.service";
# Ensure chromium is available
systemd.services.phpfpm-invoice-ninja.path = extraPrograms;
systemd.services.phpfpm-invoiceninja.path = extraPrograms;
systemd.services.invoice-ninja-queue-worker = {
systemd.services.invoiceninja-queue-worker = {
description = "Invoice Ninja periodic tasks";
after = [ "invoice-ninja-data-setup.service" ];
requires = [ "phpfpm-invoice-ninja.service" ];
after = [ "invoiceninja-data-setup.service" ];
requires = [ "phpfpm-invoiceninja.service" ];
wantedBy = [ "multi-user.target" ];
reloadTriggers = [ invoice-ninja ];
reload = "${invoice-ninja-manage}/bin/invoice-ninja-manage queue:restart";
reloadTriggers = [ invoiceninja ];
reload = "${invoiceninja-manage}/bin/invoiceninja-manage queue:restart";
serviceConfig = {
ExecStart = "${invoice-ninja-manage}/bin/invoice-ninja-manage queue:work ${
ExecStart = "${invoiceninja-manage}/bin/invoiceninja-manage queue:work ${
lib.strings.optionalString (cfg.settings.QUEUE_CONNECTION == "redis") "redis"
}";
User = user;
Group = group;
StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/invoice-ninja") "invoice-ninja";
StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/invoiceninja") "invoiceninja";
Restart = "always";
};
};
systemd.services.invoice-ninja-data-setup = {
systemd.services.invoiceninja-scheduler = {
description = "Invoice Ninja scheduled tasks (artisan schedule:run)";
after = [ "invoiceninja-data-setup.service" ];
requires = [ "phpfpm-invoiceninja.service" ];
# Ensure chromium is available, for any scheduled tasks that generate PDFs.
path = extraPrograms;
serviceConfig = {
Type = "oneshot";
ExecStart = "${invoiceninja-manage}/bin/invoiceninja-manage schedule:run";
User = user;
Group = group;
StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/invoiceninja") "invoiceninja";
};
};
systemd.timers.invoiceninja-scheduler = {
description = "Run Invoice Ninja's scheduled tasks every minute";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "minutely";
Persistent = true;
Unit = "invoiceninja-scheduler.service";
};
};
systemd.services.invoiceninja-data-setup = {
description = "Invoice Ninja setup: migrations, environment file update, cache reload, data changes";
wantedBy = [ "multi-user.target" ];
after = lib.optional cfg.database.createLocally "mysql.service";
@@ -612,7 +663,7 @@ in
with pkgs;
[
bash
invoice-ninja-manage
invoiceninja-manage
rsync
config.services.mysql.package
]
@@ -622,7 +673,7 @@ in
Type = "oneshot";
User = user;
Group = group;
StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/invoice-ninja") "invoice-ninja";
StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/invoiceninja") "invoiceninja";
StateDirectoryMode = "0750";
LoadCredential = [
"env-secrets:${cfg.secretFile}"
@@ -645,38 +696,44 @@ in
cat "$CREDENTIALS_DIRECTORY/env-secrets" >> ${cfg.dataDir}/.env
# Link the static storage (package provided) to the runtime storage
# Necessary for cities.json and static images.
rsync -av --no-perms ${invoice-ninja}/storage-static/ ${cfg.dataDir}/storage
# Link the static vendor/bin (package provided) to the runtime vendor/bin
# Necessary for cities.json and static images.
rsync -av --no-perms ${invoice-ninja}/vendor/bin-static/ ${cfg.dataDir}/vendor/bin
rsync -av --no-perms ${invoiceninja}/storage-static/ ${cfg.dataDir}/storage
# Link the app.php in the runtime folder.
# We cannot link the cache folder only because bootstrap folder needs to be writeable.
ln -sf ${invoice-ninja}/bootstrap-static/app.php ${cfg.runtimeDir}/app.php
ln -sf ${invoiceninja}/bootstrap-static/app.php ${cfg.runtimeDir}/app.php
# Perform the first migration
[[ ! -f ${cfg.dataDir}/.initial-migration ]] && invoice-ninja-manage migrate --force && touch ${cfg.dataDir}/.initial-migration
# Run migrations on every activation, not just the first: `migrate` is idempotent
# (Laravel tracks applied migrations in the DB and only runs new ones), and gating this
# behind a marker file meant migrations shipped by a later invoiceninja version upgrade
# would never run.
invoiceninja-manage migrate --force
# Seed database with records
# Necessary for languages, currencies, countries, etc.
[[ ! -f ${cfg.dataDir}/.db-seeded ]] && invoice-ninja-manage db:seed --force && touch ${cfg.dataDir}/.db-seeded
[[ ! -f ${cfg.dataDir}/.db-seeded ]] && invoiceninja-manage db:seed --force && touch ${cfg.dataDir}/.db-seeded
# Work around an upstream bug: migration 2025_08_31_224356_additional_languages_af_ca_id.php
# runs during `migrate` (before db:seed's LanguageSeeder has populated the other 42 rows) and
# does `Cache::forever('languages', Language::all())`, permanently caching just the 3 languages
# it sees at that moment. Without this, the language list stays stuck at those 3 forever,
# since StaticServiceProvider checks this cache key before ever querying the DB again.
# Unconditional (not marker-gated) so it also self-heals installs that hit this before the fix.
invoiceninja-manage cache:forget languages
# Create Invoice Ninja admin account
[[ (! -f ${cfg.dataDir}/.admin-created) && (${
if cfg.adminAccount.createAdmin then "true" else "false"
if cfg.adminAccount.enable then "true" else "false"
} == "true") ]] \
&& invoice-ninja-manage ninja:create-account --email=${cfg.adminAccount.email} --password=$(cat $CREDENTIALS_DIRECTORY/admin-pass) \
&& invoiceninja-manage ninja:create-account --email=${cfg.adminAccount.email} --password=$(cat $CREDENTIALS_DIRECTORY/admin-pass) \
&& touch ${cfg.dataDir}/.admin-created
invoice-ninja-manage route:cache
invoice-ninja-manage view:cache
invoice-ninja-manage config:cache
invoiceninja-manage route:cache
invoiceninja-manage view:cache
invoiceninja-manage config:cache
'';
};
systemd.tmpfiles.settings."10-invoice-ninja" =
systemd.tmpfiles.settings."10-invoiceninja" =
lib.attrsets.genAttrs
[
cfg.dataDir
+104 -31
View File
@@ -1,62 +1,135 @@
{ lib
, php
, openssl
, writers
, buildNpmPackage
, npmHooks
, fetchFromGitHub
, dataDir ? "/var/lib/invoice-ninja"
, runtimeDir ? "/run/invoice-ninja"
, fetchNpmDeps
, jq
, php82
, nodejs_22
, npm-lockfile-fix
, dataDir ? "/var/lib/invoiceninja"
, runtimeDir ? "/run/invoiceninja"
}:
php.buildComposerProject (finalAttrs: {
pname = "invoice-ninja";
version = "5.11.7";
let
pname = "invoiceninja";
version = "5.13.53";
src = fetchFromGitHub {
owner = "invoiceninja";
repo = "invoiceninja";
rev = "v${finalAttrs.version}";
hash = "sha256-wfKYe31ZulXJfbY5naFipXUhP0W6KUYbaoMuiV8DTas=";
name = "${pname}";
owner = pname;
repo = pname;
rev = "v${version}";
hash = "sha256-KQMb1W2S1YrRZOgjV3NPDpQaz5BbF/xtziNFd34wvxo=";
postFetch = ''
# add missing integrity fields to lockfile
${npm-lockfile-fix}/bin/npm-lockfile-fix $out/package-lock.json
'';
};
vendorHash = "sha256-RA7IkPXz1HdqQAyB/VIbYg3BmCnlJKLxIVtODIRmZxg=";
uiVersion = "18.10.2026.1";
uiSrc = fetchFromGitHub {
name = "${pname}-ui";
owner = pname;
repo = "ui";
tag = uiVersion;
hash = "sha256-56szHmIWU9De04Orykp2RxrW0RV87JxB7Y1yvdrSJm4=";
# Patch sources for more restrictive permissions
patches = [
./disable-react-for-admin.patch
postFetch = ''
# add missing integrity fields to lockfile
${npm-lockfile-fix}/bin/npm-lockfile-fix $out/package-lock.json
'';
};
# React frontend
ui = buildNpmPackage {
pname = "${pname}-ui";
version = uiVersion;
src = uiSrc;
nodejs = nodejs_22;
npmDepsHash = "sha256-j5XocHvBfMvzNrkaVkTsck2+yPraYWrXQxAbf3EYfIk=";
preConfigure = ''
sed -i 's/VITE_IS_TEST=true/VITE_IS_TEST=false/' .env.example
cp .env.example .env
cp ${src}/vite.config.ts.react ./vite.config.js
'';
installPhase = ''
runHook preInstall
mkdir $out
cp -a * $out
runHook postInstall
'';
};
in
php82.buildComposerProject (finalAttrs: {
inherit src version;
pname = "${pname}-composer";
passthru.ui = ui;
nativeBuildInputs = [
jq
nodejs_22
npmHooks.npmConfigHook
npmHooks.npmBuildHook
];
vendorHash = "sha256-sFpfnCt8jv8z9cLoFE8utPQiGz7q/KkqUXabEhcJP3M=";
# Upstream composer.json has invalid license, webpatser/laravel-countries package is pointing
# to commit-ref, and php required in require and require-dev
composerStrictValidation = false;
CYPRESS_INSTALL_BINARY = 0;
npmDeps = fetchNpmDeps {
name = "${pname}-${version}-npm-deps";
inherit src;
hash = "sha256-ditAbluBzs5OV/fCMV5Q4w1amao6ofOlee4YXMpZ37I=";
};
preConfigure = ''
cp -r ${ui}/dist/* public/
cp public/index.html resources/views/react/index.blade.php
'';
postInstall = ''
mv "$out/share/php/${finalAttrs.pname}"/* $out
rm -R $out/bootstrap/cache
# Remove JS/CSS build artifacts
rm -rf $out/node_modules
# composer.lock and vendor/composer/installed.json record each package's "dist.url" as the
# store path of the composer-repository mirror it was installed from, which otherwise drags
# that whole (500+MB) fixed-output derivation into the runtime closure for no reason -- it's
# provenance metadata, not something Laravel's package manifest (which only reads
# name/extra) or Composer\InstalledVersions (which reads vendor/composer/installed.php, not
# this file) consult at runtime.
jq '(.packages[]?.dist.url) |= "removed"' $out/vendor/composer/installed.json > $out/vendor/composer/installed.json.tmp
mv $out/vendor/composer/installed.json.tmp $out/vendor/composer/installed.json
jq '(.packages[]?.dist.url, .["packages-dev"][]?.dist.url) |= "removed"' $out/composer.lock > $out/composer.lock.tmp
mv $out/composer.lock.tmp $out/composer.lock
rm -r $out/bootstrap/cache
rm -rf $out/public/storage
# Move static contents for the NixOS module to pick it up, if needed.
mv $out/bootstrap $out/bootstrap-static
mv $out/storage $out/storage-static
mv $out/vendor/bin $out/vendor/bin-static
# Link NixOS module files to derivation output
ln -s ${dataDir}/.env $out/.env
ln -s ${dataDir}/storage $out/
ln -s ${dataDir}/storage/app/public $out/public/storage
ln -s ${dataDir}/vendor/bin $out/vendor
ln -s ${runtimeDir} $out/bootstrap
'';
meta = {
description = "Open-source, self-hosted invoicing application";
homepage = "https://www.invoiceninja.com/";
license = with lib.licenses; {
fullName = "Elastic License 2.0";
shortName = "Elastic-2.0";
free = false;
};
platforms = lib.platforms.all;
};
})
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# Bumps invoiceninja/package.nix to the latest upstream invoiceninja and
# invoiceninja/ui versions, then rebuilds repeatedly -- using the
# "specified vs. got" hash mismatch that Nix reports for each
# fixed-output derivation -- to fill in the correct src/npmDeps/vendor
# hashes. Invoked via `just update`.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
pkg=package.nix
# Replaces the RHS (between the first "=" and the last ";") of the line
# immediately following the first line containing $anchor.
set_after() {
local file="$1" anchor="$2" newrhs="$3"
awk -v anchor="$anchor" -v newrhs="$newrhs" '
{
if (found) {
sub(/=.*;/, "= " newrhs ";")
found = 0
}
if (index($0, anchor) > 0) found = 1
print
}
' "$file" > "${file}.tmp"
mv "${file}.tmp" "$file"
}
# Replaces the RHS of the first line assigning to $field. Only the first
# match is touched -- "version" is assigned twice in package.nix (the
# top-level invoiceninja version, and buildNpmPackage's own "version"
# attribute for the ui derivation, which must stay "= uiVersion;") and the
# top-level one always comes first.
set_field() {
local file="$1" field="$2" newrhs="$3"
awk -v field="$field" -v newrhs="$newrhs" '
{
if (!done && $0 ~ "^[[:space:]]*" field "[[:space:]]*=") {
sub(/=.*;/, "= " newrhs ";")
done = 1
}
print
}
' "$file" > "${file}.tmp"
mv "${file}.tmp" "$file"
}
current_main_version=$(grep -m1 -E '^\s*version = "' "$pkg" | sed -E 's/.*"([^"]+)".*/\1/')
current_ui_version=$(grep -m1 -E '^\s*uiVersion = "' "$pkg" | sed -E 's/.*"([^"]+)".*/\1/')
echo "Current: invoiceninja=$current_main_version ui=$current_ui_version"
latest_main_version=$(curl -sf https://api.github.com/repos/invoiceninja/invoiceninja/releases/latest | jq -r '.tag_name' | sed 's/^v//')
# invoiceninja/ui has no GitHub Releases, just date-stamped tags
# (DD.MM.YYYY.N) -- paginate through all tags and pick the newest by
# calendar date, ignoring "-release" hotfix tags and other one-off tag
# names that don't fit that scheme.
ui_tags=""
page=1
while :; do
batch=$(curl -sf "https://api.github.com/repos/invoiceninja/ui/tags?per_page=100&page=${page}" | jq -r '.[].name')
[ -z "$batch" ] && break
ui_tags="${ui_tags}
${batch}"
page=$((page + 1))
done
latest_ui_version=$(printf '%s\n' "$ui_tags" \
| grep -E '^[0-9]{2}\.[0-9]{2}\.[0-9]{4}\.[0-9]+$' \
| awk -F. '{printf "%s%02d%02d%s %s\n", $3, $2, $1, $4, $0}' \
| sort \
| tail -1 \
| awk '{print $2}')
if [ -z "$latest_main_version" ] || [ -z "$latest_ui_version" ]; then
echo "error: failed to determine latest versions (main=$latest_main_version ui=$latest_ui_version)" >&2
exit 1
fi
echo "Latest: invoiceninja=$latest_main_version ui=$latest_ui_version"
main_changed=0
ui_changed=0
if [ "$latest_main_version" != "$current_main_version" ]; then
main_changed=1
set_field "$pkg" version "\"$latest_main_version\""
fi
if [ "$latest_ui_version" != "$current_ui_version" ]; then
ui_changed=1
set_field "$pkg" uiVersion "\"$latest_ui_version\""
fi
if [ "$main_changed" = 0 ] && [ "$ui_changed" = 0 ]; then
echo "Already up to date."
exit 0
fi
# Invalidate every hash that could have changed as a result of the version
# bump(s) above, so the rebuild loop below reports the real ones.
if [ "$main_changed" = 1 ]; then
set_after "$pkg" 'rev = "v${version}";' 'lib.fakeHash'
set_after "$pkg" 'inherit src;' 'lib.fakeHash'
set_field "$pkg" vendorHash 'lib.fakeHash'
fi
if [ "$ui_changed" = 1 ]; then
set_after "$pkg" 'tag = uiVersion;' 'lib.fakeHash'
set_field "$pkg" npmDepsHash 'lib.fakeHash'
fi
echo "Rebuilding to discover the correct hashes (this takes a few iterations)..."
max_iterations=10
for ((i = 1; i <= max_iterations; i++)); do
echo "--- build attempt $i/$max_iterations ---"
set +e
build_output=$(nix build ".#invoiceninja" ".#invoiceninja.ui" --no-link -L 2>&1)
status=$?
set -e
if [ "$status" -eq 0 ]; then
echo "Build succeeded."
exit 0
fi
mismatch_line=$(printf '%s\n' "$build_output" | grep -m1 "hash mismatch in fixed-output derivation" || true)
if [ -z "$mismatch_line" ]; then
echo "$build_output" | tail -80
echo "error: build failed for a reason other than a hash mismatch; see output above" >&2
exit 1
fi
drv_path=$(printf '%s\n' "$mismatch_line" | sed -E "s/.*'([^']+)'.*/\1/")
# Store paths are "<32-char hash>-<name>"; strip the hash to get the name.
name=$(basename "$drv_path" .drv | sed -E 's/^[0-9a-z]{32}-//')
got_hash=$(printf '%s\n' "$build_output" | grep -m1 -E '^\s+got: ' | awk '{print $2}')
if [ -z "$got_hash" ]; then
echo "$build_output" | tail -80
echo "error: could not parse the correct hash out of the build output above" >&2
exit 1
fi
echo "Fixing hash for '$name': $got_hash"
case "$name" in
invoiceninja)
set_after "$pkg" 'rev = "v${version}";' "\"$got_hash\""
;;
invoiceninja-ui)
set_after "$pkg" 'tag = uiVersion;' "\"$got_hash\""
;;
invoiceninja-ui-*-npm-deps)
set_field "$pkg" npmDepsHash "\"$got_hash\""
;;
invoiceninja-composer-composer-repository-*)
set_field "$pkg" vendorHash "\"$got_hash\""
;;
invoiceninja-*-npm-deps)
set_after "$pkg" 'inherit src;' "\"$got_hash\""
;;
*)
echo "error: unrecognized derivation '$name' in hash mismatch; don't know which field to update" >&2
exit 1
;;
esac
done
echo "error: exceeded $max_iterations build attempts without resolving all hash mismatches" >&2
exit 1
-26
View File
@@ -1,26 +0,0 @@
{ pkgs ? import <nixpkgs> { config.allowUnfree = true; }
, lib ? import <nixpkgs/lib>
}:
let
# Helper script to generate an APP_KEY for .env
generate-invoice-ninja-app-key = pkgs.writers.writeBashBin "generate-laravel-key" ''
echo "APP_KEY=base64:$(${pkgs.openssl}/bin/openssl rand -base64 32)"
'';
# Invoice Ninja derivation
# Add to buildInputs to test in nix-shell environment
invoice-ninja = pkgs.callPackage ./default.nix {
inherit lib;
php = pkgs.php;
openssl = pkgs.openssl;
fetchFromGitHub = pkgs.fetchFromGitHub;
};
in
pkgs.mkShell {
buildInputs = [
generate-invoice-ninja-app-key
pkgs.nixpkgs-fmt
];
}
+16
View File
@@ -0,0 +1,16 @@
{ proxyServer ? "nginx" }:
{ ... }:
{
imports = [ ../nixos-module/invoiceninja.nix ];
services.invoiceninja = {
enable = true;
proxy.server = proxyServer;
adminAccount.passwordFile = ./invoice_ninja_test_password;
secretFile = ./test.env;
settings.APP_DEBUG = true;
};
networking.firewall.enable = false;
}
+52
View File
@@ -0,0 +1,52 @@
{ proxyServer ? "nginx" }:
{ pkgs, modulesPath, ... }:
{
imports = [
(modulesPath + "/profiles/qemu-guest.nix")
# Not imported by default for a plain nixosSystem in this nixpkgs (only nixosTest nodes get it
# automatically) - needed explicitly so virtualisation.cores/memorySize below are real options.
(modulesPath + "/virtualisation/qemu-vm.nix")
(import ./common.nix { inherit proxyServer; })
];
# `nix flake check` evaluates config.system.build.toplevel directly, which asserts a real root
# filesystem and bootloader are configured. `just build-vm` builds config.system.build.vm
# instead, where the qemu-vm module overrides both of these with a tmpfs root and direct
# kernel boot (virtualisation.useBootLoader defaults to false) - so these values just need to
# satisfy the assertions and are otherwise unused.
fileSystems."/" = {
device = "/dev/disk/by-label/nixos";
fsType = "ext4";
};
boot.loader.grub.enable = false;
users.users.root = {
initialPassword = "toor";
};
services.resolved.enable = true;
# `just boot-vm` forwards the guest's port 80 to host port 8080 (QEMU_NET_OPTS in the justfile),
# but the backend builds absolute URLs (e.g. the stored company logo path) from APP_URL, which
# defaults to plain "http://localhost" with no port. The SPA's own relative URLs are fine (they
# inherit whatever port the browser is already on), but any backend-generated absolute URL then
# points at host port 80 - where nothing is listening - instead of the forwarded 8080, giving a
# silent net::ERR_CONNECTION_REFUSED for things like the logo image. Override to match the actual
# forwarded port.
services.invoiceninja.settings.APP_URL = "http://localhost:8080";
# The qemu-vm module's defaults (1 core, 1024MB) are too tight for real interactive use: MySQL +
# Redis + PHP-FPM + chromium (PDF generation, spawned per request - each is a full multi-process
# browser, not a lightweight renderer) running concurrently can exhaust 1GB fast, and this VM
# boots with panic_on_oom - so it's not a graceful slowdown, the whole VM goes down. Confirmed by
# reproducing a "logo upload hangs" report this way: at the 1024MB default, even a couple of
# concurrent PDF-generation requests triggered `redis-server invoked oom-killer` and a kernel
# panic. 2 cores / 2560MB comfortably handles 2 concurrent PDF generations alongside normal
# traffic (verified: ~1.4GB still free afterward); a deliberate flood of many concurrent PDF
# requests can still exhaust even this, since each is a full chromium instance - if that's a
# concern for your deployment, consider capping `phpfpm.settings."pm.max_children"` below its
# default of 8, or provisioning more RAM.
virtualisation.cores = 2;
virtualisation.memorySize = 2560;
}
+1
View File
@@ -0,0 +1 @@
password
+131
View File
@@ -0,0 +1,131 @@
{ nixpkgsPath, system }:
let
pkgs = import nixpkgsPath { inherit system; };
adminEmail = "admin@email.com";
adminPassword = pkgs.lib.strings.trim (builtins.readFile ./invoice_ninja_test_password);
in
pkgs.testers.nixosTest {
name = "invoiceninja";
nodes = {
nginx.imports = [ (import ./common.nix { proxyServer = "nginx"; }) ];
caddy.imports = [ (import ./common.nix { proxyServer = "caddy"; }) ];
};
testScript = ''
import json
def api_get(machine, path, token):
return json.loads(machine.succeed(f"curl -sf http://localhost{path} -H 'X-API-TOKEN: {token}'"))
def api_post(machine, path, payload, token=None):
auth_header = f"-H 'X-API-TOKEN: {token}' " if token else ""
return json.loads(machine.succeed(
f"curl -sf -X POST http://localhost{path} "
+ auth_header
+ "-H 'Content-Type: application/json' "
+ f"-d '{json.dumps(payload)}'"
))
def check_invoiceninja(machine, proxy_unit):
machine.wait_for_unit("mysql.service")
machine.wait_for_unit("redis-invoiceninja.service")
machine.wait_for_unit("phpfpm-invoiceninja.service")
machine.wait_for_unit("invoiceninja-queue-worker.service")
machine.wait_for_unit(proxy_unit)
machine.wait_for_open_port(80)
# phpfpm-invoiceninja/invoiceninja-queue-worker both Require= invoiceninja-data-setup,
# so systemd already waited for it to finish above. Confirm the one-time setup steps
# it guards actually completed, rather than just that the unit exited. (Migrations run
# unconditionally on every activation rather than being marker-gated, so there's no
# marker file to check for them here.)
with subtest("one-time setup (seed, admin account) completed"):
machine.succeed("test -f /var/lib/invoiceninja/.db-seeded")
machine.succeed("test -f /var/lib/invoiceninja/.admin-created")
with subtest("application shell is served over HTTP"):
response = machine.succeed("curl -sf http://localhost/")
assert "Invoice Ninja" in response, f"unexpected response from /: {response}"
with subtest("web UI login page is served"):
# The UI is a client-side-routed React SPA, so curl (no JS) only sees the same
# index.html shell as "/" - this confirms the proxy/Laravel resolve the /login route
# to that shell instead of 404ing, which is what a browser needs before it can
# render the actual login form.
login_page = machine.succeed("curl -sf http://localhost/login")
assert "Invoice Ninja" in login_page, f"unexpected response from /login: {login_page}"
with subtest("invoiceninja-manage wrapper can run artisan commands"):
machine.succeed("invoiceninja-manage migrate:status")
with subtest("scheduler timer is enabled and schedule:run succeeds"):
# Without this, the app's dashboard reports "cron jobs are not enabled".
machine.succeed("systemctl is-active invoiceninja-scheduler.timer")
machine.succeed("systemctl start --wait invoiceninja-scheduler.service")
with subtest("admin account can log in via the API"):
login_response = api_post(machine, "/api/v1/login", {"email": "${adminEmail}", "password": "${adminPassword}"})
assert "${adminEmail}" in json.dumps(login_response), f"login did not succeed: {login_response}"
token = login_response["data"][0]["token"]["token"]
with subtest("full language list is available, not just the af/ca/id migration's 3"):
# Regression check: migration 2025_08_31_224356_additional_languages_af_ca_id.php runs
# during `migrate`, before db:seed's LanguageSeeder has populated the other 42 rows, and
# does Cache::forever('languages', Language::all()) - permanently caching just the 3
# languages it sees at that moment. invoiceninja-data-setup works around this with
# `invoiceninja-manage cache:forget languages`; this confirms the workaround is in place.
language_count = len(api_get(machine, "/api/v1/statics", token)["languages"])
assert language_count > 40, f"expected ~45 languages, got {language_count}"
with subtest("PDF generation works (chromium/snappdf)"):
# /api/v1/preview with no entity falls back to a self-contained mock PDF (no invoice/client
# setup needed), exercising the same chromium/snappdf pipeline real invoice PDFs use.
# This fallback only triggers when the company has no invoices/invitations at all yet
# (PreviewController::blankEntity), so this must run before the client/invoice/payment
# subtest below creates one.
machine.succeed(
"curl -sf -X POST http://localhost/api/v1/preview "
+ f"-H 'X-API-TOKEN: {token}' -H 'Content-Type: application/json' "
+ "-d '{}' -o /tmp/preview.pdf"
)
magic_bytes = machine.succeed("head -c 4 /tmp/preview.pdf")
assert magic_bytes == "%PDF", f"expected a PDF, got: {magic_bytes!r}"
with subtest("a client can be created, invoiced, and pay that invoice in full"):
client = api_post(machine, "/api/v1/clients", {"name": "Test Client"}, token)["data"]
invoice = api_post(machine, "/api/v1/invoices", {
"client_id": client["id"],
"line_items": [
{"quantity": 1, "cost": 100, "product_key": "test-product", "notes": "test line item"},
],
}, token)["data"]
assert invoice["amount"] == 100, f"unexpected invoice amount: {invoice}"
# Mirrors the normal workflow of sending an invoice to a client before it's paid,
# rather than relying on the fact that draft invoices also happen to be payable.
api_get(machine, f"/api/v1/invoices/{invoice['id']}/mark_sent", token)
payment = api_post(machine, "/api/v1/payments", {
"client_id": client["id"],
"amount": invoice["amount"],
"invoices": [{"invoice_id": invoice["id"], "amount": invoice["amount"]}],
}, token)["data"]
assert payment["applied"] == invoice["amount"], f"payment not fully applied: {payment}"
paid_invoice = api_get(machine, f"/api/v1/invoices/{invoice['id']}", token)["data"]
# status_id 4 == STATUS_PAID
assert paid_invoice["status_id"] == "4", f"invoice not marked paid: {paid_invoice}"
assert paid_invoice["balance"] == 0, f"invoice balance not zero: {paid_invoice}"
nginx.start()
caddy.start()
check_invoiceninja(nginx, "nginx.service")
check_invoiceninja(caddy, "caddy.service")
'';
}
-53
View File
@@ -1,53 +0,0 @@
{ modulesPath, ... }:
{
imports = [
(modulesPath + "/profiles/qemu-guest.nix")
../invoice-ninja.nix
];
system.stateVersion = "24.05";
nixpkgs.config.allowUnfree = true;
environment.etc."msmtp-password" = {
enable = true;
user = "invoiceninja";
group = "invoiceninja";
mode = "0440";
text = ''
3t5h638t3a7y7275
'';
};
users.users.test = {
isNormalUser = true;
extraGroups = [ "wheel" ];
initialPassword = "test";
};
services.invoice-ninja = {
enable = true;
database.createLocally = true;
webserver.caddy.enable = true;
webserver.nginx.enable = false;
mail.mailFromName = "Andrew Bryant";
adminEmail = "billing@allpawcare.com";
msmtp.accounts.invoice-ninja = {
auth = true;
tls = true;
tls_starttls = false;
from = "billing@allpawcare.com";
host = "smtp.fastmail.com";
port = 465;
user = "awkawb@awkawb.cloud";
passwordeval = "cat /etc/msmtp-password";
};
secretFile = ./test-secrets.env;
};
networking.firewall.enable = false;
services.resolved.enable = true;
}
-1
View File
@@ -1 +0,0 @@
test