19 Commits
Author SHA1 Message Date
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
10 changed files with 473 additions and 83 deletions
+2
View File
@@ -0,0 +1,2 @@
/result
/nixos.qcow2
+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.
Generated
+25 -35
View File
@@ -3,15 +3,15 @@
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"owner": "edolstra",
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "edolstra",
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
@@ -24,11 +24,11 @@
]
},
"locked": {
"lastModified": 1733312601,
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
@@ -42,24 +42,17 @@
"flake-compat": [
"nix"
],
"gitignore": [
"nix"
],
"nixpkgs": [
"nix",
"nixpkgs"
],
"nixpkgs-stable": [
"nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1734279981,
"narHash": "sha256-NdaCraHPp8iYMWzdXAt5Nv6sA3MUzlCiGiR586TCwo0=",
"lastModified": 1783008725,
"narHash": "sha256-jGiy6+sxjNWXSjp25uoJuNfyH9zBK1PEDY0lVoL4ibQ=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "aa9f40c906904ebd83da78e7f328cd8aeaeae785",
"rev": "bca82caa46d5ec0f5d422c61fb1e30bc51313cbe",
"type": "github"
},
"original": {
@@ -78,11 +71,11 @@
"nixpkgs-regression": "nixpkgs-regression"
},
"locked": {
"lastModified": 1740432616,
"narHash": "sha256-2yad3RqbLhCeMK01lpSLXmeZR7J74YeBg4yNAqhzLLk=",
"lastModified": 1786478010,
"narHash": "sha256-/9wXGp+aLObxqQuOGhDOnbdV0QCRDHH8uSh/4URKVAg=",
"owner": "NixOS",
"repo": "nix",
"rev": "8384e41b7608b5ff50306d3dec6171483cf2d4cd",
"rev": "24b65e35d9d4c9d64c1bd5844a35f2a779356d63",
"type": "github"
},
"original": {
@@ -92,18 +85,15 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1734359947,
"narHash": "sha256-1Noao/H+N8nFB4Beoy8fgwrcOQLVm9o4zKW1ODaqK9E=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "48d12d5e70ee91fe8481378e540433a7303dbf6a",
"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": "release-24.11",
"repo": "nixpkgs",
"type": "github"
"type": "tarball",
"url": "https://channels.nixos.org/nixos-26.05/nixexprs.tar.xz"
}
},
"nixpkgs-23-11": {
@@ -140,16 +130,16 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1779102034,
"narHash": "sha256-vZJZjLo513IeI8hjzHFc6TDezUd4uCE2Eq4SNO3DNNg=",
"lastModified": 1786430034,
"narHash": "sha256-Vux08kA5PICwS2sViCMfwVLAHNoH8TkKAeBo25LjpMI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "687f05a9184cad4eaf905c48b63649e3a86f5433",
"rev": "70cc4559b10a6062b05ff1af17e0add065ccaed9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
+13 -3
View File
@@ -20,9 +20,15 @@
);
in
rec {
nixosConfigurations.test = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ ./tests ];
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"; }) ];
};
};
overlays.default = final: prev: { invoiceninja = final.callPackage ./package.nix { }; };
@@ -32,6 +38,10 @@
default = pkgsBySystem.${system}.invoiceninja;
});
checks = forEachSystem (system: {
invoiceninja = import ./tests/nixos-test.nix { inherit system; nixpkgsPath = nixpkgs; };
});
nixosModules = import ./nixos-module { overlays = overlayList; };
};
}
+8 -3
View File
@@ -18,10 +18,10 @@ clean:
pkill qemu
exit 0
[doc('Build virtual machine for testing')]
[doc('Build virtual machine for testing (proxy: nginx or caddy)')]
[group('main')]
build-vm: clean
nom build ".#nixosConfigurations.test.config.system.build.vm"
build-vm proxy="nginx": clean
nom build ".#nixosConfigurations.test-{{proxy}}.config.system.build.vm"
[doc('Run virtual machine in current terminal')]
[group('main')]
@@ -31,3 +31,8 @@ boot-vm:
./result/bin/run-nixos-vm
reset
[doc('Run the automated NixOS VM test')]
[group('main')]
test:
nix flake check -L
+63 -21
View File
@@ -47,8 +47,22 @@ 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
@@ -486,7 +500,6 @@ in
recommendedTlsSettings = (if (cfg.hostname == "localhost") then false else true);
recommendedGzipSettings = true;
recommendedProxySettings = true;
recommendedOptimisation = true;
clientMaxBodySize = cfg.maxUploadSize;
@@ -526,14 +539,6 @@ in
if (!-e $request_filename) {
rewrite ^(.+)$ /index.php?q= 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;
'';
}
(lib.mkIf (cfg.hostname != "localhost") {
@@ -565,18 +570,18 @@ in
cfg.proxy.caddyConfig
{
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 ${invoiceninja}/public
php_fastcgi unix/${config.services.phpfpm.pools.invoiceninja.socket}
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
}
file_server
'';
}
@@ -623,6 +628,32 @@ in
};
};
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" ];
@@ -671,13 +702,24 @@ in
# We cannot link the cache folder only because bootstrap folder needs to be writeable.
ln -sf ${invoiceninja}/bootstrap-static/app.php ${cfg.runtimeDir}/app.php
# Perform the first migration
[[ ! -f ${cfg.dataDir}/.initial-migration ]] && invoiceninja-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 ]] && 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.enable then "true" else "false"
+22 -8
View File
@@ -1,7 +1,9 @@
{ buildNpmPackage
{ lib
, buildNpmPackage
, npmHooks
, fetchFromGitHub
, fetchNpmDeps
, jq
, php82
, nodejs_22
, npm-lockfile-fix
@@ -12,13 +14,13 @@
let
pname = "invoiceninja";
version = "5.13.19";
version = "5.13.43";
src = fetchFromGitHub {
name = "${pname}";
owner = pname;
repo = pname;
rev = "v${version}";
hash = "sha256-pvZNTiGGX6OqOEqcDrDqqpLD1Ohc/TA4nIRjm2jEp74=";
hash = "sha256-F8lEyVi2EFRnkxWSDRIqsqrKmtCEUEee8lDDUZXoo0c=";
postFetch = ''
# add missing integrity fields to lockfile
@@ -26,13 +28,13 @@ let
'';
};
uiVersion = "03.05.2026.1";
uiVersion = "28.09.2026.1";
uiSrc = fetchFromGitHub {
name = "${pname}-ui";
owner = pname;
repo = "ui";
tag = uiVersion;
hash = "sha256-BTaWNHTE+9NvavUYs56DdJHmqXy36N5k/crj3rU2Npg=";
hash = "sha256-RLirh0Q5lhH2O7AEwoR4MdlELAgB00f9w6FfiBNhoho=";
postFetch = ''
# add missing integrity fields to lockfile
@@ -48,7 +50,7 @@ let
nodejs = nodejs_22;
npmDepsHash = "sha256-JA5TfXeg7iHVjQdjeU6SSD2JFSLISad8hPowaR1roQw=";
npmDepsHash = "sha256-7Boz+o4pQkPNMJREWgUMeKQa7avChqd+GpZoaXO0LqE=";
preConfigure = ''
sed -i 's/VITE_IS_TEST=true/VITE_IS_TEST=false/' .env.example
@@ -73,12 +75,13 @@ php82.buildComposerProject (finalAttrs: {
pname = "${pname}-composer";
nativeBuildInputs = [
jq
nodejs_22
npmHooks.npmConfigHook
npmHooks.npmBuildHook
];
vendorHash = "sha256-2CyWKlyCyoCt/WdY7Ta0oEXW2KIkGwhilHtzpBJ5Wnk=";
vendorHash = "sha256-zTo+unMIsv2XObjwiFpxTdUxVfZoXbMNviDrLMVMJI0=";
# Upstream composer.json has invalid license, webpatser/laravel-countries package is pointing
# to commit-ref, and php required in require and require-dev
@@ -89,7 +92,7 @@ php82.buildComposerProject (finalAttrs: {
npmDeps = fetchNpmDeps {
name = "${pname}-${version}-npm-deps";
inherit src;
hash = "sha256-WCadsQVj9eusYWJEOaEp5DIkVcbmPFGe6bmKi9NZDDg=";
hash = "sha256-uHbvhf8EWOBiyPQ9ieA4ynIUkIXWJEXZ7ZVsr4NXWtU=";
};
preConfigure = ''
@@ -103,6 +106,17 @@ php82.buildComposerProject (finalAttrs: {
# 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
+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;
}
+38 -13
View File
@@ -1,27 +1,52 @@
{ proxyServer ? "nginx" }:
{ pkgs, modulesPath, ... }:
{
imports = [
(modulesPath + "/profiles/qemu-guest.nix")
../nixos-module/invoiceninja.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; })
];
nixpkgs.config.allowUnfree = true;
# `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.invoiceninja = {
enable = true;
proxy.server = "nginx";
adminAccount.passwordFile = ./invoice_ninja_test_password;
secretFile = ./test.env;
settings.APP_DEBUG = true;
};
networking.firewall.enable = false;
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;
}
+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")
'';
}