Server: web client under /app/ (D-132), analytics E-34 trends and E-35 forecast (D-133)
Build Kvitt images / build (push) Failing after 1m20s
Build Kvitt images / build (push) Failing after 1m20s
Part A (D-132): KVITT_WEB_DIR (default /app) served under /app/ by a small handler: /app redirects to /app/, app routes fall back to index.html, a missing assets/* file is 404, assets cached immutable, index.html no-store, the app's own CSP plus nosniff, DENY and no-referrer. Unsafe paths never reach the file system. /app paths count against the unauthenticated per-IP limit. Without the directory every /app path stays 404 not_found. server/Dockerfile gains a Node 24 LTS stage (pinned by digest) building web/, passed through the Rust stage so npm and rustc never run at once on the Pi 3; the build context is the repository root with an allowlist .dockerignore (server/.dockerignore removed); workflow, build-images.sh and infra/dev/build.sh follow. Part B (D-133): GET /analytics/trends (E-34) and GET /analytics/forecast (E-35), authenticated, read-only, SQL month buckets; rules (half-up average, trailing 3-month moving average, percent against the rounded average, template occurrence per month) in kvitt-domain::analytics. OpenAPI, examples, contracts-check and tests updated (35 operations). Cargo.lock: rustls-webpki 0.103.16 and webpki-root-certs 1.0.10 (RUSTSEC-2026-0382, -0383) so that cargo audit passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=<gitea 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"
|
||||
)
|
||||
|
||||
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
target/
|
||||
**/*.sqlite*
|
||||
Generated
+4
-4
@@ -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",
|
||||
]
|
||||
|
||||
+31
-6
@@ -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
|
||||
|
||||
+10
-5
@@ -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:<pinned version>-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:<pinned version>-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.
|
||||
|
||||
@@ -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<i64> {
|
||||
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<i64> {
|
||||
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<bool, RecurrenceError> {
|
||||
let end: Option<Period> = 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::<i64>::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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -60,6 +60,15 @@ impl Period {
|
||||
}
|
||||
}
|
||||
|
||||
/// The month before, or `None` before 0001-01.
|
||||
pub fn previous(self) -> Option<Self> {
|
||||
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::<Period>().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!(
|
||||
|
||||
@@ -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<AnalyticsQuery, AppError> {
|
||||
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::<i64>()
|
||||
.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<Self, AppError> {
|
||||
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<Vec<Period>, 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<Vec<Period>, 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<Uuid>,
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> Result<Vec<MonthlySum>, 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<Uuid> {
|
||||
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<i64>,
|
||||
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<AnalyticsMonthsSchema>, Query, description = "Whole months, 1 to 24 (`invalid_request` otherwise)."),
|
||||
("measure" = Option<AnalyticsMeasureParamSchema>, 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<AppState>,
|
||||
RawQuery(query): RawQuery,
|
||||
) -> Result<JsonBody<TrendsResponse>, AppError> {
|
||||
let query = parse_query(query.as_deref(), true)?;
|
||||
let calendar = Calendar::of(&state)?;
|
||||
let months = calendar.whole_months(query.months)?;
|
||||
let index: HashMap<String, usize> = 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<String, GroupAmounts> = 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<CategoryTrend> = 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::<Result<_, AppError>>()?;
|
||||
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<Uuid>,
|
||||
forecast: &[Period],
|
||||
) -> Result<HashMap<Uuid, Vec<i64>>, AppError> {
|
||||
let ids: Vec<String> = 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<Uuid, Vec<i64>> = 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<AnalyticsMeasureParamSchema>, 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<AppState>,
|
||||
RawQuery(query): RawQuery,
|
||||
) -> Result<JsonBody<ForecastResponse>, 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<String> = 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<String> = 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<String, (i64, i64)> = 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::<Result<_, AppError>>()?;
|
||||
|
||||
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<Period> = ["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<i64> = 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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/<path>` serves the file when `<path>` 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<Option<Vec<u8>>, 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<u8>) -> 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<Response, AppError> {
|
||||
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<AppState>) -> Result<Response, AppError> {
|
||||
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<AppState>) -> Result<Response, AppError> {
|
||||
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<AppState>,
|
||||
path: Result<Path<String>, axum::extract::rejection::PathRejection>,
|
||||
) -> Result<Response, AppError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<i64>,
|
||||
/// 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<TrendPoint>,
|
||||
}
|
||||
|
||||
/// 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<CategoryTrend>,
|
||||
}
|
||||
|
||||
/// 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<ForecastMonth>,
|
||||
}
|
||||
|
||||
/// 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<AccountForecast>,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<i64>,
|
||||
}
|
||||
|
||||
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<i64> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = "<!doctype html><title>Kvitt</title><script type=module src=/app/assets/index-B2x9a.js></script>";
|
||||
const SCRIPT: &str = "console.log('kvitt')";
|
||||
|
||||
/// Installs a small built app in the harness's web directory (`<data dir>/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"), "<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<Body> {
|
||||
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");
|
||||
}
|
||||
@@ -376,7 +376,7 @@ fn generated_document_equals_contract() {
|
||||
.collect::<Vec<_>>()
|
||||
.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::<usize>()
|
||||
})
|
||||
.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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user