diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..a8467c0 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +FROM mcr.microsoft.com/devcontainers/python:1-3.12-bookworm + +RUN rm -f /etc/apt/sources.list.d/yarn.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + sqlite3 \ + git \ + curl \ + && rm -rf /var/lib/apt/lists/* \ No newline at end of file diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..9d9c8b4 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/node:2": { + "version": "2.1.0", + "resolved": "ghcr.io/devcontainers/features/node@sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857", + "integrity": "sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..beabaf3 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,29 @@ +{ + "name": "PocketKid Development", + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/node:2": { + "version": "lts" + } + }, + "workspaceFolder": "/workspace/pocketkid", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace/pocketkid,type=bind", + "postCreateCommand": "pip install -r requirements.txt", + "forwardPorts": [ + 8000 + ], + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python" + } + } + }, + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.env.example b/.env.example index 6e85f5b..2108798 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,6 @@ VAPID_PRIVATE_KEY_B64= # Optional VAPID_SUBJECT=mailto:pocketkid@example.com + +# Docker Compose image tag (for example 0.1.0); defaults to latest +POCKETKID_IMAGE_TAG=latest diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5bbb372 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + pull_request: + branches: [master] + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + VAPID_PUBLIC_KEY: test-public-key + VAPID_PRIVATE_KEY: test-private-key + + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: python -m pip install -r requirements.txt + + - name: Run tests + run: python -m unittest discover -s tests -v + + container-smoke-test: + needs: test + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Build container image + run: docker build --tag pocketkid:ci . + + - name: Start and verify container + shell: bash + run: | + docker run --detach --name pocketkid-smoke \ + --publish 127.0.0.1:8000:8000 \ + --env VAPID_PUBLIC_KEY=test-public-key \ + --env VAPID_PRIVATE_KEY=test-private-key \ + pocketkid:ci + + for attempt in {1..30}; do + status="$(curl --silent --output /dev/null --write-out "%{http_code}" http://127.0.0.1:8000/ || true)" + if [ "$status" = "302" ]; then + exit 0 + fi + sleep 1 + done + + docker logs pocketkid-smoke + exit 1 + + - name: Stop smoke-test container + if: always() + run: docker rm --force pocketkid-smoke || true diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b551578..c328382 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,52 +1,195 @@ -name: Build and Publish Docker image +name: Publish release image on: - push: - branches: [ "main" ] - tags: [ "v*" ] - workflow_dispatch: + release: + types: [published] permissions: contents: read packages: write + attestations: write + id-token: write + +concurrency: + group: release-${{ github.event.release.tag_name }} + cancel-in-progress: false env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} # owner/repo + IMAGE_NAME: ${{ github.repository }} jobs: - build-and-push: + publish: runs-on: ubuntu-latest + timeout-minutes: 30 + env: + VAPID_PUBLIC_KEY: test-public-key + VAPID_PRIVATE_KEY: test-private-key steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Checkout release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.event.release.tag_name }} + fetch-depth: 0 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Validate release tag and version + id: version + shell: bash + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} + run: | + if [ "$RELEASE_PRERELEASE" != "false" ]; then + echo "Pre-releases are not supported by this stable release workflow" >&2 + exit 1 + fi + + version="$(tr -d '\r\n' < VERSION)" + + if ! [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "VERSION must contain a stable semantic version, got: $version" >&2 + exit 1 + fi + + expected_tag="fork-v${version}" + if [ "$RELEASE_TAG" != "$expected_tag" ]; then + echo "Release tag $RELEASE_TAG does not match VERSION ($expected_tag)" >&2 + exit 1 + fi + + if ! git merge-base --is-ancestor HEAD origin/master; then + echo "Release commit must be contained in master" >&2 + exit 1 + fi + + major="${version%%.*}" + major_minor="${version%.*}" + publish_major=true + if [ "$major" = "0" ]; then + publish_major=false + fi - - name: Log in to GHCR - uses: docker/login-action@v3 + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "major=$major" >> "$GITHUB_OUTPUT" + echo "major_minor=$major_minor" >> "$GITHUB_OUTPUT" + echo "publish_major=$publish_major" >> "$GITHUB_OUTPUT" + echo "commit=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + python-version: "3.12" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: python -m pip install -r requirements.txt + + - name: Run tests + run: python -m unittest discover -s tests -v + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - name: Extract Docker metadata + - name: Generate image metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=ref,event=branch - type=ref,event=tag - type=sha - type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=${{ steps.version.outputs.version }} + type=raw,value=${{ steps.version.outputs.major_minor }} + type=raw,value=${{ steps.version.outputs.major }},enable=${{ steps.version.outputs.publish_major }} + type=raw,value=latest + type=raw,value=sha-${{ steps.version.outputs.commit }} + labels: | + org.opencontainers.image.version=${{ steps.version.outputs.version }} + org.opencontainers.image.revision=${{ steps.version.outputs.commit }} - - name: Build and push - uses: docker/build-push-action@v6 + - name: Build release image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . - push: true + load: true + push: false tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Verify image version + env: + EXPECTED_VERSION: ${{ steps.version.outputs.version }} + RELEASE_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + run: | + docker run --rm \ + --env EXPECTED_VERSION \ + --env VAPID_PUBLIC_KEY \ + --env VAPID_PRIVATE_KEY \ + "$RELEASE_IMAGE" \ + python -c 'import os; from pocketkid.config import APP_VERSION; assert APP_VERSION == os.environ["EXPECTED_VERSION"]' + + - name: Start and verify release container + shell: bash + env: + RELEASE_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + run: | + docker run --detach --name pocketkid-release-smoke \ + --publish 127.0.0.1:8000:8000 \ + --env VAPID_PUBLIC_KEY \ + --env VAPID_PRIVATE_KEY \ + "$RELEASE_IMAGE" + + for attempt in {1..30}; do + status="$(curl --silent --output /dev/null --write-out "%{http_code}" http://127.0.0.1:8000/ || true)" + if [ "$status" = "302" ]; then + exit 0 + fi + sleep 1 + done + + docker logs pocketkid-release-smoke + exit 1 + + - name: Stop release smoke-test container + if: always() + run: docker rm --force pocketkid-release-smoke || true + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish tested image tags + id: publish + shell: bash + env: + DOCKER_TAGS: ${{ steps.meta.outputs.tags }} + RELEASE_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + run: | + push_output="$(docker push "$RELEASE_IMAGE")" + printf '%s\n' "$push_output" + + digest="$(printf '%s\n' "$push_output" | sed -n 's/.*digest: \(sha256:[0-9a-f]\{64\}\).*/\1/p' | tail -n 1)" + if [ -z "$digest" ]; then + echo "Could not determine published image digest" >&2 + exit 1 + fi + + while IFS= read -r tag; do + if [ -n "$tag" ] && [ "$tag" != "$RELEASE_IMAGE" ]; then + docker push "$tag" + fi + done <<< "$DOCKER_TAGS" + + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + - name: Attest image provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.publish.outputs.digest }} + push-to-registry: true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1398aef --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,117 @@ +# PocketKid fork + +This repository is a fork of `pernastefano/pocketkid`. + +## Project goal + +Adapt PocketKid into a lightweight family ledger. + +The application should support children having their own accounts and +allow balances to become positive or negative. + +Parents should retain administrative control. + +## Upstream compatibility + +Keep divergence from upstream as small as reasonably possible. + +- Preserve the existing architecture and coding style. +- Prefer small, isolated changes. +- Do not refactor unrelated code. +- Do not rename or move files without a concrete need. +- Prefer changes that remain easy to rebase onto upstream. +- Do not modify the upstream production Docker setup unless required. + +## Existing architecture + +PocketKid is a small Flask application using: + +- Python 3.12 +- Flask +- Flask-SQLAlchemy +- SQLite +- Jinja templates +- vanilla JavaScript +- Docker + +Important application files include: + +- `pocketkid/models.py` — database models +- `pocketkid/routes.py` — routes and request handling +- `pocketkid/services.py` — application/business logic +- `pocketkid/config.py` — configuration +- `templates/` — Jinja templates +- `static/` — frontend assets + +## Development environment + +Development is performed inside the VS Code Dev Container in +`.devcontainer/`. + +Use the Python environment provided by the Dev Container. + +Run PocketKid locally with: + + python app.py + +The local development database is stored below `data/` and must never +be committed. + +Secrets are stored in `.env` and must never be committed. + +## Development rules + +Before changing behavior: + +1. Inspect the existing implementation. +2. Identify the relevant code path. +3. Prefer the smallest viable change. +4. Preserve existing authorization boundaries. +5. Add or update tests for behavioral changes where practical. +6. Run relevant tests or checks before declaring work complete. + +Do not silently change unrelated behavior. + +## Security + +- Never commit secrets, VAPID private keys, passwords, or `.env`. +- Never use production data for development or testing. +- Treat authorization changes as security-sensitive. +- A child must not gain access to another child's account or transactions. + +## Current product goals + +The current direction is: + +1. Allow account balances below zero. +2. Represent negative balances clearly as debt. +3. Preserve parent/child account isolation. +4. Allow children to view their own account. +5. Potentially allow children to create or edit their own transactions. +6. Add a reliable audit history for transaction changes. +7. Keep the application lightweight and mobile-friendly. + +These goals describe direction, not permission to implement all of them +in one change. + +## Git workflow + +- Do not develop directly on `master`. +- Use one logical feature per branch. +- Keep commits small and focused. +- Do not rewrite shared history. +- Do not force-push. +- Do not create commits unless explicitly requested. +- Do not push unless explicitly requested. + +## Working style for agents + +For analysis requests, do not modify files unless explicitly asked. + +When asked to implement something: + +- explain the affected code path first when useful; +- keep the diff minimal; +- report files changed; +- report tests or checks run; +- mention unresolved risks or assumptions. \ No newline at end of file diff --git a/README.md b/README.md index c7755e3..b3efad6 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,13 @@ PocketKid is a mobile-first Python PWA to manage a virtual wallet for your children. It helps parents organize pocket money through configurable challenges and a complete request approval flow. -Public repository: -- https://github.com/pernastefano/pocketkid +Fork repository: https://github.com/nocona71/pocketkid + +Upstream project: https://github.com/pernastefano/pocketkid Parents and children access the same app with role-based features: - Children can submit reward requests linked to challenges, plus withdrawal and generic deposit requests. -- Parents can approve/reject requests, perform manual wallet movements, configure recurring movements, manage users, and review full history. +- Parents can approve/reject requests, set per-child overdraft limits, perform manual wallet movements, configure recurring movements, manage users, and review full history. The app is installable as a Progressive Web App (PWA) and supports real Web Push notifications (VAPID) for system-level alerts. @@ -121,6 +122,8 @@ cp .env.example .env # optional: set host user/group for container process # PUID=1000 # PGID=1000 +# optional: pin a published fork image, for example 0.1.0 +# POCKETKID_IMAGE_TAG=0.1.0 ``` ```bash @@ -135,6 +138,17 @@ The default compose file: --- +# Releases + +This fork uses an independent semantic version from the upstream project. The +current fork version is stored in `VERSION`, and release tags use the form +`fork-v`. + +See [`docs/RELEASING.md`](docs/RELEASING.md) for the tested GitHub Release and +GHCR publishing process. + +--- + # Main Features and How It Works ## Roles @@ -165,7 +179,7 @@ The default compose file: 2. Parent receives notification and sees pending request. 3. Parent approves or rejects: - **Approve reward/deposit** → wallet increases. - - **Approve withdrawal** → wallet decreases (if sufficient balance). + - **Approve withdrawal** → wallet decreases down to the per-child overdraft limit and may become debt. - **Reject** → request state updates to rejected. 4. Transaction is saved in history and notifications are generated. @@ -343,6 +357,8 @@ Notes: - `locales/` → i18n files (`en.json`, `it.json`) - `data/` → SQLite DB - `.env.example` → template for VAPID environment variables +- `VERSION` → fork release version +- `docs/RELEASING.md` → release and container publishing process - `Dockerfile`, `docker-compose.yml` → container setup --- diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/docker-compose.yml b/docker-compose.yml index 2de961a..f9981ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: pocketkid: build: context: . - image: ghcr.io/pernastefano/pocketkid:latest + image: ghcr.io/nocona71/pocketkid:${POCKETKID_IMAGE_TAG:-latest} container_name: pocketkid user: "${PUID:-1000}:${PGID:-1000}" env_file: diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..af29525 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,115 @@ +# Releasing the PocketKid fork + +This fork has its own release sequence. Upstream PocketKid versions remain +owned by `pernastefano/pocketkid` and are not reused as fork versions. + +## Version identity + +- `VERSION` is the single source for the fork's semantic version. +- Git tags use the namespaced form `fork-v`, for example + `fork-v0.1.0`. +- The upstream base is recorded separately as an exact commit in + `pocketkid/config.py` and displayed in App Info. +- A release branch is not used. Normal releases are made from tested commits + on `master`. + +For a stable release `0.1.0`, the workflow publishes these image tags: + +- `ghcr.io/nocona71/pocketkid:0.1.0` — immutable release version; +- `ghcr.io/nocona71/pocketkid:0.1` — latest patch in the minor line; +- `ghcr.io/nocona71/pocketkid:latest` — latest stable release; +- `ghcr.io/nocona71/pocketkid:sha-` — commit-specific build. + +A floating major tag is added after the project reaches `1.0.0`. It is +intentionally omitted for `0.x` because minor releases may still be breaking. + +## Automated checks + +Every pull request to `master`, and every push to `master`, runs: + +1. the Python test suite; +2. a Docker image build; +3. a smoke test that starts Gunicorn from the image and checks the HTTP + response. + +Publishing a GitHub Release runs the checks again before the image is pushed. +The release workflow also verifies that: + +- the release tag equals `fork-v` plus the value in `VERSION`; +- the version is a stable three-part semantic version; +- the tagged commit is contained in `master`; +- the built container reports the expected fork version. + +The exact image that passes the container tests is pushed with OCI metadata +and receives a GitHub artifact attestation tied to its registry digest. + +## Prepare a release + +Use a pull request for any version change. For example, when preparing +`0.2.0`: + +```bash +git switch master +git pull --ff-only origin master +git switch -c chore/release-0.2.0 +``` + +Update `VERSION` to `0.2.0`, update user-facing documentation if required, +run the local checks, and open a pull request. Merge only after CI succeeds. + +Do not create the tag before the release-preparation pull request is merged. + +## Publish a release + +1. Open the repository's **Releases** page on GitHub. +2. Select **Draft a new release**. +3. Create the tag `fork-v` from `master`, for example + `fork-v0.2.0`. +4. Use `PocketKid Fork ` as the release title. +5. Do not mark the stable release as a pre-release. +6. Generate the release notes, review them, and publish the release. +7. Wait for the **Publish release image** workflow to succeed. +8. Confirm the versioned image and its digest in GitHub Packages. + +Publishing the release creates the tag. Do not create a second local tag for +the same version. + +## Verify the result + +Pull the immutable version tag, not `latest`, for verification or production +rollout: + +```bash +docker pull ghcr.io/nocona71/pocketkid:0.2.0 +docker image inspect ghcr.io/nocona71/pocketkid:0.2.0 --format '{{json .RepoDigests}}' +``` + +When GitHub CLI is authenticated, verify the build provenance with: + +```bash +gh attestation verify \ + oci://ghcr.io/nocona71/pocketkid:0.2.0 \ + --repo nocona71/pocketkid +``` + +For Docker Compose deployments, set the immutable image version in `.env`: + +```dotenv +POCKETKID_IMAGE_TAG=0.2.0 +``` + +Then pull and start it without rebuilding: + +```bash +docker compose pull +docker compose up -d +``` + +## Failed release workflow + +Do not move or overwrite a published release tag. Diagnose the failed job, +apply the fix through a new pull request, increment at least the patch version, +and publish a new release. This keeps every published version reproducible. + +Long-lived release branches should only be introduced if the fork later needs +to maintain multiple supported major or minor versions in parallel. diff --git a/docs/decisions/0001-negative-balances.md b/docs/decisions/0001-negative-balances.md new file mode 100644 index 0000000..3df125a --- /dev/null +++ b/docs/decisions/0001-negative-balances.md @@ -0,0 +1,67 @@ +# 0001: Allow negative account balances + +- Status: Accepted +- Date: 2026-09-29 + +## Context + +PocketKid currently treats a wallet balance as an available-funds limit. A +parent-approved withdrawal, a parent-entered withdrawal, or a recurring +withdrawal is blocked when its amount exceeds the wallet balance. + +This fork is a lightweight family ledger rather than a stored-value payment +system. A negative balance represents money the child owes. Preventing a debit +at zero hides that debt instead of recording it. + +The existing model can already represent this state: `Wallet.balance` and +`Transaction.amount` are signed decimal values without a nonnegative database +constraint. The restriction is imposed by application checks. + +## Decision + +Wallet balances may be positive, zero, or negative. + +Each wallet has a configurable minimum balance. It defaults to `0.00`, which +preserves PocketKid's original no-overdraft behavior. A parent may set the +minimum to zero or a negative amount for each child. Children can see their +current limit but cannot change it. + +All authorized debit paths may reduce a wallet down to that minimum: + +- a withdrawal requested by a child and approved by a parent; +- a withdrawal entered directly by a parent; and +- an executed recurring withdrawal. + +Child withdrawal requests continue to require parent approval. This decision +does not change authentication, authorization, account ownership, or the rule +that a child can access only their own account. + +User-entered withdrawal amounts remain positive magnitudes. The application +subtracts the amount from `Wallet.balance` and records the corresponding +transaction with a negative amount. Deposits remain positive transactions. + +A negative balance must be presented clearly as debt in parent and child +views. Color alone is not sufficient; the interface must include a textual +debt indication while retaining the numeric sign. + +No interest calculation or automatic debt collection is added. +Negative opening balances are outside the scope of this decision; debt should +arise from recorded debit transactions. + +## Consequences + +- Debit checks compare the resulting balance with the wallet's configured + minimum. An attempted debit below that limit is rejected or skipped. +- `Wallet` requires a minimum-balance column and existing databases require a + migration that initializes it to zero. +- Wallet updates and transaction creation must continue to be committed + together so the stored balance and history agree. +- Tests must cover the zero default, configured negative limits, exact-limit + withdrawals, and rejected withdrawals across each debit path. +- Balance displays must distinguish debt from available positive funds. +- Parent and child wallet views must show the configured limit; only parents + may change it. +- Existing authorization boundaries remain unchanged. + +This decision does not introduce transaction editing, immutable audit history, +or a general accounting system. Those concerns require separate decisions. diff --git a/docs/findings/withdrawal-flow.md b/docs/findings/withdrawal-flow.md new file mode 100644 index 0000000..881c247 --- /dev/null +++ b/docs/findings/withdrawal-flow.md @@ -0,0 +1,132 @@ +> Historical review: This document describes the implementation before negative +> balances were adopted. See [ADR 0001](../decisions/0001-negative-balances.md) +> for the accepted policy and current behavior. + +The current withdrawal flow is **child request → parent approval → wallet debit and transaction creation**. Children have individual accounts and can view their own wallets. Application checks prevent overdrafts; the declared wallet model has no nonnegative constraint. + +This document records a static review of the current implementation, not an implementation proposal. No application code was changed. The review did not start the application, access the database or `.env`, or run tests. References below use repository-relative paths and line numbers from the reviewed source. + +1. **Parent and child identities** + + Both roles use `User` in `pocketkid/models.py:9`: integer `id`, unique `username`, `password_hash`, `role`, and preferred language. Routes assign the roles `parent` and `child`. There is no family identifier or parent–child ownership relationship. + + `login()` in `pocketkid/routes.py:95` verifies the password and stores the user's ID in `session["user_id"]`. `current_user()` in `pocketkid/services.py:85` retrieves that ID and loads the database user. `User.verify_password()` in `pocketkid/models.py:16` checks the password hash. + + `Wallet` in `pocketkid/models.py:20` has a required, unique `child_id` foreign key to `User.id`, allowing at most one wallet per linked user. The foreign key does not itself enforce the child's role. + +2. **Authorization boundaries** + + `login_required()` in `pocketkid/services.py:131` requires an authenticated user and optionally a specific role. Unauthenticated users redirect to login; users with the wrong role receive a permission message and redirect to the dashboard. + + The child branch of `dashboard()` in `pocketkid/routes.py:149` loads the wallet and filters requests and transactions by the authenticated `user.id`. `child_request_withdrawal()` in `pocketkid/routes.py:242` requires the child role and assigns `child_id=user.id`; it does not accept a target child ID from the form. + + `parent_child_wallet()` (`pocketkid/routes.py:361`) and `parent_manual_movement()` (`pocketkid/routes.py:386`) require the parent role and check that the target user is a child. `parent_decide_request()` (`pocketkid/routes.py:298`) requires the parent role and obtains the wallet owner from the stored request. + + Every parent can administer every child in the installation. There is no per-parent ownership boundary. `get_wallet_by_child()` (`pocketkid/services.py:165`) and `register_transaction()` (`pocketkid/services.py:174`) do not authorize callers; authorization resides in routes. + +3. **Current withdrawal flow** + + - The form in `templates/child_dashboard.html:35` submits `amount` and optional `description` to `POST /child/request/withdrawal`. The numeric input has a minimum of `0.01` and no maximum derived from the balance. + - Before route execution, `app_guardrails()` (`pocketkid/services.py:293`) checks setup state and processes due recurring movements for authenticated requests. Those movements can change balances before withdrawal handling. + - The child-role decorator authorizes the request. `child_request_withdrawal()` (`pocketkid/routes.py:244`) obtains the current child and calls `parse_amount()` (`pocketkid/services.py:69`), which quantizes to two decimal places and rejects nonpositive values. Invalid input normally produces a flash message and redirect. An empty description receives the translated withdrawal-request label. + - The route stages an `OperationRequest` with type `withdrawal`, status `pending`, the authenticated child's ID, positive amount, and description. It notifies all parents, commits, and redirects to the dashboard. Submission performs no balance check, reservation, debit, or transaction creation. + - Approval/rejection forms in `templates/parent_dashboard.html:54` post `decision=approve` or `decision=reject` to `POST /parent/request//decision`. + - `parent_decide_request()` (`pocketkid/routes.py:300`) requires an existing pending request and a recognized decision. It assigns the current parent to `reviewed_by` and sets `reviewed_at`. + - Rejection sets status `rejected`, creates a child notification, and commits without a wallet movement (`pocketkid/routes.py:315`). + - Approval loads the request owner's wallet. In the withdrawal branch (`pocketkid/routes.py:340`), insufficient funds cause a flash error and redirect, leaving the request pending. Otherwise, the route subtracts the amount, marks the request approved, stages a transaction with kind `withdrawal`, negative amount, and the parent as creator, then creates a child notification and commits. + + `OperationRequest` is defined in `pocketkid/models.py:37`. `notify_all_parents()` (`pocketkid/services.py:235`) targets all parent users. `create_notification()` (`pocketkid/services.py:230`) stages the database notification and attempts web push before the enclosing commit. The notification side effect therefore precedes confirmed database persistence. + +4. **Balance calculation** + + `Wallet.balance` (`pocketkid/models.py:23`) is a stored running total declared as `Numeric(10, 2)`, not a sum calculated from transaction history. `get_wallet_by_child()` (`pocketkid/services.py:165`) creates a missing wallet at zero and immediately commits it. + + `parent_children()` (`pocketkid/routes.py:554`) can create a child with an initial balance; a positive initial balance also creates a transaction. Its amount parser falls back to zero for invalid/nonpositive opening amounts (`pocketkid/routes.py:562`). + + `parent_decide_request()` adds approved rewards/deposits and subtracts approved withdrawals (`pocketkid/routes.py:325`). `parent_manual_movement()` adds deposits or subtracts withdrawals (`pocketkid/routes.py:439`). `process_recurring_movements()` adds a signed amount (`pocketkid/services.py:274`). These callers update the wallet separately from staging transaction rows. + +5. **Insufficient-balance enforcement** + + | Path | Function and location | Current result when balance is insufficient | + |---|---|---| + | Approval of child withdrawal | `parent_decide_request()`, `pocketkid/routes.py:341` | Flash error and redirect; request stays pending; no withdrawal transaction | + | Parent manual withdrawal | `parent_manual_movement()`, `pocketkid/routes.py:435` | Flash error and redirect; no movement recorded | + | Recurring withdrawal | `process_recurring_movements()`, `pocketkid/services.py:266` | Notify parents, advance the scheduled date, and skip that occurrence | + + All three compare the current balance with the positive withdrawal amount using `<`. Withdrawing exactly the balance is allowed. The child submission route has no such guard. + +6. **Transaction persistence** + + `Transaction` (`pocketkid/models.py:54`) stores `id`, owning `child_id`, `kind`, signed `amount`, `description`, `created_at`, and nullable `created_by`. The `child` relationship identifies the account owner; `actor` resolves the creator's user ID. Amount is `Numeric(10, 2)` and creation time defaults to the current UTC time. + + `register_transaction()` (`pocketkid/services.py:174`) stages a new row with `db.session.add()`. It neither changes the wallet nor commits. Successful withdrawal approval commits the wallet, request status, transaction, and database notification together in `parent_decide_request()` (`pocketkid/routes.py:354`), subject to the missing-wallet helper's earlier commit described below. + + `Settings` (`pocketkid/config.py:23`) configures SQLite at `data/pocketkid.db`; `db` is the Flask-SQLAlchemy instance (`pocketkid/extensions.py:3`). `create_app()` (`pocketkid/__init__.py:11`) initializes it and creates missing tables. + + Transactions have no originating-request or recurring-movement foreign key, revision number, previous-value snapshot, update timestamp, or deletion audit fields. The stored running balance is not automatically derived or reconciled by `register_transaction()`. + +7. **Edit and delete behavior** + + `register_routes()` (`pocketkid/routes.py:41`) contains no transaction-edit or individual transaction-delete endpoint. History views only display entries (`templates/child_dashboard.html:93`, `templates/parent_child_wallet.html:46`). Children submit requests rather than directly creating or editing persisted transactions. + + `delete_child()` (`pocketkid/routes.py:476`) requires a parent and the form value `double_confirmed=1`. It permanently deletes the child's wallet, operation requests, transactions, notifications, and recurring movements before deleting the child and committing. It records no deletion audit. Transaction history is therefore not immutable. + +8. **Available authenticated-user information** + + `current_user()` (`pocketkid/services.py:85`) makes the authenticated database `User` available, including its `id`, `username`, and `role`. Account owner and acting user are distinct concepts in the models. + + | Action | Identity available and attribution recorded | Source | + |---|---|---| + | Child submits withdrawal | Current child; stored as `OperationRequest.child_id`, with no separate requester field | `child_request_withdrawal()`, `pocketkid/routes.py:244` | + | Parent approves request | Current parent; stored in `reviewed_by` and `Transaction.created_by` | `parent_decide_request()`, `pocketkid/routes.py:300` | + | Parent rejects request | Current parent; stored in `reviewed_by`; no transaction | `parent_decide_request()`, `pocketkid/routes.py:312` | + | Manual movement | Current parent; stored in `Transaction.created_by` | `parent_manual_movement()`, `pocketkid/routes.py:388` | + | Initial positive balance | Current parent; stored in `Transaction.created_by` | `parent_children()`, `pocketkid/routes.py:556` | + | Recurring execution | Transaction uses the recurring definition's original `created_by`, not the authenticated user whose request triggers execution | `process_recurring_movements()`, `pocketkid/services.py:276` | + | Child/history deletion | Current parent is available through `current_user()`, but not recorded as a deletion actor | `delete_child()`, `pocketkid/routes.py:478` | + + There is no recorded transaction-edit actor because editing is not implemented. Recurring attribution identifies the definition's creator rather than a distinct execution actor (`RecurringMovement`, `pocketkid/models.py:78`). + +9. **Smallest change needed for negative balances** + + As a finding about the existing code, the smallest behavioral change for parent-approved child withdrawals alone would be removal of the insufficient-balance branch in `parent_decide_request()` (`pocketkid/routes.py:341`). The existing subtraction and signed transaction storage already express an overdraft. Parent approval and ownership checks do not need to change. + + Allowing overdrafts across all withdrawal paths would additionally require removing the equivalent blockers in `parent_manual_movement()` (`pocketkid/routes.py:435`) and `process_recurring_movements()` (`pocketkid/services.py:266`). No schema change is indicated by `Wallet` (`pocketkid/models.py:20`), which declares no nonnegative constraint. This identifies the current blockers; no implementation or policy change is made here. + + Movement inputs remain positive magnitudes, with the operation determining the stored sign. Negative opening balances are separately blocked by `parent_children()` (`pocketkid/routes.py:562`) and its form (`templates/parent_children.html:18`). + + `eur_filter()` (`pocketkid/services.py:58`) already formats negative values with a minus sign, but debt-specific presentation is absent. Parent lists hard-code the `positive` class (`templates/parent_dashboard.html:20`, `templates/parent_children.html:37`), and the child balance card still uses the available-balance label (`templates/child_dashboard.html:4`). + +10. **Relevant risks** + + These are source-level findings, not demonstrated exploits or reproduced concurrency failures. + + - **Session integrity:** `Settings.SECRET_KEY` (`pocketkid/config.py:24`) has a publicly predictable fallback. Deployment with that fallback would undermine session integrity and user isolation. The effective secret was not inspected. + - **CSRF:** The withdrawal and approval forms have no CSRF token (`templates/child_dashboard.html:37`, `templates/parent_dashboard.html:54`). `create_app()` (`pocketkid/__init__.py:11`) registers no CSRF protection. `Settings` configures `SameSite="Lax"`, but the inspected flow performs no explicit CSRF-token validation. + - **Concurrency:** `parent_decide_request()` (`pocketkid/routes.py:300`) checks pending status without an atomic claim. It and `parent_manual_movement()` (`pocketkid/routes.py:388`) use read–modify–write balance updates. `process_recurring_movements()` (`pocketkid/services.py:251`) does not atomically claim due occurrences. Concurrent requests could cause duplicate processing, lost balance updates, or database-lock failures. + - **Audit retention:** `delete_child()` (`pocketkid/routes.py:478`) erases history. `delete_parent()` (`pocketkid/routes.py:644`) deletes users without preserving an actor snapshot or explicitly handling historical actor references. `Transaction` (`pocketkid/models.py:54`) stores a user reference rather than immutable actor details. + - **Commit boundaries:** `get_wallet_by_child()` (`pocketkid/services.py:165`) commits when creating a missing wallet, including other pending session changes. `create_notification()` (`pocketkid/services.py:230`) sends push before the enclosing commit, so an external notification can precede a failed database operation. + - **Amount validation:** In `parse_amount()` (`pocketkid/services.py:69`), comparison with zero occurs outside the exception handler. A `NaN` input can raise `InvalidOperation` instead of producing an ordinary validation failure. + - **Authorization depends on callers:** Wallet and transaction helpers have no access checks (`pocketkid/services.py:165`, `pocketkid/services.py:174`). Current child routes bind ownership to the authenticated ID, but the helper layer does not independently enforce child isolation. + +11. **Missing tests** + + No test files or test configuration were found in the reviewed repository inventory. No tests were run. The following behaviors have no identified automated coverage: + + - Child A cannot access child B's wallet or history; forged child IDs do not redirect a withdrawal request to another owner; children and unauthenticated users cannot invoke parent approval, movement, or deletion routes (`dashboard()`, `child_request_withdrawal()`, `login_required()`). + - Submission creates only a pending request; rejection creates no transaction; approval stores the expected balance, signed transaction amount, review metadata, and parent actor (`parent_decide_request()`). + - Below-balance, exact-balance, and above-balance withdrawals in all three guarded paths, including behavior when a wallet already has a negative balance (`parent_decide_request()`, `parent_manual_movement()`, `process_recurring_movements()`). + - Repeat and concurrent approvals, concurrent wallet updates, and a recurring occurrence executing only once (`parent_decide_request()`, `process_recurring_movements()`). + - Invalid amount inputs, including `NaN`, rounding, and zero/negative values (`parse_amount()`). + - Rollback consistency, missing-wallet commit behavior, and deletion/actor-reference retention (`get_wallet_by_child()`, `delete_child()`, `delete_parent()`). + - Negative-value formatting and balance presentation (`eur_filter()` and the wallet templates cited above). Overdraft acceptance and debt-specific presentation are not current supported behaviors. + +12. **Open questions and uncertainties** + + - **Runtime configuration and schema:** The effective session secret, cookie configuration, database contents, and deployed schema were not inspected. Model-level findings do not establish whether an existing database has additional constraints (`Settings`, `pocketkid/config.py:23`; `Wallet`, `pocketkid/models.py:20`). + - **Concurrency outcomes:** The exact effects of overlapping requests depend on SQLite connection and transaction timing. The identified race conditions were not exercised (`parent_decide_request()`, `parent_manual_movement()`, `process_recurring_movements()`). + - **Foreign-key enforcement:** The inspected setup does not explicitly enable SQLite foreign-key enforcement (`db`, `pocketkid/extensions.py:3`; `create_app()`, `pocketkid/__init__.py:11`). Whether deleting a referenced parent leaves unresolved historical references or fails under an existing runtime configuration was not tested (`delete_parent()`, `pocketkid/routes.py:644`). + - **Missing-wallet approval:** Review metadata is assigned before `get_wallet_by_child()` (`pocketkid/routes.py:312`). If the wallet is missing, the helper's commit can persist that metadata before an insufficient-balance rejection, while status remains pending. For an existing wallet, the insufficient-balance branch has no commit. This exceptional path was not exercised. + - **Ledger consistency:** Agreement between stored wallet balances and transaction totals was not checked against data. The implementation maintains these separately (`Wallet`, `pocketkid/models.py:20`; `register_transaction()`, `pocketkid/services.py:174`). + - **Product scope:** Whether overdrafts should apply to approved requests, manual movements, recurring movements, and opening balances equally is unspecified. The code currently blocks each as described above. + - **Future editing and retention:** Child transaction-edit permissions, revision history, and retention of deleted users' attribution are undefined in the current implementation (`Transaction`, `pocketkid/models.py:54`; `delete_child()`, `pocketkid/routes.py:478`). No policy is selected by this document. diff --git a/docs/project-scope.md b/docs/project-scope.md new file mode 100644 index 0000000..f166922 --- /dev/null +++ b/docs/project-scope.md @@ -0,0 +1,39 @@ +# PocketKid Fork – Project Scope + +This fork adapts PocketKid into a lightweight family ledger. + +## Core use case + +Parents manage virtual accounts for their children. + +Each child: +- has an individual login, +- can only access their own account, +- can see their current balance and transaction history, +- may eventually be allowed to create or edit their own transactions, + subject to authorization rules. + +Parents: +- can manage all child accounts, +- can record deposits and withdrawals, +- can approve child requests. + +## Key differences from upstream + +The fork should support: +- negative balances / debts, +- clear display of negative balances, +- reliable history of account changes, +- identification of who created or changed a transaction. + +## Non-goals + +Currently not required: +- recurring allowance automation, +- complex accounting, +- multi-family or public SaaS functionality. + +## Development principle + +Keep divergence from upstream small. +Prefer isolated changes over architectural rewrites. \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index 40205d7..e6c98f6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -34,6 +34,13 @@ "reject": "Reject", "no_pending_requests": "No pending requests.", "available_balance": "Available balance", + "debt": "Debt", + "overdraft_limit": "Overdraft limit", + "overdraft_limit_hint": "Lowest permitted balance (zero or a negative amount).", + "save_overdraft_limit": "Save overdraft limit", + "overdraft_limit_updated": "Overdraft limit updated", + "overdraft_limit_exceeded_by": "Overdraft limit exceeded by {amount}.", + "further_withdrawals_blocked": "Further withdrawals are blocked.", "request_reward": "Request reward", "completed_challenge_label": "Completed challenge", "note": "Note", @@ -108,9 +115,10 @@ "inactive": "Inactive", "settings_title": "Settings", "app_info": "App Info", - "app_version_label": "Version", + "app_version_label": "Fork version", + "app_upstream_label": "Upstream base", "app_credits_label": "Credits", - "app_repo_label": "Official GitHub page", + "app_repo_label": "Fork GitHub page", "push_notifications_title": "Push Notifications", "push_notifications_desc": "Enable or disable push notifications for this device. You will receive real-time alerts for wallet updates and pending approvals.", "push_toggle_btn": "Enable Push Notifications", @@ -141,6 +149,8 @@ "request_approved_credit": "Request approved and credited", "request_approved_debit": "Request approved and debited", "insufficient_balance": "Insufficient balance", + "invalid_overdraft_limit": "Enter zero or a negative overdraft limit", + "overdraft_limit_exceeded": "This withdrawal would exceed the overdraft limit", "unsupported_request": "Unsupported request type", "child_not_found": "Child not found", "invalid_operation": "Invalid operation", @@ -177,7 +187,7 @@ "notif_wallet_debit": "Approved: -{amount}€ from your wallet", "notif_parent_movement": "Parent movement: {amount}€ ({description})", "notif_recurring_applied": "Recurring movement applied: {amount}€ ({description})", - "notif_recurring_failed": "Recurring withdrawal failed for {child}: insufficient balance for {amount}€", + "notif_recurring_failed": "Recurring withdrawal failed for {child}: {amount}€ would exceed the {limit}€ limit", "confirm_delete_1": "Are you sure you want to continue?", "confirm_delete_2": "Final confirmation: execute permanent deletion?", "confirm_delete_generic": "Are you sure you want to delete this item?", diff --git a/locales/it.json b/locales/it.json index 73eacd7..c7a1d75 100644 --- a/locales/it.json +++ b/locales/it.json @@ -34,6 +34,13 @@ "reject": "Rifiuta", "no_pending_requests": "Nessuna richiesta in attesa.", "available_balance": "Saldo disponibile", + "debt": "Debito", + "overdraft_limit": "Limite di scoperto", + "overdraft_limit_hint": "Saldo minimo consentito (zero o un importo negativo).", + "save_overdraft_limit": "Salva limite di scoperto", + "overdraft_limit_updated": "Limite di scoperto aggiornato", + "overdraft_limit_exceeded_by": "Limite di scoperto superato di {amount}.", + "further_withdrawals_blocked": "Ulteriori prelievi sono bloccati.", "request_reward": "Richiedi Premio", "completed_challenge_label": "Sfida completata", "note": "Nota", @@ -108,9 +115,10 @@ "inactive": "Disattivo", "settings_title": "Impostazioni", "app_info": "Informazioni App", - "app_version_label": "Versione", + "app_version_label": "Versione del fork", + "app_upstream_label": "Base upstream", "app_credits_label": "Crediti", - "app_repo_label": "Pagina ufficiale su GitHub", + "app_repo_label": "Pagina GitHub del fork", "push_notifications_title": "Notifiche Push", "push_notifications_desc": "Attiva o disattiva le notifiche push per questo dispositivo. Riceverai avvisi in tempo reale per aggiornamenti del portafoglio e richieste da approvare.", "push_toggle_btn": "Attiva Notifiche Push", @@ -141,6 +149,8 @@ "request_approved_credit": "Richiesta approvata e accreditata", "request_approved_debit": "Richiesta approvata e scalata", "insufficient_balance": "Saldo insufficiente", + "invalid_overdraft_limit": "Inserisci zero o un limite di scoperto negativo", + "overdraft_limit_exceeded": "Questo prelievo supererebbe il limite di scoperto", "unsupported_request": "Tipo richiesta non gestito", "child_not_found": "Figlio non trovato", "invalid_operation": "Operazione non valida", @@ -177,7 +187,7 @@ "notif_wallet_debit": "Approvato: -{amount}€ dal tuo wallet", "notif_parent_movement": "Movimento genitore: {amount}€ ({description})", "notif_recurring_applied": "Movimento ricorrente eseguito: {amount}€ ({description})", - "notif_recurring_failed": "Prelievo ricorrente fallito per {child}: saldo insufficiente per {amount}€", + "notif_recurring_failed": "Prelievo ricorrente fallito per {child}: {amount}€ supererebbe il limite di {limit}€", "confirm_delete_1": "Sei sicuro di voler continuare?", "confirm_delete_2": "Conferma finale: eseguire eliminazione permanente?", "confirm_delete_generic": "Sei sicuro di voler eliminare questo elemento?", diff --git a/pocketkid/config.py b/pocketkid/config.py index e7ae7ce..5efe0e6 100644 --- a/pocketkid/config.py +++ b/pocketkid/config.py @@ -13,11 +13,15 @@ DATA_DIR.mkdir(exist_ok=True) DB_PATH = DATA_DIR / "pocketkid.db" LOCALES_DIR = BASE_DIR / "locales" +VERSION_FILE = BASE_DIR / "VERSION" SUPPORTED_LANGUAGES = ("en", "it") -APP_VERSION = os.getenv("APP_VERSION", "1.0.2") +PROJECT_VERSION = VERSION_FILE.read_text(encoding="utf-8").strip() +APP_VERSION = PROJECT_VERSION APP_CREDITS = os.getenv("APP_CREDITS", "Stefano Perna") -APP_REPO_URL = os.getenv("APP_REPO_URL", "https://github.com/pernastefano/pocketkid") +APP_REPO_URL = os.getenv("APP_REPO_URL", "https://github.com/nocona71/pocketkid") +APP_UPSTREAM_REPO_URL = os.getenv("APP_UPSTREAM_REPO_URL", "https://github.com/pernastefano/pocketkid") +APP_UPSTREAM_COMMIT = os.getenv("APP_UPSTREAM_COMMIT", "b0356f37956fe1f4d027bba68682591e590737b9") class Settings: diff --git a/pocketkid/models.py b/pocketkid/models.py index ed0afde..a5bd830 100644 --- a/pocketkid/models.py +++ b/pocketkid/models.py @@ -21,6 +21,7 @@ class Wallet(db.Model): id = db.Column(db.Integer, primary_key=True) child_id = db.Column(db.Integer, db.ForeignKey("user.id"), unique=True, nullable=False) balance = db.Column(db.Numeric(10, 2), nullable=False, default=0) + minimum_balance = db.Column(db.Numeric(10, 2), nullable=False, default=0, server_default="0") child = db.relationship("User") diff --git a/pocketkid/routes.py b/pocketkid/routes.py index dfd1a67..6691802 100644 --- a/pocketkid/routes.py +++ b/pocketkid/routes.py @@ -22,6 +22,7 @@ Wallet, ) from .services import ( + can_debit, is_push_runtime_disabled, VAPID_PUBLIC_KEY, capitalize_name, @@ -33,6 +34,7 @@ normalize_dt, notify_all_parents, parse_amount, + parse_minimum_balance, register_transaction, tr, ) @@ -338,8 +340,8 @@ def parent_decide_request(request_id: int): return redirect(url_for("dashboard")) if operation_request.request_type == "withdrawal": - if Decimal(wallet.balance) < amount: - flash(tr("insufficient_balance"), "error") + if not can_debit(wallet, amount): + flash(tr("overdraft_limit_exceeded"), "error") return redirect(url_for("dashboard")) wallet.balance = Decimal(wallet.balance) - amount operation_request.status = "approved" @@ -432,8 +434,8 @@ def parent_manual_movement(child_id: int): description = tr("manual_parent_movement") wallet = get_wallet_by_child(child_id) - if movement == "withdraw" and Decimal(wallet.balance) < amount: - flash(tr("insufficient_balance"), "error") + if movement == "withdraw" and not can_debit(wallet, amount): + flash(tr("overdraft_limit_exceeded"), "error") return redirect(url_for("parent_child_wallet", child_id=child_id)) if movement == "deposit": @@ -455,6 +457,25 @@ def parent_manual_movement(child_id: int): flash(tr("movement_saved"), "success") return redirect(url_for("parent_child_wallet", child_id=child_id)) + @app.route("/parent/child//minimum-balance", methods=["POST"]) + @login_required(role="parent") + def parent_update_minimum_balance(child_id: int): + child = db.session.get(User, child_id) + if not child or child.role != "child": + flash(tr("child_not_found"), "error") + return redirect(url_for("dashboard")) + + minimum_balance = parse_minimum_balance(request.form.get("minimum_balance")) + if minimum_balance is None: + flash(tr("invalid_overdraft_limit"), "error") + return redirect(url_for("parent_child_wallet", child_id=child_id)) + + wallet = get_wallet_by_child(child_id) + wallet.minimum_balance = minimum_balance + db.session.commit() + flash(tr("overdraft_limit_updated"), "success") + return redirect(url_for("parent_child_wallet", child_id=child_id)) + @app.route("/parent/child//reset-password", methods=["POST"]) @login_required(role="parent") def parent_reset_child_password(child_id: int): @@ -576,7 +597,7 @@ def parent_children(): ) db.session.add(child) db.session.flush() - db.session.add(Wallet(child_id=child.id, balance=initial_balance)) + db.session.add(Wallet(child_id=child.id, balance=initial_balance, minimum_balance=Decimal("0.00"))) if initial_balance > 0: register_transaction( diff --git a/pocketkid/services.py b/pocketkid/services.py index 478071a..a8a7133 100644 --- a/pocketkid/services.py +++ b/pocketkid/services.py @@ -12,7 +12,15 @@ from pywebpush import WebPushException, webpush from sqlalchemy import text -from .config import APP_CREDITS, APP_REPO_URL, APP_VERSION, LOCALES_DIR, SUPPORTED_LANGUAGES +from .config import ( + APP_CREDITS, + APP_REPO_URL, + APP_UPSTREAM_COMMIT, + APP_UPSTREAM_REPO_URL, + APP_VERSION, + LOCALES_DIR, + SUPPORTED_LANGUAGES, +) from .extensions import db from .models import Notification, PushSubscription, RecurringMovement, Transaction, User, Wallet @@ -76,6 +84,21 @@ def parse_amount(raw: str | None) -> Decimal | None: return amount +def parse_minimum_balance(raw: str | None) -> Decimal | None: + try: + amount = Decimal(raw).quantize(Decimal("0.01")) + except (InvalidOperation, TypeError): + return None + if not amount.is_finite() or amount > 0: + return None + return amount + + +def can_debit(wallet: Wallet, amount: Decimal) -> bool: + resulting_balance = Decimal(wallet.balance) - Decimal(amount) + return resulting_balance >= Decimal(wallet.minimum_balance) + + def normalize_dt(value: datetime) -> datetime: if value.tzinfo is None: return value.replace(tzinfo=UTC) @@ -125,6 +148,8 @@ def inject_context(): "app_version": APP_VERSION, "app_credits": APP_CREDITS, "app_repo_url": APP_REPO_URL, + "app_upstream_repo_url": APP_UPSTREAM_REPO_URL, + "app_upstream_commit": APP_UPSTREAM_COMMIT, } @@ -161,11 +186,16 @@ def ensure_schema_updates(): db.session.execute(text("ALTER TABLE recurring_movement ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT 0")) db.session.commit() + wallet_columns = [row[1] for row in db.session.execute(text("PRAGMA table_info(wallet)"))] + if "minimum_balance" not in wallet_columns: + db.session.execute(text("ALTER TABLE wallet ADD COLUMN minimum_balance NUMERIC(10, 2) NOT NULL DEFAULT 0")) + db.session.commit() + def get_wallet_by_child(child_id: int) -> Wallet: wallet = Wallet.query.filter_by(child_id=child_id).first() if wallet is None: - wallet = Wallet(child_id=child_id, balance=Decimal("0.00")) + wallet = Wallet(child_id=child_id, balance=Decimal("0.00"), minimum_balance=Decimal("0.00")) db.session.add(wallet) db.session.commit() return wallet @@ -263,10 +293,15 @@ def process_recurring_movements(): wallet = get_wallet_by_child(item.child_id) amount = Decimal(item.amount) - if item.movement == "withdraw" and Decimal(wallet.balance) < amount: + if item.movement == "withdraw" and not can_debit(wallet, amount): notify_all_parents( kind="recurring_failed", - message=tr("notif_recurring_failed", child=capitalize_name(item.child.username), amount=f"{amount:.2f}"), + message=tr( + "notif_recurring_failed", + child=capitalize_name(item.child.username), + amount=f"{amount:.2f}", + limit=f"{Decimal(wallet.minimum_balance):.2f}", + ), ) item.next_run_at = next_run(item.next_run_at, item.frequency) continue diff --git a/static/css/styles.css b/static/css/styles.css index 1f0b101..346d43e 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -119,6 +119,21 @@ main { font-weight: 700; } +.balance.negative { + color: #ffd0d0; +} + +.overdraft-warning { + display: grid; + gap: 2px; + margin-top: 12px; + padding: 10px; + border-radius: 10px; + background: rgba(127, 29, 29, 0.72); + color: #fff; + font-size: 0.9rem; +} + .list { display: grid; gap: 10px; diff --git a/templates/child_dashboard.html b/templates/child_dashboard.html index 9b961d4..8656cdc 100644 --- a/templates/child_dashboard.html +++ b/templates/child_dashboard.html @@ -2,8 +2,15 @@ {% block title %}{{ _('dashboard_child') }} - {{ _('app_name') }}{% endblock %} {% block content %}
-

{{ _('available_balance') }}

-

{{ wallet.balance|eur }}

+

{{ _('debt') if wallet.balance < 0 else _('available_balance') }}

+

{{ wallet.balance|eur }}

+

{{ _('overdraft_limit') }}: {{ wallet.minimum_balance|eur }}

+ {% if wallet.balance < wallet.minimum_balance %} +
+ {{ _('overdraft_limit_exceeded_by', amount=(wallet.minimum_balance - wallet.balance)|eur) }} + {{ _('further_withdrawals_blocked') }} +
+ {% endif %}
diff --git a/templates/parent_child_wallet.html b/templates/parent_child_wallet.html index 3df519c..aa06ae4 100644 --- a/templates/parent_child_wallet.html +++ b/templates/parent_child_wallet.html @@ -7,7 +7,27 @@

{{ _('child_wallet', child=child.username|name_cap) }}

-

{{ wallet.balance|eur }}

+

+ {{ wallet.balance|eur }}{% if wallet.balance < 0 %} · {{ _('debt') }}{% endif %} +

+

{{ _('overdraft_limit') }}: {{ wallet.minimum_balance|eur }}

+ {% if wallet.balance < wallet.minimum_balance %} +
+ {{ _('overdraft_limit_exceeded_by', amount=(wallet.minimum_balance - wallet.balance)|eur) }} + {{ _('further_withdrawals_blocked') }} +
+ {% endif %} +
+ +
+

{{ _('overdraft_limit') }}

+
+ +

{{ _('overdraft_limit_hint') }}

+ +
diff --git a/templates/parent_children.html b/templates/parent_children.html index 12b472c..a781f25 100644 --- a/templates/parent_children.html +++ b/templates/parent_children.html @@ -34,7 +34,9 @@

{{ _('registered_children') }}

{{ row.child.username|name_cap }}

-

{{ row.wallet.balance|eur }}

+

+ {{ row.wallet.balance|eur }}{% if row.wallet.balance < 0 %} · {{ _('debt') }}{% endif %} +

diff --git a/templates/parent_dashboard.html b/templates/parent_dashboard.html index 1e33247..b68b839 100644 --- a/templates/parent_dashboard.html +++ b/templates/parent_dashboard.html @@ -17,7 +17,9 @@

{{ _('children_wallets') }}

{{ row.child.username|name_cap }}

{{ _('pending_count', count=row.pending_count) }}

-

{{ row.wallet.balance|eur }}

+

+ {{ row.wallet.balance|eur }}{% if row.wallet.balance < 0 %} · {{ _('debt') }}{% endif %} +

{% else %}

{{ _('no_children') }}

diff --git a/templates/settings.html b/templates/settings.html index 6762455..e246712 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -37,6 +37,9 @@

{{ _('change_password') }}

{{ _('app_info') }}

{{ _('app_version_label') }}: {{ app_version }}

+

{{ _('app_upstream_label') }}: + {{ app_upstream_commit[:7] }} +

{{ _('app_credits_label') }}: {{ app_credits }}

{{ _('app_repo_label') }}
diff --git a/tests/test_negative_balances.py b/tests/test_negative_balances.py new file mode 100644 index 0000000..f5aaf30 --- /dev/null +++ b/tests/test_negative_balances.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +import os +import unittest +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +os.environ.setdefault("VAPID_PUBLIC_KEY", "test-public-key") +os.environ.setdefault("VAPID_PRIVATE_KEY", "test-private-key") + +from pocketkid import create_app +from pocketkid.config import Settings +from pocketkid.extensions import db +from pocketkid.models import Notification, OperationRequest, RecurringMovement, Transaction, User, Wallet + + +class NegativeBalanceTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + Settings.SQLALCHEMY_DATABASE_URI = "sqlite://" + cls.app = create_app() + cls.app.config.update(TESTING=True, SECRET_KEY="test-secret") + cls.client = cls.app.test_client() + + def setUp(self): + with self.app.app_context(): + db.drop_all() + db.create_all() + + def tearDown(self): + with self.app.app_context(): + db.session.remove() + + def seed_wallet(self, balance: str, minimum_balance: str | None = None) -> tuple[int, int]: + with self.app.app_context(): + parent = User( + username="parent", + password_hash="unused", + role="parent", + preferred_language="en", + ) + child = User( + username="child", + password_hash="unused", + role="child", + preferred_language="en", + ) + db.session.add_all([parent, child]) + db.session.flush() + wallet = Wallet(child_id=child.id, balance=Decimal(balance)) + if minimum_balance is not None: + wallet.minimum_balance = Decimal(minimum_balance) + db.session.add(wallet) + db.session.commit() + return parent.id, child.id + + def login_as(self, user_id: int): + with self.client.session_transaction() as session: + session["user_id"] = user_id + + def test_approved_withdrawal_can_cross_zero(self): + cases = ( + ("10.00", "0.00", "3.00", "7.00"), + ("3.00", "0.00", "3.00", "0.00"), + ("2.00", "-1.00", "3.00", "-1.00"), + ) + + for starting_balance, minimum_balance, amount, expected_balance in cases: + with self.subTest(expected_balance=expected_balance): + self.setUp() + parent_id, child_id = self.seed_wallet(starting_balance, minimum_balance) + with self.app.app_context(): + operation_request = OperationRequest( + request_type="withdrawal", + status="pending", + child_id=child_id, + amount=Decimal(amount), + description="Approved withdrawal", + ) + db.session.add(operation_request) + db.session.commit() + request_id = operation_request.id + + self.login_as(parent_id) + response = self.client.post( + f"/parent/request/{request_id}/decision", + data={"decision": "approve"}, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + wallet = Wallet.query.filter_by(child_id=child_id).one() + operation_request = db.session.get(OperationRequest, request_id) + transaction = Transaction.query.filter_by(child_id=child_id).one() + self.assertEqual(wallet.balance, Decimal(expected_balance)) + self.assertEqual(operation_request.status, "approved") + self.assertEqual(operation_request.reviewed_by, parent_id) + self.assertEqual(transaction.amount, -Decimal(amount)) + self.assertEqual(transaction.created_by, parent_id) + + def test_manual_withdrawal_can_cross_zero(self): + cases = ( + ("10.00", "0.00", "3.00", "7.00"), + ("3.00", "0.00", "3.00", "0.00"), + ("2.00", "-1.00", "3.00", "-1.00"), + ) + + for starting_balance, minimum_balance, amount, expected_balance in cases: + with self.subTest(expected_balance=expected_balance): + self.setUp() + parent_id, child_id = self.seed_wallet(starting_balance, minimum_balance) + self.login_as(parent_id) + + response = self.client.post( + f"/parent/child/{child_id}/manual", + data={ + "movement": "withdraw", + "amount": amount, + "deposit_mode": "free", + "description": "Manual withdrawal", + }, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + wallet = Wallet.query.filter_by(child_id=child_id).one() + transaction = Transaction.query.filter_by(child_id=child_id).one() + self.assertEqual(wallet.balance, Decimal(expected_balance)) + self.assertEqual(transaction.kind, "parent_withdrawal") + self.assertEqual(transaction.amount, -Decimal(amount)) + self.assertEqual(transaction.created_by, parent_id) + + def test_recurring_withdrawal_can_cross_zero(self): + cases = ( + ("10.00", "0.00", "3.00", "7.00"), + ("3.00", "0.00", "3.00", "0.00"), + ("2.00", "-1.00", "3.00", "-1.00"), + ) + + for starting_balance, minimum_balance, amount, expected_balance in cases: + with self.subTest(expected_balance=expected_balance): + self.setUp() + parent_id, child_id = self.seed_wallet(starting_balance, minimum_balance) + with self.app.app_context(): + movement = RecurringMovement( + child_id=child_id, + movement="withdraw", + amount=Decimal(amount), + frequency="daily", + description="Recurring withdrawal", + deposit_mode="free", + next_run_at=datetime.now(UTC) - timedelta(minutes=1), + created_by=parent_id, + ) + db.session.add(movement) + db.session.commit() + movement_id = movement.id + + self.login_as(parent_id) + response = self.client.get("/dashboard") + + self.assertEqual(response.status_code, 200) + with self.app.app_context(): + wallet = Wallet.query.filter_by(child_id=child_id).one() + movement = db.session.get(RecurringMovement, movement_id) + transaction = Transaction.query.filter_by(child_id=child_id).one() + self.assertEqual(wallet.balance, Decimal(expected_balance)) + self.assertEqual(transaction.kind, "recurring_withdraw") + self.assertEqual(transaction.amount, -Decimal(amount)) + self.assertGreater(movement.next_run_at, datetime.now(UTC).replace(tzinfo=None)) + self.assertIsNone(Notification.query.filter_by(kind="recurring_failed").first()) + + def test_default_zero_limit_blocks_approved_withdrawal(self): + parent_id, child_id = self.seed_wallet("2.00") + with self.app.app_context(): + wallet = Wallet.query.filter_by(child_id=child_id).one() + self.assertEqual(wallet.minimum_balance, Decimal("0.00")) + operation_request = OperationRequest( + request_type="withdrawal", + status="pending", + child_id=child_id, + amount=Decimal("3.00"), + description="Blocked withdrawal", + ) + db.session.add(operation_request) + db.session.commit() + request_id = operation_request.id + + self.login_as(parent_id) + response = self.client.post( + f"/parent/request/{request_id}/decision", + data={"decision": "approve"}, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + wallet = Wallet.query.filter_by(child_id=child_id).one() + operation_request = db.session.get(OperationRequest, request_id) + self.assertEqual(wallet.balance, Decimal("2.00")) + self.assertEqual(operation_request.status, "pending") + self.assertIsNone(operation_request.reviewed_by) + self.assertEqual(Transaction.query.filter_by(child_id=child_id).count(), 0) + + def test_default_zero_limit_blocks_manual_withdrawal(self): + parent_id, child_id = self.seed_wallet("2.00") + self.login_as(parent_id) + + response = self.client.post( + f"/parent/child/{child_id}/manual", + data={ + "movement": "withdraw", + "amount": "3.00", + "deposit_mode": "free", + "description": "Blocked manual withdrawal", + }, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + wallet = Wallet.query.filter_by(child_id=child_id).one() + self.assertEqual(wallet.balance, Decimal("2.00")) + self.assertEqual(Transaction.query.filter_by(child_id=child_id).count(), 0) + + def test_default_zero_limit_skips_recurring_withdrawal(self): + parent_id, child_id = self.seed_wallet("2.00") + with self.app.app_context(): + previous_run = datetime.now(UTC) - timedelta(minutes=1) + movement = RecurringMovement( + child_id=child_id, + movement="withdraw", + amount=Decimal("3.00"), + frequency="daily", + description="Blocked recurring withdrawal", + deposit_mode="free", + next_run_at=previous_run, + created_by=parent_id, + ) + db.session.add(movement) + db.session.commit() + movement_id = movement.id + + self.login_as(parent_id) + response = self.client.get("/dashboard") + + self.assertEqual(response.status_code, 200) + with self.app.app_context(): + wallet = Wallet.query.filter_by(child_id=child_id).one() + movement = db.session.get(RecurringMovement, movement_id) + notification = Notification.query.filter_by(kind="recurring_failed").one() + self.assertEqual(wallet.balance, Decimal("2.00")) + self.assertEqual(Transaction.query.filter_by(child_id=child_id).count(), 0) + self.assertGreater(movement.next_run_at, previous_run.replace(tzinfo=None)) + self.assertIn("0.00", notification.message) + + def test_parent_can_update_limit_and_child_can_see_it(self): + parent_id, child_id = self.seed_wallet("0.00") + self.login_as(parent_id) + + response = self.client.post( + f"/parent/child/{child_id}/minimum-balance", + data={"minimum_balance": "-25.50"}, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + wallet = Wallet.query.filter_by(child_id=child_id).one() + self.assertEqual(wallet.minimum_balance, Decimal("-25.50")) + + self.login_as(child_id) + response = self.client.get("/dashboard") + self.assertEqual(response.status_code, 200) + self.assertIn(b"Overdraft limit", response.data) + self.assertIn(b"\xe2\x82\xac -25.50", response.data) + + def test_child_cannot_update_limit(self): + _, child_id = self.seed_wallet("0.00") + self.login_as(child_id) + + response = self.client.post( + f"/parent/child/{child_id}/minimum-balance", + data={"minimum_balance": "-25.50"}, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + wallet = Wallet.query.filter_by(child_id=child_id).one() + self.assertEqual(wallet.minimum_balance, Decimal("0.00")) + + def test_positive_limit_is_rejected(self): + parent_id, child_id = self.seed_wallet("0.00", "-10.00") + self.login_as(parent_id) + + response = self.client.post( + f"/parent/child/{child_id}/minimum-balance", + data={"minimum_balance": "5.00"}, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + wallet = Wallet.query.filter_by(child_id=child_id).one() + self.assertEqual(wallet.minimum_balance, Decimal("-10.00")) + + def test_negative_balance_is_labeled_as_debt(self): + parent_id, child_id = self.seed_wallet("-1.00", "-10.00") + + self.login_as(child_id) + child_response = self.client.get("/dashboard") + self.assertEqual(child_response.status_code, 200) + self.assertIn(b"Debt", child_response.data) + self.assertIn(b"\xe2\x82\xac -1.00", child_response.data) + + self.login_as(parent_id) + for path in ("/dashboard", "/parent/children", f"/parent/child/{child_id}"): + with self.subTest(path=path): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertIn(b"Debt", response.data) + self.assertIn(b"\xe2\x82\xac -1.00", response.data) + + def test_balance_below_limit_shows_withdrawal_warning(self): + parent_id, child_id = self.seed_wallet("-900.00", "0.00") + + self.login_as(child_id) + child_response = self.client.get("/dashboard") + self.assertEqual(child_response.status_code, 200) + self.assertIn(b"Overdraft limit exceeded by \xe2\x82\xac 900.00.", child_response.data) + self.assertIn(b"Further withdrawals are blocked.", child_response.data) + + self.login_as(parent_id) + parent_response = self.client.get(f"/parent/child/{child_id}") + self.assertEqual(parent_response.status_code, 200) + self.assertIn(b"Overdraft limit exceeded by \xe2\x82\xac 900.00.", parent_response.data) + self.assertIn(b"Further withdrawals are blocked.", parent_response.data) + + def test_balance_at_limit_does_not_show_withdrawal_warning(self): + parent_id, child_id = self.seed_wallet("-10.00", "-10.00") + + self.login_as(child_id) + child_response = self.client.get("/dashboard") + self.assertEqual(child_response.status_code, 200) + self.assertNotIn(b"Further withdrawals are blocked.", child_response.data) + + self.login_as(parent_id) + parent_response = self.client.get(f"/parent/child/{child_id}") + self.assertEqual(parent_response.status_code, 200) + self.assertNotIn(b"Further withdrawals are blocked.", parent_response.data) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py new file mode 100644 index 0000000..09ea868 --- /dev/null +++ b/tests/test_release_metadata.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import os +import re +import unittest + +os.environ.setdefault("VAPID_PUBLIC_KEY", "test-public-key") +os.environ.setdefault("VAPID_PRIVATE_KEY", "test-private-key") + +from pocketkid.config import ( + APP_REPO_URL, + APP_UPSTREAM_COMMIT, + APP_UPSTREAM_REPO_URL, + PROJECT_VERSION, + VERSION_FILE, +) + + +class ReleaseMetadataTests(unittest.TestCase): + def test_project_version_matches_version_file_and_semver(self): + stored_version = VERSION_FILE.read_text(encoding="utf-8").strip() + + self.assertEqual(PROJECT_VERSION, stored_version) + self.assertRegex(PROJECT_VERSION, r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") + + def test_fork_and_upstream_metadata_are_separate(self): + self.assertEqual(APP_REPO_URL, "https://github.com/nocona71/pocketkid") + self.assertEqual(APP_UPSTREAM_REPO_URL, "https://github.com/pernastefano/pocketkid") + self.assertIsNotNone(re.fullmatch(r"[0-9a-f]{40}", APP_UPSTREAM_COMMIT)) + self.assertNotEqual(APP_REPO_URL, APP_UPSTREAM_REPO_URL) + + +if __name__ == "__main__": + unittest.main()