From 0297e50d45d85a973d92d02a74d760a7602b86fa Mon Sep 17 00:00:00 2001
From: Toni Incog <63201084+nocona71@users.noreply.github.com>
Date: Tue, 29 Sep 2026 10:51:38 +0200
Subject: [PATCH 1/8] chore: add VS Code dev container
---
.devcontainer/Dockerfile | 9 +++++++++
.devcontainer/devcontainer.json | 31 +++++++++++++++++++++++++++++++
2 files changed, 40 insertions(+)
create mode 100644 .devcontainer/Dockerfile
create mode 100644 .devcontainer/devcontainer.json
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.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..1b732a2
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,31 @@
+{
+ "name": "PocketKid Development",
+
+ "build": {
+ "dockerfile": "Dockerfile"
+ },
+
+ "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
From d101cc5ab6a1f8264c44a230e14a4af57715c502 Mon Sep 17 00:00:00 2001
From: Toni Incog <63201084+nocona71@users.noreply.github.com>
Date: Tue, 29 Sep 2026 10:51:38 +0200
Subject: [PATCH 2/8] chore: add agent development instructions
---
AGENTS.md | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 117 insertions(+)
create mode 100644 AGENTS.md
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
From b966bd21cf694f854de9ab177bcd7ad5da24792c Mon Sep 17 00:00:00 2001
From: Toni Incog <63201084+nocona71@users.noreply.github.com>
Date: Tue, 29 Sep 2026 10:51:38 +0200
Subject: [PATCH 3/8] docs: record withdrawal flow findings
---
docs/findings/withdrawal-flow.md | 128 +++++++++++++++++++++++++++++++
1 file changed, 128 insertions(+)
create mode 100644 docs/findings/withdrawal-flow.md
diff --git a/docs/findings/withdrawal-flow.md b/docs/findings/withdrawal-flow.md
new file mode 100644
index 0000000..642e78c
--- /dev/null
+++ b/docs/findings/withdrawal-flow.md
@@ -0,0 +1,128 @@
+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.
From 756dc9c4137c5ae28a37386836607ee80cc809e2 Mon Sep 17 00:00:00 2001
From: Toni Incog <63201084+nocona71@users.noreply.github.com>
Date: Tue, 29 Sep 2026 10:51:38 +0200
Subject: [PATCH 4/8] chore: add Node.js to dev container
---
.devcontainer/devcontainer.json | 12 +++++-------
1 file changed, 5 insertions(+), 7 deletions(-)
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 1b732a2..beabaf3 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -1,20 +1,19 @@
{
"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": [
@@ -26,6 +25,5 @@
}
}
},
-
"remoteUser": "vscode"
}
\ No newline at end of file
From 6615130158276a9eb975aef42debaf18a1e7b85c Mon Sep 17 00:00:00 2001
From: Toni Incog <63201084+nocona71@users.noreply.github.com>
Date: Tue, 29 Sep 2026 10:51:58 +0200
Subject: [PATCH 5/8] chore: lock dev container features
---
.devcontainer/devcontainer-lock.json | 9 +++++++++
1 file changed, 9 insertions(+)
create mode 100644 .devcontainer/devcontainer-lock.json
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"
+ }
+ }
+}
From 3a4b027aeba9585a21ad16f486b644372228d320 Mon Sep 17 00:00:00 2001
From: Toni Incog <63201084+nocona71@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:02:37 +0200
Subject: [PATCH 6/8] feat: add configurable overdraft limitsFeat/negative
balances (#1)
* docs: define negative balance policy
* feat: add configurable overdraft limits
---
README.md | 4 +-
docs/decisions/0001-negative-balances.md | 67 +++++
docs/findings/withdrawal-flow.md | 4 +
docs/project-scope.md | 39 +++
locales/en.json | 9 +-
locales/it.json | 9 +-
pocketkid/models.py | 1 +
pocketkid/routes.py | 31 ++-
pocketkid/services.py | 31 ++-
static/css/styles.css | 4 +
templates/child_dashboard.html | 5 +-
templates/parent_child_wallet.html | 16 +-
templates/parent_children.html | 4 +-
templates/parent_dashboard.html | 4 +-
tests/test_negative_balances.py | 322 +++++++++++++++++++++++
15 files changed, 533 insertions(+), 17 deletions(-)
create mode 100644 docs/decisions/0001-negative-balances.md
create mode 100644 docs/project-scope.md
create mode 100644 tests/test_negative_balances.py
diff --git a/README.md b/README.md
index c7755e3..7b22378 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Public repository:
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.
@@ -165,7 +165,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.
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
index 642e78c..881c247 100644
--- a/docs/findings/withdrawal-flow.md
+++ b/docs/findings/withdrawal-flow.md
@@ -1,3 +1,7 @@
+> 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.
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..42ae15d 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -34,6 +34,11 @@
"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",
"request_reward": "Request reward",
"completed_challenge_label": "Completed challenge",
"note": "Note",
@@ -141,6 +146,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 +184,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..f53178f 100644
--- a/locales/it.json
+++ b/locales/it.json
@@ -34,6 +34,11 @@
"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",
"request_reward": "Richiedi Premio",
"completed_challenge_label": "Sfida completata",
"note": "Nota",
@@ -141,6 +146,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 +184,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/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..8a893f0 100644
--- a/pocketkid/services.py
+++ b/pocketkid/services.py
@@ -76,6 +76,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)
@@ -161,11 +176,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 +283,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..60fbae5 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -119,6 +119,10 @@ main {
font-weight: 700;
}
+.balance.negative {
+ color: #ffd0d0;
+}
+
.list {
display: grid;
gap: 10px;
diff --git a/templates/child_dashboard.html b/templates/child_dashboard.html
index 9b961d4..8421e5e 100644
--- a/templates/child_dashboard.html
+++ b/templates/child_dashboard.html
@@ -2,8 +2,9 @@
{% 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 }}
diff --git a/templates/parent_child_wallet.html b/templates/parent_child_wallet.html
index 3df519c..d7e6fe4 100644
--- a/templates/parent_child_wallet.html
+++ b/templates/parent_child_wallet.html
@@ -7,7 +7,21 @@
{{ _('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 }}
+
+
+
+ {{ _('overdraft_limit') }}
+
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.wallet.balance|eur }}
+
+ {{ row.wallet.balance|eur }}{% if row.wallet.balance < 0 %} · {{ _('debt') }}{% endif %}
+
{% else %}
{{ _('no_children') }}
diff --git a/tests/test_negative_balances.py b/tests/test_negative_balances.py
new file mode 100644
index 0000000..cbf6f22
--- /dev/null
+++ b/tests/test_negative_balances.py
@@ -0,0 +1,322 @@
+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)
+
+
+if __name__ == "__main__":
+ unittest.main()
From 9a5a2da2cd4f819489936971855f5ee3b4acd25e Mon Sep 17 00:00:00 2001
From: Toni Incog <63201084+nocona71@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:33:39 +0200
Subject: [PATCH 7/8] chore: add fork release process (#2)
---
.env.example | 3 +
.github/dependabot.yml | 6 +
.github/workflows/ci.yml | 76 +++++++++++
.github/workflows/docker-publish.yml | 193 +++++++++++++++++++++++----
README.md | 20 ++-
VERSION | 1 +
docker-compose.yml | 2 +-
docs/RELEASING.md | 115 ++++++++++++++++
locales/en.json | 5 +-
locales/it.json | 5 +-
pocketkid/config.py | 8 +-
pocketkid/services.py | 12 +-
templates/settings.html | 3 +
tests/test_release_metadata.py | 34 +++++
14 files changed, 448 insertions(+), 35 deletions(-)
create mode 100644 .github/dependabot.yml
create mode 100644 .github/workflows/ci.yml
create mode 100644 VERSION
create mode 100644 docs/RELEASING.md
create mode 100644 tests/test_release_metadata.py
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/README.md b/README.md
index 7b22378..b3efad6 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,9 @@
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.
@@ -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
@@ -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/locales/en.json b/locales/en.json
index 42ae15d..0ef83d1 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -113,9 +113,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",
diff --git a/locales/it.json b/locales/it.json
index f53178f..fba0a35 100644
--- a/locales/it.json
+++ b/locales/it.json
@@ -113,9 +113,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",
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/services.py b/pocketkid/services.py
index 8a893f0..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
@@ -140,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,
}
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') }}
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()
From ce2be255d555f24abae26edd9c5471b1a6da0918 Mon Sep 17 00:00:00 2001
From: Toni Incog <63201084+nocona71@users.noreply.github.com>
Date: Tue, 29 Sep 2026 15:15:54 +0200
Subject: [PATCH 8/8] fix: clarify balances below overdraft limit
---
locales/en.json | 2 ++
locales/it.json | 2 ++
static/css/styles.css | 11 +++++++++++
templates/child_dashboard.html | 6 ++++++
templates/parent_child_wallet.html | 6 ++++++
tests/test_negative_balances.py | 28 ++++++++++++++++++++++++++++
6 files changed, 55 insertions(+)
diff --git a/locales/en.json b/locales/en.json
index 0ef83d1..e6c98f6 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -39,6 +39,8 @@
"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",
diff --git a/locales/it.json b/locales/it.json
index fba0a35..c7a1d75 100644
--- a/locales/it.json
+++ b/locales/it.json
@@ -39,6 +39,8 @@
"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",
diff --git a/static/css/styles.css b/static/css/styles.css
index 60fbae5..346d43e 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -123,6 +123,17 @@ main {
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 8421e5e..8656cdc 100644
--- a/templates/child_dashboard.html
+++ b/templates/child_dashboard.html
@@ -5,6 +5,12 @@
{{ _('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 d7e6fe4..aa06ae4 100644
--- a/templates/parent_child_wallet.html
+++ b/templates/parent_child_wallet.html
@@ -11,6 +11,12 @@
{{ 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 %}
diff --git a/tests/test_negative_balances.py b/tests/test_negative_balances.py
index cbf6f22..f5aaf30 100644
--- a/tests/test_negative_balances.py
+++ b/tests/test_negative_balances.py
@@ -317,6 +317,34 @@ def test_negative_balance_is_labeled_as_debt(self):
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()