diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0562e60 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +# Build context of server/Dockerfile: the repository root (D-132, SPEC §10.3). An allowlist, so the Android project, +# the docs, the screenshot goldens, worktrees and secrets never enter the context (it stays a few MB). +# infra/backup/Dockerfile builds from its own directory and does not read this file. +* +!server +!web +!contracts +# Inside the allowed directories: build output, local data, dependencies and anything secret. +server/target +server/data +web/node_modules +web/dist +**/*.sqlite* +**/*.db +**/*.apk +**/*.pem +**/.env +**/.env.* +**/.DS_Store diff --git a/.gitea/workflows/build-images.yaml b/.gitea/workflows/build-images.yaml index da558f2..c6b25a7 100644 --- a/.gitea/workflows/build-images.yaml +++ b/.gitea/workflows/build-images.yaml @@ -11,8 +11,9 @@ # the credentials actions/checkout stored (the job's own token), not this secret, so it needs no write:repository. # # RISK: the runner is a Raspberry Pi 3 with 1 GB RAM and the server image compiles about 232 Rust crates -# (aws-lc-sys's C code included). The build passes CARGO_BUILD_JOBS=1 to keep memory down; server/Dockerfile -# honours it (`ARG CARGO_BUILD_JOBS`, SPEC §10.3). The Dockerfile keeps the cargo registry and the target directory +# (aws-lc-sys's C code included), after an npm ci and Vite build of the web client (D-132; not yet measured on the +# Pi). The build passes CARGO_BUILD_JOBS=1 to keep memory down; server/Dockerfile honours it (`ARG CARGO_BUILD_JOBS`, +# SPEC §10.3). The Dockerfile keeps the cargo registry and the target directory # in BuildKit cache mounts, which persist in the reused builder `local-http-builder`, so later builds are # incremental. First-build duration on the Pi: unknown. # The builder's buildkitd.toml is read only when the builder is created; after changing it, remove the builder on @@ -34,6 +35,8 @@ on: push: paths: - "server/**" + - "web/**" + - "contracts/**" - "infra/backup/**" - ".gitea/workflows/**" workflow_dispatch: {} @@ -65,13 +68,16 @@ jobs: with: # Tells the action to use the local 'git' binary installed above set-safe-directory: 'true' - # Only the two build contexts: server/Dockerfile copies its own context (server/, Cargo workspace, - # migrations and the import's category table included) and infra/backup/Dockerfile copies backup.sh and - # healthcheck.sh. Cone mode also checks out the files at the root and directly in infra/. A sparse - # checkout makes the action fetch with --filter=blob:none, so only the blobs of these paths are downloaded - # (not the Android screenshot goldens). + # Only what the two builds read: server/Dockerfile builds from the repository root (D-132) and copies + # server/ (Cargo workspace, migrations, the import's category table), web/ (the web client, built by its + # Node stage) and contracts/; the root .dockerignore keeps everything else out. infra/backup/Dockerfile + # copies backup.sh and healthcheck.sh. Cone mode also checks out the files at the root (.dockerignore + # included) and directly in infra/. A sparse checkout makes the action fetch with --filter=blob:none, so + # only the blobs of these paths are downloaded (not the Android screenshot goldens). sparse-checkout: | server + web + contracts infra/backup sparse-checkout-cone-mode: true # Full commit and tag history (trees and commits only, no blobs, because of the filter above): "Determine @@ -105,7 +111,8 @@ jobs: --push - name: Build server image run: | - # Context server/: server/Dockerfile copies the Cargo workspace from its own directory. + # Context: the repository root (D-132). server/Dockerfile builds the web client first (Node stage), then + # the Rust binaries, one after the other, so npm and rustc never share the Pi's 1 GB. docker buildx build \ --platform linux/arm64 \ --provenance=false \ @@ -114,7 +121,7 @@ jobs: -t "$REGISTRY/kvitt-server:$VERSION" \ -t "$REGISTRY/kvitt-server:latest" \ --metadata-file /tmp/kvitt-server.metadata.json \ - ./server \ + . \ --push - name: Print image digests # The digests the Kvitt Pi pulls (cd ~/kvitt && docker compose images, or docker image inspect) must match. diff --git a/contracts/examples/E-34-analytics-trends/response.json b/contracts/examples/E-34-analytics-trends/response.json new file mode 100644 index 0000000..4b27f55 --- /dev/null +++ b/contracts/examples/E-34-analytics-trends/response.json @@ -0,0 +1,50 @@ +{ + "measure": "household", + "today": "2026-10-17", + "current_month": "2026-10", + "months": 3, + "household": { + "total_ore": 153000, + "average_ore": 51000, + "last_month_ore": 78000, + "last_month_vs_average_pct": 53, + "current_month_ore": 12000, + "points": [ + { "month": "2026-07", "amount_ore": 40000, "moving_average_ore": 40000 }, + { "month": "2026-08", "amount_ore": 35000, "moving_average_ore": 37500 }, + { "month": "2026-09", "amount_ore": 78000, "moving_average_ore": 51000 } + ] + }, + "categories": [ + { + "category_id": "01920000-0000-7000-8000-000000000001", + "trend": { + "total_ore": 93000, + "average_ore": 31000, + "last_month_ore": 63000, + "last_month_vs_average_pct": 103, + "current_month_ore": 12000, + "points": [ + { "month": "2026-07", "amount_ore": 30000, "moving_average_ore": 30000 }, + { "month": "2026-08", "amount_ore": 0, "moving_average_ore": 15000 }, + { "month": "2026-09", "amount_ore": 63000, "moving_average_ore": 31000 } + ] + } + }, + { + "category_id": "01920000-0000-7000-8000-000000000003", + "trend": { + "total_ore": 60000, + "average_ore": 20000, + "last_month_ore": 15000, + "last_month_vs_average_pct": -25, + "current_month_ore": 0, + "points": [ + { "month": "2026-07", "amount_ore": 10000, "moving_average_ore": 10000 }, + { "month": "2026-08", "amount_ore": 35000, "moving_average_ore": 22500 }, + { "month": "2026-09", "amount_ore": 15000, "moving_average_ore": 20000 } + ] + } + } + ] +} diff --git a/contracts/examples/E-35-analytics-forecast/response.json b/contracts/examples/E-35-analytics-forecast/response.json new file mode 100644 index 0000000..c66aae2 --- /dev/null +++ b/contracts/examples/E-35-analytics-forecast/response.json @@ -0,0 +1,31 @@ +{ + "measure": "household", + "today": "2026-10-17", + "current_month": "2026-10", + "accounts": [ + { + "account_id": "0192a003-0000-7000-8000-000000000001", + "current_month_ore": 12000, + "months": [ + { "month": "2026-11", "recurring_ore": 4900, "baseline_ore": 40000, "forecast_ore": 44900 }, + { "month": "2026-12", "recurring_ore": 0, "baseline_ore": 40000, "forecast_ore": 40000 } + ] + }, + { + "account_id": "0192a003-0000-7000-8000-000000000002", + "current_month_ore": 0, + "months": [ + { "month": "2026-11", "recurring_ore": 15000, "baseline_ore": 1000, "forecast_ore": 16000 }, + { "month": "2026-12", "recurring_ore": 15000, "baseline_ore": 1000, "forecast_ore": 16000 } + ] + }, + { + "account_id": "0192a003-0000-7000-8000-000000000003", + "current_month_ore": 0, + "months": [ + { "month": "2026-11", "recurring_ore": 0, "baseline_ore": 0, "forecast_ore": 0 }, + { "month": "2026-12", "recurring_ore": 0, "baseline_ore": 0, "forecast_ore": 0 } + ] + } + ] +} diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index 667771f..00049d1 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -1364,6 +1364,109 @@ paths: '503': $ref: '#/components/responses/Unavailable' + /analytics/trends: + get: + operationId: getAnalyticsTrends + summary: E-34 Spending trends and category averages + description: | + D-133, SPEC §14.5.7. The last `months` whole calendar months before the current one (Europe/Stockholm), for + the household and per category: each month's spend, the trailing 3-month moving average (the first months + average what exists, D-128), the total, the average per month (half-up to öre, empty months count), the last + whole month against the average in whole percent (half-up; null when the average is 0), and the current month + so far. Non-deleted expenses only; settlements never count; archived and deleted categories count. + parameters: + - name: months + in: query + required: false + description: Whole months, 1 to 24 (`invalid_request` otherwise). + schema: + type: integer + default: 6 + - name: measure + in: query + required: false + description: '`household` (full amounts, whoever paid) or `my_share` (the calling user''s shares), AC-14.4; other values are `invalid_request`.' + schema: + type: string + enum: [household, my_share] + default: household + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The trends. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/TrendsResponse' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request` (bad `months` or `measure`, with `field`).' + '401': + $ref: '#/components/responses/Unauthorized' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /analytics/forecast: + get: + operationId: getAnalyticsForecast + summary: E-35 Spending forecast per account + description: | + D-133, SPEC §14.5.7. For every non-deleted account (archived included) and each of the two calendar months + after the current one (Europe/Stockholm): `recurring_ore` = the amounts of the account's non-paused, + non-deleted recurring templates due in that month (one per template and month, within `start_period` and + `end_period`); `baseline_ore` = the account's spend over the last 3 whole months (the window of D-128's moving + average), expenses generated from a recurring template excluded, divided by 3 (half-up to öre; months without + spend count, so a new account has a lower baseline); `forecast_ore = recurring_ore + baseline_ore`. `current_month_ore` is the current month so + far, generated expenses included. `my_share` uses the shares a generation would write today. + parameters: + - name: measure + in: query + required: false + description: '`household` (full amounts, whoever paid) or `my_share` (the calling user''s shares), AC-14.4; other values are `invalid_request`.' + schema: + type: string + enum: [household, my_share] + default: household + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The forecast. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ForecastResponse' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request` (bad `months` or `measure`, with `field`).' + '401': + $ref: '#/components/responses/Unauthorized' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + components: securitySchemes: device: @@ -1630,6 +1733,10 @@ components: SplitMode: type: string enum: [default, ratio, exact, full] + AnalyticsMeasure: + type: string + description: AC-14.4. `household` sums full expense amounts, whoever paid; `my_share` the calling user's shares. + enum: [household, my_share] EntityType: type: string enum: [expense, settlement] @@ -2318,6 +2425,116 @@ components: items: $ref: '#/components/schemas/Uuid' + # E-34 and E-35 (D-133, SPEC §14.5.7) + TrendPoint: + type: object + required: [month, amount_ore, moving_average_ore] + properties: + month: + $ref: '#/components/schemas/Period' + amount_ore: + $ref: '#/components/schemas/Ore' + moving_average_ore: + $ref: '#/components/schemas/Ore' + description: Trailing over 3 months; the first points average what exists (D-128). + Trend: + type: object + required: [total_ore, average_ore, last_month_ore, last_month_vs_average_pct, current_month_ore, points] + properties: + total_ore: + $ref: '#/components/schemas/Ore' + description: Sum over the whole months. + average_ore: + $ref: '#/components/schemas/Ore' + description: '`total_ore` ÷ months, half-up to öre (empty months count).' + last_month_ore: + $ref: '#/components/schemas/Ore' + description: The last whole month (the month before the current one). + last_month_vs_average_pct: + type: [integer, 'null'] + description: '`last_month_ore` against `average_ore`, whole percent of the average, half-up; null when the average is 0.' + current_month_ore: + $ref: '#/components/schemas/Ore' + description: The current month from its first day to today (Europe/Stockholm); not one of the whole months. + points: + type: array + description: One point per whole month, oldest first, empty months as zero. + items: + $ref: '#/components/schemas/TrendPoint' + CategoryTrend: + type: object + required: [category_id, trend] + properties: + category_id: + $ref: '#/components/schemas/Uuid' + trend: + $ref: '#/components/schemas/Trend' + TrendsResponse: + type: object + required: [measure, today, current_month, months, household, categories] + properties: + measure: + $ref: '#/components/schemas/AnalyticsMeasure' + today: + $ref: '#/components/schemas/Date' + description: Today in Europe/Stockholm. + current_month: + $ref: '#/components/schemas/Period' + months: + type: integer + description: The number of whole months in every series. + household: + $ref: '#/components/schemas/Trend' + categories: + type: array + description: Every category with spending in the whole months or the current month (archived and deleted + included), by `total_ore` descending, then `category_id`. + items: + $ref: '#/components/schemas/CategoryTrend' + ForecastMonth: + type: object + required: [month, recurring_ore, baseline_ore, forecast_ore] + properties: + month: + $ref: '#/components/schemas/Period' + recurring_ore: + $ref: '#/components/schemas/Ore' + baseline_ore: + $ref: '#/components/schemas/Ore' + forecast_ore: + $ref: '#/components/schemas/Ore' + description: '`recurring_ore + baseline_ore`.' + AccountForecast: + type: object + required: [account_id, current_month_ore, months] + properties: + account_id: + $ref: '#/components/schemas/Uuid' + current_month_ore: + $ref: '#/components/schemas/Ore' + description: The current month so far, recurring-generated expenses included. + months: + type: array + description: The two months after the current one, in order. + items: + $ref: '#/components/schemas/ForecastMonth' + ForecastResponse: + type: object + required: [measure, today, current_month, accounts] + properties: + measure: + $ref: '#/components/schemas/AnalyticsMeasure' + today: + $ref: '#/components/schemas/Date' + description: Today in Europe/Stockholm. + current_month: + $ref: '#/components/schemas/Period' + accounts: + type: array + description: Every non-deleted account, archived included, by `created_at`, then `account_id`. + items: + $ref: '#/components/schemas/AccountForecast' + # ---- sync (SPEC §14.4) ---- SyncPullResponse: type: object diff --git a/infra/build-images.sh b/infra/build-images.sh index 2062496..a25e539 100755 --- a/infra/build-images.sh +++ b/infra/build-images.sh @@ -3,7 +3,8 @@ # The Mac fallback for the Gitea Actions workflow .gitea/workflows/build-images.yaml (which is the normal path). # Usage: infra/build-images.sh [tag] tag defaults to the short git SHA of HEAD; `latest` is always tagged too. # -# Images (D-104, D-120): ${KVITT_REGISTRY}/kvitt-server (context server/) and kvitt-backup (infra/backup). +# Images (D-104, D-120): ${KVITT_REGISTRY}/kvitt-server (context: the repository root, D-132; server/Dockerfile +# builds the web client too) and kvitt-backup (infra/backup). # KVITT_REGISTRY and the optional credentials come from infra/secrets/registry.env (git-ignored): # KVITT_REGISTRY=gitea.lan/jacobh2 # KVITT_REGISTRY_USER= optional @@ -39,7 +40,7 @@ registry_host="${registry%%/*}" # name|context|dockerfile images=( - "kvitt-server|server|server/Dockerfile" + "kvitt-server|.|server/Dockerfile" "kvitt-backup|infra/backup|infra/backup/Dockerfile" ) diff --git a/infra/dev/build.sh b/infra/dev/build.sh index 5d0d53f..6507fc4 100755 --- a/infra/dev/build.sh +++ b/infra/dev/build.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Builds the development images on this Mac (native linux/arm64, nothing pushed): -# kvitt-server:dev from server/Dockerfile +# kvitt-server:dev from server/Dockerfile (context: the repository root, D-132: it builds web/ too) # kvitt-backup:dev from infra/backup/Dockerfile # Usage: infra/dev/build.sh [server|backup|all] (default: all) # Refuses to run while a Gradle build holds the gradle-serial lock (CLAUDE.md: never build images during a Gradle build). @@ -20,7 +20,7 @@ backup_image="$(env_value KVITT_BACKUP_IMAGE kvitt-backup:dev)" build_server() { local dockerfile="$repo_root/server/Dockerfile" - local context="${KVITT_SERVER_BUILD_CONTEXT:-$repo_root/server}" + local context="${KVITT_SERVER_BUILD_CONTEXT:-$repo_root}" echo "Building $server_image from server/Dockerfile" docker build --platform linux/arm64 -f "$dockerfile" -t "$server_image" "$context" } diff --git a/server/.dockerignore b/server/.dockerignore deleted file mode 100644 index f44b440..0000000 --- a/server/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -target/ -**/*.sqlite* diff --git a/server/Cargo.lock b/server/Cargo.lock index 28a8e5d..b9b5650 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1838,9 +1838,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -2868,9 +2868,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] diff --git a/server/Dockerfile b/server/Dockerfile index 9fbd0b7..2dedcdd 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,9 +1,28 @@ -# Kvitt server image (SPEC §10.3). Build context: server/. Built on the owner's M4 Mac, never on the Pi: -# docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server/ +# Kvitt server image (SPEC §10.3). Build context: the repository root (D-132; the root .dockerignore lets only +# server/, web/ and contracts/ in). From the repository root: +# docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev . # linux/amd64 builds the same way (--platform linux/amd64; emulated on the Mac, slow). The Rust version is the one -# pinned in rust-toolchain.toml; the two always change together. +# pinned in rust-toolchain.toml; the two always change together. The image also carries the web client (web/, +# a Vite single-page app built with base '/app/'), which the server serves under /app/ from KVITT_WEB_DIR (/app). -# ---- stage 1: build -------------------------------------------------------------------------------------------- +# ---- stage 1: web client --------------------------------------------------------------------------------------- +# Node.js 24, the Active LTS line on 2026-10-21 (26 becomes LTS at the end of October 2026). Pinned by tag and by +# the digest of its multi-arch index. Runs on the build platform: the output is static files, the same for every +# target, so an amd64 image never emulates npm. web/ without package.json or package-lock.json fails here, by design. +FROM --platform=$BUILDPLATFORM docker.io/library/node:24.22.1-bookworm-slim@sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205 AS web +ENV CI=true \ + NPM_CONFIG_AUDIT=false \ + NPM_CONFIG_FUND=false \ + NPM_CONFIG_UPDATE_NOTIFIER=false +# The repository layout is kept (/repo/web beside /repo/contracts), so the web build may read ../contracts. +WORKDIR /repo/web +COPY web/package.json web/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci +COPY contracts/ /repo/contracts/ +COPY web/ ./ +RUN npm run build && test -f dist/index.html + +# ---- stage 2: server build ------------------------------------------------------------------------------------- FROM docker.io/library/rust:1.99.1-bookworm AS build ARG TARGETARCH # Parallel rustc jobs. Unset (the default) means cargo's own default, one per core, as on the Mac. The Gitea runner @@ -16,7 +35,11 @@ ARG CARGO_BUILD_JOBS ENV RUSTUP_TOOLCHAIN=1.99.1 \ CARGO_TERM_COLOR=never WORKDIR /src -COPY . . +COPY server/ . +# The web client passes through this stage on its way to the runtime image. That also orders the builds: BuildKit +# runs independent stages in parallel, and npm next to rustc could exhaust the 1 GB of the Pi 3 runner. A web-only +# change re-runs the cargo step below, which then finds nothing to compile (the target directory is a cache mount). +COPY --from=web /repo/web/dist /out/web # Native build for the target platform: aarch64-unknown-linux-gnu on arm64 (generic Armv8-A, no # target-cpu=native: the Pi 3 is a Cortex-A53), x86_64-unknown-linux-gnu on amd64. RUN --mount=type=cache,target=/usr/local/cargo/registry \ @@ -32,11 +55,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ && mkdir -p /out \ && cp "target/$triple/release/kvitt-server" "target/$triple/release/kvitt-import" /out/ -# ---- stage 2: runtime ------------------------------------------------------------------------------------------ +# ---- stage 3: runtime ------------------------------------------------------------------------------------------ # glibc + libgcc, no shell, no package manager. Pinned by digest (multi-arch index of :nonroot). It carries the # Debian CA bundle at /etc/ssl/certs/ca-certificates.crt, which rustls-platform-verifier reads to reach Google. FROM gcr.io/distroless/cc-debian12:nonroot@sha256:9dac0a79194e45a7da0158a9c6da57b217585af0786db3845d1f0ec1a0dd182f COPY --from=build /out/kvitt-server /out/kvitt-import /usr/local/bin/ +# The web client, served under /app/ (KVITT_WEB_DIR defaults to /app); owned by root, read-only for uid 10001. +COPY --from=build /out/web /app # Runs as uid/gid 10001 with a read-only root filesystem; /data is the only writable volume and /secrets is # mounted read-only (SPEC §8.9). Listens on 8080 inside the compose network. USER 10001:10001 diff --git a/server/README.md b/server/README.md index aa2b977..2420ba9 100644 --- a/server/README.md +++ b/server/README.md @@ -34,6 +34,7 @@ Configuration is environment variables only (SPEC §10.4, exhaustive): | `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | FCM key; missing file = push disabled (warning at start). | | `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project. | | `KVITT_TRUSTED_PROXY` | `none` | IP literal or host name allowed to set `X-Forwarded-For` (on the Pi `HA_HOST`, NPM's LAN address); `none` or a name that does not resolve means no proxy. | +| `KVITT_WEB_DIR` | `/app` | The built web client, served under `/app/` (D-132): `index.html` for app routes, `assets/*` cached for a year, the app's own CSP. Missing directory: every `/app` path is 404 `not_found` (as with `cargo run`, unless you point it at `web/dist`). | | `RUST_LOG` | `info` | tracing filter; logs are JSON lines on stdout. | The default data directory `/data` is the container volume. When running outside Docker, set `KVITT_DATA_DIR` @@ -141,7 +142,8 @@ database was never fetched it prints a message and skips the check; it never fai protocol and token rules, error shapes against `contracts/examples/errors/`, wire types against every example in `contracts/examples/`. -* `crates/kvitt-server/tests/core_api/`: E-2 to E-30, E-33, generation, rate limiting, pictures, CLI. +* `crates/kvitt-server/tests/core_api/`: E-2 to E-30, E-33 to E-35 (analytics, D-133), generation, rate limiting, + pictures, CLI, the web client under `/app/` (D-132). * `crates/kvitt-server/tests/sync_api/`: every rule of SPEC §14.4 (pull, push steps 1 to 12, rejection reasons, §9.10 conflicts, 14-day offline scenario, the push examples), audit writer, `rotate-epoch`, end to end push → pull, `ConnectInfo` rate-limit buckets over real TCP. @@ -161,11 +163,14 @@ target itself); the sync, push and scheduler binaries also include `tests/common ## Docker image ```sh -docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server/ +# from the repository root: the build context is the root (D-132), filtered by the root .dockerignore +docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev . ``` Build on the Mac, never on the Pi (`infra/dev/build.sh` does this for the dev stack). Stage 1 is -`rust:-bookworm`, building both binaries in release mode with `--locked`; stage 2 is -`gcr.io/distroless/cc-debian12:nonroot` pinned by digest, binaries in `/usr/local/bin/`, user 10001, entrypoint -`kvitt-server`, command `serve`. `--platform linux/amd64` also works (emulated on the Mac). The container needs only +`node:24.22.1-bookworm-slim` (pinned by digest, on the build platform), running `npm ci && npm run build` in `web/`; +stage 2 is `rust:-bookworm`, building both binaries in release mode with `--locked` (it copies the +web build through, so the two never run at the same time); stage 3 is `gcr.io/distroless/cc-debian12:nonroot` +pinned by digest, binaries in `/usr/local/bin/`, the web client in `/app`, user 10001, entrypoint `kvitt-server`, +command `serve`. `--platform linux/amd64` also works (emulated on the Mac). The container needs only `/data` writable and runs with a read-only root filesystem. diff --git a/server/crates/kvitt-domain/src/analytics.rs b/server/crates/kvitt-domain/src/analytics.rs new file mode 100644 index 0000000..35c6113 --- /dev/null +++ b/server/crates/kvitt-domain/src/analytics.rs @@ -0,0 +1,148 @@ +//! Server-side analytics rules (D-133, SPEC §14.5.7): averages, the trailing moving average, the percent difference +//! of the last whole month against the average, and whether a recurring template occurs in a month. +//! +//! The rounding and window rules are the ones S-12 uses on the phone (D-128, `AnalyticsRules` in +//! `:feature:analytics`): division rounds half away from zero, the moving average trails over +//! [`MOVING_AVERAGE_WINDOW`] months and its first points average what exists, and the percent is taken against the +//! rounded average. Pure: no clock, no time zone; the caller picks the months. + +use crate::recurring::{Period, RecurrenceError, next_candidate_period}; + +/// The trailing moving average covers the month and the two before it (D-128); the forecast baseline averages the +/// same number of whole months (D-133). +pub const MOVING_AVERAGE_WINDOW: usize = 3; + +/// `numerator / denominator` rounded half away from zero (`3 / 2` is 2, `-3 / 2` is -2); 0 when `denominator` is 0. +pub fn div_half_up(numerator: i64, denominator: i64) -> i64 { + if denominator == 0 { + return 0; + } + let quotient = numerator / denominator; + let remainder = numerator % denominator; + if remainder.unsigned_abs().saturating_mul(2) >= denominator.unsigned_abs() { + let sign = if (numerator < 0) == (denominator < 0) { + 1 + } else { + -1 + }; + quotient + sign + } else { + quotient + } +} + +/// The average per month of `values` (one per month, empty months as 0), half-up to öre; 0 for no months. +pub fn average(values: &[i64]) -> i64 { + let count = i64::try_from(values.len()).unwrap_or(i64::MAX); + div_half_up(values.iter().sum(), count) +} + +/// The trailing moving average of `values` over `window` points, half-up to öre: each point with the `window − 1` +/// before it; the first points average what exists (1, then 2 points). A `window` of 0 counts as 1. +pub fn moving_average(values: &[i64], window: usize) -> Vec { + let window = window.max(1); + (0..values.len()) + .map(|i| average(&values[(i + 1).saturating_sub(window)..=i])) + .collect() +} + +/// The last month against the average, in whole percent of the (rounded) average, half-up: positive above, negative +/// below. `None` when the rounded average is 0 (no spending, or less than one öre per month). +pub fn percent_vs_average(last: i64, average: i64) -> Option { + if average == 0 { + return None; + } + let difference = last - average; + let percent = div_half_up(difference.abs().saturating_mul(100), average.abs()); + Some(if difference < 0 { -percent } else { percent }) +} + +/// Whether a template with this schedule creates an expense for `period`: `start_period ≤ period`, `period ≤ +/// end_period` (if set) and `period > last_generated_period` (if set) (SPEC §9.5). Paused and deleted templates are +/// the caller's to leave out. +pub fn occurs_in_period( + start_period: &str, + end_period: Option<&str>, + last_generated_period: Option<&str>, + period: Period, +) -> Result { + let end: Option = end_period.map(str::parse).transpose()?; + let first = next_candidate_period(start_period, end_period, last_generated_period)?; + Ok(match first { + Some(first) => first <= period && end.is_none_or(|end| period <= end), + None => false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn period(text: &str) -> Period { + text.parse().unwrap() + } + + #[test] + fn half_up_division_matches_the_phone() { + assert_eq!(div_half_up(3, 2), 2); + assert_eq!(div_half_up(4, 3), 1); + assert_eq!(div_half_up(5, 3), 2); + assert_eq!(div_half_up(-3, 2), -2); + assert_eq!(div_half_up(-4, 3), -1); + assert_eq!(div_half_up(0, 7), 0); + assert_eq!(div_half_up(7, 0), 0); + } + + #[test] + fn average_counts_empty_months() { + assert_eq!(average(&[100, 200, 0]), 100); + assert_eq!(average(&[1, 2]), 2); + assert_eq!(average(&[50_000, 0, 50_000]), 33_333); + assert_eq!(average(&[]), 0); + assert_eq!(average(&[0, 0, 0]), 0); + } + + #[test] + fn moving_average_trails_three_and_shortens_at_the_start() { + assert_eq!(MOVING_AVERAGE_WINDOW, 3); + assert_eq!( + moving_average(&[300, 0, 600, 900, 300], MOVING_AVERAGE_WINDOW), + vec![300, 150, 300, 500, 600] + ); + assert_eq!(moving_average(&[1, 2], 3), vec![1, 2]); + assert_eq!(moving_average(&[7], 3), vec![7]); + assert_eq!(moving_average(&[], 3), Vec::::new()); + assert_eq!(moving_average(&[4, 8], 0), vec![4, 8]); + } + + #[test] + fn percent_against_the_rounded_average() { + assert_eq!(percent_vs_average(150, 100), Some(50)); + assert_eq!(percent_vs_average(50, 100), Some(-50)); + assert_eq!(percent_vs_average(100, 100), Some(0)); + // 1/3 above: 33.33 % rounds to 33; 2/3 above: 66.67 % rounds to 67; exactly half rounds away from zero. + assert_eq!(percent_vs_average(400, 300), Some(33)); + assert_eq!(percent_vs_average(500, 300), Some(67)); + assert_eq!(percent_vs_average(1_005, 1_000), Some(1)); + assert_eq!(percent_vs_average(995, 1_000), Some(-1)); + assert_eq!(percent_vs_average(0, 100), Some(-100)); + assert_eq!(percent_vs_average(5, 0), None); + assert_eq!(percent_vs_average(0, 0), None); + } + + #[test] + fn occurrence_in_a_period() { + let p = period("2026-11"); + assert!(occurs_in_period("2026-01", None, Some("2026-10"), p).unwrap()); + assert!(occurs_in_period("2026-11", None, None, p).unwrap()); + assert!(!occurs_in_period("2026-12", None, None, p).unwrap()); + assert!(occurs_in_period("2026-01", Some("2026-11"), None, p).unwrap()); + assert!(!occurs_in_period("2026-01", Some("2026-10"), None, p).unwrap()); + assert!(!occurs_in_period("2026-01", None, Some("2026-11"), p).unwrap()); + assert!(occurs_in_period("2026-01", None, Some("2026-10"), period("2027-01")).unwrap()); + assert_eq!( + occurs_in_period("2026-13", None, None, p), + Err(RecurrenceError::PeriodInvalid) + ); + } +} diff --git a/server/crates/kvitt-domain/src/lib.rs b/server/crates/kvitt-domain/src/lib.rs index 757b999..7fcde9c 100644 --- a/server/crates/kvitt-domain/src/lib.rs +++ b/server/crates/kvitt-domain/src/lib.rs @@ -1,4 +1,5 @@ -//! Kvitt domain rules as pure functions (SPEC §9.1, §9.3, §9.4, §9.5 `next_occurrence_date`, §14.5). +//! Kvitt domain rules as pure functions (SPEC §9.1, §9.3, §9.4, §9.5 `next_occurrence_date`, §14.5, and the +//! server-side analytics rules of §14.5.7). //! //! No I/O, no clock, no randomness, no locale. Ids are plain strings (the test vectors use short ids like `"a"`); //! money is `i64` öre and basis points are `u32`. The Kotlin `:domain` module implements the same functions and both @@ -8,6 +9,7 @@ #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] mod allocation; +mod analytics; mod balance; mod recurring; mod settlement; @@ -17,6 +19,10 @@ pub use allocation::{ AccountPosition, Allocation, AllocationError, AllocationProposal, over_settled_accounts, propose_allocation, }; +pub use analytics::{ + MOVING_AVERAGE_WINDOW, average, div_half_up, moving_average, occurs_in_period, + percent_vs_average, +}; pub use balance::{BalanceExpense, BalanceSettlement, Balances, compute_balances}; pub use recurring::{ Period, RecurrenceError, RecurrenceSchedule, next_candidate_period, next_occurrence_date, diff --git a/server/crates/kvitt-domain/src/recurring.rs b/server/crates/kvitt-domain/src/recurring.rs index 8f242d2..6101664 100644 --- a/server/crates/kvitt-domain/src/recurring.rs +++ b/server/crates/kvitt-domain/src/recurring.rs @@ -60,6 +60,15 @@ impl Period { } } + /// The month before, or `None` before 0001-01. + pub fn previous(self) -> Option { + if self.month == 1 { + Self::new(self.year.checked_sub(1)?, 12) + } else { + Self::new(self.year, self.month - 1) + } + } + /// Number of days in this month (Gregorian leap years). pub fn days(self) -> u8 { match self.month { @@ -184,6 +193,15 @@ mod tests { next_occurrence_date(s).unwrap() } + #[test] + fn previous_month() { + let p = |s: &str| s.parse::().unwrap(); + assert_eq!(p("2026-03").previous(), Some(p("2026-02"))); + assert_eq!(p("2026-01").previous(), Some(p("2025-12"))); + assert_eq!(p("0001-01").previous(), None); + assert_eq!(p("2025-12").next(), Some(p("2026-01"))); + } + #[test] fn never_generated_starts_at_start_period() { assert_eq!( diff --git a/server/crates/kvitt-server/src/api/analytics.rs b/server/crates/kvitt-server/src/api/analytics.rs new file mode 100644 index 0000000..b0fb19b --- /dev/null +++ b/server/crates/kvitt-server/src/api/analytics.rs @@ -0,0 +1,526 @@ +//! E-34 spending trends and E-35 forecast (D-133, SPEC §14.5.7): read-only, computed per request from the +//! non-deleted expenses. Settlements never count as spend (AC-14.6); archived accounts and categories count +//! (AC-14.9); `measure` is `household` or `my_share` (AC-14.4). +//! +//! Months are calendar months of the stored `expense_date` (a plain `YYYY-MM-DD`, no time zone), and "today" is the +//! calendar date in Europe/Stockholm. SQLite sums per group and month (`substr(expense_date, 1, 7)`), so no request +//! walks the expenses in Rust; the averages and percentages then come from `kvitt_domain` (§14.5.7). + +use std::collections::{BTreeMap, HashMap}; + +use axum::extract::{RawQuery, State}; +use chrono::{Datelike, NaiveDate}; +use kvitt_domain::{MOVING_AVERAGE_WINDOW, Period}; +use sqlx::SqliteConnection; +use uuid::Uuid; + +use crate::AppState; +use crate::api::{JsonBody, recurring, rows}; +use crate::auth::AuthDevice; +use crate::error::AppError; +use crate::openapi::responses::BadRequest; +use crate::time::stockholm_today; +use crate::wire::{ + AccountForecast, AnalyticsMeasure, AnalyticsMeasureParamSchema, AnalyticsMonthsSchema, + CategoryTrend, ForecastMonth, ForecastResponse, Trend, TrendPoint, TrendsResponse, +}; + +/// Default of E-34's `months`. +pub const DEFAULT_MONTHS: i64 = 6; +/// Largest E-34 `months`. +pub const MAX_MONTHS: i64 = 24; +/// Months E-35 forecasts: the two after the current one. +pub const FORECAST_MONTHS: usize = 2; +/// Whole months the E-35 baseline averages over: the window of D-128's moving average, shared (D-133). +pub const BASELINE_MONTHS: i64 = MOVING_AVERAGE_WINDOW as i64; + +/// The query parameters of E-34 and E-35. Unknown keys are ignored (E-35 has no `months`); the last occurrence of a +/// key wins. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AnalyticsQuery { + measure: AnalyticsMeasure, + months: i64, +} + +fn parse_query(query: Option<&str>, with_months: bool) -> Result { + let mut parsed = AnalyticsQuery { + measure: AnalyticsMeasure::Household, + months: DEFAULT_MONTHS, + }; + for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + match key { + "measure" => { + parsed.measure = match value { + "household" => AnalyticsMeasure::Household, + "my_share" => AnalyticsMeasure::MyShare, + _ => { + return Err(AppError::invalid_request( + "measure must be household or my_share", + ) + .with_field("measure")); + } + }; + } + "months" if with_months => { + parsed.months = value + .parse::() + .ok() + .filter(|m| (1..=MAX_MONTHS).contains(m)) + .ok_or_else(|| { + AppError::invalid_request(format!( + "months must be an integer from 1 to {MAX_MONTHS}" + )) + .with_field("months") + })?; + } + _ => {} + } + } + Ok(parsed) +} + +/// "Today" in Europe/Stockholm and its month. +struct Calendar { + today: NaiveDate, + current: Period, +} + +impl Calendar { + fn of(state: &AppState) -> Result { + let today = stockholm_today(state.clock.now()); + let current = Period::new( + u16::try_from(today.year()).map_err(AppError::internal)?, + u8::try_from(today.month()).map_err(AppError::internal)?, + ) + .ok_or_else(|| AppError::internal("today is outside 0001 to 9999"))?; + Ok(Self { today, current }) + } + + fn today_text(&self) -> String { + self.today.format("%Y-%m-%d").to_string() + } + + /// The `count` whole months before the current one, oldest first. + fn whole_months(&self, count: i64) -> Result, AppError> { + let mut months = Vec::new(); + let mut month = self.current; + for _ in 0..count { + month = month + .previous() + .ok_or_else(|| AppError::internal("month before 0001-01"))?; + months.push(month); + } + months.reverse(); + Ok(months) + } + + /// The `count` months after the current one, in order. + fn next_months(&self, count: usize) -> Result, AppError> { + let mut months = Vec::new(); + let mut month = self.current; + for _ in 0..count { + month = month + .next() + .ok_or_else(|| AppError::internal("month after 9999-12"))?; + months.push(month); + } + Ok(months) + } +} + +/// The first day of `month`, `YYYY-MM-01`. +fn first_day(month: Period) -> String { + format!("{month}-01") +} + +/// One row of [`monthly_sums`]: the group id, the month, whether the expenses were generated from a recurring +/// template, and their sum for the measure. +type MonthlySum = (String, String, bool, i64); + +/// What [`monthly_sums`] groups by. +#[derive(Debug, Clone, Copy)] +enum GroupBy { + Category, + Account, +} + +/// The statement of [`monthly_sums`] for one group column, value and join (constants only). +macro_rules! monthly_sums_sql { + ($group:literal, $value:literal, $join:literal) => { + concat!( + "SELECT ", + $group, + ", substr(e.expense_date, 1, 7) AS month, ", + "e.recurring_template_id IS NOT NULL AS generated, SUM(", + $value, + ") FROM expenses e ", + $join, + " WHERE e.deleted_at IS NULL AND e.expense_date >= ? AND e.expense_date <= ? ", + "GROUP BY ", + $group, + ", month, generated" + ) + }; +} + +/// Sums of the non-deleted expenses dated `from` to `to` (inclusive, `YYYY-MM-DD`), per group, month and generated +/// flag. `user` set: that user's shares (`my_share`, the join binds the user before the dates); `None`: the full +/// amounts (`household`). +async fn monthly_sums( + conn: &mut SqliteConnection, + group_by: GroupBy, + user: Option, + from: &str, + to: &str, +) -> Result, AppError> { + let sql: &'static str = match (group_by, user.is_some()) { + (GroupBy::Category, false) => monthly_sums_sql!("e.category_id", "e.amount_ore", ""), + (GroupBy::Account, false) => monthly_sums_sql!("e.account_id", "e.amount_ore", ""), + (GroupBy::Category, true) => monthly_sums_sql!( + "e.category_id", + "s.share_ore", + "JOIN expense_splits s ON s.expense_id = e.id AND s.user_id = ?" + ), + (GroupBy::Account, true) => monthly_sums_sql!( + "e.account_id", + "s.share_ore", + "JOIN expense_splits s ON s.expense_id = e.id AND s.user_id = ?" + ), + }; + let mut query = sqlx::query_as::<_, MonthlySum>(sql); + if let Some(user) = user { + query = query.bind(user.to_string()); + } + Ok(query.bind(from).bind(to).fetch_all(&mut *conn).await?) +} + +/// The user whose shares `measure` adds up, if any. +fn measured_user(measure: AnalyticsMeasure, device: &AuthDevice) -> Option { + match measure { + AnalyticsMeasure::Household => None, + AnalyticsMeasure::MyShare => Some(device.user_id), + } +} + +/// A [`Trend`] from the amounts of the whole months (oldest first) and the current month so far. +fn trend(months: &[Period], amounts: &[i64], current_month_ore: i64) -> Trend { + let average_ore = kvitt_domain::average(amounts); + let last_month_ore = amounts.last().copied().unwrap_or(0); + let moving = kvitt_domain::moving_average(amounts, MOVING_AVERAGE_WINDOW); + Trend { + total_ore: amounts.iter().sum(), + average_ore, + last_month_ore, + last_month_vs_average_pct: kvitt_domain::percent_vs_average(last_month_ore, average_ore), + current_month_ore, + points: months + .iter() + .zip(amounts) + .zip(moving) + .map(|((month, amount), moving_average_ore)| TrendPoint { + month: month.to_string(), + amount_ore: *amount, + moving_average_ore, + }) + .collect(), + } +} + +/// Amounts per whole month and the current month's, for one group. +#[derive(Debug, Clone, Default)] +struct GroupAmounts { + whole: Vec, + current: i64, +} + +#[utoipa::path( + get, + path = "/analytics/trends", + operation_id = "getAnalyticsTrends", + summary = "E-34 Spending trends and category averages", + description = "D-133, SPEC §14.5.7. The last `months` whole calendar months before the current one (Europe/Stockholm), \ +for the household and per category: each month's spend, the trailing 3-month moving average (the first months average \ +what exists, D-128), the total, the average per month (half-up to öre, empty months count), the last whole month \ +against the average in whole percent (half-up; null when the average is 0), and the current month so far. Non-deleted \ +expenses only; settlements never count; archived and deleted categories count.", + params( + ("months" = Option, Query, description = "Whole months, 1 to 24 (`invalid_request` otherwise)."), + ("measure" = Option, Query, description = "`household` or `my_share` (`invalid_request` otherwise), AC-14.4."), + ), + responses( + (status = 200, description = "The trends.", body = TrendsResponse, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + ) +)] +pub async fn get_trends( + device: AuthDevice, + State(state): State, + RawQuery(query): RawQuery, +) -> Result, AppError> { + let query = parse_query(query.as_deref(), true)?; + let calendar = Calendar::of(&state)?; + let months = calendar.whole_months(query.months)?; + let index: HashMap = months + .iter() + .enumerate() + .map(|(i, m)| (m.to_string(), i)) + .collect(); + let current_key = calendar.current.to_string(); + let from = months + .first() + .map_or_else(|| first_day(calendar.current), |m| first_day(*m)); + + let mut conn = state.db.acquire().await?; + let sums = monthly_sums( + &mut conn, + GroupBy::Category, + measured_user(query.measure, &device), + &from, + &calendar.today_text(), + ) + .await?; + drop(conn); + + let empty = || GroupAmounts { + whole: vec![0; months.len()], + current: 0, + }; + let mut household = empty(); + let mut categories: BTreeMap = BTreeMap::new(); + for (category_id, month, _generated, amount) in sums { + let category = categories.entry(category_id).or_insert_with(empty); + if let Some(&i) = index.get(&month) { + category.whole[i] += amount; + household.whole[i] += amount; + } else if month == current_key { + category.current += amount; + household.current += amount; + } + } + + let mut categories: Vec = categories + .into_iter() + .filter(|(_, a)| a.current != 0 || a.whole.iter().any(|v| *v != 0)) + .map(|(id, a)| { + Ok(CategoryTrend { + category_id: rows::uuid(&id)?, + trend: trend(&months, &a.whole, a.current), + }) + }) + .collect::>()?; + categories.sort_by(|a, b| { + b.trend + .total_ore + .cmp(&a.trend.total_ore) + .then_with(|| a.category_id.cmp(&b.category_id)) + }); + + Ok(JsonBody(TrendsResponse { + measure: query.measure, + today: calendar.today_text(), + current_month: current_key, + months: query.months, + household: trend(&months, &household.whole, household.current), + categories, + })) +} + +/// Per account: the recurring amount due in each forecast month, for the measure. +async fn recurring_per_account( + conn: &mut SqliteConnection, + user: Option, + forecast: &[Period], +) -> Result>, AppError> { + let ids: Vec = sqlx::query_scalar( + "SELECT id FROM recurring_templates WHERE deleted_at IS NULL AND paused = 0 ORDER BY id", + ) + .fetch_all(&mut *conn) + .await?; + let users = match user { + Some(_) => rows::household(conn).await?, + None => Vec::new(), + }; + let mut due: HashMap> = HashMap::new(); + for id in ids { + let Some(template) = rows::template(conn, rows::uuid(&id)?).await? else { + continue; + }; + let amount = match user { + None => template.amount_ore, + // The shares a generation would write now (mode default: the account's current default ratio). + Some(user) => recurring::resolve_splits(conn, &template, &users) + .await? + .iter() + .find(|s| s.user_id == user) + .map_or(0, |s| s.share_ore), + }; + let amounts = due + .entry(template.account_id) + .or_insert_with(|| vec![0; forecast.len()]); + for (slot, month) in amounts.iter_mut().zip(forecast) { + // One expense per template and month (SPEC §9.5). + if kvitt_domain::occurs_in_period( + &template.start_period, + template.end_period.as_deref(), + template.last_generated_period.as_deref(), + *month, + ) + .map_err(AppError::internal)? + { + *slot += amount; + } + } + } + Ok(due) +} + +#[utoipa::path( + get, + path = "/analytics/forecast", + operation_id = "getAnalyticsForecast", + summary = "E-35 Spending forecast per account", + description = "D-133, SPEC §14.5.7. For every non-deleted account (archived included) and each of the two calendar \ +months after the current one (Europe/Stockholm): `recurring_ore` = the amounts of the account's non-paused, \ +non-deleted recurring templates due in that month (one per template and month, within `start_period` and \ +`end_period`); `baseline_ore` = the account's spend over the last 3 whole months (the window of D-128's \ +moving average), expenses generated from a recurring template excluded, divided by 3 (half-up to öre; months without spend count, so a new account has a lower \ +baseline); `forecast_ore = recurring_ore + baseline_ore`. `current_month_ore` is the current month so far, \ +generated expenses included. `my_share` uses the shares a generation would write today.", + params( + ("measure" = Option, Query, description = "`household` or `my_share` (`invalid_request` otherwise), AC-14.4."), + ), + responses( + (status = 200, description = "The forecast.", body = ForecastResponse, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + ) +)] +pub async fn get_forecast( + device: AuthDevice, + State(state): State, + RawQuery(query): RawQuery, +) -> Result, AppError> { + let query = parse_query(query.as_deref(), false)?; + let user = measured_user(query.measure, &device); + let calendar = Calendar::of(&state)?; + let baseline_months = calendar.whole_months(BASELINE_MONTHS)?; + let forecast_months = calendar.next_months(FORECAST_MONTHS)?; + let current_key = calendar.current.to_string(); + let baseline_keys: Vec = baseline_months.iter().map(ToString::to_string).collect(); + let from = baseline_months + .first() + .map_or_else(|| first_day(calendar.current), |m| first_day(*m)); + + let mut conn = state.db.acquire().await?; + let accounts: Vec = sqlx::query_scalar( + "SELECT id FROM accounts WHERE deleted_at IS NULL ORDER BY created_at, id", + ) + .fetch_all(&mut *conn) + .await?; + let sums = monthly_sums( + &mut conn, + GroupBy::Account, + user, + &from, + &calendar.today_text(), + ) + .await?; + let recurring = recurring_per_account(&mut conn, user, &forecast_months).await?; + drop(conn); + + // Per account: (baseline total over the whole months, current month so far). + let mut spend: HashMap = HashMap::new(); + for (account_id, month, generated, amount) in sums { + let entry = spend.entry(account_id).or_default(); + if month == current_key { + entry.1 += amount; + } else if !generated && baseline_keys.contains(&month) { + entry.0 += amount; + } + } + + let accounts = accounts + .into_iter() + .map(|id| { + let account_id = rows::uuid(&id)?; + let (baseline_total, current_month_ore) = spend.get(&id).copied().unwrap_or((0, 0)); + let baseline_ore = kvitt_domain::div_half_up(baseline_total, BASELINE_MONTHS); + let due = recurring.get(&account_id); + Ok(AccountForecast { + account_id, + current_month_ore, + months: forecast_months + .iter() + .enumerate() + .map(|(i, month)| { + let recurring_ore = due.and_then(|d| d.get(i)).copied().unwrap_or(0); + ForecastMonth { + month: month.to_string(), + recurring_ore, + baseline_ore, + forecast_ore: recurring_ore + baseline_ore, + } + }) + .collect(), + }) + }) + .collect::>()?; + + Ok(JsonBody(ForecastResponse { + measure: query.measure, + today: calendar.today_text(), + current_month: current_key, + accounts, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn query_defaults_and_values() { + let q = parse_query(None, true).unwrap(); + assert_eq!(q.measure, AnalyticsMeasure::Household); + assert_eq!(q.months, 6); + let q = parse_query(Some("months=24&measure=my_share&other=1"), true).unwrap(); + assert_eq!(q.measure, AnalyticsMeasure::MyShare); + assert_eq!(q.months, 24); + assert_eq!(parse_query(Some("months=1"), true).unwrap().months, 1); + // E-35 ignores `months`. + assert_eq!(parse_query(Some("months=99"), false).unwrap().months, 6); + } + + #[test] + fn query_errors_name_the_parameter() { + for bad in ["months=0", "months=25", "months=x", "months=", "months=-1"] { + let e = parse_query(Some(bad), true).unwrap_err(); + assert_eq!(e.field.as_deref(), Some("months"), "{bad}"); + } + for bad in ["measure=mine", "measure=", "measure=Household"] { + let e = parse_query(Some(bad), true).unwrap_err(); + assert_eq!(e.field.as_deref(), Some("measure"), "{bad}"); + } + } + + #[test] + fn trend_fields() { + let months: Vec = ["2026-07", "2026-08", "2026-09"] + .iter() + .map(|m| m.parse().unwrap()) + .collect(); + let t = trend(&months, &[300, 0, 600], 50); + assert_eq!(t.total_ore, 900); + assert_eq!(t.average_ore, 300); + assert_eq!(t.last_month_ore, 600); + assert_eq!(t.last_month_vs_average_pct, Some(100)); + assert_eq!(t.current_month_ore, 50); + let moving: Vec = t.points.iter().map(|p| p.moving_average_ore).collect(); + assert_eq!(moving, vec![300, 150, 300]); + assert_eq!(t.points[0].month, "2026-07"); + assert_eq!( + trend(&months, &[0, 0, 0], 0).last_month_vs_average_pct, + None + ); + } +} diff --git a/server/crates/kvitt-server/src/api/mod.rs b/server/crates/kvitt-server/src/api/mod.rs index 11715d7..978cfaf 100644 --- a/server/crates/kvitt-server/src/api/mod.rs +++ b/server/crates/kvitt-server/src/api/mod.rs @@ -3,6 +3,7 @@ //! row access and input rules the handlers, the sync protocol and the importer share. pub mod accounts; +pub mod analytics; pub mod apk; pub mod categories; pub mod devices; @@ -18,6 +19,7 @@ pub mod rows; pub mod setup; pub mod sync; pub mod validate; +pub mod web; use axum::extract::rejection::{JsonRejection, PathRejection}; use axum::extract::{FromRequest, FromRequestParts, Request}; diff --git a/server/crates/kvitt-server/src/api/rate_limit.rs b/server/crates/kvitt-server/src/api/rate_limit.rs index e3ca2b7..826f293 100644 --- a/server/crates/kvitt-server/src/api/rate_limit.rs +++ b/server/crates/kvitt-server/src/api/rate_limit.rs @@ -1,6 +1,8 @@ //! Rate limiting (SPEC §8.6, AC-2.12, §8.5), as a tower layer that `router.rs` attaches to every route. //! -//! * Unauthenticated endpoints (E-1 to E-5 and unknown paths): at most 60 requests per minute per client IP. +//! * Unauthenticated endpoints (E-1 to E-5, the web client's static files under `/app/` and unknown paths): at most +//! 60 requests per minute per client IP. A page load of the web client is its `index.html` and a handful of hashed +//! assets, which the browser then caches for a year, so 60 per minute leaves room for reloads (D-132). //! * APK downloads (E-6, `/apk/files/{file_name}`, D-119): at most 10 requests per minute per client IP, in a bucket //! of their own that does not count against the 60 (security review L-3: each answer can be a 10 MB file). //! * Authenticated endpoints: at most 600 requests per minute per device, keyed by the hash of the bearer token @@ -62,14 +64,18 @@ pub const PROXY_RESOLVE_INTERVAL: std::time::Duration = std::time::Duration::fro /// How often expired windows are pruned from memory. const PRUNE_INTERVAL: Duration = Duration::minutes(10); -/// Route templates of the unauthenticated endpoints (E-1 to E-6). -const PUBLIC_ROUTES: [&str; 6] = [ +/// Route templates of the unauthenticated endpoints (E-1 to E-6) and of the web client (D-132), which count against +/// the client IP even when a request carries a bearer token. +const PUBLIC_ROUTES: [&str; 9] = [ "/health", "/setup/status", "/invites/accept", "/i/{code}", "/apk/latest", "/apk/files/{file_name}", + "/app", + "/app/", + "/app/{*path}", ]; const ACCEPT_ROUTE: &str = "/invites/accept"; /// Route template of E-6, limited by [`APK_DOWNLOAD_LIMIT`] instead of [`UNAUTHENTICATED_LIMIT`]. diff --git a/server/crates/kvitt-server/src/api/recurring.rs b/server/crates/kvitt-server/src/api/recurring.rs index 254886d..6c5f70b 100644 --- a/server/crates/kvitt-server/src/api/recurring.rs +++ b/server/crates/kvitt-server/src/api/recurring.rs @@ -58,8 +58,8 @@ pub struct GeneratedExpense { pub period: String, } -/// The expense splits of a generated expense. -async fn resolve_splits( +/// The expense splits of a generated expense (also E-35's `my_share` forecast of a template). +pub(crate) async fn resolve_splits( conn: &mut SqliteConnection, template: &RecurringTemplate, users: &[User], diff --git a/server/crates/kvitt-server/src/api/web.rs b/server/crates/kvitt-server/src/api/web.rs new file mode 100644 index 0000000..22e2eff --- /dev/null +++ b/server/crates/kvitt-server/src/api/web.rs @@ -0,0 +1,245 @@ +//! The web client under `/app/` (D-132, SPEC §10.4 `KVITT_WEB_DIR`): static files of the single-page app built into +//! the image at `/app`. Not an API route: no protocol check, no token, not in `contracts/openapi.yaml`. +//! +//! * `GET /app` redirects to `/app/` (308); `/app/` and `/app/index.html` serve `index.html`. +//! * `GET /app/` serves the file when `` is safe (below) and names a file in the directory. A missing or +//! unsafe path falls back to `index.html` (the app routes in the browser), except under `assets/`, where a missing +//! file is 404 `not_found`: a hashed asset that is gone must fail, not load HTML. +//! * Caching: `assets/*` (content-hashed names) `public, max-age=31536000, immutable`; `index.html` `no-store` (it names +//! the current assets); other files `no-cache`. +//! * Headers: the app's own `Content-Security-Policy` ([`APP_CSP`]; the strict policy NPM sets elsewhere would block the +//! app's scripts and API calls), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: +//! no-referrer`. +//! * Without the directory (an image built without the app, the development `cargo run`) every `/app` path is 404 +//! `not_found`, as before the web client existed. +//! +//! Path safety: a path is at most [`MAX_PATH_LEN`] bytes and [`MAX_SEGMENTS`] segments, and each segment is 1 to 255 +//! characters of `A-Z a-z 0-9 . _ -` that does not start with `.`. So no `..`, no hidden file, no absolute path, no +//! back-slash or percent trick reaches the file system: an unsafe path is never joined to the directory. + +use std::path::Path as FsPath; + +use axum::extract::{Path, State}; +use axum::http::{HeaderValue, header}; +use axum::response::{IntoResponse, Redirect, Response}; + +use crate::AppState; +use crate::error::AppError; + +/// The prefix the app is served under; Vite builds it with `base: '/app/'`. +pub const APP_PREFIX: &str = "/app/"; +/// The `Content-Security-Policy` of every response under `/app/`: exactly the policy NPM sets for `/app/` (D-132, +/// `$kvitt_csp` in `infra/npm/kvitt.conf`), so the development stack behaves as the Pi does. +pub const APP_CSP: &str = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; \ +img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; \ +frame-ancestors 'none'"; +/// `Cache-Control` of `assets/*`. +pub const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable"; +/// `Cache-Control` of `index.html`. +pub const CACHE_INDEX: &str = "no-store"; +/// `Cache-Control` of the other files (favicon, manifest). +pub const CACHE_OTHER: &str = "no-cache"; +/// Longest path under `/app/` that is looked up, in bytes. +pub const MAX_PATH_LEN: usize = 512; +/// Most segments of a path under `/app/` that is looked up. +pub const MAX_SEGMENTS: usize = 8; + +const INDEX: &str = "index.html"; +const ASSETS: &str = "assets/"; + +/// Whether `path` (relative, without the `/app/` prefix) may be joined to the web directory. +pub fn is_safe_path(path: &str) -> bool { + path.len() <= MAX_PATH_LEN + && path.split('/').count() <= MAX_SEGMENTS + && path.split('/').all(|segment| { + (1..=255).contains(&segment.len()) + && !segment.starts_with('.') + && segment + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + }) +} + +/// The media type of a served file, from its extension. +pub fn content_type(path: &str) -> &'static str { + let extension = path + .rsplit_once('.') + .map(|(_, e)| e.to_ascii_lowercase()) + .unwrap_or_default(); + match extension.as_str() { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" | "map" => "application/json; charset=utf-8", + "webmanifest" => "application/manifest+json", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "ico" => "image/x-icon", + "woff2" => "font/woff2", + "woff" => "font/woff", + "txt" => "text/plain; charset=utf-8", + "wasm" => "application/wasm", + _ => "application/octet-stream", + } +} + +fn cache_control(path: &str) -> &'static str { + if path.starts_with(ASSETS) { + CACHE_IMMUTABLE + } else if path == INDEX { + CACHE_INDEX + } else { + CACHE_OTHER + } +} + +/// The bytes of `dir/path` when it is a regular file; `None` when it is missing or not a file. +async fn read_file(dir: &FsPath, path: &str) -> Result>, AppError> { + let full = dir.join(path); + match tokio::fs::metadata(&full).await { + Ok(meta) if meta.is_file() => {} + Ok(_) => return Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(AppError::internal(format!("cannot stat web file: {e}"))), + } + match tokio::fs::read(&full).await { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(AppError::internal(format!("cannot read web file: {e}"))), + } +} + +async fn web_dir_exists(state: &AppState) -> bool { + tokio::fs::metadata(&state.config.web_dir) + .await + .is_ok_and(|m| m.is_dir()) +} + +fn file_response(path: &str, bytes: Vec) -> Response { + let mut response = bytes.into_response(); + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static(content_type(path)), + ); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(cache_control(path)), + ); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(APP_CSP), + ); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + response +} + +/// Serves `path` (relative to the web directory; empty for the index) with the fallback rules of the module docs. +async fn serve(state: &AppState, path: &str) -> Result { + if !web_dir_exists(state).await { + return Err(AppError::not_found()); + } + let dir = &state.config.web_dir; + if !path.is_empty() + && is_safe_path(path) + && let Some(bytes) = read_file(dir, path).await? + { + return Ok(file_response(path, bytes)); + } + if path.starts_with(ASSETS) { + return Err(AppError::not_found()); + } + match read_file(dir, INDEX).await? { + Some(bytes) => Ok(file_response(INDEX, bytes)), + None => Err(AppError::not_found()), + } +} + +/// `GET /app`: 308 to `/app/` when the app is installed. +pub async fn get_app_root(State(state): State) -> Result { + if web_dir_exists(&state).await { + Ok(Redirect::permanent(APP_PREFIX).into_response()) + } else { + Err(AppError::not_found()) + } +} + +/// `GET /app/`: the index. +pub async fn get_app_index(State(state): State) -> Result { + serve(&state, "").await +} + +/// `GET /app/{*path}`. A path that does not percent-decode to UTF-8 is never looked up: it gets the index. +pub async fn get_app_file( + State(state): State, + path: Result, axum::extract::rejection::PathRejection>, +) -> Result { + match path { + Ok(Path(path)) => serve(&state, &path).await, + Err(_) => serve(&state, "").await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_paths() { + for ok in [ + "index.html", + "assets/index-B2x_9a.js", + "favicon.svg", + "expenses/0192a003-0000-7000-8000-000000000001", + "a/b/c/d/e/f/g/h", + ] { + assert!(is_safe_path(ok), "{ok}"); + } + for bad in [ + "", + "..", + "../kvitt.sqlite", + "assets/../../etc/passwd", + ".env", + "assets/.hidden", + "/etc/passwd", + "a//b", + "a/", + "a\\b", + "a%2e%2e", + "caf\u{e9}", + "a b", + "a/b/c/d/e/f/g/h/i", + "\u{0}", + ] { + assert!(!is_safe_path(bad), "{bad:?}"); + } + assert!(!is_safe_path(&"a".repeat(256))); + assert!(!is_safe_path(&format!("{}/x", "a/".repeat(300)))); + } + + #[test] + fn content_types_and_caching() { + assert_eq!(content_type("index.html"), "text/html; charset=utf-8"); + assert_eq!( + content_type("assets/a-1.JS"), + "text/javascript; charset=utf-8" + ); + assert_eq!(content_type("assets/a-1.css"), "text/css; charset=utf-8"); + assert_eq!(content_type("favicon.svg"), "image/svg+xml"); + assert_eq!(content_type("LICENSE"), "application/octet-stream"); + assert_eq!(cache_control("assets/a-1.js"), CACHE_IMMUTABLE); + assert_eq!(cache_control("index.html"), CACHE_INDEX); + assert_eq!(cache_control("favicon.svg"), CACHE_OTHER); + } +} diff --git a/server/crates/kvitt-server/src/config.rs b/server/crates/kvitt-server/src/config.rs index bdb265b..c24daeb 100644 --- a/server/crates/kvitt-server/src/config.rs +++ b/server/crates/kvitt-server/src/config.rs @@ -19,6 +19,8 @@ pub const DEFAULT_FCM_PROJECT_ID: &str = "kvitt-1337"; pub const DEFAULT_TRUSTED_PROXY: &str = NO_TRUSTED_PROXY; /// The `KVITT_TRUSTED_PROXY` value that means no proxy (compared ignoring case and surrounding white space). pub const NO_TRUSTED_PROXY: &str = "none"; +/// Default of `KVITT_WEB_DIR`: the web client's files in the image (D-132). +pub const DEFAULT_WEB_DIR: &str = "/app"; /// Default of `RUST_LOG`. pub const DEFAULT_LOG_FILTER: &str = "info"; @@ -45,6 +47,9 @@ pub struct Config { /// `KVITT_TRUSTED_PROXY`: host name whose resolved address may set `X-Forwarded-For`. Resolved lazily by the /// rate limiter; [`NO_TRUSTED_PROXY`] (the default) and an unresolvable name mean "no proxy". pub trusted_proxy: String, + /// `KVITT_WEB_DIR`: the built web client, served under `/app/` (`api::web`); a missing directory means no web + /// client (every `/app` path is 404). + pub web_dir: PathBuf, } impl Config { @@ -79,6 +84,7 @@ impl Config { fcm_credentials: PathBuf::from(get("KVITT_FCM_CREDENTIALS", DEFAULT_FCM_CREDENTIALS)), fcm_project_id: get("KVITT_FCM_PROJECT_ID", DEFAULT_FCM_PROJECT_ID), trusted_proxy: get("KVITT_TRUSTED_PROXY", DEFAULT_TRUSTED_PROXY), + web_dir: PathBuf::from(get("KVITT_WEB_DIR", DEFAULT_WEB_DIR)), }) } @@ -121,6 +127,7 @@ mod tests { ); assert_eq!(config.fcm_project_id, "kvitt-1337"); assert_eq!(config.trusted_proxy, "none"); + assert_eq!(config.web_dir, PathBuf::from("/app")); assert_eq!(config.database_path(), PathBuf::from("/data/kvitt.sqlite")); } @@ -131,6 +138,7 @@ mod tests { "KVITT_BIND" => Some("127.0.0.1:9000".into()), "KVITT_PUBLIC_URL" => Some("http://192.168.1.10:8080/".into()), "KVITT_TRUSTED_PROXY" => Some("192.168.1.20".into()), + "KVITT_WEB_DIR" => Some("/srv/kvitt-web".into()), _ => None, }) .unwrap(); @@ -141,6 +149,7 @@ mod tests { assert_eq!(config.bind, "127.0.0.1:9000".parse().unwrap()); assert_eq!(config.public_url, "http://192.168.1.10:8080"); assert_eq!(config.trusted_proxy, "192.168.1.20"); + assert_eq!(config.web_dir, PathBuf::from("/srv/kvitt-web")); } #[test] diff --git a/server/crates/kvitt-server/src/openapi.rs b/server/crates/kvitt-server/src/openapi.rs index a639db2..d5aae4b 100644 --- a/server/crates/kvitt-server/src/openapi.rs +++ b/server/crates/kvitt-server/src/openapi.rs @@ -85,6 +85,8 @@ const SERVER_VERSION_HEADER: &str = "X-Kvitt-Server-Version"; api::sync::sync_pull, api::sync::sync_push, api::proposal::propose_settlement, + api::analytics::get_trends, + api::analytics::get_forecast, ), components( schemas( @@ -96,6 +98,8 @@ const SERVER_VERSION_HEADER: &str = "X-Kvitt-Server-Version"; wire::IntSchema, wire::PictureSizeSchema, wire::SyncLimitSchema, + wire::AnalyticsMeasureParamSchema, + wire::AnalyticsMonthsSchema, ), responses( responses::BadRequest, diff --git a/server/crates/kvitt-server/src/router.rs b/server/crates/kvitt-server/src/router.rs index f106363..cca427f 100644 --- a/server/crates/kvitt-server/src/router.rs +++ b/server/crates/kvitt-server/src/router.rs @@ -10,8 +10,8 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{get, post, put}; use crate::api::{ - accounts, apk, categories, devices, health, invites, landing, me, pictures, proposal, - rate_limit, recurring, setup, sync, + accounts, analytics, apk, categories, devices, health, invites, landing, me, pictures, + proposal, rate_limit, recurring, setup, sync, web, }; use crate::error::{AppError, LoggedErrorCode}; use crate::{AppState, SERVER_VERSION, auth}; @@ -25,7 +25,7 @@ pub const SERVER_VERSION_HEADER: &str = "x-kvitt-server-version"; /// Builds the application. pub fn router(state: AppState) -> Router { - // E-1 to E-6: no authentication, no protocol check. + // E-1 to E-6 and the web client under /app/: no authentication, no protocol check. let public = Router::new() .route("/health", get(health::get_health)) .route("/setup/status", get(setup::get_setup_status)) @@ -34,9 +34,13 @@ pub fn router(state: AppState) -> Router { // `/i/` with an empty code: the landing page's HTML 404, not the JSON fallback. .route("/i/", get(empty_invite_code)) .route("/apk/latest", get(apk::get_apk_latest)) - .route("/apk/files/{file_name}", get(apk::get_apk_file)); + .route("/apk/files/{file_name}", get(apk::get_apk_file)) + // The web client (D-132): static files, not API routes (`api::web`). + .route("/app", get(web::get_app_root)) + .route("/app/", get(web::get_app_index)) + .route("/app/{*path}", get(web::get_app_file)); - // E-7 to E-33: device token (the handlers' `AuthDevice` / `AdminDevice` extractors) and `X-Kvitt-Protocol: 1`. + // E-7 to E-35: device token (the handlers' `AuthDevice` / `AdminDevice` extractors) and `X-Kvitt-Protocol: 1`. let authenticated = Router::new() .route("/me", get(me::get_me).patch(me::patch_me)) .route( @@ -97,6 +101,8 @@ pub fn router(state: AppState) -> Router { ) .route("/sync", get(sync::sync_pull).post(sync::sync_push)) .route("/settlements/proposal", post(proposal::propose_settlement)) + .route("/analytics/trends", get(analytics::get_trends)) + .route("/analytics/forecast", get(analytics::get_forecast)) .route_layer(middleware::from_fn(auth::require_protocol)); Router::new() diff --git a/server/crates/kvitt-server/src/wire/analytics.rs b/server/crates/kvitt-server/src/wire/analytics.rs new file mode 100644 index 0000000..1408995 --- /dev/null +++ b/server/crates/kvitt-server/src/wire/analytics.rs @@ -0,0 +1,117 @@ +//! Analytics responses (D-133, SPEC §14.3.4 E-34 and E-35, rules in §14.5.7): read-only, computed per request. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::{DateSchema, OreSchema, PeriodSchema, UuidSchema, int, nullable_int}; + +/// What a sum adds up (AC-14.4): `household` the full expense amounts, whoever paid; `my_share` the calling user's +/// shares. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum AnalyticsMeasure { + Household, + MyShare, +} + +/// One whole month of a [`Trend`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct TrendPoint { + #[schema(value_type = PeriodSchema)] + pub month: String, + #[schema(value_type = OreSchema)] + pub amount_ore: i64, + /// Trailing over 3 months, the first points over what exists (D-128). + #[schema(value_type = OreSchema)] + pub moving_average_ore: i64, +} + +/// Spending over the last whole months, for the household or one category. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Trend { + /// Sum over the whole months. + #[schema(value_type = OreSchema)] + pub total_ore: i64, + /// `total_ore` ÷ months, half-up to öre (empty months count). + #[schema(value_type = OreSchema)] + pub average_ore: i64, + /// The last whole month (the month before the current one). + #[schema(value_type = OreSchema)] + pub last_month_ore: i64, + /// `last_month_ore` against `average_ore` in whole percent of the average, half-up; null when the average is 0. + #[schema(schema_with = nullable_int, required = true)] + pub last_month_vs_average_pct: Option, + /// The current month from its first day to today (Europe/Stockholm), not part of the whole months. + #[schema(value_type = OreSchema)] + pub current_month_ore: i64, + /// One point per whole month, oldest first, empty months as zero. + pub points: Vec, +} + +/// A category's [`Trend`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct CategoryTrend { + #[schema(value_type = UuidSchema)] + pub category_id: Uuid, + pub trend: Trend, +} + +/// E-34 response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct TrendsResponse { + pub measure: AnalyticsMeasure, + /// Today in Europe/Stockholm. + #[schema(value_type = DateSchema)] + pub today: String, + #[schema(value_type = PeriodSchema)] + pub current_month: String, + /// The number of whole months in every series. + #[schema(schema_with = int)] + pub months: i64, + pub household: Trend, + /// Every category with spending in the whole months or the current month, archived and deleted categories + /// included; by `total_ore` descending, then `category_id`. + pub categories: Vec, +} + +/// One forecast month of an account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct ForecastMonth { + #[schema(value_type = PeriodSchema)] + pub month: String, + /// The account's active recurring templates due in the month. + #[schema(value_type = OreSchema)] + pub recurring_ore: i64, + /// Average per month of the last 3 whole months, recurring-generated expenses excluded. + #[schema(value_type = OreSchema)] + pub baseline_ore: i64, + /// `recurring_ore + baseline_ore`. + #[schema(value_type = OreSchema)] + pub forecast_ore: i64, +} + +/// One account's forecast. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct AccountForecast { + #[schema(value_type = UuidSchema)] + pub account_id: Uuid, + /// The current month from its first day to today (Europe/Stockholm), recurring-generated expenses included. + #[schema(value_type = OreSchema)] + pub current_month_ore: i64, + /// The two months after the current one, in order. + pub months: Vec, +} + +/// E-35 response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct ForecastResponse { + pub measure: AnalyticsMeasure, + /// Today in Europe/Stockholm. + #[schema(value_type = DateSchema)] + pub today: String, + #[schema(value_type = PeriodSchema)] + pub current_month: String, + /// Every non-deleted account, archived included, by `created_at`, then `account_id`. + pub accounts: Vec, +} diff --git a/server/crates/kvitt-server/src/wire/mod.rs b/server/crates/kvitt-server/src/wire/mod.rs index 7170b93..4cb7fe8 100644 --- a/server/crates/kvitt-server/src/wire/mod.rs +++ b/server/crates/kvitt-server/src/wire/mod.rs @@ -6,6 +6,7 @@ //! Request bodies ignore unknown fields (serde's default). pub mod account; +pub mod analytics; pub mod audit; pub mod category; pub mod device; @@ -17,6 +18,10 @@ pub mod sync; pub mod user; pub use account::{Account, CreateAccountRequest, PatchAccountRequest, Share}; +pub use analytics::{ + AccountForecast, AnalyticsMeasure, CategoryTrend, ForecastMonth, ForecastResponse, Trend, + TrendPoint, TrendsResponse, +}; pub use audit::{AuditAction, AuditChange, AuditEntry, AuditSource, EntityType}; pub use category::{Category, CreateCategoryRequest, DefaultCategoryKey, PatchCategoryRequest}; pub use device::{Device, DeviceList, PushTokenRequest}; @@ -191,6 +196,25 @@ primitive_schema!( .default(Some(serde_json::json!(512))) ); +primitive_schema!( + /// The `measure` query parameter of E-34 and E-35 (AC-14.4): default `household`. + AnalyticsMeasureParamSchema, + "AnalyticsMeasureParam", + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::String)) + .enum_values(Some(["household", "my_share"])) + .default(Some(serde_json::json!("household"))) +); + +primitive_schema!( + /// The `months` query parameter of E-34: 1 to 24, default 6. + AnalyticsMonthsSchema, + "AnalyticsMonths", + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::Integer)) + .default(Some(serde_json::json!(6))) +); + primitive_schema!( /// The `limit` query parameter of E-31: default 500. SyncLimitSchema, diff --git a/server/crates/kvitt-server/tests/common/mod.rs b/server/crates/kvitt-server/tests/common/mod.rs index 1258252..33da8b6 100644 --- a/server/crates/kvitt-server/tests/common/mod.rs +++ b/server/crates/kvitt-server/tests/common/mod.rs @@ -156,6 +156,8 @@ impl TestApp { "KVITT_PUBLIC_URL" => Some(PUBLIC_URL.to_owned()), // No proxy is trusted unless a test says so. "KVITT_TRUSTED_PROXY" => Some("127.0.0.9".to_owned()), + // The web client's directory; absent unless a test creates it (`/app` on the host never matters). + "KVITT_WEB_DIR" => Some(format!("{path}/web")), _ => None, }) .unwrap(); diff --git a/server/crates/kvitt-server/tests/core_api/analytics.rs b/server/crates/kvitt-server/tests/core_api/analytics.rs new file mode 100644 index 0000000..4a5df8f --- /dev/null +++ b/server/crates/kvitt-server/tests/core_api/analytics.rs @@ -0,0 +1,550 @@ +//! E-34 trends and E-35 forecast (D-133, SPEC §14.5.7) over a seeded database. Today is 2026-10-17 (the harness +//! clock), so the whole months of E-34 `months=3` and of the forecast baseline are 2026-07 to 2026-09 and the +//! forecast months are 2026-11 and 2026-12. + +use axum::http::Method; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::common::*; + +const ACCOUNT_3: &str = "0192a003-0000-7000-8000-000000000003"; +const T_RENT: &str = "0192a004-0000-7000-8000-000000000001"; +const T_STREAMING: &str = "0192a004-0000-7000-8000-000000000002"; +const T_PAUSED: &str = "0192a004-0000-7000-8000-000000000003"; +const T_DELETED: &str = "0192a004-0000-7000-8000-000000000004"; + +/// How a seeded expense differs from a plain one. +#[derive(Clone, Copy)] +enum Kind<'a> { + Plain, + /// Generated from (template id, period). + Generated(&'a str, &'a str), + Deleted, +} + +/// An expense with a date; `shares` are (admin öre, member öre). +async fn expense( + app: &TestApp, + account: &str, + category: &str, + amount: i64, + date: &str, + shares: (i64, i64), + kind: Kind<'_>, +) { + let generated = match kind { + Kind::Generated(template, period) => Some((template, period)), + Kind::Plain | Kind::Deleted => None, + }; + let deleted = matches!(kind, Kind::Deleted); + let id = Uuid::now_v7().to_string(); + let version = app.next_version().await; + sqlx::query( + "INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \ + payer_user_id, split_mode, recurring_template_id, recurring_period, created_by_user_id, updated_by_user_id, \ + created_at, updated_at, deleted_at, server_received_at, server_version) \ + VALUES (?, ?, ?, ?, 'x', '', ?, ?, 'exact', ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(account) + .bind(category) + .bind(amount) + .bind(date) + .bind(ADMIN_ID) + .bind(generated.map(|g| g.0)) + .bind(generated.map(|g| g.1)) + .bind(ADMIN_ID) + .bind(ADMIN_ID) + .bind(NOW) + .bind(NOW) + .bind(deleted.then_some(NOW)) + .bind(NOW) + .bind(version) + .execute(&app.state.db) + .await + .unwrap(); + for (user, ore) in [(ADMIN_ID, shares.0), (MEMBER_ID, shares.1)] { + sqlx::query( + "INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES (?, ?, ?, NULL)", + ) + .bind(&id) + .bind(user) + .bind(ore) + .execute(&app.state.db) + .await + .unwrap(); + } +} + +/// A template paid by the admin. `ratio` is the admin's bp for mode ratio; `None` is mode default. +struct Template<'a> { + id: &'a str, + account: &'a str, + amount: i64, + day: i64, + start: &'a str, + end: Option<&'a str>, + last: Option<&'a str>, + paused: bool, + deleted: bool, + ratio: Option, +} + +async fn template(app: &TestApp, t: Template<'_>) { + let version = app.next_version().await; + sqlx::query( + "INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, notes, payer_user_id, \ + split_mode, day_of_month, start_period, end_period, paused, last_generated_period, created_by_user_id, \ + updated_by_user_id, created_at, updated_at, deleted_at, server_version) \ + VALUES (?, ?, ?, ?, 'x', '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(t.id) + .bind(t.account) + .bind(HOME) + .bind(t.amount) + .bind(ADMIN_ID) + .bind(if t.ratio.is_some() { "ratio" } else { "default" }) + .bind(t.day) + .bind(t.start) + .bind(t.end) + .bind(i64::from(t.paused)) + .bind(t.last) + .bind(ADMIN_ID) + .bind(ADMIN_ID) + .bind(NOW) + .bind(NOW) + .bind(t.deleted.then_some(NOW)) + .bind(version) + .execute(&app.state.db) + .await + .unwrap(); + if let Some(bp) = t.ratio { + for (user, share) in [(ADMIN_ID, bp), (MEMBER_ID, 10_000 - bp)] { + sqlx::query( + "INSERT INTO recurring_template_splits (template_id, user_id, share_bp, share_ore) \ + VALUES (?, ?, ?, NULL)", + ) + .bind(t.id) + .bind(user) + .bind(share) + .execute(&app.state.db) + .await + .unwrap(); + } + } +} + +/// The scenario of the E-34 and E-35 examples. +async fn example_state() -> TestApp { + let app = TestApp::household().await; + app.insert_account(ACCOUNT_1, "Vardag", "2026-01-01T00:00:00.000Z", 5000) + .await; + app.insert_account(ACCOUNT_2, "Hyra", "2026-02-01T00:00:00.000Z", 6000) + .await; + app.insert_account(ACCOUNT_3, "Nytt", "2026-10-10T00:00:00.000Z", 5000) + .await; + // An archived account still counts (AC-14.9). + sqlx::query("UPDATE accounts SET archived_at = ? WHERE id = ?") + .bind(NOW) + .bind(ACCOUNT_2) + .execute(&app.state.db) + .await + .unwrap(); + + let a1 = |category, amount, date, shares| (ACCOUNT_1, category, amount, date, shares); + for (account, category, amount, date, shares) in [ + a1(GROCERIES, 30_000, "2026-07-10", (15_000, 15_000)), + a1(GROCERIES, 60_000, "2026-09-03", (30_000, 30_000)), + a1(HOME, 10_000, "2026-07-20", (5_000, 5_000)), + a1(HOME, 20_000, "2026-08-15", (10_000, 10_000)), + // The current month so far. + a1(GROCERIES, 12_000, "2026-10-05", (6_000, 6_000)), + // After today: not "so far". + a1(GROCERIES, 5_000, "2026-10-25", (2_500, 2_500)), + // Before the whole months. + a1(HOME, 99_999, "2026-06-30", (50_000, 49_999)), + (ACCOUNT_2, GROCERIES, 3_000, "2026-09-12", (1_800, 1_200)), + ] { + expense(&app, account, category, amount, date, shares, Kind::Plain).await; + } + // Deleted expenses never count. + expense( + &app, + ACCOUNT_1, + HOME, + 77_777, + "2026-08-01", + (0, 77_777), + Kind::Deleted, + ) + .await; + + // Rent on day 31 (clamped to 30 September), generated for August and September: spend in E-34, not in the + // forecast baseline. + template( + &app, + Template { + id: T_RENT, + account: ACCOUNT_2, + amount: 15_000, + day: 31, + start: "2026-08", + end: None, + last: Some("2026-09"), + paused: false, + deleted: false, + ratio: None, + }, + ) + .await; + for (date, period) in [("2026-08-31", "2026-08"), ("2026-09-30", "2026-09")] { + expense( + &app, + ACCOUNT_2, + HOME, + 15_000, + date, + (9_000, 6_000), + Kind::Generated(T_RENT, period), + ) + .await; + } + // November only (end_period). + template( + &app, + Template { + id: T_STREAMING, + account: ACCOUNT_1, + amount: 4_900, + day: 5, + start: "2026-11", + end: Some("2026-11"), + last: None, + paused: false, + deleted: false, + ratio: Some(7_000), + }, + ) + .await; + // Paused and deleted templates are not forecast. + for (id, paused, deleted) in [(T_PAUSED, true, false), (T_DELETED, false, true)] { + template( + &app, + Template { + id, + account: ACCOUNT_1, + amount: 100_000, + day: 1, + start: "2026-01", + end: None, + last: Some("2026-10"), + paused, + deleted, + ratio: None, + }, + ) + .await; + } + // Settlements are never spend (AC-14.6). + app.insert_settlement(MEMBER_ID, &[(ACCOUNT_1, 50_000)], false) + .await; + app +} + +async fn get(app: &TestApp, path: &str, tok: &str) -> Value { + let reply = app.call(Method::GET, path, tok, None).await; + assert_eq!(reply.status, 200, "{path}: {}", reply.text()); + reply.json +} + +#[tokio::test] +async fn e34_matches_the_example() { + let app = example_state().await; + let body = get(&app, "/analytics/trends?months=3", &admin_token()).await; + assert_eq!(body, example("E-34-analytics-trends/response.json")); + // The measure written out is the same. + let explicit = get( + &app, + "/analytics/trends?measure=household&months=3", + &member_token(), + ) + .await; + assert_eq!(explicit, body); +} + +#[tokio::test] +async fn e34_my_share_sums_the_callers_shares() { + let app = example_state().await; + let admin = get( + &app, + "/analytics/trends?months=3&measure=my_share", + &admin_token(), + ) + .await; + assert_eq!(admin["measure"], "my_share"); + let amounts = |trend: &Value| -> Vec { + trend["points"] + .as_array() + .unwrap() + .iter() + .map(|p| p["amount_ore"].as_i64().unwrap()) + .collect() + }; + assert_eq!(amounts(&admin["household"]), vec![20_000, 19_000, 40_800]); + assert_eq!(admin["household"]["current_month_ore"], 6_000); + assert_eq!(admin["categories"][0]["category_id"], GROCERIES); + assert_eq!( + amounts(&admin["categories"][0]["trend"]), + vec![15_000, 0, 31_800] + ); + let member = get( + &app, + "/analytics/trends?months=3&measure=my_share", + &member_token(), + ) + .await; + assert_eq!(amounts(&member["household"]), vec![20_000, 16_000, 37_200]); + // The two shares add up to the household. + let household = get(&app, "/analytics/trends?months=3", &member_token()).await; + assert_eq!( + admin["household"]["total_ore"].as_i64().unwrap() + + member["household"]["total_ore"].as_i64().unwrap(), + household["household"]["total_ore"].as_i64().unwrap() + ); +} + +#[tokio::test] +async fn e34_default_months_short_windows_and_zero_average() { + let app = TestApp::household().await; + app.insert_account(ACCOUNT_1, "Vardag", "2026-01-01T00:00:00.000Z", 5000) + .await; + // Nothing at all: every series is zeros, no category, no percent. + let body = get(&app, "/analytics/trends", &admin_token()).await; + assert_eq!(body["months"], 6); + let points = body["household"]["points"].as_array().unwrap(); + assert_eq!(points.len(), 6); + assert_eq!(points[0]["month"], "2026-04"); + assert_eq!(points[5]["month"], "2026-09"); + assert_eq!(body["household"]["average_ore"], 0); + assert_eq!(body["household"]["last_month_vs_average_pct"], Value::Null); + assert_eq!(body["categories"], json!([])); + + // Only the current month: listed, all whole months zero, no percent. + expense( + &app, + ACCOUNT_1, + HOME, + 500, + "2026-10-01", + (250, 250), + Kind::Plain, + ) + .await; + // 2 öre over two months is an average of 1 öre; 2 öre in the last month is +100 %. + expense( + &app, + ACCOUNT_1, + GROCERIES, + 2, + "2026-09-30", + (1, 1), + Kind::Plain, + ) + .await; + let body = get(&app, "/analytics/trends?months=2", &admin_token()).await; + let home = body["categories"] + .as_array() + .unwrap() + .iter() + .find(|c| c["category_id"] == HOME) + .unwrap(); + assert_eq!(home["trend"]["total_ore"], 0); + assert_eq!(home["trend"]["current_month_ore"], 500); + assert_eq!(home["trend"]["last_month_vs_average_pct"], Value::Null); + assert_eq!(body["categories"][0]["category_id"], GROCERIES); + let groceries = &body["categories"][0]["trend"]; + assert_eq!(groceries["average_ore"], 1); + assert_eq!(groceries["last_month_vs_average_pct"], 100); + // Moving average over the first two months: 0, then (0 + 2) / 2. + assert_eq!( + groceries["points"], + json!([ + {"month": "2026-08", "amount_ore": 0, "moving_average_ore": 0}, + {"month": "2026-09", "amount_ore": 2, "moving_average_ore": 1} + ]) + ); + // One whole month: the moving average is the month itself. + let body = get(&app, "/analytics/trends?months=1", &admin_token()).await; + assert_eq!( + body["household"]["points"], + json!([{"month": "2026-09", "amount_ore": 2, "moving_average_ore": 2}]) + ); + assert_eq!(body["household"]["last_month_vs_average_pct"], 0); +} + +#[tokio::test] +async fn months_follow_the_stockholm_calendar() { + let app = TestApp::household().await; + // 23:30 UTC on 31 October is 00:30 on 1 November in Stockholm (CET after the clocks went back on 25 October). + app.set_now("2026-10-31T23:30:00.000Z"); + let body = get(&app, "/analytics/trends?months=1", &admin_token()).await; + assert_eq!(body["today"], "2026-11-01"); + assert_eq!(body["current_month"], "2026-11"); + assert_eq!(body["household"]["points"][0]["month"], "2026-10"); + let body = get(&app, "/analytics/forecast", &admin_token()).await; + assert_eq!(body["current_month"], "2026-11"); + assert_eq!(body["accounts"], json!([])); + // December rolls over into the next year. + app.set_now("2026-12-15T12:00:00.000Z"); + app.insert_account(ACCOUNT_1, "Vardag", "2026-01-01T00:00:00.000Z", 5000) + .await; + let body = get(&app, "/analytics/forecast", &admin_token()).await; + let months: Vec<&str> = body["accounts"][0]["months"] + .as_array() + .unwrap() + .iter() + .map(|m| m["month"].as_str().unwrap()) + .collect(); + assert_eq!(months, vec!["2027-01", "2027-02"]); +} + +#[tokio::test] +async fn e35_matches_the_example() { + let app = example_state().await; + let body = get(&app, "/analytics/forecast", &member_token()).await; + assert_eq!(body, example("E-35-analytics-forecast/response.json")); +} + +#[tokio::test] +async fn e35_my_share_uses_the_shares_a_generation_would_write() { + let app = example_state().await; + let admin = get(&app, "/analytics/forecast?measure=my_share", &admin_token()).await; + assert_eq!(admin["measure"], "my_share"); + // Account 1: streaming 4900 at 7000 bp for the admin (payer): the member's 1470 is floored, the admin pays + // 3430; baseline (15000 + 30000 + 5000 + 10000) / 3; today's month 6000. + assert_eq!( + admin["accounts"][0], + json!({"account_id": ACCOUNT_1, "current_month_ore": 6_000, "months": [ + {"month": "2026-11", "recurring_ore": 3_430, "baseline_ore": 20_000, "forecast_ore": 23_430}, + {"month": "2026-12", "recurring_ore": 0, "baseline_ore": 20_000, "forecast_ore": 20_000} + ]}) + ); + // Account 2: rent in mode default resolves the account's current ratio (admin 6000 bp): 9000 / 6000. + assert_eq!(admin["accounts"][1]["months"][0]["recurring_ore"], 9_000); + assert_eq!(admin["accounts"][1]["months"][0]["baseline_ore"], 600); + let member = get( + &app, + "/analytics/forecast?measure=my_share", + &member_token(), + ) + .await; + assert_eq!(member["accounts"][1]["months"][1]["recurring_ore"], 6_000); + assert_eq!(member["accounts"][1]["months"][1]["baseline_ore"], 400); + assert_eq!(member["accounts"][0]["months"][0]["recurring_ore"], 1_470); +} + +#[tokio::test] +async fn e35_templates_by_period_and_baseline_rounding() { + let app = TestApp::household().await; + app.insert_account(ACCOUNT_1, "Vardag", "2026-01-01T00:00:00.000Z", 5000) + .await; + // Starts in December: not in November. + template( + &app, + Template { + id: T_RENT, + account: ACCOUNT_1, + amount: 10_000, + day: 29, + start: "2026-12", + end: None, + last: None, + paused: false, + deleted: false, + ratio: None, + }, + ) + .await; + // A monthly template counts once per month, whatever its day. + template( + &app, + Template { + id: T_STREAMING, + account: ACCOUNT_1, + amount: 1_000, + day: 31, + start: "2026-01", + end: None, + last: Some("2026-09"), + paused: false, + deleted: false, + ratio: Some(5_000), + }, + ) + .await; + // 100 öre over three months: 33.33 rounds to 33; 200 öre: 66.67 rounds to 67. + expense( + &app, + ACCOUNT_1, + GROCERIES, + 100, + "2026-08-02", + (50, 50), + Kind::Plain, + ) + .await; + let body = get(&app, "/analytics/forecast", &admin_token()).await; + assert_eq!( + body["accounts"][0]["months"], + json!([ + {"month": "2026-11", "recurring_ore": 1_000, "baseline_ore": 33, "forecast_ore": 1_033}, + {"month": "2026-12", "recurring_ore": 11_000, "baseline_ore": 33, "forecast_ore": 11_033} + ]) + ); + expense( + &app, + ACCOUNT_1, + GROCERIES, + 100, + "2026-07-31", + (50, 50), + Kind::Plain, + ) + .await; + let body = get(&app, "/analytics/forecast", &admin_token()).await; + assert_eq!(body["accounts"][0]["months"][0]["baseline_ore"], 67); +} + +#[tokio::test] +async fn bad_parameters_and_authentication() { + let app = TestApp::household().await; + for (path, field) in [ + ("/analytics/trends?months=0", "months"), + ("/analytics/trends?months=25", "months"), + ("/analytics/trends?months=six", "months"), + ("/analytics/trends?measure=mine", "measure"), + ("/analytics/forecast?measure=", "measure"), + ] { + let reply = app.call(Method::GET, path, &admin_token(), None).await; + assert_field_error(&reply, 400, "invalid_request", field); + } + // Unknown keys are ignored; E-35 has no `months`. + let reply = app + .call( + Method::GET, + "/analytics/forecast?months=99&x=1", + &admin_token(), + None, + ) + .await; + assert_eq!(reply.status, 200, "{}", reply.text()); + for path in ["/analytics/trends", "/analytics/forecast"] { + let reply = app.public(Method::GET, path, None).await; + assert_error(&reply, 401, "unauthorized"); + let reply = app.call(Method::GET, path, &old_device_token(), None).await; + assert_error(&reply, 401, "device_revoked"); + let reply = app.call(Method::POST, path, &admin_token(), None).await; + assert_eq!(reply.status, 405); + } +} diff --git a/server/crates/kvitt-server/tests/core_api/main.rs b/server/crates/kvitt-server/tests/core_api/main.rs index 3a47c83..0c0111f 100644 --- a/server/crates/kvitt-server/tests/core_api/main.rs +++ b/server/crates/kvitt-server/tests/core_api/main.rs @@ -1,7 +1,7 @@ -//! REST integration tests: every endpoint of E-2 to E-30 and E-33 with each error code SPEC §14.3.4 lists for it, -//! the recurring generation cases of SPEC §9.5, rate limiting (§8.6, AC-2.12), pictures (AC-4.4), the landing page -//! (AC-2.5), APK serving (§10.6), the `bootstrap`, `pair-device` and `invite-partner` subcommands (FR-1, D-113) and -//! the logging rule (§8.7). +//! REST integration tests: every endpoint of E-2 to E-30 and E-33 to E-35 with each error code SPEC §14.3.4 lists for +//! it, the recurring generation cases of SPEC §9.5, rate limiting (§8.6, AC-2.12), pictures (AC-4.4), the landing page +//! (AC-2.5), APK serving (§10.6), the web client under `/app/` (D-132), the `bootstrap`, `pair-device` and +//! `invite-partner` subcommands (FR-1, D-113) and the logging rule (§8.7). //! //! One test binary (faster to build than one per file); the harness is `tests/common/`. @@ -9,6 +9,7 @@ pub mod common; mod accounts; +mod analytics; mod apk; mod categories; mod cli; @@ -22,3 +23,4 @@ mod pictures; mod proposal; mod rate_limit; mod recurring; +mod web; diff --git a/server/crates/kvitt-server/tests/core_api/web.rs b/server/crates/kvitt-server/tests/core_api/web.rs new file mode 100644 index 0000000..43287fe --- /dev/null +++ b/server/crates/kvitt-server/tests/core_api/web.rs @@ -0,0 +1,164 @@ +//! The web client under `/app/` (D-132, `KVITT_WEB_DIR`): static files, SPA fallback, caching, the app's CSP, path +//! safety and the unauthenticated rate limit. + +use axum::body::Body; +use axum::http::{Method, Request, header}; + +use crate::common::*; + +const INDEX: &str = "Kvitt"; +const SCRIPT: &str = "console.log('kvitt')"; + +/// Installs a small built app in the harness's web directory (`/web`). +fn install(app: &TestApp) { + let web = app.data_dir().join("web"); + std::fs::create_dir_all(web.join("assets")).unwrap(); + std::fs::write(web.join("index.html"), INDEX).unwrap(); + std::fs::write(web.join("assets/index-B2x9a.js"), SCRIPT).unwrap(); + std::fs::write(web.join("favicon.svg"), "").unwrap(); + std::fs::create_dir_all(web.join("nested")).unwrap(); +} + +/// A browser request: no protocol header, no token. +fn browser(method: Method, path: &str) -> Request { + Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .unwrap() +} + +fn header_of(reply: &Reply, name: header::HeaderName) -> &str { + reply.headers.get(name).map_or("", |v| v.to_str().unwrap()) +} + +#[tokio::test] +async fn without_the_directory_every_app_path_is_not_found() { + let app = TestApp::empty().await; + for path in [ + "/app", + "/app/", + "/app/index.html", + "/app/assets/index-B2x9a.js", + "/app/expenses/1", + ] { + let reply = app.send(browser(Method::GET, path)).await; + assert_error(&reply, 404, "not_found"); + } +} + +#[tokio::test] +async fn serves_the_index_assets_and_the_spa_fallback() { + let app = TestApp::empty().await; + install(&app); + + let reply = app.send(browser(Method::GET, "/app")).await; + assert_eq!(reply.status, 308); + assert_eq!(header_of(&reply, header::LOCATION), "/app/"); + + for path in [ + "/app/", + "/app/index.html", + "/app/expenses/0192a003-0000-7000-8000-000000000001", + "/app/settings", + // Unsafe paths never reach the file system; they get the index. + "/app/../kvitt.sqlite", + "/app/%2e%2e/kvitt.sqlite", + "/app/.hidden", + "/app/nested", + "/app/%FF", + ] { + let reply = app.send(browser(Method::GET, path)).await; + assert_eq!(reply.status, 200, "{path}"); + assert_eq!(reply.text(), INDEX, "{path}"); + assert_eq!( + header_of(&reply, header::CONTENT_TYPE), + "text/html; charset=utf-8" + ); + assert_eq!(header_of(&reply, header::CACHE_CONTROL), "no-store"); + assert_eq!( + header_of(&reply, header::CONTENT_SECURITY_POLICY), + "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; \ + font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ); + assert_eq!(header_of(&reply, header::X_CONTENT_TYPE_OPTIONS), "nosniff"); + assert_eq!(header_of(&reply, header::X_FRAME_OPTIONS), "DENY"); + assert_eq!(header_of(&reply, header::REFERRER_POLICY), "no-referrer"); + assert_eq!( + header_of(&reply, "x-kvitt-server-version".parse().unwrap()), + env!("CARGO_PKG_VERSION") + ); + } + + let reply = app + .send(browser(Method::GET, "/app/assets/index-B2x9a.js")) + .await; + assert_eq!(reply.status, 200); + assert_eq!(reply.text(), SCRIPT); + assert_eq!( + header_of(&reply, header::CONTENT_TYPE), + "text/javascript; charset=utf-8" + ); + assert_eq!( + header_of(&reply, header::CACHE_CONTROL), + "public, max-age=31536000, immutable" + ); + assert!(!header_of(&reply, header::CONTENT_SECURITY_POLICY).is_empty()); + + let reply = app.send(browser(Method::GET, "/app/favicon.svg")).await; + assert_eq!(header_of(&reply, header::CONTENT_TYPE), "image/svg+xml"); + assert_eq!(header_of(&reply, header::CACHE_CONTROL), "no-cache"); + + // A hashed asset that is gone fails instead of loading HTML. + for path in [ + "/app/assets/index-old.js", + "/app/assets/../../kvitt.sqlite", + "/app/assets%2f..%2f..%2fkvitt.sqlite", + ] { + let reply = app.send(browser(Method::GET, path)).await; + assert_error(&reply, 404, "not_found"); + } + + // HEAD is answered without a body; other methods are 405 with an empty body. + let reply = app.send(browser(Method::HEAD, "/app/")).await; + assert_eq!(reply.status, 200); + assert!(reply.bytes.is_empty()); + let reply = app.send(browser(Method::POST, "/app/")).await; + assert_eq!(reply.status, 405); + assert!(reply.bytes.is_empty()); + + // The rest of the API keeps its rules: an unknown path outside /app is still the JSON 404. + let reply = app.send(browser(Method::GET, "/application")).await; + assert_error(&reply, 404, "not_found"); +} + +#[tokio::test] +async fn without_an_index_the_fallback_is_not_found() { + let app = TestApp::empty().await; + std::fs::create_dir_all(app.data_dir().join("web")).unwrap(); + let reply = app.send(browser(Method::GET, "/app/")).await; + assert_error(&reply, 404, "not_found"); +} + +#[tokio::test] +async fn app_files_count_against_the_unauthenticated_ip_limit() { + let app = TestApp::empty().await; + install(&app); + for _ in 0..30 { + let reply = app.send(browser(Method::GET, "/app/")).await; + assert_eq!(reply.status, 200); + } + // A token does not move a static request to the per-device limit. + for _ in 0..30 { + let request = Request::builder() + .uri("/app/assets/index-B2x9a.js") + .header("authorization", format!("Bearer {}", admin_token())) + .body(Body::empty()) + .unwrap(); + assert_eq!(app.send(request).await.status, 200); + } + let reply = app.send(browser(Method::GET, "/app/")).await; + assert_error(&reply, 429, "rate_limited"); + let reply = app.public(Method::GET, "/health", None).await; + assert_error(&reply, 429, "rate_limited"); +} diff --git a/server/crates/kvitt-server/tests/openapi_contract.rs b/server/crates/kvitt-server/tests/openapi_contract.rs index eb797db..f816ce2 100644 --- a/server/crates/kvitt-server/tests/openapi_contract.rs +++ b/server/crates/kvitt-server/tests/openapi_contract.rs @@ -376,7 +376,7 @@ fn generated_document_equals_contract() { .collect::>() .join("\n") ); - // The contract has 33 operations (SPEC §7, E-1 to E-33); make sure none were lost on both sides. + // The contract has 35 operations (SPEC §7, E-1 to E-35); make sure none were lost on both sides. let count = normalise(&generated)["operations"] .as_object() .map(|paths| { @@ -387,7 +387,7 @@ fn generated_document_equals_contract() { .sum::() }) .unwrap_or(0); - assert_eq!(count, 33); + assert_eq!(count, 35); } // The comparison itself must be strict: each mutation of the contract below must be detected. diff --git a/server/crates/kvitt-server/tests/skeleton/support.rs b/server/crates/kvitt-server/tests/skeleton/support.rs index cf9e1c6..e4f5daf 100644 --- a/server/crates/kvitt-server/tests/skeleton/support.rs +++ b/server/crates/kvitt-server/tests/skeleton/support.rs @@ -50,6 +50,7 @@ impl TestApp { let config = Config::from_lookup(|k| match k { "KVITT_DATA_DIR" => Some(dir.0.to_string_lossy().into_owned()), "KVITT_PUBLIC_URL" => Some("https://kvitt.example.invalid".to_owned()), + "KVITT_WEB_DIR" => Some(dir.0.join("web").to_string_lossy().into_owned()), _ => None, }) .unwrap(); diff --git a/server/crates/kvitt-server/tests/skeleton/wire_examples.rs b/server/crates/kvitt-server/tests/skeleton/wire_examples.rs index a4006f6..a23d191 100644 --- a/server/crates/kvitt-server/tests/skeleton/wire_examples.rs +++ b/server/crates/kvitt-server/tests/skeleton/wire_examples.rs @@ -70,6 +70,8 @@ fn check(name: &str, example: &Value) -> bool { "E-32-sync-push/response.json" => SyncPushResponse, "E-33-settlement-proposal/request.json" => ProposalRequest, "E-33-settlement-proposal/response.json" => ProposalResponse, + "E-34-analytics-trends/response.json" => TrendsResponse, + "E-35-analytics-forecast/response.json" => ForecastResponse, "sync/pull-empty-page.json" => SyncPullResponse, "sync/pull-page.json" => SyncPullResponse, "sync/pull-page-categories-v2.json" => SyncPullResponse, diff --git a/tools/contracts-check.py b/tools/contracts-check.py index 7729358..d7504d6 100755 --- a/tools/contracts-check.py +++ b/tools/contracts-check.py @@ -769,6 +769,7 @@ DEFAULT_KEY = E(*DEFAULT_KEYS) KEYWORD_MATCHES = ["token", "prefix", "phrase"] SPLIT_MODE = E("default", "ratio", "exact", "full") ENTITY_TYPE = E("expense", "settlement") +MEASURE = E("household", "my_share") REASON = E("stale", "deleted", "validation_failed", "reference_not_found", "clock_skew") ERROR_CODES = ["invalid_request", "invite_invalid", "unauthorized", "device_revoked", "forbidden", "not_found", "household_full", "household_incomplete", "name_taken", "account_not_empty", "category_in_use", @@ -853,6 +854,16 @@ SHAPES = { "ProposalRequest": S({"payer_user_id": "uuid", "amount_ore": "int", "exclude_settlement_id": N("uuid")}, optional=["exclude_settlement_id"]), "ProposalResponse": S({"allocations": A("Allocation"), "excess_ore": "int", "over_settled_account_ids": A("uuid")}), + # E-34, E-35 analytics (D-133, §14.5.7) + "TrendPoint": S({"month": "period", "amount_ore": "int", "moving_average_ore": "int"}), + "Trend": S({"total_ore": "int", "average_ore": "int", "last_month_ore": "int", "last_month_vs_average_pct": N("int"), + "current_month_ore": "int", "points": A("TrendPoint", 1, 24)}), + "CategoryTrend": S({"category_id": "uuid", "trend": "Trend"}), + "TrendsResponse": S({"measure": MEASURE, "today": "date", "current_month": "period", "months": "int", + "household": "Trend", "categories": A("CategoryTrend")}), + "ForecastMonth": S({"month": "period", "recurring_ore": "int", "baseline_ore": "int", "forecast_ore": "int"}), + "AccountForecast": S({"account_id": "uuid", "current_month_ore": "int", "months": A("ForecastMonth", 2, 2)}), + "ForecastResponse": S({"measure": MEASURE, "today": "date", "current_month": "period", "accounts": A("AccountForecast")}), # §14.4.2 and §14.4.3 sync "SyncPullResponse": S({"epoch": "hex32", "since": "int", "next_since": "int", "has_more": "bool", "server_time": "ts", "users": A("User"), "categories": A("Category"), "accounts": A("Account"), @@ -1097,6 +1108,8 @@ EXAMPLES = { "E-32-sync-push/response.json": "SyncPushResponse", "E-33-settlement-proposal/request.json": "ProposalRequest", "E-33-settlement-proposal/response.json": "ProposalResponse", + "E-34-analytics-trends/response.json": "TrendsResponse", + "E-35-analytics-forecast/response.json": "ForecastResponse", "sync/pull-page.json": "SyncPullResponse", "sync/pull-empty-page.json": "SyncPullResponse", "sync/pull-page-categories-v2.json": "SyncPullResponse", @@ -1458,6 +1471,7 @@ ENDPOINTS = [ ("E-27", "delete", "/categories/{category_id}"), ("E-28", "post", "/recurring-templates"), ("E-29", "patch", "/recurring-templates/{template_id}"), ("E-30", "delete", "/recurring-templates/{template_id}"), ("E-31", "get", "/sync"), ("E-32", "post", "/sync"), ("E-33", "post", "/settlements/proposal"), + ("E-34", "get", "/analytics/trends"), ("E-35", "get", "/analytics/forecast"), ] UNAUTHENTICATED = {"E-1", "E-2", "E-3", "E-4", "E-5", "E-6"} @@ -1500,7 +1514,7 @@ def check_openapi(): ops[(cur_method, cur_path)]["security"] = m.group(1).strip() want = {(meth, p): eid for eid, meth, p in ENDPOINTS} got = {k: v["eid"] for k, v in ops.items()} - report(got == want, "openapi operations are exactly E-1..E-33 of §7", + report(got == want, "openapi operations are exactly E-1..E-35 of §7", "missing %s, extra %s, wrong ids %s" % (sorted(set(want) - set(got)), sorted(set(got) - set(want)), sorted(k for k in want if k in got and got[k] != want[k])) if got != want else "%d operations" % len(got)) bad_sec = sorted(eid for (meth, p), eid in want.items() if (meth, p) in ops and