From d4ab76d749744593add6dad4fd1066b557b34608 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 29 Jun 2026 20:58:02 +0200 Subject: [PATCH 01/10] [morph] Add bank example: models, tests, CLI, and Qt 6 GUI A feature-rich example application in examples/bank demonstrating morph together with the LASTRADA Lightweight ORM (SQLite via ODBC). Models (each = wire DTOs + Lightweight entity + model + Catch2 test): Auth, Account, Transaction (atomic transfer), Payee, Payment (one-off / scheduled / standing), Card, Loan (with amortization schedule), Budget (spending analytics), Notification, and Statement. Highlights: - Two-layer design: plain-aggregate wire DTOs (Glaze) vs Field<>-based Lightweight entities, mapped in the model. Money as integer minor units. - Per-model DataMapper (one connection per strand); shared ledger_ops for debit/credit/transfer; transfers/payments atomic via SqlTransaction. - Sessions/principal scoping; a remote-backend + custom IAuthorizer test; an offline queue + SyncWorker replay test. - bank_cli runs the full scenario on a local then a simulated remote backend with identical call sites. - Qt 6 GUI (bank_gui, -DMORPH_BUILD_BANK_GUI=ON): QtExecutor over a local backend, a warm "Claude-inspired" theme, and Login/Accounts/Move Money/ Cards/Payees/Loans screens. Headless screenshot smoke test included. Tests: 101 assertions across 16 Catch2 cases, all green. The example is opt-in (-DMORPH_BUILD_BANK_EXAMPLE=ON) so the default build is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FXnfVmdiHoCRGzj8PtuAJq --- .gitignore | 2 + CMakeLists.txt | 6 + examples/bank/CMakeLists.txt | 96 ++++++++ examples/bank/README.md | 125 ++++++++++ examples/bank/gui/BankClient.cpp | 34 +++ examples/bank/gui/BankClient.hpp | 47 ++++ examples/bank/gui/CMakeLists.txt | 22 ++ examples/bank/gui/LoginView.cpp | 107 +++++++++ examples/bank/gui/LoginView.hpp | 40 ++++ examples/bank/gui/MainWindow.cpp | 111 +++++++++ examples/bank/gui/MainWindow.hpp | 43 ++++ examples/bank/gui/Page.hpp | 17 ++ examples/bank/gui/Smoke.hpp | 117 +++++++++ examples/bank/gui/Theme.hpp | 201 ++++++++++++++++ examples/bank/gui/Ui.hpp | 113 +++++++++ examples/bank/gui/main.cpp | 55 +++++ examples/bank/gui/views/AccountsView.cpp | 177 ++++++++++++++ examples/bank/gui/views/AccountsView.hpp | 41 ++++ examples/bank/gui/views/CardsView.cpp | 164 +++++++++++++ examples/bank/gui/views/CardsView.hpp | 39 +++ examples/bank/gui/views/LoansView.cpp | 219 +++++++++++++++++ examples/bank/gui/views/LoansView.hpp | 46 ++++ examples/bank/gui/views/MoveMoneyView.cpp | 225 ++++++++++++++++++ examples/bank/gui/views/MoveMoneyView.hpp | 46 ++++ examples/bank/gui/views/PayeesView.cpp | 175 ++++++++++++++ examples/bank/gui/views/PayeesView.hpp | 48 ++++ examples/bank/include/bank/app/app.hpp | 57 +++++ examples/bank/include/bank/core/errors.hpp | 44 ++++ examples/bank/include/bank/core/money.hpp | 48 ++++ examples/bank/include/bank/core/principal.hpp | 29 +++ examples/bank/include/bank/core/types.hpp | 113 +++++++++ .../bank/include/bank/db/account_entity.hpp | 39 +++ .../bank/include/bank/db/budget_entity.hpp | 22 ++ examples/bank/include/bank/db/card_entity.hpp | 30 +++ examples/bank/include/bank/db/database.hpp | 31 +++ examples/bank/include/bank/db/db_model.hpp | 36 +++ examples/bank/include/bank/db/ledger_ops.hpp | 87 +++++++ examples/bank/include/bank/db/loan_entity.hpp | 30 +++ .../include/bank/db/notification_entity.hpp | 24 ++ .../bank/include/bank/db/payee_entity.hpp | 22 ++ .../bank/include/bank/db/payment_entity.hpp | 32 +++ examples/bank/include/bank/db/txn_entity.hpp | 36 +++ examples/bank/include/bank/db/user_entity.hpp | 26 ++ .../bank/include/bank/dto/account_dto.hpp | 67 ++++++ examples/bank/include/bank/dto/auth_dto.hpp | 51 ++++ examples/bank/include/bank/dto/budget_dto.hpp | 67 ++++++ examples/bank/include/bank/dto/card_dto.hpp | 72 ++++++ examples/bank/include/bank/dto/common.hpp | 17 ++ examples/bank/include/bank/dto/loan_dto.hpp | 85 +++++++ .../include/bank/dto/notification_dto.hpp | 53 +++++ examples/bank/include/bank/dto/payee_dto.hpp | 68 ++++++ .../bank/include/bank/dto/payment_dto.hpp | 83 +++++++ .../bank/include/bank/dto/statement_dto.hpp | 42 ++++ .../bank/include/bank/dto/transaction_dto.hpp | 77 ++++++ .../include/bank/models/account_model.hpp | 53 +++++ .../bank/include/bank/models/auth_model.hpp | 46 ++++ .../bank/include/bank/models/budget_model.hpp | 37 +++ .../bank/include/bank/models/card_model.hpp | 46 ++++ .../bank/include/bank/models/loan_model.hpp | 41 ++++ .../bank/models/notification_model.hpp | 36 +++ .../bank/include/bank/models/payee_model.hpp | 40 ++++ .../include/bank/models/payment_model.hpp | 51 ++++ .../include/bank/models/statement_model.hpp | 27 +++ .../include/bank/models/transaction_model.hpp | 44 ++++ examples/bank/src/app/app.cpp | 30 +++ examples/bank/src/cli/main.cpp | 202 ++++++++++++++++ examples/bank/src/core/money.cpp | 29 +++ examples/bank/src/db/schema.cpp | 138 +++++++++++ examples/bank/src/models/account_model.cpp | 117 +++++++++ examples/bank/src/models/auth_model.cpp | 104 ++++++++ examples/bank/src/models/budget_model.cpp | 129 ++++++++++ examples/bank/src/models/card_model.cpp | 153 ++++++++++++ examples/bank/src/models/loan_model.cpp | 181 ++++++++++++++ .../bank/src/models/notification_model.cpp | 105 ++++++++ examples/bank/src/models/payee_model.cpp | 77 ++++++ examples/bank/src/models/payment_model.cpp | 176 ++++++++++++++ examples/bank/src/models/statement_model.cpp | 71 ++++++ .../bank/src/models/transaction_model.cpp | 99 ++++++++ examples/bank/tests/bank_test_support.hpp | 88 +++++++ examples/bank/tests/test_account.cpp | 106 +++++++++ examples/bank/tests/test_auth.cpp | 101 ++++++++ examples/bank/tests/test_budget.cpp | 75 ++++++ examples/bank/tests/test_card.cpp | 74 ++++++ examples/bank/tests/test_loan.cpp | 79 ++++++ examples/bank/tests/test_notification.cpp | 51 ++++ examples/bank/tests/test_offline.cpp | 69 ++++++ examples/bank/tests/test_payee.cpp | 84 +++++++ examples/bank/tests/test_payment.cpp | 92 +++++++ examples/bank/tests/test_remote.cpp | 80 +++++++ examples/bank/tests/test_statement.cpp | 53 +++++ examples/bank/tests/test_transaction.cpp | 121 ++++++++++ 91 files changed, 6809 insertions(+) create mode 100644 examples/bank/CMakeLists.txt create mode 100644 examples/bank/README.md create mode 100644 examples/bank/gui/BankClient.cpp create mode 100644 examples/bank/gui/BankClient.hpp create mode 100644 examples/bank/gui/CMakeLists.txt create mode 100644 examples/bank/gui/LoginView.cpp create mode 100644 examples/bank/gui/LoginView.hpp create mode 100644 examples/bank/gui/MainWindow.cpp create mode 100644 examples/bank/gui/MainWindow.hpp create mode 100644 examples/bank/gui/Page.hpp create mode 100644 examples/bank/gui/Smoke.hpp create mode 100644 examples/bank/gui/Theme.hpp create mode 100644 examples/bank/gui/Ui.hpp create mode 100644 examples/bank/gui/main.cpp create mode 100644 examples/bank/gui/views/AccountsView.cpp create mode 100644 examples/bank/gui/views/AccountsView.hpp create mode 100644 examples/bank/gui/views/CardsView.cpp create mode 100644 examples/bank/gui/views/CardsView.hpp create mode 100644 examples/bank/gui/views/LoansView.cpp create mode 100644 examples/bank/gui/views/LoansView.hpp create mode 100644 examples/bank/gui/views/MoveMoneyView.cpp create mode 100644 examples/bank/gui/views/MoveMoneyView.hpp create mode 100644 examples/bank/gui/views/PayeesView.cpp create mode 100644 examples/bank/gui/views/PayeesView.hpp create mode 100644 examples/bank/include/bank/app/app.hpp create mode 100644 examples/bank/include/bank/core/errors.hpp create mode 100644 examples/bank/include/bank/core/money.hpp create mode 100644 examples/bank/include/bank/core/principal.hpp create mode 100644 examples/bank/include/bank/core/types.hpp create mode 100644 examples/bank/include/bank/db/account_entity.hpp create mode 100644 examples/bank/include/bank/db/budget_entity.hpp create mode 100644 examples/bank/include/bank/db/card_entity.hpp create mode 100644 examples/bank/include/bank/db/database.hpp create mode 100644 examples/bank/include/bank/db/db_model.hpp create mode 100644 examples/bank/include/bank/db/ledger_ops.hpp create mode 100644 examples/bank/include/bank/db/loan_entity.hpp create mode 100644 examples/bank/include/bank/db/notification_entity.hpp create mode 100644 examples/bank/include/bank/db/payee_entity.hpp create mode 100644 examples/bank/include/bank/db/payment_entity.hpp create mode 100644 examples/bank/include/bank/db/txn_entity.hpp create mode 100644 examples/bank/include/bank/db/user_entity.hpp create mode 100644 examples/bank/include/bank/dto/account_dto.hpp create mode 100644 examples/bank/include/bank/dto/auth_dto.hpp create mode 100644 examples/bank/include/bank/dto/budget_dto.hpp create mode 100644 examples/bank/include/bank/dto/card_dto.hpp create mode 100644 examples/bank/include/bank/dto/common.hpp create mode 100644 examples/bank/include/bank/dto/loan_dto.hpp create mode 100644 examples/bank/include/bank/dto/notification_dto.hpp create mode 100644 examples/bank/include/bank/dto/payee_dto.hpp create mode 100644 examples/bank/include/bank/dto/payment_dto.hpp create mode 100644 examples/bank/include/bank/dto/statement_dto.hpp create mode 100644 examples/bank/include/bank/dto/transaction_dto.hpp create mode 100644 examples/bank/include/bank/models/account_model.hpp create mode 100644 examples/bank/include/bank/models/auth_model.hpp create mode 100644 examples/bank/include/bank/models/budget_model.hpp create mode 100644 examples/bank/include/bank/models/card_model.hpp create mode 100644 examples/bank/include/bank/models/loan_model.hpp create mode 100644 examples/bank/include/bank/models/notification_model.hpp create mode 100644 examples/bank/include/bank/models/payee_model.hpp create mode 100644 examples/bank/include/bank/models/payment_model.hpp create mode 100644 examples/bank/include/bank/models/statement_model.hpp create mode 100644 examples/bank/include/bank/models/transaction_model.hpp create mode 100644 examples/bank/src/app/app.cpp create mode 100644 examples/bank/src/cli/main.cpp create mode 100644 examples/bank/src/core/money.cpp create mode 100644 examples/bank/src/db/schema.cpp create mode 100644 examples/bank/src/models/account_model.cpp create mode 100644 examples/bank/src/models/auth_model.cpp create mode 100644 examples/bank/src/models/budget_model.cpp create mode 100644 examples/bank/src/models/card_model.cpp create mode 100644 examples/bank/src/models/loan_model.cpp create mode 100644 examples/bank/src/models/notification_model.cpp create mode 100644 examples/bank/src/models/payee_model.cpp create mode 100644 examples/bank/src/models/payment_model.cpp create mode 100644 examples/bank/src/models/statement_model.cpp create mode 100644 examples/bank/src/models/transaction_model.cpp create mode 100644 examples/bank/tests/bank_test_support.hpp create mode 100644 examples/bank/tests/test_account.cpp create mode 100644 examples/bank/tests/test_auth.cpp create mode 100644 examples/bank/tests/test_budget.cpp create mode 100644 examples/bank/tests/test_card.cpp create mode 100644 examples/bank/tests/test_loan.cpp create mode 100644 examples/bank/tests/test_notification.cpp create mode 100644 examples/bank/tests/test_offline.cpp create mode 100644 examples/bank/tests/test_payee.cpp create mode 100644 examples/bank/tests/test_payment.cpp create mode 100644 examples/bank/tests/test_remote.cpp create mode 100644 examples/bank/tests/test_statement.cpp create mode 100644 examples/bank/tests/test_transaction.cpp diff --git a/.gitignore b/.gitignore index f3776de..ffef247 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /build/ +/build-bank/ /out/ +*.db /.cache/ /compile_commands.json *.user diff --git a/CMakeLists.txt b/CMakeLists.txt index 86329ef..5e749fa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,8 @@ set(CMAKE_CXX_EXTENSIONS OFF) option(MORPH_BUILD_TESTS "Build tests" ON) option(MORPH_BUILD_EXAMPLES "Build examples" ON) +option(MORPH_BUILD_BANK_EXAMPLE "Build the SQLite/Lightweight bank example (heavy deps)" OFF) +option(MORPH_BUILD_BANK_GUI "Build the Qt 6 GUI for the bank example" OFF) option(MORPH_BUILD_QT "Build Qt6 WebSocket backend and tests" OFF) option(MORPH_BUILD_DOCUMENTATION "Build doxygen docs" OFF) @@ -87,6 +89,10 @@ if(MORPH_BUILD_EXAMPLES) if(AF_COVERAGE) apply_coverage(morph_example) endif() + + if(MORPH_BUILD_BANK_EXAMPLE) + add_subdirectory(examples/bank) + endif() endif() # ── Tests ──────────────────────────────────────────────────────────────────── diff --git a/examples/bank/CMakeLists.txt b/examples/bank/CMakeLists.txt new file mode 100644 index 0000000..14d3d3f --- /dev/null +++ b/examples/bank/CMakeLists.txt @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Bank example: a feature-rich demo app built on morph (async UI<->model +# bridge) with a SQLite persistence layer provided by the LASTRADA Lightweight +# ORM. Intended to be enabled from the top-level build with +# -DMORPH_BUILD_BANK_EXAMPLE=ON (it pulls a heavy dependency tree, so it is off +# by default). + +cmake_minimum_required(VERSION 3.25) + +# Allow standalone configuration (examples/bank as its own project) as well as +# being add_subdirectory()'d from the morph root. +if(NOT TARGET morph::morph) + message(FATAL_ERROR + "examples/bank expects the morph::morph target. Configure from the repository root " + "with -DMORPH_BUILD_BANK_EXAMPLE=ON instead of configuring examples/bank directly.") +endif() + +include(FetchContent) + +# ── Lightweight ORM (SQLite via ODBC) ──────────────────────────────────────── +# Lightweight resolves reflection-cpp / stdexec via CPM and finds yaml-cpp / +# Catch2 / libzip as system CONFIG packages. +set(LIGHTWEIGHT_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(LIGHTWEIGHT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(LIGHTWEIGHT_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(LIGHTWEIGHT_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE) + +FetchContent_Declare(Lightweight + GIT_REPOSITORY https://github.com/LASTRADA-Software/Lightweight.git + GIT_TAG v0.20260625.0 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(Lightweight) + +# ── Bank domain library ────────────────────────────────────────────────────── +add_library(bank_lib STATIC + src/core/money.cpp + src/db/schema.cpp + src/models/account_model.cpp + src/models/auth_model.cpp + src/models/transaction_model.cpp + src/models/payee_model.cpp + src/models/payment_model.cpp + src/models/card_model.cpp + src/models/loan_model.cpp + src/models/budget_model.cpp + src/models/notification_model.cpp + src/models/statement_model.cpp + src/app/app.cpp +) +target_include_directories(bank_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) +target_link_libraries(bank_lib PUBLIC morph::morph Lightweight::Lightweight) +target_compile_features(bank_lib PUBLIC cxx_std_23) +# Note: deliberately NOT calling apply_warnings() here — the third-party ORM +# headers are not -Werror clean and would fail the build. + +# ── CLI driver ─────────────────────────────────────────────────────────────── +add_executable(bank_cli src/cli/main.cpp) +target_link_libraries(bank_cli PRIVATE bank_lib) +target_compile_features(bank_cli PRIVATE cxx_std_23) + +# ── Qt 6 GUI (opt-in) ──────────────────────────────────────────────────────── +if(MORPH_BUILD_BANK_GUI) + add_subdirectory(gui) +endif() + +# ── Tests ──────────────────────────────────────────────────────────────────── +if(MORPH_BUILD_TESTS) + find_package(Catch2 3 CONFIG QUIET) + if(NOT Catch2_FOUND) + message(WARNING "Catch2 not found; bank example tests will not be built.") + else() + add_executable(bank_tests + tests/test_account.cpp + tests/test_auth.cpp + tests/test_transaction.cpp + tests/test_payee.cpp + tests/test_payment.cpp + tests/test_card.cpp + tests/test_loan.cpp + tests/test_budget.cpp + tests/test_notification.cpp + tests/test_statement.cpp + tests/test_remote.cpp + tests/test_offline.cpp + ) + target_include_directories(bank_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests) + target_link_libraries(bank_tests PRIVATE bank_lib Catch2::Catch2WithMain) + target_compile_features(bank_tests PRIVATE cxx_std_23) + + list(APPEND CMAKE_MODULE_PATH ${Catch2_DIR}) + include(Catch) + catch_discover_tests(bank_tests DISCOVERY_MODE PRE_TEST) + endif() +endif() diff --git a/examples/bank/README.md b/examples/bank/README.md new file mode 100644 index 0000000..6626b2a --- /dev/null +++ b/examples/bank/README.md @@ -0,0 +1,125 @@ +# Bank — a worked example of `morph` + Lightweight + +A feature-rich demo banking application built on two libraries: + +- **[morph](../../README.md)** — the typed, asynchronous UI ↔ model bridge. Every + banking domain is a plain single-threaded C++ model; morph handles concurrency + (one strand per model), result marshalling, and transport. The *same* model code + and call sites run **in-process (local)** or **across a server (remote)**. +- **[Lightweight](https://github.com/LASTRADA-Software/Lightweight)** — the ORM / + ODBC layer. Each model persists to **SQLite** through a typed `DataMapper`; the + schema is owned by Lightweight migrations. + +It ships the models, a full test suite, a scripted CLI driver, and a **Qt 6 +desktop GUI**. + +## Architecture: two type layers + +morph actions/results must be plain aggregates (Glaze serialises them onto the wire). +Lightweight entities are `Field<>`-wrapped structs. The example keeps them separate +and maps between them in the model: + +``` +GUI / CLI ──actions/results (plain DTOs)──▶ morph Bridge ──▶ Model + │ maps DTO ⇄ entity + ▼ + Lightweight DataMapper ──▶ SQLite +``` + +- **`include/bank/dto/`** — wire DTOs (the morph action/result types). Amounts are + integer **minor units** (cents); enums travel as their integer values. +- **`include/bank/db/`** — Lightweight entity records (`*_entity.hpp`), the shared + `WithMapper` mixin (one lazily-opened `DataMapper` per model), and reusable + `ledger_ops.hpp` (debit/credit/post-entry helpers used by every money-moving model). +- **`include/bank/models/` + `src/models/`** — one model per banking domain. The + `BRIDGE_REGISTER_*` macros live in the **model header** so every `.execute()` call + site sees the `ActionTraits` specialisation. +- **`src/db/schema.cpp`** — all `LIGHTWEIGHT_SQL_MIGRATION` table definitions. +- **`include/bank/app/` + `src/app/`** — `App`: shared worker pool, GUI executor, + `Bridge`, database setup, and login (which sets the bridge's default session). + +### Why per-model `DataMapper`? + +morph runs each model on its own strand (single-threaded), so a model can own its own +connection with no locking. The database is an **on-disk SQLite file** (not `:memory:`, +which is private per connection) so every model's connection sees the same data. +Cross-row atomic operations (transfer, bill payment, loan disbursement/repayment) run +inside a `SqlTransaction`. + +## Models & features + +| Model | Actions | +|---|---| +| **Auth** | register, login, change password, `WhoAmI` (session introspection) | +| **Account** | open (checking/savings/credit), list, get, close; overdraft, interest | +| **Transaction** | deposit, withdraw, **atomic transfer**, paginated history | +| **Payee** | add (with IBAN validation + `set<>` streaming), remove, list | +| **Payment** | one-off bill pay, scheduled payments, standing orders, cancel | +| **Card** | issue debit/credit, freeze/unfreeze/cancel, set limit, change PIN | +| **Loan** | apply (disburse), amortization schedule, repay, payoff | +| **Budget** | per-category limits, spending-by-kind analytics | +| **Notification** | post, list (unread filter), mark read / mark all read | +| **Statement** | date-ranged credit/debit summary across all accounts | + +morph features exercised: `Completion` then/onError, **sessions** (principal scoping + +authorization), **validation** (`validate()` + the `set<>`/`subscribe<>` streaming +form flow), **local ↔ remote parity**, a custom **`IAuthorizer`**, and the **offline +queue + `SyncWorker`** replay path. + +## Build & run + +The example is **off by default** (it pulls a heavy dependency tree — reflection-cpp, +stdexec, yaml-cpp, libzip — via Lightweight). Enable it from the repo root: + +```sh +cmake -G Ninja -B build -S . -DMORPH_BUILD_BANK_EXAMPLE=ON +cmake --build build --target bank_tests bank_cli +``` + +Requirements: a C++23 compiler, **unixODBC**, and the **SQLite3 ODBC driver** +registered with unixODBC (the connection string is `DRIVER=SQLite3;Database=…`). + +```sh +# Run the test suite +./build/examples/bank/bank_tests + +# Run the scripted tour (same scenario on local, then remote, backend) +./build/examples/bank/bank_cli +``` + +### Qt 6 GUI + +A desktop GUI (`gui/`) is built when `-DMORPH_BUILD_BANK_GUI=ON` is also passed +(requires Qt 6 Widgets): + +```sh +cmake -G Ninja -B build -S . -DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_BANK_GUI=ON +cmake --build build --target bank_gui +./build/examples/bank/gui/bank_gui +``` + +It binds to the models through `morph::qt::QtExecutor` over a local backend, so +completion callbacks land on the Qt GUI thread — the views never touch threads. +The design is a warm, "Claude-inspired" theme (paper background, clay accent, +soft cards, dark sidebar) defined entirely in `gui/Theme.hpp`. Screens: Login, +Accounts (dashboard), Move Money (+ history), Cards, Payees & Bills, and Loans +(with amortization schedule). Each `Page` reloads its data from the models when +shown. A headless screenshot smoke test runs when `BANK_GUI_SMOKE=` is set +(uses `QT_QPA_PLATFORM=offscreen`). + +## Tests + +Each model has a `tests/test_*.cpp` (Catch2). `tests/bank_test_support.hpp` provides +`await(completion, gui)` — pumps the GUI executor until a `Completion` resolves — and a +shared on-disk test database. Notable cross-cutting tests: + +- `test_remote.cpp` — runs `AccountModel` over `SimulatedRemoteBackend` and shows a + custom `IAuthorizer` rejecting an action. +- `test_offline.cpp` — parks deposits in an `InMemoryOfflineQueue` while "offline" and + replays them via `SyncWorker` on "reconnect". + +## Status + +Models, tests, CLI, and the Qt 6 GUI are complete. Possible extensions: wiring +the GUI over the Qt WebSocket backend (`morph::qt::QtWebSocketBackend`) for a +true client/server split, and surfacing the offline queue in the UI. diff --git a/examples/bank/gui/BankClient.cpp b/examples/bank/gui/BankClient.cpp new file mode 100644 index 0000000..34db6cd --- /dev/null +++ b/examples/bank/gui/BankClient.cpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "BankClient.hpp" + +#include +#include + +#include +#include + +#include "bank/db/database.hpp" + +namespace bankgui { + +BankClient::BankClient(const std::string& connectionString, std::size_t workers) + : _pool{workers}, _bridge{std::make_unique(_pool)} { + bank::db::setup(connectionString); +} + +void BankClient::login(const QString& principal, const QString& displayName) { + _principal = principal; + _displayName = displayName; + morph::session::Context ctx; + ctx.principal = principal.toStdString(); + _bridge.setDefaultSession(std::move(ctx)); +} + +void BankClient::logout() { + _principal.clear(); + _displayName.clear(); + _bridge.setDefaultSession({}); +} + +} // namespace bankgui diff --git a/examples/bank/gui/BankClient.hpp b/examples/bank/gui/BankClient.hpp new file mode 100644 index 0000000..3465e9b --- /dev/null +++ b/examples/bank/gui/BankClient.hpp @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include + +#include +#include + +/// @file +/// Shared client context for the GUI: a worker pool, a `QtExecutor` that +/// delivers completion callbacks on the Qt GUI thread, the process-wide +/// `Bridge` (over a local backend), database setup, and the session principal. +/// Each view constructs its own `BridgeHandler`s against `bridge()` / `gui()`. + +namespace bankgui { + +/// @brief Owns the morph wiring for the GUI and the current session. +class BankClient { +public: + explicit BankClient(const std::string& connectionString, std::size_t workers = 4); + + BankClient(const BankClient&) = delete; + BankClient& operator=(const BankClient&) = delete; + + [[nodiscard]] morph::bridge::Bridge& bridge() noexcept { return _bridge; } + [[nodiscard]] morph::exec::IExecutor* gui() noexcept { return &_gui; } + + /// @brief Installs @p principal / @p displayName as the session for every call. + void login(const QString& principal, const QString& displayName); + void logout(); + + [[nodiscard]] const QString& principal() const noexcept { return _principal; } + [[nodiscard]] const QString& displayName() const noexcept { return _displayName; } + +private: + morph::exec::ThreadPoolExecutor _pool; + morph::qt::QtExecutor _gui; + morph::bridge::Bridge _bridge; + QString _principal; + QString _displayName; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/CMakeLists.txt b/examples/bank/gui/CMakeLists.txt new file mode 100644 index 0000000..c63d4a6 --- /dev/null +++ b/examples/bank/gui/CMakeLists.txt @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Qt 6 GUI for the bank example. Built when -DMORPH_BUILD_BANK_GUI=ON. + +find_package(Qt6 REQUIRED COMPONENTS Widgets) + +set(CMAKE_AUTOMOC ON) + +add_executable(bank_gui + main.cpp + BankClient.cpp + LoginView.cpp + MainWindow.cpp + views/AccountsView.cpp + views/MoveMoneyView.cpp + views/CardsView.cpp + views/PayeesView.cpp + views/LoansView.cpp +) +target_include_directories(bank_gui PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(bank_gui PRIVATE bank_lib Qt6::Widgets) +target_compile_features(bank_gui PRIVATE cxx_std_23) diff --git a/examples/bank/gui/LoginView.cpp b/examples/bank/gui/LoginView.cpp new file mode 100644 index 0000000..b6f2d70 --- /dev/null +++ b/examples/bank/gui/LoginView.cpp @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "LoginView.hpp" + +#include +#include +#include +#include + +#include "Ui.hpp" +#include "bank/dto/auth_dto.hpp" + +namespace bankgui { + +LoginView::LoginView(BankClient& client, QWidget* parent) + : QWidget(parent), _client{client}, _auth{client.bridge(), client.gui()} { + setObjectName(QStringLiteral("Root")); + + auto* card = ui::card(); + card->setFixedWidth(380); + auto* form = new QVBoxLayout(card); + form->setContentsMargins(32, 32, 32, 32); + form->setSpacing(12); + + auto* brand = ui::label(QStringLiteral("Morph Bank"), QStringLiteral("H1")); + auto* subtitle = ui::label(QStringLiteral("Sign in to your account"), QStringLiteral("Muted")); + + _username = new QLineEdit; + _username->setPlaceholderText(QStringLiteral("Username")); + _password = new QLineEdit; + _password->setPlaceholderText(QStringLiteral("Password")); + _password->setEchoMode(QLineEdit::Password); + _displayName = new QLineEdit; + _displayName->setPlaceholderText(QStringLiteral("Display name (for new accounts)")); + + _error = ui::label(QString(), QStringLiteral("Danger")); + _error->setWordWrap(true); + _error->hide(); + + auto* signIn = ui::button(QStringLiteral("Sign in"), QStringLiteral("primary")); + auto* create = ui::button(QStringLiteral("Create account"), QStringLiteral("ghost")); + + form->addWidget(brand); + form->addWidget(subtitle); + form->addSpacing(8); + form->addWidget(_username); + form->addWidget(_password); + form->addWidget(_displayName); + form->addWidget(_error); + form->addSpacing(4); + form->addWidget(signIn); + form->addWidget(create, 0, Qt::AlignHCenter); + + QObject::connect(signIn, &QPushButton::clicked, this, [this] { attemptLogin(); }); + QObject::connect(create, &QPushButton::clicked, this, [this] { attemptRegister(); }); + QObject::connect(_password, &QLineEdit::returnPressed, this, [this] { attemptLogin(); }); + + // Centre the card on the paper background. + auto* outer = new QVBoxLayout(this); + outer->addStretch(); + auto* row = new QHBoxLayout; + row->addStretch(); + row->addWidget(card); + row->addStretch(); + outer->addLayout(row); + outer->addStretch(); +} + +void LoginView::setError(const QString& message) { + _error->setText(message); + _error->setVisible(!message.isEmpty()); +} + +void LoginView::attemptLogin() { + setError({}); + const QString user = _username->text().trimmed(); + _auth.execute(bank::dto::LoginRequest{.username = user.toStdString(), + .password = _password->text().toStdString()}) + .then([this, user](bank::dto::AuthResult result) { + if (result.ok && onAuthenticated) { + onAuthenticated(QString::fromStdString(result.principal), + QString::fromStdString(result.displayName)); + } else if (!result.ok) { + setError(QString::fromStdString(result.message)); + } + }) + .onError([this](const std::exception_ptr& err) { setError(ui::errorText(err)); }); +} + +void LoginView::attemptRegister() { + setError({}); + const QString user = _username->text().trimmed(); + _auth.execute(bank::dto::RegisterUser{.username = user.toStdString(), + .password = _password->text().toStdString(), + .displayName = _displayName->text().toStdString()}) + .then([this](bank::dto::AuthResult result) { + if (result.ok && onAuthenticated) { + onAuthenticated(QString::fromStdString(result.principal), + QString::fromStdString(result.displayName)); + } else if (!result.ok) { + setError(QString::fromStdString(result.message)); + } + }) + .onError([this](const std::exception_ptr& err) { setError(ui::errorText(err)); }); +} + +} // namespace bankgui diff --git a/examples/bank/gui/LoginView.hpp b/examples/bank/gui/LoginView.hpp new file mode 100644 index 0000000..b27c541 --- /dev/null +++ b/examples/bank/gui/LoginView.hpp @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include + +#include "BankClient.hpp" +#include "bank/models/auth_model.hpp" + +class QLineEdit; +class QLabel; + +namespace bankgui { + +/// @brief Sign-in / create-account screen. Emits the authenticated principal +/// via the `onAuthenticated` callback. +class LoginView : public QWidget { +public: + explicit LoginView(BankClient& client, QWidget* parent = nullptr); + + /// Invoked with (principal, displayName) once sign-in/registration succeeds. + std::function onAuthenticated; + +private: + void attemptLogin(); + void attemptRegister(); + void setError(const QString& message); + + BankClient& _client; + morph::bridge::BridgeHandler _auth; + QLineEdit* _username{}; + QLineEdit* _password{}; + QLineEdit* _displayName{}; + QLabel* _error{}; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/MainWindow.cpp b/examples/bank/gui/MainWindow.cpp new file mode 100644 index 0000000..d768b2e --- /dev/null +++ b/examples/bank/gui/MainWindow.cpp @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "MainWindow.hpp" + +#include +#include +#include +#include +#include +#include + +#include "Ui.hpp" +#include "views/AccountsView.hpp" +#include "views/CardsView.hpp" +#include "views/LoansView.hpp" +#include "views/MoveMoneyView.hpp" +#include "views/PayeesView.hpp" + +namespace bankgui { + +MainWindow::MainWindow(BankClient& client, QWidget* parent) : QWidget(parent), _client{client} { + setObjectName(QStringLiteral("Root")); + auto* root = new QHBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + // ── Sidebar ────────────────────────────────────────────────────────────── + auto* sidebar = new QWidget; + sidebar->setObjectName(QStringLiteral("Sidebar")); + sidebar->setFixedWidth(232); + auto* side = new QVBoxLayout(sidebar); + side->setContentsMargins(0, 0, 0, 0); + side->setSpacing(0); + + side->addWidget(ui::label(QStringLiteral("Morph Bank"), QStringLiteral("Brand"))); + side->addWidget(ui::label(QStringLiteral("personal banking"), QStringLiteral("BrandSub"))); + + _navGroup = new QButtonGroup(this); + _navGroup->setExclusive(true); + _navLayout = new QVBoxLayout; + _navLayout->setContentsMargins(0, 8, 0, 8); + _navLayout->setSpacing(0); + side->addLayout(_navLayout); + side->addStretch(); + + auto* user = ui::label(_client.displayName(), QStringLiteral("SidebarUser")); + auto* userSub = ui::label(QStringLiteral("@") + _client.principal(), QStringLiteral("SidebarUserSub")); + side->addWidget(user); + side->addWidget(userSub); + auto* logout = ui::button(QStringLiteral("Log out"), QStringLiteral("ghost")); + QObject::connect(logout, &QPushButton::clicked, this, [this] { + if (onLogout) { + onLogout(); + } + }); + side->addWidget(logout, 0, Qt::AlignLeft); + side->addSpacing(12); + + // ── Content area: header + stacked pages ────────────────────────────────── + auto* content = new QVBoxLayout; + content->setContentsMargins(32, 28, 32, 28); + content->setSpacing(20); + _title = ui::label(QString(), QStringLiteral("H1")); + content->addWidget(_title); + _stack = new QStackedWidget; + content->addWidget(_stack, 1); + + root->addWidget(sidebar); + root->addLayout(content, 1); + + // ── Pages ───────────────────────────────────────────────────────────────── + addPage(QStringLiteral("Accounts"), new AccountsView(_client)); + addPage(QStringLiteral("Move Money"), new MoveMoneyView(_client)); + addPage(QStringLiteral("Cards"), new CardsView(_client)); + addPage(QStringLiteral("Payees & Bills"), new PayeesView(_client)); + addPage(QStringLiteral("Loans"), new LoansView(_client)); + + showPage(0); +} + +void MainWindow::addPage(const QString& title, Page* page) { + const int index = _pageCount++; + _stack->addWidget(page); + + // Double any '&' so QPushButton does not treat it as a mnemonic accelerator. + auto* navButton = new QPushButton(QString(title).replace(QLatin1Char('&'), QStringLiteral("&&"))); + navButton->setObjectName(QStringLiteral("NavButton")); + navButton->setCheckable(true); + navButton->setCursor(Qt::PointingHandCursor); + navButton->setProperty("pageTitle", title); + _navGroup->addButton(navButton, index); + _navLayout->addWidget(navButton); + if (index == 0) { + navButton->setChecked(true); + } + QObject::connect(navButton, &QPushButton::clicked, this, [this, index] { showPage(index); }); +} + +void MainWindow::showPage(int index) { + _stack->setCurrentIndex(index); + if (auto* button = _navGroup->button(index)) { + button->setChecked(true); + _title->setText(button->property("pageTitle").toString()); + } + // Every stacked widget is a Page (added only via addPage). + if (auto* page = static_cast(_stack->widget(index))) { + page->refresh(); + } +} + +} // namespace bankgui diff --git a/examples/bank/gui/MainWindow.hpp b/examples/bank/gui/MainWindow.hpp new file mode 100644 index 0000000..d1a6b60 --- /dev/null +++ b/examples/bank/gui/MainWindow.hpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "BankClient.hpp" +#include "Page.hpp" + +class QStackedWidget; +class QVBoxLayout; +class QLabel; +class QButtonGroup; + +namespace bankgui { + +/// @brief The signed-in shell: a sidebar of navigation buttons, a header, and a +/// stacked content area of `Page`s. Each page is refreshed when shown. +class MainWindow : public QWidget { +public: + explicit MainWindow(BankClient& client, QWidget* parent = nullptr); + + /// Invoked when the user clicks "Log out". + std::function onLogout; + + /// @brief Switches to page @p index (also used by the screenshot smoke test). + void selectPage(int index) { showPage(index); } + [[nodiscard]] int pageCount() const { return _pageCount; } + +private: + void addPage(const QString& title, Page* page); + void showPage(int index); + + BankClient& _client; + QVBoxLayout* _navLayout{}; + QButtonGroup* _navGroup{}; + QStackedWidget* _stack{}; + QLabel* _title{}; + int _pageCount{0}; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/Page.hpp b/examples/bank/gui/Page.hpp new file mode 100644 index 0000000..17d9d5a --- /dev/null +++ b/examples/bank/gui/Page.hpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +namespace bankgui { + +/// @brief Base for a navigable content page. `refresh()` is called by the main +/// window whenever the page becomes visible, so each page reloads its +/// data from the models on demand. +class Page : public QWidget { +public: + using QWidget::QWidget; + virtual void refresh() {} +}; + +} // namespace bankgui diff --git a/examples/bank/gui/Smoke.hpp b/examples/bank/gui/Smoke.hpp new file mode 100644 index 0000000..1282ab4 --- /dev/null +++ b/examples/bank/gui/Smoke.hpp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include "BankClient.hpp" +#include "MainWindow.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/card_dto.hpp" +#include "bank/dto/loan_dto.hpp" +#include "bank/dto/payee_dto.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/auth_model.hpp" +#include "bank/models/card_model.hpp" +#include "bank/models/loan_model.hpp" +#include "bank/models/payee_model.hpp" +#include "bank/models/transaction_model.hpp" + +/// @file +/// Headless screenshot smoke test, run only when BANK_GUI_SMOKE is set. It seeds +/// representative demo data, logs in, and saves a PNG of each page — used to +/// verify the GUI renders (and looks right) without a display. + +namespace bankgui::smoke { + +/// Pumps the Qt event loop until @p completion resolves. +template +void pumpAwait(Completion completion) { + bool done = false; + completion.then([&](auto&&) { done = true; }).onError([&](const std::exception_ptr&) { done = true; }); + while (!done) { + QApplication::processEvents(QEventLoop::AllEvents, 10); + } +} + +/// Seeds a representative account/transaction/payee/card/loan set for @p principal. +inline void seed(BankClient& client, const QString& principal) { + morph::bridge::BridgeHandler auth{client.bridge(), client.gui()}; + pumpAwait(auth.execute(bank::dto::RegisterUser{ + .username = principal.toStdString(), .password = "demo1234", .displayName = "Demo User"})); + client.login(principal, QStringLiteral("Demo User")); + + morph::bridge::BridgeHandler accounts{client.bridge(), client.gui()}; + morph::bridge::BridgeHandler txns{client.bridge(), client.gui()}; + morph::bridge::BridgeHandler payees{client.bridge(), client.gui()}; + morph::bridge::BridgeHandler cards{client.bridge(), client.gui()}; + morph::bridge::BridgeHandler loans{client.bridge(), client.gui()}; + + pumpAwait(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0, .overdraftMinor = 50000})); + pumpAwait(accounts.execute(bank::dto::OpenAccount{.kind = 1, .currency = 0})); + + auto list = bank::dto::AccountList{}; + { + bool done = false; + accounts.execute(bank::dto::ListAccounts{}) + .then([&](bank::dto::AccountList result) { list = std::move(result); done = true; }) + .onError([&](const std::exception_ptr&) { done = true; }); + while (!done) { + QApplication::processEvents(QEventLoop::AllEvents, 10); + } + } + if (list.accounts.size() >= 2) { + const auto checking = list.accounts[0].id; + const auto savings = list.accounts[1].id; + pumpAwait(txns.execute(bank::dto::Deposit{.accountId = checking, .amountMinor = 480000, + .description = "Salary"})); + pumpAwait(txns.execute(bank::dto::Transfer{.fromAccountId = checking, .toAccountId = savings, + .amountMinor = 120000, .description = "Savings"})); + pumpAwait(txns.execute(bank::dto::Withdraw{.accountId = checking, .amountMinor = 3200, + .description = "Groceries"})); + pumpAwait(cards.execute(bank::dto::IssueCard{.accountId = checking, .kind = 0, + .dailyLimitMinor = 100000})); + pumpAwait(loans.execute(bank::dto::ApplyLoan{.accountId = checking, .principalMinor = 1200000, + .rateBps = 600, .termMonths = 12})); + } + pumpAwait(payees.execute(bank::dto::AddPayee{ + .name = "City Power", .iban = "DE89370400440532013000", .bankName = "Stadtbank"})); +} + +/// Logs in, builds the main window, and screenshots each page, then quits. +inline void run(BankClient& client, QStackedWidget* window, const QString& outDir) { + // Capture the login screen (currently shown) before signing in. + QApplication::processEvents(QEventLoop::AllEvents, 50); + window->grab().save(outDir + QStringLiteral("/bank_login.png")); + + const QString principal = QStringLiteral("gui-demo"); + seed(client, principal); + + auto* main = new MainWindow{client}; + const int index = window->addWidget(main); + window->setCurrentIndex(index); + + QTimer::singleShot(300, window, [main, window, outDir] { + const char* names[] = {"accounts", "move-money", "cards", "payees", "loans"}; + for (int page = 0; page < main->pageCount(); ++page) { + main->selectPage(page); + // Let the page's async refresh (account list, history, etc.) settle + // before capturing — completions arrive via the Qt event loop. + for (int tick = 0; tick < 40; ++tick) { + QApplication::processEvents(QEventLoop::AllEvents, 15); + QThread::msleep(5); + } + window->grab().save(outDir + QStringLiteral("/bank_%1.png").arg(names[page])); + } + QApplication::quit(); + }); +} + +} // namespace bankgui::smoke diff --git a/examples/bank/gui/Theme.hpp b/examples/bank/gui/Theme.hpp new file mode 100644 index 0000000..ac5958f --- /dev/null +++ b/examples/bank/gui/Theme.hpp @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +/// @file +/// The visual design system for the bank GUI: a warm, modern "Claude-inspired" +/// palette (paper background, clay/coral accent, soft cards, dark warm sidebar) +/// expressed as a single application-wide Qt style sheet. + +namespace bankgui::theme { + +// ── Palette ────────────────────────────────────────────────────────────────── +inline constexpr const char* kPaper = "#FAF9F5"; // app background (warm paper) +inline constexpr const char* kSurface = "#FFFFFF"; // cards / inputs +inline constexpr const char* kSurfaceAlt = "#F2F0E9"; // subtle fills, hover +inline constexpr const char* kInk = "#1F1E1D"; // primary text +inline constexpr const char* kInkSoft = "#6B6862"; // secondary text +inline constexpr const char* kBorder = "#E7E4DB"; // hairline borders +inline constexpr const char* kAccent = "#C96442"; // Claude clay/coral +inline constexpr const char* kAccentHover = "#B5573698"; // (unused placeholder) +inline constexpr const char* kSidebar = "#262624"; // warm near-black sidebar +inline constexpr const char* kSidebarText = "#C9C6BE"; +inline constexpr const char* kSuccess = "#2F9E66"; +inline constexpr const char* kDanger = "#C0392B"; + +/// @brief Returns the application-wide style sheet. +inline QString styleSheet() { + return QStringLiteral(R"QSS( +* { + font-family: "Inter", "Segoe UI", "Helvetica Neue", sans-serif; + font-size: 14px; + color: #1F1E1D; +} + +QWidget#Root, QStackedWidget, QScrollArea, QScrollArea > QWidget > QWidget { + background: #FAF9F5; +} + +/* ── Sidebar ──────────────────────────────────────────────────────────────── */ +QWidget#Sidebar { + background: #262624; + border: none; +} +QLabel#Brand { + color: #FAF9F5; + font-size: 19px; + font-weight: 700; + padding: 22px 20px 6px 20px; +} +QLabel#BrandSub { + color: #8C887F; + font-size: 12px; + padding: 0 20px 18px 20px; +} +QPushButton#NavButton { + color: #C9C6BE; + background: transparent; + border: none; + border-radius: 9px; + padding: 11px 14px; + margin: 2px 12px; + text-align: left; + font-size: 14px; + font-weight: 500; +} +QPushButton#NavButton:hover { + background: #34322F; + color: #FAF9F5; +} +QPushButton#NavButton:checked { + background: #C96442; + color: #FFFFFF; + font-weight: 600; +} +QLabel#SidebarUser { + color: #FAF9F5; + font-weight: 600; + padding: 0 20px; +} +QLabel#SidebarUserSub { + color: #8C887F; + font-size: 12px; + padding: 0 20px 12px 20px; +} + +/* ── Headings & text ─────────────────────────────────────────────────────── */ +QLabel#H1 { font-size: 26px; font-weight: 700; color: #1F1E1D; } +QLabel#H2 { font-size: 17px; font-weight: 600; color: #1F1E1D; } +QLabel#Muted { color: #6B6862; } +QLabel#Danger { color: #C0392B; font-weight: 500; } +QLabel#Success { color: #2F9E66; font-weight: 500; } + +/* ── Cards ───────────────────────────────────────────────────────────────── */ +QFrame#Card { + background: #FFFFFF; + border: 1px solid #E7E4DB; + border-radius: 14px; +} +QFrame#StatCard { + background: #262624; + border: none; + border-radius: 14px; +} +QLabel#StatValue { color: #FFFFFF; font-size: 28px; font-weight: 700; } +QLabel#StatLabel { color: #A7A39A; font-size: 13px; font-weight: 500; } + +/* ── Inputs ──────────────────────────────────────────────────────────────── */ +QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox { + background: #FFFFFF; + border: 1px solid #E7E4DB; + border-radius: 9px; + padding: 9px 12px; + selection-background-color: #C96442; + selection-color: #FFFFFF; +} +QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus { + border: 1px solid #C96442; +} +QComboBox::drop-down { border: none; width: 26px; } +QComboBox QAbstractItemView { + background: #FFFFFF; + border: 1px solid #E7E4DB; + border-radius: 8px; + selection-background-color: #F2F0E9; + selection-color: #1F1E1D; + outline: none; +} + +/* ── Buttons ─────────────────────────────────────────────────────────────── */ +QPushButton { + background: #FFFFFF; + color: #1F1E1D; + border: 1px solid #E7E4DB; + border-radius: 9px; + padding: 9px 16px; + font-weight: 600; +} +QPushButton:hover { background: #F2F0E9; } +QPushButton:disabled { color: #B6B2A9; background: #F2F0E9; } + +QPushButton[variant="primary"] { + background: #C96442; + color: #FFFFFF; + border: none; +} +QPushButton[variant="primary"]:hover { background: #B5572F; } +QPushButton[variant="primary"]:disabled { background: #DDB8A8; color: #FFFFFF; } + +QPushButton[variant="ghost"] { + background: transparent; + border: none; + color: #C96442; + padding: 6px 8px; +} +QPushButton[variant="ghost"]:hover { color: #B5572F; background: transparent; } + +QPushButton[variant="danger"] { + background: transparent; + color: #C0392B; + border: 1px solid #E7C9C5; +} +QPushButton[variant="danger"]:hover { background: #FBEDEB; } + +/* ── Tables ──────────────────────────────────────────────────────────────── */ +QTableWidget { + background: #FFFFFF; + border: 1px solid #E7E4DB; + border-radius: 12px; + gridline-color: transparent; + outline: none; +} +QTableWidget::item { padding: 8px 10px; border-bottom: 1px solid #F0EEE7; } +QTableWidget::item:selected { background: #F7EDE8; color: #1F1E1D; } +QHeaderView::section { + background: #FFFFFF; + color: #6B6862; + border: none; + border-bottom: 1px solid #E7E4DB; + padding: 10px; + font-weight: 600; + text-transform: uppercase; + font-size: 11px; +} +QTableCornerButton::section { background: #FFFFFF; border: none; } + +/* ── Pills / badges ──────────────────────────────────────────────────────── */ +QLabel[pill="neutral"] { background: #F2F0E9; color: #6B6862; border-radius: 10px; padding: 3px 10px; font-size: 12px; font-weight: 600; } +QLabel[pill="good"] { background: #E5F4EC; color: #2F9E66; border-radius: 10px; padding: 3px 10px; font-size: 12px; font-weight: 600; } +QLabel[pill="warn"] { background: #FBEDE8; color: #C96442; border-radius: 10px; padding: 3px 10px; font-size: 12px; font-weight: 600; } +QLabel[pill="bad"] { background: #FBEDEB; color: #C0392B; border-radius: 10px; padding: 3px 10px; font-size: 12px; font-weight: 600; } + +QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; } +QScrollBar::handle:vertical { background: #D9D5CB; border-radius: 5px; min-height: 30px; } +QScrollBar::handle:vertical:hover { background: #C2BDB1; } +QScrollBar::add-line, QScrollBar::sub-line { height: 0; } +)QSS"); +} + +} // namespace bankgui::theme diff --git a/examples/bank/gui/Ui.hpp b/examples/bank/gui/Ui.hpp new file mode 100644 index 0000000..82135a4 --- /dev/null +++ b/examples/bank/gui/Ui.hpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "bank/core/money.hpp" +#include "bank/core/types.hpp" + +/// @file +/// Small UI helpers: money formatting/parsing, error extraction, and factory +/// functions for the themed widgets used across the views. + +namespace bankgui::ui { + +/// @brief Formats minor units for a currency, e.g. (1234, USD) -> "12.34 USD". +inline QString formatMinor(std::int64_t minor, int currency) { + const auto cur = static_cast(currency); + return QString::fromStdString(bank::format(bank::Money{.minor = minor, .currency = cur})); +} + +/// @brief Parses a user-entered amount (major units) into minor units. +/// @return std::nullopt if the text is not a valid non-negative number. +inline std::optional parseToMinor(const QString& text, int decimals) { + bool ok = false; + const double major = text.trimmed().toDouble(&ok); + if (!ok || major < 0.0) { + return std::nullopt; + } + double scale = 1.0; + for (int idx = 0; idx < decimals; ++idx) { + scale *= 10.0; + } + return static_cast(major * scale + 0.5); +} + +/// @brief Extracts a human-readable message from an exception_ptr. +inline QString errorText(const std::exception_ptr& err) { + try { + std::rethrow_exception(err); + } catch (const std::exception& exc) { + return QString::fromUtf8(exc.what()); + } catch (...) { + return QStringLiteral("unknown error"); + } +} + +/// @brief Re-applies the style sheet to @p widget after a dynamic property change. +inline void repolish(QWidget* widget) { + widget->style()->unpolish(widget); + widget->style()->polish(widget); +} + +/// @brief Creates a button with a style variant ("primary", "ghost", "danger", or ""). +inline QPushButton* button(const QString& text, const QString& variant = {}) { + auto* btn = new QPushButton(text); + btn->setCursor(Qt::PointingHandCursor); + if (!variant.isEmpty()) { + btn->setProperty("variant", variant); + } + return btn; +} + +/// @brief Creates a label with a style objectName ("H1", "H2", "Muted", ...). +inline QLabel* label(const QString& text, const QString& role = {}) { + auto* lbl = new QLabel(text); + if (!role.isEmpty()) { + lbl->setObjectName(role); + } + return lbl; +} + +/// @brief Creates a coloured status pill ("neutral", "good", "warn", "bad"). +inline QLabel* pill(const QString& text, const QString& kind) { + auto* lbl = new QLabel(text); + lbl->setProperty("pill", kind); + lbl->setAlignment(Qt::AlignCenter); + lbl->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); + return lbl; +} + +/// @brief Creates an empty rounded "Card" frame. +inline QFrame* card() { + auto* frame = new QFrame; + frame->setObjectName(QStringLiteral("Card")); + return frame; +} + +/// @brief Removes and deletes every item (and child widget) in @p layout. +inline void clearLayout(QLayout* layout) { + while (QLayoutItem* item = layout->takeAt(0)) { + if (QWidget* widget = item->widget()) { + widget->deleteLater(); + } + if (QLayout* child = item->layout()) { + clearLayout(child); + } + delete item; + } +} + +} // namespace bankgui::ui diff --git a/examples/bank/gui/main.cpp b/examples/bank/gui/main.cpp new file mode 100644 index 0000000..be4ab2b --- /dev/null +++ b/examples/bank/gui/main.cpp @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Entry point for the Qt 6 bank GUI. Wires a BankClient (local backend + Qt +// executor) and swaps between the login screen and the signed-in main window. + +#include +#include + +#include +#include +#include + +#include "BankClient.hpp" +#include "LoginView.hpp" +#include "MainWindow.hpp" +#include "Smoke.hpp" +#include "Theme.hpp" + +int main(int argc, char* argv[]) { + QApplication app{argc, argv}; + app.setApplicationName(QStringLiteral("Morph Bank")); + app.setStyleSheet(bankgui::theme::styleSheet()); + + const auto dbPath = std::filesystem::temp_directory_path() / "morph_bank_gui.db"; + bankgui::BankClient client{"DRIVER=SQLite3;Database=" + dbPath.string()}; + + auto* window = new QStackedWidget; + window->setObjectName(QStringLiteral("Root")); + window->setWindowTitle(QStringLiteral("Morph Bank")); + window->resize(1160, 760); + + auto* login = new bankgui::LoginView{client}; + window->addWidget(login); + + login->onAuthenticated = [&client, window](const QString& principal, const QString& displayName) { + client.login(principal, displayName); + auto* main = new bankgui::MainWindow{client}; + const int index = window->addWidget(main); + main->onLogout = [&client, window, main] { + client.logout(); + window->setCurrentIndex(0); + main->deleteLater(); + }; + window->setCurrentIndex(index); + }; + + window->setCurrentWidget(login); + window->show(); + + if (std::getenv("BANK_GUI_SMOKE") != nullptr) { + bankgui::smoke::run(client, window, QString::fromUtf8(std::getenv("BANK_GUI_SMOKE"))); + } + + return app.exec(); +} diff --git a/examples/bank/gui/views/AccountsView.cpp b/examples/bank/gui/views/AccountsView.cpp new file mode 100644 index 0000000..8adc9be --- /dev/null +++ b/examples/bank/gui/views/AccountsView.cpp @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "AccountsView.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../Ui.hpp" +#include "bank/core/types.hpp" + +namespace bankgui { + +namespace { + +QString kindName(int kind) { + switch (static_cast(kind)) { + case bank::AccountKind::Checking: return QStringLiteral("Checking"); + case bank::AccountKind::Savings: return QStringLiteral("Savings"); + case bank::AccountKind::Credit: return QStringLiteral("Credit"); + } + return QStringLiteral("Account"); +} + +QFrame* accountCard(const bank::dto::AccountInfo& account) { + auto* card = ui::card(); + card->setMinimumWidth(260); + auto* box = new QVBoxLayout(card); + box->setContentsMargins(20, 18, 20, 18); + box->setSpacing(6); + + auto* top = new QHBoxLayout; + auto* type = ui::label(kindName(account.kind), QStringLiteral("H2")); + const bool closed = account.status == static_cast(bank::AccountStatus::Closed); + auto* status = ui::pill(closed ? QStringLiteral("Closed") : QStringLiteral("Open"), + closed ? QStringLiteral("neutral") : QStringLiteral("good")); + top->addWidget(type); + top->addStretch(); + top->addWidget(status); + box->addLayout(top); + + const QString last4 = QString::fromStdString(account.number).right(4); + box->addWidget(ui::label(QStringLiteral("•••• •••• ") + last4, QStringLiteral("Muted"))); + + auto* balance = new QLabel(ui::formatMinor(account.balanceMinor, account.currency)); + balance->setStyleSheet(QStringLiteral("font-size:24px; font-weight:700; color:#1F1E1D;")); + box->addSpacing(6); + box->addWidget(balance); + + if (account.overdraftMinor > 0) { + box->addWidget(ui::label(QStringLiteral("Overdraft ") + + ui::formatMinor(account.overdraftMinor, account.currency), + QStringLiteral("Muted"))); + } + return card; +} + +} // namespace + +AccountsView::AccountsView(BankClient& client, QWidget* parent) + : Page(parent), _client{client}, _accounts{client.bridge(), client.gui()} { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(18); + + // Summary stat card. + auto* stat = new QFrame; + stat->setObjectName(QStringLiteral("StatCard")); + stat->setFixedHeight(96); + auto* statBox = new QVBoxLayout(stat); + statBox->setContentsMargins(24, 16, 24, 16); + _statValue = ui::label(QStringLiteral("—"), QStringLiteral("StatValue")); + _statLabel = ui::label(QStringLiteral("total balance"), QStringLiteral("StatLabel")); + statBox->addWidget(_statValue); + statBox->addWidget(_statLabel); + root->addWidget(stat); + + // Inline "open account" form. + auto* formCard = ui::card(); + auto* form = new QHBoxLayout(formCard); + form->setContentsMargins(16, 14, 16, 14); + form->setSpacing(10); + _kind = new QComboBox; + _kind->addItems({QStringLiteral("Checking"), QStringLiteral("Savings"), QStringLiteral("Credit")}); + _currency = new QComboBox; + _currency->addItems({QStringLiteral("USD"), QStringLiteral("EUR"), QStringLiteral("GBP"), + QStringLiteral("CHF"), QStringLiteral("JPY")}); + _overdraft = new QLineEdit; + _overdraft->setPlaceholderText(QStringLiteral("Overdraft (optional)")); + auto* open = ui::button(QStringLiteral("Open account"), QStringLiteral("primary")); + form->addWidget(ui::label(QStringLiteral("New account"), QStringLiteral("H2"))); + form->addStretch(); + form->addWidget(_kind); + form->addWidget(_currency); + form->addWidget(_overdraft); + form->addWidget(open); + root->addWidget(formCard); + QObject::connect(open, &QPushButton::clicked, this, [this] { openAccount(); }); + + // Scrollable grid of account cards. + auto* scroll = new QScrollArea; + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + auto* gridHost = new QWidget; + _grid = new QGridLayout(gridHost); + _grid->setContentsMargins(0, 0, 0, 0); + _grid->setSpacing(16); + _grid->setAlignment(Qt::AlignTop); + scroll->setWidget(gridHost); + root->addWidget(scroll, 1); +} + +void AccountsView::openAccount() { + const std::optional overdraft = + _overdraft->text().trimmed().isEmpty() ? std::optional{0} + : ui::parseToMinor(_overdraft->text(), 2); + _accounts + .execute(bank::dto::OpenAccount{.kind = _kind->currentIndex(), + .currency = _currency->currentIndex(), + .overdraftMinor = overdraft.value_or(0)}) + .then([this](bank::dto::AccountInfo) { + _overdraft->clear(); + refresh(); + }) + .onError([](const std::exception_ptr&) {}); +} + +void AccountsView::refresh() { + _accounts.execute(bank::dto::ListAccounts{}) + .then([this](bank::dto::AccountList list) { rebuild(list.accounts); }) + .onError([](const std::exception_ptr&) {}); +} + +void AccountsView::rebuild(const std::vector& accounts) { + ui::clearLayout(_grid); + + std::int64_t total = 0; + bool sameCurrency = true; + int currency = accounts.empty() ? 0 : accounts.front().currency; + int openCount = 0; + for (const auto& account : accounts) { + if (account.status == static_cast(bank::AccountStatus::Closed)) { + continue; + } + ++openCount; + total += account.balanceMinor; + if (account.currency != currency) { + sameCurrency = false; + } + } + + if (sameCurrency && openCount > 0) { + _statValue->setText(ui::formatMinor(total, currency)); + _statLabel->setText(QStringLiteral("total across %1 open account(s)").arg(openCount)); + } else { + _statValue->setText(QString::number(openCount)); + _statLabel->setText(QStringLiteral("open accounts")); + } + + int row = 0; + int col = 0; + for (const auto& account : accounts) { + _grid->addWidget(accountCard(account), row, col); + if (++col == 3) { + col = 0; + ++row; + } + } +} + +} // namespace bankgui diff --git a/examples/bank/gui/views/AccountsView.hpp b/examples/bank/gui/views/AccountsView.hpp new file mode 100644 index 0000000..df3e8e6 --- /dev/null +++ b/examples/bank/gui/views/AccountsView.hpp @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "../BankClient.hpp" +#include "../Page.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/models/account_model.hpp" + +class QComboBox; +class QLineEdit; +class QLabel; +class QGridLayout; + +namespace bankgui { + +/// @brief Dashboard: a summary stat, an inline "open account" form, and a grid +/// of account cards. +class AccountsView : public Page { +public: + explicit AccountsView(BankClient& client, QWidget* parent = nullptr); + void refresh() override; + +private: + void openAccount(); + void rebuild(const std::vector& accounts); + + BankClient& _client; + morph::bridge::BridgeHandler _accounts; + QComboBox* _kind{}; + QComboBox* _currency{}; + QLineEdit* _overdraft{}; + QLabel* _statValue{}; + QLabel* _statLabel{}; + QGridLayout* _grid{}; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/views/CardsView.cpp b/examples/bank/gui/views/CardsView.cpp new file mode 100644 index 0000000..02761d2 --- /dev/null +++ b/examples/bank/gui/views/CardsView.cpp @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "CardsView.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +#include "../Ui.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" + +namespace bankgui { + +namespace { + +QString statusName(int status) { + switch (static_cast(status)) { + case bank::CardStatus::Active: return QStringLiteral("Active"); + case bank::CardStatus::Frozen: return QStringLiteral("Frozen"); + case bank::CardStatus::Cancelled: return QStringLiteral("Cancelled"); + } + return QStringLiteral("—"); +} + +QString statusPill(int status) { + switch (static_cast(status)) { + case bank::CardStatus::Active: return QStringLiteral("good"); + case bank::CardStatus::Frozen: return QStringLiteral("warn"); + case bank::CardStatus::Cancelled: return QStringLiteral("bad"); + } + return QStringLiteral("neutral"); +} + +} // namespace + +CardsView::CardsView(BankClient& client, QWidget* parent) + : Page(parent), + _client{client}, + _accounts{client.bridge(), client.gui()}, + _cards{client.bridge(), client.gui()} { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(18); + + auto* formCard = ui::card(); + auto* form = new QHBoxLayout(formCard); + form->setContentsMargins(16, 14, 16, 14); + form->setSpacing(10); + form->addWidget(ui::label(QStringLiteral("Issue card"), QStringLiteral("H2"))); + form->addStretch(); + _account = new QComboBox; + _account->setMinimumWidth(220); + _kind = new QComboBox; + _kind->addItems({QStringLiteral("Debit"), QStringLiteral("Credit")}); + _limit = new QLineEdit; + _limit->setPlaceholderText(QStringLiteral("Daily limit (optional)")); + auto* issue = ui::button(QStringLiteral("Issue"), QStringLiteral("primary")); + form->addWidget(_account); + form->addWidget(_kind); + form->addWidget(_limit); + form->addWidget(issue); + root->addWidget(formCard); + QObject::connect(issue, &QPushButton::clicked, this, [this] { issueCard(); }); + + auto* scroll = new QScrollArea; + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + auto* host = new QWidget; + _list = new QVBoxLayout(host); + _list->setContentsMargins(0, 0, 0, 0); + _list->setSpacing(12); + _list->setAlignment(Qt::AlignTop); + scroll->setWidget(host); + root->addWidget(scroll, 1); +} + +void CardsView::issueCard() { + if (!_account->currentData().isValid()) { + return; + } + const auto limit = _limit->text().trimmed().isEmpty() ? std::optional{0} + : ui::parseToMinor(_limit->text(), 2); + _cards + .execute(bank::dto::IssueCard{.accountId = _account->currentData().toLongLong(), + .kind = _kind->currentIndex(), + .dailyLimitMinor = limit.value_or(0)}) + .then([this](bank::dto::CardInfo) { _limit->clear(); refresh(); }) + .onError([](const std::exception_ptr&) {}); +} + +void CardsView::refresh() { + _accounts.execute(bank::dto::ListAccounts{}) + .then([this](bank::dto::AccountList list) { + _account->clear(); + for (const auto& account : list.accounts) { + if (account.status == static_cast(bank::AccountStatus::Closed)) { + continue; + } + _account->addItem(QStringLiteral("•••• ") + QString::fromStdString(account.number).right(4), + QVariant::fromValue(account.id)); + } + }) + .onError([](const std::exception_ptr&) {}); + + _cards.execute(bank::dto::ListCards{}) + .then([this](bank::dto::CardList list) { rebuild(list.cards); }) + .onError([](const std::exception_ptr&) {}); +} + +void CardsView::rebuild(const std::vector& cards) { + ui::clearLayout(_list); + for (const auto& card : cards) { + auto* frame = ui::card(); + auto* row = new QHBoxLayout(frame); + row->setContentsMargins(20, 16, 20, 16); + row->setSpacing(14); + + auto* info = new QVBoxLayout; + const QString kind = card.kind == static_cast(bank::CardKind::Credit) ? QStringLiteral("Credit") + : QStringLiteral("Debit"); + info->addWidget(ui::label(kind + QStringLiteral(" card ••••") + QString::fromStdString(card.panLast4), + QStringLiteral("H2"))); + info->addWidget(ui::label(QStringLiteral("Daily limit ") + ui::formatMinor(card.dailyLimitMinor, 0), + QStringLiteral("Muted"))); + row->addLayout(info); + row->addStretch(); + row->addWidget(ui::pill(statusName(card.status), statusPill(card.status))); + + const auto id = card.id; + const bool active = card.status == static_cast(bank::CardStatus::Active); + const bool cancelled = card.status == static_cast(bank::CardStatus::Cancelled); + + if (!cancelled) { + auto* toggle = ui::button(active ? QStringLiteral("Freeze") : QStringLiteral("Unfreeze")); + QObject::connect(toggle, &QPushButton::clicked, this, [this, id, active] { + auto done = [this](bank::dto::CommandResult) { refresh(); }; + auto fail = [](const std::exception_ptr&) {}; + if (active) { + _cards.execute(bank::dto::FreezeCard{.id = id}).then(done).onError(fail); + } else { + _cards.execute(bank::dto::UnfreezeCard{.id = id}).then(done).onError(fail); + } + }); + row->addWidget(toggle); + + auto* cancel = ui::button(QStringLiteral("Cancel"), QStringLiteral("danger")); + QObject::connect(cancel, &QPushButton::clicked, this, [this, id] { + _cards.execute(bank::dto::CancelCard{.id = id}) + .then([this](bank::dto::CommandResult) { refresh(); }) + .onError([](const std::exception_ptr&) {}); + }); + row->addWidget(cancel); + } + _list->addWidget(frame); + } +} + +} // namespace bankgui diff --git a/examples/bank/gui/views/CardsView.hpp b/examples/bank/gui/views/CardsView.hpp new file mode 100644 index 0000000..ef4ffb3 --- /dev/null +++ b/examples/bank/gui/views/CardsView.hpp @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "../BankClient.hpp" +#include "../Page.hpp" +#include "bank/dto/card_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/card_model.hpp" + +class QComboBox; +class QLineEdit; +class QVBoxLayout; + +namespace bankgui { + +/// @brief Issue cards against an account and freeze / unfreeze / cancel them. +class CardsView : public Page { +public: + explicit CardsView(BankClient& client, QWidget* parent = nullptr); + void refresh() override; + +private: + void issueCard(); + void rebuild(const std::vector& cards); + + BankClient& _client; + morph::bridge::BridgeHandler _accounts; + morph::bridge::BridgeHandler _cards; + QComboBox* _account{}; + QComboBox* _kind{}; + QLineEdit* _limit{}; + QVBoxLayout* _list{}; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/views/LoansView.cpp b/examples/bank/gui/views/LoansView.cpp new file mode 100644 index 0000000..6ecef15 --- /dev/null +++ b/examples/bank/gui/views/LoansView.cpp @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "LoansView.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../Ui.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" + +namespace bankgui { + +namespace { + +QString loanStatusName(int status) { + switch (static_cast(status)) { + case bank::LoanStatus::Active: return QStringLiteral("Active"); + case bank::LoanStatus::PaidOff: return QStringLiteral("Paid off"); + case bank::LoanStatus::Defaulted: return QStringLiteral("Defaulted"); + } + return QStringLiteral("—"); +} + +} // namespace + +LoansView::LoansView(BankClient& client, QWidget* parent) + : Page(parent), + _client{client}, + _loans{client.bridge(), client.gui()}, + _accounts{client.bridge(), client.gui()} { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(18); + + // ── Apply ────────────────────────────────────────────────────────────── + auto* applyCard = ui::card(); + auto* apply = new QHBoxLayout(applyCard); + apply->setContentsMargins(16, 14, 16, 14); + apply->setSpacing(10); + apply->addWidget(ui::label(QStringLiteral("Apply for a loan"), QStringLiteral("H2"))); + apply->addStretch(); + _account = new QComboBox; + _account->setMinimumWidth(160); + _principal = new QLineEdit; + _principal->setPlaceholderText(QStringLiteral("Principal")); + _rate = new QLineEdit; + _rate->setPlaceholderText(QStringLiteral("Rate (bps)")); + _rate->setMaximumWidth(110); + _term = new QLineEdit; + _term->setPlaceholderText(QStringLiteral("Months")); + _term->setMaximumWidth(90); + auto* applyBtn = ui::button(QStringLiteral("Apply"), QStringLiteral("primary")); + apply->addWidget(_account); + apply->addWidget(_principal); + apply->addWidget(_rate); + apply->addWidget(_term); + apply->addWidget(applyBtn); + root->addWidget(applyCard); + QObject::connect(applyBtn, &QPushButton::clicked, this, [this] { applyLoan(); }); + + _status = ui::label(QString(), QStringLiteral("Muted")); + root->addWidget(_status); + + // ── Body: loan list (top) + schedule (below), stacked full-width ─────────── + auto* body = new QVBoxLayout; + body->setSpacing(16); + + auto* scroll = new QScrollArea; + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + auto* host = new QWidget; + _list = new QVBoxLayout(host); + _list->setContentsMargins(0, 0, 0, 0); + _list->setSpacing(12); + _list->setAlignment(Qt::AlignTop); + scroll->setWidget(host); + body->addWidget(scroll, 1); + + auto* schedCard = ui::card(); + auto* sched = new QVBoxLayout(schedCard); + sched->setContentsMargins(16, 14, 16, 14); + sched->addWidget(ui::label(QStringLiteral("Amortization schedule"), QStringLiteral("H2"))); + _schedule = new QTableWidget(0, 4); + _schedule->setHorizontalHeaderLabels( + {QStringLiteral("#"), QStringLiteral("Principal"), QStringLiteral("Interest"), QStringLiteral("Remaining")}); + _schedule->horizontalHeader()->setStretchLastSection(true); + _schedule->verticalHeader()->setVisible(false); + _schedule->setEditTriggers(QAbstractItemView::NoEditTriggers); + _schedule->setSelectionMode(QAbstractItemView::NoSelection); + _schedule->setShowGrid(false); + sched->addWidget(_schedule); + body->addWidget(schedCard, 1); + + root->addLayout(body, 1); +} + +void LoansView::setStatus(const QString& message, bool error) { + _status->setObjectName(error ? QStringLiteral("Danger") : QStringLiteral("Success")); + _status->setText(message); + ui::repolish(_status); +} + +void LoansView::applyLoan() { + if (!_account->currentData().isValid()) { + setStatus(QStringLiteral("Pick an account."), true); + return; + } + const auto principal = ui::parseToMinor(_principal->text(), 2); + bool rateOk = false; + bool termOk = false; + const int rate = _rate->text().trimmed().toInt(&rateOk); + const int term = _term->text().trimmed().toInt(&termOk); + if (!principal || !rateOk || !termOk || term <= 0) { + setStatus(QStringLiteral("Enter principal, rate (bps), and term (months)."), true); + return; + } + _loans + .execute(bank::dto::ApplyLoan{.accountId = _account->currentData().toLongLong(), + .principalMinor = *principal, + .rateBps = rate, + .termMonths = term}) + .then([this](bank::dto::LoanInfo) { + _principal->clear(); + _rate->clear(); + _term->clear(); + setStatus(QStringLiteral("Loan disbursed."), false); + refresh(); + }) + .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); +} + +void LoansView::refresh() { + _accounts.execute(bank::dto::ListAccounts{}) + .then([this](bank::dto::AccountList list) { + _account->clear(); + for (const auto& account : list.accounts) { + if (account.status == static_cast(bank::AccountStatus::Closed)) { + continue; + } + _account->addItem(QStringLiteral("•••• ") + QString::fromStdString(account.number).right(4), + QVariant::fromValue(account.id)); + } + }) + .onError([](const std::exception_ptr&) {}); + + _loans.execute(bank::dto::ListLoans{}) + .then([this](bank::dto::LoanList list) { rebuild(list.loans); }) + .onError([](const std::exception_ptr&) {}); +} + +void LoansView::rebuild(const std::vector& loans) { + ui::clearLayout(_list); + for (const auto& loan : loans) { + auto* frame = ui::card(); + auto* row = new QHBoxLayout(frame); + row->setContentsMargins(20, 14, 20, 14); + + auto* info = new QVBoxLayout; + info->addWidget(ui::label(QStringLiteral("Loan #%1").arg(loan.id), QStringLiteral("H2"))); + info->addWidget(ui::label(QStringLiteral("Outstanding ") + + ui::formatMinor(loan.outstandingMinor, loan.currency) + + QStringLiteral(" · %1 bps · %2 mo").arg(loan.rateBps).arg(loan.termMonths), + QStringLiteral("Muted"))); + row->addLayout(info); + row->addStretch(); + + const bool paid = loan.status == static_cast(bank::LoanStatus::PaidOff); + row->addWidget(ui::pill(loanStatusName(loan.status), + paid ? QStringLiteral("good") : QStringLiteral("neutral"))); + + const auto id = loan.id; + const auto accountId = loan.accountId; + const auto outstanding = loan.outstandingMinor; + auto* sched = ui::button(QStringLiteral("Schedule")); + QObject::connect(sched, &QPushButton::clicked, this, [this, id] { showSchedule(id); }); + row->addWidget(sched); + + if (!paid) { + auto* repay = ui::button(QStringLiteral("Repay"), QStringLiteral("primary")); + QObject::connect(repay, &QPushButton::clicked, this, [this, id, accountId, outstanding] { + _loans + .execute(bank::dto::RepayLoan{.loanId = id, .fromAccountId = accountId, + .amountMinor = outstanding}) + .then([this](bank::dto::LoanInfo) { setStatus(QStringLiteral("Repayment made."), false); refresh(); }) + .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); + }); + row->addWidget(repay); + } + _list->addWidget(frame); + } +} + +void LoansView::showSchedule(std::int64_t loanId) { + _loans.execute(bank::dto::LoanScheduleRequest{.loanId = loanId}) + .then([this](bank::dto::LoanScheduleResult result) { + _schedule->setRowCount(static_cast(result.installments.size())); + int row = 0; + for (const auto& inst : result.installments) { + _schedule->setItem(row, 0, new QTableWidgetItem(QString::number(inst.month))); + _schedule->setItem(row, 1, new QTableWidgetItem(ui::formatMinor(inst.principalMinor, 0))); + _schedule->setItem(row, 2, new QTableWidgetItem(ui::formatMinor(inst.interestMinor, 0))); + _schedule->setItem(row, 3, new QTableWidgetItem(ui::formatMinor(inst.remainingMinor, 0))); + ++row; + } + }) + .onError([](const std::exception_ptr&) {}); +} + +} // namespace bankgui diff --git a/examples/bank/gui/views/LoansView.hpp b/examples/bank/gui/views/LoansView.hpp new file mode 100644 index 0000000..4b3fb58 --- /dev/null +++ b/examples/bank/gui/views/LoansView.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "../BankClient.hpp" +#include "../Page.hpp" +#include "bank/dto/loan_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/loan_model.hpp" + +class QComboBox; +class QLineEdit; +class QLabel; +class QVBoxLayout; +class QTableWidget; + +namespace bankgui { + +/// @brief Apply for loans, view amortization schedules, and make repayments. +class LoansView : public Page { +public: + explicit LoansView(BankClient& client, QWidget* parent = nullptr); + void refresh() override; + +private: + void applyLoan(); + void rebuild(const std::vector& loans); + void showSchedule(std::int64_t loanId); + void setStatus(const QString& message, bool error); + + BankClient& _client; + morph::bridge::BridgeHandler _loans; + morph::bridge::BridgeHandler _accounts; + QComboBox* _account{}; + QLineEdit* _principal{}; + QLineEdit* _rate{}; + QLineEdit* _term{}; + QLabel* _status{}; + QVBoxLayout* _list{}; + QTableWidget* _schedule{}; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/views/MoveMoneyView.cpp b/examples/bank/gui/views/MoveMoneyView.cpp new file mode 100644 index 0000000..e921e22 --- /dev/null +++ b/examples/bank/gui/views/MoveMoneyView.cpp @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "MoveMoneyView.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../Ui.hpp" +#include "bank/core/types.hpp" + +namespace bankgui { + +namespace { + +QString txnKindName(int kind) { + switch (static_cast(kind)) { + case bank::TxnKind::Deposit: return QStringLiteral("Deposit"); + case bank::TxnKind::Withdrawal: return QStringLiteral("Withdrawal"); + case bank::TxnKind::TransferIn: return QStringLiteral("Transfer in"); + case bank::TxnKind::TransferOut: return QStringLiteral("Transfer out"); + case bank::TxnKind::Payment: return QStringLiteral("Payment"); + case bank::TxnKind::Fee: return QStringLiteral("Fee"); + case bank::TxnKind::Interest: return QStringLiteral("Interest"); + case bank::TxnKind::LoanDisbursement: return QStringLiteral("Loan in"); + case bank::TxnKind::LoanRepayment: return QStringLiteral("Loan repay"); + case bank::TxnKind::CardPurchase: return QStringLiteral("Card"); + case bank::TxnKind::Exchange: return QStringLiteral("Exchange"); + } + return QStringLiteral("Entry"); +} + +QString accountLabel(const bank::dto::AccountInfo& account) { + return QStringLiteral("•••• %1 (%2)") + .arg(QString::fromStdString(account.number).right(4), + ui::formatMinor(account.balanceMinor, account.currency)); +} + +} // namespace + +MoveMoneyView::MoveMoneyView(BankClient& client, QWidget* parent) + : Page(parent), + _client{client}, + _accounts{client.bridge(), client.gui()}, + _txns{client.bridge(), client.gui()} { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(18); + + // ── Move money card ──────────────────────────────────────────────────── + auto* moveCard = ui::card(); + auto* move = new QVBoxLayout(moveCard); + move->setContentsMargins(20, 18, 20, 18); + move->setSpacing(12); + move->addWidget(ui::label(QStringLiteral("Move money"), QStringLiteral("H2"))); + + auto* accRow = new QHBoxLayout; + accRow->addWidget(ui::label(QStringLiteral("Account"), QStringLiteral("Muted"))); + _account = new QComboBox; + _account->setMinimumWidth(280); + accRow->addWidget(_account, 1); + move->addLayout(accRow); + QObject::connect(_account, &QComboBox::currentIndexChanged, this, [this] { reloadHistory(); }); + + // Deposit + withdraw row. + auto* dwRow = new QHBoxLayout; + _amount = new QLineEdit; + _amount->setPlaceholderText(QStringLiteral("Amount")); + auto* deposit = ui::button(QStringLiteral("Deposit"), QStringLiteral("primary")); + auto* withdraw = ui::button(QStringLiteral("Withdraw")); + dwRow->addWidget(_amount, 1); + dwRow->addWidget(deposit); + dwRow->addWidget(withdraw); + move->addLayout(dwRow); + + // Transfer row. + auto* tRow = new QHBoxLayout; + _target = new QComboBox; + _target->setMinimumWidth(220); + _transferAmount = new QLineEdit; + _transferAmount->setPlaceholderText(QStringLiteral("Amount")); + auto* transfer = ui::button(QStringLiteral("Transfer to"), QStringLiteral("primary")); + tRow->addWidget(transfer); + tRow->addWidget(_target, 1); + tRow->addWidget(_transferAmount, 1); + move->addLayout(tRow); + + _status = ui::label(QString(), QStringLiteral("Muted")); + _status->setWordWrap(true); + move->addWidget(_status); + root->addWidget(moveCard); + + // ── History card ───────────────────────────────────────────────────────── + auto* histCard = ui::card(); + auto* hist = new QVBoxLayout(histCard); + hist->setContentsMargins(20, 18, 20, 18); + hist->setSpacing(12); + hist->addWidget(ui::label(QStringLiteral("Recent activity"), QStringLiteral("H2"))); + _history = new QTableWidget(0, 3); + _history->setHorizontalHeaderLabels({QStringLiteral("Type"), QStringLiteral("Amount"), + QStringLiteral("Balance")}); + _history->horizontalHeader()->setStretchLastSection(true); + _history->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + _history->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + _history->verticalHeader()->setVisible(false); + _history->setEditTriggers(QAbstractItemView::NoEditTriggers); + _history->setSelectionMode(QAbstractItemView::NoSelection); + _history->setShowGrid(false); + hist->addWidget(_history); + root->addWidget(histCard, 1); + + // ── Wiring ──────────────────────────────────────────────────────────────── + QObject::connect(deposit, &QPushButton::clicked, this, [this] { + auto minor = ui::parseToMinor(_amount->text(), bank::currencyDecimals(static_cast(selectedCurrency()))); + if (!minor || selectedAccountId() == 0) { + setStatus(QStringLiteral("Enter a valid amount."), true); + return; + } + _txns.execute(bank::dto::Deposit{.accountId = selectedAccountId(), .amountMinor = *minor}) + .then([this](bank::dto::TxnInfo) { _amount->clear(); setStatus(QStringLiteral("Deposit complete."), false); refresh(); }) + .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); + }); + QObject::connect(withdraw, &QPushButton::clicked, this, [this] { + auto minor = ui::parseToMinor(_amount->text(), bank::currencyDecimals(static_cast(selectedCurrency()))); + if (!minor || selectedAccountId() == 0) { + setStatus(QStringLiteral("Enter a valid amount."), true); + return; + } + _txns.execute(bank::dto::Withdraw{.accountId = selectedAccountId(), .amountMinor = *minor}) + .then([this](bank::dto::TxnInfo) { _amount->clear(); setStatus(QStringLiteral("Withdrawal complete."), false); refresh(); }) + .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); + }); + QObject::connect(transfer, &QPushButton::clicked, this, [this] { + auto minor = ui::parseToMinor(_transferAmount->text(), bank::currencyDecimals(static_cast(selectedCurrency()))); + const auto to = _target->currentData().toLongLong(); + if (!minor || selectedAccountId() == 0 || to == 0) { + setStatus(QStringLiteral("Pick a target account and amount."), true); + return; + } + _txns.execute(bank::dto::Transfer{.fromAccountId = selectedAccountId(), .toAccountId = to, .amountMinor = *minor}) + .then([this](bank::dto::TransferResult) { _transferAmount->clear(); setStatus(QStringLiteral("Transfer complete."), false); refresh(); }) + .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); + }); +} + +std::int64_t MoveMoneyView::selectedAccountId() const { + return _account->currentData().isValid() ? _account->currentData().toLongLong() : 0; +} + +int MoveMoneyView::selectedCurrency() const { + const auto id = selectedAccountId(); + for (const auto& account : _cache) { + if (account.id == id) { + return account.currency; + } + } + return 0; +} + +void MoveMoneyView::setStatus(const QString& message, bool error) { + _status->setObjectName(error ? QStringLiteral("Danger") : QStringLiteral("Success")); + _status->setText(message); + ui::repolish(_status); +} + +void MoveMoneyView::refresh() { + _accounts.execute(bank::dto::ListAccounts{}) + .then([this](bank::dto::AccountList list) { + const auto previous = selectedAccountId(); + _cache.clear(); + _account->clear(); + _target->clear(); + for (const auto& account : list.accounts) { + if (account.status == static_cast(bank::AccountStatus::Closed)) { + continue; + } + _cache.push_back(account); + _account->addItem(accountLabel(account), QVariant::fromValue(account.id)); + _target->addItem(accountLabel(account), QVariant::fromValue(account.id)); + } + // Restore prior selection if still present. + const int idx = _account->findData(QVariant::fromValue(previous)); + if (idx >= 0) { + _account->setCurrentIndex(idx); + } + reloadHistory(); + }) + .onError([](const std::exception_ptr&) {}); +} + +void MoveMoneyView::reloadHistory() { + const auto id = selectedAccountId(); + if (id == 0) { + _history->setRowCount(0); + return; + } + _txns.execute(bank::dto::History{.accountId = id, .limit = 50}) + .then([this](bank::dto::HistoryPage page) { + _history->setRowCount(static_cast(page.entries.size())); + int row = 0; + for (const auto& entry : page.entries) { + const bool credit = entry.direction == static_cast(bank::TxnDirection::Credit); + const QString sign = credit ? QStringLiteral("+") : QStringLiteral("−"); + auto* kind = new QTableWidgetItem(txnKindName(entry.kind)); + auto* amount = new QTableWidgetItem(sign + ui::formatMinor(entry.amountMinor, entry.currency)); + amount->setForeground(QColor(credit ? "#2F9E66" : "#C0392B")); + auto* balance = new QTableWidgetItem(ui::formatMinor(entry.balanceAfterMinor, entry.currency)); + _history->setItem(row, 0, kind); + _history->setItem(row, 1, amount); + _history->setItem(row, 2, balance); + ++row; + } + }) + .onError([](const std::exception_ptr&) {}); +} + +} // namespace bankgui diff --git a/examples/bank/gui/views/MoveMoneyView.hpp b/examples/bank/gui/views/MoveMoneyView.hpp new file mode 100644 index 0000000..0f52220 --- /dev/null +++ b/examples/bank/gui/views/MoveMoneyView.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "../BankClient.hpp" +#include "../Page.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/transaction_model.hpp" + +class QComboBox; +class QLineEdit; +class QLabel; +class QTableWidget; + +namespace bankgui { + +/// @brief Deposit / withdraw / transfer plus the selected account's history. +class MoveMoneyView : public Page { +public: + explicit MoveMoneyView(BankClient& client, QWidget* parent = nullptr); + void refresh() override; + +private: + [[nodiscard]] std::int64_t selectedAccountId() const; + [[nodiscard]] int selectedCurrency() const; + void reloadHistory(); + void setStatus(const QString& message, bool error); + + BankClient& _client; + morph::bridge::BridgeHandler _accounts; + morph::bridge::BridgeHandler _txns; + std::vector _cache; + QComboBox* _account{}; + QComboBox* _target{}; + QLineEdit* _amount{}; + QLineEdit* _transferAmount{}; + QLabel* _status{}; + QTableWidget* _history{}; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/views/PayeesView.cpp b/examples/bank/gui/views/PayeesView.cpp new file mode 100644 index 0000000..bfcd762 --- /dev/null +++ b/examples/bank/gui/views/PayeesView.cpp @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "PayeesView.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +#include "../Ui.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/payment_dto.hpp" + +namespace bankgui { + +PayeesView::PayeesView(BankClient& client, QWidget* parent) + : Page(parent), + _client{client}, + _payees{client.bridge(), client.gui()}, + _accounts{client.bridge(), client.gui()}, + _payments{client.bridge(), client.gui()} { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(18); + + // ── Add payee ────────────────────────────────────────────────────────── + auto* addCard = ui::card(); + auto* add = new QHBoxLayout(addCard); + add->setContentsMargins(16, 14, 16, 14); + add->setSpacing(10); + _name = new QLineEdit; + _name->setPlaceholderText(QStringLiteral("Payee name")); + _iban = new QLineEdit; + _iban->setPlaceholderText(QStringLiteral("IBAN")); + _bank = new QLineEdit; + _bank->setPlaceholderText(QStringLiteral("Bank (optional)")); + auto* addBtn = ui::button(QStringLiteral("Add payee"), QStringLiteral("primary")); + add->addWidget(_name, 1); + add->addWidget(_iban, 1); + add->addWidget(_bank, 1); + add->addWidget(addBtn); + root->addWidget(addCard); + QObject::connect(addBtn, &QPushButton::clicked, this, [this] { addPayee(); }); + + // ── Pay a bill ───────────────────────────────────────────────────────── + auto* payCard = ui::card(); + auto* pay = new QHBoxLayout(payCard); + pay->setContentsMargins(16, 14, 16, 14); + pay->setSpacing(10); + pay->addWidget(ui::label(QStringLiteral("Pay bill"), QStringLiteral("H2"))); + pay->addStretch(); + _payAccount = new QComboBox; + _payAccount->setMinimumWidth(180); + _payPayee = new QComboBox; + _payPayee->setMinimumWidth(180); + _payAmount = new QLineEdit; + _payAmount->setPlaceholderText(QStringLiteral("Amount")); + auto* payBtn = ui::button(QStringLiteral("Pay"), QStringLiteral("primary")); + pay->addWidget(_payAccount); + pay->addWidget(_payPayee); + pay->addWidget(_payAmount); + pay->addWidget(payBtn); + root->addWidget(payCard); + QObject::connect(payBtn, &QPushButton::clicked, this, [this] { payBill(); }); + + _status = ui::label(QString(), QStringLiteral("Muted")); + root->addWidget(_status); + + // ── Payee list ───────────────────────────────────────────────────────── + auto* scroll = new QScrollArea; + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + auto* host = new QWidget; + _list = new QVBoxLayout(host); + _list->setContentsMargins(0, 0, 0, 0); + _list->setSpacing(12); + _list->setAlignment(Qt::AlignTop); + scroll->setWidget(host); + root->addWidget(scroll, 1); +} + +void PayeesView::setStatus(const QString& message, bool error) { + _status->setObjectName(error ? QStringLiteral("Danger") : QStringLiteral("Success")); + _status->setText(message); + ui::repolish(_status); +} + +void PayeesView::addPayee() { + _payees + .execute(bank::dto::AddPayee{.name = _name->text().toStdString(), + .iban = _iban->text().trimmed().toStdString(), + .bankName = _bank->text().toStdString()}) + .then([this](bank::dto::PayeeInfo) { + _name->clear(); + _iban->clear(); + _bank->clear(); + setStatus(QStringLiteral("Payee added."), false); + refresh(); + }) + .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); +} + +void PayeesView::payBill() { + if (!_payAccount->currentData().isValid() || !_payPayee->currentData().isValid()) { + setStatus(QStringLiteral("Pick an account and payee."), true); + return; + } + const auto minor = ui::parseToMinor(_payAmount->text(), 2); + if (!minor) { + setStatus(QStringLiteral("Enter a valid amount."), true); + return; + } + _payments + .execute(bank::dto::PayBill{.fromAccountId = _payAccount->currentData().toLongLong(), + .payeeId = _payPayee->currentData().toLongLong(), + .amountMinor = *minor}) + .then([this](bank::dto::PaymentInfo) { + _payAmount->clear(); + setStatus(QStringLiteral("Payment sent."), false); + }) + .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); +} + +void PayeesView::refresh() { + _accounts.execute(bank::dto::ListAccounts{}) + .then([this](bank::dto::AccountList list) { + _payAccount->clear(); + for (const auto& account : list.accounts) { + if (account.status == static_cast(bank::AccountStatus::Closed)) { + continue; + } + _payAccount->addItem(QStringLiteral("•••• ") + QString::fromStdString(account.number).right(4), + QVariant::fromValue(account.id)); + } + }) + .onError([](const std::exception_ptr&) {}); + + _payees.execute(bank::dto::ListPayees{}) + .then([this](bank::dto::PayeeList list) { rebuild(list.payees); }) + .onError([](const std::exception_ptr&) {}); +} + +void PayeesView::rebuild(const std::vector& payees) { + ui::clearLayout(_list); + _payPayee->clear(); + for (const auto& payee : payees) { + _payPayee->addItem(QString::fromStdString(payee.name), QVariant::fromValue(payee.id)); + + auto* frame = ui::card(); + auto* row = new QHBoxLayout(frame); + row->setContentsMargins(20, 14, 20, 14); + auto* info = new QVBoxLayout; + info->addWidget(ui::label(QString::fromStdString(payee.name), QStringLiteral("H2"))); + info->addWidget(ui::label(QString::fromStdString(payee.iban), QStringLiteral("Muted"))); + row->addLayout(info); + row->addStretch(); + + const auto id = payee.id; + auto* remove = ui::button(QStringLiteral("Remove"), QStringLiteral("danger")); + QObject::connect(remove, &QPushButton::clicked, this, [this, id] { + _payees.execute(bank::dto::RemovePayee{.id = id}) + .then([this](bank::dto::CommandResult) { refresh(); }) + .onError([](const std::exception_ptr&) {}); + }); + row->addWidget(remove); + _list->addWidget(frame); + } +} + +} // namespace bankgui diff --git a/examples/bank/gui/views/PayeesView.hpp b/examples/bank/gui/views/PayeesView.hpp new file mode 100644 index 0000000..db3b129 --- /dev/null +++ b/examples/bank/gui/views/PayeesView.hpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "../BankClient.hpp" +#include "../Page.hpp" +#include "bank/dto/payee_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/payee_model.hpp" +#include "bank/models/payment_model.hpp" + +class QComboBox; +class QLineEdit; +class QLabel; +class QVBoxLayout; + +namespace bankgui { + +/// @brief Manage beneficiaries and pay bills. +class PayeesView : public Page { +public: + explicit PayeesView(BankClient& client, QWidget* parent = nullptr); + void refresh() override; + +private: + void addPayee(); + void payBill(); + void rebuild(const std::vector& payees); + void setStatus(const QString& message, bool error); + + BankClient& _client; + morph::bridge::BridgeHandler _payees; + morph::bridge::BridgeHandler _accounts; + morph::bridge::BridgeHandler _payments; + QLineEdit* _name{}; + QLineEdit* _iban{}; + QLineEdit* _bank{}; + QComboBox* _payAccount{}; + QComboBox* _payPayee{}; + QLineEdit* _payAmount{}; + QLabel* _status{}; + QVBoxLayout* _list{}; +}; + +} // namespace bankgui diff --git a/examples/bank/include/bank/app/app.hpp b/examples/bank/include/bank/app/app.hpp new file mode 100644 index 0000000..8590901 --- /dev/null +++ b/examples/bank/include/bank/app/app.hpp @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include +#include + +/// @file +/// `App` wires the pieces every screen shares: the worker pool that runs the +/// models, the GUI executor that callbacks land on, and the single process-wide +/// `Bridge`. It also owns the database lifecycle (connection + migrations) and +/// the login/logout flow that sets the bridge's default session. +/// +/// A real GUI would construct one `App`, then build a `BridgeHandler` +/// per screen against `app.bridge()` / `app.gui()`. + +namespace bank::app { + +/// @brief Shared application context: pool, GUI executor, bridge, and session. +class App { +public: + /// @brief Builds the app over a local backend and sets up the database. + /// + /// @param connectionString ODBC string for the SQLite database. + /// @param workers Size of the model worker pool. + explicit App(const std::string& connectionString, std::size_t workers = 4); + + App(const App&) = delete; + App& operator=(const App&) = delete; + App(App&&) = delete; + App& operator=(App&&) = delete; + ~App() = default; + + /// @brief The shared bridge every handler registers on. + [[nodiscard]] morph::bridge::Bridge& bridge() noexcept { return _bridge; } + + /// @brief Executor that `Completion` callbacks are delivered on. + [[nodiscard]] morph::exec::IExecutor* gui() noexcept { return &_gui; } + + /// @brief The pumpable GUI loop (call `runFor` from a driver / event loop). + [[nodiscard]] morph::exec::MainThreadExecutor& guiLoop() noexcept { return _gui; } + + /// @brief Sets the session principal applied to every subsequent call. + void login(const std::string& principal); + + /// @brief Clears the session principal. + void logout(); + +private: + morph::exec::ThreadPoolExecutor _pool; + morph::exec::MainThreadExecutor _gui; + morph::bridge::Bridge _bridge; +}; + +} // namespace bank::app diff --git a/examples/bank/include/bank/core/errors.hpp b/examples/bank/include/bank/core/errors.hpp new file mode 100644 index 0000000..e6f741f --- /dev/null +++ b/examples/bank/include/bank/core/errors.hpp @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +/// @file +/// Domain exceptions. A model's `execute(...)` throws one of these; morph +/// captures it as a `std::exception_ptr` and delivers it to the caller's +/// `.onError(...)` callback on the GUI executor. On a remote backend the +/// `what()` string travels back in the error envelope. + +namespace bank { + +/// @brief Base class for all banking domain errors. +struct BankError : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +/// @brief A referenced entity (account, payee, loan, …) does not exist. +struct NotFound : BankError { + using BankError::BankError; +}; + +/// @brief An account lacked the funds (incl. overdraft) to complete a debit. +struct InsufficientFunds : BankError { + using BankError::BankError; +}; + +/// @brief The caller's session is not permitted to perform the action. +struct Unauthorized : BankError { + using BankError::BankError; +}; + +/// @brief The action's inputs failed validation. +struct ValidationError : BankError { + using BankError::BankError; +}; + +/// @brief The action conflicts with current state (e.g. closing a non-empty account). +struct ConflictError : BankError { + using BankError::BankError; +}; + +} // namespace bank diff --git a/examples/bank/include/bank/core/money.hpp b/examples/bank/include/bank/core/money.hpp new file mode 100644 index 0000000..de414d4 --- /dev/null +++ b/examples/bank/include/bank/core/money.hpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include "types.hpp" + +/// @file +/// `Money` — a plain aggregate carrying an amount in **integer minor units** +/// (e.g. cents) plus its currency. Integer minor units avoid floating-point +/// rounding entirely; the database stores `minor` as a BIGINT column. +/// +/// `Money` is deliberately a flat aggregate of trivially-serialisable members +/// so it round-trips through Glaze (and therefore the morph wire) with no +/// hand-written codec. + +namespace bank { + +/// @brief An amount of money: integer minor units tagged with a currency. +struct Money { + std::int64_t minor = 0; ///< amount in minor units (cents); may be negative + Currency currency = Currency::USD; ///< currency the amount is denominated in +}; + +/// @brief Convenience constructor. +[[nodiscard]] inline Money money(std::int64_t minor, Currency currency) noexcept { + return Money{.minor = minor, .currency = currency}; +} + +/// @brief Adds two amounts. Caller must ensure currencies match. +[[nodiscard]] inline Money operator+(Money lhs, Money rhs) noexcept { + return Money{.minor = lhs.minor + rhs.minor, .currency = lhs.currency}; +} + +/// @brief Subtracts @p rhs from @p lhs. Caller must ensure currencies match. +[[nodiscard]] inline Money operator-(Money lhs, Money rhs) noexcept { + return Money{.minor = lhs.minor - rhs.minor, .currency = lhs.currency}; +} + +[[nodiscard]] inline bool operator==(Money lhs, Money rhs) noexcept { + return lhs.minor == rhs.minor && lhs.currency == rhs.currency; +} + +/// @brief Human-readable rendering, e.g. `1234` cents USD -> "12.34 USD". +[[nodiscard]] std::string format(Money amount); + +} // namespace bank diff --git a/examples/bank/include/bank/core/principal.hpp b/examples/bank/include/bank/core/principal.hpp new file mode 100644 index 0000000..2d8a0f3 --- /dev/null +++ b/examples/bank/include/bank/core/principal.hpp @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +/// @file +/// Helpers for reading the authenticated principal from the morph session +/// context. The bridge attaches its default session (set once at login via +/// `Bridge::setDefaultSession`) to every call, and the model reads it here +/// without changing its `execute()` signatures. + +namespace bank { + +/// @brief Returns the current session principal, or empty if none is attached. +[[nodiscard]] inline std::string sessionPrincipal() { + if (const auto* ctx = morph::session::current(); ctx != nullptr) { + return ctx->principal; + } + return {}; +} + +/// @brief Returns @p explicitOwner if non-empty, otherwise the session principal. +[[nodiscard]] inline std::string resolveOwner(const std::string& explicitOwner) { + return explicitOwner.empty() ? sessionPrincipal() : explicitOwner; +} + +} // namespace bank diff --git a/examples/bank/include/bank/core/types.hpp b/examples/bank/include/bank/core/types.hpp new file mode 100644 index 0000000..aca4ecd --- /dev/null +++ b/examples/bank/include/bank/core/types.hpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +/// @file +/// Core enumerations shared by the wire DTOs and the database entities. +/// +/// These enums are stored in the database as their integer value and travel +/// over the morph wire as integers too, so the numeric values are part of the +/// (example) protocol — append, never renumber. + +namespace bank { + +/// @brief ISO-4217-ish currency tags supported by the demo bank. +enum class Currency : std::uint8_t { + USD = 0, + EUR = 1, + GBP = 2, + CHF = 3, + JPY = 4, +}; + +/// @brief Number of fractional digits a currency uses (minor units per major unit exponent). +/// JPY has no minor unit; everything else here uses 2 decimal places. +[[nodiscard]] constexpr int currencyDecimals(Currency c) noexcept { + return c == Currency::JPY ? 0 : 2; +} + +/// @brief Three-letter code for a currency (for display / statements). +[[nodiscard]] constexpr std::string_view currencyCode(Currency c) noexcept { + switch (c) { + case Currency::USD: return "USD"; + case Currency::EUR: return "EUR"; + case Currency::GBP: return "GBP"; + case Currency::CHF: return "CHF"; + case Currency::JPY: return "JPY"; + } + return "???"; +} + +/// @brief Kind of account a customer can hold. +enum class AccountKind : std::uint8_t { + Checking = 0, + Savings = 1, + Credit = 2, +}; + +/// @brief Lifecycle state of an account. +enum class AccountStatus : std::uint8_t { + Open = 0, + Frozen = 1, + Closed = 2, +}; + +/// @brief Whether a transaction moved money in or out of an account. +enum class TxnDirection : std::uint8_t { + Credit = 0, ///< money in + Debit = 1, ///< money out +}; + +/// @brief How a payment recurs. +enum class PaymentSchedule : std::uint8_t { + OneOff = 0, ///< paid immediately, once + Scheduled = 1, ///< due once at a future time + Standing = 2, ///< recurring every N days +}; + +/// @brief Lifecycle of a payment instruction. +enum class PaymentStatus : std::uint8_t { + Pending = 0, + Completed = 1, + Cancelled = 2, + Failed = 3, +}; + +/// @brief Lifecycle/state of a payment card. +enum class CardStatus : std::uint8_t { + Active = 0, + Frozen = 1, + Cancelled = 2, +}; + +/// @brief Kind of payment card. +enum class CardKind : std::uint8_t { + Debit = 0, + Credit = 1, +}; + +/// @brief Lifecycle of a loan. +enum class LoanStatus : std::uint8_t { + Active = 0, + PaidOff = 1, + Defaulted = 2, +}; + +/// @brief Category of a ledger entry (drives history rendering and analytics). +enum class TxnKind : std::uint8_t { + Deposit = 0, + Withdrawal = 1, + TransferIn = 2, + TransferOut = 3, + Payment = 4, + Fee = 5, + Interest = 6, + LoanDisbursement = 7, + LoanRepayment = 8, + CardPurchase = 9, + Exchange = 10, +}; + +} // namespace bank diff --git a/examples/bank/include/bank/db/account_entity.hpp b/examples/bank/include/bank/db/account_entity.hpp new file mode 100644 index 0000000..93561f2 --- /dev/null +++ b/examples/bank/include/bank/db/account_entity.hpp @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +/// @file +/// Lightweight entity for the `accounts` table. This is the *persistence* shape +/// — `Light::Field<>`-wrapped members mapped to columns — and is distinct from +/// the wire DTOs in `bank/dto/`. Models translate between the two. + +namespace bank::db { + +/// @brief One row of the `accounts` table. +struct AccountRecord { + static constexpr std::string_view TableName = "accounts"; + + Light::Field id; + /// Owning principal (the logged-in user's session principal). + Light::Field, Light::SqlRealName{"owner"}> owner; + /// Generated account number (also used as the IBAN-ish identifier). + Light::Field, Light::SqlRealName{"number"}> number; + /// `AccountKind` as integer. + Light::Field kind; + /// `Currency` as integer. + Light::Field currency; + /// Current balance in minor units. + Light::Field balanceMinor{0}; + /// Allowed overdraft (positive number of minor units below zero) for the account. + Light::Field overdraftMinor{0}; + /// `AccountStatus` as integer. + Light::Field status{0}; + /// Annual interest rate in basis points (1% = 100 bps). + Light::Field interestBps{0}; +}; + +} // namespace bank::db diff --git a/examples/bank/include/bank/db/budget_entity.hpp b/examples/bank/include/bank/db/budget_entity.hpp new file mode 100644 index 0000000..72edb4c --- /dev/null +++ b/examples/bank/include/bank/db/budget_entity.hpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +namespace bank::db { + +/// @brief One row of the `budgets` table (a per-category monthly limit). +struct BudgetRecord { + static constexpr std::string_view TableName = "budgets"; + + Light::Field id; + Light::Field, Light::SqlRealName{"owner"}> owner; + Light::Field, Light::SqlRealName{"category"}> category; + Light::Field monthlyLimitMinor; + Light::Field currency; +}; + +} // namespace bank::db diff --git a/examples/bank/include/bank/db/card_entity.hpp b/examples/bank/include/bank/db/card_entity.hpp new file mode 100644 index 0000000..383a0f1 --- /dev/null +++ b/examples/bank/include/bank/db/card_entity.hpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +namespace bank::db { + +/// @brief One row of the `cards` table. +struct CardRecord { + static constexpr std::string_view TableName = "cards"; + + Light::Field id; + Light::Field, Light::SqlRealName{"owner"}> owner; + Light::Field accountId; + /// `CardKind` as integer. + Light::Field kind; + /// Last four digits of the (fictional) card number. + Light::Field, Light::SqlRealName{"pan_last4"}> panLast4; + /// `CardStatus` as integer. + Light::Field status{0}; + /// Daily spend limit in minor units (0 = no limit). + Light::Field dailyLimitMinor{0}; + /// Hash of the card PIN (demo-grade). + Light::Field, Light::SqlRealName{"pin_hash"}> pinHash; +}; + +} // namespace bank::db diff --git a/examples/bank/include/bank/db/database.hpp b/examples/bank/include/bank/db/database.hpp new file mode 100644 index 0000000..5ac42dd --- /dev/null +++ b/examples/bank/include/bank/db/database.hpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +/// @file +/// Process-wide database lifecycle for the bank example. +/// +/// Lightweight resolves its connection from a process-global default +/// connection string, and each morph model opens its own `DataMapper` against +/// that default (one connection per model, since each model runs single- +/// threaded on its own strand). The schema is owned by the migration runner. + +namespace bank::db { + +/// @brief Installs @p connectionString as Lightweight's default connection. +/// +/// @param connectionString ODBC connection string, e.g. +/// `"DRIVER=SQLite3;Database=bank.db"`. +void configure(const std::string& connectionString); + +/// @brief Applies any pending schema migrations against the default connection. +/// +/// Idempotent: migrations already recorded in the database's migration history +/// are skipped, so this is safe to call on every startup. +void applyMigrations(); + +/// @brief Convenience: `configure(connectionString)` followed by `applyMigrations()`. +void setup(const std::string& connectionString); + +} // namespace bank::db diff --git a/examples/bank/include/bank/db/db_model.hpp b/examples/bank/include/bank/db/db_model.hpp new file mode 100644 index 0000000..cf2a3cb --- /dev/null +++ b/examples/bank/include/bank/db/db_model.hpp @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +/// @file +/// Small mixin that gives a model a lazily-opened Lightweight `DataMapper`. +/// +/// morph runs each model single-threaded on its own strand, so a model can own +/// its own database connection with no synchronisation. The connection is +/// created on first use (i.e. on the strand thread, during the first +/// `execute(...)`) rather than at construction, keeping ODBC handles on the +/// thread that actually uses them. + +namespace bank::db { + +/// @brief Base providing `mapper()` — one lazily-constructed DataMapper per model. +class WithMapper { +protected: + WithMapper() = default; + + /// @brief Returns this model's DataMapper, opening it on first use. + [[nodiscard]] Lightweight::DataMapper& mapper() { + if (!_mapper.has_value()) { + _mapper.emplace(); + } + return *_mapper; + } + +private: + std::optional _mapper; +}; + +} // namespace bank::db diff --git a/examples/bank/include/bank/db/ledger_ops.hpp b/examples/bank/include/bank/db/ledger_ops.hpp new file mode 100644 index 0000000..217e9e5 --- /dev/null +++ b/examples/bank/include/bank/db/ledger_ops.hpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/types.hpp" +#include "bank/db/account_entity.hpp" +#include "bank/db/txn_entity.hpp" + +/// @file +/// Reusable ledger operations shared by every model that moves money +/// (transactions, payments, cards, loans, interest). All persistence goes +/// through the typed Lightweight `DataMapper` — no hand-written SQL. +/// +/// The caller passes its own `DataMapper`; when several writes must be atomic, +/// it wraps the calls in a `SqlTransaction` over `mapper.Connection()`. + +namespace bank::db { + +/// @brief Unix epoch milliseconds (used as the ledger's creation timestamp). +[[nodiscard]] inline std::int64_t nowMillis() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +/// @brief Loads an account by id, requiring it to exist and be open. +/// @throws NotFound if the account does not exist; ConflictError if not open. +[[nodiscard]] inline AccountRecord loadOpenAccount(Lightweight::DataMapper& mapper, std::int64_t accountId) { + auto acct = mapper.QuerySingle(static_cast(accountId)); + if (!acct.has_value()) { + throw NotFound{"account not found"}; + } + if (acct->status.Value() != static_cast(AccountStatus::Open)) { + throw ConflictError{"account is not open"}; + } + return *acct; +} + +/// @brief Inserts a ledger row reflecting @p account's *current* balance. +inline TxnRecord postEntry(Lightweight::DataMapper& mapper, const AccountRecord& account, + TxnDirection direction, TxnKind kind, std::int64_t amountMinor, + std::int64_t counterpartyId, const std::string& description) { + TxnRecord txn; + txn.accountId = static_cast(account.id.Value()); + txn.counterpartyId = counterpartyId; + txn.direction = static_cast(direction); + txn.kind = static_cast(kind); + txn.amountMinor = amountMinor; + txn.currency = account.currency.Value(); + txn.balanceAfterMinor = account.balanceMinor.Value(); + txn.description = Light::SqlAnsiString<128>{description}; + txn.createdAtMs = nowMillis(); + mapper.Create(txn); + return txn; +} + +/// @brief Credits @p account by @p amountMinor, persists it, and posts an entry. +inline TxnRecord applyCredit(Lightweight::DataMapper& mapper, AccountRecord& account, + std::int64_t amountMinor, TxnKind kind, std::int64_t counterpartyId, + const std::string& description) { + account.balanceMinor = account.balanceMinor.Value() + amountMinor; + mapper.Update(account); + return postEntry(mapper, account, TxnDirection::Credit, kind, amountMinor, counterpartyId, description); +} + +/// @brief Debits @p account by @p amountMinor (respecting overdraft), persists +/// it, and posts an entry. +/// @throws InsufficientFunds if the debit would breach the overdraft limit. +inline TxnRecord applyDebit(Lightweight::DataMapper& mapper, AccountRecord& account, + std::int64_t amountMinor, TxnKind kind, std::int64_t counterpartyId, + const std::string& description) { + const std::int64_t projected = account.balanceMinor.Value() - amountMinor; + if (projected < -account.overdraftMinor.Value()) { + throw InsufficientFunds{"amount exceeds available balance plus overdraft"}; + } + account.balanceMinor = projected; + mapper.Update(account); + return postEntry(mapper, account, TxnDirection::Debit, kind, amountMinor, counterpartyId, description); +} + +} // namespace bank::db diff --git a/examples/bank/include/bank/db/loan_entity.hpp b/examples/bank/include/bank/db/loan_entity.hpp new file mode 100644 index 0000000..ab75bd1 --- /dev/null +++ b/examples/bank/include/bank/db/loan_entity.hpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +namespace bank::db { + +/// @brief One row of the `loans` table. +struct LoanRecord { + static constexpr std::string_view TableName = "loans"; + + Light::Field id; + Light::Field, Light::SqlRealName{"owner"}> owner; + /// Account the loan was disbursed into / is repaid from. + Light::Field accountId; + Light::Field principalMinor; + Light::Field outstandingMinor; + Light::Field currency; + /// Annual interest rate in basis points. + Light::Field rateBps; + Light::Field termMonths; + /// `LoanStatus` as integer. + Light::Field status{0}; + Light::Field createdAtMs; +}; + +} // namespace bank::db diff --git a/examples/bank/include/bank/db/notification_entity.hpp b/examples/bank/include/bank/db/notification_entity.hpp new file mode 100644 index 0000000..ef546e8 --- /dev/null +++ b/examples/bank/include/bank/db/notification_entity.hpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +namespace bank::db { + +/// @brief One row of the `notifications` table. +struct NotificationRecord { + static constexpr std::string_view TableName = "notifications"; + + Light::Field id; + Light::Field, Light::SqlRealName{"owner"}> owner; + /// 0 = info, 1 = warning, 2 = alert. + Light::Field severity{0}; + Light::Field, Light::SqlRealName{"message"}> message; + Light::Field read{false}; + Light::Field createdAtMs; +}; + +} // namespace bank::db diff --git a/examples/bank/include/bank/db/payee_entity.hpp b/examples/bank/include/bank/db/payee_entity.hpp new file mode 100644 index 0000000..d0cb4db --- /dev/null +++ b/examples/bank/include/bank/db/payee_entity.hpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +namespace bank::db { + +/// @brief One row of the `payees` (beneficiaries) table. +struct PayeeRecord { + static constexpr std::string_view TableName = "payees"; + + Light::Field id; + Light::Field, Light::SqlRealName{"owner"}> owner; + Light::Field, Light::SqlRealName{"name"}> name; + Light::Field, Light::SqlRealName{"iban"}> iban; + Light::Field, Light::SqlRealName{"bank_name"}> bankName; +}; + +} // namespace bank::db diff --git a/examples/bank/include/bank/db/payment_entity.hpp b/examples/bank/include/bank/db/payment_entity.hpp new file mode 100644 index 0000000..c2ac124 --- /dev/null +++ b/examples/bank/include/bank/db/payment_entity.hpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +namespace bank::db { + +/// @brief One row of the `payments` table (one-off, scheduled, or standing). +struct PaymentRecord { + static constexpr std::string_view TableName = "payments"; + + Light::Field id; + Light::Field, Light::SqlRealName{"owner"}> owner; + Light::Field fromAccountId; + Light::Field payeeId; + Light::Field amountMinor; + Light::Field currency; + /// `PaymentSchedule` as integer. + Light::Field schedule; + /// `PaymentStatus` as integer. + Light::Field status; + /// Due time (epoch ms) for scheduled/standing payments; 0 for one-off. + Light::Field dueAtMs{0}; + /// Recurrence period in days for standing orders; 0 otherwise. + Light::Field intervalDays{0}; + Light::Field, Light::SqlRealName{"description"}> description; +}; + +} // namespace bank::db diff --git a/examples/bank/include/bank/db/txn_entity.hpp b/examples/bank/include/bank/db/txn_entity.hpp new file mode 100644 index 0000000..93cf8cb --- /dev/null +++ b/examples/bank/include/bank/db/txn_entity.hpp @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +namespace bank::db { + +/// @brief One row of the `transactions` ledger table. +struct TxnRecord { + static constexpr std::string_view TableName = "transactions"; + + Light::Field id; + /// Account this ledger entry belongs to. + Light::Field accountId; + /// The other account in a transfer (0 if none). + Light::Field counterpartyId{0}; + /// `TxnDirection` as integer. + Light::Field direction; + /// `TxnKind` as integer. + Light::Field kind; + /// Absolute amount moved, in minor units. + Light::Field amountMinor; + /// `Currency` as integer. + Light::Field currency; + /// Account balance immediately after this entry, in minor units. + Light::Field balanceAfterMinor; + /// Free-text memo. + Light::Field, Light::SqlRealName{"description"}> description; + /// Creation time as Unix epoch milliseconds (used for ordering/display). + Light::Field createdAtMs; +}; + +} // namespace bank::db diff --git a/examples/bank/include/bank/db/user_entity.hpp b/examples/bank/include/bank/db/user_entity.hpp new file mode 100644 index 0000000..5e503a2 --- /dev/null +++ b/examples/bank/include/bank/db/user_entity.hpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +namespace bank::db { + +/// @brief One row of the `users` table. +struct UserRecord { + static constexpr std::string_view TableName = "users"; + + Light::Field id; + /// Login name; also the session principal. Unique. + Light::Field, Light::SqlRealName{"username"}> username; + /// Salted hash of the password (demo-grade, not real crypto). + Light::Field, Light::SqlRealName{"password_hash"}> passwordHash; + /// Human-friendly display name. + Light::Field, Light::SqlRealName{"display_name"}> displayName; + /// 0 = active, 1 = disabled. + Light::Field status{0}; +}; + +} // namespace bank::db diff --git a/examples/bank/include/bank/dto/account_dto.hpp b/examples/bank/include/bank/dto/account_dto.hpp new file mode 100644 index 0000000..9d1d802 --- /dev/null +++ b/examples/bank/include/bank/dto/account_dto.hpp @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include "bank/dto/common.hpp" + +/// @file +/// Wire DTOs for the Account model. These are plain aggregates so Glaze can +/// serialise them with no hand-written codec; they are the action/result types +/// the GUI (or CLI) exchanges with `AccountModel` through the morph bridge. +/// +/// Amounts are carried as integer minor units; `kind`/`currency`/`status` are +/// carried as the integer values of the `bank::AccountKind` / `bank::Currency` +/// / `bank::AccountStatus` enums. + +namespace bank::dto { + +/// @brief Open a new account for the current session's owner. +struct OpenAccount { + std::string owner; ///< empty => use the session principal + int kind = 0; ///< bank::AccountKind + int currency = 0; ///< bank::Currency + std::int64_t overdraftMinor = 0; ///< permitted overdraft, minor units + + /// Form-readiness predicate (used by `BridgeHandler::set<>` streaming). + [[nodiscard]] bool validate() const { + return kind >= 0 && kind <= 2 && currency >= 0 && currency <= 4 && overdraftMinor >= 0; + } +}; + +/// @brief A snapshot of one account (the Account model's primary result type). +struct AccountInfo { + std::int64_t id = 0; + std::string owner; + std::string number; + int kind = 0; + int currency = 0; + std::int64_t balanceMinor = 0; + std::int64_t overdraftMinor = 0; + int status = 0; + int interestBps = 0; +}; + +/// @brief List the accounts belonging to an owner. +struct ListAccounts { + std::string owner; ///< empty => use the session principal +}; + +/// @brief Result of `ListAccounts`. +struct AccountList { + std::vector accounts; +}; + +/// @brief Fetch a single account by id. +struct GetAccount { + std::int64_t id = 0; +}; + +/// @brief Close an account (only permitted when its balance is zero). +struct CloseAccount { + std::int64_t id = 0; +}; + +} // namespace bank::dto diff --git a/examples/bank/include/bank/dto/auth_dto.hpp b/examples/bank/include/bank/dto/auth_dto.hpp new file mode 100644 index 0000000..c697189 --- /dev/null +++ b/examples/bank/include/bank/dto/auth_dto.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +/// @file +/// Wire DTOs for the Auth model: registration, login, password change, and a +/// session-introspection action. + +namespace bank::dto { + +/// @brief Create a new user account. +struct RegisterUser { + std::string username; + std::string password; + std::string displayName; + + [[nodiscard]] bool validate() const { return !username.empty() && password.size() >= 4; } +}; + +/// @brief Authenticate a user. +struct LoginRequest { + std::string username; + std::string password; +}; + +/// @brief Result of register/login: on success carries the principal to install. +struct AuthResult { + bool ok = false; + std::string principal; ///< the username to use as the session principal + std::string displayName; + std::string message; +}; + +/// @brief Change a user's password (requires the current password). +struct ChangePassword { + std::string username; + std::string oldPassword; + std::string newPassword; +}; + +/// @brief Introspect the current session (no inputs). +struct WhoAmI {}; + +/// @brief Result of `WhoAmI`. +struct SessionInfo { + bool authenticated = false; + std::string principal; +}; + +} // namespace bank::dto diff --git a/examples/bank/include/bank/dto/budget_dto.hpp b/examples/bank/include/bank/dto/budget_dto.hpp new file mode 100644 index 0000000..87e80c6 --- /dev/null +++ b/examples/bank/include/bank/dto/budget_dto.hpp @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// Wire DTOs for the Budget model: per-category monthly limits plus simple +/// spending analytics derived from the ledger. + +namespace bank::dto { + +/// @brief A monthly spending limit for a named category. +struct BudgetInfo { + std::int64_t id = 0; + std::string owner; + std::string category; + std::int64_t monthlyLimitMinor = 0; + int currency = 0; +}; + +/// @brief Create or update (upsert) a category budget for the current owner. +struct SetBudget { + std::string category; + std::int64_t monthlyLimitMinor = 0; + int currency = 0; + + [[nodiscard]] bool validate() const { return !category.empty() && monthlyLimitMinor >= 0; } +}; + +/// @brief Delete a category budget by id. +struct DeleteBudget { + std::int64_t id = 0; +}; + +/// @brief List the current owner's budgets. +struct ListBudgets { + std::string owner; ///< empty => session principal +}; + +/// @brief Result of `ListBudgets`. +struct BudgetList { + std::vector budgets; +}; + +/// @brief Total spend for one ledger kind. +struct KindSpend { + int kind = 0; ///< bank::TxnKind + std::int64_t totalMinor = 0; + int count = 0; +}; + +/// @brief Ask for spending grouped by ledger kind on an account. +struct SpendingByKind { + std::int64_t accountId = 0; + std::int64_t sinceMs = 0; ///< only entries at/after this epoch-ms (0 = all time) +}; + +/// @brief Result of `SpendingByKind` (debit kinds only). +struct SpendingReport { + std::int64_t accountId = 0; + std::int64_t totalDebitsMinor = 0; + std::vector byKind; +}; + +} // namespace bank::dto diff --git a/examples/bank/include/bank/dto/card_dto.hpp b/examples/bank/include/bank/dto/card_dto.hpp new file mode 100644 index 0000000..f755dde --- /dev/null +++ b/examples/bank/include/bank/dto/card_dto.hpp @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// Wire DTOs for the Card model. + +namespace bank::dto { + +/// @brief A payment card. +struct CardInfo { + std::int64_t id = 0; + std::string owner; + std::int64_t accountId = 0; + int kind = 0; ///< bank::CardKind + std::string panLast4; + int status = 0; ///< bank::CardStatus + std::int64_t dailyLimitMinor = 0; +}; + +/// @brief Issue a new card linked to an account. +struct IssueCard { + std::int64_t accountId = 0; + int kind = 0; ///< bank::CardKind + std::int64_t dailyLimitMinor = 0; + + [[nodiscard]] bool validate() const { return accountId > 0 && kind >= 0 && kind <= 1; } +}; + +/// @brief Freeze a card (temporarily block use). +struct FreezeCard { + std::int64_t id = 0; +}; + +/// @brief Unfreeze a previously frozen card. +struct UnfreezeCard { + std::int64_t id = 0; +}; + +/// @brief Permanently cancel a card. +struct CancelCard { + std::int64_t id = 0; +}; + +/// @brief Set a card's daily spend limit. +struct SetCardLimit { + std::int64_t id = 0; + std::int64_t dailyLimitMinor = 0; +}; + +/// @brief Change a card's PIN. +struct ChangePin { + std::int64_t id = 0; + std::string newPin; + + [[nodiscard]] bool validate() const { return newPin.size() == 4; } +}; + +/// @brief List the current owner's cards. +struct ListCards { + std::string owner; ///< empty => session principal +}; + +/// @brief Result of `ListCards`. +struct CardList { + std::vector cards; +}; + +} // namespace bank::dto diff --git a/examples/bank/include/bank/dto/common.hpp b/examples/bank/include/bank/dto/common.hpp new file mode 100644 index 0000000..5fd7726 --- /dev/null +++ b/examples/bank/include/bank/dto/common.hpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +/// @file +/// DTOs shared by more than one model. + +namespace bank::dto { + +/// @brief Generic ok/message acknowledgement for commands without richer output. +struct CommandResult { + bool ok = false; + std::string message; +}; + +} // namespace bank::dto diff --git a/examples/bank/include/bank/dto/loan_dto.hpp b/examples/bank/include/bank/dto/loan_dto.hpp new file mode 100644 index 0000000..cafb79c --- /dev/null +++ b/examples/bank/include/bank/dto/loan_dto.hpp @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// Wire DTOs for the Loan model: applications, repayments, and a computed +/// amortization schedule. + +namespace bank::dto { + +/// @brief A loan and its current state. +struct LoanInfo { + std::int64_t id = 0; + std::string owner; + std::int64_t accountId = 0; + std::int64_t principalMinor = 0; + std::int64_t outstandingMinor = 0; + int currency = 0; + int rateBps = 0; + int termMonths = 0; + int status = 0; ///< bank::LoanStatus + std::int64_t createdAtMs = 0; +}; + +/// @brief Apply for a loan, disbursed into an account. +struct ApplyLoan { + std::int64_t accountId = 0; + std::int64_t principalMinor = 0; + int rateBps = 0; + int termMonths = 0; + + [[nodiscard]] bool validate() const { + return accountId > 0 && principalMinor > 0 && rateBps >= 0 && termMonths > 0; + } +}; + +/// @brief Repay part (or all) of a loan from an account. +struct RepayLoan { + std::int64_t loanId = 0; + std::int64_t fromAccountId = 0; + std::int64_t amountMinor = 0; + + [[nodiscard]] bool validate() const { return loanId > 0 && fromAccountId > 0 && amountMinor > 0; } +}; + +/// @brief Fetch one loan by id. +struct GetLoan { + std::int64_t id = 0; +}; + +/// @brief List the current owner's loans. +struct ListLoans { + std::string owner; ///< empty => session principal +}; + +/// @brief Result of `ListLoans`. +struct LoanList { + std::vector loans; +}; + +/// @brief One row of an amortization schedule. +struct Installment { + int month = 0; + std::int64_t paymentMinor = 0; + std::int64_t principalMinor = 0; + std::int64_t interestMinor = 0; + std::int64_t remainingMinor = 0; +}; + +/// @brief Request the amortization schedule for a loan. +struct LoanScheduleRequest { + std::int64_t loanId = 0; +}; + +/// @brief Result of `LoanScheduleRequest`. +struct LoanScheduleResult { + std::int64_t loanId = 0; + std::int64_t monthlyPaymentMinor = 0; + std::vector installments; +}; + +} // namespace bank::dto diff --git a/examples/bank/include/bank/dto/notification_dto.hpp b/examples/bank/include/bank/dto/notification_dto.hpp new file mode 100644 index 0000000..48f9c11 --- /dev/null +++ b/examples/bank/include/bank/dto/notification_dto.hpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// Wire DTOs for the Notification model. + +namespace bank::dto { + +/// @brief A user-facing alert. +struct NotificationInfo { + std::int64_t id = 0; + std::string owner; + int severity = 0; + std::string message; + bool read = false; + std::int64_t createdAtMs = 0; +}; + +/// @brief Post a notification for the current owner. +struct Notify { + std::string message; + int severity = 0; + + [[nodiscard]] bool validate() const { return !message.empty() && severity >= 0 && severity <= 2; } +}; + +/// @brief List the current owner's notifications. +struct ListNotifications { + std::string owner; ///< empty => session principal + bool unreadOnly = false; +}; + +/// @brief Result of `ListNotifications`. +struct NotificationList { + std::vector notifications; + int unreadCount = 0; +}; + +/// @brief Mark a single notification read. +struct MarkRead { + std::int64_t id = 0; +}; + +/// @brief Mark all of the current owner's notifications read. +struct MarkAllRead { + std::string owner; ///< empty => session principal +}; + +} // namespace bank::dto diff --git a/examples/bank/include/bank/dto/payee_dto.hpp b/examples/bank/include/bank/dto/payee_dto.hpp new file mode 100644 index 0000000..6095b14 --- /dev/null +++ b/examples/bank/include/bank/dto/payee_dto.hpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +/// @file +/// Wire DTOs for the Payee (beneficiary) model. + +namespace bank::dto { + +/// @brief Loose IBAN sanity check: 2 letters, 2 digits, then alphanumerics, +/// total length 15..34. Not a full mod-97 checksum (kept simple). +[[nodiscard]] inline bool looksLikeIban(const std::string& iban) { + if (iban.size() < 15 || iban.size() > 34) { + return false; + } + if (std::isalpha(static_cast(iban[0])) == 0 || + std::isalpha(static_cast(iban[1])) == 0) { + return false; + } + for (char chr : iban) { + if (std::isalnum(static_cast(chr)) == 0) { + return false; + } + } + return true; +} + +/// @brief A saved beneficiary. +struct PayeeInfo { + std::int64_t id = 0; + std::string owner; + std::string name; + std::string iban; + std::string bankName; +}; + +/// @brief Add a beneficiary for the current owner. +/// +/// Designed for field-by-field entry via `BridgeHandler::set<>`: the action only +/// becomes ready once both a name and a plausible IBAN are present. +struct AddPayee { + std::string name; + std::string iban; + std::string bankName; + + [[nodiscard]] bool validate() const { return !name.empty() && looksLikeIban(iban); } +}; + +/// @brief Remove a beneficiary by id. +struct RemovePayee { + std::int64_t id = 0; +}; + +/// @brief List the current owner's beneficiaries. +struct ListPayees { + std::string owner; ///< empty => session principal +}; + +/// @brief Result of `ListPayees`. +struct PayeeList { + std::vector payees; +}; + +} // namespace bank::dto diff --git a/examples/bank/include/bank/dto/payment_dto.hpp b/examples/bank/include/bank/dto/payment_dto.hpp new file mode 100644 index 0000000..8af4a3d --- /dev/null +++ b/examples/bank/include/bank/dto/payment_dto.hpp @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// Wire DTOs for the Payment model: one-off bill payments, future-dated +/// scheduled payments, and recurring standing orders. + +namespace bank::dto { + +/// @brief A payment instruction in any of its lifecycle states. +struct PaymentInfo { + std::int64_t id = 0; + std::string owner; + std::int64_t fromAccountId = 0; + std::int64_t payeeId = 0; + std::int64_t amountMinor = 0; + int currency = 0; + int schedule = 0; ///< bank::PaymentSchedule + int status = 0; ///< bank::PaymentStatus + std::int64_t dueAtMs = 0; + int intervalDays = 0; + std::string description; +}; + +/// @brief Pay a beneficiary immediately from an account. +struct PayBill { + std::int64_t fromAccountId = 0; + std::int64_t payeeId = 0; + std::int64_t amountMinor = 0; + std::string description; + + [[nodiscard]] bool validate() const { + return fromAccountId > 0 && payeeId > 0 && amountMinor > 0; + } +}; + +/// @brief Schedule a one-time future payment. +struct SchedulePayment { + std::int64_t fromAccountId = 0; + std::int64_t payeeId = 0; + std::int64_t amountMinor = 0; + std::int64_t dueAtMs = 0; + std::string description; + + [[nodiscard]] bool validate() const { + return fromAccountId > 0 && payeeId > 0 && amountMinor > 0 && dueAtMs > 0; + } +}; + +/// @brief Create a recurring standing order. +struct CreateStandingOrder { + std::int64_t fromAccountId = 0; + std::int64_t payeeId = 0; + std::int64_t amountMinor = 0; + int intervalDays = 0; + std::int64_t firstDueAtMs = 0; + std::string description; + + [[nodiscard]] bool validate() const { + return fromAccountId > 0 && payeeId > 0 && amountMinor > 0 && intervalDays > 0; + } +}; + +/// @brief Cancel a pending scheduled/standing payment. +struct CancelPayment { + std::int64_t id = 0; +}; + +/// @brief List the current owner's payments. +struct ListPayments { + std::string owner; ///< empty => session principal +}; + +/// @brief Result of `ListPayments`. +struct PaymentList { + std::vector payments; +}; + +} // namespace bank::dto diff --git a/examples/bank/include/bank/dto/statement_dto.hpp b/examples/bank/include/bank/dto/statement_dto.hpp new file mode 100644 index 0000000..ecb174d --- /dev/null +++ b/examples/bank/include/bank/dto/statement_dto.hpp @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// Wire DTOs for the Statement model: a date-ranged summary across all of an +/// owner's accounts. + +namespace bank::dto { + +/// @brief Per-account summary line within a statement. +struct StatementLine { + std::int64_t accountId = 0; + std::string number; + int currency = 0; + std::int64_t creditsMinor = 0; + std::int64_t debitsMinor = 0; + std::int64_t closingBalanceMinor = 0; + int entryCount = 0; +}; + +/// @brief Generate a statement for the owner over a time range. +struct GenerateStatement { + std::string owner; ///< empty => session principal + std::int64_t fromMs = 0; ///< inclusive lower bound (epoch ms) + std::int64_t toMs = 0; ///< inclusive upper bound; 0 => no upper bound +}; + +/// @brief Result of `GenerateStatement`. +struct Statement { + std::string owner; + std::int64_t fromMs = 0; + std::int64_t toMs = 0; + std::vector lines; + std::int64_t totalCreditsMinor = 0; + std::int64_t totalDebitsMinor = 0; +}; + +} // namespace bank::dto diff --git a/examples/bank/include/bank/dto/transaction_dto.hpp b/examples/bank/include/bank/dto/transaction_dto.hpp new file mode 100644 index 0000000..fda73dc --- /dev/null +++ b/examples/bank/include/bank/dto/transaction_dto.hpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// Wire DTOs for the Transaction model: deposits, withdrawals, transfers, and +/// paginated history. + +namespace bank::dto { + +/// @brief A single ledger entry (the Transaction model's view of one row). +struct TxnInfo { + std::int64_t id = 0; + std::int64_t accountId = 0; + std::int64_t counterpartyId = 0; + int direction = 0; ///< bank::TxnDirection + int kind = 0; ///< bank::TxnKind + std::int64_t amountMinor = 0; + int currency = 0; + std::int64_t balanceAfterMinor = 0; + std::string description; + std::int64_t createdAtMs = 0; +}; + +/// @brief Pay money into an account. +struct Deposit { + std::int64_t accountId = 0; + std::int64_t amountMinor = 0; + std::string description; + + [[nodiscard]] bool validate() const { return accountId > 0 && amountMinor > 0; } +}; + +/// @brief Take money out of an account (subject to overdraft limits). +struct Withdraw { + std::int64_t accountId = 0; + std::int64_t amountMinor = 0; + std::string description; + + [[nodiscard]] bool validate() const { return accountId > 0 && amountMinor > 0; } +}; + +/// @brief Move money between two accounts atomically. +struct Transfer { + std::int64_t fromAccountId = 0; + std::int64_t toAccountId = 0; + std::int64_t amountMinor = 0; + std::string description; + + [[nodiscard]] bool validate() const { + return fromAccountId > 0 && toAccountId > 0 && fromAccountId != toAccountId && amountMinor > 0; + } +}; + +/// @brief Result of a transfer: the resulting balances of both accounts. +struct TransferResult { + std::int64_t fromBalanceMinor = 0; + std::int64_t toBalanceMinor = 0; +}; + +/// @brief Request a page of an account's ledger, newest first. +struct History { + std::int64_t accountId = 0; + int limit = 50; + int offset = 0; +}; + +/// @brief A page of ledger entries. +struct HistoryPage { + std::int64_t accountId = 0; + std::vector entries; +}; + +} // namespace bank::dto diff --git a/examples/bank/include/bank/models/account_model.hpp b/examples/bank/include/bank/models/account_model.hpp new file mode 100644 index 0000000..b0d3df4 --- /dev/null +++ b/examples/bank/include/bank/models/account_model.hpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "bank/db/db_model.hpp" +#include "bank/dto/account_dto.hpp" + +/// @file +/// The Account model. A plain, single-threaded C++ class — morph runs each +/// instance on its own strand, so the model never deals with concurrency. It +/// owns one Lightweight `DataMapper` (lazily opened on first use, on the strand +/// thread) and translates between wire DTOs and the `AccountRecord` entity. + +namespace bank { + +/// @brief Opens, lists, inspects, and closes customer accounts. +class AccountModel : private db::WithMapper { +public: + /// @brief Opens a new account; returns the freshly created account. + dto::AccountInfo execute(const dto::OpenAccount& action); + + /// @brief Lists accounts owned by the requested (or session) owner. + dto::AccountList execute(const dto::ListAccounts& action); + + /// @brief Returns one account by id, or throws `NotFound`. + dto::AccountInfo execute(const dto::GetAccount& action); + + /// @brief Closes a zero-balance account; returns ok/message. + dto::CommandResult execute(const dto::CloseAccount& action); +}; + +} // namespace bank + +// ─── morph registration ────────────────────────────────────────────────────── +// Registration lives in the header (not the .cpp) on purpose: the +// BRIDGE_REGISTER_ACTION macro specialises `morph::model::ActionTraits`, +// and every translation unit that calls `handler.execute(Action{...})` needs +// that specialisation visible to deduce the result type. The macros must sit at +// global scope and token-paste unqualified identifiers, so the types are pulled +// in with using-declarations first. The static registration runs once per +// including TU; the underlying registry assignment is idempotent. +using bank::AccountModel; +using bank::dto::CloseAccount; +using bank::dto::GetAccount; +using bank::dto::ListAccounts; +using bank::dto::OpenAccount; + +BRIDGE_REGISTER_MODEL(AccountModel, "AccountModel") +BRIDGE_REGISTER_ACTION(AccountModel, OpenAccount, "OpenAccount") +BRIDGE_REGISTER_ACTION(AccountModel, ListAccounts, "ListAccounts") +BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount") +BRIDGE_REGISTER_ACTION(AccountModel, CloseAccount, "CloseAccount") diff --git a/examples/bank/include/bank/models/auth_model.hpp b/examples/bank/include/bank/models/auth_model.hpp new file mode 100644 index 0000000..d18e17d --- /dev/null +++ b/examples/bank/include/bank/models/auth_model.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "bank/db/db_model.hpp" +#include "bank/dto/auth_dto.hpp" +#include "bank/dto/common.hpp" + +/// @file +/// The Auth model: user registration, login, password change, and session +/// introspection. The model validates credentials and returns the principal to +/// install; the application layer (`App::login`) is what actually attaches the +/// principal to the bridge's default session. + +namespace bank { + +/// @brief Manages user identities and authentication. +class AuthModel : private db::WithMapper { +public: + /// @brief Registers a new user; returns an AuthResult carrying the principal. + dto::AuthResult execute(const dto::RegisterUser& action); + + /// @brief Verifies credentials; returns an AuthResult carrying the principal. + dto::AuthResult execute(const dto::LoginRequest& action); + + /// @brief Changes a user's password after verifying the old one. + dto::CommandResult execute(const dto::ChangePassword& action); + + /// @brief Reports whether a principal is attached to the current session. + dto::SessionInfo execute(const dto::WhoAmI& action); +}; + +} // namespace bank + +using bank::AuthModel; +using bank::dto::ChangePassword; +using bank::dto::LoginRequest; +using bank::dto::RegisterUser; +using bank::dto::WhoAmI; + +BRIDGE_REGISTER_MODEL(AuthModel, "AuthModel") +BRIDGE_REGISTER_ACTION(AuthModel, RegisterUser, "RegisterUser") +BRIDGE_REGISTER_ACTION(AuthModel, LoginRequest, "LoginRequest") +BRIDGE_REGISTER_ACTION(AuthModel, ChangePassword, "ChangePassword") +BRIDGE_REGISTER_ACTION(AuthModel, WhoAmI, "WhoAmI") diff --git a/examples/bank/include/bank/models/budget_model.hpp b/examples/bank/include/bank/models/budget_model.hpp new file mode 100644 index 0000000..276a8a4 --- /dev/null +++ b/examples/bank/include/bank/models/budget_model.hpp @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "bank/db/db_model.hpp" +#include "bank/dto/budget_dto.hpp" +#include "bank/dto/common.hpp" + +/// @file +/// The Budget model: per-category monthly limits and simple spending analytics +/// computed from the ledger (debits grouped by kind). + +namespace bank { + +/// @brief Manages budgets and derives spending analytics. +class BudgetModel : private db::WithMapper { +public: + dto::BudgetInfo execute(const dto::SetBudget& action); + dto::CommandResult execute(const dto::DeleteBudget& action); + dto::BudgetList execute(const dto::ListBudgets& action); + dto::SpendingReport execute(const dto::SpendingByKind& action); +}; + +} // namespace bank + +using bank::BudgetModel; +using bank::dto::DeleteBudget; +using bank::dto::ListBudgets; +using bank::dto::SetBudget; +using bank::dto::SpendingByKind; + +BRIDGE_REGISTER_MODEL(BudgetModel, "BudgetModel") +BRIDGE_REGISTER_ACTION(BudgetModel, SetBudget, "SetBudget") +BRIDGE_REGISTER_ACTION(BudgetModel, DeleteBudget, "DeleteBudget") +BRIDGE_REGISTER_ACTION(BudgetModel, ListBudgets, "ListBudgets") +BRIDGE_REGISTER_ACTION(BudgetModel, SpendingByKind, "SpendingByKind") diff --git a/examples/bank/include/bank/models/card_model.hpp b/examples/bank/include/bank/models/card_model.hpp new file mode 100644 index 0000000..baca67b --- /dev/null +++ b/examples/bank/include/bank/models/card_model.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "bank/db/db_model.hpp" +#include "bank/dto/card_dto.hpp" +#include "bank/dto/common.hpp" + +/// @file +/// The Card model: issue cards against an account, freeze/unfreeze/cancel them, +/// set spend limits, and change PINs. + +namespace bank { + +/// @brief Issues and manages payment cards. +class CardModel : private db::WithMapper { +public: + dto::CardInfo execute(const dto::IssueCard& action); + dto::CommandResult execute(const dto::FreezeCard& action); + dto::CommandResult execute(const dto::UnfreezeCard& action); + dto::CommandResult execute(const dto::CancelCard& action); + dto::CommandResult execute(const dto::SetCardLimit& action); + dto::CommandResult execute(const dto::ChangePin& action); + dto::CardList execute(const dto::ListCards& action); +}; + +} // namespace bank + +using bank::CardModel; +using bank::dto::CancelCard; +using bank::dto::ChangePin; +using bank::dto::FreezeCard; +using bank::dto::IssueCard; +using bank::dto::ListCards; +using bank::dto::SetCardLimit; +using bank::dto::UnfreezeCard; + +BRIDGE_REGISTER_MODEL(CardModel, "CardModel") +BRIDGE_REGISTER_ACTION(CardModel, IssueCard, "IssueCard") +BRIDGE_REGISTER_ACTION(CardModel, FreezeCard, "FreezeCard") +BRIDGE_REGISTER_ACTION(CardModel, UnfreezeCard, "UnfreezeCard") +BRIDGE_REGISTER_ACTION(CardModel, CancelCard, "CancelCard") +BRIDGE_REGISTER_ACTION(CardModel, SetCardLimit, "SetCardLimit") +BRIDGE_REGISTER_ACTION(CardModel, ChangePin, "ChangePin") +BRIDGE_REGISTER_ACTION(CardModel, ListCards, "ListCards") diff --git a/examples/bank/include/bank/models/loan_model.hpp b/examples/bank/include/bank/models/loan_model.hpp new file mode 100644 index 0000000..46309e7 --- /dev/null +++ b/examples/bank/include/bank/models/loan_model.hpp @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "bank/db/db_model.hpp" +#include "bank/dto/loan_dto.hpp" + +/// @file +/// The Loan model: apply (disbursing the principal into an account), repay +/// (debiting an account and reducing the outstanding balance), and compute an +/// amortization schedule. Disbursement and repayment each touch an account and +/// the ledger inside a single `SqlTransaction`. + +namespace bank { + +/// @brief Originates and services loans. +class LoanModel : private db::WithMapper { +public: + dto::LoanInfo execute(const dto::ApplyLoan& action); + dto::LoanInfo execute(const dto::RepayLoan& action); + dto::LoanInfo execute(const dto::GetLoan& action); + dto::LoanList execute(const dto::ListLoans& action); + dto::LoanScheduleResult execute(const dto::LoanScheduleRequest& action); +}; + +} // namespace bank + +using bank::LoanModel; +using bank::dto::ApplyLoan; +using bank::dto::GetLoan; +using bank::dto::ListLoans; +using bank::dto::LoanScheduleRequest; +using bank::dto::RepayLoan; + +BRIDGE_REGISTER_MODEL(LoanModel, "LoanModel") +BRIDGE_REGISTER_ACTION(LoanModel, ApplyLoan, "ApplyLoan") +BRIDGE_REGISTER_ACTION(LoanModel, RepayLoan, "RepayLoan") +BRIDGE_REGISTER_ACTION(LoanModel, GetLoan, "GetLoan") +BRIDGE_REGISTER_ACTION(LoanModel, ListLoans, "ListLoans") +BRIDGE_REGISTER_ACTION(LoanModel, LoanScheduleRequest, "LoanScheduleRequest") diff --git a/examples/bank/include/bank/models/notification_model.hpp b/examples/bank/include/bank/models/notification_model.hpp new file mode 100644 index 0000000..be37a44 --- /dev/null +++ b/examples/bank/include/bank/models/notification_model.hpp @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "bank/db/db_model.hpp" +#include "bank/dto/common.hpp" +#include "bank/dto/notification_dto.hpp" + +/// @file +/// The Notification model: post, list, and mark-read alerts for the owner. + +namespace bank { + +/// @brief Stores and serves per-owner notifications. +class NotificationModel : private db::WithMapper { +public: + dto::NotificationInfo execute(const dto::Notify& action); + dto::NotificationList execute(const dto::ListNotifications& action); + dto::CommandResult execute(const dto::MarkRead& action); + dto::CommandResult execute(const dto::MarkAllRead& action); +}; + +} // namespace bank + +using bank::NotificationModel; +using bank::dto::ListNotifications; +using bank::dto::MarkAllRead; +using bank::dto::MarkRead; +using bank::dto::Notify; + +BRIDGE_REGISTER_MODEL(NotificationModel, "NotificationModel") +BRIDGE_REGISTER_ACTION(NotificationModel, Notify, "Notify") +BRIDGE_REGISTER_ACTION(NotificationModel, ListNotifications, "ListNotifications") +BRIDGE_REGISTER_ACTION(NotificationModel, MarkRead, "MarkRead") +BRIDGE_REGISTER_ACTION(NotificationModel, MarkAllRead, "MarkAllRead") diff --git a/examples/bank/include/bank/models/payee_model.hpp b/examples/bank/include/bank/models/payee_model.hpp new file mode 100644 index 0000000..76a8585 --- /dev/null +++ b/examples/bank/include/bank/models/payee_model.hpp @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "bank/db/db_model.hpp" +#include "bank/dto/common.hpp" +#include "bank/dto/payee_dto.hpp" + +/// @file +/// The Payee model: manage the current owner's beneficiaries. `AddPayee` is a +/// good fit for morph's field-by-field `set<>` streaming surface — it only +/// fires once a name and a plausible IBAN are both present. + +namespace bank { + +/// @brief Stores and lists beneficiaries scoped to the session owner. +class PayeeModel : private db::WithMapper { +public: + /// @brief Adds a beneficiary for the current owner; returns the saved payee. + dto::PayeeInfo execute(const dto::AddPayee& action); + + /// @brief Removes a beneficiary the current owner owns. + dto::CommandResult execute(const dto::RemovePayee& action); + + /// @brief Lists the current owner's beneficiaries. + dto::PayeeList execute(const dto::ListPayees& action); +}; + +} // namespace bank + +using bank::PayeeModel; +using bank::dto::AddPayee; +using bank::dto::ListPayees; +using bank::dto::RemovePayee; + +BRIDGE_REGISTER_MODEL(PayeeModel, "PayeeModel") +BRIDGE_REGISTER_ACTION(PayeeModel, AddPayee, "AddPayee") +BRIDGE_REGISTER_ACTION(PayeeModel, RemovePayee, "RemovePayee") +BRIDGE_REGISTER_ACTION(PayeeModel, ListPayees, "ListPayees") diff --git a/examples/bank/include/bank/models/payment_model.hpp b/examples/bank/include/bank/models/payment_model.hpp new file mode 100644 index 0000000..aa742ff --- /dev/null +++ b/examples/bank/include/bank/models/payment_model.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "bank/db/db_model.hpp" +#include "bank/dto/common.hpp" +#include "bank/dto/payment_dto.hpp" + +/// @file +/// The Payment model: immediate bill payments (which debit an account and post +/// a ledger entry atomically), future-dated scheduled payments, and recurring +/// standing orders. Accounts and payees are validated to belong to the session +/// owner before any money moves. + +namespace bank { + +/// @brief Pays beneficiaries and manages scheduled / standing instructions. +class PaymentModel : private db::WithMapper { +public: + /// @brief Pays a beneficiary now; debits the account and records the payment. + dto::PaymentInfo execute(const dto::PayBill& action); + + /// @brief Records a future-dated payment (no money moves yet). + dto::PaymentInfo execute(const dto::SchedulePayment& action); + + /// @brief Records a recurring standing order (no money moves yet). + dto::PaymentInfo execute(const dto::CreateStandingOrder& action); + + /// @brief Cancels a pending scheduled/standing payment. + dto::CommandResult execute(const dto::CancelPayment& action); + + /// @brief Lists the current owner's payments. + dto::PaymentList execute(const dto::ListPayments& action); +}; + +} // namespace bank + +using bank::PaymentModel; +using bank::dto::CancelPayment; +using bank::dto::CreateStandingOrder; +using bank::dto::ListPayments; +using bank::dto::PayBill; +using bank::dto::SchedulePayment; + +BRIDGE_REGISTER_MODEL(PaymentModel, "PaymentModel") +BRIDGE_REGISTER_ACTION(PaymentModel, PayBill, "PayBill") +BRIDGE_REGISTER_ACTION(PaymentModel, SchedulePayment, "SchedulePayment") +BRIDGE_REGISTER_ACTION(PaymentModel, CreateStandingOrder, "CreateStandingOrder") +BRIDGE_REGISTER_ACTION(PaymentModel, CancelPayment, "CancelPayment") +BRIDGE_REGISTER_ACTION(PaymentModel, ListPayments, "ListPayments") diff --git a/examples/bank/include/bank/models/statement_model.hpp b/examples/bank/include/bank/models/statement_model.hpp new file mode 100644 index 0000000..393be9d --- /dev/null +++ b/examples/bank/include/bank/models/statement_model.hpp @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "bank/db/db_model.hpp" +#include "bank/dto/statement_dto.hpp" + +/// @file +/// The Statement model: a read-only aggregation across an owner's accounts and +/// the ledger, producing per-account credit/debit totals for a date range. + +namespace bank { + +/// @brief Produces date-ranged statements across an owner's accounts. +class StatementModel : private db::WithMapper { +public: + dto::Statement execute(const dto::GenerateStatement& action); +}; + +} // namespace bank + +using bank::StatementModel; +using bank::dto::GenerateStatement; + +BRIDGE_REGISTER_MODEL(StatementModel, "StatementModel") +BRIDGE_REGISTER_ACTION(StatementModel, GenerateStatement, "GenerateStatement") diff --git a/examples/bank/include/bank/models/transaction_model.hpp b/examples/bank/include/bank/models/transaction_model.hpp new file mode 100644 index 0000000..8c0b7b6 --- /dev/null +++ b/examples/bank/include/bank/models/transaction_model.hpp @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "bank/db/db_model.hpp" +#include "bank/dto/transaction_dto.hpp" + +/// @file +/// The Transaction model: deposits, withdrawals, atomic transfers, and history. +/// Transfers update two accounts and write two ledger rows inside a single +/// `SqlTransaction`, so a failure leaves no partial state. + +namespace bank { + +/// @brief Moves money and records the ledger. +class TransactionModel : private db::WithMapper { +public: + /// @brief Credits an account and records a Deposit entry. + dto::TxnInfo execute(const dto::Deposit& action); + + /// @brief Debits an account (within overdraft) and records a Withdrawal entry. + dto::TxnInfo execute(const dto::Withdraw& action); + + /// @brief Atomically moves money between two accounts of the same currency. + dto::TransferResult execute(const dto::Transfer& action); + + /// @brief Returns a newest-first page of an account's ledger. + dto::HistoryPage execute(const dto::History& action); +}; + +} // namespace bank + +using bank::TransactionModel; +using bank::dto::Deposit; +using bank::dto::History; +using bank::dto::Transfer; +using bank::dto::Withdraw; + +BRIDGE_REGISTER_MODEL(TransactionModel, "TransactionModel") +BRIDGE_REGISTER_ACTION(TransactionModel, Deposit, "Deposit") +BRIDGE_REGISTER_ACTION(TransactionModel, Withdraw, "Withdraw") +BRIDGE_REGISTER_ACTION(TransactionModel, Transfer, "Transfer") +BRIDGE_REGISTER_ACTION(TransactionModel, History, "History") diff --git a/examples/bank/src/app/app.cpp b/examples/bank/src/app/app.cpp new file mode 100644 index 0000000..058c457 --- /dev/null +++ b/examples/bank/src/app/app.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/app/app.hpp" + +#include +#include + +#include + +#include "bank/db/database.hpp" + +namespace bank::app { + +App::App(const std::string& connectionString, std::size_t workers) + : _pool{workers}, + _bridge{std::make_unique(_pool)} { + db::setup(connectionString); +} + +void App::login(const std::string& principal) { + morph::session::Context ctx; + ctx.principal = principal; + _bridge.setDefaultSession(std::move(ctx)); +} + +void App::logout() { + _bridge.setDefaultSession({}); +} + +} // namespace bank::app diff --git a/examples/bank/src/cli/main.cpp b/examples/bank/src/cli/main.cpp new file mode 100644 index 0000000..1e1bf19 --- /dev/null +++ b/examples/bank/src/cli/main.cpp @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// A scripted, headless tour of the bank example. The same scenario function is +// run twice — once over a LocalBackend and once over a SimulatedRemoteBackend — +// to show that the model code and call sites are identical regardless of where +// the models actually execute. + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank/core/money.hpp" +#include "bank/core/types.hpp" +#include "bank/db/database.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/auth_dto.hpp" +#include "bank/dto/budget_dto.hpp" +#include "bank/dto/card_dto.hpp" +#include "bank/dto/loan_dto.hpp" +#include "bank/dto/notification_dto.hpp" +#include "bank/dto/payee_dto.hpp" +#include "bank/dto/payment_dto.hpp" +#include "bank/dto/statement_dto.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/auth_model.hpp" +#include "bank/models/budget_model.hpp" +#include "bank/models/card_model.hpp" +#include "bank/models/loan_model.hpp" +#include "bank/models/notification_model.hpp" +#include "bank/models/payee_model.hpp" +#include "bank/models/payment_model.hpp" +#include "bank/models/statement_model.hpp" +#include "bank/models/transaction_model.hpp" + +namespace { + +/// Pumps @p gui until @p completion resolves, returning its value or throwing. +template +T await(morph::async::Completion completion, morph::exec::MainThreadExecutor& gui) { + std::atomic done{false}; + std::optional value; + std::exception_ptr error; + completion.then([&](T resolved) { value = std::move(resolved); done.store(true); }) + .onError([&](const std::exception_ptr& err) { error = err; done.store(true); }); + while (!done.load()) { + gui.runFor(std::chrono::milliseconds{10}); + } + if (error) { + std::rethrow_exception(error); + } + return std::move(*value); +} + +bank::Money usd(std::int64_t minor) { return bank::Money{.minor = minor, .currency = bank::Currency::USD}; } + +/// Runs the full scenario against an already-wired bridge + gui executor. +void runScenario(morph::bridge::Bridge& bridge, morph::exec::MainThreadExecutor& gui, const char* label, + const std::string& principal) { + std::println("\n========== {} ==========", label); + + morph::bridge::BridgeHandler auth{bridge, &gui}; + morph::bridge::BridgeHandler accounts{bridge, &gui}; + morph::bridge::BridgeHandler txns{bridge, &gui}; + morph::bridge::BridgeHandler payees{bridge, &gui}; + morph::bridge::BridgeHandler payments{bridge, &gui}; + morph::bridge::BridgeHandler cards{bridge, &gui}; + morph::bridge::BridgeHandler loans{bridge, &gui}; + morph::bridge::BridgeHandler budgets{bridge, &gui}; + morph::bridge::BridgeHandler notes{bridge, &gui}; + morph::bridge::BridgeHandler statements{bridge, &gui}; + + // Register + login. After login the bridge carries the principal on every call. + await(auth.execute(bank::dto::RegisterUser{.username = principal, .password = "s3cret", + .displayName = "Demo User"}), + gui); + auto login = await(auth.execute(bank::dto::LoginRequest{.username = principal, .password = "s3cret"}), gui); + morph::session::Context ctx; + ctx.principal = login.principal; + bridge.setDefaultSession(ctx); + std::println("logged in as {} ({})", login.principal, login.displayName); + + // Open two accounts. + auto checking = await(accounts.execute(bank::dto::OpenAccount{ + .kind = static_cast(bank::AccountKind::Checking), + .currency = static_cast(bank::Currency::USD), + .overdraftMinor = 20000}), + gui); + auto savings = await(accounts.execute(bank::dto::OpenAccount{ + .kind = static_cast(bank::AccountKind::Savings), + .currency = static_cast(bank::Currency::USD)}), + gui); + std::println("opened checking {} and savings {}", checking.number, savings.number); + + // Deposit + transfer. + await(txns.execute(bank::dto::Deposit{.accountId = checking.id, .amountMinor = 100000, + .description = "opening deposit"}), + gui); + auto transfer = await(txns.execute(bank::dto::Transfer{.fromAccountId = checking.id, + .toAccountId = savings.id, + .amountMinor = 25000, + .description = "to savings"}), + gui); + std::println("after transfer: checking {} / savings {}", bank::format(usd(transfer.fromBalanceMinor)), + bank::format(usd(transfer.toBalanceMinor))); + + // Payee + bill payment. + auto payee = await(payees.execute(bank::dto::AddPayee{.name = "City Power", + .iban = "DE89370400440532013000", + .bankName = "Stadtbank"}), + gui); + await(payments.execute(bank::dto::PayBill{.fromAccountId = checking.id, .payeeId = payee.id, + .amountMinor = 7500, .description = "electricity"}), + gui); + std::println("paid {} to {}", bank::format(usd(7500)), payee.name); + + // Card lifecycle. + auto card = await(cards.execute(bank::dto::IssueCard{.accountId = checking.id, + .kind = static_cast(bank::CardKind::Debit), + .dailyLimitMinor = 50000}), + gui); + await(cards.execute(bank::dto::FreezeCard{.id = card.id}), gui); + await(cards.execute(bank::dto::UnfreezeCard{.id = card.id}), gui); + std::println("issued debit card ****{} (frozen, then unfrozen)", card.panLast4); + + // Loan: apply, show first 3 schedule rows, repay. + auto loan = await(loans.execute(bank::dto::ApplyLoan{.accountId = checking.id, .principalMinor = 1200000, + .rateBps = 600, .termMonths = 12}), + gui); + auto schedule = await(loans.execute(bank::dto::LoanScheduleRequest{.loanId = loan.id}), gui); + std::println("loan {} disbursed; monthly payment {}", loan.id, + bank::format(usd(schedule.monthlyPaymentMinor))); + for (int idx = 0; idx < 3 && idx < static_cast(schedule.installments.size()); ++idx) { + const auto& inst = schedule.installments[static_cast(idx)]; + std::println(" month {}: principal {} interest {} remaining {}", inst.month, + bank::format(usd(inst.principalMinor)), bank::format(usd(inst.interestMinor)), + bank::format(usd(inst.remainingMinor))); + } + auto repaid = await(loans.execute(bank::dto::RepayLoan{.loanId = loan.id, .fromAccountId = checking.id, + .amountMinor = 20000}), + gui); + std::println("after repayment, loan outstanding {}", bank::format(usd(repaid.outstandingMinor))); + + // Budget + spending analytics. + await(budgets.execute(bank::dto::SetBudget{.category = "utilities", .monthlyLimitMinor = 30000, + .currency = 0}), + gui); + auto spend = await(budgets.execute(bank::dto::SpendingByKind{.accountId = checking.id}), gui); + std::println("total debits on checking: {}", bank::format(usd(spend.totalDebitsMinor))); + + // Notifications. + await(notes.execute(bank::dto::Notify{.message = "Welcome to morph bank!", .severity = 0}), gui); + auto noteList = await(notes.execute(bank::dto::ListNotifications{}), gui); + std::println("notifications: {} ({} unread)", noteList.notifications.size(), noteList.unreadCount); + + // Statement across accounts. + auto statement = await(statements.execute(bank::dto::GenerateStatement{}), gui); + std::println("statement: {} accounts, credits {} debits {}", statement.lines.size(), + bank::format(usd(statement.totalCreditsMinor)), + bank::format(usd(statement.totalDebitsMinor))); +} + +} // namespace + +int main() { + const auto dbPath = std::filesystem::temp_directory_path() / "morph_bank_cli.db"; + std::error_code ec; + std::filesystem::remove(dbPath, ec); + bank::db::setup("DRIVER=SQLite3;Database=" + dbPath.string()); + + morph::exec::ThreadPoolExecutor workerPool{4}; + morph::exec::MainThreadExecutor gui; + + // 1) Local backend: models run in this process on the worker pool. + { + morph::bridge::Bridge bridge{std::make_unique(workerPool)}; + runScenario(bridge, gui, "LocalBackend", "demo-local"); + } + + // 2) Remote backend (simulated): identical scenario, identical code. + { + morph::exec::ThreadPoolExecutor serverPool{4}; + auto server = std::make_shared(serverPool); + morph::bridge::Bridge bridge{std::make_unique(*server)}; + runScenario(bridge, gui, "SimulatedRemoteBackend", "demo-remote"); + } + + std::println("\nDone."); + return 0; +} diff --git a/examples/bank/src/core/money.cpp b/examples/bank/src/core/money.cpp new file mode 100644 index 0000000..e0fa7a9 --- /dev/null +++ b/examples/bank/src/core/money.cpp @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/core/money.hpp" + +#include +#include + +namespace bank { + +std::string format(Money amount) { + const int decimals = currencyDecimals(amount.currency); + const std::string_view code = currencyCode(amount.currency); + const std::int64_t magnitude = std::llabs(amount.minor); + const char* sign = amount.minor < 0 ? "-" : ""; + + if (decimals == 0) { + return std::format("{}{} {}", sign, magnitude, code); + } + + std::int64_t scale = 1; + for (int idx = 0; idx < decimals; ++idx) { + scale *= 10; + } + const std::int64_t major = magnitude / scale; + const std::int64_t minor = magnitude % scale; + return std::format("{}{}.{:0{}} {}", sign, major, minor, decimals, code); +} + +} // namespace bank diff --git a/examples/bank/src/db/schema.cpp b/examples/bank/src/db/schema.cpp new file mode 100644 index 0000000..c50143d --- /dev/null +++ b/examples/bank/src/db/schema.cpp @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/db/database.hpp" + +#include +#include + +namespace bank::db { + +void configure(const std::string& connectionString) { + Lightweight::SqlConnection::SetDefaultConnectionString(Lightweight::SqlConnectionString{connectionString}); +} + +void applyMigrations() { + auto& migrations = Lightweight::SqlMigration::MigrationManager::GetInstance(); + migrations.CreateMigrationHistory(); + migrations.ApplyPendingMigrations(); +} + +void setup(const std::string& connectionString) { + configure(connectionString); + applyMigrations(); +} + +} // namespace bank::db + +// ─── Schema migrations ─────────────────────────────────────────────────────── +// Each LIGHTWEIGHT_SQL_MIGRATION auto-registers with the MigrationManager at +// static-init time; linking this TU into the binary makes the schema known. +// Timestamps are monotonically increasing and group by domain (accounts use the +// 0001 slot; later parts claim higher slots). + +using namespace Lightweight::SqlColumnTypeDefinitions; + +LIGHTWEIGHT_SQL_MIGRATION(20260629000001, "Create accounts table") { + plan.CreateTableIfNotExists("accounts") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("owner", Varchar(64)) + .RequiredColumn("number", Varchar(34)) + .RequiredColumn("kind", Integer()) + .RequiredColumn("currency", Integer()) + .RequiredColumn("balance_minor", Bigint()) + .RequiredColumn("overdraft_minor", Bigint()) + .RequiredColumn("status", Integer()) + .RequiredColumn("interest_bps", Integer()); +} + +LIGHTWEIGHT_SQL_MIGRATION(20260629000002, "Create users table") { + plan.CreateTableIfNotExists("users") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("username", Varchar(64)) + .RequiredColumn("password_hash", Varchar(32)) + .RequiredColumn("display_name", Varchar(128)) + .RequiredColumn("status", Integer()); +} + +LIGHTWEIGHT_SQL_MIGRATION(20260629000003, "Create transactions table") { + plan.CreateTableIfNotExists("transactions") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("account_id", Bigint()) + .RequiredColumn("counterparty_id", Bigint()) + .RequiredColumn("direction", Integer()) + .RequiredColumn("kind", Integer()) + .RequiredColumn("amount_minor", Bigint()) + .RequiredColumn("currency", Integer()) + .RequiredColumn("balance_after_minor", Bigint()) + .RequiredColumn("description", Varchar(128)) + .RequiredColumn("created_at_ms", Bigint()); +} + +LIGHTWEIGHT_SQL_MIGRATION(20260629000004, "Create payees table") { + plan.CreateTableIfNotExists("payees") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("owner", Varchar(64)) + .RequiredColumn("name", Varchar(128)) + .RequiredColumn("iban", Varchar(34)) + .RequiredColumn("bank_name", Varchar(128)); +} + +LIGHTWEIGHT_SQL_MIGRATION(20260629000005, "Create payments table") { + plan.CreateTableIfNotExists("payments") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("owner", Varchar(64)) + .RequiredColumn("from_account_id", Bigint()) + .RequiredColumn("payee_id", Bigint()) + .RequiredColumn("amount_minor", Bigint()) + .RequiredColumn("currency", Integer()) + .RequiredColumn("schedule", Integer()) + .RequiredColumn("status", Integer()) + .RequiredColumn("due_at_ms", Bigint()) + .RequiredColumn("interval_days", Integer()) + .RequiredColumn("description", Varchar(128)); +} + +LIGHTWEIGHT_SQL_MIGRATION(20260629000006, "Create cards table") { + plan.CreateTableIfNotExists("cards") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("owner", Varchar(64)) + .RequiredColumn("account_id", Bigint()) + .RequiredColumn("kind", Integer()) + .RequiredColumn("pan_last4", Varchar(4)) + .RequiredColumn("status", Integer()) + .RequiredColumn("daily_limit_minor", Bigint()) + .RequiredColumn("pin_hash", Varchar(16)); +} + +LIGHTWEIGHT_SQL_MIGRATION(20260629000007, "Create loans table") { + plan.CreateTableIfNotExists("loans") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("owner", Varchar(64)) + .RequiredColumn("account_id", Bigint()) + .RequiredColumn("principal_minor", Bigint()) + .RequiredColumn("outstanding_minor", Bigint()) + .RequiredColumn("currency", Integer()) + .RequiredColumn("rate_bps", Integer()) + .RequiredColumn("term_months", Integer()) + .RequiredColumn("status", Integer()) + .RequiredColumn("created_at_ms", Bigint()); +} + +LIGHTWEIGHT_SQL_MIGRATION(20260629000008, "Create budgets table") { + plan.CreateTableIfNotExists("budgets") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("owner", Varchar(64)) + .RequiredColumn("category", Varchar(64)) + .RequiredColumn("monthly_limit_minor", Bigint()) + .RequiredColumn("currency", Integer()); +} + +LIGHTWEIGHT_SQL_MIGRATION(20260629000009, "Create notifications table") { + plan.CreateTableIfNotExists("notifications") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("owner", Varchar(64)) + .RequiredColumn("severity", Integer()) + .RequiredColumn("message", Varchar(256)) + .RequiredColumn("is_read", Bool()) + .RequiredColumn("created_at_ms", Bigint()); +} diff --git a/examples/bank/src/models/account_model.cpp b/examples/bank/src/models/account_model.cpp new file mode 100644 index 0000000..b13cea4 --- /dev/null +++ b/examples/bank/src/models/account_model.cpp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/models/account_model.hpp" + +#include +#include + +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/db/account_entity.hpp" + +namespace bank { + +namespace { + +/// Generates a pseudo-IBAN-ish account number: "DE" + 20 digits. Good enough +/// for an example; not a real check-digit-valid IBAN. +std::string generateAccountNumber() { + static thread_local std::mt19937_64 rng{std::random_device{}()}; + std::uniform_int_distribution digit{0, 9}; + std::string number = "DE"; + for (int idx = 0; idx < 20; ++idx) { + number.push_back(static_cast('0' + digit(rng))); + } + return number; +} + +/// Translates a persisted `AccountRecord` into the wire `AccountInfo` DTO. +dto::AccountInfo toInfo(const db::AccountRecord& rec) { + return dto::AccountInfo{ + .id = static_cast(rec.id.Value()), + .owner = std::string{rec.owner.Value().str()}, + .number = std::string{rec.number.Value().str()}, + .kind = rec.kind.Value(), + .currency = rec.currency.Value(), + .balanceMinor = rec.balanceMinor.Value(), + .overdraftMinor = rec.overdraftMinor.Value(), + .status = rec.status.Value(), + .interestBps = rec.interestBps.Value(), + }; +} + +/// Default annual interest for savings accounts (1.5% = 150 bps); others earn 0. +int defaultInterestBps(int kind) { + return kind == static_cast(AccountKind::Savings) ? 150 : 0; +} + +} // namespace + +dto::AccountInfo AccountModel::execute(const dto::OpenAccount& action) { + if (!action.validate()) { + throw ValidationError{"invalid account kind/currency/overdraft"}; + } + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal to own the account"}; + } + + db::AccountRecord rec; + rec.owner = Light::SqlAnsiString<64>{owner}; + rec.number = Light::SqlAnsiString<34>{generateAccountNumber()}; + rec.kind = action.kind; + rec.currency = action.currency; + rec.balanceMinor = 0; + rec.overdraftMinor = action.overdraftMinor; + rec.status = static_cast(AccountStatus::Open); + rec.interestBps = defaultInterestBps(action.kind); + + mapper().Create(rec); + return toInfo(rec); +} + +dto::AccountList AccountModel::execute(const dto::ListAccounts& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal to list accounts for"}; + } + + auto rows = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::AccountRecord::owner>, "=", owner) + .All(); + + dto::AccountList out; + out.accounts.reserve(rows.size()); + for (const auto& rec : rows) { + out.accounts.push_back(toInfo(rec)); + } + return out; +} + +dto::AccountInfo AccountModel::execute(const dto::GetAccount& action) { + auto rec = mapper().QuerySingle(static_cast(action.id)); + if (!rec.has_value()) { + throw NotFound{"account not found"}; + } + return toInfo(*rec); +} + +dto::CommandResult AccountModel::execute(const dto::CloseAccount& action) { + auto rec = mapper().QuerySingle(static_cast(action.id)); + if (!rec.has_value()) { + throw NotFound{"account not found"}; + } + if (rec->balanceMinor.Value() != 0) { + return dto::CommandResult{.ok = false, .message = "account balance must be zero before closing"}; + } + rec->status = static_cast(AccountStatus::Closed); + mapper().Update(*rec); + return dto::CommandResult{.ok = true, .message = "account closed"}; +} + +} // namespace bank diff --git a/examples/bank/src/models/auth_model.cpp b/examples/bank/src/models/auth_model.cpp new file mode 100644 index 0000000..418bff3 --- /dev/null +++ b/examples/bank/src/models/auth_model.cpp @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/models/auth_model.hpp" + +#include + +#include +#include +#include +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/db/user_entity.hpp" + +namespace bank { + +namespace { + +/// Demo-grade password hashing. NOT secure — a real app would use a slow, +/// salted KDF (Argon2/bcrypt). Salting with the username keeps identical +/// passwords from colliding across users. +std::string hashPassword(std::string_view username, std::string_view password) { + const std::string material = std::string{username} + ":" + std::string{password} + ":morph-bank"; + const std::size_t digest = std::hash{}(material); + return std::format("{:016x}", digest); +} + +/// Finds a user by username, or std::nullopt. +std::optional findUser(Lightweight::DataMapper& mapper, const std::string& username) { + auto rows = mapper.Query() + .Where(Lightweight::FieldNameOf<&db::UserRecord::username>, "=", username) + .All(); + if (rows.empty()) { + return std::nullopt; + } + return std::move(rows.front()); +} + +} // namespace + +dto::AuthResult AuthModel::execute(const dto::RegisterUser& action) { + if (!action.validate()) { + throw ValidationError{"username required and password must be at least 4 characters"}; + } + if (findUser(mapper(), action.username).has_value()) { + return dto::AuthResult{.ok = false, .message = "username already taken"}; + } + + db::UserRecord rec; + rec.username = Light::SqlAnsiString<64>{action.username}; + rec.passwordHash = Light::SqlAnsiString<32>{hashPassword(action.username, action.password)}; + rec.displayName = + Light::SqlAnsiString<128>{action.displayName.empty() ? action.username : action.displayName}; + rec.status = 0; + mapper().Create(rec); + + return dto::AuthResult{.ok = true, + .principal = action.username, + .displayName = std::string{rec.displayName.Value().str()}, + .message = "registered"}; +} + +dto::AuthResult AuthModel::execute(const dto::LoginRequest& action) { + auto user = findUser(mapper(), action.username); + if (!user.has_value()) { + return dto::AuthResult{.ok = false, .message = "no such user"}; + } + if (user->status.Value() != 0) { + return dto::AuthResult{.ok = false, .message = "account disabled"}; + } + const std::string expected = hashPassword(action.username, action.password); + if (std::string{user->passwordHash.Value().str()} != expected) { + return dto::AuthResult{.ok = false, .message = "invalid credentials"}; + } + return dto::AuthResult{.ok = true, + .principal = action.username, + .displayName = std::string{user->displayName.Value().str()}, + .message = "welcome"}; +} + +dto::CommandResult AuthModel::execute(const dto::ChangePassword& action) { + auto user = findUser(mapper(), action.username); + if (!user.has_value()) { + throw NotFound{"no such user"}; + } + if (std::string{user->passwordHash.Value().str()} != hashPassword(action.username, action.oldPassword)) { + throw Unauthorized{"current password does not match"}; + } + if (action.newPassword.size() < 4) { + throw ValidationError{"new password must be at least 4 characters"}; + } + user->passwordHash = Light::SqlAnsiString<32>{hashPassword(action.username, action.newPassword)}; + mapper().Update(*user); + return dto::CommandResult{.ok = true, .message = "password changed"}; +} + +dto::SessionInfo AuthModel::execute(const dto::WhoAmI& /*action*/) { + const std::string principal = sessionPrincipal(); + return dto::SessionInfo{.authenticated = !principal.empty(), .principal = principal}; +} + +} // namespace bank diff --git a/examples/bank/src/models/budget_model.cpp b/examples/bank/src/models/budget_model.cpp new file mode 100644 index 0000000..8d94e0f --- /dev/null +++ b/examples/bank/src/models/budget_model.cpp @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/models/budget_model.hpp" + +#include + +#include +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/db/budget_entity.hpp" +#include "bank/db/txn_entity.hpp" + +namespace bank { + +namespace { + +dto::BudgetInfo toInfo(const db::BudgetRecord& rec) { + return dto::BudgetInfo{ + .id = static_cast(rec.id.Value()), + .owner = std::string{rec.owner.Value().str()}, + .category = std::string{rec.category.Value().str()}, + .monthlyLimitMinor = rec.monthlyLimitMinor.Value(), + .currency = rec.currency.Value(), + }; +} + +} // namespace + +dto::BudgetInfo BudgetModel::execute(const dto::SetBudget& action) { + if (!action.validate()) { + throw ValidationError{"category required and limit must be non-negative"}; + } + const std::string owner = sessionPrincipal(); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + + // Upsert: update the existing row for (owner, category) or create a new one. + auto existing = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::BudgetRecord::owner>, "=", owner) + .Where(Lightweight::FieldNameOf<&db::BudgetRecord::category>, "=", action.category) + .All(); + if (!existing.empty()) { + auto rec = existing.front(); + rec.monthlyLimitMinor = action.monthlyLimitMinor; + rec.currency = action.currency; + mapper().Update(rec); + return toInfo(rec); + } + + db::BudgetRecord rec; + rec.owner = Light::SqlAnsiString<64>{owner}; + rec.category = Light::SqlAnsiString<64>{action.category}; + rec.monthlyLimitMinor = action.monthlyLimitMinor; + rec.currency = action.currency; + mapper().Create(rec); + return toInfo(rec); +} + +dto::CommandResult BudgetModel::execute(const dto::DeleteBudget& action) { + auto rec = mapper().QuerySingle(static_cast(action.id)); + if (!rec.has_value()) { + throw NotFound{"budget not found"}; + } + if (std::string{rec->owner.Value().str()} != sessionPrincipal()) { + throw Unauthorized{"budget belongs to a different owner"}; + } + mapper().Delete(*rec); + return dto::CommandResult{.ok = true, .message = "budget deleted"}; +} + +dto::BudgetList BudgetModel::execute(const dto::ListBudgets& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto rows = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::BudgetRecord::owner>, "=", owner) + .All(); + dto::BudgetList out; + out.budgets.reserve(rows.size()); + for (const auto& rec : rows) { + out.budgets.push_back(toInfo(rec)); + } + return out; +} + +dto::SpendingReport BudgetModel::execute(const dto::SpendingByKind& action) { + auto rows = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::TxnRecord::accountId>, "=", action.accountId) + .All(); + + // Aggregate debits by kind in code (kept on the typed DataMapper path rather + // than hand-written GROUP BY SQL). + std::map byKind; + std::int64_t totalDebits = 0; + for (const auto& rec : rows) { + if (rec.direction.Value() != static_cast(TxnDirection::Debit)) { + continue; + } + if (rec.createdAtMs.Value() < action.sinceMs) { + continue; + } + const int kind = rec.kind.Value(); + auto& entry = byKind[kind]; + entry.kind = kind; + entry.totalMinor += rec.amountMinor.Value(); + entry.count += 1; + totalDebits += rec.amountMinor.Value(); + } + + dto::SpendingReport report; + report.accountId = action.accountId; + report.totalDebitsMinor = totalDebits; + report.byKind.reserve(byKind.size()); + for (const auto& [kind, spend] : byKind) { + report.byKind.push_back(spend); + } + return report; +} + +} // namespace bank diff --git a/examples/bank/src/models/card_model.cpp b/examples/bank/src/models/card_model.cpp new file mode 100644 index 0000000..726d2c8 --- /dev/null +++ b/examples/bank/src/models/card_model.cpp @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/models/card_model.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/db/account_entity.hpp" +#include "bank/db/card_entity.hpp" + +namespace bank { + +namespace { + +std::string randomLast4() { + static thread_local std::mt19937 rng{std::random_device{}()}; + std::uniform_int_distribution dist{0, 9999}; + return std::format("{:04}", dist(rng)); +} + +std::string hashPin(std::string_view pin) { + return std::format("{:016x}", std::hash{}(std::string{pin} + ":pin")); +} + +dto::CardInfo toInfo(const db::CardRecord& rec) { + return dto::CardInfo{ + .id = static_cast(rec.id.Value()), + .owner = std::string{rec.owner.Value().str()}, + .accountId = rec.accountId.Value(), + .kind = rec.kind.Value(), + .panLast4 = std::string{rec.panLast4.Value().str()}, + .status = rec.status.Value(), + .dailyLimitMinor = rec.dailyLimitMinor.Value(), + }; +} + +} // namespace + +dto::CardInfo CardModel::execute(const dto::IssueCard& action) { + if (!action.validate()) { + throw ValidationError{"invalid card request"}; + } + const std::string owner = sessionPrincipal(); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto account = mapper().QuerySingle(static_cast(action.accountId)); + if (!account.has_value()) { + throw NotFound{"account not found"}; + } + if (std::string{account->owner.Value().str()} != owner) { + throw Unauthorized{"account belongs to a different owner"}; + } + + db::CardRecord card; + card.owner = Light::SqlAnsiString<64>{owner}; + card.accountId = action.accountId; + card.kind = action.kind; + card.panLast4 = Light::SqlAnsiString<4>{randomLast4()}; + card.status = static_cast(CardStatus::Active); + card.dailyLimitMinor = action.dailyLimitMinor; + card.pinHash = Light::SqlAnsiString<16>{hashPin("0000")}; + mapper().Create(card); + return toInfo(card); +} + +namespace { + +/// Loads a card the session owner owns, or throws. +db::CardRecord requireOwnedCard(Lightweight::DataMapper& mapper, std::int64_t cardId) { + auto card = mapper.QuerySingle(static_cast(cardId)); + if (!card.has_value()) { + throw NotFound{"card not found"}; + } + if (std::string{card->owner.Value().str()} != sessionPrincipal()) { + throw Unauthorized{"card belongs to a different owner"}; + } + return *card; +} + +} // namespace + +dto::CommandResult CardModel::execute(const dto::FreezeCard& action) { + auto card = requireOwnedCard(mapper(), action.id); + card.status = static_cast(CardStatus::Frozen); + mapper().Update(card); + return dto::CommandResult{.ok = true, .message = "card frozen"}; +} + +dto::CommandResult CardModel::execute(const dto::UnfreezeCard& action) { + auto card = requireOwnedCard(mapper(), action.id); + if (card.status.Value() == static_cast(CardStatus::Cancelled)) { + throw ConflictError{"cancelled cards cannot be reactivated"}; + } + card.status = static_cast(CardStatus::Active); + mapper().Update(card); + return dto::CommandResult{.ok = true, .message = "card active"}; +} + +dto::CommandResult CardModel::execute(const dto::CancelCard& action) { + auto card = requireOwnedCard(mapper(), action.id); + card.status = static_cast(CardStatus::Cancelled); + mapper().Update(card); + return dto::CommandResult{.ok = true, .message = "card cancelled"}; +} + +dto::CommandResult CardModel::execute(const dto::SetCardLimit& action) { + if (action.dailyLimitMinor < 0) { + throw ValidationError{"limit must be non-negative"}; + } + auto card = requireOwnedCard(mapper(), action.id); + card.dailyLimitMinor = action.dailyLimitMinor; + mapper().Update(card); + return dto::CommandResult{.ok = true, .message = "limit updated"}; +} + +dto::CommandResult CardModel::execute(const dto::ChangePin& action) { + if (!action.validate()) { + throw ValidationError{"PIN must be exactly 4 digits"}; + } + auto card = requireOwnedCard(mapper(), action.id); + card.pinHash = Light::SqlAnsiString<16>{hashPin(action.newPin)}; + mapper().Update(card); + return dto::CommandResult{.ok = true, .message = "PIN changed"}; +} + +dto::CardList CardModel::execute(const dto::ListCards& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto rows = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::CardRecord::owner>, "=", owner) + .All(); + dto::CardList out; + out.cards.reserve(rows.size()); + for (const auto& rec : rows) { + out.cards.push_back(toInfo(rec)); + } + return out; +} + +} // namespace bank diff --git a/examples/bank/src/models/loan_model.cpp b/examples/bank/src/models/loan_model.cpp new file mode 100644 index 0000000..2322aac --- /dev/null +++ b/examples/bank/src/models/loan_model.cpp @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/models/loan_model.hpp" + +#include +#include + +#include +#include +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/db/ledger_ops.hpp" +#include "bank/db/loan_entity.hpp" + +namespace bank { + +namespace { + +dto::LoanInfo toInfo(const db::LoanRecord& rec) { + return dto::LoanInfo{ + .id = static_cast(rec.id.Value()), + .owner = std::string{rec.owner.Value().str()}, + .accountId = rec.accountId.Value(), + .principalMinor = rec.principalMinor.Value(), + .outstandingMinor = rec.outstandingMinor.Value(), + .currency = rec.currency.Value(), + .rateBps = rec.rateBps.Value(), + .termMonths = rec.termMonths.Value(), + .status = rec.status.Value(), + .createdAtMs = rec.createdAtMs.Value(), + }; +} + +db::LoanRecord requireOwnedLoan(Lightweight::DataMapper& mapper, std::int64_t loanId) { + auto loan = mapper.QuerySingle(static_cast(loanId)); + if (!loan.has_value()) { + throw NotFound{"loan not found"}; + } + if (std::string{loan->owner.Value().str()} != sessionPrincipal()) { + throw Unauthorized{"loan belongs to a different owner"}; + } + return *loan; +} + +/// Fixed monthly payment (minor units) for an amortizing loan. +std::int64_t monthlyPayment(std::int64_t principalMinor, int rateBps, int termMonths) { + const double principal = static_cast(principalMinor); + const double monthlyRate = static_cast(rateBps) / 10000.0 / 12.0; + if (monthlyRate <= 0.0) { + return static_cast(std::llround(principal / termMonths)); + } + const double factor = std::pow(1.0 + monthlyRate, -termMonths); + return static_cast(std::llround(principal * monthlyRate / (1.0 - factor))); +} + +} // namespace + +dto::LoanInfo LoanModel::execute(const dto::ApplyLoan& action) { + if (!action.validate()) { + throw ValidationError{"invalid loan application"}; + } + const std::string owner = sessionPrincipal(); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto& dm = mapper(); + auto account = db::loadOpenAccount(dm, action.accountId); + if (std::string{account.owner.Value().str()} != owner) { + throw Unauthorized{"account belongs to a different owner"}; + } + + db::LoanRecord loan; + loan.owner = Light::SqlAnsiString<64>{owner}; + loan.accountId = action.accountId; + loan.principalMinor = action.principalMinor; + loan.outstandingMinor = action.principalMinor; + loan.currency = account.currency.Value(); + loan.rateBps = action.rateBps; + loan.termMonths = action.termMonths; + loan.status = static_cast(LoanStatus::Active); + loan.createdAtMs = db::nowMillis(); + + Lightweight::SqlTransaction tx{dm.Connection(), Lightweight::SqlTransactionMode::ROLLBACK}; + dm.Create(loan); + db::applyCredit(dm, account, action.principalMinor, TxnKind::LoanDisbursement, 0, "loan disbursement"); + tx.Commit(); + + return toInfo(loan); +} + +dto::LoanInfo LoanModel::execute(const dto::RepayLoan& action) { + if (!action.validate()) { + throw ValidationError{"invalid repayment"}; + } + auto& dm = mapper(); + auto loan = requireOwnedLoan(dm, action.loanId); + if (loan.status.Value() != static_cast(LoanStatus::Active)) { + throw ConflictError{"loan is not active"}; + } + auto account = db::loadOpenAccount(dm, action.fromAccountId); + if (std::string{account.owner.Value().str()} != sessionPrincipal()) { + throw Unauthorized{"account belongs to a different owner"}; + } + const std::int64_t payment = std::min(action.amountMinor, loan.outstandingMinor.Value()); + + Lightweight::SqlTransaction tx{dm.Connection(), Lightweight::SqlTransactionMode::ROLLBACK}; + db::applyDebit(dm, account, payment, TxnKind::LoanRepayment, 0, "loan repayment"); + loan.outstandingMinor = loan.outstandingMinor.Value() - payment; + if (loan.outstandingMinor.Value() <= 0) { + loan.outstandingMinor = 0; + loan.status = static_cast(LoanStatus::PaidOff); + } + dm.Update(loan); + tx.Commit(); + + return toInfo(loan); +} + +dto::LoanInfo LoanModel::execute(const dto::GetLoan& action) { + auto loan = mapper().QuerySingle(static_cast(action.id)); + if (!loan.has_value()) { + throw NotFound{"loan not found"}; + } + return toInfo(*loan); +} + +dto::LoanList LoanModel::execute(const dto::ListLoans& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto rows = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::LoanRecord::owner>, "=", owner) + .All(); + dto::LoanList out; + out.loans.reserve(rows.size()); + for (const auto& rec : rows) { + out.loans.push_back(toInfo(rec)); + } + return out; +} + +dto::LoanScheduleResult LoanModel::execute(const dto::LoanScheduleRequest& action) { + auto loan = requireOwnedLoan(mapper(), action.loanId); + + const std::int64_t principal = loan.principalMinor.Value(); + const int rateBps = loan.rateBps.Value(); + const int term = loan.termMonths.Value(); + const double monthlyRate = static_cast(rateBps) / 10000.0 / 12.0; + const std::int64_t payment = monthlyPayment(principal, rateBps, term); + + dto::LoanScheduleResult out; + out.loanId = action.loanId; + out.monthlyPaymentMinor = payment; + + std::int64_t remaining = principal; + for (int month = 1; month <= term && remaining > 0; ++month) { + const std::int64_t interest = + static_cast(std::llround(static_cast(remaining) * monthlyRate)); + std::int64_t principalPart = payment - interest; + if (month == term || principalPart > remaining) { + principalPart = remaining; // final instalment clears the balance + } + remaining -= principalPart; + out.installments.push_back(dto::Installment{ + .month = month, + .paymentMinor = principalPart + interest, + .principalMinor = principalPart, + .interestMinor = interest, + .remainingMinor = remaining, + }); + } + return out; +} + +} // namespace bank diff --git a/examples/bank/src/models/notification_model.cpp b/examples/bank/src/models/notification_model.cpp new file mode 100644 index 0000000..cdc6c5e --- /dev/null +++ b/examples/bank/src/models/notification_model.cpp @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/models/notification_model.hpp" + +#include + +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/db/ledger_ops.hpp" // for nowMillis() +#include "bank/db/notification_entity.hpp" + +namespace bank { + +namespace { + +dto::NotificationInfo toInfo(const db::NotificationRecord& rec) { + return dto::NotificationInfo{ + .id = static_cast(rec.id.Value()), + .owner = std::string{rec.owner.Value().str()}, + .severity = rec.severity.Value(), + .message = std::string{rec.message.Value().str()}, + .read = rec.read.Value(), + .createdAtMs = rec.createdAtMs.Value(), + }; +} + +} // namespace + +dto::NotificationInfo NotificationModel::execute(const dto::Notify& action) { + if (!action.validate()) { + throw ValidationError{"message required and severity must be 0..2"}; + } + const std::string owner = sessionPrincipal(); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + db::NotificationRecord rec; + rec.owner = Light::SqlAnsiString<64>{owner}; + rec.severity = action.severity; + rec.message = Light::SqlAnsiString<256>{action.message}; + rec.read = false; + rec.createdAtMs = db::nowMillis(); + mapper().Create(rec); + return toInfo(rec); +} + +dto::NotificationList NotificationModel::execute(const dto::ListNotifications& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto rows = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::NotificationRecord::owner>, "=", owner) + .All(); + dto::NotificationList out; + for (const auto& rec : rows) { + if (!rec.read.Value()) { + out.unreadCount += 1; + } + if (action.unreadOnly && rec.read.Value()) { + continue; + } + out.notifications.push_back(toInfo(rec)); + } + return out; +} + +dto::CommandResult NotificationModel::execute(const dto::MarkRead& action) { + auto rec = mapper().QuerySingle(static_cast(action.id)); + if (!rec.has_value()) { + throw NotFound{"notification not found"}; + } + if (std::string{rec->owner.Value().str()} != sessionPrincipal()) { + throw Unauthorized{"notification belongs to a different owner"}; + } + rec->read = true; + mapper().Update(*rec); + return dto::CommandResult{.ok = true, .message = "marked read"}; +} + +dto::CommandResult NotificationModel::execute(const dto::MarkAllRead& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto rows = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::NotificationRecord::owner>, "=", owner) + .All(); + int updated = 0; + for (auto& rec : rows) { + if (!rec.read.Value()) { + rec.read = true; + mapper().Update(rec); + ++updated; + } + } + return dto::CommandResult{.ok = true, .message = std::to_string(updated) + " marked read"}; +} + +} // namespace bank diff --git a/examples/bank/src/models/payee_model.cpp b/examples/bank/src/models/payee_model.cpp new file mode 100644 index 0000000..ed762d2 --- /dev/null +++ b/examples/bank/src/models/payee_model.cpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/models/payee_model.hpp" + +#include + +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/db/payee_entity.hpp" + +namespace bank { + +namespace { + +dto::PayeeInfo toInfo(const db::PayeeRecord& rec) { + return dto::PayeeInfo{ + .id = static_cast(rec.id.Value()), + .owner = std::string{rec.owner.Value().str()}, + .name = std::string{rec.name.Value().str()}, + .iban = std::string{rec.iban.Value().str()}, + .bankName = std::string{rec.bankName.Value().str()}, + }; +} + +} // namespace + +dto::PayeeInfo PayeeModel::execute(const dto::AddPayee& action) { + if (!action.validate()) { + throw ValidationError{"payee needs a name and a valid IBAN"}; + } + const std::string owner = sessionPrincipal(); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + + db::PayeeRecord rec; + rec.owner = Light::SqlAnsiString<64>{owner}; + rec.name = Light::SqlAnsiString<128>{action.name}; + rec.iban = Light::SqlAnsiString<34>{action.iban}; + rec.bankName = Light::SqlAnsiString<128>{action.bankName}; + mapper().Create(rec); + return toInfo(rec); +} + +dto::CommandResult PayeeModel::execute(const dto::RemovePayee& action) { + auto rec = mapper().QuerySingle(static_cast(action.id)); + if (!rec.has_value()) { + throw NotFound{"payee not found"}; + } + if (std::string{rec->owner.Value().str()} != sessionPrincipal()) { + throw Unauthorized{"payee belongs to a different owner"}; + } + mapper().Delete(*rec); + return dto::CommandResult{.ok = true, .message = "payee removed"}; +} + +dto::PayeeList PayeeModel::execute(const dto::ListPayees& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto rows = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::PayeeRecord::owner>, "=", owner) + .All(); + dto::PayeeList out; + out.payees.reserve(rows.size()); + for (const auto& rec : rows) { + out.payees.push_back(toInfo(rec)); + } + return out; +} + +} // namespace bank diff --git a/examples/bank/src/models/payment_model.cpp b/examples/bank/src/models/payment_model.cpp new file mode 100644 index 0000000..15fb7a2 --- /dev/null +++ b/examples/bank/src/models/payment_model.cpp @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/models/payment_model.hpp" + +#include +#include + +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/db/ledger_ops.hpp" +#include "bank/db/payee_entity.hpp" +#include "bank/db/payment_entity.hpp" + +namespace bank { + +namespace { + +dto::PaymentInfo toInfo(const db::PaymentRecord& rec) { + return dto::PaymentInfo{ + .id = static_cast(rec.id.Value()), + .owner = std::string{rec.owner.Value().str()}, + .fromAccountId = rec.fromAccountId.Value(), + .payeeId = rec.payeeId.Value(), + .amountMinor = rec.amountMinor.Value(), + .currency = rec.currency.Value(), + .schedule = rec.schedule.Value(), + .status = rec.status.Value(), + .dueAtMs = rec.dueAtMs.Value(), + .intervalDays = rec.intervalDays.Value(), + .description = std::string{rec.description.Value().str()}, + }; +} + +/// Loads a payee, requiring it to exist and belong to @p owner. +db::PayeeRecord requireOwnedPayee(Lightweight::DataMapper& mapper, std::int64_t payeeId, + const std::string& owner) { + auto payee = mapper.QuerySingle(static_cast(payeeId)); + if (!payee.has_value()) { + throw NotFound{"payee not found"}; + } + if (std::string{payee->owner.Value().str()} != owner) { + throw Unauthorized{"payee belongs to a different owner"}; + } + return *payee; +} + +/// Loads an open account, requiring it to belong to @p owner. +db::AccountRecord requireOwnedAccount(Lightweight::DataMapper& mapper, std::int64_t accountId, + const std::string& owner) { + auto account = db::loadOpenAccount(mapper, accountId); + if (std::string{account.owner.Value().str()} != owner) { + throw Unauthorized{"account belongs to a different owner"}; + } + return account; +} + +} // namespace + +dto::PaymentInfo PaymentModel::execute(const dto::PayBill& action) { + if (!action.validate()) { + throw ValidationError{"invalid bill payment"}; + } + const std::string owner = sessionPrincipal(); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto& dm = mapper(); + auto account = requireOwnedAccount(dm, action.fromAccountId, owner); + requireOwnedPayee(dm, action.payeeId, owner); + + db::PaymentRecord payment; + payment.owner = Light::SqlAnsiString<64>{owner}; + payment.fromAccountId = action.fromAccountId; + payment.payeeId = action.payeeId; + payment.amountMinor = action.amountMinor; + payment.currency = account.currency.Value(); + payment.schedule = static_cast(PaymentSchedule::OneOff); + payment.status = static_cast(PaymentStatus::Completed); + payment.dueAtMs = db::nowMillis(); + payment.intervalDays = 0; + payment.description = Light::SqlAnsiString<128>{action.description}; + + Lightweight::SqlTransaction tx{dm.Connection(), Lightweight::SqlTransactionMode::ROLLBACK}; + db::applyDebit(dm, account, action.amountMinor, TxnKind::Payment, action.payeeId, action.description); + dm.Create(payment); + tx.Commit(); + + return toInfo(payment); +} + +dto::PaymentInfo PaymentModel::execute(const dto::SchedulePayment& action) { + if (!action.validate()) { + throw ValidationError{"invalid scheduled payment"}; + } + const std::string owner = sessionPrincipal(); + auto& dm = mapper(); + auto account = requireOwnedAccount(dm, action.fromAccountId, owner); + requireOwnedPayee(dm, action.payeeId, owner); + + db::PaymentRecord payment; + payment.owner = Light::SqlAnsiString<64>{owner}; + payment.fromAccountId = action.fromAccountId; + payment.payeeId = action.payeeId; + payment.amountMinor = action.amountMinor; + payment.currency = account.currency.Value(); + payment.schedule = static_cast(PaymentSchedule::Scheduled); + payment.status = static_cast(PaymentStatus::Pending); + payment.dueAtMs = action.dueAtMs; + payment.intervalDays = 0; + payment.description = Light::SqlAnsiString<128>{action.description}; + dm.Create(payment); + return toInfo(payment); +} + +dto::PaymentInfo PaymentModel::execute(const dto::CreateStandingOrder& action) { + if (!action.validate()) { + throw ValidationError{"invalid standing order"}; + } + const std::string owner = sessionPrincipal(); + auto& dm = mapper(); + auto account = requireOwnedAccount(dm, action.fromAccountId, owner); + requireOwnedPayee(dm, action.payeeId, owner); + + db::PaymentRecord payment; + payment.owner = Light::SqlAnsiString<64>{owner}; + payment.fromAccountId = action.fromAccountId; + payment.payeeId = action.payeeId; + payment.amountMinor = action.amountMinor; + payment.currency = account.currency.Value(); + payment.schedule = static_cast(PaymentSchedule::Standing); + payment.status = static_cast(PaymentStatus::Pending); + payment.dueAtMs = action.firstDueAtMs; + payment.intervalDays = action.intervalDays; + payment.description = Light::SqlAnsiString<128>{action.description}; + dm.Create(payment); + return toInfo(payment); +} + +dto::CommandResult PaymentModel::execute(const dto::CancelPayment& action) { + auto payment = mapper().QuerySingle(static_cast(action.id)); + if (!payment.has_value()) { + throw NotFound{"payment not found"}; + } + if (std::string{payment->owner.Value().str()} != sessionPrincipal()) { + throw Unauthorized{"payment belongs to a different owner"}; + } + if (payment->status.Value() != static_cast(PaymentStatus::Pending)) { + throw ConflictError{"only pending payments can be cancelled"}; + } + payment->status = static_cast(PaymentStatus::Cancelled); + mapper().Update(*payment); + return dto::CommandResult{.ok = true, .message = "payment cancelled"}; +} + +dto::PaymentList PaymentModel::execute(const dto::ListPayments& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto rows = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::PaymentRecord::owner>, "=", owner) + .All(); + dto::PaymentList out; + out.payments.reserve(rows.size()); + for (const auto& rec : rows) { + out.payments.push_back(toInfo(rec)); + } + return out; +} + +} // namespace bank diff --git a/examples/bank/src/models/statement_model.cpp b/examples/bank/src/models/statement_model.cpp new file mode 100644 index 0000000..00ab695 --- /dev/null +++ b/examples/bank/src/models/statement_model.cpp @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/models/statement_model.hpp" + +#include + +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/db/account_entity.hpp" +#include "bank/db/txn_entity.hpp" + +namespace bank { + +dto::Statement StatementModel::execute(const dto::GenerateStatement& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + + auto accounts = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::AccountRecord::owner>, "=", owner) + .All(); + + dto::Statement statement; + statement.owner = owner; + statement.fromMs = action.fromMs; + statement.toMs = action.toMs; + + for (const auto& account : accounts) { + const auto accountId = static_cast(account.id.Value()); + auto entries = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::TxnRecord::accountId>, "=", accountId) + .All(); + + dto::StatementLine line; + line.accountId = accountId; + line.number = std::string{account.number.Value().str()}; + line.currency = account.currency.Value(); + line.closingBalanceMinor = account.balanceMinor.Value(); + + for (const auto& entry : entries) { + const std::int64_t when = entry.createdAtMs.Value(); + if (when < action.fromMs) { + continue; + } + if (action.toMs != 0 && when > action.toMs) { + continue; + } + if (entry.direction.Value() == static_cast(TxnDirection::Credit)) { + line.creditsMinor += entry.amountMinor.Value(); + } else { + line.debitsMinor += entry.amountMinor.Value(); + } + line.entryCount += 1; + } + + statement.totalCreditsMinor += line.creditsMinor; + statement.totalDebitsMinor += line.debitsMinor; + statement.lines.push_back(std::move(line)); + } + + return statement; +} + +} // namespace bank diff --git a/examples/bank/src/models/transaction_model.cpp b/examples/bank/src/models/transaction_model.cpp new file mode 100644 index 0000000..114b159 --- /dev/null +++ b/examples/bank/src/models/transaction_model.cpp @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "bank/models/transaction_model.hpp" + +#include +#include + +#include +#include +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/types.hpp" +#include "bank/db/ledger_ops.hpp" + +namespace bank { + +namespace { + +dto::TxnInfo toTxnInfo(const db::TxnRecord& rec) { + return dto::TxnInfo{ + .id = static_cast(rec.id.Value()), + .accountId = rec.accountId.Value(), + .counterpartyId = rec.counterpartyId.Value(), + .direction = rec.direction.Value(), + .kind = rec.kind.Value(), + .amountMinor = rec.amountMinor.Value(), + .currency = rec.currency.Value(), + .balanceAfterMinor = rec.balanceAfterMinor.Value(), + .description = std::string{rec.description.Value().str()}, + .createdAtMs = rec.createdAtMs.Value(), + }; +} + +} // namespace + +dto::TxnInfo TransactionModel::execute(const dto::Deposit& action) { + if (!action.validate()) { + throw ValidationError{"deposit amount must be positive"}; + } + auto account = db::loadOpenAccount(mapper(), action.accountId); + auto txn = db::applyCredit(mapper(), account, action.amountMinor, TxnKind::Deposit, 0, action.description); + return toTxnInfo(txn); +} + +dto::TxnInfo TransactionModel::execute(const dto::Withdraw& action) { + if (!action.validate()) { + throw ValidationError{"withdrawal amount must be positive"}; + } + auto account = db::loadOpenAccount(mapper(), action.accountId); + auto txn = + db::applyDebit(mapper(), account, action.amountMinor, TxnKind::Withdrawal, 0, action.description); + return toTxnInfo(txn); +} + +dto::TransferResult TransactionModel::execute(const dto::Transfer& action) { + if (!action.validate()) { + throw ValidationError{"invalid transfer (accounts must differ and amount be positive)"}; + } + auto& dm = mapper(); + auto source = db::loadOpenAccount(dm, action.fromAccountId); + auto dest = db::loadOpenAccount(dm, action.toAccountId); + if (source.currency.Value() != dest.currency.Value()) { + throw ValidationError{"cross-currency transfers are not supported"}; + } + + Lightweight::SqlTransaction tx{dm.Connection(), Lightweight::SqlTransactionMode::ROLLBACK}; + db::applyDebit(dm, source, action.amountMinor, TxnKind::TransferOut, action.toAccountId, + action.description); + db::applyCredit(dm, dest, action.amountMinor, TxnKind::TransferIn, action.fromAccountId, + action.description); + tx.Commit(); + + return dto::TransferResult{.fromBalanceMinor = source.balanceMinor.Value(), + .toBalanceMinor = dest.balanceMinor.Value()}; +} + +dto::HistoryPage TransactionModel::execute(const dto::History& action) { + auto rows = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::TxnRecord::accountId>, "=", action.accountId) + .All(); + // Newest first by id. + std::ranges::sort(rows, [](const db::TxnRecord& lhs, const db::TxnRecord& rhs) { + return lhs.id.Value() > rhs.id.Value(); + }); + + dto::HistoryPage page; + page.accountId = action.accountId; + const auto offset = static_cast(std::max(0, action.offset)); + const auto limit = static_cast(std::max(0, action.limit)); + for (std::size_t idx = offset; idx < rows.size() && page.entries.size() < limit; ++idx) { + page.entries.push_back(toTxnInfo(rows[idx])); + } + return page; +} + +} // namespace bank diff --git a/examples/bank/tests/bank_test_support.hpp b/examples/bank/tests/bank_test_support.hpp new file mode 100644 index 0000000..7495ec9 --- /dev/null +++ b/examples/bank/tests/bank_test_support.hpp @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "bank/db/database.hpp" + +/// @file +/// Shared helpers for the bank example tests. + +namespace bank::testing { + +/// @brief Sets up the shared test database exactly once for the whole binary. +/// +/// All tests run against one on-disk SQLite file (a single `:memory:` +/// connection cannot be shared across the per-model DataMappers). Migrations +/// are applied once; individual tests isolate themselves by using unique owner +/// principals rather than by wiping tables. +inline void ensureDatabase() { + static const bool once = [] { + const auto path = std::filesystem::temp_directory_path() / "morph_bank_tests.db"; + std::error_code err; + std::filesystem::remove(path, err); + bank::db::setup("DRIVER=SQLite3;Database=" + path.string()); + return true; + }(); + (void)once; +} + +/// @brief Runs a morph action to completion synchronously by pumping @p gui. +/// +/// Posts the completion's callbacks onto @p gui (which must be the same +/// executor the handler was constructed with), drains it on the calling thread +/// until the result or error arrives, and either returns the value or rethrows +/// the error — so tests can write straight-line `REQUIRE(await(...) == ...)`. +/// +/// @tparam T The action's result type. +/// @param completion The completion returned by `handler.execute(action)`. +/// @param gui The pumpable GUI executor to drain. +/// @return The resolved value. +template +T await(morph::async::Completion completion, morph::exec::MainThreadExecutor& gui) { + std::atomic done{false}; + std::optional value; + std::exception_ptr error; + completion + .then([&](T resolved) { + value = std::move(resolved); + done.store(true); + }) + .onError([&](const std::exception_ptr& err) { + error = err; + done.store(true); + }); + while (!done.load()) { + gui.runFor(std::chrono::milliseconds{20}); + } + if (error) { + std::rethrow_exception(error); + } + return std::move(*value); +} + +/// @brief Polls @p pred until true or @p budget elapses, **pumping @p gui** each +/// step so that callbacks posted to the GUI executor actually run. +template +bool waitUntil(Pred pred, std::chrono::milliseconds budget, std::chrono::milliseconds step, + morph::exec::MainThreadExecutor& gui) { + const auto deadline = std::chrono::steady_clock::now() + budget; + while (!pred()) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + gui.runFor(step); + } + return true; +} + +} // namespace bank::testing diff --git a/examples/bank/tests/test_account.cpp b/examples/bank/tests/test_account.cpp new file mode 100644 index 0000000..2146cd5 --- /dev/null +++ b/examples/bank/tests/test_account.cpp @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include + +#include "bank/app/app.hpp" +#include "bank/core/errors.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; + +namespace { + +/// Builds an App against the shared test DB and logs in @p principal. +std::string dbConnectionForTests() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +} // namespace + +TEST_CASE("AccountModel opens, lists, fetches and closes accounts", "[account]") { + bank::app::App app{dbConnectionForTests()}; + app.login("alice-account-basic"); + + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + + SECTION("open populates an account with a number and zero balance") { + auto info = await(accounts.execute(bank::dto::OpenAccount{ + .owner = "", + .kind = static_cast(bank::AccountKind::Checking), + .currency = static_cast(bank::Currency::EUR), + .overdraftMinor = 50000, + }), + app.guiLoop()); + + REQUIRE(info.id > 0); + REQUIRE(info.owner == "alice-account-basic"); + REQUIRE(info.number.size() == 22); // "DE" + 20 digits + REQUIRE(info.balanceMinor == 0); + REQUIRE(info.overdraftMinor == 50000); + REQUIRE(info.status == static_cast(bank::AccountStatus::Open)); + REQUIRE(info.currency == static_cast(bank::Currency::EUR)); + } + + SECTION("savings accounts get a non-zero interest rate") { + auto info = await(accounts.execute(bank::dto::OpenAccount{ + .kind = static_cast(bank::AccountKind::Savings), + .currency = static_cast(bank::Currency::USD), + }), + app.guiLoop()); + REQUIRE(info.interestBps > 0); + } + + SECTION("list returns only the session owner's accounts") { + await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()); + await(accounts.execute(bank::dto::OpenAccount{.kind = 1, .currency = 0}), app.guiLoop()); + + auto list = await(accounts.execute(bank::dto::ListAccounts{}), app.guiLoop()); + REQUIRE(list.accounts.size() >= 2); + for (const auto& acct : list.accounts) { + REQUIRE(acct.owner == "alice-account-basic"); + } + } + + SECTION("get returns the same account that was opened") { + auto opened = await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()); + auto fetched = await(accounts.execute(bank::dto::GetAccount{.id = opened.id}), app.guiLoop()); + REQUIRE(fetched.id == opened.id); + REQUIRE(fetched.number == opened.number); + } + + SECTION("closing a zero-balance account succeeds") { + auto opened = await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()); + auto result = await(accounts.execute(bank::dto::CloseAccount{.id = opened.id}), app.guiLoop()); + REQUIRE(result.ok); + + auto fetched = await(accounts.execute(bank::dto::GetAccount{.id = opened.id}), app.guiLoop()); + REQUIRE(fetched.status == static_cast(bank::AccountStatus::Closed)); + } +} + +TEST_CASE("AccountModel reports errors through onError", "[account]") { + bank::app::App app{dbConnectionForTests()}; + app.login("bob-account-errors"); + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + + SECTION("fetching a non-existent account throws NotFound") { + REQUIRE_THROWS_AS(await(accounts.execute(bank::dto::GetAccount{.id = 999999}), app.guiLoop()), + bank::NotFound); + } + + SECTION("invalid currency fails validation") { + REQUIRE_THROWS_AS( + await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 99}), app.guiLoop()), + bank::ValidationError); + } +} diff --git a/examples/bank/tests/test_auth.cpp b/examples/bank/tests/test_auth.cpp new file mode 100644 index 0000000..672cba4 --- /dev/null +++ b/examples/bank/tests/test_auth.cpp @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include + +#include "bank/app/app.hpp" +#include "bank/core/errors.hpp" +#include "bank/dto/auth_dto.hpp" +#include "bank/models/auth_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; + +namespace { + +std::string testConnection() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +} // namespace + +TEST_CASE("AuthModel register/login/change-password flow", "[auth]") { + bank::app::App app{testConnection()}; + morph::bridge::BridgeHandler auth{app.bridge(), app.gui()}; + + const std::string user = "carol-" + std::to_string(std::filesystem::hash_value("carol")); + + SECTION("registering then logging in succeeds; wrong password fails") { + auto reg = await(auth.execute(bank::dto::RegisterUser{.username = user, + .password = "hunter2", + .displayName = "Carol"}), + app.guiLoop()); + REQUIRE(reg.ok); + REQUIRE(reg.principal == user); + REQUIRE(reg.displayName == "Carol"); + + auto good = await(auth.execute(bank::dto::LoginRequest{.username = user, .password = "hunter2"}), + app.guiLoop()); + REQUIRE(good.ok); + REQUIRE(good.principal == user); + + auto bad = await(auth.execute(bank::dto::LoginRequest{.username = user, .password = "wrong"}), + app.guiLoop()); + REQUIRE_FALSE(bad.ok); + } + + SECTION("registering a duplicate username is rejected") { + const std::string dupe = user + "-dupe"; + await(auth.execute(bank::dto::RegisterUser{.username = dupe, .password = "pass1"}), app.guiLoop()); + auto second = + await(auth.execute(bank::dto::RegisterUser{.username = dupe, .password = "pass2"}), app.guiLoop()); + REQUIRE_FALSE(second.ok); + } + + SECTION("a short password fails validation") { + REQUIRE_THROWS_AS( + await(auth.execute(bank::dto::RegisterUser{.username = user + "-x", .password = "no"}), + app.guiLoop()), + bank::ValidationError); + } + + SECTION("changing the password requires the old one") { + const std::string cpUser = user + "-cp"; + await(auth.execute(bank::dto::RegisterUser{.username = cpUser, .password = "first"}), app.guiLoop()); + + REQUIRE_THROWS_AS(await(auth.execute(bank::dto::ChangePassword{.username = cpUser, + .oldPassword = "nope", + .newPassword = "second"}), + app.guiLoop()), + bank::Unauthorized); + + auto ok = await(auth.execute(bank::dto::ChangePassword{.username = cpUser, + .oldPassword = "first", + .newPassword = "second"}), + app.guiLoop()); + REQUIRE(ok.ok); + + auto login = + await(auth.execute(bank::dto::LoginRequest{.username = cpUser, .password = "second"}), app.guiLoop()); + REQUIRE(login.ok); + } +} + +TEST_CASE("AuthModel WhoAmI reflects the bridge session", "[auth]") { + bank::app::App app{testConnection()}; + morph::bridge::BridgeHandler auth{app.bridge(), app.gui()}; + + auto anon = await(auth.execute(bank::dto::WhoAmI{}), app.guiLoop()); + REQUIRE_FALSE(anon.authenticated); + + app.login("dave-session"); + auto known = await(auth.execute(bank::dto::WhoAmI{}), app.guiLoop()); + REQUIRE(known.authenticated); + REQUIRE(known.principal == "dave-session"); +} diff --git a/examples/bank/tests/test_budget.cpp b/examples/bank/tests/test_budget.cpp new file mode 100644 index 0000000..a5f9b7f --- /dev/null +++ b/examples/bank/tests/test_budget.cpp @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include + +#include "bank/app/app.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/budget_dto.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/budget_model.hpp" +#include "bank/models/transaction_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; + +namespace { + +std::string testConnection() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +} // namespace + +TEST_CASE("BudgetModel upserts budgets and computes spending", "[budget]") { + bank::app::App app{testConnection()}; + app.login("laura-budget"); + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler budgets{app.bridge(), app.gui()}; + + SECTION("setting the same category twice updates rather than duplicates") { + await(budgets.execute(bank::dto::SetBudget{.category = "groceries", .monthlyLimitMinor = 30000}), + app.guiLoop()); + await(budgets.execute(bank::dto::SetBudget{.category = "groceries", .monthlyLimitMinor = 45000}), + app.guiLoop()); + + auto list = await(budgets.execute(bank::dto::ListBudgets{}), app.guiLoop()); + int groceries = 0; + for (const auto& budget : list.budgets) { + if (budget.category == "groceries") { + ++groceries; + REQUIRE(budget.monthlyLimitMinor == 45000); + } + } + REQUIRE(groceries == 1); + } + + SECTION("spending-by-kind sums debits from the ledger") { + const auto acct = + await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; + await(txns.execute(bank::dto::Deposit{.accountId = acct, .amountMinor = 100000}), app.guiLoop()); + await(txns.execute(bank::dto::Withdraw{.accountId = acct, .amountMinor = 3000}), app.guiLoop()); + await(txns.execute(bank::dto::Withdraw{.accountId = acct, .amountMinor = 2000}), app.guiLoop()); + + auto report = await(budgets.execute(bank::dto::SpendingByKind{.accountId = acct}), app.guiLoop()); + REQUIRE(report.totalDebitsMinor == 5000); + bool sawWithdrawals = false; + for (const auto& spend : report.byKind) { + if (spend.kind == static_cast(bank::TxnKind::Withdrawal)) { + sawWithdrawals = true; + REQUIRE(spend.totalMinor == 5000); + REQUIRE(spend.count == 2); + } + } + REQUIRE(sawWithdrawals); + } +} diff --git a/examples/bank/tests/test_card.cpp b/examples/bank/tests/test_card.cpp new file mode 100644 index 0000000..bcfefd0 --- /dev/null +++ b/examples/bank/tests/test_card.cpp @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include + +#include "bank/app/app.hpp" +#include "bank/core/errors.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/card_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/card_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; + +namespace { + +std::string testConnection() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +} // namespace + +TEST_CASE("CardModel issues and manages cards", "[card]") { + bank::app::App app{testConnection()}; + app.login("judy-card"); + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler cards{app.bridge(), app.gui()}; + + const auto account = + await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; + + SECTION("issuing a card returns an active card with last-4") { + auto card = await(cards.execute(bank::dto::IssueCard{.accountId = account, + .kind = static_cast(bank::CardKind::Debit), + .dailyLimitMinor = 100000}), + app.guiLoop()); + REQUIRE(card.id > 0); + REQUIRE(card.status == static_cast(bank::CardStatus::Active)); + REQUIRE(card.panLast4.size() == 4); + } + + SECTION("freeze/unfreeze and limit/pin changes work") { + auto card = + await(cards.execute(bank::dto::IssueCard{.accountId = account, .kind = 0}), app.guiLoop()); + + REQUIRE(await(cards.execute(bank::dto::FreezeCard{.id = card.id}), app.guiLoop()).ok); + REQUIRE(await(cards.execute(bank::dto::UnfreezeCard{.id = card.id}), app.guiLoop()).ok); + REQUIRE(await(cards.execute(bank::dto::SetCardLimit{.id = card.id, .dailyLimitMinor = 5000}), + app.guiLoop()) + .ok); + REQUIRE( + await(cards.execute(bank::dto::ChangePin{.id = card.id, .newPin = "1357"}), app.guiLoop()).ok); + } + + SECTION("a 3-digit PIN is rejected and cancelled cards cannot be unfrozen") { + auto card = + await(cards.execute(bank::dto::IssueCard{.accountId = account, .kind = 0}), app.guiLoop()); + REQUIRE_THROWS_AS( + await(cards.execute(bank::dto::ChangePin{.id = card.id, .newPin = "12"}), app.guiLoop()), + bank::ValidationError); + + await(cards.execute(bank::dto::CancelCard{.id = card.id}), app.guiLoop()); + REQUIRE_THROWS_AS(await(cards.execute(bank::dto::UnfreezeCard{.id = card.id}), app.guiLoop()), + bank::ConflictError); + } +} diff --git a/examples/bank/tests/test_loan.cpp b/examples/bank/tests/test_loan.cpp new file mode 100644 index 0000000..b31e7ef --- /dev/null +++ b/examples/bank/tests/test_loan.cpp @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include + +#include "bank/app/app.hpp" +#include "bank/core/errors.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/loan_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/loan_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; + +namespace { + +std::string testConnection() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +} // namespace + +TEST_CASE("LoanModel disburses, schedules, and repays", "[loan]") { + bank::app::App app{testConnection()}; + app.login("ken-loan"); + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler loans{app.bridge(), app.gui()}; + + const auto account = + await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; + + SECTION("applying disburses the principal into the account") { + auto loan = await(loans.execute(bank::dto::ApplyLoan{.accountId = account, + .principalMinor = 1200000, + .rateBps = 600, + .termMonths = 12}), + app.guiLoop()); + REQUIRE(loan.outstandingMinor == 1200000); + REQUIRE(loan.status == static_cast(bank::LoanStatus::Active)); + + auto acct = await(accounts.execute(bank::dto::GetAccount{.id = account}), app.guiLoop()); + REQUIRE(acct.balanceMinor == 1200000); + } + + SECTION("the amortization schedule covers the term and clears the balance") { + auto loan = await(loans.execute(bank::dto::ApplyLoan{.accountId = account, + .principalMinor = 1200000, + .rateBps = 600, + .termMonths = 12}), + app.guiLoop()); + auto schedule = + await(loans.execute(bank::dto::LoanScheduleRequest{.loanId = loan.id}), app.guiLoop()); + REQUIRE(schedule.installments.size() == 12); + REQUIRE(schedule.monthlyPaymentMinor > 0); + REQUIRE(schedule.installments.back().remainingMinor == 0); + } + + SECTION("repaying the full balance marks the loan paid off") { + auto loan = await(loans.execute(bank::dto::ApplyLoan{.accountId = account, + .principalMinor = 100000, + .rateBps = 0, + .termMonths = 6}), + app.guiLoop()); + auto after = await(loans.execute(bank::dto::RepayLoan{.loanId = loan.id, + .fromAccountId = account, + .amountMinor = 100000}), + app.guiLoop()); + REQUIRE(after.outstandingMinor == 0); + REQUIRE(after.status == static_cast(bank::LoanStatus::PaidOff)); + } +} diff --git a/examples/bank/tests/test_notification.cpp b/examples/bank/tests/test_notification.cpp new file mode 100644 index 0000000..85c9340 --- /dev/null +++ b/examples/bank/tests/test_notification.cpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include + +#include "bank/app/app.hpp" +#include "bank/dto/notification_dto.hpp" +#include "bank/models/notification_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; + +namespace { + +std::string testConnection() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +} // namespace + +TEST_CASE("NotificationModel posts, lists, and marks read", "[notification]") { + bank::app::App app{testConnection()}; + app.login("mike-notify"); + morph::bridge::BridgeHandler notes{app.bridge(), app.gui()}; + + auto first = await(notes.execute(bank::dto::Notify{.message = "Low balance", .severity = 1}), + app.guiLoop()); + await(notes.execute(bank::dto::Notify{.message = "Large transaction", .severity = 2}), app.guiLoop()); + + SECTION("unread count and unread-only filter reflect state") { + auto all = await(notes.execute(bank::dto::ListNotifications{}), app.guiLoop()); + REQUIRE(all.unreadCount == 2); + + await(notes.execute(bank::dto::MarkRead{.id = first.id}), app.guiLoop()); + auto unread = + await(notes.execute(bank::dto::ListNotifications{.unreadOnly = true}), app.guiLoop()); + REQUIRE(unread.notifications.size() == 1); + } + + SECTION("mark-all-read clears the unread count") { + await(notes.execute(bank::dto::MarkAllRead{}), app.guiLoop()); + auto after = await(notes.execute(bank::dto::ListNotifications{}), app.guiLoop()); + REQUIRE(after.unreadCount == 0); + } +} diff --git a/examples/bank/tests/test_offline.cpp b/examples/bank/tests/test_offline.cpp new file mode 100644 index 0000000..89ee11b --- /dev/null +++ b/examples/bank/tests/test_offline.cpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Demonstrates the morph offline stack with real bank actions: while +// "offline", actions are serialised (via the same ActionTraits codec the wire +// uses) and parked in an IOfflineQueue. On "reconnect", a SyncWorker drains the +// queue and replays each action through the live bridge handler. + +#include + +#include +#include +#include +#include + +#include +#include + +#include "bank/app/app.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/transaction_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; + +namespace { + +std::string testConnection() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +} // namespace + +TEST_CASE("Offline deposits are queued and replayed on reconnect", "[offline]") { + bank::app::App app{testConnection()}; + app.login("peter-offline"); + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; + + const auto acct = + await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; + + // --- While "offline": park deposits in the durable queue instead of sending. + morph::offline::InMemoryOfflineQueue queue; + using Codec = morph::model::ActionTraits; + queue.enqueue(Codec::toJson(bank::dto::Deposit{.accountId = acct, .amountMinor = 1500})); + queue.enqueue(Codec::toJson(bank::dto::Deposit{.accountId = acct, .amountMinor = 2500})); + + // Nothing has been applied yet. + REQUIRE(await(accounts.execute(bank::dto::GetAccount{.id = acct}), app.guiLoop()).balanceMinor == 0); + + // --- On "reconnect": drain the queue, replaying each action via the bridge. + morph::offline::SyncWorker worker{queue, [&](const std::string& payload) -> bool { + try { + await(txns.execute(Codec::fromJson(payload)), app.guiLoop()); + return true; + } catch (...) { + return false; + } + }}; + auto result = worker.run(); + + REQUIRE(result.successful == 2); + REQUIRE(result.failed == 0); + REQUIRE(await(accounts.execute(bank::dto::GetAccount{.id = acct}), app.guiLoop()).balanceMinor == 4000); +} diff --git a/examples/bank/tests/test_payee.cpp b/examples/bank/tests/test_payee.cpp new file mode 100644 index 0000000..0c9a121 --- /dev/null +++ b/examples/bank/tests/test_payee.cpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include +#include + +#include "bank/app/app.hpp" +#include "bank/core/errors.hpp" +#include "bank/dto/payee_dto.hpp" +#include "bank/models/payee_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; +using bank::testing::waitUntil; + +namespace { + +std::string testConnection() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +} // namespace + +TEST_CASE("PayeeModel add/list/remove scoped to the owner", "[payee]") { + bank::app::App app{testConnection()}; + app.login("grace-payee"); + morph::bridge::BridgeHandler payees{app.bridge(), app.gui()}; + + SECTION("a valid payee is saved and listed") { + auto added = await(payees.execute(bank::dto::AddPayee{.name = "Landlord", + .iban = "DE89370400440532013000", + .bankName = "Deutsche Bank"}), + app.guiLoop()); + REQUIRE(added.id > 0); + REQUIRE(added.owner == "grace-payee"); + + auto list = await(payees.execute(bank::dto::ListPayees{}), app.guiLoop()); + REQUIRE_FALSE(list.payees.empty()); + } + + SECTION("an implausible IBAN is rejected") { + REQUIRE_THROWS_AS( + await(payees.execute(bank::dto::AddPayee{.name = "Bad", .iban = "123"}), app.guiLoop()), + bank::ValidationError); + } + + SECTION("removing a payee succeeds") { + auto added = await(payees.execute(bank::dto::AddPayee{.name = "Utility", + .iban = "GB29NWBK60161331926819"}), + app.guiLoop()); + auto removed = await(payees.execute(bank::dto::RemovePayee{.id = added.id}), app.guiLoop()); + REQUIRE(removed.ok); + } +} + +TEST_CASE("PayeeModel form-style streaming via subscribe/set", "[payee][subscribe]") { + bank::app::App app{testConnection()}; + app.login("heidi-form"); + morph::bridge::BridgeHandler payees{app.bridge(), app.gui()}; + + std::atomic fired{false}; + std::int64_t newId = 0; + payees.subscribe([&](bank::dto::PayeeInfo info) { + newId = info.id; + fired.store(true); + }); + + // Setting the name alone does not satisfy validate() (no IBAN yet)... + payees.set<&bank::dto::AddPayee::name>("Streamed Payee"); + app.guiLoop().runFor(std::chrono::milliseconds{50}); + REQUIRE_FALSE(fired.load()); + + // ...completing the IBAN makes the draft ready and fires the action. + payees.set<&bank::dto::AddPayee::iban>("FR1420041010050500013M02606"); + REQUIRE(waitUntil([&] { return fired.load(); }, std::chrono::milliseconds{2000}, + std::chrono::milliseconds{5}, app.guiLoop())); + REQUIRE(newId > 0); +} diff --git a/examples/bank/tests/test_payment.cpp b/examples/bank/tests/test_payment.cpp new file mode 100644 index 0000000..e625fd0 --- /dev/null +++ b/examples/bank/tests/test_payment.cpp @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include + +#include "bank/app/app.hpp" +#include "bank/core/errors.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/payee_dto.hpp" +#include "bank/dto/payment_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/payee_model.hpp" +#include "bank/models/payment_model.hpp" +#include "bank/models/transaction_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; + +namespace { + +std::string testConnection() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +} // namespace + +TEST_CASE("PaymentModel pays bills, schedules, and cancels", "[payment]") { + bank::app::App app{testConnection()}; + app.login("ivan-pay"); + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler payees{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler payments{app.bridge(), app.gui()}; + + const auto account = + await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; + await(txns.execute(bank::dto::Deposit{.accountId = account, .amountMinor = 50000}), app.guiLoop()); + const auto payee = await(payees.execute(bank::dto::AddPayee{.name = "Electric Co", + .iban = "DE89370400440532013000"}), + app.guiLoop()) + .id; + + SECTION("paying a bill debits the account and records a completed payment") { + auto info = await(payments.execute(bank::dto::PayBill{.fromAccountId = account, + .payeeId = payee, + .amountMinor = 12000, + .description = "March bill"}), + app.guiLoop()); + REQUIRE(info.status == static_cast(bank::PaymentStatus::Completed)); + + auto acct = await(accounts.execute(bank::dto::GetAccount{.id = account}), app.guiLoop()); + REQUIRE(acct.balanceMinor == 38000); + } + + SECTION("scheduling and a standing order start pending and can be cancelled") { + auto scheduled = await(payments.execute(bank::dto::SchedulePayment{.fromAccountId = account, + .payeeId = payee, + .amountMinor = 1000, + .dueAtMs = 9999999999999}), + app.guiLoop()); + REQUIRE(scheduled.status == static_cast(bank::PaymentStatus::Pending)); + REQUIRE(scheduled.schedule == static_cast(bank::PaymentSchedule::Scheduled)); + + auto standing = await(payments.execute(bank::dto::CreateStandingOrder{.fromAccountId = account, + .payeeId = payee, + .amountMinor = 2000, + .intervalDays = 30, + .firstDueAtMs = 9999999999999}), + app.guiLoop()); + REQUIRE(standing.schedule == static_cast(bank::PaymentSchedule::Standing)); + REQUIRE(standing.intervalDays == 30); + + auto cancelled = + await(payments.execute(bank::dto::CancelPayment{.id = scheduled.id}), app.guiLoop()); + REQUIRE(cancelled.ok); + } + + SECTION("paying more than the balance is rejected") { + REQUIRE_THROWS_AS(await(payments.execute(bank::dto::PayBill{.fromAccountId = account, + .payeeId = payee, + .amountMinor = 999999}), + app.guiLoop()), + bank::InsufficientFunds); + } +} diff --git a/examples/bank/tests/test_remote.cpp b/examples/bank/tests/test_remote.cpp new file mode 100644 index 0000000..8d72e75 --- /dev/null +++ b/examples/bank/tests/test_remote.cpp @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Demonstrates morph's headline property: the *same* models, DTOs, and call +// sites work unchanged over a remote backend. Here we drive AccountModel +// through a SimulatedRemoteBackend (actions are serialised to JSON, dispatched +// on a server, and the typed result comes back), and install a real +// IAuthorizer that rejects one action type. + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; + +namespace { + +std::string testConnection() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +/// Authorizer that forbids closing accounts but allows everything else. +struct NoCloseAuthorizer : morph::session::IAuthorizer { + [[nodiscard]] bool authorize(const morph::session::Context& /*ctx*/, std::string_view /*model*/, + std::string_view actionType) const override { + return actionType != "CloseAccount"; + } +}; + +} // namespace + +TEST_CASE("AccountModel runs unchanged over a remote backend", "[remote]") { + bank::testing::ensureDatabase(); + (void)testConnection(); // ensure the shared DB is configured + + morph::exec::ThreadPoolExecutor serverPool{2}; + morph::exec::MainThreadExecutor gui; + + auto server = std::make_shared(serverPool, + std::make_shared()); + morph::bridge::Bridge bridge{std::make_unique(*server)}; + + morph::session::Context ctx; + ctx.principal = "olivia-remote"; + bridge.setDefaultSession(ctx); + + morph::bridge::BridgeHandler accounts{bridge, &gui}; + + SECTION("opening an account round-trips through JSON serialisation") { + auto info = await(accounts.execute(bank::dto::OpenAccount{ + .kind = static_cast(bank::AccountKind::Savings), + .currency = static_cast(bank::Currency::GBP), + }), + gui); + REQUIRE(info.id > 0); + REQUIRE(info.owner == "olivia-remote"); + REQUIRE(info.currency == static_cast(bank::Currency::GBP)); + } + + SECTION("the authorizer rejects the forbidden action") { + auto info = + await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), gui); + REQUIRE_THROWS(await(accounts.execute(bank::dto::CloseAccount{.id = info.id}), gui)); + } +} diff --git a/examples/bank/tests/test_statement.cpp b/examples/bank/tests/test_statement.cpp new file mode 100644 index 0000000..bc003ac --- /dev/null +++ b/examples/bank/tests/test_statement.cpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include + +#include "bank/app/app.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/statement_dto.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/statement_model.hpp" +#include "bank/models/transaction_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; + +namespace { + +std::string testConnection() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +} // namespace + +TEST_CASE("StatementModel aggregates credits and debits across accounts", "[statement]") { + bank::app::App app{testConnection()}; + app.login("nina-stmt"); + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler statements{app.bridge(), app.gui()}; + + const auto acctA = + await(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0}), app.guiLoop()).id; + const auto acctB = + await(accounts.execute(bank::dto::OpenAccount{.kind = 1, .currency = 0}), app.guiLoop()).id; + + await(txns.execute(bank::dto::Deposit{.accountId = acctA, .amountMinor = 10000}), app.guiLoop()); + await(txns.execute(bank::dto::Withdraw{.accountId = acctA, .amountMinor = 2500}), app.guiLoop()); + await(txns.execute(bank::dto::Deposit{.accountId = acctB, .amountMinor = 7000}), app.guiLoop()); + + auto stmt = await(statements.execute(bank::dto::GenerateStatement{}), app.guiLoop()); + + REQUIRE(stmt.owner == "nina-stmt"); + REQUIRE(stmt.lines.size() >= 2); + REQUIRE(stmt.totalCreditsMinor == 17000); + REQUIRE(stmt.totalDebitsMinor == 2500); +} diff --git a/examples/bank/tests/test_transaction.cpp b/examples/bank/tests/test_transaction.cpp new file mode 100644 index 0000000..f3bb837 --- /dev/null +++ b/examples/bank/tests/test_transaction.cpp @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include + +#include "bank/app/app.hpp" +#include "bank/core/errors.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/transaction_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; + +namespace { + +std::string testConnection() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +/// Opens a fresh checking account in the given currency and returns its id. +std::int64_t openAccount(bank::app::App& app, morph::bridge::BridgeHandler& accounts, + bank::Currency currency = bank::Currency::USD) { + auto info = await(accounts.execute(bank::dto::OpenAccount{ + .kind = static_cast(bank::AccountKind::Checking), + .currency = static_cast(currency), + .overdraftMinor = 0, + }), + app.guiLoop()); + return info.id; +} + +} // namespace + +TEST_CASE("TransactionModel deposit / withdraw adjust balances and ledger", "[transaction]") { + bank::app::App app{testConnection()}; + app.login("erin-txn"); + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; + + const std::int64_t acct = openAccount(app, accounts); + + SECTION("deposit increases the balance and records a credit") { + auto entry = await(txns.execute(bank::dto::Deposit{.accountId = acct, .amountMinor = 10000, + .description = "paycheck"}), + app.guiLoop()); + REQUIRE(entry.balanceAfterMinor == 10000); + REQUIRE(entry.direction == static_cast(bank::TxnDirection::Credit)); + REQUIRE(entry.kind == static_cast(bank::TxnKind::Deposit)); + + auto fetched = await(accounts.execute(bank::dto::GetAccount{.id = acct}), app.guiLoop()); + REQUIRE(fetched.balanceMinor == 10000); + } + + SECTION("withdrawal beyond balance+overdraft is rejected") { + await(txns.execute(bank::dto::Deposit{.accountId = acct, .amountMinor = 5000}), app.guiLoop()); + REQUIRE_THROWS_AS( + await(txns.execute(bank::dto::Withdraw{.accountId = acct, .amountMinor = 6000}), app.guiLoop()), + bank::InsufficientFunds); + } + + SECTION("history returns entries newest-first") { + await(txns.execute(bank::dto::Deposit{.accountId = acct, .amountMinor = 100}), app.guiLoop()); + await(txns.execute(bank::dto::Deposit{.accountId = acct, .amountMinor = 200}), app.guiLoop()); + await(txns.execute(bank::dto::Withdraw{.accountId = acct, .amountMinor = 50}), app.guiLoop()); + + auto page = await(txns.execute(bank::dto::History{.accountId = acct, .limit = 10}), app.guiLoop()); + REQUIRE(page.entries.size() == 3); + REQUIRE(page.entries.front().id > page.entries.back().id); // newest first + } +} + +TEST_CASE("TransactionModel transfer is atomic and balance-preserving", "[transaction]") { + bank::app::App app{testConnection()}; + app.login("frank-transfer"); + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; + + const std::int64_t src = openAccount(app, accounts); + const std::int64_t dst = openAccount(app, accounts); + await(txns.execute(bank::dto::Deposit{.accountId = src, .amountMinor = 10000}), app.guiLoop()); + + SECTION("a valid transfer moves money and conserves the total") { + auto result = await(txns.execute(bank::dto::Transfer{.fromAccountId = src, + .toAccountId = dst, + .amountMinor = 4000, + .description = "rent"}), + app.guiLoop()); + REQUIRE(result.fromBalanceMinor == 6000); + REQUIRE(result.toBalanceMinor == 4000); + REQUIRE(result.fromBalanceMinor + result.toBalanceMinor == 10000); + } + + SECTION("transferring to the same account fails validation") { + REQUIRE_THROWS_AS(await(txns.execute(bank::dto::Transfer{.fromAccountId = src, + .toAccountId = src, + .amountMinor = 100}), + app.guiLoop()), + bank::ValidationError); + } + + SECTION("an over-balance transfer leaves both balances untouched") { + REQUIRE_THROWS_AS(await(txns.execute(bank::dto::Transfer{.fromAccountId = src, + .toAccountId = dst, + .amountMinor = 999999}), + app.guiLoop()), + bank::InsufficientFunds); + auto srcInfo = await(accounts.execute(bank::dto::GetAccount{.id = src}), app.guiLoop()); + auto dstInfo = await(accounts.execute(bank::dto::GetAccount{.id = dst}), app.guiLoop()); + REQUIRE(srcInfo.balanceMinor == 10000); + REQUIRE(dstInfo.balanceMinor == 0); + } +} From bda7a63bb354029124853260de30dc78569391e2 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 29 Jun 2026 21:23:03 +0200 Subject: [PATCH 02/10] [morph] bank example: rework GUI from Qt Widgets to QML (Qt Quick) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Qt Widgets GUI with a QML (Qt Quick) front-end, per request. - BankClient is unchanged (bridge + QtExecutor, UI-agnostic). - New per-domain QObject controllers (gui/controllers/) exposed to QML as context properties (app, accounts, txns, cards, payees, loans). Each calls BridgeHandler.execute(...).then(...) — callbacks land on the Qt GUI thread via QtExecutor — and publishes display-ready QVariantList properties plus an error(QString) signal. Heavy morph/Lightweight includes are hidden from moc behind #ifndef Q_MOC_RUN (moc follows includes and trips on them). - QML front-end (gui/qml/) via qt_add_qml_module (URI BankGui): Main (login ⇄ shell + error toast), AppShell (sidebar + stacked pages), the five pages, and reusable components (Panel, AppButton, Field, Pill, Picker). The warm "Claude-inspired" palette is passed from main.cpp as the `theme` object. - CMake switches to qt_add_executable + qt_add_qml_module, linking Qt6 Quick / Qml / QuickControls2. main.cpp uses QQmlApplicationEngine + QQuickStyle Basic. - Headless screenshot smoke (BANK_GUI_SMOKE=, QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software) seeds data, signs in, and grabs each page. Verified: builds clean against Qt 6.11; all six screens render correctly headless. The models/tests/CLI are unchanged (still 101 assertions, 16 cases). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FXnfVmdiHoCRGzj8PtuAJq --- examples/bank/README.md | 35 ++- examples/bank/gui/CMakeLists.txt | 44 +++- examples/bank/gui/LoginView.cpp | 107 --------- examples/bank/gui/LoginView.hpp | 40 ---- examples/bank/gui/MainWindow.cpp | 111 --------- examples/bank/gui/MainWindow.hpp | 43 ---- examples/bank/gui/Page.hpp | 17 -- examples/bank/gui/Smoke.hpp | 117 --------- examples/bank/gui/Theme.hpp | 201 ---------------- examples/bank/gui/Ui.hpp | 113 --------- .../gui/controllers/AccountController.cpp | 70 ++++++ .../gui/controllers/AccountController.hpp | 46 ++++ .../bank/gui/controllers/AppController.cpp | 59 +++++ .../bank/gui/controllers/AppController.hpp | 51 ++++ .../bank/gui/controllers/BankController.cpp | 20 ++ .../bank/gui/controllers/BankController.hpp | 31 +++ .../bank/gui/controllers/CardController.cpp | 105 ++++++++ .../bank/gui/controllers/CardController.hpp | 53 +++++ examples/bank/gui/controllers/Format.hpp | 68 ++++++ .../bank/gui/controllers/LoanController.cpp | 113 +++++++++ .../bank/gui/controllers/LoanController.hpp | 56 +++++ .../bank/gui/controllers/PayeeController.cpp | 87 +++++++ .../bank/gui/controllers/PayeeController.hpp | 55 +++++ .../gui/controllers/TransactionController.cpp | 128 ++++++++++ .../gui/controllers/TransactionController.hpp | 59 +++++ examples/bank/gui/main.cpp | 149 +++++++++--- examples/bank/gui/qml/AccountsPage.qml | 96 ++++++++ examples/bank/gui/qml/AppButton.qml | 37 +++ examples/bank/gui/qml/AppShell.qml | 126 ++++++++++ examples/bank/gui/qml/CardsPage.qml | 73 ++++++ examples/bank/gui/qml/Field.qml | 23 ++ examples/bank/gui/qml/LoansPage.qml | 115 +++++++++ examples/bank/gui/qml/Login.qml | 61 +++++ examples/bank/gui/qml/Main.qml | 55 +++++ examples/bank/gui/qml/MoveMoneyPage.qml | 98 ++++++++ examples/bank/gui/qml/Panel.qml | 10 + examples/bank/gui/qml/PayeesPage.qml | 81 +++++++ examples/bank/gui/qml/Picker.qml | 27 +++ examples/bank/gui/qml/Pill.qml | 29 +++ examples/bank/gui/views/AccountsView.cpp | 177 -------------- examples/bank/gui/views/AccountsView.hpp | 41 ---- examples/bank/gui/views/CardsView.cpp | 164 ------------- examples/bank/gui/views/CardsView.hpp | 39 --- examples/bank/gui/views/LoansView.cpp | 219 ----------------- examples/bank/gui/views/LoansView.hpp | 46 ---- examples/bank/gui/views/MoveMoneyView.cpp | 225 ------------------ examples/bank/gui/views/MoveMoneyView.hpp | 46 ---- examples/bank/gui/views/PayeesView.cpp | 175 -------------- examples/bank/gui/views/PayeesView.hpp | 48 ---- 49 files changed, 2005 insertions(+), 1984 deletions(-) delete mode 100644 examples/bank/gui/LoginView.cpp delete mode 100644 examples/bank/gui/LoginView.hpp delete mode 100644 examples/bank/gui/MainWindow.cpp delete mode 100644 examples/bank/gui/MainWindow.hpp delete mode 100644 examples/bank/gui/Page.hpp delete mode 100644 examples/bank/gui/Smoke.hpp delete mode 100644 examples/bank/gui/Theme.hpp delete mode 100644 examples/bank/gui/Ui.hpp create mode 100644 examples/bank/gui/controllers/AccountController.cpp create mode 100644 examples/bank/gui/controllers/AccountController.hpp create mode 100644 examples/bank/gui/controllers/AppController.cpp create mode 100644 examples/bank/gui/controllers/AppController.hpp create mode 100644 examples/bank/gui/controllers/BankController.cpp create mode 100644 examples/bank/gui/controllers/BankController.hpp create mode 100644 examples/bank/gui/controllers/CardController.cpp create mode 100644 examples/bank/gui/controllers/CardController.hpp create mode 100644 examples/bank/gui/controllers/Format.hpp create mode 100644 examples/bank/gui/controllers/LoanController.cpp create mode 100644 examples/bank/gui/controllers/LoanController.hpp create mode 100644 examples/bank/gui/controllers/PayeeController.cpp create mode 100644 examples/bank/gui/controllers/PayeeController.hpp create mode 100644 examples/bank/gui/controllers/TransactionController.cpp create mode 100644 examples/bank/gui/controllers/TransactionController.hpp create mode 100644 examples/bank/gui/qml/AccountsPage.qml create mode 100644 examples/bank/gui/qml/AppButton.qml create mode 100644 examples/bank/gui/qml/AppShell.qml create mode 100644 examples/bank/gui/qml/CardsPage.qml create mode 100644 examples/bank/gui/qml/Field.qml create mode 100644 examples/bank/gui/qml/LoansPage.qml create mode 100644 examples/bank/gui/qml/Login.qml create mode 100644 examples/bank/gui/qml/Main.qml create mode 100644 examples/bank/gui/qml/MoveMoneyPage.qml create mode 100644 examples/bank/gui/qml/Panel.qml create mode 100644 examples/bank/gui/qml/PayeesPage.qml create mode 100644 examples/bank/gui/qml/Picker.qml create mode 100644 examples/bank/gui/qml/Pill.qml delete mode 100644 examples/bank/gui/views/AccountsView.cpp delete mode 100644 examples/bank/gui/views/AccountsView.hpp delete mode 100644 examples/bank/gui/views/CardsView.cpp delete mode 100644 examples/bank/gui/views/CardsView.hpp delete mode 100644 examples/bank/gui/views/LoansView.cpp delete mode 100644 examples/bank/gui/views/LoansView.hpp delete mode 100644 examples/bank/gui/views/MoveMoneyView.cpp delete mode 100644 examples/bank/gui/views/MoveMoneyView.hpp delete mode 100644 examples/bank/gui/views/PayeesView.cpp delete mode 100644 examples/bank/gui/views/PayeesView.hpp diff --git a/examples/bank/README.md b/examples/bank/README.md index 6626b2a..5f20079 100644 --- a/examples/bank/README.md +++ b/examples/bank/README.md @@ -87,10 +87,10 @@ registered with unixODBC (the connection string is `DRIVER=SQLite3;Database=…` ./build/examples/bank/bank_cli ``` -### Qt 6 GUI +### Qt 6 QML GUI -A desktop GUI (`gui/`) is built when `-DMORPH_BUILD_BANK_GUI=ON` is also passed -(requires Qt 6 Widgets): +A **QML (Qt Quick)** desktop GUI (`gui/`) is built when `-DMORPH_BUILD_BANK_GUI=ON` +is also passed (requires Qt 6 Quick + Quick Controls 2): ```sh cmake -G Ninja -B build -S . -DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_BANK_GUI=ON @@ -98,14 +98,27 @@ cmake --build build --target bank_gui ./build/examples/bank/gui/bank_gui ``` -It binds to the models through `morph::qt::QtExecutor` over a local backend, so -completion callbacks land on the Qt GUI thread — the views never touch threads. -The design is a warm, "Claude-inspired" theme (paper background, clay accent, -soft cards, dark sidebar) defined entirely in `gui/Theme.hpp`. Screens: Login, -Accounts (dashboard), Move Money (+ history), Cards, Payees & Bills, and Loans -(with amortization schedule). Each `Page` reloads its data from the models when -shown. A headless screenshot smoke test runs when `BANK_GUI_SMOKE=` is set -(uses `QT_QPA_PLATFORM=offscreen`). +Structure: + +- **`gui/BankClient`** — owns the worker pool, a `morph::qt::QtExecutor`, the + `Bridge` (local backend), DB setup, and the session. UI-toolkit-agnostic. +- **`gui/controllers/`** — one QObject controller per domain (`AppController`, + `AccountController`, …), exposed to QML as context properties (`app`, + `accounts`, `txns`, `cards`, `payees`, `loans`). Each calls + `BridgeHandler.execute(...).then(...)` — callbacks land on the Qt GUI + thread via `QtExecutor` — and publishes display-ready data as `Q_PROPERTY` + `QVariantList`s (money pre-formatted), plus an `error(QString)` signal. The + heavy morph/Lightweight includes are hidden from `moc` behind `#ifndef + Q_MOC_RUN` (moc follows includes and its parser trips on them). +- **`gui/qml/`** — the front-end: `Main` (login ⇄ shell + error toast), + `AppShell` (sidebar + stacked pages), the five pages, and reusable components + (`Panel`, `AppButton`, `Field`, `Pill`, `Picker`). Bundled via + `qt_add_qml_module` (URI `BankGui`). The warm, "Claude-inspired" palette is + passed in from `main.cpp` as the `theme` object. + +A headless screenshot smoke test runs when `BANK_GUI_SMOKE=` is set (with +`QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software`): it seeds data, signs in, +and grabs a PNG of each page. ## Tests diff --git a/examples/bank/gui/CMakeLists.txt b/examples/bank/gui/CMakeLists.txt index c63d4a6..30b4a88 100644 --- a/examples/bank/gui/CMakeLists.txt +++ b/examples/bank/gui/CMakeLists.txt @@ -1,22 +1,42 @@ # SPDX-License-Identifier: Apache-2.0 # -# Qt 6 GUI for the bank example. Built when -DMORPH_BUILD_BANK_GUI=ON. +# QML (Qt Quick) GUI for the bank example. Built when -DMORPH_BUILD_BANK_GUI=ON. -find_package(Qt6 REQUIRED COMPONENTS Widgets) +find_package(Qt6 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2) -set(CMAKE_AUTOMOC ON) +qt_standard_project_setup(REQUIRES 6.5) -add_executable(bank_gui +qt_add_executable(bank_gui main.cpp BankClient.cpp - LoginView.cpp - MainWindow.cpp - views/AccountsView.cpp - views/MoveMoneyView.cpp - views/CardsView.cpp - views/PayeesView.cpp - views/LoansView.cpp + controllers/BankController.cpp + controllers/AppController.cpp + controllers/AccountController.cpp + controllers/TransactionController.cpp + controllers/CardController.cpp + controllers/PayeeController.cpp + controllers/LoanController.cpp ) + +qt_add_qml_module(bank_gui + URI BankGui + VERSION 1.0 + QML_FILES + qml/Main.qml + qml/Login.qml + qml/AppShell.qml + qml/AccountsPage.qml + qml/MoveMoneyPage.qml + qml/CardsPage.qml + qml/PayeesPage.qml + qml/LoansPage.qml + qml/Panel.qml + qml/AppButton.qml + qml/Field.qml + qml/Pill.qml + qml/Picker.qml +) + target_include_directories(bank_gui PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(bank_gui PRIVATE bank_lib Qt6::Widgets) +target_link_libraries(bank_gui PRIVATE bank_lib Qt6::Quick Qt6::Qml Qt6::QuickControls2) target_compile_features(bank_gui PRIVATE cxx_std_23) diff --git a/examples/bank/gui/LoginView.cpp b/examples/bank/gui/LoginView.cpp deleted file mode 100644 index b6f2d70..0000000 --- a/examples/bank/gui/LoginView.cpp +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#include "LoginView.hpp" - -#include -#include -#include -#include - -#include "Ui.hpp" -#include "bank/dto/auth_dto.hpp" - -namespace bankgui { - -LoginView::LoginView(BankClient& client, QWidget* parent) - : QWidget(parent), _client{client}, _auth{client.bridge(), client.gui()} { - setObjectName(QStringLiteral("Root")); - - auto* card = ui::card(); - card->setFixedWidth(380); - auto* form = new QVBoxLayout(card); - form->setContentsMargins(32, 32, 32, 32); - form->setSpacing(12); - - auto* brand = ui::label(QStringLiteral("Morph Bank"), QStringLiteral("H1")); - auto* subtitle = ui::label(QStringLiteral("Sign in to your account"), QStringLiteral("Muted")); - - _username = new QLineEdit; - _username->setPlaceholderText(QStringLiteral("Username")); - _password = new QLineEdit; - _password->setPlaceholderText(QStringLiteral("Password")); - _password->setEchoMode(QLineEdit::Password); - _displayName = new QLineEdit; - _displayName->setPlaceholderText(QStringLiteral("Display name (for new accounts)")); - - _error = ui::label(QString(), QStringLiteral("Danger")); - _error->setWordWrap(true); - _error->hide(); - - auto* signIn = ui::button(QStringLiteral("Sign in"), QStringLiteral("primary")); - auto* create = ui::button(QStringLiteral("Create account"), QStringLiteral("ghost")); - - form->addWidget(brand); - form->addWidget(subtitle); - form->addSpacing(8); - form->addWidget(_username); - form->addWidget(_password); - form->addWidget(_displayName); - form->addWidget(_error); - form->addSpacing(4); - form->addWidget(signIn); - form->addWidget(create, 0, Qt::AlignHCenter); - - QObject::connect(signIn, &QPushButton::clicked, this, [this] { attemptLogin(); }); - QObject::connect(create, &QPushButton::clicked, this, [this] { attemptRegister(); }); - QObject::connect(_password, &QLineEdit::returnPressed, this, [this] { attemptLogin(); }); - - // Centre the card on the paper background. - auto* outer = new QVBoxLayout(this); - outer->addStretch(); - auto* row = new QHBoxLayout; - row->addStretch(); - row->addWidget(card); - row->addStretch(); - outer->addLayout(row); - outer->addStretch(); -} - -void LoginView::setError(const QString& message) { - _error->setText(message); - _error->setVisible(!message.isEmpty()); -} - -void LoginView::attemptLogin() { - setError({}); - const QString user = _username->text().trimmed(); - _auth.execute(bank::dto::LoginRequest{.username = user.toStdString(), - .password = _password->text().toStdString()}) - .then([this, user](bank::dto::AuthResult result) { - if (result.ok && onAuthenticated) { - onAuthenticated(QString::fromStdString(result.principal), - QString::fromStdString(result.displayName)); - } else if (!result.ok) { - setError(QString::fromStdString(result.message)); - } - }) - .onError([this](const std::exception_ptr& err) { setError(ui::errorText(err)); }); -} - -void LoginView::attemptRegister() { - setError({}); - const QString user = _username->text().trimmed(); - _auth.execute(bank::dto::RegisterUser{.username = user.toStdString(), - .password = _password->text().toStdString(), - .displayName = _displayName->text().toStdString()}) - .then([this](bank::dto::AuthResult result) { - if (result.ok && onAuthenticated) { - onAuthenticated(QString::fromStdString(result.principal), - QString::fromStdString(result.displayName)); - } else if (!result.ok) { - setError(QString::fromStdString(result.message)); - } - }) - .onError([this](const std::exception_ptr& err) { setError(ui::errorText(err)); }); -} - -} // namespace bankgui diff --git a/examples/bank/gui/LoginView.hpp b/examples/bank/gui/LoginView.hpp deleted file mode 100644 index b27c541..0000000 --- a/examples/bank/gui/LoginView.hpp +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#pragma once - -#include - -#include - -#include - -#include "BankClient.hpp" -#include "bank/models/auth_model.hpp" - -class QLineEdit; -class QLabel; - -namespace bankgui { - -/// @brief Sign-in / create-account screen. Emits the authenticated principal -/// via the `onAuthenticated` callback. -class LoginView : public QWidget { -public: - explicit LoginView(BankClient& client, QWidget* parent = nullptr); - - /// Invoked with (principal, displayName) once sign-in/registration succeeds. - std::function onAuthenticated; - -private: - void attemptLogin(); - void attemptRegister(); - void setError(const QString& message); - - BankClient& _client; - morph::bridge::BridgeHandler _auth; - QLineEdit* _username{}; - QLineEdit* _password{}; - QLineEdit* _displayName{}; - QLabel* _error{}; -}; - -} // namespace bankgui diff --git a/examples/bank/gui/MainWindow.cpp b/examples/bank/gui/MainWindow.cpp deleted file mode 100644 index d768b2e..0000000 --- a/examples/bank/gui/MainWindow.cpp +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#include "MainWindow.hpp" - -#include -#include -#include -#include -#include -#include - -#include "Ui.hpp" -#include "views/AccountsView.hpp" -#include "views/CardsView.hpp" -#include "views/LoansView.hpp" -#include "views/MoveMoneyView.hpp" -#include "views/PayeesView.hpp" - -namespace bankgui { - -MainWindow::MainWindow(BankClient& client, QWidget* parent) : QWidget(parent), _client{client} { - setObjectName(QStringLiteral("Root")); - auto* root = new QHBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(0); - - // ── Sidebar ────────────────────────────────────────────────────────────── - auto* sidebar = new QWidget; - sidebar->setObjectName(QStringLiteral("Sidebar")); - sidebar->setFixedWidth(232); - auto* side = new QVBoxLayout(sidebar); - side->setContentsMargins(0, 0, 0, 0); - side->setSpacing(0); - - side->addWidget(ui::label(QStringLiteral("Morph Bank"), QStringLiteral("Brand"))); - side->addWidget(ui::label(QStringLiteral("personal banking"), QStringLiteral("BrandSub"))); - - _navGroup = new QButtonGroup(this); - _navGroup->setExclusive(true); - _navLayout = new QVBoxLayout; - _navLayout->setContentsMargins(0, 8, 0, 8); - _navLayout->setSpacing(0); - side->addLayout(_navLayout); - side->addStretch(); - - auto* user = ui::label(_client.displayName(), QStringLiteral("SidebarUser")); - auto* userSub = ui::label(QStringLiteral("@") + _client.principal(), QStringLiteral("SidebarUserSub")); - side->addWidget(user); - side->addWidget(userSub); - auto* logout = ui::button(QStringLiteral("Log out"), QStringLiteral("ghost")); - QObject::connect(logout, &QPushButton::clicked, this, [this] { - if (onLogout) { - onLogout(); - } - }); - side->addWidget(logout, 0, Qt::AlignLeft); - side->addSpacing(12); - - // ── Content area: header + stacked pages ────────────────────────────────── - auto* content = new QVBoxLayout; - content->setContentsMargins(32, 28, 32, 28); - content->setSpacing(20); - _title = ui::label(QString(), QStringLiteral("H1")); - content->addWidget(_title); - _stack = new QStackedWidget; - content->addWidget(_stack, 1); - - root->addWidget(sidebar); - root->addLayout(content, 1); - - // ── Pages ───────────────────────────────────────────────────────────────── - addPage(QStringLiteral("Accounts"), new AccountsView(_client)); - addPage(QStringLiteral("Move Money"), new MoveMoneyView(_client)); - addPage(QStringLiteral("Cards"), new CardsView(_client)); - addPage(QStringLiteral("Payees & Bills"), new PayeesView(_client)); - addPage(QStringLiteral("Loans"), new LoansView(_client)); - - showPage(0); -} - -void MainWindow::addPage(const QString& title, Page* page) { - const int index = _pageCount++; - _stack->addWidget(page); - - // Double any '&' so QPushButton does not treat it as a mnemonic accelerator. - auto* navButton = new QPushButton(QString(title).replace(QLatin1Char('&'), QStringLiteral("&&"))); - navButton->setObjectName(QStringLiteral("NavButton")); - navButton->setCheckable(true); - navButton->setCursor(Qt::PointingHandCursor); - navButton->setProperty("pageTitle", title); - _navGroup->addButton(navButton, index); - _navLayout->addWidget(navButton); - if (index == 0) { - navButton->setChecked(true); - } - QObject::connect(navButton, &QPushButton::clicked, this, [this, index] { showPage(index); }); -} - -void MainWindow::showPage(int index) { - _stack->setCurrentIndex(index); - if (auto* button = _navGroup->button(index)) { - button->setChecked(true); - _title->setText(button->property("pageTitle").toString()); - } - // Every stacked widget is a Page (added only via addPage). - if (auto* page = static_cast(_stack->widget(index))) { - page->refresh(); - } -} - -} // namespace bankgui diff --git a/examples/bank/gui/MainWindow.hpp b/examples/bank/gui/MainWindow.hpp deleted file mode 100644 index d1a6b60..0000000 --- a/examples/bank/gui/MainWindow.hpp +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#pragma once - -#include - -#include - -#include "BankClient.hpp" -#include "Page.hpp" - -class QStackedWidget; -class QVBoxLayout; -class QLabel; -class QButtonGroup; - -namespace bankgui { - -/// @brief The signed-in shell: a sidebar of navigation buttons, a header, and a -/// stacked content area of `Page`s. Each page is refreshed when shown. -class MainWindow : public QWidget { -public: - explicit MainWindow(BankClient& client, QWidget* parent = nullptr); - - /// Invoked when the user clicks "Log out". - std::function onLogout; - - /// @brief Switches to page @p index (also used by the screenshot smoke test). - void selectPage(int index) { showPage(index); } - [[nodiscard]] int pageCount() const { return _pageCount; } - -private: - void addPage(const QString& title, Page* page); - void showPage(int index); - - BankClient& _client; - QVBoxLayout* _navLayout{}; - QButtonGroup* _navGroup{}; - QStackedWidget* _stack{}; - QLabel* _title{}; - int _pageCount{0}; -}; - -} // namespace bankgui diff --git a/examples/bank/gui/Page.hpp b/examples/bank/gui/Page.hpp deleted file mode 100644 index 17d9d5a..0000000 --- a/examples/bank/gui/Page.hpp +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#pragma once - -#include - -namespace bankgui { - -/// @brief Base for a navigable content page. `refresh()` is called by the main -/// window whenever the page becomes visible, so each page reloads its -/// data from the models on demand. -class Page : public QWidget { -public: - using QWidget::QWidget; - virtual void refresh() {} -}; - -} // namespace bankgui diff --git a/examples/bank/gui/Smoke.hpp b/examples/bank/gui/Smoke.hpp deleted file mode 100644 index 1282ab4..0000000 --- a/examples/bank/gui/Smoke.hpp +++ /dev/null @@ -1,117 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#pragma once - -#include - -#include -#include -#include -#include -#include -#include - -#include "BankClient.hpp" -#include "MainWindow.hpp" -#include "bank/dto/account_dto.hpp" -#include "bank/dto/card_dto.hpp" -#include "bank/dto/loan_dto.hpp" -#include "bank/dto/payee_dto.hpp" -#include "bank/dto/transaction_dto.hpp" -#include "bank/models/account_model.hpp" -#include "bank/models/auth_model.hpp" -#include "bank/models/card_model.hpp" -#include "bank/models/loan_model.hpp" -#include "bank/models/payee_model.hpp" -#include "bank/models/transaction_model.hpp" - -/// @file -/// Headless screenshot smoke test, run only when BANK_GUI_SMOKE is set. It seeds -/// representative demo data, logs in, and saves a PNG of each page — used to -/// verify the GUI renders (and looks right) without a display. - -namespace bankgui::smoke { - -/// Pumps the Qt event loop until @p completion resolves. -template -void pumpAwait(Completion completion) { - bool done = false; - completion.then([&](auto&&) { done = true; }).onError([&](const std::exception_ptr&) { done = true; }); - while (!done) { - QApplication::processEvents(QEventLoop::AllEvents, 10); - } -} - -/// Seeds a representative account/transaction/payee/card/loan set for @p principal. -inline void seed(BankClient& client, const QString& principal) { - morph::bridge::BridgeHandler auth{client.bridge(), client.gui()}; - pumpAwait(auth.execute(bank::dto::RegisterUser{ - .username = principal.toStdString(), .password = "demo1234", .displayName = "Demo User"})); - client.login(principal, QStringLiteral("Demo User")); - - morph::bridge::BridgeHandler accounts{client.bridge(), client.gui()}; - morph::bridge::BridgeHandler txns{client.bridge(), client.gui()}; - morph::bridge::BridgeHandler payees{client.bridge(), client.gui()}; - morph::bridge::BridgeHandler cards{client.bridge(), client.gui()}; - morph::bridge::BridgeHandler loans{client.bridge(), client.gui()}; - - pumpAwait(accounts.execute(bank::dto::OpenAccount{.kind = 0, .currency = 0, .overdraftMinor = 50000})); - pumpAwait(accounts.execute(bank::dto::OpenAccount{.kind = 1, .currency = 0})); - - auto list = bank::dto::AccountList{}; - { - bool done = false; - accounts.execute(bank::dto::ListAccounts{}) - .then([&](bank::dto::AccountList result) { list = std::move(result); done = true; }) - .onError([&](const std::exception_ptr&) { done = true; }); - while (!done) { - QApplication::processEvents(QEventLoop::AllEvents, 10); - } - } - if (list.accounts.size() >= 2) { - const auto checking = list.accounts[0].id; - const auto savings = list.accounts[1].id; - pumpAwait(txns.execute(bank::dto::Deposit{.accountId = checking, .amountMinor = 480000, - .description = "Salary"})); - pumpAwait(txns.execute(bank::dto::Transfer{.fromAccountId = checking, .toAccountId = savings, - .amountMinor = 120000, .description = "Savings"})); - pumpAwait(txns.execute(bank::dto::Withdraw{.accountId = checking, .amountMinor = 3200, - .description = "Groceries"})); - pumpAwait(cards.execute(bank::dto::IssueCard{.accountId = checking, .kind = 0, - .dailyLimitMinor = 100000})); - pumpAwait(loans.execute(bank::dto::ApplyLoan{.accountId = checking, .principalMinor = 1200000, - .rateBps = 600, .termMonths = 12})); - } - pumpAwait(payees.execute(bank::dto::AddPayee{ - .name = "City Power", .iban = "DE89370400440532013000", .bankName = "Stadtbank"})); -} - -/// Logs in, builds the main window, and screenshots each page, then quits. -inline void run(BankClient& client, QStackedWidget* window, const QString& outDir) { - // Capture the login screen (currently shown) before signing in. - QApplication::processEvents(QEventLoop::AllEvents, 50); - window->grab().save(outDir + QStringLiteral("/bank_login.png")); - - const QString principal = QStringLiteral("gui-demo"); - seed(client, principal); - - auto* main = new MainWindow{client}; - const int index = window->addWidget(main); - window->setCurrentIndex(index); - - QTimer::singleShot(300, window, [main, window, outDir] { - const char* names[] = {"accounts", "move-money", "cards", "payees", "loans"}; - for (int page = 0; page < main->pageCount(); ++page) { - main->selectPage(page); - // Let the page's async refresh (account list, history, etc.) settle - // before capturing — completions arrive via the Qt event loop. - for (int tick = 0; tick < 40; ++tick) { - QApplication::processEvents(QEventLoop::AllEvents, 15); - QThread::msleep(5); - } - window->grab().save(outDir + QStringLiteral("/bank_%1.png").arg(names[page])); - } - QApplication::quit(); - }); -} - -} // namespace bankgui::smoke diff --git a/examples/bank/gui/Theme.hpp b/examples/bank/gui/Theme.hpp deleted file mode 100644 index ac5958f..0000000 --- a/examples/bank/gui/Theme.hpp +++ /dev/null @@ -1,201 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#pragma once - -#include -#include - -/// @file -/// The visual design system for the bank GUI: a warm, modern "Claude-inspired" -/// palette (paper background, clay/coral accent, soft cards, dark warm sidebar) -/// expressed as a single application-wide Qt style sheet. - -namespace bankgui::theme { - -// ── Palette ────────────────────────────────────────────────────────────────── -inline constexpr const char* kPaper = "#FAF9F5"; // app background (warm paper) -inline constexpr const char* kSurface = "#FFFFFF"; // cards / inputs -inline constexpr const char* kSurfaceAlt = "#F2F0E9"; // subtle fills, hover -inline constexpr const char* kInk = "#1F1E1D"; // primary text -inline constexpr const char* kInkSoft = "#6B6862"; // secondary text -inline constexpr const char* kBorder = "#E7E4DB"; // hairline borders -inline constexpr const char* kAccent = "#C96442"; // Claude clay/coral -inline constexpr const char* kAccentHover = "#B5573698"; // (unused placeholder) -inline constexpr const char* kSidebar = "#262624"; // warm near-black sidebar -inline constexpr const char* kSidebarText = "#C9C6BE"; -inline constexpr const char* kSuccess = "#2F9E66"; -inline constexpr const char* kDanger = "#C0392B"; - -/// @brief Returns the application-wide style sheet. -inline QString styleSheet() { - return QStringLiteral(R"QSS( -* { - font-family: "Inter", "Segoe UI", "Helvetica Neue", sans-serif; - font-size: 14px; - color: #1F1E1D; -} - -QWidget#Root, QStackedWidget, QScrollArea, QScrollArea > QWidget > QWidget { - background: #FAF9F5; -} - -/* ── Sidebar ──────────────────────────────────────────────────────────────── */ -QWidget#Sidebar { - background: #262624; - border: none; -} -QLabel#Brand { - color: #FAF9F5; - font-size: 19px; - font-weight: 700; - padding: 22px 20px 6px 20px; -} -QLabel#BrandSub { - color: #8C887F; - font-size: 12px; - padding: 0 20px 18px 20px; -} -QPushButton#NavButton { - color: #C9C6BE; - background: transparent; - border: none; - border-radius: 9px; - padding: 11px 14px; - margin: 2px 12px; - text-align: left; - font-size: 14px; - font-weight: 500; -} -QPushButton#NavButton:hover { - background: #34322F; - color: #FAF9F5; -} -QPushButton#NavButton:checked { - background: #C96442; - color: #FFFFFF; - font-weight: 600; -} -QLabel#SidebarUser { - color: #FAF9F5; - font-weight: 600; - padding: 0 20px; -} -QLabel#SidebarUserSub { - color: #8C887F; - font-size: 12px; - padding: 0 20px 12px 20px; -} - -/* ── Headings & text ─────────────────────────────────────────────────────── */ -QLabel#H1 { font-size: 26px; font-weight: 700; color: #1F1E1D; } -QLabel#H2 { font-size: 17px; font-weight: 600; color: #1F1E1D; } -QLabel#Muted { color: #6B6862; } -QLabel#Danger { color: #C0392B; font-weight: 500; } -QLabel#Success { color: #2F9E66; font-weight: 500; } - -/* ── Cards ───────────────────────────────────────────────────────────────── */ -QFrame#Card { - background: #FFFFFF; - border: 1px solid #E7E4DB; - border-radius: 14px; -} -QFrame#StatCard { - background: #262624; - border: none; - border-radius: 14px; -} -QLabel#StatValue { color: #FFFFFF; font-size: 28px; font-weight: 700; } -QLabel#StatLabel { color: #A7A39A; font-size: 13px; font-weight: 500; } - -/* ── Inputs ──────────────────────────────────────────────────────────────── */ -QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox { - background: #FFFFFF; - border: 1px solid #E7E4DB; - border-radius: 9px; - padding: 9px 12px; - selection-background-color: #C96442; - selection-color: #FFFFFF; -} -QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus { - border: 1px solid #C96442; -} -QComboBox::drop-down { border: none; width: 26px; } -QComboBox QAbstractItemView { - background: #FFFFFF; - border: 1px solid #E7E4DB; - border-radius: 8px; - selection-background-color: #F2F0E9; - selection-color: #1F1E1D; - outline: none; -} - -/* ── Buttons ─────────────────────────────────────────────────────────────── */ -QPushButton { - background: #FFFFFF; - color: #1F1E1D; - border: 1px solid #E7E4DB; - border-radius: 9px; - padding: 9px 16px; - font-weight: 600; -} -QPushButton:hover { background: #F2F0E9; } -QPushButton:disabled { color: #B6B2A9; background: #F2F0E9; } - -QPushButton[variant="primary"] { - background: #C96442; - color: #FFFFFF; - border: none; -} -QPushButton[variant="primary"]:hover { background: #B5572F; } -QPushButton[variant="primary"]:disabled { background: #DDB8A8; color: #FFFFFF; } - -QPushButton[variant="ghost"] { - background: transparent; - border: none; - color: #C96442; - padding: 6px 8px; -} -QPushButton[variant="ghost"]:hover { color: #B5572F; background: transparent; } - -QPushButton[variant="danger"] { - background: transparent; - color: #C0392B; - border: 1px solid #E7C9C5; -} -QPushButton[variant="danger"]:hover { background: #FBEDEB; } - -/* ── Tables ──────────────────────────────────────────────────────────────── */ -QTableWidget { - background: #FFFFFF; - border: 1px solid #E7E4DB; - border-radius: 12px; - gridline-color: transparent; - outline: none; -} -QTableWidget::item { padding: 8px 10px; border-bottom: 1px solid #F0EEE7; } -QTableWidget::item:selected { background: #F7EDE8; color: #1F1E1D; } -QHeaderView::section { - background: #FFFFFF; - color: #6B6862; - border: none; - border-bottom: 1px solid #E7E4DB; - padding: 10px; - font-weight: 600; - text-transform: uppercase; - font-size: 11px; -} -QTableCornerButton::section { background: #FFFFFF; border: none; } - -/* ── Pills / badges ──────────────────────────────────────────────────────── */ -QLabel[pill="neutral"] { background: #F2F0E9; color: #6B6862; border-radius: 10px; padding: 3px 10px; font-size: 12px; font-weight: 600; } -QLabel[pill="good"] { background: #E5F4EC; color: #2F9E66; border-radius: 10px; padding: 3px 10px; font-size: 12px; font-weight: 600; } -QLabel[pill="warn"] { background: #FBEDE8; color: #C96442; border-radius: 10px; padding: 3px 10px; font-size: 12px; font-weight: 600; } -QLabel[pill="bad"] { background: #FBEDEB; color: #C0392B; border-radius: 10px; padding: 3px 10px; font-size: 12px; font-weight: 600; } - -QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; } -QScrollBar::handle:vertical { background: #D9D5CB; border-radius: 5px; min-height: 30px; } -QScrollBar::handle:vertical:hover { background: #C2BDB1; } -QScrollBar::add-line, QScrollBar::sub-line { height: 0; } -)QSS"); -} - -} // namespace bankgui::theme diff --git a/examples/bank/gui/Ui.hpp b/examples/bank/gui/Ui.hpp deleted file mode 100644 index 82135a4..0000000 --- a/examples/bank/gui/Ui.hpp +++ /dev/null @@ -1,113 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -#include "bank/core/money.hpp" -#include "bank/core/types.hpp" - -/// @file -/// Small UI helpers: money formatting/parsing, error extraction, and factory -/// functions for the themed widgets used across the views. - -namespace bankgui::ui { - -/// @brief Formats minor units for a currency, e.g. (1234, USD) -> "12.34 USD". -inline QString formatMinor(std::int64_t minor, int currency) { - const auto cur = static_cast(currency); - return QString::fromStdString(bank::format(bank::Money{.minor = minor, .currency = cur})); -} - -/// @brief Parses a user-entered amount (major units) into minor units. -/// @return std::nullopt if the text is not a valid non-negative number. -inline std::optional parseToMinor(const QString& text, int decimals) { - bool ok = false; - const double major = text.trimmed().toDouble(&ok); - if (!ok || major < 0.0) { - return std::nullopt; - } - double scale = 1.0; - for (int idx = 0; idx < decimals; ++idx) { - scale *= 10.0; - } - return static_cast(major * scale + 0.5); -} - -/// @brief Extracts a human-readable message from an exception_ptr. -inline QString errorText(const std::exception_ptr& err) { - try { - std::rethrow_exception(err); - } catch (const std::exception& exc) { - return QString::fromUtf8(exc.what()); - } catch (...) { - return QStringLiteral("unknown error"); - } -} - -/// @brief Re-applies the style sheet to @p widget after a dynamic property change. -inline void repolish(QWidget* widget) { - widget->style()->unpolish(widget); - widget->style()->polish(widget); -} - -/// @brief Creates a button with a style variant ("primary", "ghost", "danger", or ""). -inline QPushButton* button(const QString& text, const QString& variant = {}) { - auto* btn = new QPushButton(text); - btn->setCursor(Qt::PointingHandCursor); - if (!variant.isEmpty()) { - btn->setProperty("variant", variant); - } - return btn; -} - -/// @brief Creates a label with a style objectName ("H1", "H2", "Muted", ...). -inline QLabel* label(const QString& text, const QString& role = {}) { - auto* lbl = new QLabel(text); - if (!role.isEmpty()) { - lbl->setObjectName(role); - } - return lbl; -} - -/// @brief Creates a coloured status pill ("neutral", "good", "warn", "bad"). -inline QLabel* pill(const QString& text, const QString& kind) { - auto* lbl = new QLabel(text); - lbl->setProperty("pill", kind); - lbl->setAlignment(Qt::AlignCenter); - lbl->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); - return lbl; -} - -/// @brief Creates an empty rounded "Card" frame. -inline QFrame* card() { - auto* frame = new QFrame; - frame->setObjectName(QStringLiteral("Card")); - return frame; -} - -/// @brief Removes and deletes every item (and child widget) in @p layout. -inline void clearLayout(QLayout* layout) { - while (QLayoutItem* item = layout->takeAt(0)) { - if (QWidget* widget = item->widget()) { - widget->deleteLater(); - } - if (QLayout* child = item->layout()) { - clearLayout(child); - } - delete item; - } -} - -} // namespace bankgui::ui diff --git a/examples/bank/gui/controllers/AccountController.cpp b/examples/bank/gui/controllers/AccountController.cpp new file mode 100644 index 0000000..6d8182f --- /dev/null +++ b/examples/bank/gui/controllers/AccountController.cpp @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "AccountController.hpp" + +#include + +#include "../BankClient.hpp" +#include "Format.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" + +namespace bankgui { + +namespace { + +QVariantMap toMap(const bank::dto::AccountInfo& account) { + const bool closed = account.status == static_cast(bank::AccountStatus::Closed); + QVariantMap map; + map[QStringLiteral("id")] = static_cast(account.id); + map[QStringLiteral("kind")] = fmt::accountKind(account.kind); + map[QStringLiteral("number")] = fmt::last4(account.number); + map[QStringLiteral("balanceText")] = fmt::money(account.balanceMinor, account.currency); + map[QStringLiteral("statusText")] = closed ? QStringLiteral("Closed") : QStringLiteral("Open"); + map[QStringLiteral("statusKind")] = closed ? QStringLiteral("neutral") : QStringLiteral("good"); + map[QStringLiteral("closed")] = closed; + map[QStringLiteral("hasOverdraft")] = account.overdraftMinor > 0; + map[QStringLiteral("overdraftText")] = + QStringLiteral("Overdraft ") + fmt::money(account.overdraftMinor, account.currency); + return map; +} + +} // namespace + +AccountController::AccountController(BankClient& client, QObject* parent) + : BankController(client, parent), _model{client.bridge(), client.gui()} {} + +void AccountController::refresh() { + _model.execute(bank::dto::ListAccounts{}) + .then([this](bank::dto::AccountList list) { + _accounts.clear(); + std::int64_t total = 0; + int currency = list.accounts.empty() ? 0 : list.accounts.front().currency; + bool sameCurrency = true; + _openCount = 0; + for (const auto& account : list.accounts) { + _accounts.append(toMap(account)); + if (account.status != static_cast(bank::AccountStatus::Closed)) { + ++_openCount; + total += account.balanceMinor; + if (account.currency != currency) { + sameCurrency = false; + } + } + } + _totalBalance = (sameCurrency && _openCount > 0) ? fmt::money(total, currency) + : QString::number(_openCount); + emit accountsChanged(); + }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void AccountController::openAccount(int kind, int currency, const QString& overdraft) { + const auto minor = overdraft.trimmed().isEmpty() ? 0 : fmt::parseMinor(overdraft).value_or(0); + _model + .execute(bank::dto::OpenAccount{.kind = kind, .currency = currency, .overdraftMinor = minor}) + .then([this](bank::dto::AccountInfo) { refresh(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/AccountController.hpp b/examples/bank/gui/controllers/AccountController.hpp new file mode 100644 index 0000000..3ebb1b5 --- /dev/null +++ b/examples/bank/gui/controllers/AccountController.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include "BankController.hpp" + +#ifndef Q_MOC_RUN +#include + +#include "bank/models/account_model.hpp" +#endif + +namespace bankgui { + +class BankClient; + +/// @brief Account list + open/close, exposed to QML as `accounts`. +class AccountController : public BankController { + Q_OBJECT + Q_PROPERTY(QVariantList accounts READ accounts NOTIFY accountsChanged) + Q_PROPERTY(QString totalBalance READ totalBalance NOTIFY accountsChanged) + Q_PROPERTY(int openCount READ openCount NOTIFY accountsChanged) + +public: + explicit AccountController(BankClient& client, QObject* parent = nullptr); + + [[nodiscard]] QVariantList accounts() const { return _accounts; } + [[nodiscard]] QString totalBalance() const { return _totalBalance; } + [[nodiscard]] int openCount() const { return _openCount; } + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void openAccount(int kind, int currency, const QString& overdraft); + +signals: + void accountsChanged(); + +private: + morph::bridge::BridgeHandler _model; + QVariantList _accounts; + QString _totalBalance{QStringLiteral("—")}; + int _openCount{0}; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/AppController.cpp b/examples/bank/gui/controllers/AppController.cpp new file mode 100644 index 0000000..eb7dc8a --- /dev/null +++ b/examples/bank/gui/controllers/AppController.cpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "AppController.hpp" + +#include "../BankClient.hpp" +#include "bank/dto/auth_dto.hpp" + +namespace bankgui { + +AppController::AppController(BankClient& client, QObject* parent) + : BankController(client, parent), _auth{client.bridge(), client.gui()} {} + +void AppController::adopt(const QString& principal, const QString& displayName) { + _client.login(principal, displayName); + _principal = principal; + _displayName = displayName; + _authenticated = true; + emit authChanged(); +} + +void AppController::login(const QString& username, const QString& password) { + _auth.execute(bank::dto::LoginRequest{.username = username.toStdString(), + .password = password.toStdString()}) + .then([this](bank::dto::AuthResult result) { + if (result.ok) { + adopt(QString::fromStdString(result.principal), + QString::fromStdString(result.displayName)); + } else { + emit error(QString::fromStdString(result.message)); + } + }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void AppController::registerUser(const QString& username, const QString& password, + const QString& displayName) { + _auth.execute(bank::dto::RegisterUser{.username = username.toStdString(), + .password = password.toStdString(), + .displayName = displayName.toStdString()}) + .then([this](bank::dto::AuthResult result) { + if (result.ok) { + adopt(QString::fromStdString(result.principal), + QString::fromStdString(result.displayName)); + } else { + emit error(QString::fromStdString(result.message)); + } + }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void AppController::logout() { + _client.logout(); + _authenticated = false; + _principal.clear(); + _displayName.clear(); + emit authChanged(); +} + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/AppController.hpp b/examples/bank/gui/controllers/AppController.hpp new file mode 100644 index 0000000..cd9a40b --- /dev/null +++ b/examples/bank/gui/controllers/AppController.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "BankController.hpp" + +// Hidden from moc: moc follows includes and its parser trips on the heavy +// morph/Lightweight headers. The compiler still sees them. +#ifndef Q_MOC_RUN +#include + +#include "bank/models/auth_model.hpp" +#endif + +namespace bankgui { + +class BankClient; + +/// @brief Authentication + session state, exposed to QML as `app`. +class AppController : public BankController { + Q_OBJECT + Q_PROPERTY(bool authenticated READ authenticated NOTIFY authChanged) + Q_PROPERTY(QString principal READ principal NOTIFY authChanged) + Q_PROPERTY(QString displayName READ displayName NOTIFY authChanged) + +public: + explicit AppController(BankClient& client, QObject* parent = nullptr); + + [[nodiscard]] bool authenticated() const { return _authenticated; } + [[nodiscard]] QString principal() const { return _principal; } + [[nodiscard]] QString displayName() const { return _displayName; } + + Q_INVOKABLE void login(const QString& username, const QString& password); + Q_INVOKABLE void registerUser(const QString& username, const QString& password, + const QString& displayName); + Q_INVOKABLE void logout(); + +signals: + void authChanged(); + +private: + void adopt(const QString& principal, const QString& displayName); + + morph::bridge::BridgeHandler _auth; + bool _authenticated{false}; + QString _principal; + QString _displayName; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/BankController.cpp b/examples/bank/gui/controllers/BankController.cpp new file mode 100644 index 0000000..3fcfe3e --- /dev/null +++ b/examples/bank/gui/controllers/BankController.cpp @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "BankController.hpp" + +namespace bankgui { + +QString BankController::errorText(const std::exception_ptr& err) { + try { + if (err) { + std::rethrow_exception(err); + } + } catch (const std::exception& exc) { + return QString::fromUtf8(exc.what()); + } catch (...) { + return QStringLiteral("unknown error"); + } + return {}; +} + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/BankController.hpp b/examples/bank/gui/controllers/BankController.hpp new file mode 100644 index 0000000..5d41384 --- /dev/null +++ b/examples/bank/gui/controllers/BankController.hpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include + +namespace bankgui { + +class BankClient; + +/// @brief Base for the QML-facing controllers. Holds the shared `BankClient` +/// and a common `error` signal that the QML shell surfaces as a toast. +class BankController : public QObject { + Q_OBJECT +public: + explicit BankController(BankClient& client, QObject* parent = nullptr) + : QObject(parent), _client{client} {} + +signals: + void error(const QString& message); + +protected: + /// Extracts a human-readable message from a captured exception. + static QString errorText(const std::exception_ptr& err); + + BankClient& _client; // NOLINT(*-non-private-member-variables-in-classes) +}; + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/CardController.cpp b/examples/bank/gui/controllers/CardController.cpp new file mode 100644 index 0000000..c6d372e --- /dev/null +++ b/examples/bank/gui/controllers/CardController.cpp @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "CardController.hpp" + +#include + +#include "../BankClient.hpp" +#include "Format.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/card_dto.hpp" + +namespace bankgui { + +CardController::CardController(BankClient& client, QObject* parent) + : BankController(client, parent), + _accountModel{client.bridge(), client.gui()}, + _cardModel{client.bridge(), client.gui()} {} + +void CardController::refresh() { + reloadAccounts(); + reloadCards(); +} + +void CardController::reloadAccounts() { + _accountModel.execute(bank::dto::ListAccounts{}) + .then([this](bank::dto::AccountList list) { + _accounts.clear(); + for (const auto& account : list.accounts) { + if (account.status == static_cast(bank::AccountStatus::Closed)) { + continue; + } + QVariantMap map; + map[QStringLiteral("id")] = static_cast(account.id); + map[QStringLiteral("label")] = fmt::last4(account.number); + _accounts.append(map); + } + emit accountsChanged(); + }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void CardController::reloadCards() { + _cardModel.execute(bank::dto::ListCards{}) + .then([this](bank::dto::CardList list) { + _cards.clear(); + for (const auto& card : list.cards) { + const auto status = static_cast(card.status); + const QString kind = card.kind == static_cast(bank::CardKind::Credit) + ? QStringLiteral("Credit") + : QStringLiteral("Debit"); + QVariantMap map; + map[QStringLiteral("id")] = static_cast(card.id); + map[QStringLiteral("title")] = + kind + QStringLiteral(" card ••••") + QString::fromStdString(card.panLast4); + map[QStringLiteral("limitText")] = + QStringLiteral("Daily limit ") + fmt::money(card.dailyLimitMinor, 0); + map[QStringLiteral("statusText")] = + status == bank::CardStatus::Active ? QStringLiteral("Active") + : status == bank::CardStatus::Frozen ? QStringLiteral("Frozen") + : QStringLiteral("Cancelled"); + map[QStringLiteral("statusKind")] = + status == bank::CardStatus::Active ? QStringLiteral("good") + : status == bank::CardStatus::Frozen ? QStringLiteral("warn") + : QStringLiteral("bad"); + map[QStringLiteral("active")] = status == bank::CardStatus::Active; + map[QStringLiteral("cancelled")] = status == bank::CardStatus::Cancelled; + _cards.append(map); + } + emit cardsChanged(); + }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void CardController::issue(qlonglong accountId, int kind, const QString& limit) { + if (accountId == 0) { + emit error(QStringLiteral("Pick an account.")); + return; + } + const auto minor = limit.trimmed().isEmpty() ? 0 : fmt::parseMinor(limit).value_or(0); + _cardModel + .execute(bank::dto::IssueCard{.accountId = accountId, .kind = kind, .dailyLimitMinor = minor}) + .then([this](bank::dto::CardInfo) { reloadCards(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void CardController::freeze(qlonglong id) { + _cardModel.execute(bank::dto::FreezeCard{.id = id}) + .then([this](bank::dto::CommandResult) { reloadCards(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void CardController::unfreeze(qlonglong id) { + _cardModel.execute(bank::dto::UnfreezeCard{.id = id}) + .then([this](bank::dto::CommandResult) { reloadCards(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void CardController::cancel(qlonglong id) { + _cardModel.execute(bank::dto::CancelCard{.id = id}) + .then([this](bank::dto::CommandResult) { reloadCards(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/CardController.hpp b/examples/bank/gui/controllers/CardController.hpp new file mode 100644 index 0000000..cfe7620 --- /dev/null +++ b/examples/bank/gui/controllers/CardController.hpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "BankController.hpp" + +#ifndef Q_MOC_RUN +#include + +#include "bank/models/account_model.hpp" +#include "bank/models/card_model.hpp" +#endif + +namespace bankgui { + +class BankClient; + +/// @brief Card list + issue/freeze/unfreeze/cancel, exposed to QML as `cards`. +class CardController : public BankController { + Q_OBJECT + Q_PROPERTY(QVariantList cards READ cards NOTIFY cardsChanged) + Q_PROPERTY(QVariantList accounts READ accounts NOTIFY accountsChanged) + +public: + explicit CardController(BankClient& client, QObject* parent = nullptr); + + [[nodiscard]] QVariantList cards() const { return _cards; } + [[nodiscard]] QVariantList accounts() const { return _accounts; } + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void issue(qlonglong accountId, int kind, const QString& limit); + Q_INVOKABLE void freeze(qlonglong id); + Q_INVOKABLE void unfreeze(qlonglong id); + Q_INVOKABLE void cancel(qlonglong id); + +signals: + void cardsChanged(); + void accountsChanged(); + +private: + void reloadCards(); + void reloadAccounts(); + + morph::bridge::BridgeHandler _accountModel; + morph::bridge::BridgeHandler _cardModel; + QVariantList _cards; + QVariantList _accounts; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/Format.hpp b/examples/bank/gui/controllers/Format.hpp new file mode 100644 index 0000000..950e59d --- /dev/null +++ b/examples/bank/gui/controllers/Format.hpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +#include "bank/core/money.hpp" +#include "bank/core/types.hpp" + +/// @file +/// Display/format helpers shared by the QML controllers. Controllers hand QML +/// display-ready strings (money already formatted), so the QML layer never does +/// currency math. + +namespace bankgui::fmt { + +inline QString money(std::int64_t minor, int currency) { + return QString::fromStdString( + bank::format(bank::Money{.minor = minor, .currency = static_cast(currency)})); +} + +inline QString accountKind(int kind) { + switch (static_cast(kind)) { + case bank::AccountKind::Checking: return QStringLiteral("Checking"); + case bank::AccountKind::Savings: return QStringLiteral("Savings"); + case bank::AccountKind::Credit: return QStringLiteral("Credit"); + } + return QStringLiteral("Account"); +} + +inline QString txnKind(int kind) { + switch (static_cast(kind)) { + case bank::TxnKind::Deposit: return QStringLiteral("Deposit"); + case bank::TxnKind::Withdrawal: return QStringLiteral("Withdrawal"); + case bank::TxnKind::TransferIn: return QStringLiteral("Transfer in"); + case bank::TxnKind::TransferOut: return QStringLiteral("Transfer out"); + case bank::TxnKind::Payment: return QStringLiteral("Payment"); + case bank::TxnKind::Fee: return QStringLiteral("Fee"); + case bank::TxnKind::Interest: return QStringLiteral("Interest"); + case bank::TxnKind::LoanDisbursement: return QStringLiteral("Loan in"); + case bank::TxnKind::LoanRepayment: return QStringLiteral("Loan repay"); + case bank::TxnKind::CardPurchase: return QStringLiteral("Card"); + case bank::TxnKind::Exchange: return QStringLiteral("Exchange"); + } + return QStringLiteral("Entry"); +} + +inline QString last4(const std::string& number) { + return QStringLiteral("•••• ") + QString::fromStdString(number).right(4); +} + +/// Parses a user-entered major-unit amount into minor units (assumes @p decimals). +inline std::optional parseMinor(const QString& text, int decimals = 2) { + bool ok = false; + const double major = text.trimmed().toDouble(&ok); + if (!ok || major < 0.0) { + return std::nullopt; + } + double scale = 1.0; + for (int idx = 0; idx < decimals; ++idx) { + scale *= 10.0; + } + return static_cast(major * scale + 0.5); +} + +} // namespace bankgui::fmt diff --git a/examples/bank/gui/controllers/LoanController.cpp b/examples/bank/gui/controllers/LoanController.cpp new file mode 100644 index 0000000..57a44e9 --- /dev/null +++ b/examples/bank/gui/controllers/LoanController.cpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "LoanController.hpp" + +#include + +#include "../BankClient.hpp" +#include "Format.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" + +namespace bankgui { + +LoanController::LoanController(BankClient& client, QObject* parent) + : BankController(client, parent), + _loanModel{client.bridge(), client.gui()}, + _accountModel{client.bridge(), client.gui()} {} + +void LoanController::refresh() { + reloadAccounts(); + reloadLoans(); +} + +void LoanController::reloadAccounts() { + _accountModel.execute(bank::dto::ListAccounts{}) + .then([this](bank::dto::AccountList list) { + _accounts.clear(); + for (const auto& account : list.accounts) { + if (account.status == static_cast(bank::AccountStatus::Closed)) { + continue; + } + QVariantMap map; + map[QStringLiteral("id")] = static_cast(account.id); + map[QStringLiteral("label")] = fmt::last4(account.number); + _accounts.append(map); + } + emit accountsChanged(); + }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void LoanController::reloadLoans() { + _loanModel.execute(bank::dto::ListLoans{}) + .then([this](bank::dto::LoanList list) { + _loans.clear(); + for (const auto& loan : list.loans) { + const auto status = static_cast(loan.status); + const bool paid = status == bank::LoanStatus::PaidOff; + QVariantMap map; + map[QStringLiteral("id")] = static_cast(loan.id); + map[QStringLiteral("accountId")] = static_cast(loan.accountId); + map[QStringLiteral("title")] = QStringLiteral("Loan #%1").arg(loan.id); + map[QStringLiteral("detail")] = + QStringLiteral("Outstanding %1 · %2 bps · %3 mo") + .arg(fmt::money(loan.outstandingMinor, loan.currency)) + .arg(loan.rateBps) + .arg(loan.termMonths); + map[QStringLiteral("outstanding")] = static_cast(loan.outstandingMinor); + map[QStringLiteral("statusText")] = paid ? QStringLiteral("Paid off") + : QStringLiteral("Active"); + map[QStringLiteral("statusKind")] = paid ? QStringLiteral("good") + : QStringLiteral("neutral"); + map[QStringLiteral("active")] = status == bank::LoanStatus::Active; + _loans.append(map); + } + emit loansChanged(); + }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void LoanController::apply(qlonglong accountId, const QString& principal, int rateBps, int termMonths) { + const auto minor = fmt::parseMinor(principal); + if (!minor || accountId == 0 || termMonths <= 0) { + emit error(QStringLiteral("Enter account, principal, and term.")); + return; + } + _loanModel + .execute(bank::dto::ApplyLoan{.accountId = accountId, .principalMinor = *minor, + .rateBps = rateBps, .termMonths = termMonths}) + .then([this](bank::dto::LoanInfo) { reloadLoans(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void LoanController::repay(qlonglong loanId, qlonglong accountId, const QString& amount) { + const auto minor = fmt::parseMinor(amount); + if (!minor) { + emit error(QStringLiteral("Enter a valid amount.")); + return; + } + _loanModel + .execute(bank::dto::RepayLoan{.loanId = loanId, .fromAccountId = accountId, .amountMinor = *minor}) + .then([this](bank::dto::LoanInfo) { reloadLoans(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void LoanController::showSchedule(qlonglong loanId) { + _loanModel.execute(bank::dto::LoanScheduleRequest{.loanId = loanId}) + .then([this](bank::dto::LoanScheduleResult result) { + _schedule.clear(); + for (const auto& inst : result.installments) { + QVariantMap map; + map[QStringLiteral("month")] = inst.month; + map[QStringLiteral("principalText")] = fmt::money(inst.principalMinor, 0); + map[QStringLiteral("interestText")] = fmt::money(inst.interestMinor, 0); + map[QStringLiteral("remainingText")] = fmt::money(inst.remainingMinor, 0); + _schedule.append(map); + } + emit scheduleChanged(); + }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/LoanController.hpp b/examples/bank/gui/controllers/LoanController.hpp new file mode 100644 index 0000000..6ab893a --- /dev/null +++ b/examples/bank/gui/controllers/LoanController.hpp @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "BankController.hpp" + +#ifndef Q_MOC_RUN +#include + +#include "bank/models/account_model.hpp" +#include "bank/models/loan_model.hpp" +#endif + +namespace bankgui { + +class BankClient; + +/// @brief Loans: apply, repay, and amortization schedule, exposed as `loans`. +class LoanController : public BankController { + Q_OBJECT + Q_PROPERTY(QVariantList loans READ loans NOTIFY loansChanged) + Q_PROPERTY(QVariantList accounts READ accounts NOTIFY accountsChanged) + Q_PROPERTY(QVariantList schedule READ schedule NOTIFY scheduleChanged) + +public: + explicit LoanController(BankClient& client, QObject* parent = nullptr); + + [[nodiscard]] QVariantList loans() const { return _loans; } + [[nodiscard]] QVariantList accounts() const { return _accounts; } + [[nodiscard]] QVariantList schedule() const { return _schedule; } + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void apply(qlonglong accountId, const QString& principal, int rateBps, int termMonths); + Q_INVOKABLE void repay(qlonglong loanId, qlonglong accountId, const QString& amount); + Q_INVOKABLE void showSchedule(qlonglong loanId); + +signals: + void loansChanged(); + void accountsChanged(); + void scheduleChanged(); + +private: + void reloadLoans(); + void reloadAccounts(); + + morph::bridge::BridgeHandler _loanModel; + morph::bridge::BridgeHandler _accountModel; + QVariantList _loans; + QVariantList _accounts; + QVariantList _schedule; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/PayeeController.cpp b/examples/bank/gui/controllers/PayeeController.cpp new file mode 100644 index 0000000..8cec67b --- /dev/null +++ b/examples/bank/gui/controllers/PayeeController.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "PayeeController.hpp" + +#include + +#include "../BankClient.hpp" +#include "Format.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/payment_dto.hpp" + +namespace bankgui { + +PayeeController::PayeeController(BankClient& client, QObject* parent) + : BankController(client, parent), + _payeeModel{client.bridge(), client.gui()}, + _accountModel{client.bridge(), client.gui()}, + _paymentModel{client.bridge(), client.gui()} {} + +void PayeeController::refresh() { + reloadAccounts(); + reloadPayees(); +} + +void PayeeController::reloadAccounts() { + _accountModel.execute(bank::dto::ListAccounts{}) + .then([this](bank::dto::AccountList list) { + _accounts.clear(); + for (const auto& account : list.accounts) { + if (account.status == static_cast(bank::AccountStatus::Closed)) { + continue; + } + QVariantMap map; + map[QStringLiteral("id")] = static_cast(account.id); + map[QStringLiteral("label")] = fmt::last4(account.number); + _accounts.append(map); + } + emit accountsChanged(); + }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void PayeeController::reloadPayees() { + _payeeModel.execute(bank::dto::ListPayees{}) + .then([this](bank::dto::PayeeList list) { + _payees.clear(); + for (const auto& payee : list.payees) { + QVariantMap map; + map[QStringLiteral("id")] = static_cast(payee.id); + map[QStringLiteral("name")] = QString::fromStdString(payee.name); + map[QStringLiteral("iban")] = QString::fromStdString(payee.iban); + _payees.append(map); + } + emit payeesChanged(); + }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void PayeeController::addPayee(const QString& name, const QString& iban, const QString& bank) { + _payeeModel + .execute(bank::dto::AddPayee{.name = name.toStdString(), + .iban = iban.trimmed().toStdString(), + .bankName = bank.toStdString()}) + .then([this](bank::dto::PayeeInfo) { reloadPayees(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void PayeeController::removePayee(qlonglong id) { + _payeeModel.execute(bank::dto::RemovePayee{.id = id}) + .then([this](bank::dto::CommandResult) { reloadPayees(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void PayeeController::payBill(qlonglong accountId, qlonglong payeeId, const QString& amount) { + const auto minor = fmt::parseMinor(amount); + if (!minor || accountId == 0 || payeeId == 0) { + emit error(QStringLiteral("Pick an account, payee, and amount.")); + return; + } + _paymentModel + .execute(bank::dto::PayBill{.fromAccountId = accountId, .payeeId = payeeId, .amountMinor = *minor}) + .then([this](bank::dto::PaymentInfo) { emit paid(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/PayeeController.hpp b/examples/bank/gui/controllers/PayeeController.hpp new file mode 100644 index 0000000..051919d --- /dev/null +++ b/examples/bank/gui/controllers/PayeeController.hpp @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "BankController.hpp" + +#ifndef Q_MOC_RUN +#include + +#include "bank/models/account_model.hpp" +#include "bank/models/payee_model.hpp" +#include "bank/models/payment_model.hpp" +#endif + +namespace bankgui { + +class BankClient; + +/// @brief Payees + bill payment, exposed to QML as `payees`. +class PayeeController : public BankController { + Q_OBJECT + Q_PROPERTY(QVariantList payees READ payees NOTIFY payeesChanged) + Q_PROPERTY(QVariantList accounts READ accounts NOTIFY accountsChanged) + +public: + explicit PayeeController(BankClient& client, QObject* parent = nullptr); + + [[nodiscard]] QVariantList payees() const { return _payees; } + [[nodiscard]] QVariantList accounts() const { return _accounts; } + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void addPayee(const QString& name, const QString& iban, const QString& bank); + Q_INVOKABLE void removePayee(qlonglong id); + Q_INVOKABLE void payBill(qlonglong accountId, qlonglong payeeId, const QString& amount); + +signals: + void payeesChanged(); + void accountsChanged(); + void paid(); + +private: + void reloadPayees(); + void reloadAccounts(); + + morph::bridge::BridgeHandler _payeeModel; + morph::bridge::BridgeHandler _accountModel; + morph::bridge::BridgeHandler _paymentModel; + QVariantList _payees; + QVariantList _accounts; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/TransactionController.cpp b/examples/bank/gui/controllers/TransactionController.cpp new file mode 100644 index 0000000..d395426 --- /dev/null +++ b/examples/bank/gui/controllers/TransactionController.cpp @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "TransactionController.hpp" + +#include + +#include "../BankClient.hpp" +#include "Format.hpp" +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/transaction_dto.hpp" + +namespace bankgui { + +TransactionController::TransactionController(BankClient& client, QObject* parent) + : BankController(client, parent), + _accountModel{client.bridge(), client.gui()}, + _txnModel{client.bridge(), client.gui()} {} + +void TransactionController::refresh() { + _accountModel.execute(bank::dto::ListAccounts{}) + .then([this](bank::dto::AccountList list) { + _accounts.clear(); + bool stillPresent = false; + for (const auto& account : list.accounts) { + if (account.status == static_cast(bank::AccountStatus::Closed)) { + continue; + } + QVariantMap map; + map[QStringLiteral("id")] = static_cast(account.id); + map[QStringLiteral("label")] = + fmt::last4(account.number) + QStringLiteral(" · ") + + fmt::money(account.balanceMinor, account.currency); + map[QStringLiteral("currency")] = account.currency; + _accounts.append(map); + if (account.id == _selected) { + stillPresent = true; + _selectedCurrency = account.currency; + } + } + if (!stillPresent && !_accounts.isEmpty()) { + _selected = _accounts.front().toMap().value(QStringLiteral("id")).toLongLong(); + _selectedCurrency = _accounts.front().toMap().value(QStringLiteral("currency")).toInt(); + } + emit accountsChanged(); + emit selectedChanged(); + reloadHistory(); + }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void TransactionController::selectAccount(qlonglong id) { + if (_selected == id) { + return; + } + _selected = id; + for (const auto& entry : std::as_const(_accounts)) { + const auto map = entry.toMap(); + if (map.value(QStringLiteral("id")).toLongLong() == id) { + _selectedCurrency = map.value(QStringLiteral("currency")).toInt(); + } + } + emit selectedChanged(); + reloadHistory(); +} + +int TransactionController::selectedCurrency() const { return _selectedCurrency; } + +void TransactionController::reloadHistory() { + _history.clear(); + if (_selected == 0) { + emit historyChanged(); + return; + } + _txnModel.execute(bank::dto::History{.accountId = _selected, .limit = 50}) + .then([this](bank::dto::HistoryPage page) { + _history.clear(); + for (const auto& entry : page.entries) { + const bool credit = entry.direction == static_cast(bank::TxnDirection::Credit); + QVariantMap map; + map[QStringLiteral("kind")] = fmt::txnKind(entry.kind); + map[QStringLiteral("amountText")] = + (credit ? QStringLiteral("+") : QStringLiteral("−")) + + fmt::money(entry.amountMinor, entry.currency); + map[QStringLiteral("isCredit")] = credit; + map[QStringLiteral("balanceText")] = fmt::money(entry.balanceAfterMinor, entry.currency); + _history.append(map); + } + emit historyChanged(); + }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void TransactionController::deposit(const QString& amount) { + const auto minor = fmt::parseMinor(amount, bank::currencyDecimals(static_cast(_selectedCurrency))); + if (!minor || _selected == 0) { + emit error(QStringLiteral("Enter a valid amount.")); + return; + } + _txnModel.execute(bank::dto::Deposit{.accountId = _selected, .amountMinor = *minor}) + .then([this](bank::dto::TxnInfo) { emit posted(); refresh(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void TransactionController::withdraw(const QString& amount) { + const auto minor = fmt::parseMinor(amount, bank::currencyDecimals(static_cast(_selectedCurrency))); + if (!minor || _selected == 0) { + emit error(QStringLiteral("Enter a valid amount.")); + return; + } + _txnModel.execute(bank::dto::Withdraw{.accountId = _selected, .amountMinor = *minor}) + .then([this](bank::dto::TxnInfo) { emit posted(); refresh(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +void TransactionController::transfer(qlonglong toId, const QString& amount) { + const auto minor = fmt::parseMinor(amount, bank::currencyDecimals(static_cast(_selectedCurrency))); + if (!minor || _selected == 0 || toId == 0) { + emit error(QStringLiteral("Pick a target account and amount.")); + return; + } + _txnModel + .execute(bank::dto::Transfer{.fromAccountId = _selected, .toAccountId = toId, .amountMinor = *minor}) + .then([this](bank::dto::TransferResult) { emit posted(); refresh(); }) + .onError([this](const std::exception_ptr& err) { emit error(errorText(err)); }); +} + +} // namespace bankgui diff --git a/examples/bank/gui/controllers/TransactionController.hpp b/examples/bank/gui/controllers/TransactionController.hpp new file mode 100644 index 0000000..156b27a --- /dev/null +++ b/examples/bank/gui/controllers/TransactionController.hpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "BankController.hpp" + +#ifndef Q_MOC_RUN +#include + +#include "bank/models/account_model.hpp" +#include "bank/models/transaction_model.hpp" +#endif + +namespace bankgui { + +class BankClient; + +/// @brief Deposit/withdraw/transfer + history for a selected account. +class TransactionController : public BankController { + Q_OBJECT + Q_PROPERTY(QVariantList accounts READ accounts NOTIFY accountsChanged) + Q_PROPERTY(QVariantList history READ history NOTIFY historyChanged) + Q_PROPERTY(qlonglong selectedAccount READ selectedAccount WRITE selectAccount NOTIFY selectedChanged) + +public: + explicit TransactionController(BankClient& client, QObject* parent = nullptr); + + [[nodiscard]] QVariantList accounts() const { return _accounts; } + [[nodiscard]] QVariantList history() const { return _history; } + [[nodiscard]] qlonglong selectedAccount() const { return _selected; } + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void selectAccount(qlonglong id); + Q_INVOKABLE void deposit(const QString& amount); + Q_INVOKABLE void withdraw(const QString& amount); + Q_INVOKABLE void transfer(qlonglong toId, const QString& amount); + +signals: + void accountsChanged(); + void historyChanged(); + void selectedChanged(); + void posted(); + +private: + void reloadHistory(); + [[nodiscard]] int selectedCurrency() const; + + morph::bridge::BridgeHandler _accountModel; + morph::bridge::BridgeHandler _txnModel; + QVariantList _accounts; + QVariantList _history; + qlonglong _selected{0}; + int _selectedCurrency{0}; +}; + +} // namespace bankgui diff --git a/examples/bank/gui/main.cpp b/examples/bank/gui/main.cpp index be4ab2b..83d9d3a 100644 --- a/examples/bank/gui/main.cpp +++ b/examples/bank/gui/main.cpp @@ -1,54 +1,139 @@ // SPDX-License-Identifier: Apache-2.0 // -// Entry point for the Qt 6 bank GUI. Wires a BankClient (local backend + Qt -// executor) and swaps between the login screen and the signed-in main window. +// Entry point for the QML bank GUI. Wires a BankClient (local backend + Qt +// executor) and the per-domain controllers, exposes them to QML as context +// properties, and loads the QML front-end. -#include -#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include #include #include #include #include "BankClient.hpp" -#include "LoginView.hpp" -#include "MainWindow.hpp" -#include "Smoke.hpp" -#include "Theme.hpp" +#include "controllers/AccountController.hpp" +#include "controllers/AppController.hpp" +#include "controllers/CardController.hpp" +#include "controllers/LoanController.hpp" +#include "controllers/PayeeController.hpp" +#include "controllers/TransactionController.hpp" + +namespace { + +/// The warm, "Claude-inspired" palette, handed to QML as the `theme` object. +QVariantMap makeTheme() { + return QVariantMap{ + {QStringLiteral("paper"), QStringLiteral("#FAF9F5")}, + {QStringLiteral("surface"), QStringLiteral("#FFFFFF")}, + {QStringLiteral("surfaceAlt"), QStringLiteral("#F2F0E9")}, + {QStringLiteral("ink"), QStringLiteral("#1F1E1D")}, + {QStringLiteral("inkSoft"), QStringLiteral("#6B6862")}, + {QStringLiteral("border"), QStringLiteral("#E7E4DB")}, + {QStringLiteral("accent"), QStringLiteral("#C96442")}, + {QStringLiteral("accentHover"), QStringLiteral("#B5572F")}, + {QStringLiteral("sidebar"), QStringLiteral("#262624")}, + {QStringLiteral("sidebarText"), QStringLiteral("#C9C6BE")}, + {QStringLiteral("sidebarHover"), QStringLiteral("#34322F")}, + {QStringLiteral("good"), QStringLiteral("#2F9E66")}, + {QStringLiteral("warn"), QStringLiteral("#C96442")}, + {QStringLiteral("bad"), QStringLiteral("#C0392B")}, + {QStringLiteral("goodBg"), QStringLiteral("#E5F4EC")}, + {QStringLiteral("warnBg"), QStringLiteral("#FBEDE8")}, + {QStringLiteral("badBg"), QStringLiteral("#FBEDEB")}, + {QStringLiteral("neutralBg"), QStringLiteral("#F2F0E9")}, + {QStringLiteral("dangerBorder"), QStringLiteral("#E7C9C5")}, + {QStringLiteral("radius"), 12}, + }; +} + +} // namespace int main(int argc, char* argv[]) { - QApplication app{argc, argv}; + QGuiApplication app{argc, argv}; app.setApplicationName(QStringLiteral("Morph Bank")); - app.setStyleSheet(bankgui::theme::styleSheet()); + QQuickStyle::setStyle(QStringLiteral("Basic")); // so our custom styling applies const auto dbPath = std::filesystem::temp_directory_path() / "morph_bank_gui.db"; bankgui::BankClient client{"DRIVER=SQLite3;Database=" + dbPath.string()}; - auto* window = new QStackedWidget; - window->setObjectName(QStringLiteral("Root")); - window->setWindowTitle(QStringLiteral("Morph Bank")); - window->resize(1160, 760); - - auto* login = new bankgui::LoginView{client}; - window->addWidget(login); - - login->onAuthenticated = [&client, window](const QString& principal, const QString& displayName) { - client.login(principal, displayName); - auto* main = new bankgui::MainWindow{client}; - const int index = window->addWidget(main); - main->onLogout = [&client, window, main] { - client.logout(); - window->setCurrentIndex(0); - main->deleteLater(); + bankgui::AppController appController{client}; + bankgui::AccountController accountController{client}; + bankgui::TransactionController transactionController{client}; + bankgui::CardController cardController{client}; + bankgui::PayeeController payeeController{client}; + bankgui::LoanController loanController{client}; + + QQmlApplicationEngine engine; + auto* ctx = engine.rootContext(); + ctx->setContextProperty(QStringLiteral("theme"), makeTheme()); + ctx->setContextProperty(QStringLiteral("app"), &appController); + ctx->setContextProperty(QStringLiteral("accounts"), &accountController); + ctx->setContextProperty(QStringLiteral("txns"), &transactionController); + ctx->setContextProperty(QStringLiteral("cards"), &cardController); + ctx->setContextProperty(QStringLiteral("payees"), &payeeController); + ctx->setContextProperty(QStringLiteral("loans"), &loanController); + + engine.loadFromModule("BankGui", "Main"); + if (engine.rootObjects().isEmpty()) { + return -1; + } + + // Headless screenshot smoke test: seed data, sign in, and grab each page. + if (const char* outEnv = std::getenv("BANK_GUI_SMOKE")) { + const QString out = QString::fromUtf8(outEnv); + auto* window = qobject_cast(engine.rootObjects().constFirst()); + const auto pump = [](int ms) { + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < ms) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + QThread::msleep(5); + } }; - window->setCurrentIndex(index); - }; + const auto firstAccountId = [&] { + const auto list = accountController.accounts(); + return list.isEmpty() ? 0LL : list.constFirst().toMap().value("id").toLongLong(); + }; + + pump(400); + if (window) { + window->grabWindow().save(out + "/qml_login.png"); + } - window->setCurrentWidget(login); - window->show(); + appController.registerUser("gui-demo", "demo1234", "Demo User"); + pump(600); + accountController.openAccount(0, 0, "500"); + pump(300); + accountController.openAccount(1, 0, ""); + pump(300); + const auto checking = firstAccountId(); + transactionController.selectAccount(checking); + transactionController.deposit("4800"); + pump(300); + cardController.issue(checking, 0, "1000"); + loanController.apply(checking, "12000", 600, 12); + payeeController.addPayee("City Power", "DE89370400440532013000", "Stadtbank"); + pump(400); - if (std::getenv("BANK_GUI_SMOKE") != nullptr) { - bankgui::smoke::run(client, window, QString::fromUtf8(std::getenv("BANK_GUI_SMOKE"))); + if (auto* shell = window ? window->findChild("appShell") : nullptr) { + accountController.refresh(); // page 0 is already current; force its data to reload + const char* names[] = {"accounts", "move-money", "cards", "payees", "loans"}; + for (int page = 0; page < 5; ++page) { + shell->setProperty("current", page); + pump(500); + window->grabWindow().save(out + QStringLiteral("/qml_%1.png").arg(names[page])); + } + } + return 0; } return app.exec(); diff --git a/examples/bank/gui/qml/AccountsPage.qml b/examples/bank/gui/qml/AccountsPage.qml new file mode 100644 index 0000000..ed051b9 --- /dev/null +++ b/examples/bank/gui/qml/AccountsPage.qml @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +ColumnLayout { + spacing: 16 + + // ── Summary stat ──────────────────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + implicitHeight: 96 + radius: theme.radius + color: theme.sidebar + ColumnLayout { + anchors.fill: parent + anchors.margins: 22 + spacing: 2 + Text { + text: accounts.totalBalance + color: "#FFFFFF" + font.pixelSize: 28 + font.weight: Font.Bold + } + Text { + text: accounts.openCount + " open account(s)" + color: "#A7A39A" + font.pixelSize: 13 + } + } + } + + // ── Open account form ───────────────────────────────────────────────── + Panel { + Layout.fillWidth: true + implicitHeight: 72 + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + spacing: 10 + Text { text: "New account"; font.pixelSize: 16; font.weight: Font.DemiBold; color: theme.ink } + Item { Layout.fillWidth: true } + Picker { id: kind; model: ["Checking", "Savings", "Credit"]; implicitWidth: 150 } + Picker { id: currency; model: ["USD", "EUR", "GBP", "CHF", "JPY"]; implicitWidth: 110 } + Field { id: overdraft; placeholderText: "Overdraft (opt.)"; implicitWidth: 150 } + AppButton { + text: "Open account" + variant: "primary" + onClicked: { + accounts.openAccount(kind.currentIndex, currency.currentIndex, overdraft.text); + overdraft.text = ""; + } + } + } + } + + // ── Account cards ───────────────────────────────────────────────────── + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + Flow { + width: parent.width + spacing: 16 + Repeater { + model: accounts.accounts + delegate: Panel { + required property var modelData + width: 280 + height: 150 + ColumnLayout { + anchors.fill: parent + anchors.margins: 20 + spacing: 6 + RowLayout { + Layout.fillWidth: true + Text { text: modelData.kind; font.pixelSize: 17; font.weight: Font.DemiBold; color: theme.ink } + Item { Layout.fillWidth: true } + Pill { text: modelData.statusText; kind: modelData.statusKind } + } + Text { text: modelData.number; color: theme.inkSoft } + Item { Layout.fillHeight: true } + Text { text: modelData.balanceText; font.pixelSize: 24; font.weight: Font.Bold; color: theme.ink } + Text { + text: modelData.overdraftText + color: theme.inkSoft + font.pixelSize: 12 + visible: modelData.hasOverdraft + } + } + } + } + } + } +} diff --git a/examples/bank/gui/qml/AppButton.qml b/examples/bank/gui/qml/AppButton.qml new file mode 100644 index 0000000..03716f6 --- /dev/null +++ b/examples/bank/gui/qml/AppButton.qml @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Controls.Basic + +// A themed button. `variant`: "default" | "primary" | "ghost" | "danger". +Button { + id: ctrl + property string variant: "default" + + font.weight: Font.DemiBold + topPadding: 10 + bottomPadding: 10 + leftPadding: 18 + rightPadding: 18 + hoverEnabled: true + + contentItem: Text { + text: ctrl.text + font: ctrl.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + color: ctrl.variant === "primary" ? "#FFFFFF" + : ctrl.variant === "danger" ? theme.bad + : ctrl.variant === "ghost" ? theme.accent + : theme.ink + } + + background: Rectangle { + radius: 9 + color: ctrl.variant === "primary" ? (ctrl.down ? theme.accentHover : theme.accent) + : ctrl.variant === "ghost" ? "transparent" + : ctrl.variant === "danger" ? (ctrl.hovered ? theme.badBg : "transparent") + : (ctrl.hovered ? theme.surfaceAlt : theme.surface) + border.width: (ctrl.variant === "primary" || ctrl.variant === "ghost") ? 0 : 1 + border.color: ctrl.variant === "danger" ? theme.dangerBorder : theme.border + } +} diff --git a/examples/bank/gui/qml/AppShell.qml b/examples/bank/gui/qml/AppShell.qml new file mode 100644 index 0000000..71ca5e9 --- /dev/null +++ b/examples/bank/gui/qml/AppShell.qml @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +RowLayout { + id: shell + objectName: "appShell" + spacing: 0 + property int current: 0 + readonly property var titles: ["Accounts", "Move Money", "Cards", "Payees & Bills", "Loans"] + readonly property var controllers: [accounts, txns, cards, payees, loans] + + function refreshCurrent() { shell.controllers[shell.current].refresh() } + onCurrentChanged: refreshCurrent() + Component.onCompleted: refreshCurrent() + + // ── Sidebar ─────────────────────────────────────────────────────────── + Rectangle { + Layout.preferredWidth: 232 + Layout.fillHeight: true + color: theme.sidebar + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Text { + text: "Morph Bank" + color: "#FAF9F5" + font.pixelSize: 19 + font.weight: Font.Bold + Layout.leftMargin: 20 + Layout.topMargin: 22 + } + Text { + text: "personal banking" + color: "#8C887F" + font.pixelSize: 12 + Layout.leftMargin: 20 + Layout.bottomMargin: 14 + } + + Repeater { + model: shell.titles + delegate: Rectangle { + required property int index + required property string modelData + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.topMargin: 2 + implicitHeight: 42 + radius: 9 + color: shell.current === index ? theme.accent + : navMouse.containsMouse ? theme.sidebarHover : "transparent" + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 14 + text: modelData + font.pixelSize: 14 + font.weight: shell.current === index ? Font.DemiBold : Font.Medium + color: shell.current === index ? "#FFFFFF" : theme.sidebarText + } + MouseArea { + id: navMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: shell.current = index + } + } + } + + Item { Layout.fillHeight: true } + + Text { + text: app.displayName + color: "#FAF9F5" + font.weight: Font.DemiBold + Layout.leftMargin: 20 + } + Text { + text: "@" + app.principal + color: "#8C887F" + font.pixelSize: 12 + Layout.leftMargin: 20 + Layout.bottomMargin: 8 + } + AppButton { + text: "Log out" + variant: "ghost" + Layout.leftMargin: 8 + Layout.bottomMargin: 14 + onClicked: app.logout() + } + } + } + + // ── Content ─────────────────────────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: 30 + spacing: 18 + + Text { + text: shell.titles[shell.current] + font.pixelSize: 26 + font.weight: Font.Bold + color: theme.ink + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: shell.current + AccountsPage {} + MoveMoneyPage {} + CardsPage {} + PayeesPage {} + LoansPage {} + } + } +} diff --git a/examples/bank/gui/qml/CardsPage.qml b/examples/bank/gui/qml/CardsPage.qml new file mode 100644 index 0000000..dbed975 --- /dev/null +++ b/examples/bank/gui/qml/CardsPage.qml @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +ColumnLayout { + spacing: 16 + + // ── Issue card ──────────────────────────────────────────────────────── + Panel { + Layout.fillWidth: true + implicitHeight: 72 + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + spacing: 10 + Text { text: "Issue card"; font.pixelSize: 16; font.weight: Font.DemiBold; color: theme.ink } + Item { Layout.fillWidth: true } + Picker { id: account; model: cards.accounts; textRole: "label"; valueRole: "id"; implicitWidth: 160 } + Picker { id: kind; model: ["Debit", "Credit"]; implicitWidth: 130 } + Field { id: limit; placeholderText: "Daily limit (opt.)"; implicitWidth: 160 } + AppButton { + text: "Issue" + variant: "primary" + onClicked: { cards.issue(account.currentValue, kind.currentIndex, limit.text); limit.text = "" } + } + } + } + + // ── Card list ───────────────────────────────────────────────────────── + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + ColumnLayout { + width: parent.width + spacing: 12 + Repeater { + model: cards.cards + delegate: Panel { + required property var modelData + Layout.fillWidth: true + implicitHeight: 78 + RowLayout { + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 20 + spacing: 12 + ColumnLayout { + spacing: 4 + Text { text: modelData.title; font.pixelSize: 16; font.weight: Font.DemiBold; color: theme.ink } + Text { text: modelData.limitText; color: theme.inkSoft; font.pixelSize: 12 } + } + Item { Layout.fillWidth: true } + Pill { text: modelData.statusText; kind: modelData.statusKind } + AppButton { + visible: !modelData.cancelled + text: modelData.active ? "Freeze" : "Unfreeze" + onClicked: modelData.active ? cards.freeze(modelData.id) : cards.unfreeze(modelData.id) + } + AppButton { + visible: !modelData.cancelled + text: "Cancel" + variant: "danger" + onClicked: cards.cancel(modelData.id) + } + } + } + } + } + } +} diff --git a/examples/bank/gui/qml/Field.qml b/examples/bank/gui/qml/Field.qml new file mode 100644 index 0000000..7a33964 --- /dev/null +++ b/examples/bank/gui/qml/Field.qml @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Controls.Basic + +// A themed single-line text input. +TextField { + id: ctrl + color: theme.ink + placeholderTextColor: theme.inkSoft + selectionColor: theme.accent + selectedTextColor: "#FFFFFF" + topPadding: 10 + bottomPadding: 10 + leftPadding: 12 + rightPadding: 12 + + background: Rectangle { + radius: 9 + color: theme.surface + border.width: 1 + border.color: ctrl.activeFocus ? theme.accent : theme.border + } +} diff --git a/examples/bank/gui/qml/LoansPage.qml b/examples/bank/gui/qml/LoansPage.qml new file mode 100644 index 0000000..fadfbeb --- /dev/null +++ b/examples/bank/gui/qml/LoansPage.qml @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +ColumnLayout { + spacing: 16 + + // ── Apply ───────────────────────────────────────────────────────────── + Panel { + Layout.fillWidth: true + implicitHeight: 72 + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + spacing: 10 + Text { text: "Apply for a loan"; font.pixelSize: 16; font.weight: Font.DemiBold; color: theme.ink } + Item { Layout.fillWidth: true } + Picker { id: account; model: loans.accounts; textRole: "label"; valueRole: "id"; implicitWidth: 150 } + Field { id: principal; placeholderText: "Principal"; implicitWidth: 130 } + Field { id: rate; placeholderText: "Rate (bps)"; implicitWidth: 110 } + Field { id: term; placeholderText: "Months"; implicitWidth: 90 } + AppButton { + text: "Apply" + variant: "primary" + onClicked: { + loans.apply(account.currentValue, principal.text, parseInt(rate.text || "0"), parseInt(term.text || "0")); + principal.text = ""; rate.text = ""; term.text = ""; + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 16 + + // ── Loan list ────────────────────────────────────────────────────── + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: false + Layout.preferredHeight: 200 + clip: true + ColumnLayout { + width: parent.width + spacing: 12 + Repeater { + model: loans.loans + delegate: Panel { + required property var modelData + Layout.fillWidth: true + implicitHeight: 78 + RowLayout { + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 20 + spacing: 10 + ColumnLayout { + spacing: 4 + Text { text: modelData.title; font.pixelSize: 16; font.weight: Font.DemiBold; color: theme.ink } + Text { text: modelData.detail; color: theme.inkSoft; font.pixelSize: 12 } + } + Item { Layout.fillWidth: true } + Pill { text: modelData.statusText; kind: modelData.statusKind } + AppButton { text: "Schedule"; onClicked: loans.showSchedule(modelData.id) } + AppButton { + visible: modelData.active + text: "Repay" + variant: "primary" + onClicked: loans.repay(modelData.id, modelData.accountId, (modelData.outstanding / 100).toString()) + } + } + } + } + } + } + + // ── Amortization schedule ──────────────────────────────────────────── + Panel { + Layout.fillWidth: true + Layout.fillHeight: true + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 10 + Text { text: "Amortization schedule"; font.pixelSize: 16; font.weight: Font.DemiBold; color: theme.ink } + RowLayout { + Layout.fillWidth: true + Text { text: "#"; color: theme.inkSoft; font.pixelSize: 11; font.weight: Font.DemiBold; Layout.preferredWidth: 40 } + Text { text: "PRINCIPAL"; color: theme.inkSoft; font.pixelSize: 11; font.weight: Font.DemiBold; Layout.fillWidth: true } + Text { text: "INTEREST"; color: theme.inkSoft; font.pixelSize: 11; font.weight: Font.DemiBold; Layout.fillWidth: true } + Text { text: "REMAINING"; color: theme.inkSoft; font.pixelSize: 11; font.weight: Font.DemiBold; Layout.fillWidth: true } + } + Rectangle { Layout.fillWidth: true; height: 1; color: theme.border } + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: loans.schedule + delegate: RowLayout { + required property var modelData + width: ListView.view ? ListView.view.width : 0 + height: 34 + Text { text: modelData.month; color: theme.ink; Layout.preferredWidth: 40 } + Text { text: modelData.principalText; color: theme.ink; Layout.fillWidth: true } + Text { text: modelData.interestText; color: theme.ink; Layout.fillWidth: true } + Text { text: modelData.remainingText; color: theme.ink; Layout.fillWidth: true } + } + } + } + } + } +} diff --git a/examples/bank/gui/qml/Login.qml b/examples/bank/gui/qml/Login.qml new file mode 100644 index 0000000..2a40a95 --- /dev/null +++ b/examples/bank/gui/qml/Login.qml @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Item { + Panel { + anchors.centerIn: parent + width: 380 + height: col.implicitHeight + 64 + + ColumnLayout { + id: col + anchors.fill: parent + anchors.margins: 32 + spacing: 12 + + Text { + text: "Morph Bank" + font.pixelSize: 26 + font.weight: Font.Bold + color: theme.ink + } + Text { + text: "Sign in to your account" + color: theme.inkSoft + Layout.bottomMargin: 6 + } + Field { + id: username + placeholderText: "Username" + Layout.fillWidth: true + } + Field { + id: password + placeholderText: "Password" + echoMode: TextInput.Password + Layout.fillWidth: true + onAccepted: app.login(username.text, password.text) + } + Field { + id: displayName + placeholderText: "Display name (for new accounts)" + Layout.fillWidth: true + } + AppButton { + text: "Sign in" + variant: "primary" + Layout.fillWidth: true + Layout.topMargin: 4 + onClicked: app.login(username.text, password.text) + } + AppButton { + text: "Create account" + variant: "ghost" + Layout.alignment: Qt.AlignHCenter + onClicked: app.registerUser(username.text, password.text, displayName.text) + } + } + } +} diff --git a/examples/bank/gui/qml/Main.qml b/examples/bank/gui/qml/Main.qml new file mode 100644 index 0000000..3be71d2 --- /dev/null +++ b/examples/bank/gui/qml/Main.qml @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Controls.Basic + +ApplicationWindow { + id: window + visible: true + width: 1160 + height: 760 + title: "Morph Bank" + color: theme.paper + + // Swap between the login screen and the signed-in shell. + Loader { + anchors.fill: parent + sourceComponent: app.authenticated ? shellComponent : loginComponent + } + Component { id: loginComponent; Login {} } + Component { id: shellComponent; AppShell {} } + + // ── Error toast ──────────────────────────────────────────────────────── + function showError(message) { + toastText.text = message; + toast.opacity = 1; + toastTimer.restart(); + } + Timer { id: toastTimer; interval: 3200; onTriggered: toast.opacity = 0 } + + Rectangle { + id: toast + opacity: 0 + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 28 + radius: 10 + color: "#2A2724" + width: toastText.implicitWidth + 36 + height: 44 + z: 1000 + Behavior on opacity { NumberAnimation { duration: 180 } } + Text { + id: toastText + anchors.centerIn: parent + color: "#FFFFFF" + font.weight: Font.Medium + } + } + + Connections { target: app; function onError(m) { window.showError(m) } } + Connections { target: accounts; function onError(m) { window.showError(m) } } + Connections { target: txns; function onError(m) { window.showError(m) } } + Connections { target: cards; function onError(m) { window.showError(m) } } + Connections { target: payees; function onError(m) { window.showError(m) } } + Connections { target: loans; function onError(m) { window.showError(m) } } +} diff --git a/examples/bank/gui/qml/MoveMoneyPage.qml b/examples/bank/gui/qml/MoveMoneyPage.qml new file mode 100644 index 0000000..8057b4e --- /dev/null +++ b/examples/bank/gui/qml/MoveMoneyPage.qml @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +ColumnLayout { + spacing: 16 + + // ── Move money ──────────────────────────────────────────────────────── + Panel { + Layout.fillWidth: true + implicitHeight: moveCol.implicitHeight + 36 + ColumnLayout { + id: moveCol + anchors.fill: parent + anchors.margins: 18 + spacing: 12 + + Text { text: "Move money"; font.pixelSize: 16; font.weight: Font.DemiBold; color: theme.ink } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + Text { text: "Account"; color: theme.inkSoft } + Picker { + id: account + Layout.fillWidth: true + model: txns.accounts + textRole: "label" + valueRole: "id" + onActivated: txns.selectAccount(currentValue) + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + Field { id: amount; placeholderText: "Amount"; Layout.fillWidth: true } + AppButton { text: "Deposit"; variant: "primary"; onClicked: { txns.deposit(amount.text); amount.text = "" } } + AppButton { text: "Withdraw"; onClicked: { txns.withdraw(amount.text); amount.text = "" } } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + AppButton { text: "Transfer to"; variant: "primary"; onClicked: { txns.transfer(target.currentValue, transferAmount.text); transferAmount.text = "" } } + Picker { id: target; Layout.fillWidth: true; model: txns.accounts; textRole: "label"; valueRole: "id" } + Field { id: transferAmount; placeholderText: "Amount"; Layout.fillWidth: true } + } + } + } + + // ── Recent activity ─────────────────────────────────────────────────── + Panel { + Layout.fillWidth: true + Layout.fillHeight: true + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 10 + Text { text: "Recent activity"; font.pixelSize: 16; font.weight: Font.DemiBold; color: theme.ink } + + // Header row + RowLayout { + Layout.fillWidth: true + Text { text: "TYPE"; color: theme.inkSoft; font.pixelSize: 11; font.weight: Font.DemiBold; Layout.fillWidth: true } + Text { text: "AMOUNT"; color: theme.inkSoft; font.pixelSize: 11; font.weight: Font.DemiBold; Layout.preferredWidth: 160 } + Text { text: "BALANCE"; color: theme.inkSoft; font.pixelSize: 11; font.weight: Font.DemiBold; Layout.preferredWidth: 160 } + } + Rectangle { Layout.fillWidth: true; height: 1; color: theme.border } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: txns.history + delegate: Rectangle { + required property var modelData + width: ListView.view ? ListView.view.width : 0 + height: 40 + color: "transparent" + RowLayout { + anchors.fill: parent + Text { text: modelData.kind; color: theme.ink; Layout.fillWidth: true } + Text { + text: modelData.amountText + color: modelData.isCredit ? theme.good : theme.bad + font.weight: Font.Medium + Layout.preferredWidth: 160 + } + Text { text: modelData.balanceText; color: theme.ink; Layout.preferredWidth: 160 } + } + Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: "#F0EEE7" } + } + } + } + } +} diff --git a/examples/bank/gui/qml/Panel.qml b/examples/bank/gui/qml/Panel.qml new file mode 100644 index 0000000..a75d8ba --- /dev/null +++ b/examples/bank/gui/qml/Panel.qml @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick + +// A rounded "card" surface. Put a Layout (anchored with margins) inside. +Rectangle { + color: theme.surface + radius: theme.radius + border.width: 1 + border.color: theme.border +} diff --git a/examples/bank/gui/qml/PayeesPage.qml b/examples/bank/gui/qml/PayeesPage.qml new file mode 100644 index 0000000..42c4812 --- /dev/null +++ b/examples/bank/gui/qml/PayeesPage.qml @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +ColumnLayout { + spacing: 16 + + // ── Add payee ───────────────────────────────────────────────────────── + Panel { + Layout.fillWidth: true + implicitHeight: 72 + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + spacing: 10 + Field { id: name; placeholderText: "Payee name"; Layout.fillWidth: true } + Field { id: iban; placeholderText: "IBAN"; Layout.fillWidth: true } + Field { id: bank; placeholderText: "Bank (optional)"; Layout.fillWidth: true } + AppButton { + text: "Add payee" + variant: "primary" + onClicked: { payees.addPayee(name.text, iban.text, bank.text); name.text = ""; iban.text = ""; bank.text = "" } + } + } + } + + // ── Pay bill ────────────────────────────────────────────────────────── + Panel { + Layout.fillWidth: true + implicitHeight: 72 + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + spacing: 10 + Text { text: "Pay bill"; font.pixelSize: 16; font.weight: Font.DemiBold; color: theme.ink } + Item { Layout.fillWidth: true } + Picker { id: payAccount; model: payees.accounts; textRole: "label"; valueRole: "id"; implicitWidth: 170 } + Picker { id: payPayee; model: payees.payees; textRole: "name"; valueRole: "id"; implicitWidth: 170 } + Field { id: payAmount; placeholderText: "Amount"; implicitWidth: 140 } + AppButton { + text: "Pay" + variant: "primary" + onClicked: { payees.payBill(payAccount.currentValue, payPayee.currentValue, payAmount.text); payAmount.text = "" } + } + } + } + + // ── Payee list ──────────────────────────────────────────────────────── + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + ColumnLayout { + width: parent.width + spacing: 12 + Repeater { + model: payees.payees + delegate: Panel { + required property var modelData + Layout.fillWidth: true + implicitHeight: 74 + RowLayout { + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 20 + ColumnLayout { + spacing: 4 + Text { text: modelData.name; font.pixelSize: 16; font.weight: Font.DemiBold; color: theme.ink } + Text { text: modelData.iban; color: theme.inkSoft; font.pixelSize: 12 } + } + Item { Layout.fillWidth: true } + AppButton { text: "Remove"; variant: "danger"; onClicked: payees.removePayee(modelData.id) } + } + } + } + } + } +} diff --git a/examples/bank/gui/qml/Picker.qml b/examples/bank/gui/qml/Picker.qml new file mode 100644 index 0000000..7f83f54 --- /dev/null +++ b/examples/bank/gui/qml/Picker.qml @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Controls.Basic + +// A themed ComboBox; pairs with QVariantList models using textRole/valueRole. +ComboBox { + id: ctrl + font.weight: Font.Medium + + background: Rectangle { + radius: 9 + implicitHeight: 40 + color: theme.surface + border.width: 1 + border.color: ctrl.activeFocus ? theme.accent : theme.border + } + + leftPadding: 12 + rightPadding: 32 + + contentItem: Text { + text: ctrl.displayText + color: theme.ink + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } +} diff --git a/examples/bank/gui/qml/Pill.qml b/examples/bank/gui/qml/Pill.qml new file mode 100644 index 0000000..f15eea5 --- /dev/null +++ b/examples/bank/gui/qml/Pill.qml @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick + +// A small status badge. `kind`: "good" | "warn" | "bad" | "neutral". +Rectangle { + id: pill + property string text: "" + property string kind: "neutral" + + radius: 11 + implicitHeight: 24 + implicitWidth: label.implicitWidth + 22 + color: kind === "good" ? theme.goodBg + : kind === "warn" ? theme.warnBg + : kind === "bad" ? theme.badBg + : theme.neutralBg + + Text { + id: label + anchors.centerIn: parent + text: pill.text + font.pixelSize: 12 + font.weight: Font.DemiBold + color: pill.kind === "good" ? theme.good + : pill.kind === "warn" ? theme.warn + : pill.kind === "bad" ? theme.bad + : theme.inkSoft + } +} diff --git a/examples/bank/gui/views/AccountsView.cpp b/examples/bank/gui/views/AccountsView.cpp deleted file mode 100644 index 8adc9be..0000000 --- a/examples/bank/gui/views/AccountsView.cpp +++ /dev/null @@ -1,177 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#include "AccountsView.hpp" - -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "../Ui.hpp" -#include "bank/core/types.hpp" - -namespace bankgui { - -namespace { - -QString kindName(int kind) { - switch (static_cast(kind)) { - case bank::AccountKind::Checking: return QStringLiteral("Checking"); - case bank::AccountKind::Savings: return QStringLiteral("Savings"); - case bank::AccountKind::Credit: return QStringLiteral("Credit"); - } - return QStringLiteral("Account"); -} - -QFrame* accountCard(const bank::dto::AccountInfo& account) { - auto* card = ui::card(); - card->setMinimumWidth(260); - auto* box = new QVBoxLayout(card); - box->setContentsMargins(20, 18, 20, 18); - box->setSpacing(6); - - auto* top = new QHBoxLayout; - auto* type = ui::label(kindName(account.kind), QStringLiteral("H2")); - const bool closed = account.status == static_cast(bank::AccountStatus::Closed); - auto* status = ui::pill(closed ? QStringLiteral("Closed") : QStringLiteral("Open"), - closed ? QStringLiteral("neutral") : QStringLiteral("good")); - top->addWidget(type); - top->addStretch(); - top->addWidget(status); - box->addLayout(top); - - const QString last4 = QString::fromStdString(account.number).right(4); - box->addWidget(ui::label(QStringLiteral("•••• •••• ") + last4, QStringLiteral("Muted"))); - - auto* balance = new QLabel(ui::formatMinor(account.balanceMinor, account.currency)); - balance->setStyleSheet(QStringLiteral("font-size:24px; font-weight:700; color:#1F1E1D;")); - box->addSpacing(6); - box->addWidget(balance); - - if (account.overdraftMinor > 0) { - box->addWidget(ui::label(QStringLiteral("Overdraft ") + - ui::formatMinor(account.overdraftMinor, account.currency), - QStringLiteral("Muted"))); - } - return card; -} - -} // namespace - -AccountsView::AccountsView(BankClient& client, QWidget* parent) - : Page(parent), _client{client}, _accounts{client.bridge(), client.gui()} { - auto* root = new QVBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(18); - - // Summary stat card. - auto* stat = new QFrame; - stat->setObjectName(QStringLiteral("StatCard")); - stat->setFixedHeight(96); - auto* statBox = new QVBoxLayout(stat); - statBox->setContentsMargins(24, 16, 24, 16); - _statValue = ui::label(QStringLiteral("—"), QStringLiteral("StatValue")); - _statLabel = ui::label(QStringLiteral("total balance"), QStringLiteral("StatLabel")); - statBox->addWidget(_statValue); - statBox->addWidget(_statLabel); - root->addWidget(stat); - - // Inline "open account" form. - auto* formCard = ui::card(); - auto* form = new QHBoxLayout(formCard); - form->setContentsMargins(16, 14, 16, 14); - form->setSpacing(10); - _kind = new QComboBox; - _kind->addItems({QStringLiteral("Checking"), QStringLiteral("Savings"), QStringLiteral("Credit")}); - _currency = new QComboBox; - _currency->addItems({QStringLiteral("USD"), QStringLiteral("EUR"), QStringLiteral("GBP"), - QStringLiteral("CHF"), QStringLiteral("JPY")}); - _overdraft = new QLineEdit; - _overdraft->setPlaceholderText(QStringLiteral("Overdraft (optional)")); - auto* open = ui::button(QStringLiteral("Open account"), QStringLiteral("primary")); - form->addWidget(ui::label(QStringLiteral("New account"), QStringLiteral("H2"))); - form->addStretch(); - form->addWidget(_kind); - form->addWidget(_currency); - form->addWidget(_overdraft); - form->addWidget(open); - root->addWidget(formCard); - QObject::connect(open, &QPushButton::clicked, this, [this] { openAccount(); }); - - // Scrollable grid of account cards. - auto* scroll = new QScrollArea; - scroll->setWidgetResizable(true); - scroll->setFrameShape(QFrame::NoFrame); - auto* gridHost = new QWidget; - _grid = new QGridLayout(gridHost); - _grid->setContentsMargins(0, 0, 0, 0); - _grid->setSpacing(16); - _grid->setAlignment(Qt::AlignTop); - scroll->setWidget(gridHost); - root->addWidget(scroll, 1); -} - -void AccountsView::openAccount() { - const std::optional overdraft = - _overdraft->text().trimmed().isEmpty() ? std::optional{0} - : ui::parseToMinor(_overdraft->text(), 2); - _accounts - .execute(bank::dto::OpenAccount{.kind = _kind->currentIndex(), - .currency = _currency->currentIndex(), - .overdraftMinor = overdraft.value_or(0)}) - .then([this](bank::dto::AccountInfo) { - _overdraft->clear(); - refresh(); - }) - .onError([](const std::exception_ptr&) {}); -} - -void AccountsView::refresh() { - _accounts.execute(bank::dto::ListAccounts{}) - .then([this](bank::dto::AccountList list) { rebuild(list.accounts); }) - .onError([](const std::exception_ptr&) {}); -} - -void AccountsView::rebuild(const std::vector& accounts) { - ui::clearLayout(_grid); - - std::int64_t total = 0; - bool sameCurrency = true; - int currency = accounts.empty() ? 0 : accounts.front().currency; - int openCount = 0; - for (const auto& account : accounts) { - if (account.status == static_cast(bank::AccountStatus::Closed)) { - continue; - } - ++openCount; - total += account.balanceMinor; - if (account.currency != currency) { - sameCurrency = false; - } - } - - if (sameCurrency && openCount > 0) { - _statValue->setText(ui::formatMinor(total, currency)); - _statLabel->setText(QStringLiteral("total across %1 open account(s)").arg(openCount)); - } else { - _statValue->setText(QString::number(openCount)); - _statLabel->setText(QStringLiteral("open accounts")); - } - - int row = 0; - int col = 0; - for (const auto& account : accounts) { - _grid->addWidget(accountCard(account), row, col); - if (++col == 3) { - col = 0; - ++row; - } - } -} - -} // namespace bankgui diff --git a/examples/bank/gui/views/AccountsView.hpp b/examples/bank/gui/views/AccountsView.hpp deleted file mode 100644 index df3e8e6..0000000 --- a/examples/bank/gui/views/AccountsView.hpp +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#pragma once - -#include - -#include - -#include "../BankClient.hpp" -#include "../Page.hpp" -#include "bank/dto/account_dto.hpp" -#include "bank/models/account_model.hpp" - -class QComboBox; -class QLineEdit; -class QLabel; -class QGridLayout; - -namespace bankgui { - -/// @brief Dashboard: a summary stat, an inline "open account" form, and a grid -/// of account cards. -class AccountsView : public Page { -public: - explicit AccountsView(BankClient& client, QWidget* parent = nullptr); - void refresh() override; - -private: - void openAccount(); - void rebuild(const std::vector& accounts); - - BankClient& _client; - morph::bridge::BridgeHandler _accounts; - QComboBox* _kind{}; - QComboBox* _currency{}; - QLineEdit* _overdraft{}; - QLabel* _statValue{}; - QLabel* _statLabel{}; - QGridLayout* _grid{}; -}; - -} // namespace bankgui diff --git a/examples/bank/gui/views/CardsView.cpp b/examples/bank/gui/views/CardsView.cpp deleted file mode 100644 index 02761d2..0000000 --- a/examples/bank/gui/views/CardsView.cpp +++ /dev/null @@ -1,164 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#include "CardsView.hpp" - -#include -#include -#include -#include -#include -#include - -#include - -#include "../Ui.hpp" -#include "bank/core/types.hpp" -#include "bank/dto/account_dto.hpp" - -namespace bankgui { - -namespace { - -QString statusName(int status) { - switch (static_cast(status)) { - case bank::CardStatus::Active: return QStringLiteral("Active"); - case bank::CardStatus::Frozen: return QStringLiteral("Frozen"); - case bank::CardStatus::Cancelled: return QStringLiteral("Cancelled"); - } - return QStringLiteral("—"); -} - -QString statusPill(int status) { - switch (static_cast(status)) { - case bank::CardStatus::Active: return QStringLiteral("good"); - case bank::CardStatus::Frozen: return QStringLiteral("warn"); - case bank::CardStatus::Cancelled: return QStringLiteral("bad"); - } - return QStringLiteral("neutral"); -} - -} // namespace - -CardsView::CardsView(BankClient& client, QWidget* parent) - : Page(parent), - _client{client}, - _accounts{client.bridge(), client.gui()}, - _cards{client.bridge(), client.gui()} { - auto* root = new QVBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(18); - - auto* formCard = ui::card(); - auto* form = new QHBoxLayout(formCard); - form->setContentsMargins(16, 14, 16, 14); - form->setSpacing(10); - form->addWidget(ui::label(QStringLiteral("Issue card"), QStringLiteral("H2"))); - form->addStretch(); - _account = new QComboBox; - _account->setMinimumWidth(220); - _kind = new QComboBox; - _kind->addItems({QStringLiteral("Debit"), QStringLiteral("Credit")}); - _limit = new QLineEdit; - _limit->setPlaceholderText(QStringLiteral("Daily limit (optional)")); - auto* issue = ui::button(QStringLiteral("Issue"), QStringLiteral("primary")); - form->addWidget(_account); - form->addWidget(_kind); - form->addWidget(_limit); - form->addWidget(issue); - root->addWidget(formCard); - QObject::connect(issue, &QPushButton::clicked, this, [this] { issueCard(); }); - - auto* scroll = new QScrollArea; - scroll->setWidgetResizable(true); - scroll->setFrameShape(QFrame::NoFrame); - auto* host = new QWidget; - _list = new QVBoxLayout(host); - _list->setContentsMargins(0, 0, 0, 0); - _list->setSpacing(12); - _list->setAlignment(Qt::AlignTop); - scroll->setWidget(host); - root->addWidget(scroll, 1); -} - -void CardsView::issueCard() { - if (!_account->currentData().isValid()) { - return; - } - const auto limit = _limit->text().trimmed().isEmpty() ? std::optional{0} - : ui::parseToMinor(_limit->text(), 2); - _cards - .execute(bank::dto::IssueCard{.accountId = _account->currentData().toLongLong(), - .kind = _kind->currentIndex(), - .dailyLimitMinor = limit.value_or(0)}) - .then([this](bank::dto::CardInfo) { _limit->clear(); refresh(); }) - .onError([](const std::exception_ptr&) {}); -} - -void CardsView::refresh() { - _accounts.execute(bank::dto::ListAccounts{}) - .then([this](bank::dto::AccountList list) { - _account->clear(); - for (const auto& account : list.accounts) { - if (account.status == static_cast(bank::AccountStatus::Closed)) { - continue; - } - _account->addItem(QStringLiteral("•••• ") + QString::fromStdString(account.number).right(4), - QVariant::fromValue(account.id)); - } - }) - .onError([](const std::exception_ptr&) {}); - - _cards.execute(bank::dto::ListCards{}) - .then([this](bank::dto::CardList list) { rebuild(list.cards); }) - .onError([](const std::exception_ptr&) {}); -} - -void CardsView::rebuild(const std::vector& cards) { - ui::clearLayout(_list); - for (const auto& card : cards) { - auto* frame = ui::card(); - auto* row = new QHBoxLayout(frame); - row->setContentsMargins(20, 16, 20, 16); - row->setSpacing(14); - - auto* info = new QVBoxLayout; - const QString kind = card.kind == static_cast(bank::CardKind::Credit) ? QStringLiteral("Credit") - : QStringLiteral("Debit"); - info->addWidget(ui::label(kind + QStringLiteral(" card ••••") + QString::fromStdString(card.panLast4), - QStringLiteral("H2"))); - info->addWidget(ui::label(QStringLiteral("Daily limit ") + ui::formatMinor(card.dailyLimitMinor, 0), - QStringLiteral("Muted"))); - row->addLayout(info); - row->addStretch(); - row->addWidget(ui::pill(statusName(card.status), statusPill(card.status))); - - const auto id = card.id; - const bool active = card.status == static_cast(bank::CardStatus::Active); - const bool cancelled = card.status == static_cast(bank::CardStatus::Cancelled); - - if (!cancelled) { - auto* toggle = ui::button(active ? QStringLiteral("Freeze") : QStringLiteral("Unfreeze")); - QObject::connect(toggle, &QPushButton::clicked, this, [this, id, active] { - auto done = [this](bank::dto::CommandResult) { refresh(); }; - auto fail = [](const std::exception_ptr&) {}; - if (active) { - _cards.execute(bank::dto::FreezeCard{.id = id}).then(done).onError(fail); - } else { - _cards.execute(bank::dto::UnfreezeCard{.id = id}).then(done).onError(fail); - } - }); - row->addWidget(toggle); - - auto* cancel = ui::button(QStringLiteral("Cancel"), QStringLiteral("danger")); - QObject::connect(cancel, &QPushButton::clicked, this, [this, id] { - _cards.execute(bank::dto::CancelCard{.id = id}) - .then([this](bank::dto::CommandResult) { refresh(); }) - .onError([](const std::exception_ptr&) {}); - }); - row->addWidget(cancel); - } - _list->addWidget(frame); - } -} - -} // namespace bankgui diff --git a/examples/bank/gui/views/CardsView.hpp b/examples/bank/gui/views/CardsView.hpp deleted file mode 100644 index ef4ffb3..0000000 --- a/examples/bank/gui/views/CardsView.hpp +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#pragma once - -#include - -#include - -#include "../BankClient.hpp" -#include "../Page.hpp" -#include "bank/dto/card_dto.hpp" -#include "bank/models/account_model.hpp" -#include "bank/models/card_model.hpp" - -class QComboBox; -class QLineEdit; -class QVBoxLayout; - -namespace bankgui { - -/// @brief Issue cards against an account and freeze / unfreeze / cancel them. -class CardsView : public Page { -public: - explicit CardsView(BankClient& client, QWidget* parent = nullptr); - void refresh() override; - -private: - void issueCard(); - void rebuild(const std::vector& cards); - - BankClient& _client; - morph::bridge::BridgeHandler _accounts; - morph::bridge::BridgeHandler _cards; - QComboBox* _account{}; - QComboBox* _kind{}; - QLineEdit* _limit{}; - QVBoxLayout* _list{}; -}; - -} // namespace bankgui diff --git a/examples/bank/gui/views/LoansView.cpp b/examples/bank/gui/views/LoansView.cpp deleted file mode 100644 index 6ecef15..0000000 --- a/examples/bank/gui/views/LoansView.cpp +++ /dev/null @@ -1,219 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#include "LoansView.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "../Ui.hpp" -#include "bank/core/types.hpp" -#include "bank/dto/account_dto.hpp" - -namespace bankgui { - -namespace { - -QString loanStatusName(int status) { - switch (static_cast(status)) { - case bank::LoanStatus::Active: return QStringLiteral("Active"); - case bank::LoanStatus::PaidOff: return QStringLiteral("Paid off"); - case bank::LoanStatus::Defaulted: return QStringLiteral("Defaulted"); - } - return QStringLiteral("—"); -} - -} // namespace - -LoansView::LoansView(BankClient& client, QWidget* parent) - : Page(parent), - _client{client}, - _loans{client.bridge(), client.gui()}, - _accounts{client.bridge(), client.gui()} { - auto* root = new QVBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(18); - - // ── Apply ────────────────────────────────────────────────────────────── - auto* applyCard = ui::card(); - auto* apply = new QHBoxLayout(applyCard); - apply->setContentsMargins(16, 14, 16, 14); - apply->setSpacing(10); - apply->addWidget(ui::label(QStringLiteral("Apply for a loan"), QStringLiteral("H2"))); - apply->addStretch(); - _account = new QComboBox; - _account->setMinimumWidth(160); - _principal = new QLineEdit; - _principal->setPlaceholderText(QStringLiteral("Principal")); - _rate = new QLineEdit; - _rate->setPlaceholderText(QStringLiteral("Rate (bps)")); - _rate->setMaximumWidth(110); - _term = new QLineEdit; - _term->setPlaceholderText(QStringLiteral("Months")); - _term->setMaximumWidth(90); - auto* applyBtn = ui::button(QStringLiteral("Apply"), QStringLiteral("primary")); - apply->addWidget(_account); - apply->addWidget(_principal); - apply->addWidget(_rate); - apply->addWidget(_term); - apply->addWidget(applyBtn); - root->addWidget(applyCard); - QObject::connect(applyBtn, &QPushButton::clicked, this, [this] { applyLoan(); }); - - _status = ui::label(QString(), QStringLiteral("Muted")); - root->addWidget(_status); - - // ── Body: loan list (top) + schedule (below), stacked full-width ─────────── - auto* body = new QVBoxLayout; - body->setSpacing(16); - - auto* scroll = new QScrollArea; - scroll->setWidgetResizable(true); - scroll->setFrameShape(QFrame::NoFrame); - auto* host = new QWidget; - _list = new QVBoxLayout(host); - _list->setContentsMargins(0, 0, 0, 0); - _list->setSpacing(12); - _list->setAlignment(Qt::AlignTop); - scroll->setWidget(host); - body->addWidget(scroll, 1); - - auto* schedCard = ui::card(); - auto* sched = new QVBoxLayout(schedCard); - sched->setContentsMargins(16, 14, 16, 14); - sched->addWidget(ui::label(QStringLiteral("Amortization schedule"), QStringLiteral("H2"))); - _schedule = new QTableWidget(0, 4); - _schedule->setHorizontalHeaderLabels( - {QStringLiteral("#"), QStringLiteral("Principal"), QStringLiteral("Interest"), QStringLiteral("Remaining")}); - _schedule->horizontalHeader()->setStretchLastSection(true); - _schedule->verticalHeader()->setVisible(false); - _schedule->setEditTriggers(QAbstractItemView::NoEditTriggers); - _schedule->setSelectionMode(QAbstractItemView::NoSelection); - _schedule->setShowGrid(false); - sched->addWidget(_schedule); - body->addWidget(schedCard, 1); - - root->addLayout(body, 1); -} - -void LoansView::setStatus(const QString& message, bool error) { - _status->setObjectName(error ? QStringLiteral("Danger") : QStringLiteral("Success")); - _status->setText(message); - ui::repolish(_status); -} - -void LoansView::applyLoan() { - if (!_account->currentData().isValid()) { - setStatus(QStringLiteral("Pick an account."), true); - return; - } - const auto principal = ui::parseToMinor(_principal->text(), 2); - bool rateOk = false; - bool termOk = false; - const int rate = _rate->text().trimmed().toInt(&rateOk); - const int term = _term->text().trimmed().toInt(&termOk); - if (!principal || !rateOk || !termOk || term <= 0) { - setStatus(QStringLiteral("Enter principal, rate (bps), and term (months)."), true); - return; - } - _loans - .execute(bank::dto::ApplyLoan{.accountId = _account->currentData().toLongLong(), - .principalMinor = *principal, - .rateBps = rate, - .termMonths = term}) - .then([this](bank::dto::LoanInfo) { - _principal->clear(); - _rate->clear(); - _term->clear(); - setStatus(QStringLiteral("Loan disbursed."), false); - refresh(); - }) - .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); -} - -void LoansView::refresh() { - _accounts.execute(bank::dto::ListAccounts{}) - .then([this](bank::dto::AccountList list) { - _account->clear(); - for (const auto& account : list.accounts) { - if (account.status == static_cast(bank::AccountStatus::Closed)) { - continue; - } - _account->addItem(QStringLiteral("•••• ") + QString::fromStdString(account.number).right(4), - QVariant::fromValue(account.id)); - } - }) - .onError([](const std::exception_ptr&) {}); - - _loans.execute(bank::dto::ListLoans{}) - .then([this](bank::dto::LoanList list) { rebuild(list.loans); }) - .onError([](const std::exception_ptr&) {}); -} - -void LoansView::rebuild(const std::vector& loans) { - ui::clearLayout(_list); - for (const auto& loan : loans) { - auto* frame = ui::card(); - auto* row = new QHBoxLayout(frame); - row->setContentsMargins(20, 14, 20, 14); - - auto* info = new QVBoxLayout; - info->addWidget(ui::label(QStringLiteral("Loan #%1").arg(loan.id), QStringLiteral("H2"))); - info->addWidget(ui::label(QStringLiteral("Outstanding ") + - ui::formatMinor(loan.outstandingMinor, loan.currency) + - QStringLiteral(" · %1 bps · %2 mo").arg(loan.rateBps).arg(loan.termMonths), - QStringLiteral("Muted"))); - row->addLayout(info); - row->addStretch(); - - const bool paid = loan.status == static_cast(bank::LoanStatus::PaidOff); - row->addWidget(ui::pill(loanStatusName(loan.status), - paid ? QStringLiteral("good") : QStringLiteral("neutral"))); - - const auto id = loan.id; - const auto accountId = loan.accountId; - const auto outstanding = loan.outstandingMinor; - auto* sched = ui::button(QStringLiteral("Schedule")); - QObject::connect(sched, &QPushButton::clicked, this, [this, id] { showSchedule(id); }); - row->addWidget(sched); - - if (!paid) { - auto* repay = ui::button(QStringLiteral("Repay"), QStringLiteral("primary")); - QObject::connect(repay, &QPushButton::clicked, this, [this, id, accountId, outstanding] { - _loans - .execute(bank::dto::RepayLoan{.loanId = id, .fromAccountId = accountId, - .amountMinor = outstanding}) - .then([this](bank::dto::LoanInfo) { setStatus(QStringLiteral("Repayment made."), false); refresh(); }) - .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); - }); - row->addWidget(repay); - } - _list->addWidget(frame); - } -} - -void LoansView::showSchedule(std::int64_t loanId) { - _loans.execute(bank::dto::LoanScheduleRequest{.loanId = loanId}) - .then([this](bank::dto::LoanScheduleResult result) { - _schedule->setRowCount(static_cast(result.installments.size())); - int row = 0; - for (const auto& inst : result.installments) { - _schedule->setItem(row, 0, new QTableWidgetItem(QString::number(inst.month))); - _schedule->setItem(row, 1, new QTableWidgetItem(ui::formatMinor(inst.principalMinor, 0))); - _schedule->setItem(row, 2, new QTableWidgetItem(ui::formatMinor(inst.interestMinor, 0))); - _schedule->setItem(row, 3, new QTableWidgetItem(ui::formatMinor(inst.remainingMinor, 0))); - ++row; - } - }) - .onError([](const std::exception_ptr&) {}); -} - -} // namespace bankgui diff --git a/examples/bank/gui/views/LoansView.hpp b/examples/bank/gui/views/LoansView.hpp deleted file mode 100644 index 4b3fb58..0000000 --- a/examples/bank/gui/views/LoansView.hpp +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#pragma once - -#include - -#include - -#include "../BankClient.hpp" -#include "../Page.hpp" -#include "bank/dto/loan_dto.hpp" -#include "bank/models/account_model.hpp" -#include "bank/models/loan_model.hpp" - -class QComboBox; -class QLineEdit; -class QLabel; -class QVBoxLayout; -class QTableWidget; - -namespace bankgui { - -/// @brief Apply for loans, view amortization schedules, and make repayments. -class LoansView : public Page { -public: - explicit LoansView(BankClient& client, QWidget* parent = nullptr); - void refresh() override; - -private: - void applyLoan(); - void rebuild(const std::vector& loans); - void showSchedule(std::int64_t loanId); - void setStatus(const QString& message, bool error); - - BankClient& _client; - morph::bridge::BridgeHandler _loans; - morph::bridge::BridgeHandler _accounts; - QComboBox* _account{}; - QLineEdit* _principal{}; - QLineEdit* _rate{}; - QLineEdit* _term{}; - QLabel* _status{}; - QVBoxLayout* _list{}; - QTableWidget* _schedule{}; -}; - -} // namespace bankgui diff --git a/examples/bank/gui/views/MoveMoneyView.cpp b/examples/bank/gui/views/MoveMoneyView.cpp deleted file mode 100644 index e921e22..0000000 --- a/examples/bank/gui/views/MoveMoneyView.cpp +++ /dev/null @@ -1,225 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#include "MoveMoneyView.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "../Ui.hpp" -#include "bank/core/types.hpp" - -namespace bankgui { - -namespace { - -QString txnKindName(int kind) { - switch (static_cast(kind)) { - case bank::TxnKind::Deposit: return QStringLiteral("Deposit"); - case bank::TxnKind::Withdrawal: return QStringLiteral("Withdrawal"); - case bank::TxnKind::TransferIn: return QStringLiteral("Transfer in"); - case bank::TxnKind::TransferOut: return QStringLiteral("Transfer out"); - case bank::TxnKind::Payment: return QStringLiteral("Payment"); - case bank::TxnKind::Fee: return QStringLiteral("Fee"); - case bank::TxnKind::Interest: return QStringLiteral("Interest"); - case bank::TxnKind::LoanDisbursement: return QStringLiteral("Loan in"); - case bank::TxnKind::LoanRepayment: return QStringLiteral("Loan repay"); - case bank::TxnKind::CardPurchase: return QStringLiteral("Card"); - case bank::TxnKind::Exchange: return QStringLiteral("Exchange"); - } - return QStringLiteral("Entry"); -} - -QString accountLabel(const bank::dto::AccountInfo& account) { - return QStringLiteral("•••• %1 (%2)") - .arg(QString::fromStdString(account.number).right(4), - ui::formatMinor(account.balanceMinor, account.currency)); -} - -} // namespace - -MoveMoneyView::MoveMoneyView(BankClient& client, QWidget* parent) - : Page(parent), - _client{client}, - _accounts{client.bridge(), client.gui()}, - _txns{client.bridge(), client.gui()} { - auto* root = new QVBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(18); - - // ── Move money card ──────────────────────────────────────────────────── - auto* moveCard = ui::card(); - auto* move = new QVBoxLayout(moveCard); - move->setContentsMargins(20, 18, 20, 18); - move->setSpacing(12); - move->addWidget(ui::label(QStringLiteral("Move money"), QStringLiteral("H2"))); - - auto* accRow = new QHBoxLayout; - accRow->addWidget(ui::label(QStringLiteral("Account"), QStringLiteral("Muted"))); - _account = new QComboBox; - _account->setMinimumWidth(280); - accRow->addWidget(_account, 1); - move->addLayout(accRow); - QObject::connect(_account, &QComboBox::currentIndexChanged, this, [this] { reloadHistory(); }); - - // Deposit + withdraw row. - auto* dwRow = new QHBoxLayout; - _amount = new QLineEdit; - _amount->setPlaceholderText(QStringLiteral("Amount")); - auto* deposit = ui::button(QStringLiteral("Deposit"), QStringLiteral("primary")); - auto* withdraw = ui::button(QStringLiteral("Withdraw")); - dwRow->addWidget(_amount, 1); - dwRow->addWidget(deposit); - dwRow->addWidget(withdraw); - move->addLayout(dwRow); - - // Transfer row. - auto* tRow = new QHBoxLayout; - _target = new QComboBox; - _target->setMinimumWidth(220); - _transferAmount = new QLineEdit; - _transferAmount->setPlaceholderText(QStringLiteral("Amount")); - auto* transfer = ui::button(QStringLiteral("Transfer to"), QStringLiteral("primary")); - tRow->addWidget(transfer); - tRow->addWidget(_target, 1); - tRow->addWidget(_transferAmount, 1); - move->addLayout(tRow); - - _status = ui::label(QString(), QStringLiteral("Muted")); - _status->setWordWrap(true); - move->addWidget(_status); - root->addWidget(moveCard); - - // ── History card ───────────────────────────────────────────────────────── - auto* histCard = ui::card(); - auto* hist = new QVBoxLayout(histCard); - hist->setContentsMargins(20, 18, 20, 18); - hist->setSpacing(12); - hist->addWidget(ui::label(QStringLiteral("Recent activity"), QStringLiteral("H2"))); - _history = new QTableWidget(0, 3); - _history->setHorizontalHeaderLabels({QStringLiteral("Type"), QStringLiteral("Amount"), - QStringLiteral("Balance")}); - _history->horizontalHeader()->setStretchLastSection(true); - _history->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); - _history->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); - _history->verticalHeader()->setVisible(false); - _history->setEditTriggers(QAbstractItemView::NoEditTriggers); - _history->setSelectionMode(QAbstractItemView::NoSelection); - _history->setShowGrid(false); - hist->addWidget(_history); - root->addWidget(histCard, 1); - - // ── Wiring ──────────────────────────────────────────────────────────────── - QObject::connect(deposit, &QPushButton::clicked, this, [this] { - auto minor = ui::parseToMinor(_amount->text(), bank::currencyDecimals(static_cast(selectedCurrency()))); - if (!minor || selectedAccountId() == 0) { - setStatus(QStringLiteral("Enter a valid amount."), true); - return; - } - _txns.execute(bank::dto::Deposit{.accountId = selectedAccountId(), .amountMinor = *minor}) - .then([this](bank::dto::TxnInfo) { _amount->clear(); setStatus(QStringLiteral("Deposit complete."), false); refresh(); }) - .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); - }); - QObject::connect(withdraw, &QPushButton::clicked, this, [this] { - auto minor = ui::parseToMinor(_amount->text(), bank::currencyDecimals(static_cast(selectedCurrency()))); - if (!minor || selectedAccountId() == 0) { - setStatus(QStringLiteral("Enter a valid amount."), true); - return; - } - _txns.execute(bank::dto::Withdraw{.accountId = selectedAccountId(), .amountMinor = *minor}) - .then([this](bank::dto::TxnInfo) { _amount->clear(); setStatus(QStringLiteral("Withdrawal complete."), false); refresh(); }) - .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); - }); - QObject::connect(transfer, &QPushButton::clicked, this, [this] { - auto minor = ui::parseToMinor(_transferAmount->text(), bank::currencyDecimals(static_cast(selectedCurrency()))); - const auto to = _target->currentData().toLongLong(); - if (!minor || selectedAccountId() == 0 || to == 0) { - setStatus(QStringLiteral("Pick a target account and amount."), true); - return; - } - _txns.execute(bank::dto::Transfer{.fromAccountId = selectedAccountId(), .toAccountId = to, .amountMinor = *minor}) - .then([this](bank::dto::TransferResult) { _transferAmount->clear(); setStatus(QStringLiteral("Transfer complete."), false); refresh(); }) - .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); - }); -} - -std::int64_t MoveMoneyView::selectedAccountId() const { - return _account->currentData().isValid() ? _account->currentData().toLongLong() : 0; -} - -int MoveMoneyView::selectedCurrency() const { - const auto id = selectedAccountId(); - for (const auto& account : _cache) { - if (account.id == id) { - return account.currency; - } - } - return 0; -} - -void MoveMoneyView::setStatus(const QString& message, bool error) { - _status->setObjectName(error ? QStringLiteral("Danger") : QStringLiteral("Success")); - _status->setText(message); - ui::repolish(_status); -} - -void MoveMoneyView::refresh() { - _accounts.execute(bank::dto::ListAccounts{}) - .then([this](bank::dto::AccountList list) { - const auto previous = selectedAccountId(); - _cache.clear(); - _account->clear(); - _target->clear(); - for (const auto& account : list.accounts) { - if (account.status == static_cast(bank::AccountStatus::Closed)) { - continue; - } - _cache.push_back(account); - _account->addItem(accountLabel(account), QVariant::fromValue(account.id)); - _target->addItem(accountLabel(account), QVariant::fromValue(account.id)); - } - // Restore prior selection if still present. - const int idx = _account->findData(QVariant::fromValue(previous)); - if (idx >= 0) { - _account->setCurrentIndex(idx); - } - reloadHistory(); - }) - .onError([](const std::exception_ptr&) {}); -} - -void MoveMoneyView::reloadHistory() { - const auto id = selectedAccountId(); - if (id == 0) { - _history->setRowCount(0); - return; - } - _txns.execute(bank::dto::History{.accountId = id, .limit = 50}) - .then([this](bank::dto::HistoryPage page) { - _history->setRowCount(static_cast(page.entries.size())); - int row = 0; - for (const auto& entry : page.entries) { - const bool credit = entry.direction == static_cast(bank::TxnDirection::Credit); - const QString sign = credit ? QStringLiteral("+") : QStringLiteral("−"); - auto* kind = new QTableWidgetItem(txnKindName(entry.kind)); - auto* amount = new QTableWidgetItem(sign + ui::formatMinor(entry.amountMinor, entry.currency)); - amount->setForeground(QColor(credit ? "#2F9E66" : "#C0392B")); - auto* balance = new QTableWidgetItem(ui::formatMinor(entry.balanceAfterMinor, entry.currency)); - _history->setItem(row, 0, kind); - _history->setItem(row, 1, amount); - _history->setItem(row, 2, balance); - ++row; - } - }) - .onError([](const std::exception_ptr&) {}); -} - -} // namespace bankgui diff --git a/examples/bank/gui/views/MoveMoneyView.hpp b/examples/bank/gui/views/MoveMoneyView.hpp deleted file mode 100644 index 0f52220..0000000 --- a/examples/bank/gui/views/MoveMoneyView.hpp +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#pragma once - -#include - -#include - -#include "../BankClient.hpp" -#include "../Page.hpp" -#include "bank/dto/account_dto.hpp" -#include "bank/dto/transaction_dto.hpp" -#include "bank/models/account_model.hpp" -#include "bank/models/transaction_model.hpp" - -class QComboBox; -class QLineEdit; -class QLabel; -class QTableWidget; - -namespace bankgui { - -/// @brief Deposit / withdraw / transfer plus the selected account's history. -class MoveMoneyView : public Page { -public: - explicit MoveMoneyView(BankClient& client, QWidget* parent = nullptr); - void refresh() override; - -private: - [[nodiscard]] std::int64_t selectedAccountId() const; - [[nodiscard]] int selectedCurrency() const; - void reloadHistory(); - void setStatus(const QString& message, bool error); - - BankClient& _client; - morph::bridge::BridgeHandler _accounts; - morph::bridge::BridgeHandler _txns; - std::vector _cache; - QComboBox* _account{}; - QComboBox* _target{}; - QLineEdit* _amount{}; - QLineEdit* _transferAmount{}; - QLabel* _status{}; - QTableWidget* _history{}; -}; - -} // namespace bankgui diff --git a/examples/bank/gui/views/PayeesView.cpp b/examples/bank/gui/views/PayeesView.cpp deleted file mode 100644 index bfcd762..0000000 --- a/examples/bank/gui/views/PayeesView.cpp +++ /dev/null @@ -1,175 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#include "PayeesView.hpp" - -#include -#include -#include -#include -#include -#include - -#include - -#include "../Ui.hpp" -#include "bank/core/types.hpp" -#include "bank/dto/account_dto.hpp" -#include "bank/dto/payment_dto.hpp" - -namespace bankgui { - -PayeesView::PayeesView(BankClient& client, QWidget* parent) - : Page(parent), - _client{client}, - _payees{client.bridge(), client.gui()}, - _accounts{client.bridge(), client.gui()}, - _payments{client.bridge(), client.gui()} { - auto* root = new QVBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(18); - - // ── Add payee ────────────────────────────────────────────────────────── - auto* addCard = ui::card(); - auto* add = new QHBoxLayout(addCard); - add->setContentsMargins(16, 14, 16, 14); - add->setSpacing(10); - _name = new QLineEdit; - _name->setPlaceholderText(QStringLiteral("Payee name")); - _iban = new QLineEdit; - _iban->setPlaceholderText(QStringLiteral("IBAN")); - _bank = new QLineEdit; - _bank->setPlaceholderText(QStringLiteral("Bank (optional)")); - auto* addBtn = ui::button(QStringLiteral("Add payee"), QStringLiteral("primary")); - add->addWidget(_name, 1); - add->addWidget(_iban, 1); - add->addWidget(_bank, 1); - add->addWidget(addBtn); - root->addWidget(addCard); - QObject::connect(addBtn, &QPushButton::clicked, this, [this] { addPayee(); }); - - // ── Pay a bill ───────────────────────────────────────────────────────── - auto* payCard = ui::card(); - auto* pay = new QHBoxLayout(payCard); - pay->setContentsMargins(16, 14, 16, 14); - pay->setSpacing(10); - pay->addWidget(ui::label(QStringLiteral("Pay bill"), QStringLiteral("H2"))); - pay->addStretch(); - _payAccount = new QComboBox; - _payAccount->setMinimumWidth(180); - _payPayee = new QComboBox; - _payPayee->setMinimumWidth(180); - _payAmount = new QLineEdit; - _payAmount->setPlaceholderText(QStringLiteral("Amount")); - auto* payBtn = ui::button(QStringLiteral("Pay"), QStringLiteral("primary")); - pay->addWidget(_payAccount); - pay->addWidget(_payPayee); - pay->addWidget(_payAmount); - pay->addWidget(payBtn); - root->addWidget(payCard); - QObject::connect(payBtn, &QPushButton::clicked, this, [this] { payBill(); }); - - _status = ui::label(QString(), QStringLiteral("Muted")); - root->addWidget(_status); - - // ── Payee list ───────────────────────────────────────────────────────── - auto* scroll = new QScrollArea; - scroll->setWidgetResizable(true); - scroll->setFrameShape(QFrame::NoFrame); - auto* host = new QWidget; - _list = new QVBoxLayout(host); - _list->setContentsMargins(0, 0, 0, 0); - _list->setSpacing(12); - _list->setAlignment(Qt::AlignTop); - scroll->setWidget(host); - root->addWidget(scroll, 1); -} - -void PayeesView::setStatus(const QString& message, bool error) { - _status->setObjectName(error ? QStringLiteral("Danger") : QStringLiteral("Success")); - _status->setText(message); - ui::repolish(_status); -} - -void PayeesView::addPayee() { - _payees - .execute(bank::dto::AddPayee{.name = _name->text().toStdString(), - .iban = _iban->text().trimmed().toStdString(), - .bankName = _bank->text().toStdString()}) - .then([this](bank::dto::PayeeInfo) { - _name->clear(); - _iban->clear(); - _bank->clear(); - setStatus(QStringLiteral("Payee added."), false); - refresh(); - }) - .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); -} - -void PayeesView::payBill() { - if (!_payAccount->currentData().isValid() || !_payPayee->currentData().isValid()) { - setStatus(QStringLiteral("Pick an account and payee."), true); - return; - } - const auto minor = ui::parseToMinor(_payAmount->text(), 2); - if (!minor) { - setStatus(QStringLiteral("Enter a valid amount."), true); - return; - } - _payments - .execute(bank::dto::PayBill{.fromAccountId = _payAccount->currentData().toLongLong(), - .payeeId = _payPayee->currentData().toLongLong(), - .amountMinor = *minor}) - .then([this](bank::dto::PaymentInfo) { - _payAmount->clear(); - setStatus(QStringLiteral("Payment sent."), false); - }) - .onError([this](const std::exception_ptr& err) { setStatus(ui::errorText(err), true); }); -} - -void PayeesView::refresh() { - _accounts.execute(bank::dto::ListAccounts{}) - .then([this](bank::dto::AccountList list) { - _payAccount->clear(); - for (const auto& account : list.accounts) { - if (account.status == static_cast(bank::AccountStatus::Closed)) { - continue; - } - _payAccount->addItem(QStringLiteral("•••• ") + QString::fromStdString(account.number).right(4), - QVariant::fromValue(account.id)); - } - }) - .onError([](const std::exception_ptr&) {}); - - _payees.execute(bank::dto::ListPayees{}) - .then([this](bank::dto::PayeeList list) { rebuild(list.payees); }) - .onError([](const std::exception_ptr&) {}); -} - -void PayeesView::rebuild(const std::vector& payees) { - ui::clearLayout(_list); - _payPayee->clear(); - for (const auto& payee : payees) { - _payPayee->addItem(QString::fromStdString(payee.name), QVariant::fromValue(payee.id)); - - auto* frame = ui::card(); - auto* row = new QHBoxLayout(frame); - row->setContentsMargins(20, 14, 20, 14); - auto* info = new QVBoxLayout; - info->addWidget(ui::label(QString::fromStdString(payee.name), QStringLiteral("H2"))); - info->addWidget(ui::label(QString::fromStdString(payee.iban), QStringLiteral("Muted"))); - row->addLayout(info); - row->addStretch(); - - const auto id = payee.id; - auto* remove = ui::button(QStringLiteral("Remove"), QStringLiteral("danger")); - QObject::connect(remove, &QPushButton::clicked, this, [this, id] { - _payees.execute(bank::dto::RemovePayee{.id = id}) - .then([this](bank::dto::CommandResult) { refresh(); }) - .onError([](const std::exception_ptr&) {}); - }); - row->addWidget(remove); - _list->addWidget(frame); - } -} - -} // namespace bankgui diff --git a/examples/bank/gui/views/PayeesView.hpp b/examples/bank/gui/views/PayeesView.hpp deleted file mode 100644 index db3b129..0000000 --- a/examples/bank/gui/views/PayeesView.hpp +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#pragma once - -#include - -#include - -#include "../BankClient.hpp" -#include "../Page.hpp" -#include "bank/dto/payee_dto.hpp" -#include "bank/models/account_model.hpp" -#include "bank/models/payee_model.hpp" -#include "bank/models/payment_model.hpp" - -class QComboBox; -class QLineEdit; -class QLabel; -class QVBoxLayout; - -namespace bankgui { - -/// @brief Manage beneficiaries and pay bills. -class PayeesView : public Page { -public: - explicit PayeesView(BankClient& client, QWidget* parent = nullptr); - void refresh() override; - -private: - void addPayee(); - void payBill(); - void rebuild(const std::vector& payees); - void setStatus(const QString& message, bool error); - - BankClient& _client; - morph::bridge::BridgeHandler _payees; - morph::bridge::BridgeHandler _accounts; - morph::bridge::BridgeHandler _payments; - QLineEdit* _name{}; - QLineEdit* _iban{}; - QLineEdit* _bank{}; - QComboBox* _payAccount{}; - QComboBox* _payPayee{}; - QLineEdit* _payAmount{}; - QLabel* _status{}; - QVBoxLayout* _list{}; -}; - -} // namespace bankgui From 52c855101854e0c9c7e721be7941ce52c8d5ef4a Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 29 Jun 2026 21:37:10 +0200 Subject: [PATCH 03/10] [morph] bank GUI: make demo-seed credentials configurable The headless seed/screenshot path now reads BANK_SEED_USER / BANK_SEED_PASS (default gui-demo / demo1234), so the demo database can be populated with a chosen login, e.g. BANK_SEED_USER=test BANK_SEED_PASS=test. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FXnfVmdiHoCRGzj8PtuAJq --- examples/bank/gui/main.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/examples/bank/gui/main.cpp b/examples/bank/gui/main.cpp index 83d9d3a..0708846 100644 --- a/examples/bank/gui/main.cpp +++ b/examples/bank/gui/main.cpp @@ -109,7 +109,13 @@ int main(int argc, char* argv[]) { window->grabWindow().save(out + "/qml_login.png"); } - appController.registerUser("gui-demo", "demo1234", "Demo User"); + const QByteArray seedUser = qgetenv("BANK_SEED_USER"); + const QByteArray seedPass = qgetenv("BANK_SEED_PASS"); + appController.registerUser(seedUser.isEmpty() ? QStringLiteral("gui-demo") + : QString::fromUtf8(seedUser), + seedPass.isEmpty() ? QStringLiteral("demo1234") + : QString::fromUtf8(seedPass), + QStringLiteral("Demo User")); pump(600); accountController.openAccount(0, 0, "500"); pump(300); From a93efc9bf9d274f7c4fee3d2bee10bb4c5267096 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 29 Jun 2026 22:21:24 +0200 Subject: [PATCH 04/10] [morph] bank example: fix authorization, money UB, and query-efficiency defects Two rounds of code review surfaced a cluster of correctness, security, and cleanup issues in the bank example. Fixes: Authorization / correctness - Enforce ownership on every single-record read and money-movement path: GetAccount, GetLoan, CloseAccount, and (newly) Deposit/Withdraw/Transfer previously let any authenticated user touch another customer's account. Centralize the rule in db::loadOwned and db::loadOwnedOpenAccount (owner checked before status, so non-owners never learn an account's state). - IssueCard now requires an open account the caller owns (was issuable on closed accounts; also leaked status to non-owners). - money::format no longer calls std::llabs on the amount (UB at INT64_MIN); computes the magnitude via well-defined unsigned negation. - LoanController distinguishes a blank rate field (-1, rejected) from an intentional 0% loan (accepted, which the model supports). - CloseAccount's zero-balance guard is documented as best-effort given the per-model-connection design (no false atomicity claim). Concurrency - Give every SQLite connection a busy timeout so contending writers wait rather than failing with SQLITE_BUSY; wrap deposit/withdraw balance+ledger writes in a transaction. Residual cross-connection limits are documented. Efficiency - History paginates in the DB (ORDER BY id DESC + Range) instead of loading and sorting the whole ledger. - Statement generation uses one WhereIn query instead of N+1 per account. - SpendingByKind / Statement push their direction/time filters into SQL; MarkAllRead filters to unread rows. Reuse - Single ownership-check helper replaces ~11 hand-rolled copies across models. - Shared bank::demoHash backs both hashPin and hashPassword. - Shared pow10i / currencyScale back money::format and the GUI parseMinor. Adds a regression test asserting a non-owner cannot withdraw or transfer. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/bank/gui/controllers/Format.hpp | 6 +- .../bank/gui/controllers/LoanController.cpp | 6 +- examples/bank/gui/qml/LoansPage.qml | 6 +- examples/bank/include/bank/core/demo_hash.hpp | 24 +++++++ examples/bank/include/bank/core/types.hpp | 16 +++++ examples/bank/include/bank/db/ledger_ops.hpp | 44 ++++++++++++- examples/bank/src/core/money.cpp | 16 ++--- examples/bank/src/db/schema.cpp | 20 +++++- examples/bank/src/models/account_model.cpp | 25 ++++---- examples/bank/src/models/auth_model.cpp | 7 +-- examples/bank/src/models/budget_model.cpp | 24 +++---- examples/bank/src/models/card_model.cpp | 23 ++----- examples/bank/src/models/loan_model.cpp | 25 ++------ .../bank/src/models/notification_model.cpp | 23 +++---- examples/bank/src/models/payee_model.cpp | 11 +--- examples/bank/src/models/payment_model.cpp | 29 ++------- examples/bank/src/models/statement_model.cpp | 62 +++++++++++++------ .../bank/src/models/transaction_model.cpp | 49 +++++++++------ examples/bank/tests/test_transaction.cpp | 12 ++++ 19 files changed, 255 insertions(+), 173 deletions(-) create mode 100644 examples/bank/include/bank/core/demo_hash.hpp diff --git a/examples/bank/gui/controllers/Format.hpp b/examples/bank/gui/controllers/Format.hpp index 950e59d..79d43f1 100644 --- a/examples/bank/gui/controllers/Format.hpp +++ b/examples/bank/gui/controllers/Format.hpp @@ -58,10 +58,8 @@ inline std::optional parseMinor(const QString& text, int decimals if (!ok || major < 0.0) { return std::nullopt; } - double scale = 1.0; - for (int idx = 0; idx < decimals; ++idx) { - scale *= 10.0; - } + // Reuse the core scale primitive so parse and format share one source. + const auto scale = static_cast(bank::pow10i(decimals)); return static_cast(major * scale + 0.5); } diff --git a/examples/bank/gui/controllers/LoanController.cpp b/examples/bank/gui/controllers/LoanController.cpp index 57a44e9..db090f6 100644 --- a/examples/bank/gui/controllers/LoanController.cpp +++ b/examples/bank/gui/controllers/LoanController.cpp @@ -70,8 +70,10 @@ void LoanController::reloadLoans() { void LoanController::apply(qlonglong accountId, const QString& principal, int rateBps, int termMonths) { const auto minor = fmt::parseMinor(principal); - if (!minor || accountId == 0 || termMonths <= 0) { - emit error(QStringLiteral("Enter account, principal, and term.")); + // rateBps < 0 means the field was left blank (see LoansPage.qml); a rate of 0 + // is a valid interest-free loan, which the model accepts. + if (!minor || accountId == 0 || rateBps < 0 || termMonths <= 0) { + emit error(QStringLiteral("Enter account, principal, rate (bps), and term.")); return; } _loanModel diff --git a/examples/bank/gui/qml/LoansPage.qml b/examples/bank/gui/qml/LoansPage.qml index fadfbeb..2f2bd19 100644 --- a/examples/bank/gui/qml/LoansPage.qml +++ b/examples/bank/gui/qml/LoansPage.qml @@ -25,7 +25,11 @@ ColumnLayout { text: "Apply" variant: "primary" onClicked: { - loans.apply(account.currentValue, principal.text, parseInt(rate.text || "0"), parseInt(term.text || "0")); + // Pass -1 for a blank rate so the controller can tell "left blank" + // apart from an intentional 0% loan (which the model allows). + var rateBps = rate.text.length > 0 ? parseInt(rate.text) : -1; + loans.apply(account.currentValue, principal.text, + isNaN(rateBps) ? -1 : rateBps, parseInt(term.text || "0")); principal.text = ""; rate.text = ""; term.text = ""; } } diff --git a/examples/bank/include/bank/core/demo_hash.hpp b/examples/bank/include/bank/core/demo_hash.hpp new file mode 100644 index 0000000..467837d --- /dev/null +++ b/examples/bank/include/bank/core/demo_hash.hpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +/// @file +/// One demo-grade hash shared by the PIN and password paths. + +namespace bank { + +/// @brief Demo-grade, **non-secure** hash of @p material. +/// +/// A real app would use a slow, salted KDF (Argon2/bcrypt). Centralised here so +/// the PIN and password hashes cannot drift onto different implementations when +/// this is later upgraded — both go through this one function. Callers are +/// responsible for salting @p material (e.g. with a username or a field tag). +[[nodiscard]] inline std::string demoHash(std::string_view material) { + return std::format("{:016x}", std::hash{}(std::string{material})); +} + +} // namespace bank diff --git a/examples/bank/include/bank/core/types.hpp b/examples/bank/include/bank/core/types.hpp index aca4ecd..a92f354 100644 --- a/examples/bank/include/bank/core/types.hpp +++ b/examples/bank/include/bank/core/types.hpp @@ -28,6 +28,22 @@ enum class Currency : std::uint8_t { return c == Currency::JPY ? 0 : 2; } +/// @brief Integer 10^exponent. Single source of truth for the parse/format +/// scale, so the hand-rolled `10^decimals` loops don't drift apart across the +/// core and the GUI. +[[nodiscard]] constexpr std::int64_t pow10i(int exponent) noexcept { + std::int64_t value = 1; + for (int idx = 0; idx < exponent; ++idx) { + value *= 10; + } + return value; +} + +/// @brief Number of minor units in one major unit of @p c, i.e. 10^decimals. +[[nodiscard]] constexpr std::int64_t currencyScale(Currency c) noexcept { + return pow10i(currencyDecimals(c)); +} + /// @brief Three-letter code for a currency (for display / statements). [[nodiscard]] constexpr std::string_view currencyCode(Currency c) noexcept { switch (c) { diff --git a/examples/bank/include/bank/db/ledger_ops.hpp b/examples/bank/include/bank/db/ledger_ops.hpp index 217e9e5..2d29090 100644 --- a/examples/bank/include/bank/db/ledger_ops.hpp +++ b/examples/bank/include/bank/db/ledger_ops.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "bank/core/errors.hpp" #include "bank/core/types.hpp" @@ -19,6 +20,16 @@ /// /// The caller passes its own `DataMapper`; when several writes must be atomic, /// it wraps the calls in a `SqlTransaction` over `mapper.Connection()`. +/// +/// Concurrency note: `applyCredit`/`applyDebit` read the balance, adjust it in +/// memory, and write it back. Because each model owns a *separate* SQLite +/// connection, two models mutating the same account concurrently could +/// interleave and lose an update. The example mitigates this by (a) giving every +/// connection a busy timeout (see `db::configure`) so writers serialize rather +/// than fail, and (b) wrapping each balance change in a `SqlTransaction` so the +/// balance write and its ledger entry commit as a unit. A production ledger +/// would additionally use an atomic `balance = balance - ?` update or an +/// optimistic version column to fully close the read-modify-write window. namespace bank::db { @@ -29,13 +40,40 @@ namespace bank::db { .count(); } -/// @brief Loads an account by id, requiring it to exist and be open. -/// @throws NotFound if the account does not exist; ConflictError if not open. -[[nodiscard]] inline AccountRecord loadOpenAccount(Lightweight::DataMapper& mapper, std::int64_t accountId) { +/// @brief Loads a record by id, requiring it to exist and be owned by @p owner. +/// +/// Every per-resource access check (accounts, loans, cards, payees, payments, +/// budgets, notifications) shares this one guard so the authorization rule lives +/// in a single place. @p noun is woven into the thrown messages, e.g. "loan". +/// @throws NotFound if no row has that id; Unauthorized if it belongs elsewhere. +template +[[nodiscard]] Record loadOwned(Lightweight::DataMapper& mapper, std::int64_t id, + const std::string& owner, std::string_view noun) { + auto rec = mapper.QuerySingle(static_cast(id)); + if (!rec.has_value()) { + throw NotFound{std::string{noun} + " not found"}; + } + if (std::string{rec->owner.Value().str()} != owner) { + throw Unauthorized{std::string{noun} + " belongs to a different owner"}; + } + return *rec; +} + +/// @brief Loads an account, requiring it to exist, be owned by @p owner, and be +/// open — checked in that order so a non-owner never learns the account's +/// status. The single home for the "owned and open" rule that every +/// money-movement path (deposit, withdraw, transfer, payment, loan, card) +/// shares. +/// @throws NotFound if missing; Unauthorized if owned elsewhere; ConflictError if not open. +[[nodiscard]] inline AccountRecord loadOwnedOpenAccount(Lightweight::DataMapper& mapper, + std::int64_t accountId, const std::string& owner) { auto acct = mapper.QuerySingle(static_cast(accountId)); if (!acct.has_value()) { throw NotFound{"account not found"}; } + if (std::string{acct->owner.Value().str()} != owner) { + throw Unauthorized{"account belongs to a different owner"}; + } if (acct->status.Value() != static_cast(AccountStatus::Open)) { throw ConflictError{"account is not open"}; } diff --git a/examples/bank/src/core/money.cpp b/examples/bank/src/core/money.cpp index e0fa7a9..2ea4d1f 100644 --- a/examples/bank/src/core/money.cpp +++ b/examples/bank/src/core/money.cpp @@ -2,7 +2,7 @@ #include "bank/core/money.hpp" -#include +#include #include namespace bank { @@ -10,19 +10,19 @@ namespace bank { std::string format(Money amount) { const int decimals = currencyDecimals(amount.currency); const std::string_view code = currencyCode(amount.currency); - const std::int64_t magnitude = std::llabs(amount.minor); + // Take the magnitude in unsigned space: std::llabs(INT64_MIN) is UB, but + // unsigned negation (0 - x, modulo 2^64) is well-defined for every value. + const auto raw = static_cast(amount.minor); + const std::uint64_t magnitude = amount.minor < 0 ? 0ULL - raw : raw; const char* sign = amount.minor < 0 ? "-" : ""; if (decimals == 0) { return std::format("{}{} {}", sign, magnitude, code); } - std::int64_t scale = 1; - for (int idx = 0; idx < decimals; ++idx) { - scale *= 10; - } - const std::int64_t major = magnitude / scale; - const std::int64_t minor = magnitude % scale; + const auto scale = static_cast(currencyScale(amount.currency)); + const std::uint64_t major = magnitude / scale; + const std::uint64_t minor = magnitude % scale; return std::format("{}{}.{:0{}} {}", sign, major, minor, decimals, code); } diff --git a/examples/bank/src/db/schema.cpp b/examples/bank/src/db/schema.cpp index c50143d..16e2f8b 100644 --- a/examples/bank/src/db/schema.cpp +++ b/examples/bank/src/db/schema.cpp @@ -5,10 +5,28 @@ #include #include +#include +#include +#include + namespace bank::db { void configure(const std::string& connectionString) { - Lightweight::SqlConnection::SetDefaultConnectionString(Lightweight::SqlConnectionString{connectionString}); + // Each model opens its own SQLite connection to the same file. Give every + // connection a busy timeout so that when they contend on SQLite's single + // writer lock they wait-and-retry instead of failing immediately with + // SQLITE_BUSY. (The SQLite ODBC driver reads `Timeout` in milliseconds.) + std::string lower = connectionString; + std::ranges::transform(lower, lower.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + std::string augmented = connectionString; + if (lower.find("timeout=") == std::string::npos) { + if (!augmented.empty() && augmented.back() != ';') { + augmented.push_back(';'); + } + augmented += "Timeout=5000"; + } + Lightweight::SqlConnection::SetDefaultConnectionString(Lightweight::SqlConnectionString{augmented}); } void applyMigrations() { diff --git a/examples/bank/src/models/account_model.cpp b/examples/bank/src/models/account_model.cpp index b13cea4..8ab3fc5 100644 --- a/examples/bank/src/models/account_model.cpp +++ b/examples/bank/src/models/account_model.cpp @@ -12,6 +12,7 @@ #include "bank/core/principal.hpp" #include "bank/core/types.hpp" #include "bank/db/account_entity.hpp" +#include "bank/db/ledger_ops.hpp" namespace bank { @@ -94,23 +95,23 @@ dto::AccountList AccountModel::execute(const dto::ListAccounts& action) { } dto::AccountInfo AccountModel::execute(const dto::GetAccount& action) { - auto rec = mapper().QuerySingle(static_cast(action.id)); - if (!rec.has_value()) { - throw NotFound{"account not found"}; - } - return toInfo(*rec); + auto rec = db::loadOwned(mapper(), action.id, sessionPrincipal(), "account"); + return toInfo(rec); } dto::CommandResult AccountModel::execute(const dto::CloseAccount& action) { - auto rec = mapper().QuerySingle(static_cast(action.id)); - if (!rec.has_value()) { - throw NotFound{"account not found"}; - } - if (rec->balanceMinor.Value() != 0) { + auto rec = db::loadOwned(mapper(), action.id, sessionPrincipal(), "account"); + // Best-effort zero-balance guard. The balance is read on this model's own + // connection, so a deposit committing on another model's connection between + // this read and the Update could leave a Closed account holding funds — the + // same cross-connection window documented in ledger_ops.hpp. A production + // ledger would close the account inside the same transaction that settles + // its balance, or gate on an atomic conditional update. + if (rec.balanceMinor.Value() != 0) { return dto::CommandResult{.ok = false, .message = "account balance must be zero before closing"}; } - rec->status = static_cast(AccountStatus::Closed); - mapper().Update(*rec); + rec.status = static_cast(AccountStatus::Closed); + mapper().Update(rec); return dto::CommandResult{.ok = true, .message = "account closed"}; } diff --git a/examples/bank/src/models/auth_model.cpp b/examples/bank/src/models/auth_model.cpp index 418bff3..00ebff8 100644 --- a/examples/bank/src/models/auth_model.cpp +++ b/examples/bank/src/models/auth_model.cpp @@ -4,12 +4,11 @@ #include -#include -#include #include #include #include +#include "bank/core/demo_hash.hpp" #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" #include "bank/db/user_entity.hpp" @@ -22,9 +21,7 @@ namespace { /// salted KDF (Argon2/bcrypt). Salting with the username keeps identical /// passwords from colliding across users. std::string hashPassword(std::string_view username, std::string_view password) { - const std::string material = std::string{username} + ":" + std::string{password} + ":morph-bank"; - const std::size_t digest = std::hash{}(material); - return std::format("{:016x}", digest); + return demoHash(std::string{username} + ":" + std::string{password} + ":morph-bank"); } /// Finds a user by username, or std::nullopt. diff --git a/examples/bank/src/models/budget_model.cpp b/examples/bank/src/models/budget_model.cpp index 8d94e0f..951b91b 100644 --- a/examples/bank/src/models/budget_model.cpp +++ b/examples/bank/src/models/budget_model.cpp @@ -12,6 +12,7 @@ #include "bank/core/principal.hpp" #include "bank/core/types.hpp" #include "bank/db/budget_entity.hpp" +#include "bank/db/ledger_ops.hpp" #include "bank/db/txn_entity.hpp" namespace bank { @@ -63,14 +64,8 @@ dto::BudgetInfo BudgetModel::execute(const dto::SetBudget& action) { } dto::CommandResult BudgetModel::execute(const dto::DeleteBudget& action) { - auto rec = mapper().QuerySingle(static_cast(action.id)); - if (!rec.has_value()) { - throw NotFound{"budget not found"}; - } - if (std::string{rec->owner.Value().str()} != sessionPrincipal()) { - throw Unauthorized{"budget belongs to a different owner"}; - } - mapper().Delete(*rec); + auto rec = db::loadOwned(mapper(), action.id, sessionPrincipal(), "budget"); + mapper().Delete(rec); return dto::CommandResult{.ok = true, .message = "budget deleted"}; } @@ -92,22 +87,19 @@ dto::BudgetList BudgetModel::execute(const dto::ListBudgets& action) { } dto::SpendingReport BudgetModel::execute(const dto::SpendingByKind& action) { + // Push the account/direction/time filters into the query so only the rows we + // aggregate cross the wire; the by-kind rollup stays in code (no GROUP BY SQL). auto rows = mapper() .Query() .Where(Lightweight::FieldNameOf<&db::TxnRecord::accountId>, "=", action.accountId) + .Where(Lightweight::FieldNameOf<&db::TxnRecord::direction>, "=", + static_cast(TxnDirection::Debit)) + .Where(Lightweight::FieldNameOf<&db::TxnRecord::createdAtMs>, ">=", action.sinceMs) .All(); - // Aggregate debits by kind in code (kept on the typed DataMapper path rather - // than hand-written GROUP BY SQL). std::map byKind; std::int64_t totalDebits = 0; for (const auto& rec : rows) { - if (rec.direction.Value() != static_cast(TxnDirection::Debit)) { - continue; - } - if (rec.createdAtMs.Value() < action.sinceMs) { - continue; - } const int kind = rec.kind.Value(); auto& entry = byKind[kind]; entry.kind = kind; diff --git a/examples/bank/src/models/card_model.cpp b/examples/bank/src/models/card_model.cpp index 726d2c8..53d0895 100644 --- a/examples/bank/src/models/card_model.cpp +++ b/examples/bank/src/models/card_model.cpp @@ -6,16 +6,17 @@ #include #include -#include #include #include #include +#include "bank/core/demo_hash.hpp" #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" #include "bank/core/types.hpp" #include "bank/db/account_entity.hpp" #include "bank/db/card_entity.hpp" +#include "bank/db/ledger_ops.hpp" namespace bank { @@ -28,7 +29,7 @@ std::string randomLast4() { } std::string hashPin(std::string_view pin) { - return std::format("{:016x}", std::hash{}(std::string{pin} + ":pin")); + return demoHash(std::string{pin} + ":pin"); } dto::CardInfo toInfo(const db::CardRecord& rec) { @@ -53,13 +54,8 @@ dto::CardInfo CardModel::execute(const dto::IssueCard& action) { if (owner.empty()) { throw Unauthorized{"no session principal"}; } - auto account = mapper().QuerySingle(static_cast(action.accountId)); - if (!account.has_value()) { - throw NotFound{"account not found"}; - } - if (std::string{account->owner.Value().str()} != owner) { - throw Unauthorized{"account belongs to a different owner"}; - } + // Cards may only be issued against an open account the caller owns. + auto account = db::loadOwnedOpenAccount(mapper(), action.accountId, owner); db::CardRecord card; card.owner = Light::SqlAnsiString<64>{owner}; @@ -77,14 +73,7 @@ namespace { /// Loads a card the session owner owns, or throws. db::CardRecord requireOwnedCard(Lightweight::DataMapper& mapper, std::int64_t cardId) { - auto card = mapper.QuerySingle(static_cast(cardId)); - if (!card.has_value()) { - throw NotFound{"card not found"}; - } - if (std::string{card->owner.Value().str()} != sessionPrincipal()) { - throw Unauthorized{"card belongs to a different owner"}; - } - return *card; + return db::loadOwned(mapper, cardId, sessionPrincipal(), "card"); } } // namespace diff --git a/examples/bank/src/models/loan_model.cpp b/examples/bank/src/models/loan_model.cpp index 2322aac..965b59b 100644 --- a/examples/bank/src/models/loan_model.cpp +++ b/examples/bank/src/models/loan_model.cpp @@ -36,14 +36,7 @@ dto::LoanInfo toInfo(const db::LoanRecord& rec) { } db::LoanRecord requireOwnedLoan(Lightweight::DataMapper& mapper, std::int64_t loanId) { - auto loan = mapper.QuerySingle(static_cast(loanId)); - if (!loan.has_value()) { - throw NotFound{"loan not found"}; - } - if (std::string{loan->owner.Value().str()} != sessionPrincipal()) { - throw Unauthorized{"loan belongs to a different owner"}; - } - return *loan; + return db::loadOwned(mapper, loanId, sessionPrincipal(), "loan"); } /// Fixed monthly payment (minor units) for an amortizing loan. @@ -68,10 +61,7 @@ dto::LoanInfo LoanModel::execute(const dto::ApplyLoan& action) { throw Unauthorized{"no session principal"}; } auto& dm = mapper(); - auto account = db::loadOpenAccount(dm, action.accountId); - if (std::string{account.owner.Value().str()} != owner) { - throw Unauthorized{"account belongs to a different owner"}; - } + auto account = db::loadOwnedOpenAccount(dm, action.accountId, owner); db::LoanRecord loan; loan.owner = Light::SqlAnsiString<64>{owner}; @@ -101,10 +91,7 @@ dto::LoanInfo LoanModel::execute(const dto::RepayLoan& action) { if (loan.status.Value() != static_cast(LoanStatus::Active)) { throw ConflictError{"loan is not active"}; } - auto account = db::loadOpenAccount(dm, action.fromAccountId); - if (std::string{account.owner.Value().str()} != sessionPrincipal()) { - throw Unauthorized{"account belongs to a different owner"}; - } + auto account = db::loadOwnedOpenAccount(dm, action.fromAccountId, sessionPrincipal()); const std::int64_t payment = std::min(action.amountMinor, loan.outstandingMinor.Value()); Lightweight::SqlTransaction tx{dm.Connection(), Lightweight::SqlTransactionMode::ROLLBACK}; @@ -121,11 +108,7 @@ dto::LoanInfo LoanModel::execute(const dto::RepayLoan& action) { } dto::LoanInfo LoanModel::execute(const dto::GetLoan& action) { - auto loan = mapper().QuerySingle(static_cast(action.id)); - if (!loan.has_value()) { - throw NotFound{"loan not found"}; - } - return toInfo(*loan); + return toInfo(requireOwnedLoan(mapper(), action.id)); } dto::LoanList LoanModel::execute(const dto::ListLoans& action) { diff --git a/examples/bank/src/models/notification_model.cpp b/examples/bank/src/models/notification_model.cpp index cdc6c5e..4ade8a3 100644 --- a/examples/bank/src/models/notification_model.cpp +++ b/examples/bank/src/models/notification_model.cpp @@ -70,15 +70,9 @@ dto::NotificationList NotificationModel::execute(const dto::ListNotifications& a } dto::CommandResult NotificationModel::execute(const dto::MarkRead& action) { - auto rec = mapper().QuerySingle(static_cast(action.id)); - if (!rec.has_value()) { - throw NotFound{"notification not found"}; - } - if (std::string{rec->owner.Value().str()} != sessionPrincipal()) { - throw Unauthorized{"notification belongs to a different owner"}; - } - rec->read = true; - mapper().Update(*rec); + auto rec = db::loadOwned(mapper(), action.id, sessionPrincipal(), "notification"); + rec.read = true; + mapper().Update(rec); return dto::CommandResult{.ok = true, .message = "marked read"}; } @@ -87,17 +81,18 @@ dto::CommandResult NotificationModel::execute(const dto::MarkAllRead& action) { if (owner.empty()) { throw Unauthorized{"no session principal"}; } + // Only the unread rows need touching, so filter in the query rather than + // scanning every notification and branching per row. auto rows = mapper() .Query() .Where(Lightweight::FieldNameOf<&db::NotificationRecord::owner>, "=", owner) + .Where(Lightweight::FieldNameOf<&db::NotificationRecord::read>, "=", false) .All(); int updated = 0; for (auto& rec : rows) { - if (!rec.read.Value()) { - rec.read = true; - mapper().Update(rec); - ++updated; - } + rec.read = true; + mapper().Update(rec); + ++updated; } return dto::CommandResult{.ok = true, .message = std::to_string(updated) + " marked read"}; } diff --git a/examples/bank/src/models/payee_model.cpp b/examples/bank/src/models/payee_model.cpp index ed762d2..b576cc8 100644 --- a/examples/bank/src/models/payee_model.cpp +++ b/examples/bank/src/models/payee_model.cpp @@ -9,6 +9,7 @@ #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" +#include "bank/db/ledger_ops.hpp" #include "bank/db/payee_entity.hpp" namespace bank { @@ -46,14 +47,8 @@ dto::PayeeInfo PayeeModel::execute(const dto::AddPayee& action) { } dto::CommandResult PayeeModel::execute(const dto::RemovePayee& action) { - auto rec = mapper().QuerySingle(static_cast(action.id)); - if (!rec.has_value()) { - throw NotFound{"payee not found"}; - } - if (std::string{rec->owner.Value().str()} != sessionPrincipal()) { - throw Unauthorized{"payee belongs to a different owner"}; - } - mapper().Delete(*rec); + auto rec = db::loadOwned(mapper(), action.id, sessionPrincipal(), "payee"); + mapper().Delete(rec); return dto::CommandResult{.ok = true, .message = "payee removed"}; } diff --git a/examples/bank/src/models/payment_model.cpp b/examples/bank/src/models/payment_model.cpp index 15fb7a2..4fde99a 100644 --- a/examples/bank/src/models/payment_model.cpp +++ b/examples/bank/src/models/payment_model.cpp @@ -38,24 +38,13 @@ dto::PaymentInfo toInfo(const db::PaymentRecord& rec) { /// Loads a payee, requiring it to exist and belong to @p owner. db::PayeeRecord requireOwnedPayee(Lightweight::DataMapper& mapper, std::int64_t payeeId, const std::string& owner) { - auto payee = mapper.QuerySingle(static_cast(payeeId)); - if (!payee.has_value()) { - throw NotFound{"payee not found"}; - } - if (std::string{payee->owner.Value().str()} != owner) { - throw Unauthorized{"payee belongs to a different owner"}; - } - return *payee; + return db::loadOwned(mapper, payeeId, owner, "payee"); } /// Loads an open account, requiring it to belong to @p owner. db::AccountRecord requireOwnedAccount(Lightweight::DataMapper& mapper, std::int64_t accountId, const std::string& owner) { - auto account = db::loadOpenAccount(mapper, accountId); - if (std::string{account.owner.Value().str()} != owner) { - throw Unauthorized{"account belongs to a different owner"}; - } - return account; + return db::loadOwnedOpenAccount(mapper, accountId, owner); } } // namespace @@ -141,18 +130,12 @@ dto::PaymentInfo PaymentModel::execute(const dto::CreateStandingOrder& action) { } dto::CommandResult PaymentModel::execute(const dto::CancelPayment& action) { - auto payment = mapper().QuerySingle(static_cast(action.id)); - if (!payment.has_value()) { - throw NotFound{"payment not found"}; - } - if (std::string{payment->owner.Value().str()} != sessionPrincipal()) { - throw Unauthorized{"payment belongs to a different owner"}; - } - if (payment->status.Value() != static_cast(PaymentStatus::Pending)) { + auto payment = db::loadOwned(mapper(), action.id, sessionPrincipal(), "payment"); + if (payment.status.Value() != static_cast(PaymentStatus::Pending)) { throw ConflictError{"only pending payments can be cancelled"}; } - payment->status = static_cast(PaymentStatus::Cancelled); - mapper().Update(*payment); + payment.status = static_cast(PaymentStatus::Cancelled); + mapper().Update(payment); return dto::CommandResult{.ok = true, .message = "payment cancelled"}; } diff --git a/examples/bank/src/models/statement_model.cpp b/examples/bank/src/models/statement_model.cpp index 00ab695..70b539f 100644 --- a/examples/bank/src/models/statement_model.cpp +++ b/examples/bank/src/models/statement_model.cpp @@ -4,8 +4,11 @@ #include +#include #include +#include #include +#include #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" @@ -31,38 +34,57 @@ dto::Statement StatementModel::execute(const dto::GenerateStatement& action) { statement.fromMs = action.fromMs; statement.toMs = action.toMs; + // One statement line per account, indexed by id so the single txn query + // below can be folded in without re-scanning. + std::map lineIndex; + std::vector accountIds; + statement.lines.reserve(accounts.size()); + accountIds.reserve(accounts.size()); for (const auto& account : accounts) { const auto accountId = static_cast(account.id.Value()); - auto entries = mapper() - .Query() - .Where(Lightweight::FieldNameOf<&db::TxnRecord::accountId>, "=", accountId) - .All(); - dto::StatementLine line; line.accountId = accountId; line.number = std::string{account.number.Value().str()}; line.currency = account.currency.Value(); line.closingBalanceMinor = account.balanceMinor.Value(); + lineIndex.emplace(accountId, statement.lines.size()); + statement.lines.push_back(std::move(line)); + accountIds.push_back(accountId); + } + if (accountIds.empty()) { + return statement; + } + + // All of the owner's transactions in one query (no per-account round-trip); + // the window's lower bound is pushed down, the optional upper bound + // (toMs == 0 means "open ended") stays as a cheap in-loop check. + auto entries = mapper() + .Query() + .WhereIn(Lightweight::FieldNameOf<&db::TxnRecord::accountId>, accountIds) + .Where(Lightweight::FieldNameOf<&db::TxnRecord::createdAtMs>, ">=", action.fromMs) + .All(); - for (const auto& entry : entries) { - const std::int64_t when = entry.createdAtMs.Value(); - if (when < action.fromMs) { - continue; - } - if (action.toMs != 0 && when > action.toMs) { - continue; - } - if (entry.direction.Value() == static_cast(TxnDirection::Credit)) { - line.creditsMinor += entry.amountMinor.Value(); - } else { - line.debitsMinor += entry.amountMinor.Value(); - } - line.entryCount += 1; + for (const auto& entry : entries) { + const std::int64_t when = entry.createdAtMs.Value(); + if (action.toMs != 0 && when > action.toMs) { + continue; } + const auto it = lineIndex.find(entry.accountId.Value()); + if (it == lineIndex.end()) { + continue; + } + auto& line = statement.lines[it->second]; + if (entry.direction.Value() == static_cast(TxnDirection::Credit)) { + line.creditsMinor += entry.amountMinor.Value(); + } else { + line.debitsMinor += entry.amountMinor.Value(); + } + line.entryCount += 1; + } + for (const auto& line : statement.lines) { statement.totalCreditsMinor += line.creditsMinor; statement.totalDebitsMinor += line.debitsMinor; - statement.lines.push_back(std::move(line)); } return statement; diff --git a/examples/bank/src/models/transaction_model.cpp b/examples/bank/src/models/transaction_model.cpp index 114b159..bd49ca8 100644 --- a/examples/bank/src/models/transaction_model.cpp +++ b/examples/bank/src/models/transaction_model.cpp @@ -11,6 +11,7 @@ #include #include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" #include "bank/core/types.hpp" #include "bank/db/ledger_ops.hpp" @@ -39,8 +40,12 @@ dto::TxnInfo TransactionModel::execute(const dto::Deposit& action) { if (!action.validate()) { throw ValidationError{"deposit amount must be positive"}; } - auto account = db::loadOpenAccount(mapper(), action.accountId); - auto txn = db::applyCredit(mapper(), account, action.amountMinor, TxnKind::Deposit, 0, action.description); + auto& dm = mapper(); + auto account = db::loadOwnedOpenAccount(dm, action.accountId, sessionPrincipal()); + // Balance update and its ledger entry must commit (or roll back) as a unit. + Lightweight::SqlTransaction tx{dm.Connection(), Lightweight::SqlTransactionMode::ROLLBACK}; + auto txn = db::applyCredit(dm, account, action.amountMinor, TxnKind::Deposit, 0, action.description); + tx.Commit(); return toTxnInfo(txn); } @@ -48,9 +53,11 @@ dto::TxnInfo TransactionModel::execute(const dto::Withdraw& action) { if (!action.validate()) { throw ValidationError{"withdrawal amount must be positive"}; } - auto account = db::loadOpenAccount(mapper(), action.accountId); - auto txn = - db::applyDebit(mapper(), account, action.amountMinor, TxnKind::Withdrawal, 0, action.description); + auto& dm = mapper(); + auto account = db::loadOwnedOpenAccount(dm, action.accountId, sessionPrincipal()); + Lightweight::SqlTransaction tx{dm.Connection(), Lightweight::SqlTransactionMode::ROLLBACK}; + auto txn = db::applyDebit(dm, account, action.amountMinor, TxnKind::Withdrawal, 0, action.description); + tx.Commit(); return toTxnInfo(txn); } @@ -59,8 +66,9 @@ dto::TransferResult TransactionModel::execute(const dto::Transfer& action) { throw ValidationError{"invalid transfer (accounts must differ and amount be positive)"}; } auto& dm = mapper(); - auto source = db::loadOpenAccount(dm, action.fromAccountId); - auto dest = db::loadOpenAccount(dm, action.toAccountId); + const std::string owner = sessionPrincipal(); + auto source = db::loadOwnedOpenAccount(dm, action.fromAccountId, owner); + auto dest = db::loadOwnedOpenAccount(dm, action.toAccountId, owner); if (source.currency.Value() != dest.currency.Value()) { throw ValidationError{"cross-currency transfers are not supported"}; } @@ -77,21 +85,26 @@ dto::TransferResult TransactionModel::execute(const dto::Transfer& action) { } dto::HistoryPage TransactionModel::execute(const dto::History& action) { - auto rows = mapper() - .Query() - .Where(Lightweight::FieldNameOf<&db::TxnRecord::accountId>, "=", action.accountId) - .All(); - // Newest first by id. - std::ranges::sort(rows, [](const db::TxnRecord& lhs, const db::TxnRecord& rhs) { - return lhs.id.Value() > rhs.id.Value(); - }); - dto::HistoryPage page; page.accountId = action.accountId; const auto offset = static_cast(std::max(0, action.offset)); const auto limit = static_cast(std::max(0, action.limit)); - for (std::size_t idx = offset; idx < rows.size() && page.entries.size() < limit; ++idx) { - page.entries.push_back(toTxnInfo(rows[idx])); + if (limit == 0) { + return page; + } + + // Newest first, paginated in the database: only the requested window is + // fetched instead of loading and sorting the whole ledger in memory. + auto rows = mapper() + .Query() + .Where(Lightweight::FieldNameOf<&db::TxnRecord::accountId>, "=", action.accountId) + .OrderBy(Lightweight::FieldNameOf<&db::TxnRecord::id>, + Lightweight::SqlResultOrdering::DESCENDING) + .Range(offset, limit); + + page.entries.reserve(rows.size()); + for (const auto& rec : rows) { + page.entries.push_back(toTxnInfo(rec)); } return page; } diff --git a/examples/bank/tests/test_transaction.cpp b/examples/bank/tests/test_transaction.cpp index f3bb837..40dd58f 100644 --- a/examples/bank/tests/test_transaction.cpp +++ b/examples/bank/tests/test_transaction.cpp @@ -118,4 +118,16 @@ TEST_CASE("TransactionModel transfer is atomic and balance-preserving", "[transa REQUIRE(srcInfo.balanceMinor == 10000); REQUIRE(dstInfo.balanceMinor == 0); } + + SECTION("a different principal cannot move money from accounts they do not own") { + app.login("mallory-intruder"); + REQUIRE_THROWS_AS( + await(txns.execute(bank::dto::Withdraw{.accountId = src, .amountMinor = 100}), app.guiLoop()), + bank::Unauthorized); + REQUIRE_THROWS_AS(await(txns.execute(bank::dto::Transfer{.fromAccountId = src, + .toAccountId = dst, + .amountMinor = 100}), + app.guiLoop()), + bank::Unauthorized); + } } From dabb868e1e58452da0ef887f085c4945ed1e9d10 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 1 Jul 2026 07:05:31 +0200 Subject: [PATCH 05/10] [morph] bank example: model the schema with Lightweight relations Replace raw foreign-key columns with Lightweight relation types: - BelongsTo everywhere: owner -> user_id (BelongsTo<&UserRecord::id>) on all owned tables; account_id, nullable counterparty_id, from_account_id, payee_id as BelongsTo. Authorization navigates rec->user->username in db::loadOwned. - HasMany: UserRecord::accounts (ListAccounts/Statement) and PayeeRecord::payments. Migrations declare matching FK constraints and are reordered users -> accounts/payees -> rest. - Add db/entities.hpp aggregator and db/user_ops.hpp (findUserId/requireUserId/ ensureUser). App::login provisions the principal's users row; tests without App use bank::testing::ensurePrincipal. - Wire DTOs are unchanged (owner username + int ids), so GUI/CLI are untouched; models map relation values into the DTOs. Work around two limitations of the pinned Lightweight (non-reflection build), documented inline and reported upstream (LASTRADA-Software/Lightweight#517): its Update and fluent Query() don't skip HasMany members. So UserRecord and PayeeRecord are read-only aggregates (Create/Delete/QuerySingle+navigation) with relation-free projections UserRow/PayeeRow backing fluent lists and updates; AccountRecord carries no HasMany since it is updated on every balance change. HasMany field indices are aligned with the child BelongsTo indices as the ORM requires. Add tests/test_relations.cpp covering BelongsTo navigation, both HasMany inverses, and the nullable counterparty. Full suite: 17 cases / 131 assertions. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq --- examples/bank/CMakeLists.txt | 1 + examples/bank/README.md | 41 ++++- .../bank/include/bank/db/account_entity.hpp | 37 +++-- .../bank/include/bank/db/budget_entity.hpp | 13 +- examples/bank/include/bank/db/card_entity.hpp | 23 ++- examples/bank/include/bank/db/entities.hpp | 37 +++++ examples/bank/include/bank/db/ledger_ops.hpp | 35 +++- examples/bank/include/bank/db/loan_entity.hpp | 26 +-- .../include/bank/db/notification_entity.hpp | 15 +- .../bank/include/bank/db/payee_entity.hpp | 34 +++- .../bank/include/bank/db/payment_entity.hpp | 32 ++-- examples/bank/include/bank/db/txn_entity.hpp | 34 ++-- examples/bank/include/bank/db/user_entity.hpp | 45 ++++- examples/bank/include/bank/db/user_ops.hpp | 70 ++++++++ examples/bank/src/app/app.cpp | 11 ++ examples/bank/src/db/schema.cpp | 108 +++++++----- examples/bank/src/models/account_model.cpp | 32 ++-- examples/bank/src/models/auth_model.cpp | 23 ++- examples/bank/src/models/budget_model.cpp | 25 +-- examples/bank/src/models/card_model.cpp | 20 +-- examples/bank/src/models/loan_model.cpp | 25 +-- .../bank/src/models/notification_model.cpp | 18 +- examples/bank/src/models/payee_model.cpp | 22 ++- examples/bank/src/models/payment_model.cpp | 40 ++--- examples/bank/src/models/statement_model.cpp | 24 +-- .../bank/src/models/transaction_model.cpp | 7 +- examples/bank/tests/bank_test_support.hpp | 15 ++ examples/bank/tests/test_relations.cpp | 155 ++++++++++++++++++ examples/bank/tests/test_remote.cpp | 2 + 29 files changed, 733 insertions(+), 237 deletions(-) create mode 100644 examples/bank/include/bank/db/entities.hpp create mode 100644 examples/bank/include/bank/db/user_ops.hpp create mode 100644 examples/bank/tests/test_relations.cpp diff --git a/examples/bank/CMakeLists.txt b/examples/bank/CMakeLists.txt index 14d3d3f..22a38d8 100644 --- a/examples/bank/CMakeLists.txt +++ b/examples/bank/CMakeLists.txt @@ -84,6 +84,7 @@ if(MORPH_BUILD_TESTS) tests/test_statement.cpp tests/test_remote.cpp tests/test_offline.cpp + tests/test_relations.cpp ) target_include_directories(bank_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests) target_link_libraries(bank_tests PRIVATE bank_lib Catch2::Catch2WithMain) diff --git a/examples/bank/README.md b/examples/bank/README.md index 5f20079..862cb9f 100644 --- a/examples/bank/README.md +++ b/examples/bank/README.md @@ -28,9 +28,10 @@ GUI / CLI ──actions/results (plain DTOs)──▶ morph Bridge ──▶ Mod - **`include/bank/dto/`** — wire DTOs (the morph action/result types). Amounts are integer **minor units** (cents); enums travel as their integer values. -- **`include/bank/db/`** — Lightweight entity records (`*_entity.hpp`), the shared - `WithMapper` mixin (one lazily-opened `DataMapper` per model), and reusable - `ledger_ops.hpp` (debit/credit/post-entry helpers used by every money-moving model). +- **`include/bank/db/`** — Lightweight entity records (`*_entity.hpp`, aggregated by + `entities.hpp`), the shared `WithMapper` mixin (one lazily-opened `DataMapper` per + model), `user_ops.hpp` (principal→`user_id` resolution), and reusable `ledger_ops.hpp` + (relation-aware debit/credit/post-entry + the `loadOwned` ownership guard). - **`include/bank/models/` + `src/models/`** — one model per banking domain. The `BRIDGE_REGISTER_*` macros live in the **model header** so every `.execute()` call site sees the `ActionTraits` specialisation. @@ -46,6 +47,40 @@ which is private per connection) so every model's connection sees the same data. Cross-row atomic operations (transfer, bill payment, loan disbursement/repayment) run inside a `SqlTransaction`. +### Relations: `BelongsTo` / `HasMany` + +The schema is modelled with Lightweight's relation types rather than bare foreign-key +columns: + +- **`BelongsTo<&UserRecord::id>`** — every owned record (`accounts`, `payees`, `cards`, + `loans`, `payments`, `budgets`, `notifications`) references its owner by `user_id`. + Authorization is expressed *through the relation*: `db::loadOwned` navigates + `rec->user->username` (lazily loaded) and compares it to the session principal. +- **`BelongsTo` for the id FKs** — `transactions.account_id`, the nullable + `transactions.counterparty_id` (NULL for deposits/withdrawals, set for transfers), + `payments.from_account_id` / `payee_id`, `cards.account_id`, `loans.account_id`. +- **`HasMany`** — `UserRecord::accounts` (used by `ListAccounts` and `StatementModel`) + and `PayeeRecord::payments`. The migrations also declare the matching SQL + `RequiredForeignKey`/`ForeignKey` constraints. + +Two quirks of the current Lightweight version (non-reflection build) shaped the entity +layout, and are documented inline in the `*_entity.hpp` headers: + +1. **`HasMany` resolves the child's foreign key by *ordinal member index***, not + by type — so each `HasMany` field is placed at the same member index as the + back-pointing `BelongsTo` on the child (e.g. `UserRecord::accounts` and + `AccountRecord::user` are both at index 5). The relations test locks this in. +2. **A record that has a `HasMany` member can't be used with `DataMapper::Update` or the + fluent `Query()`** (both enumerate every member without a storage guard). So + `UserRecord`/`PayeeRecord` are read-only *aggregates* (used via `Create`/`Delete`/ + `QuerySingle` + navigation), and a relation-free *projection* over the same table + (`UserRow`/`PayeeRow`) backs the fluent list queries and credential updates. + `AccountRecord` carries no `HasMany` at all because it is updated on every balance + change. + +The wire DTOs are unchanged by this (they still expose `owner` as a username and ids as +integers), so the GUI and CLI are unaffected; models map the relation values to the DTO. + ## Models & features | Model | Actions | diff --git a/examples/bank/include/bank/db/account_entity.hpp b/examples/bank/include/bank/db/account_entity.hpp index 93561f2..b869640 100644 --- a/examples/bank/include/bank/db/account_entity.hpp +++ b/examples/bank/include/bank/db/account_entity.hpp @@ -6,34 +6,47 @@ #include #include +#include "bank/db/user_entity.hpp" + /// @file /// Lightweight entity for the `accounts` table. This is the *persistence* shape -/// — `Light::Field<>`-wrapped members mapped to columns — and is distinct from -/// the wire DTOs in `bank/dto/`. Models translate between the two. +/// — `Light::Field<>`/`Light::BelongsTo<>`-wrapped members mapped to columns — +/// and is distinct from the wire DTOs in `bank/dto/`. Models translate between +/// the two. namespace bank::db { /// @brief One row of the `accounts` table. +/// +/// @note `user` sits at member index 5 to match `UserRecord::accounts` (the +/// parent's `HasMany` at index 5) — Lightweight resolves a `HasMany` by +/// the child column at the *same ordinal member index* as the relation field. +/// +/// AccountRecord intentionally carries no inverse `HasMany` of its own: rows are +/// updated on every balance change, and (in this Lightweight version's +/// non-reflection build) `DataMapper::Update` cannot be instantiated for a +/// record that has a `HasMany` member. Children are reached via their +/// `account_id` foreign key instead (see e.g. `TransactionModel::History`). struct AccountRecord { static constexpr std::string_view TableName = "accounts"; - Light::Field id; - /// Owning principal (the logged-in user's session principal). - Light::Field, Light::SqlRealName{"owner"}> owner; + Light::Field id; // 0 /// Generated account number (also used as the IBAN-ish identifier). - Light::Field, Light::SqlRealName{"number"}> number; + Light::Field, Light::SqlRealName{"number"}> number; // 1 /// `AccountKind` as integer. - Light::Field kind; + Light::Field kind; // 2 /// `Currency` as integer. - Light::Field currency; + Light::Field currency; // 3 /// Current balance in minor units. - Light::Field balanceMinor{0}; + Light::Field balanceMinor{0}; // 4 + /// Owning user. + Light::BelongsTo<&UserRecord::id, Light::SqlRealName{"user_id"}> user; // 5 /// Allowed overdraft (positive number of minor units below zero) for the account. - Light::Field overdraftMinor{0}; + Light::Field overdraftMinor{0}; // 6 /// `AccountStatus` as integer. - Light::Field status{0}; + Light::Field status{0}; // 7 /// Annual interest rate in basis points (1% = 100 bps). - Light::Field interestBps{0}; + Light::Field interestBps{0}; // 8 }; } // namespace bank::db diff --git a/examples/bank/include/bank/db/budget_entity.hpp b/examples/bank/include/bank/db/budget_entity.hpp index 72edb4c..2834836 100644 --- a/examples/bank/include/bank/db/budget_entity.hpp +++ b/examples/bank/include/bank/db/budget_entity.hpp @@ -6,17 +6,20 @@ #include #include +#include "bank/db/user_entity.hpp" + namespace bank::db { /// @brief One row of the `budgets` table (a per-category monthly limit). struct BudgetRecord { static constexpr std::string_view TableName = "budgets"; - Light::Field id; - Light::Field, Light::SqlRealName{"owner"}> owner; - Light::Field, Light::SqlRealName{"category"}> category; - Light::Field monthlyLimitMinor; - Light::Field currency; + Light::Field id; // 0 + /// Owning user. + Light::BelongsTo<&UserRecord::id, Light::SqlRealName{"user_id"}> user; // 1 + Light::Field, Light::SqlRealName{"category"}> category; // 2 + Light::Field monthlyLimitMinor; // 3 + Light::Field currency; // 4 }; } // namespace bank::db diff --git a/examples/bank/include/bank/db/card_entity.hpp b/examples/bank/include/bank/db/card_entity.hpp index 383a0f1..86139d0 100644 --- a/examples/bank/include/bank/db/card_entity.hpp +++ b/examples/bank/include/bank/db/card_entity.hpp @@ -6,25 +6,32 @@ #include #include +#include "bank/db/account_entity.hpp" +#include "bank/db/user_entity.hpp" + namespace bank::db { /// @brief One row of the `cards` table. +/// +/// @note `account` sits at member index 1 to match `AccountRecord::cards`. struct CardRecord { static constexpr std::string_view TableName = "cards"; - Light::Field id; - Light::Field, Light::SqlRealName{"owner"}> owner; - Light::Field accountId; + Light::Field id; // 0 + /// Account this card draws on. + Light::BelongsTo<&AccountRecord::id, Light::SqlRealName{"account_id"}> account; // 1 + /// Owning user. + Light::BelongsTo<&UserRecord::id, Light::SqlRealName{"user_id"}> user; // 2 /// `CardKind` as integer. - Light::Field kind; + Light::Field kind; // 3 /// Last four digits of the (fictional) card number. - Light::Field, Light::SqlRealName{"pan_last4"}> panLast4; + Light::Field, Light::SqlRealName{"pan_last4"}> panLast4; // 4 /// `CardStatus` as integer. - Light::Field status{0}; + Light::Field status{0}; // 5 /// Daily spend limit in minor units (0 = no limit). - Light::Field dailyLimitMinor{0}; + Light::Field dailyLimitMinor{0}; // 6 /// Hash of the card PIN (demo-grade). - Light::Field, Light::SqlRealName{"pin_hash"}> pinHash; + Light::Field, Light::SqlRealName{"pin_hash"}> pinHash; // 7 }; } // namespace bank::db diff --git a/examples/bank/include/bank/db/entities.hpp b/examples/bank/include/bank/db/entities.hpp new file mode 100644 index 0000000..a287c24 --- /dev/null +++ b/examples/bank/include/bank/db/entities.hpp @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// One-stop include for every Lightweight entity record, pulled in *dependency +/// order* so that every referenced type is complete. +/// +/// Why an aggregator: the entities form a relationship graph. A `BelongsTo<&T::id>` +/// needs `T` complete, and — crucially — any translation unit that *loads* a +/// record's relations (`DataMapper::Query`/`Create` configure on-demand loaders +/// for its `HasMany`/`BelongsTo` members) instantiates lambdas that reference +/// the related record types, so those must be complete at the call site too. +/// Including this header guarantees that, instead of every model guessing which +/// child entity headers it transitively needs. +/// +/// ── Relationship map ───────────────────────────────────────────────────────── +/// UserRecord 1───* AccountRecord (UserRecord::accounts) +/// AccountRecord 1───* CardRecord, LoanRecord, (AccountRecord::cards/loans/ +/// TxnRecord, PaymentRecord transactions/payments) +/// PayeeRecord 1───* PaymentRecord (PayeeRecord::payments) +/// AccountRecord, PayeeRecord, CardRecord, LoanRecord, PaymentRecord, +/// BudgetRecord, NotificationRecord *───1 UserRecord (… ::user) +/// TxnRecord *───1 AccountRecord (account) and *───0..1 AccountRecord +/// (counterparty, nullable) +/// +/// See each entity header for the member-ordering constraint that Lightweight's +/// index-based `HasMany` resolution imposes. + +#include "bank/db/user_entity.hpp" +#include "bank/db/account_entity.hpp" +#include "bank/db/payee_entity.hpp" +#include "bank/db/txn_entity.hpp" +#include "bank/db/card_entity.hpp" +#include "bank/db/loan_entity.hpp" +#include "bank/db/payment_entity.hpp" +#include "bank/db/budget_entity.hpp" +#include "bank/db/notification_entity.hpp" diff --git a/examples/bank/include/bank/db/ledger_ops.hpp b/examples/bank/include/bank/db/ledger_ops.hpp index 2d29090..e8048d7 100644 --- a/examples/bank/include/bank/db/ledger_ops.hpp +++ b/examples/bank/include/bank/db/ledger_ops.hpp @@ -10,8 +10,8 @@ #include "bank/core/errors.hpp" #include "bank/core/types.hpp" -#include "bank/db/account_entity.hpp" -#include "bank/db/txn_entity.hpp" +#include "bank/db/entities.hpp" +#include "bank/db/user_ops.hpp" /// @file /// Reusable ledger operations shared by every model that moves money @@ -40,11 +40,25 @@ namespace bank::db { .count(); } +/// @brief Points a `BelongsTo<>` field at the referenced row's primary key and +/// marks it modified so a subsequent `Update` writes the foreign key. +/// +/// Models usually have the parent record's id (from the wire DTO) but not the +/// loaded parent object; this sets the foreign key directly. Works for both +/// mandatory (`uint64`) and nullable (`std::optional`) relations. +template +inline void setReference(BelongsToField& field, std::uint64_t id) { + field.MutableValue() = id; + field.SetModified(true); +} + /// @brief Loads a record by id, requiring it to exist and be owned by @p owner. /// -/// Every per-resource access check (accounts, loans, cards, payees, payments, -/// budgets, notifications) shares this one guard so the authorization rule lives -/// in a single place. @p noun is woven into the thrown messages, e.g. "loan". +/// Authorization is expressed through the relation itself: the record's +/// `BelongsTo<&UserRecord::id> user` is navigated (`rec.user->username`, +/// lazily loaded by `QuerySingle`) and compared to the caller's principal. One +/// guard serves every owned resource (accounts, loans, cards, payees, payments, +/// budgets, notifications); @p noun is woven into the thrown messages. /// @throws NotFound if no row has that id; Unauthorized if it belongs elsewhere. template [[nodiscard]] Record loadOwned(Lightweight::DataMapper& mapper, std::int64_t id, @@ -53,7 +67,7 @@ template if (!rec.has_value()) { throw NotFound{std::string{noun} + " not found"}; } - if (std::string{rec->owner.Value().str()} != owner) { + if (std::string{rec->user->username.Value().str()} != owner) { throw Unauthorized{std::string{noun} + " belongs to a different owner"}; } return *rec; @@ -71,7 +85,7 @@ template if (!acct.has_value()) { throw NotFound{"account not found"}; } - if (std::string{acct->owner.Value().str()} != owner) { + if (std::string{acct->user->username.Value().str()} != owner) { throw Unauthorized{"account belongs to a different owner"}; } if (acct->status.Value() != static_cast(AccountStatus::Open)) { @@ -81,12 +95,15 @@ template } /// @brief Inserts a ledger row reflecting @p account's *current* balance. +/// @p counterpartyId is the other account in a transfer, or 0 for none. inline TxnRecord postEntry(Lightweight::DataMapper& mapper, const AccountRecord& account, TxnDirection direction, TxnKind kind, std::int64_t amountMinor, std::int64_t counterpartyId, const std::string& description) { TxnRecord txn; - txn.accountId = static_cast(account.id.Value()); - txn.counterpartyId = counterpartyId; + setReference(txn.account, account.id.Value()); + if (counterpartyId > 0) { + setReference(txn.counterparty, static_cast(counterpartyId)); + } txn.direction = static_cast(direction); txn.kind = static_cast(kind); txn.amountMinor = amountMinor; diff --git a/examples/bank/include/bank/db/loan_entity.hpp b/examples/bank/include/bank/db/loan_entity.hpp index ab75bd1..d0baa11 100644 --- a/examples/bank/include/bank/db/loan_entity.hpp +++ b/examples/bank/include/bank/db/loan_entity.hpp @@ -6,25 +6,31 @@ #include #include +#include "bank/db/account_entity.hpp" +#include "bank/db/user_entity.hpp" + namespace bank::db { /// @brief One row of the `loans` table. +/// +/// @note `account` sits at member index 2 to match `AccountRecord::loans`. struct LoanRecord { static constexpr std::string_view TableName = "loans"; - Light::Field id; - Light::Field, Light::SqlRealName{"owner"}> owner; + Light::Field id; // 0 + /// Owning user. + Light::BelongsTo<&UserRecord::id, Light::SqlRealName{"user_id"}> user; // 1 /// Account the loan was disbursed into / is repaid from. - Light::Field accountId; - Light::Field principalMinor; - Light::Field outstandingMinor; - Light::Field currency; + Light::BelongsTo<&AccountRecord::id, Light::SqlRealName{"account_id"}> account; // 2 + Light::Field principalMinor; // 3 + Light::Field outstandingMinor; // 4 + Light::Field currency; // 5 /// Annual interest rate in basis points. - Light::Field rateBps; - Light::Field termMonths; + Light::Field rateBps; // 6 + Light::Field termMonths; // 7 /// `LoanStatus` as integer. - Light::Field status{0}; - Light::Field createdAtMs; + Light::Field status{0}; // 8 + Light::Field createdAtMs; // 9 }; } // namespace bank::db diff --git a/examples/bank/include/bank/db/notification_entity.hpp b/examples/bank/include/bank/db/notification_entity.hpp index ef546e8..3e94b5d 100644 --- a/examples/bank/include/bank/db/notification_entity.hpp +++ b/examples/bank/include/bank/db/notification_entity.hpp @@ -6,19 +6,22 @@ #include #include +#include "bank/db/user_entity.hpp" + namespace bank::db { /// @brief One row of the `notifications` table. struct NotificationRecord { static constexpr std::string_view TableName = "notifications"; - Light::Field id; - Light::Field, Light::SqlRealName{"owner"}> owner; + Light::Field id; // 0 + /// Owning user. + Light::BelongsTo<&UserRecord::id, Light::SqlRealName{"user_id"}> user; // 1 /// 0 = info, 1 = warning, 2 = alert. - Light::Field severity{0}; - Light::Field, Light::SqlRealName{"message"}> message; - Light::Field read{false}; - Light::Field createdAtMs; + Light::Field severity{0}; // 2 + Light::Field, Light::SqlRealName{"message"}> message; // 3 + Light::Field read{false}; // 4 + Light::Field createdAtMs; // 5 }; } // namespace bank::db diff --git a/examples/bank/include/bank/db/payee_entity.hpp b/examples/bank/include/bank/db/payee_entity.hpp index d0cb4db..3e5b841 100644 --- a/examples/bank/include/bank/db/payee_entity.hpp +++ b/examples/bank/include/bank/db/payee_entity.hpp @@ -6,17 +6,47 @@ #include #include +#include "bank/db/user_entity.hpp" + namespace bank::db { -/// @brief One row of the `payees` (beneficiaries) table. +// "Many" side of the Payee→Payment relation; forward declaration is enough to +// declare the `HasMany<>` member below. +struct PaymentRecord; + +/// @brief The `payees` table as a navigable aggregate: scalar columns, the +/// owning-user `BelongsTo`, and the `HasMany` inverse of +/// `PaymentRecord::payee`. +/// +/// @note `payments` sits at member index 5 so it matches `PaymentRecord::payee` +/// (its back-referencing `BelongsTo` at index 5). +/// +/// @warning As with `UserRecord`, the `HasMany` member means this record only +/// works with `Create`/`Delete`/`QuerySingle`(+navigation). Fluent list queries +/// use the relation-free `PayeeRow` projection below. struct PayeeRecord { static constexpr std::string_view TableName = "payees"; + Light::Field id; // 0 + Light::Field, Light::SqlRealName{"name"}> name; // 1 + Light::Field, Light::SqlRealName{"iban"}> iban; // 2 + Light::Field, Light::SqlRealName{"bank_name"}> bankName; // 3 + /// Owning user. + Light::BelongsTo<&UserRecord::id, Light::SqlRealName{"user_id"}> user; // 4 + /// Payments addressed to this payee (inverse of `PaymentRecord::payee`). + Light::HasMany payments; // 5 +}; + +/// @brief Relation-free projection over the same `payees` table for fluent +/// queries / updates (see the warning on `PayeeRecord`). +struct PayeeRow { + static constexpr std::string_view TableName = "payees"; + Light::Field id; - Light::Field, Light::SqlRealName{"owner"}> owner; Light::Field, Light::SqlRealName{"name"}> name; Light::Field, Light::SqlRealName{"iban"}> iban; Light::Field, Light::SqlRealName{"bank_name"}> bankName; + Light::BelongsTo<&UserRecord::id, Light::SqlRealName{"user_id"}> user; }; } // namespace bank::db diff --git a/examples/bank/include/bank/db/payment_entity.hpp b/examples/bank/include/bank/db/payment_entity.hpp index c2ac124..f235793 100644 --- a/examples/bank/include/bank/db/payment_entity.hpp +++ b/examples/bank/include/bank/db/payment_entity.hpp @@ -6,27 +6,37 @@ #include #include +#include "bank/db/account_entity.hpp" +#include "bank/db/payee_entity.hpp" +#include "bank/db/user_entity.hpp" + namespace bank::db { /// @brief One row of the `payments` table (one-off, scheduled, or standing). +/// +/// @note `fromAccount` sits at member index 4 to match `AccountRecord::payments`, +/// and `payee` at index 5 to match `PayeeRecord::payments`. struct PaymentRecord { static constexpr std::string_view TableName = "payments"; - Light::Field id; - Light::Field, Light::SqlRealName{"owner"}> owner; - Light::Field fromAccountId; - Light::Field payeeId; - Light::Field amountMinor; - Light::Field currency; + Light::Field id; // 0 + /// Owning user. + Light::BelongsTo<&UserRecord::id, Light::SqlRealName{"user_id"}> user; // 1 + Light::Field currency; // 2 + Light::Field amountMinor; // 3 + /// Account the payment is drawn from. + Light::BelongsTo<&AccountRecord::id, Light::SqlRealName{"from_account_id"}> fromAccount; // 4 + /// Beneficiary the payment is addressed to. + Light::BelongsTo<&PayeeRecord::id, Light::SqlRealName{"payee_id"}> payee; // 5 /// `PaymentSchedule` as integer. - Light::Field schedule; + Light::Field schedule; // 6 /// `PaymentStatus` as integer. - Light::Field status; + Light::Field status; // 7 /// Due time (epoch ms) for scheduled/standing payments; 0 for one-off. - Light::Field dueAtMs{0}; + Light::Field dueAtMs{0}; // 8 /// Recurrence period in days for standing orders; 0 otherwise. - Light::Field intervalDays{0}; - Light::Field, Light::SqlRealName{"description"}> description; + Light::Field intervalDays{0}; // 9 + Light::Field, Light::SqlRealName{"description"}> description; // 10 }; } // namespace bank::db diff --git a/examples/bank/include/bank/db/txn_entity.hpp b/examples/bank/include/bank/db/txn_entity.hpp index 93cf8cb..0094fe0 100644 --- a/examples/bank/include/bank/db/txn_entity.hpp +++ b/examples/bank/include/bank/db/txn_entity.hpp @@ -6,31 +6,41 @@ #include #include +#include "bank/db/account_entity.hpp" + namespace bank::db { /// @brief One row of the `transactions` ledger table. +/// +/// @note `account` sits at member index 3 to match `AccountRecord::transactions` +/// (the parent's `HasMany` at index 3). `counterparty` is a second, *nullable* +/// `BelongsTo` to the same `accounts` table — used only by transfers — and is +/// intentionally NOT the relation `AccountRecord::transactions` resolves +/// against, so a transfer's counterparty never shows up in the other account's +/// ledger. struct TxnRecord { static constexpr std::string_view TableName = "transactions"; - Light::Field id; - /// Account this ledger entry belongs to. - Light::Field accountId; - /// The other account in a transfer (0 if none). - Light::Field counterpartyId{0}; + Light::Field id; // 0 + /// The other account in a transfer; NULL for deposits/withdrawals. + Light::BelongsTo<&AccountRecord::id, Light::SqlRealName{"counterparty_id"}, Light::SqlNullable::Null> + counterparty; // 1 /// `TxnDirection` as integer. - Light::Field direction; + Light::Field direction; // 2 + /// Account this ledger entry belongs to. + Light::BelongsTo<&AccountRecord::id, Light::SqlRealName{"account_id"}> account; // 3 /// `TxnKind` as integer. - Light::Field kind; + Light::Field kind; // 4 /// Absolute amount moved, in minor units. - Light::Field amountMinor; + Light::Field amountMinor; // 5 /// `Currency` as integer. - Light::Field currency; + Light::Field currency; // 6 /// Account balance immediately after this entry, in minor units. - Light::Field balanceAfterMinor; + Light::Field balanceAfterMinor; // 7 /// Free-text memo. - Light::Field, Light::SqlRealName{"description"}> description; + Light::Field, Light::SqlRealName{"description"}> description; // 8 /// Creation time as Unix epoch milliseconds (used for ordering/display). - Light::Field createdAtMs; + Light::Field createdAtMs; // 9 }; } // namespace bank::db diff --git a/examples/bank/include/bank/db/user_entity.hpp b/examples/bank/include/bank/db/user_entity.hpp index 5e503a2..1dd41d8 100644 --- a/examples/bank/include/bank/db/user_entity.hpp +++ b/examples/bank/include/bank/db/user_entity.hpp @@ -8,18 +8,53 @@ namespace bank::db { -/// @brief One row of the `users` table. +// Forward declaration: `UserRecord::accounts` is the "one" side of a 1-to-many +// relationship. `HasMany` only needs the type to be declared +// here; the full definition is pulled in (via `entities.hpp`) by any TU that +// actually loads the relation. +struct AccountRecord; + +/// @brief The `users` table as a navigable aggregate: scalar columns plus the +/// `HasMany` inverse of `AccountRecord::user`. +/// +/// @note Member order is significant. Lightweight resolves `HasMany` by +/// the child column at the SAME ordinal member index as the relation field, so +/// `accounts` sits at index 5 to match `AccountRecord::user`'s index. +/// +/// @warning In this Lightweight version's non-reflection build, a record that +/// has a `HasMany` member can only be used with `Create`, `Delete`, and +/// `QuerySingle`-by-primary-key (+ relation navigation). The fluent +/// `Query()` builder and `Update` enumerate *all* members (no +/// `FieldWithStorage` guard), so they emit/inspect the relation as if it were a +/// column. Use the relation-free `UserRow` projection below for fluent lookups +/// and credential updates; reserve `UserRecord` for navigation (see +/// `AccountModel::ListAccounts`, `StatementModel`). struct UserRecord { static constexpr std::string_view TableName = "users"; + Light::Field id; // 0 + Light::Field, Light::SqlRealName{"username"}> username; // 1 + Light::Field, Light::SqlRealName{"password_hash"}> passwordHash; // 2 + Light::Field, Light::SqlRealName{"display_name"}> displayName; // 3 + Light::Field status{0}; // 4 + /// All accounts owned by this user (inverse of `AccountRecord::user`). + Light::HasMany accounts; // 5 +}; + +/// @brief A relation-free projection over the same `users` table. +/// +/// Carries every scalar column but no `HasMany`, so it works with the fluent +/// `Query<>()` builder and `Update` (which `UserRecord` cannot — see the warning +/// above). All CRUD and by-username lookups go through this; `UserRecord` is the +/// read-side aggregate used only to navigate the `accounts` relation. This is +/// the library's documented multiple-structs-per-table pattern. +struct UserRow { + static constexpr std::string_view TableName = "users"; + Light::Field id; - /// Login name; also the session principal. Unique. Light::Field, Light::SqlRealName{"username"}> username; - /// Salted hash of the password (demo-grade, not real crypto). Light::Field, Light::SqlRealName{"password_hash"}> passwordHash; - /// Human-friendly display name. Light::Field, Light::SqlRealName{"display_name"}> displayName; - /// 0 = active, 1 = disabled. Light::Field status{0}; }; diff --git a/examples/bank/include/bank/db/user_ops.hpp b/examples/bank/include/bank/db/user_ops.hpp new file mode 100644 index 0000000..3f18b4a --- /dev/null +++ b/examples/bank/include/bank/db/user_ops.hpp @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/db/user_entity.hpp" + +/// @file +/// Helpers that map the session principal (a username string) to the `users` +/// row it identifies. Every owned record references its owner through a +/// `BelongsTo<&UserRecord::id>` foreign key, so the models resolve the +/// principal to a `user_id` here rather than storing the username on each row. + +namespace bank::db { + +/// @brief Looks up the id of the user with the given @p username, if any. +/// +/// Uses the relation-free `UserRow` projection: `UserRecord` carries a +/// `HasMany`, which the fluent `Query<>()` builder can't select in this +/// Lightweight version (see `UserRecord`'s warning). +[[nodiscard]] inline std::optional findUserId(Lightweight::DataMapper& mapper, + std::string_view username) { + auto row = mapper.Query() + .Where(Lightweight::FieldNameOf<&UserRow::username>, "=", std::string{username}) + .First(); + if (!row.has_value()) { + return std::nullopt; + } + return row->id.Value(); +} + +/// @brief Resolves @p username to its user id, or throws if no such user exists. +/// @throws Unauthorized when the principal has no backing `users` row. +[[nodiscard]] inline std::uint64_t requireUserId(Lightweight::DataMapper& mapper, + std::string_view username) { + if (auto id = findUserId(mapper, username); id.has_value()) { + return *id; + } + throw Unauthorized{"unknown user: " + std::string{username}}; +} + +/// @brief Returns the id of the user named @p username, creating a minimal row +/// if none exists yet (demo convenience used by `App::login`). +/// +/// A real app would require explicit registration; here, "logging in" as a +/// principal provisions it so example flows and tests don't each have to +/// register first. The password hash is left empty — `AuthModel` owns real +/// credential handling for users created through registration. +inline std::uint64_t ensureUser(Lightweight::DataMapper& mapper, std::string_view username, + std::string_view displayName = {}) { + if (auto id = findUserId(mapper, username); id.has_value()) { + return *id; + } + UserRow rec; + rec.username = Light::SqlAnsiString<64>{std::string{username}}; + rec.passwordHash = Light::SqlAnsiString<32>{}; + rec.displayName = + Light::SqlAnsiString<128>{std::string{displayName.empty() ? username : displayName}}; + rec.status = 0; + mapper.Create(rec); + return rec.id.Value(); +} + +} // namespace bank::db diff --git a/examples/bank/src/app/app.cpp b/examples/bank/src/app/app.cpp index 058c457..54a7f92 100644 --- a/examples/bank/src/app/app.cpp +++ b/examples/bank/src/app/app.cpp @@ -2,12 +2,15 @@ #include "bank/app/app.hpp" +#include #include #include #include #include "bank/db/database.hpp" +#include "bank/db/entities.hpp" +#include "bank/db/user_ops.hpp" namespace bank::app { @@ -18,6 +21,14 @@ App::App(const std::string& connectionString, std::size_t workers) } void App::login(const std::string& principal) { + // Owned records reference their owner by `user_id`, so the principal must + // map to a `users` row. Provision it on login as a demo convenience — this + // is where a registered user would normally already exist (the CLI/GUI + // register first); tests can simply `login("alice")` and start banking. + if (!principal.empty()) { + Lightweight::DataMapper dm; + db::ensureUser(dm, principal); + } morph::session::Context ctx; ctx.principal = principal; _bridge.setDefaultSession(std::move(ctx)); diff --git a/examples/bank/src/db/schema.cpp b/examples/bank/src/db/schema.cpp index 16e2f8b..cb1c4dc 100644 --- a/examples/bank/src/db/schema.cpp +++ b/examples/bank/src/db/schema.cpp @@ -49,21 +49,27 @@ void setup(const std::string& connectionString) { // 0001 slot; later parts claim higher slots). using namespace Lightweight::SqlColumnTypeDefinitions; +using Lightweight::SqlForeignKeyReferenceDefinition; -LIGHTWEIGHT_SQL_MIGRATION(20260629000001, "Create accounts table") { - plan.CreateTableIfNotExists("accounts") - .PrimaryKeyWithAutoIncrement("id", Bigint()) - .RequiredColumn("owner", Varchar(64)) - .RequiredColumn("number", Varchar(34)) - .RequiredColumn("kind", Integer()) - .RequiredColumn("currency", Integer()) - .RequiredColumn("balance_minor", Bigint()) - .RequiredColumn("overdraft_minor", Bigint()) - .RequiredColumn("status", Integer()) - .RequiredColumn("interest_bps", Integer()); +// Foreign-key references mirror the `BelongsTo<>` members on the entity records +// (see bank/db/*_entity.hpp). Tables are created in dependency order so a +// referenced table always exists first: users → accounts/payees → the rest. +// The declared constraints document the schema; SQLite only enforces them when +// `PRAGMA foreign_keys=ON`, which the example leaves at its default (off). + +namespace { +constexpr auto usersRef() { + return SqlForeignKeyReferenceDefinition{.tableName = "users", .columnName = "id"}; +} +constexpr auto accountsRef() { + return SqlForeignKeyReferenceDefinition{.tableName = "accounts", .columnName = "id"}; +} +constexpr auto payeesRef() { + return SqlForeignKeyReferenceDefinition{.tableName = "payees", .columnName = "id"}; } +} // namespace -LIGHTWEIGHT_SQL_MIGRATION(20260629000002, "Create users table") { +LIGHTWEIGHT_SQL_MIGRATION(20260630000001, "Create users table") { plan.CreateTableIfNotExists("users") .PrimaryKeyWithAutoIncrement("id", Bigint()) .RequiredColumn("username", Varchar(64)) @@ -72,49 +78,48 @@ LIGHTWEIGHT_SQL_MIGRATION(20260629000002, "Create users table") { .RequiredColumn("status", Integer()); } -LIGHTWEIGHT_SQL_MIGRATION(20260629000003, "Create transactions table") { - plan.CreateTableIfNotExists("transactions") +LIGHTWEIGHT_SQL_MIGRATION(20260630000002, "Create accounts table") { + plan.CreateTableIfNotExists("accounts") .PrimaryKeyWithAutoIncrement("id", Bigint()) - .RequiredColumn("account_id", Bigint()) - .RequiredColumn("counterparty_id", Bigint()) - .RequiredColumn("direction", Integer()) + .RequiredForeignKey("user_id", Bigint(), usersRef()) + .RequiredColumn("number", Varchar(34)) .RequiredColumn("kind", Integer()) - .RequiredColumn("amount_minor", Bigint()) .RequiredColumn("currency", Integer()) - .RequiredColumn("balance_after_minor", Bigint()) - .RequiredColumn("description", Varchar(128)) - .RequiredColumn("created_at_ms", Bigint()); + .RequiredColumn("balance_minor", Bigint()) + .RequiredColumn("overdraft_minor", Bigint()) + .RequiredColumn("status", Integer()) + .RequiredColumn("interest_bps", Integer()); } -LIGHTWEIGHT_SQL_MIGRATION(20260629000004, "Create payees table") { +LIGHTWEIGHT_SQL_MIGRATION(20260630000003, "Create payees table") { plan.CreateTableIfNotExists("payees") .PrimaryKeyWithAutoIncrement("id", Bigint()) - .RequiredColumn("owner", Varchar(64)) .RequiredColumn("name", Varchar(128)) .RequiredColumn("iban", Varchar(34)) - .RequiredColumn("bank_name", Varchar(128)); + .RequiredColumn("bank_name", Varchar(128)) + .RequiredForeignKey("user_id", Bigint(), usersRef()); } -LIGHTWEIGHT_SQL_MIGRATION(20260629000005, "Create payments table") { - plan.CreateTableIfNotExists("payments") +LIGHTWEIGHT_SQL_MIGRATION(20260630000004, "Create transactions table") { + plan.CreateTableIfNotExists("transactions") .PrimaryKeyWithAutoIncrement("id", Bigint()) - .RequiredColumn("owner", Varchar(64)) - .RequiredColumn("from_account_id", Bigint()) - .RequiredColumn("payee_id", Bigint()) + // Nullable: deposits/withdrawals have no counterparty. + .ForeignKey("counterparty_id", Bigint(), accountsRef()) + .RequiredColumn("direction", Integer()) + .RequiredForeignKey("account_id", Bigint(), accountsRef()) + .RequiredColumn("kind", Integer()) .RequiredColumn("amount_minor", Bigint()) .RequiredColumn("currency", Integer()) - .RequiredColumn("schedule", Integer()) - .RequiredColumn("status", Integer()) - .RequiredColumn("due_at_ms", Bigint()) - .RequiredColumn("interval_days", Integer()) - .RequiredColumn("description", Varchar(128)); + .RequiredColumn("balance_after_minor", Bigint()) + .RequiredColumn("description", Varchar(128)) + .RequiredColumn("created_at_ms", Bigint()); } -LIGHTWEIGHT_SQL_MIGRATION(20260629000006, "Create cards table") { +LIGHTWEIGHT_SQL_MIGRATION(20260630000005, "Create cards table") { plan.CreateTableIfNotExists("cards") .PrimaryKeyWithAutoIncrement("id", Bigint()) - .RequiredColumn("owner", Varchar(64)) - .RequiredColumn("account_id", Bigint()) + .RequiredForeignKey("account_id", Bigint(), accountsRef()) + .RequiredForeignKey("user_id", Bigint(), usersRef()) .RequiredColumn("kind", Integer()) .RequiredColumn("pan_last4", Varchar(4)) .RequiredColumn("status", Integer()) @@ -122,11 +127,11 @@ LIGHTWEIGHT_SQL_MIGRATION(20260629000006, "Create cards table") { .RequiredColumn("pin_hash", Varchar(16)); } -LIGHTWEIGHT_SQL_MIGRATION(20260629000007, "Create loans table") { +LIGHTWEIGHT_SQL_MIGRATION(20260630000006, "Create loans table") { plan.CreateTableIfNotExists("loans") .PrimaryKeyWithAutoIncrement("id", Bigint()) - .RequiredColumn("owner", Varchar(64)) - .RequiredColumn("account_id", Bigint()) + .RequiredForeignKey("user_id", Bigint(), usersRef()) + .RequiredForeignKey("account_id", Bigint(), accountsRef()) .RequiredColumn("principal_minor", Bigint()) .RequiredColumn("outstanding_minor", Bigint()) .RequiredColumn("currency", Integer()) @@ -136,19 +141,34 @@ LIGHTWEIGHT_SQL_MIGRATION(20260629000007, "Create loans table") { .RequiredColumn("created_at_ms", Bigint()); } -LIGHTWEIGHT_SQL_MIGRATION(20260629000008, "Create budgets table") { +LIGHTWEIGHT_SQL_MIGRATION(20260630000007, "Create payments table") { + plan.CreateTableIfNotExists("payments") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredForeignKey("user_id", Bigint(), usersRef()) + .RequiredColumn("currency", Integer()) + .RequiredColumn("amount_minor", Bigint()) + .RequiredForeignKey("from_account_id", Bigint(), accountsRef()) + .RequiredForeignKey("payee_id", Bigint(), payeesRef()) + .RequiredColumn("schedule", Integer()) + .RequiredColumn("status", Integer()) + .RequiredColumn("due_at_ms", Bigint()) + .RequiredColumn("interval_days", Integer()) + .RequiredColumn("description", Varchar(128)); +} + +LIGHTWEIGHT_SQL_MIGRATION(20260630000008, "Create budgets table") { plan.CreateTableIfNotExists("budgets") .PrimaryKeyWithAutoIncrement("id", Bigint()) - .RequiredColumn("owner", Varchar(64)) + .RequiredForeignKey("user_id", Bigint(), usersRef()) .RequiredColumn("category", Varchar(64)) .RequiredColumn("monthly_limit_minor", Bigint()) .RequiredColumn("currency", Integer()); } -LIGHTWEIGHT_SQL_MIGRATION(20260629000009, "Create notifications table") { +LIGHTWEIGHT_SQL_MIGRATION(20260630000009, "Create notifications table") { plan.CreateTableIfNotExists("notifications") .PrimaryKeyWithAutoIncrement("id", Bigint()) - .RequiredColumn("owner", Varchar(64)) + .RequiredForeignKey("user_id", Bigint(), usersRef()) .RequiredColumn("severity", Integer()) .RequiredColumn("message", Varchar(256)) .RequiredColumn("is_read", Bool()) diff --git a/examples/bank/src/models/account_model.cpp b/examples/bank/src/models/account_model.cpp index 8ab3fc5..60be7af 100644 --- a/examples/bank/src/models/account_model.cpp +++ b/examples/bank/src/models/account_model.cpp @@ -11,8 +11,8 @@ #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" #include "bank/core/types.hpp" -#include "bank/db/account_entity.hpp" #include "bank/db/ledger_ops.hpp" +#include "bank/db/user_ops.hpp" namespace bank { @@ -31,10 +31,12 @@ std::string generateAccountNumber() { } /// Translates a persisted `AccountRecord` into the wire `AccountInfo` DTO. -dto::AccountInfo toInfo(const db::AccountRecord& rec) { +/// @p owner is the resolved owner username (the wire DTO carries the username +/// rather than the internal `user_id` the record stores). +dto::AccountInfo toInfo(const db::AccountRecord& rec, const std::string& owner) { return dto::AccountInfo{ .id = static_cast(rec.id.Value()), - .owner = std::string{rec.owner.Value().str()}, + .owner = owner, .number = std::string{rec.number.Value().str()}, .kind = rec.kind.Value(), .currency = rec.currency.Value(), @@ -62,7 +64,7 @@ dto::AccountInfo AccountModel::execute(const dto::OpenAccount& action) { } db::AccountRecord rec; - rec.owner = Light::SqlAnsiString<64>{owner}; + db::setReference(rec.user, db::requireUserId(mapper(), owner)); rec.number = Light::SqlAnsiString<34>{generateAccountNumber()}; rec.kind = action.kind; rec.currency = action.currency; @@ -72,7 +74,7 @@ dto::AccountInfo AccountModel::execute(const dto::OpenAccount& action) { rec.interestBps = defaultInterestBps(action.kind); mapper().Create(rec); - return toInfo(rec); + return toInfo(rec, owner); } dto::AccountList AccountModel::execute(const dto::ListAccounts& action) { @@ -81,22 +83,24 @@ dto::AccountList AccountModel::execute(const dto::ListAccounts& action) { throw Unauthorized{"no session principal to list accounts for"}; } - auto rows = mapper() - .Query() - .Where(Lightweight::FieldNameOf<&db::AccountRecord::owner>, "=", owner) - .All(); + // Load the owner and walk the `UserRecord::accounts` HasMany relation rather + // than issuing a manual `WHERE user_id = ?` — the relation resolves the join + // for us and returns the user's accounts directly. + const auto userId = db::requireUserId(mapper(), owner); + auto user = mapper().QuerySingle(userId).value(); dto::AccountList out; - out.accounts.reserve(rows.size()); - for (const auto& rec : rows) { - out.accounts.push_back(toInfo(rec)); + out.accounts.reserve(user.accounts.Count()); + for (const auto& account : user.accounts.All()) { + out.accounts.push_back(toInfo(*account, owner)); } return out; } dto::AccountInfo AccountModel::execute(const dto::GetAccount& action) { - auto rec = db::loadOwned(mapper(), action.id, sessionPrincipal(), "account"); - return toInfo(rec); + const std::string owner = sessionPrincipal(); + auto rec = db::loadOwned(mapper(), action.id, owner, "account"); + return toInfo(rec, owner); } dto::CommandResult AccountModel::execute(const dto::CloseAccount& action) { diff --git a/examples/bank/src/models/auth_model.cpp b/examples/bank/src/models/auth_model.cpp index 00ebff8..eae0a9a 100644 --- a/examples/bank/src/models/auth_model.cpp +++ b/examples/bank/src/models/auth_model.cpp @@ -11,7 +11,9 @@ #include "bank/core/demo_hash.hpp" #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" -#include "bank/db/user_entity.hpp" +// Querying UserRecord configures its `HasMany` auto-loader, which +// needs the related entity types complete — pull in the whole graph. +#include "bank/db/entities.hpp" namespace bank { @@ -24,15 +26,12 @@ std::string hashPassword(std::string_view username, std::string_view password) { return demoHash(std::string{username} + ":" + std::string{password} + ":morph-bank"); } -/// Finds a user by username, or std::nullopt. -std::optional findUser(Lightweight::DataMapper& mapper, const std::string& username) { - auto rows = mapper.Query() - .Where(Lightweight::FieldNameOf<&db::UserRecord::username>, "=", username) - .All(); - if (rows.empty()) { - return std::nullopt; - } - return std::move(rows.front()); +/// Finds a user by username, or std::nullopt. Uses the relation-free `UserRow` +/// projection so the fluent query/update work (see `UserRecord`'s warning). +std::optional findUser(Lightweight::DataMapper& mapper, const std::string& username) { + return mapper.Query() + .Where(Lightweight::FieldNameOf<&db::UserRow::username>, "=", username) + .First(); } } // namespace @@ -45,7 +44,7 @@ dto::AuthResult AuthModel::execute(const dto::RegisterUser& action) { return dto::AuthResult{.ok = false, .message = "username already taken"}; } - db::UserRecord rec; + db::UserRow rec; rec.username = Light::SqlAnsiString<64>{action.username}; rec.passwordHash = Light::SqlAnsiString<32>{hashPassword(action.username, action.password)}; rec.displayName = @@ -89,7 +88,7 @@ dto::CommandResult AuthModel::execute(const dto::ChangePassword& action) { throw ValidationError{"new password must be at least 4 characters"}; } user->passwordHash = Light::SqlAnsiString<32>{hashPassword(action.username, action.newPassword)}; - mapper().Update(*user); + mapper().Update(*user); // UserRow is relation-free, so typed Update works return dto::CommandResult{.ok = true, .message = "password changed"}; } diff --git a/examples/bank/src/models/budget_model.cpp b/examples/bank/src/models/budget_model.cpp index 951b91b..3fad1f9 100644 --- a/examples/bank/src/models/budget_model.cpp +++ b/examples/bank/src/models/budget_model.cpp @@ -11,18 +11,17 @@ #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" #include "bank/core/types.hpp" -#include "bank/db/budget_entity.hpp" #include "bank/db/ledger_ops.hpp" -#include "bank/db/txn_entity.hpp" +#include "bank/db/user_ops.hpp" namespace bank { namespace { -dto::BudgetInfo toInfo(const db::BudgetRecord& rec) { +dto::BudgetInfo toInfo(const db::BudgetRecord& rec, const std::string& owner) { return dto::BudgetInfo{ .id = static_cast(rec.id.Value()), - .owner = std::string{rec.owner.Value().str()}, + .owner = owner, .category = std::string{rec.category.Value().str()}, .monthlyLimitMinor = rec.monthlyLimitMinor.Value(), .currency = rec.currency.Value(), @@ -40,10 +39,11 @@ dto::BudgetInfo BudgetModel::execute(const dto::SetBudget& action) { throw Unauthorized{"no session principal"}; } - // Upsert: update the existing row for (owner, category) or create a new one. + // Upsert: update the existing row for (user, category) or create a new one. + const auto userId = db::requireUserId(mapper(), owner); auto existing = mapper() .Query() - .Where(Lightweight::FieldNameOf<&db::BudgetRecord::owner>, "=", owner) + .Where(Lightweight::FieldNameOf<&db::BudgetRecord::user>, "=", userId) .Where(Lightweight::FieldNameOf<&db::BudgetRecord::category>, "=", action.category) .All(); if (!existing.empty()) { @@ -51,16 +51,16 @@ dto::BudgetInfo BudgetModel::execute(const dto::SetBudget& action) { rec.monthlyLimitMinor = action.monthlyLimitMinor; rec.currency = action.currency; mapper().Update(rec); - return toInfo(rec); + return toInfo(rec, owner); } db::BudgetRecord rec; - rec.owner = Light::SqlAnsiString<64>{owner}; + db::setReference(rec.user, userId); rec.category = Light::SqlAnsiString<64>{action.category}; rec.monthlyLimitMinor = action.monthlyLimitMinor; rec.currency = action.currency; mapper().Create(rec); - return toInfo(rec); + return toInfo(rec, owner); } dto::CommandResult BudgetModel::execute(const dto::DeleteBudget& action) { @@ -74,14 +74,15 @@ dto::BudgetList BudgetModel::execute(const dto::ListBudgets& action) { if (owner.empty()) { throw Unauthorized{"no session principal"}; } + const auto userId = db::requireUserId(mapper(), owner); auto rows = mapper() .Query() - .Where(Lightweight::FieldNameOf<&db::BudgetRecord::owner>, "=", owner) + .Where(Lightweight::FieldNameOf<&db::BudgetRecord::user>, "=", userId) .All(); dto::BudgetList out; out.budgets.reserve(rows.size()); for (const auto& rec : rows) { - out.budgets.push_back(toInfo(rec)); + out.budgets.push_back(toInfo(rec, owner)); } return out; } @@ -91,7 +92,7 @@ dto::SpendingReport BudgetModel::execute(const dto::SpendingByKind& action) { // aggregate cross the wire; the by-kind rollup stays in code (no GROUP BY SQL). auto rows = mapper() .Query() - .Where(Lightweight::FieldNameOf<&db::TxnRecord::accountId>, "=", action.accountId) + .Where(Lightweight::FieldNameOf<&db::TxnRecord::account>, "=", action.accountId) .Where(Lightweight::FieldNameOf<&db::TxnRecord::direction>, "=", static_cast(TxnDirection::Debit)) .Where(Lightweight::FieldNameOf<&db::TxnRecord::createdAtMs>, ">=", action.sinceMs) diff --git a/examples/bank/src/models/card_model.cpp b/examples/bank/src/models/card_model.cpp index 53d0895..3076c70 100644 --- a/examples/bank/src/models/card_model.cpp +++ b/examples/bank/src/models/card_model.cpp @@ -14,9 +14,8 @@ #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" #include "bank/core/types.hpp" -#include "bank/db/account_entity.hpp" -#include "bank/db/card_entity.hpp" #include "bank/db/ledger_ops.hpp" +#include "bank/db/user_ops.hpp" namespace bank { @@ -32,11 +31,11 @@ std::string hashPin(std::string_view pin) { return demoHash(std::string{pin} + ":pin"); } -dto::CardInfo toInfo(const db::CardRecord& rec) { +dto::CardInfo toInfo(const db::CardRecord& rec, const std::string& owner) { return dto::CardInfo{ .id = static_cast(rec.id.Value()), - .owner = std::string{rec.owner.Value().str()}, - .accountId = rec.accountId.Value(), + .owner = owner, + .accountId = static_cast(rec.account.Value()), .kind = rec.kind.Value(), .panLast4 = std::string{rec.panLast4.Value().str()}, .status = rec.status.Value(), @@ -58,15 +57,15 @@ dto::CardInfo CardModel::execute(const dto::IssueCard& action) { auto account = db::loadOwnedOpenAccount(mapper(), action.accountId, owner); db::CardRecord card; - card.owner = Light::SqlAnsiString<64>{owner}; - card.accountId = action.accountId; + db::setReference(card.account, account.id.Value()); + db::setReference(card.user, db::requireUserId(mapper(), owner)); card.kind = action.kind; card.panLast4 = Light::SqlAnsiString<4>{randomLast4()}; card.status = static_cast(CardStatus::Active); card.dailyLimitMinor = action.dailyLimitMinor; card.pinHash = Light::SqlAnsiString<16>{hashPin("0000")}; mapper().Create(card); - return toInfo(card); + return toInfo(card, owner); } namespace { @@ -127,14 +126,15 @@ dto::CardList CardModel::execute(const dto::ListCards& action) { if (owner.empty()) { throw Unauthorized{"no session principal"}; } + const auto userId = db::requireUserId(mapper(), owner); auto rows = mapper() .Query() - .Where(Lightweight::FieldNameOf<&db::CardRecord::owner>, "=", owner) + .Where(Lightweight::FieldNameOf<&db::CardRecord::user>, "=", userId) .All(); dto::CardList out; out.cards.reserve(rows.size()); for (const auto& rec : rows) { - out.cards.push_back(toInfo(rec)); + out.cards.push_back(toInfo(rec, owner)); } return out; } diff --git a/examples/bank/src/models/loan_model.cpp b/examples/bank/src/models/loan_model.cpp index 965b59b..6743f25 100644 --- a/examples/bank/src/models/loan_model.cpp +++ b/examples/bank/src/models/loan_model.cpp @@ -14,17 +14,17 @@ #include "bank/core/principal.hpp" #include "bank/core/types.hpp" #include "bank/db/ledger_ops.hpp" -#include "bank/db/loan_entity.hpp" +#include "bank/db/user_ops.hpp" namespace bank { namespace { -dto::LoanInfo toInfo(const db::LoanRecord& rec) { +dto::LoanInfo toInfo(const db::LoanRecord& rec, const std::string& owner) { return dto::LoanInfo{ .id = static_cast(rec.id.Value()), - .owner = std::string{rec.owner.Value().str()}, - .accountId = rec.accountId.Value(), + .owner = owner, + .accountId = static_cast(rec.account.Value()), .principalMinor = rec.principalMinor.Value(), .outstandingMinor = rec.outstandingMinor.Value(), .currency = rec.currency.Value(), @@ -64,8 +64,8 @@ dto::LoanInfo LoanModel::execute(const dto::ApplyLoan& action) { auto account = db::loadOwnedOpenAccount(dm, action.accountId, owner); db::LoanRecord loan; - loan.owner = Light::SqlAnsiString<64>{owner}; - loan.accountId = action.accountId; + db::setReference(loan.user, db::requireUserId(dm, owner)); + db::setReference(loan.account, account.id.Value()); loan.principalMinor = action.principalMinor; loan.outstandingMinor = action.principalMinor; loan.currency = account.currency.Value(); @@ -79,7 +79,7 @@ dto::LoanInfo LoanModel::execute(const dto::ApplyLoan& action) { db::applyCredit(dm, account, action.principalMinor, TxnKind::LoanDisbursement, 0, "loan disbursement"); tx.Commit(); - return toInfo(loan); + return toInfo(loan, owner); } dto::LoanInfo LoanModel::execute(const dto::RepayLoan& action) { @@ -87,6 +87,7 @@ dto::LoanInfo LoanModel::execute(const dto::RepayLoan& action) { throw ValidationError{"invalid repayment"}; } auto& dm = mapper(); + const std::string owner = sessionPrincipal(); auto loan = requireOwnedLoan(dm, action.loanId); if (loan.status.Value() != static_cast(LoanStatus::Active)) { throw ConflictError{"loan is not active"}; @@ -104,11 +105,12 @@ dto::LoanInfo LoanModel::execute(const dto::RepayLoan& action) { dm.Update(loan); tx.Commit(); - return toInfo(loan); + return toInfo(loan, owner); } dto::LoanInfo LoanModel::execute(const dto::GetLoan& action) { - return toInfo(requireOwnedLoan(mapper(), action.id)); + const std::string owner = sessionPrincipal(); + return toInfo(requireOwnedLoan(mapper(), action.id), owner); } dto::LoanList LoanModel::execute(const dto::ListLoans& action) { @@ -116,14 +118,15 @@ dto::LoanList LoanModel::execute(const dto::ListLoans& action) { if (owner.empty()) { throw Unauthorized{"no session principal"}; } + const auto userId = db::requireUserId(mapper(), owner); auto rows = mapper() .Query() - .Where(Lightweight::FieldNameOf<&db::LoanRecord::owner>, "=", owner) + .Where(Lightweight::FieldNameOf<&db::LoanRecord::user>, "=", userId) .All(); dto::LoanList out; out.loans.reserve(rows.size()); for (const auto& rec : rows) { - out.loans.push_back(toInfo(rec)); + out.loans.push_back(toInfo(rec, owner)); } return out; } diff --git a/examples/bank/src/models/notification_model.cpp b/examples/bank/src/models/notification_model.cpp index 4ade8a3..faee96c 100644 --- a/examples/bank/src/models/notification_model.cpp +++ b/examples/bank/src/models/notification_model.cpp @@ -10,16 +10,16 @@ #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" #include "bank/db/ledger_ops.hpp" // for nowMillis() -#include "bank/db/notification_entity.hpp" +#include "bank/db/user_ops.hpp" namespace bank { namespace { -dto::NotificationInfo toInfo(const db::NotificationRecord& rec) { +dto::NotificationInfo toInfo(const db::NotificationRecord& rec, const std::string& owner) { return dto::NotificationInfo{ .id = static_cast(rec.id.Value()), - .owner = std::string{rec.owner.Value().str()}, + .owner = owner, .severity = rec.severity.Value(), .message = std::string{rec.message.Value().str()}, .read = rec.read.Value(), @@ -38,13 +38,13 @@ dto::NotificationInfo NotificationModel::execute(const dto::Notify& action) { throw Unauthorized{"no session principal"}; } db::NotificationRecord rec; - rec.owner = Light::SqlAnsiString<64>{owner}; + db::setReference(rec.user, db::requireUserId(mapper(), owner)); rec.severity = action.severity; rec.message = Light::SqlAnsiString<256>{action.message}; rec.read = false; rec.createdAtMs = db::nowMillis(); mapper().Create(rec); - return toInfo(rec); + return toInfo(rec, owner); } dto::NotificationList NotificationModel::execute(const dto::ListNotifications& action) { @@ -52,9 +52,10 @@ dto::NotificationList NotificationModel::execute(const dto::ListNotifications& a if (owner.empty()) { throw Unauthorized{"no session principal"}; } + const auto userId = db::requireUserId(mapper(), owner); auto rows = mapper() .Query() - .Where(Lightweight::FieldNameOf<&db::NotificationRecord::owner>, "=", owner) + .Where(Lightweight::FieldNameOf<&db::NotificationRecord::user>, "=", userId) .All(); dto::NotificationList out; for (const auto& rec : rows) { @@ -64,7 +65,7 @@ dto::NotificationList NotificationModel::execute(const dto::ListNotifications& a if (action.unreadOnly && rec.read.Value()) { continue; } - out.notifications.push_back(toInfo(rec)); + out.notifications.push_back(toInfo(rec, owner)); } return out; } @@ -83,9 +84,10 @@ dto::CommandResult NotificationModel::execute(const dto::MarkAllRead& action) { } // Only the unread rows need touching, so filter in the query rather than // scanning every notification and branching per row. + const auto userId = db::requireUserId(mapper(), owner); auto rows = mapper() .Query() - .Where(Lightweight::FieldNameOf<&db::NotificationRecord::owner>, "=", owner) + .Where(Lightweight::FieldNameOf<&db::NotificationRecord::user>, "=", userId) .Where(Lightweight::FieldNameOf<&db::NotificationRecord::read>, "=", false) .All(); int updated = 0; diff --git a/examples/bank/src/models/payee_model.cpp b/examples/bank/src/models/payee_model.cpp index b576cc8..1daa1d4 100644 --- a/examples/bank/src/models/payee_model.cpp +++ b/examples/bank/src/models/payee_model.cpp @@ -10,16 +10,19 @@ #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" #include "bank/db/ledger_ops.hpp" -#include "bank/db/payee_entity.hpp" +#include "bank/db/user_ops.hpp" namespace bank { namespace { -dto::PayeeInfo toInfo(const db::PayeeRecord& rec) { +/// Works for either the `PayeeRecord` aggregate or the `PayeeRow` projection — +/// both expose the same scalar fields. +template +dto::PayeeInfo toInfo(const Record& rec, const std::string& owner) { return dto::PayeeInfo{ .id = static_cast(rec.id.Value()), - .owner = std::string{rec.owner.Value().str()}, + .owner = owner, .name = std::string{rec.name.Value().str()}, .iban = std::string{rec.iban.Value().str()}, .bankName = std::string{rec.bankName.Value().str()}, @@ -38,12 +41,12 @@ dto::PayeeInfo PayeeModel::execute(const dto::AddPayee& action) { } db::PayeeRecord rec; - rec.owner = Light::SqlAnsiString<64>{owner}; + db::setReference(rec.user, db::requireUserId(mapper(), owner)); rec.name = Light::SqlAnsiString<128>{action.name}; rec.iban = Light::SqlAnsiString<34>{action.iban}; rec.bankName = Light::SqlAnsiString<128>{action.bankName}; mapper().Create(rec); - return toInfo(rec); + return toInfo(rec, owner); } dto::CommandResult PayeeModel::execute(const dto::RemovePayee& action) { @@ -57,14 +60,17 @@ dto::PayeeList PayeeModel::execute(const dto::ListPayees& action) { if (owner.empty()) { throw Unauthorized{"no session principal"}; } + // Fluent list query uses the relation-free `PayeeRow` projection (the + // `PayeeRecord` aggregate carries a `HasMany` the fluent builder can't select). + const auto userId = db::requireUserId(mapper(), owner); auto rows = mapper() - .Query() - .Where(Lightweight::FieldNameOf<&db::PayeeRecord::owner>, "=", owner) + .Query() + .Where(Lightweight::FieldNameOf<&db::PayeeRow::user>, "=", userId) .All(); dto::PayeeList out; out.payees.reserve(rows.size()); for (const auto& rec : rows) { - out.payees.push_back(toInfo(rec)); + out.payees.push_back(toInfo(rec, owner)); } return out; } diff --git a/examples/bank/src/models/payment_model.cpp b/examples/bank/src/models/payment_model.cpp index 4fde99a..5f52170 100644 --- a/examples/bank/src/models/payment_model.cpp +++ b/examples/bank/src/models/payment_model.cpp @@ -12,19 +12,18 @@ #include "bank/core/principal.hpp" #include "bank/core/types.hpp" #include "bank/db/ledger_ops.hpp" -#include "bank/db/payee_entity.hpp" -#include "bank/db/payment_entity.hpp" +#include "bank/db/user_ops.hpp" namespace bank { namespace { -dto::PaymentInfo toInfo(const db::PaymentRecord& rec) { +dto::PaymentInfo toInfo(const db::PaymentRecord& rec, const std::string& owner) { return dto::PaymentInfo{ .id = static_cast(rec.id.Value()), - .owner = std::string{rec.owner.Value().str()}, - .fromAccountId = rec.fromAccountId.Value(), - .payeeId = rec.payeeId.Value(), + .owner = owner, + .fromAccountId = static_cast(rec.fromAccount.Value()), + .payeeId = static_cast(rec.payee.Value()), .amountMinor = rec.amountMinor.Value(), .currency = rec.currency.Value(), .schedule = rec.schedule.Value(), @@ -62,9 +61,9 @@ dto::PaymentInfo PaymentModel::execute(const dto::PayBill& action) { requireOwnedPayee(dm, action.payeeId, owner); db::PaymentRecord payment; - payment.owner = Light::SqlAnsiString<64>{owner}; - payment.fromAccountId = action.fromAccountId; - payment.payeeId = action.payeeId; + db::setReference(payment.user, db::requireUserId(dm, owner)); + db::setReference(payment.fromAccount, static_cast(action.fromAccountId)); + db::setReference(payment.payee, static_cast(action.payeeId)); payment.amountMinor = action.amountMinor; payment.currency = account.currency.Value(); payment.schedule = static_cast(PaymentSchedule::OneOff); @@ -78,7 +77,7 @@ dto::PaymentInfo PaymentModel::execute(const dto::PayBill& action) { dm.Create(payment); tx.Commit(); - return toInfo(payment); + return toInfo(payment, owner); } dto::PaymentInfo PaymentModel::execute(const dto::SchedulePayment& action) { @@ -91,9 +90,9 @@ dto::PaymentInfo PaymentModel::execute(const dto::SchedulePayment& action) { requireOwnedPayee(dm, action.payeeId, owner); db::PaymentRecord payment; - payment.owner = Light::SqlAnsiString<64>{owner}; - payment.fromAccountId = action.fromAccountId; - payment.payeeId = action.payeeId; + db::setReference(payment.user, db::requireUserId(dm, owner)); + db::setReference(payment.fromAccount, static_cast(action.fromAccountId)); + db::setReference(payment.payee, static_cast(action.payeeId)); payment.amountMinor = action.amountMinor; payment.currency = account.currency.Value(); payment.schedule = static_cast(PaymentSchedule::Scheduled); @@ -102,7 +101,7 @@ dto::PaymentInfo PaymentModel::execute(const dto::SchedulePayment& action) { payment.intervalDays = 0; payment.description = Light::SqlAnsiString<128>{action.description}; dm.Create(payment); - return toInfo(payment); + return toInfo(payment, owner); } dto::PaymentInfo PaymentModel::execute(const dto::CreateStandingOrder& action) { @@ -115,9 +114,9 @@ dto::PaymentInfo PaymentModel::execute(const dto::CreateStandingOrder& action) { requireOwnedPayee(dm, action.payeeId, owner); db::PaymentRecord payment; - payment.owner = Light::SqlAnsiString<64>{owner}; - payment.fromAccountId = action.fromAccountId; - payment.payeeId = action.payeeId; + db::setReference(payment.user, db::requireUserId(dm, owner)); + db::setReference(payment.fromAccount, static_cast(action.fromAccountId)); + db::setReference(payment.payee, static_cast(action.payeeId)); payment.amountMinor = action.amountMinor; payment.currency = account.currency.Value(); payment.schedule = static_cast(PaymentSchedule::Standing); @@ -126,7 +125,7 @@ dto::PaymentInfo PaymentModel::execute(const dto::CreateStandingOrder& action) { payment.intervalDays = action.intervalDays; payment.description = Light::SqlAnsiString<128>{action.description}; dm.Create(payment); - return toInfo(payment); + return toInfo(payment, owner); } dto::CommandResult PaymentModel::execute(const dto::CancelPayment& action) { @@ -144,14 +143,15 @@ dto::PaymentList PaymentModel::execute(const dto::ListPayments& action) { if (owner.empty()) { throw Unauthorized{"no session principal"}; } + const auto userId = db::requireUserId(mapper(), owner); auto rows = mapper() .Query() - .Where(Lightweight::FieldNameOf<&db::PaymentRecord::owner>, "=", owner) + .Where(Lightweight::FieldNameOf<&db::PaymentRecord::user>, "=", userId) .All(); dto::PaymentList out; out.payments.reserve(rows.size()); for (const auto& rec : rows) { - out.payments.push_back(toInfo(rec)); + out.payments.push_back(toInfo(rec, owner)); } return out; } diff --git a/examples/bank/src/models/statement_model.cpp b/examples/bank/src/models/statement_model.cpp index 70b539f..14eef56 100644 --- a/examples/bank/src/models/statement_model.cpp +++ b/examples/bank/src/models/statement_model.cpp @@ -13,8 +13,8 @@ #include "bank/core/errors.hpp" #include "bank/core/principal.hpp" #include "bank/core/types.hpp" -#include "bank/db/account_entity.hpp" -#include "bank/db/txn_entity.hpp" +#include "bank/db/entities.hpp" +#include "bank/db/user_ops.hpp" namespace bank { @@ -24,10 +24,10 @@ dto::Statement StatementModel::execute(const dto::GenerateStatement& action) { throw Unauthorized{"no session principal"}; } - auto accounts = mapper() - .Query() - .Where(Lightweight::FieldNameOf<&db::AccountRecord::owner>, "=", owner) - .All(); + // Reach the owner's accounts through the `UserRecord::accounts` relation. + const auto userId = db::requireUserId(mapper(), owner); + auto user = mapper().QuerySingle(userId).value(); + const auto& accounts = user.accounts.All(); dto::Statement statement; statement.owner = owner; @@ -41,12 +41,12 @@ dto::Statement StatementModel::execute(const dto::GenerateStatement& action) { statement.lines.reserve(accounts.size()); accountIds.reserve(accounts.size()); for (const auto& account : accounts) { - const auto accountId = static_cast(account.id.Value()); + const auto accountId = static_cast(account->id.Value()); dto::StatementLine line; line.accountId = accountId; - line.number = std::string{account.number.Value().str()}; - line.currency = account.currency.Value(); - line.closingBalanceMinor = account.balanceMinor.Value(); + line.number = std::string{account->number.Value().str()}; + line.currency = account->currency.Value(); + line.closingBalanceMinor = account->balanceMinor.Value(); lineIndex.emplace(accountId, statement.lines.size()); statement.lines.push_back(std::move(line)); accountIds.push_back(accountId); @@ -60,7 +60,7 @@ dto::Statement StatementModel::execute(const dto::GenerateStatement& action) { // (toMs == 0 means "open ended") stays as a cheap in-loop check. auto entries = mapper() .Query() - .WhereIn(Lightweight::FieldNameOf<&db::TxnRecord::accountId>, accountIds) + .WhereIn(Lightweight::FieldNameOf<&db::TxnRecord::account>, accountIds) .Where(Lightweight::FieldNameOf<&db::TxnRecord::createdAtMs>, ">=", action.fromMs) .All(); @@ -69,7 +69,7 @@ dto::Statement StatementModel::execute(const dto::GenerateStatement& action) { if (action.toMs != 0 && when > action.toMs) { continue; } - const auto it = lineIndex.find(entry.accountId.Value()); + const auto it = lineIndex.find(static_cast(entry.account.Value())); if (it == lineIndex.end()) { continue; } diff --git a/examples/bank/src/models/transaction_model.cpp b/examples/bank/src/models/transaction_model.cpp index bd49ca8..faed221 100644 --- a/examples/bank/src/models/transaction_model.cpp +++ b/examples/bank/src/models/transaction_model.cpp @@ -22,8 +22,9 @@ namespace { dto::TxnInfo toTxnInfo(const db::TxnRecord& rec) { return dto::TxnInfo{ .id = static_cast(rec.id.Value()), - .accountId = rec.accountId.Value(), - .counterpartyId = rec.counterpartyId.Value(), + .accountId = static_cast(rec.account.Value()), + // Nullable relation: NULL (deposits/withdrawals) surfaces as 0 on the wire. + .counterpartyId = static_cast(rec.counterparty.Value().value_or(0)), .direction = rec.direction.Value(), .kind = rec.kind.Value(), .amountMinor = rec.amountMinor.Value(), @@ -97,7 +98,7 @@ dto::HistoryPage TransactionModel::execute(const dto::History& action) { // fetched instead of loading and sorting the whole ledger in memory. auto rows = mapper() .Query() - .Where(Lightweight::FieldNameOf<&db::TxnRecord::accountId>, "=", action.accountId) + .Where(Lightweight::FieldNameOf<&db::TxnRecord::account>, "=", action.accountId) .OrderBy(Lightweight::FieldNameOf<&db::TxnRecord::id>, Lightweight::SqlResultOrdering::DESCENDING) .Range(offset, limit); diff --git a/examples/bank/tests/bank_test_support.hpp b/examples/bank/tests/bank_test_support.hpp index 7495ec9..7e5bd69 100644 --- a/examples/bank/tests/bank_test_support.hpp +++ b/examples/bank/tests/bank_test_support.hpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include #include #include @@ -13,6 +14,8 @@ #include #include "bank/db/database.hpp" +#include "bank/db/entities.hpp" +#include "bank/db/user_ops.hpp" /// @file /// Shared helpers for the bank example tests. @@ -36,6 +39,18 @@ inline void ensureDatabase() { (void)once; } +/// @brief Ensures the database exists and a `users` row for @p principal does +/// too, so models can resolve it to a `user_id`. +/// +/// `App::login` provisions the principal automatically; tests that drive a model +/// without `App` (e.g. the remote-backend test, which sets the session principal +/// directly) call this to get the same effect. +inline void ensurePrincipal(const std::string& principal) { + ensureDatabase(); + Lightweight::DataMapper mapper; + bank::db::ensureUser(mapper, principal); +} + /// @brief Runs a morph action to completion synchronously by pumping @p gui. /// /// Posts the completion's callbacks onto @p gui (which must be the same diff --git a/examples/bank/tests/test_relations.cpp b/examples/bank/tests/test_relations.cpp new file mode 100644 index 0000000..e681acd --- /dev/null +++ b/examples/bank/tests/test_relations.cpp @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Exercises the Lightweight ORM relations the persistence layer is built on: +// * BelongsTo — navigating a foreign key to the referenced record +// (account.user, card.account, payment.payee/fromAccount, …) +// * HasMany — the inverse one-to-many (user.accounts, payee.payments) +// * nullable BelongsTo — transactions.counterparty (set for transfers, +// NULL for deposits/withdrawals) +// +// Data is created through the real models/bridge (the same paths the GUI/CLI +// use); the relations are then read back directly through a DataMapper so the +// assertions are about what actually persisted. + +#include + +#include +#include + +#include +#include + +#include "bank/app/app.hpp" +#include "bank/core/types.hpp" +#include "bank/db/entities.hpp" +#include "bank/db/user_ops.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/card_dto.hpp" +#include "bank/dto/payee_dto.hpp" +#include "bank/dto/payment_dto.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank/models/card_model.hpp" +#include "bank/models/payee_model.hpp" +#include "bank/models/payment_model.hpp" +#include "bank/models/transaction_model.hpp" +#include "bank_test_support.hpp" + +using bank::testing::await; + +namespace { + +std::string dbConnectionForTests() { + bank::testing::ensureDatabase(); + return "DRIVER=SQLite3;Database=" + + (std::filesystem::temp_directory_path() / "morph_bank_tests.db").string(); +} + +} // namespace + +TEST_CASE("ORM relations: BelongsTo navigation and HasMany inverses", "[relations]") { + const std::string principal = "rel-user"; + bank::app::App app{dbConnectionForTests()}; + app.login(principal); // provisions the users row + + morph::bridge::BridgeHandler accounts{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler cards{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler payees{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler payments{app.bridge(), app.gui()}; + morph::bridge::BridgeHandler txns{app.bridge(), app.gui()}; + + // Build a small graph: two accounts, a card, a payee, a one-off payment, a + // deposit, and a transfer between the accounts. + auto checking = await(accounts.execute(bank::dto::OpenAccount{ + .kind = static_cast(bank::AccountKind::Checking), + .currency = static_cast(bank::Currency::EUR)}), + app.guiLoop()); + auto savings = await(accounts.execute(bank::dto::OpenAccount{ + .kind = static_cast(bank::AccountKind::Savings), + .currency = static_cast(bank::Currency::EUR)}), + app.guiLoop()); + auto card = await(cards.execute(bank::dto::IssueCard{.accountId = checking.id, .kind = 0}), + app.guiLoop()); + auto payee = await(payees.execute(bank::dto::AddPayee{ + .name = "Landlord", .iban = "DE89370400440532013000", .bankName = "ACME"}), + app.guiLoop()); + await(txns.execute(bank::dto::Deposit{.accountId = checking.id, .amountMinor = 100000}), app.guiLoop()); + auto payment = await(payments.execute(bank::dto::PayBill{.fromAccountId = checking.id, + .payeeId = payee.id, + .amountMinor = 5000, + .description = "rent"}), + app.guiLoop()); + await(txns.execute(bank::dto::Transfer{.fromAccountId = checking.id, + .toAccountId = savings.id, + .amountMinor = 30000}), + app.guiLoop()); + + // Read the persisted graph back through the relation API. + Lightweight::DataMapper dm; + const auto userId = bank::db::requireUserId(dm, principal); + + SECTION("BelongsTo: account.user navigates to the owning user") { + auto acct = dm.QuerySingle(static_cast(checking.id)).value(); + REQUIRE(acct.user.Value() == userId); + REQUIRE(std::string{acct.user->username.Value().str()} == principal); // lazy navigation + } + + SECTION("BelongsTo: card.account and card.user resolve") { + auto rec = dm.QuerySingle(static_cast(card.id)).value(); + REQUIRE(static_cast(rec.account.Value()) == checking.id); + REQUIRE(std::string{rec.account->number.Value().str()} == checking.number); + REQUIRE(std::string{rec.user->username.Value().str()} == principal); + } + + SECTION("BelongsTo: payment.fromAccount and payment.payee resolve") { + auto rec = dm.QuerySingle(static_cast(payment.id)).value(); + REQUIRE(static_cast(rec.fromAccount.Value()) == checking.id); + REQUIRE(static_cast(rec.payee.Value()) == payee.id); + REQUIRE(std::string{rec.payee->name.Value().str()} == "Landlord"); + REQUIRE(std::string{rec.fromAccount->number.Value().str()} == checking.number); + } + + SECTION("HasMany: user.accounts contains the opened accounts") { + auto user = dm.QuerySingle(userId).value(); + REQUIRE(user.accounts.Count() >= 2); + bool sawChecking = false; + bool sawSavings = false; + for (const auto& acct : user.accounts.All()) { + const auto id = static_cast(acct->id.Value()); + sawChecking = sawChecking || id == checking.id; + sawSavings = sawSavings || id == savings.id; + REQUIRE(acct->user.Value() == userId); // every child points back + } + REQUIRE(sawChecking); + REQUIRE(sawSavings); + } + + SECTION("HasMany: payee.payments contains the bill payment") { + auto rec = dm.QuerySingle(static_cast(payee.id)).value(); + REQUIRE(rec.payments.Count() == 1); + REQUIRE(static_cast(rec.payments[0].id.Value()) == payment.id); + // navigate the "many" side back to its "one" side + REQUIRE(static_cast(rec.payments[0].payee.Value()) == payee.id); + } + + SECTION("nullable BelongsTo: counterparty is set for transfers, NULL otherwise") { + auto rows = dm.Query() + .Where(Lightweight::FieldNameOf<&bank::db::TxnRecord::account>, "=", checking.id) + .All(); + bool sawDepositWithoutCounterparty = false; + bool sawTransferWithCounterparty = false; + for (const auto& txn : rows) { + if (txn.kind.Value() == static_cast(bank::TxnKind::Deposit)) { + REQUIRE_FALSE(static_cast(txn.counterparty)); // NULL + sawDepositWithoutCounterparty = true; + } + if (txn.kind.Value() == static_cast(bank::TxnKind::TransferOut)) { + REQUIRE(static_cast(txn.counterparty)); + REQUIRE(static_cast(txn.counterparty.Value().value()) == savings.id); + sawTransferWithCounterparty = true; + } + } + REQUIRE(sawDepositWithoutCounterparty); + REQUIRE(sawTransferWithCounterparty); + } +} diff --git a/examples/bank/tests/test_remote.cpp b/examples/bank/tests/test_remote.cpp index 8d72e75..b80a8b8 100644 --- a/examples/bank/tests/test_remote.cpp +++ b/examples/bank/tests/test_remote.cpp @@ -58,6 +58,8 @@ TEST_CASE("AccountModel runs unchanged over a remote backend", "[remote]") { morph::session::Context ctx; ctx.principal = "olivia-remote"; bridge.setDefaultSession(ctx); + // No App here to provision the principal, so ensure its users row exists. + bank::testing::ensurePrincipal("olivia-remote"); morph::bridge::BridgeHandler accounts{bridge, &gui}; From eb2174fbbc2dee14917e85a3085c21464f1090d3 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 1 Jul 2026 08:17:52 +0200 Subject: [PATCH 06/10] [morph] bank example: self-contained WebAssembly GUI on GitHub Pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add examples/bank/gui_wasm: a single-threaded WebAssembly build of the QML bank GUI that runs entirely in the browser (models are the "server in the background") and is hostable on plain GitHub Pages — no external server. Lightweight is ODBC-only and can't run in a browser, so the WASM build swaps persistence for an in-memory store: - gui_wasm/include/bank/wasm/{store,store_ops}.hpp — Table/Db/sharedDb() and the ownership guards + ledger primitives, throwing the same bank:: errors. - Shadow model headers (gui_wasm/include/bank/models/*_model.hpp) declare the same class + BRIDGE_REGISTER_* + execute() overloads minus db_model.hpp; the WASM include path lists gui_wasm/include BEFORE so they shadow the native headers by name. Controllers, DTOs and QML are reused verbatim. - gui_wasm/src/models/*_model_wasm.cpp reimplement all 7 GUI-facing models against the store; main_wasm.cpp seeds a demo user + accounts and auto-signs in. BankClient is dual-moded (#ifdef __EMSCRIPTEN__): the WASM path drops the thread pool + ODBC setup and runs models on LocalBackend over QtExecutor (single thread). Single-threaded => no SharedArrayBuffer => no COOP/COEP => plain Pages hosting works. The native stack (Lightweight, bank_lib, CLI, tests) is gated behind if(NOT EMSCRIPTEN); the top-level CMake also gates Threads/morph_example for emscripten. Native build + all 17 bank tests remain green. CI: .github/workflows/wasm-demo.yml builds the bundle with a matched host+wasm Qt pair (aqtinstall) + emsdk and publishes to GitHub Pages under /demo/; docs.yml gains clean-exclude: demo/ so the two deploys coexist. Verified locally: builds to a valid wasm bundle and serves over plain HTTP with no special headers. Interactive browser click-through not run locally (no headless browser available). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq --- .github/workflows/docs.yml | 1 + .github/workflows/wasm-demo.yml | 104 +++++++++++ .gitignore | 1 + CMakeLists.txt | 29 +-- examples/bank/CMakeLists.txt | 12 ++ examples/bank/README.md | 39 +++- examples/bank/gui/BankClient.cpp | 10 ++ examples/bank/gui/BankClient.hpp | 5 + examples/bank/gui_wasm/CMakeLists.txt | 73 ++++++++ .../include/bank/models/account_model.hpp | 33 ++++ .../include/bank/models/auth_model.hpp | 37 ++++ .../include/bank/models/card_model.hpp | 43 +++++ .../include/bank/models/loan_model.hpp | 36 ++++ .../include/bank/models/payee_model.hpp | 31 ++++ .../include/bank/models/payment_model.hpp | 37 ++++ .../include/bank/models/transaction_model.hpp | 33 ++++ .../bank/gui_wasm/include/bank/wasm/store.hpp | 170 ++++++++++++++++++ .../gui_wasm/include/bank/wasm/store_ops.hpp | 129 +++++++++++++ examples/bank/gui_wasm/main_wasm.cpp | 130 ++++++++++++++ .../src/models/account_model_wasm.cpp | 108 +++++++++++ .../gui_wasm/src/models/auth_model_wasm.cpp | 96 ++++++++++ .../gui_wasm/src/models/card_model_wasm.cpp | 147 +++++++++++++++ .../gui_wasm/src/models/loan_model_wasm.cpp | 165 +++++++++++++++++ .../gui_wasm/src/models/payee_model_wasm.cpp | 76 ++++++++ .../src/models/payment_model_wasm.cpp | 160 +++++++++++++++++ .../src/models/transaction_model_wasm.cpp | 100 +++++++++++ 26 files changed, 1791 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/wasm-demo.yml create mode 100644 examples/bank/gui_wasm/CMakeLists.txt create mode 100644 examples/bank/gui_wasm/include/bank/models/account_model.hpp create mode 100644 examples/bank/gui_wasm/include/bank/models/auth_model.hpp create mode 100644 examples/bank/gui_wasm/include/bank/models/card_model.hpp create mode 100644 examples/bank/gui_wasm/include/bank/models/loan_model.hpp create mode 100644 examples/bank/gui_wasm/include/bank/models/payee_model.hpp create mode 100644 examples/bank/gui_wasm/include/bank/models/payment_model.hpp create mode 100644 examples/bank/gui_wasm/include/bank/models/transaction_model.hpp create mode 100644 examples/bank/gui_wasm/include/bank/wasm/store.hpp create mode 100644 examples/bank/gui_wasm/include/bank/wasm/store_ops.hpp create mode 100644 examples/bank/gui_wasm/main_wasm.cpp create mode 100644 examples/bank/gui_wasm/src/models/account_model_wasm.cpp create mode 100644 examples/bank/gui_wasm/src/models/auth_model_wasm.cpp create mode 100644 examples/bank/gui_wasm/src/models/card_model_wasm.cpp create mode 100644 examples/bank/gui_wasm/src/models/loan_model_wasm.cpp create mode 100644 examples/bank/gui_wasm/src/models/payee_model_wasm.cpp create mode 100644 examples/bank/gui_wasm/src/models/payment_model_wasm.cpp create mode 100644 examples/bank/gui_wasm/src/models/transaction_model_wasm.cpp diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 736e265..092f4b7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -53,3 +53,4 @@ jobs: uses: JamesIves/github-pages-deploy-action@v4 with: folder: build/html/html + clean-exclude: demo/ # preserve the WASM demo published by wasm-demo.yml diff --git a/.github/workflows/wasm-demo.yml b/.github/workflows/wasm-demo.yml new file mode 100644 index 0000000..5696fb7 --- /dev/null +++ b/.github/workflows/wasm-demo.yml @@ -0,0 +1,104 @@ +name: WASM Demo + +# Builds the self-contained WebAssembly bank GUI (examples/bank/gui_wasm) and +# publishes it to GitHub Pages under /demo/, alongside the Doxygen docs at the +# site root. Single-threaded Qt-for-WASM ⇒ no SharedArrayBuffer ⇒ no COOP/COEP +# headers ⇒ plain Pages hosting works. + +on: + push: + branches: + - master + paths: + - 'examples/bank/**' + - 'include/morph/**' + - '.github/workflows/wasm-demo.yml' + pull_request: + branches: + - master + paths: + - 'examples/bank/**' + - 'include/morph/**' + - '.github/workflows/wasm-demo.yml' + +concurrency: + group: wasm-demo-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +env: + QT_VERSION: 6.8.3 + EMSDK_VERSION: 3.1.56 # the emscripten Qt 6.8 was built against + +jobs: + build-wasm: + name: Build & deploy WASM demo + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install build tools + run: | + sudo apt-get update -q + sudo apt-get install -y ninja-build + + # aqtinstall gives a matched host + wasm Qt pair (same cmake glue), so no + # host/target version skew. + - name: Install Qt (host desktop) + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: linux + target: desktop + arch: linux_gcc_64 + dir: ${{ runner.temp }}/qt + + - name: Install Qt (wasm, single-threaded) + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: linux + target: desktop + arch: wasm_singlethread + dir: ${{ runner.temp }}/qt + + - name: Set up emsdk + uses: mymindstorm/setup-emsdk@v14 + with: + version: ${{ env.EMSDK_VERSION }} + actions-cache-folder: emsdk-cache + + - name: Configure + run: | + export EM_CACHE="$PWD/.emcache" + mkdir -p "$EM_CACHE" + HOST=${{ runner.temp }}/qt/Qt/${{ env.QT_VERSION }}/gcc_64 + WASM=${{ runner.temp }}/qt/Qt/${{ env.QT_VERSION }}/wasm_singlethread + "$WASM/bin/qt-cmake" -S . -B build-wasm -G Ninja \ + -DQT_HOST_PATH="$HOST" \ + -DMORPH_BUILD_EXAMPLES=ON -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_BANK_GUI=ON -DMORPH_BUILD_TESTS=OFF + + - name: Build + run: | + export EM_CACHE="$PWD/.emcache" + cmake --build build-wasm --target bank_gui_wasm + + - name: Stage bundle + run: | + mkdir -p site + BIN=build-wasm/examples/bank/gui_wasm + cp "$BIN"/bank_gui_wasm.js "$BIN"/bank_gui_wasm.wasm "$BIN"/qtloader.js site/ + # Serve the Qt loader page as the directory index. + cp "$BIN"/bank_gui_wasm.html site/index.html + + - name: Deploy to GitHub Pages (/demo) + if: github.ref == 'refs/heads/master' + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: site + target-folder: demo + clean: false # leave the Doxygen docs at the site root untouched diff --git a/.gitignore b/.gitignore index ffef247..697cd31 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /build/ /build-bank/ +/build-wasm/ /out/ *.db /.cache/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 5e749fa..11148dd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,7 +35,10 @@ if(NOT glaze_FOUND) FetchContent_MakeAvailable(glaze) endif() -if(NOT WIN32) +# Emscripten's single-threaded build has no pthreads; requiring Threads there +# would force -pthread (SharedArrayBuffer), which we deliberately avoid so the +# WASM bundle can be served from plain GitHub Pages with no COOP/COEP headers. +if(NOT WIN32 AND NOT EMSCRIPTEN) find_package(Threads REQUIRED) endif() @@ -48,7 +51,7 @@ target_include_directories(morph INTERFACE $ ) target_link_libraries(morph INTERFACE glaze::glaze) -if(NOT WIN32) +if(NOT WIN32 AND NOT EMSCRIPTEN) target_link_libraries(morph INTERFACE Threads::Threads) endif() @@ -79,15 +82,19 @@ target_sources(morph # ── Demo executable ────────────────────────────────────────────────────────── if(MORPH_BUILD_EXAMPLES) - add_executable(morph_example src/main.cpp) - target_link_libraries(morph_example PRIVATE morph) - apply_warnings(morph_example) - - if(DEFINED AF_SANITIZER) - apply_sanitizers(morph_example ${AF_SANITIZER}) - endif() - if(AF_COVERAGE) - apply_coverage(morph_example) + # The console demo uses the thread-pool executor; skip it for the + # single-threaded WASM build (which only wants the bank GUI target). + if(NOT EMSCRIPTEN) + add_executable(morph_example src/main.cpp) + target_link_libraries(morph_example PRIVATE morph) + apply_warnings(morph_example) + + if(DEFINED AF_SANITIZER) + apply_sanitizers(morph_example ${AF_SANITIZER}) + endif() + if(AF_COVERAGE) + apply_coverage(morph_example) + endif() endif() if(MORPH_BUILD_BANK_EXAMPLE) diff --git a/examples/bank/CMakeLists.txt b/examples/bank/CMakeLists.txt index 22a38d8..698629c 100644 --- a/examples/bank/CMakeLists.txt +++ b/examples/bank/CMakeLists.txt @@ -16,6 +16,18 @@ if(NOT TARGET morph::morph) "with -DMORPH_BUILD_BANK_EXAMPLE=ON instead of configuring examples/bank directly.") endif() +# ── WebAssembly build ──────────────────────────────────────────────────────── +# In an Emscripten configure, the native persistence layer (Lightweight ORM over +# ODBC) cannot exist in the browser, so the whole native stack — Lightweight, +# bank_lib, the CLI and the tests — is skipped. Only the self-contained WASM GUI +# (its own in-memory backend) is built. See gui_wasm/. +if(EMSCRIPTEN) + if(MORPH_BUILD_BANK_GUI) + add_subdirectory(gui_wasm) + endif() + return() +endif() + include(FetchContent) # ── Lightweight ORM (SQLite via ODBC) ──────────────────────────────────────── diff --git a/examples/bank/README.md b/examples/bank/README.md index 862cb9f..eeba370 100644 --- a/examples/bank/README.md +++ b/examples/bank/README.md @@ -155,6 +155,36 @@ A headless screenshot smoke test runs when `BANK_GUI_SMOKE=` is set (with `QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software`): it seeds data, signs in, and grabs a PNG of each page. +### WebAssembly demo (self-contained, GitHub Pages) + +`gui_wasm/` is a **single-threaded WebAssembly** build of the same GUI that runs +**entirely in the browser** — the morph model layer is the "server in the background," +with no external process. Since Lightweight (ODBC/SQLite) can't run in a browser, the +WASM build swaps persistence for an **in-memory store** (`gui_wasm/include/bank/wasm/`) +behind **shadow model headers** that shine ahead of the native ones on the include path, +so the QML, controllers and DTOs are reused unchanged. `BankClient` is dual-moded +(`#ifdef __EMSCRIPTEN__`): models run on the Qt event loop via `QtExecutor` (no thread +pool, no database). Single-threaded ⇒ no SharedArrayBuffer ⇒ **no COOP/COEP headers**, so +plain GitHub Pages hosts it. A demo user (`demo` / `demo1234`) and two accounts are +seeded and auto-signed-in. + +Build locally (needs Qt-for-WASM + a matching emsdk): + +``` +source /path/to/qt6-wasm/emsdk/emsdk_env.sh +export EM_CACHE="$PWD/.emcache" +/path/to/qt6-wasm/bin/qt-cmake -S . -B build-wasm -G Ninja \ + -DMORPH_BUILD_EXAMPLES=ON -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_BANK_GUI=ON -DMORPH_BUILD_TESTS=OFF +cmake --build build-wasm --target bank_gui_wasm +python3 -m http.server -d build-wasm/examples/bank/gui_wasm 8000 # open bank_gui_wasm.html +``` + +CI (`.github/workflows/wasm-demo.yml`) builds the bundle with a matched host+wasm Qt pair +and publishes it to `…github.io//demo/`, coexisting with the Doxygen docs at the +site root. The native (non-Emscripten) build is unaffected — the whole native stack is +gated `if(NOT EMSCRIPTEN)` in `CMakeLists.txt`. + ## Tests Each model has a `tests/test_*.cpp` (Catch2). `tests/bank_test_support.hpp` provides @@ -168,6 +198,9 @@ shared on-disk test database. Notable cross-cutting tests: ## Status -Models, tests, CLI, and the Qt 6 GUI are complete. Possible extensions: wiring -the GUI over the Qt WebSocket backend (`morph::qt::QtWebSocketBackend`) for a -true client/server split, and surfacing the offline queue in the UI. +Models, tests, CLI, the Qt 6 GUI, and a self-contained WebAssembly build (hosted +on GitHub Pages) are complete. Possible extensions: durable in-browser +persistence (IDBFS/OPFS) for the WASM build, switching its in-browser backend to +`RemoteServer` + `SimulatedRemoteBackend` to surface the JSON wire protocol, and +wiring the desktop GUI over `morph::qt::QtWebSocketBackend` for a true networked +client/server split. diff --git a/examples/bank/gui/BankClient.cpp b/examples/bank/gui/BankClient.cpp index 34db6cd..3ae2b06 100644 --- a/examples/bank/gui/BankClient.cpp +++ b/examples/bank/gui/BankClient.cpp @@ -8,14 +8,24 @@ #include #include +#ifndef __EMSCRIPTEN__ #include "bank/db/database.hpp" +#endif namespace bankgui { +#ifdef __EMSCRIPTEN__ +// WebAssembly: no ODBC/SQLite. Models run on the single-threaded Qt event loop +// (`_gui`) and persist to the in-memory store (see gui_wasm/). The connection +// string and worker count are ignored. +BankClient::BankClient(const std::string& /*connectionString*/, std::size_t /*workers*/) + : _bridge{std::make_unique(_gui)} {} +#else BankClient::BankClient(const std::string& connectionString, std::size_t workers) : _pool{workers}, _bridge{std::make_unique(_pool)} { bank::db::setup(connectionString); } +#endif void BankClient::login(const QString& principal, const QString& displayName) { _principal = principal; diff --git a/examples/bank/gui/BankClient.hpp b/examples/bank/gui/BankClient.hpp index 3465e9b..5e6232d 100644 --- a/examples/bank/gui/BankClient.hpp +++ b/examples/bank/gui/BankClient.hpp @@ -37,7 +37,12 @@ class BankClient { [[nodiscard]] const QString& displayName() const noexcept { return _displayName; } private: +#ifndef __EMSCRIPTEN__ + // Native: models run on a worker thread pool. WebAssembly is single-threaded, + // so the WASM build (see gui_wasm/) runs models on the Qt event loop via + // `_gui` instead — no thread pool, no ODBC database. morph::exec::ThreadPoolExecutor _pool; +#endif morph::qt::QtExecutor _gui; morph::bridge::Bridge _bridge; QString _principal; diff --git a/examples/bank/gui_wasm/CMakeLists.txt b/examples/bank/gui_wasm/CMakeLists.txt new file mode 100644 index 0000000..cd9e5e5 --- /dev/null +++ b/examples/bank/gui_wasm/CMakeLists.txt @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Self-contained WebAssembly build of the bank QML GUI. Reuses the native QML, +# controllers and BankClient, but swaps the Lightweight/ODBC persistence for an +# in-memory store (include/bank/wasm/) via shadow model headers that shine ahead +# of the native ones on the include path. Only built in an Emscripten configure +# (see ../CMakeLists.txt) with -DMORPH_BUILD_BANK_GUI=ON. + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2) + +qt_standard_project_setup(REQUIRES 6.5) + +set(GUI ${CMAKE_CURRENT_SOURCE_DIR}/../gui) + +qt_add_executable(bank_gui_wasm + main_wasm.cpp + # Reused verbatim from the native GUI: + ${GUI}/BankClient.cpp # dual-moded via __EMSCRIPTEN__ + ${GUI}/controllers/BankController.cpp + ${GUI}/controllers/AppController.cpp + ${GUI}/controllers/AccountController.cpp + ${GUI}/controllers/TransactionController.cpp + ${GUI}/controllers/CardController.cpp + ${GUI}/controllers/PayeeController.cpp + ${GUI}/controllers/LoanController.cpp + # In-memory model implementations (persistence-free): + src/models/auth_model_wasm.cpp + src/models/account_model_wasm.cpp + src/models/transaction_model_wasm.cpp + src/models/card_model_wasm.cpp + src/models/payee_model_wasm.cpp + src/models/payment_model_wasm.cpp + src/models/loan_model_wasm.cpp + # Pure core helpers (no Lightweight): + ${CMAKE_CURRENT_SOURCE_DIR}/../src/core/money.cpp +) + +# The QML lives in the sibling native gui/ dir, so it is referenced by absolute +# path; give each file a relative resource alias so the QML module lays them out +# under qrc:/qt/qml/BankGui/ exactly as an in-tree module would. +set(BANK_QML_FILES + Main.qml Login.qml AppShell.qml + AccountsPage.qml MoveMoneyPage.qml CardsPage.qml PayeesPage.qml LoansPage.qml + Panel.qml AppButton.qml Field.qml Pill.qml Picker.qml +) +set(BANK_QML_PATHS) +foreach(qml ${BANK_QML_FILES}) + set_source_files_properties(${GUI}/qml/${qml} PROPERTIES QT_RESOURCE_ALIAS ${qml}) + list(APPEND BANK_QML_PATHS ${GUI}/qml/${qml}) +endforeach() + +qt_add_qml_module(bank_gui_wasm + URI BankGui + VERSION 1.0 + QML_FILES ${BANK_QML_PATHS} +) + +# Order matters: gui_wasm/include is searched BEFORE examples/bank/include so the +# shadow "bank/models/*.hpp" (in-memory, no Lightweight) win over the native +# headers, while "bank/dto/*" and "bank/core/*" still resolve to the shared set. +target_include_directories(bank_gui_wasm BEFORE PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include # shadow models + bank/wasm store + ${GUI} # BankClient.hpp, controllers/* + ${CMAKE_CURRENT_SOURCE_DIR}/../include # shared bank/dto, bank/core +) + +# morph is header-only (INTERFACE); it brings glaze and the include dir. No +# Lightweight anywhere in this target. +target_link_libraries(bank_gui_wasm PRIVATE + morph::morph + Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 +) +target_compile_features(bank_gui_wasm PRIVATE cxx_std_23) diff --git a/examples/bank/gui_wasm/include/bank/models/account_model.hpp b/examples/bank/gui_wasm/include/bank/models/account_model.hpp new file mode 100644 index 0000000..594aad0 --- /dev/null +++ b/examples/bank/gui_wasm/include/bank/models/account_model.hpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +// WASM shadow of include/bank/models/account_model.hpp (in-memory backend). + +#include + +#include "bank/dto/account_dto.hpp" + +namespace bank { + +/// @brief Opens, lists, inspects, and closes customer accounts (in-memory). +class AccountModel { +public: + dto::AccountInfo execute(const dto::OpenAccount& action); + dto::AccountList execute(const dto::ListAccounts& action); + dto::AccountInfo execute(const dto::GetAccount& action); + dto::CommandResult execute(const dto::CloseAccount& action); +}; + +} // namespace bank + +using bank::AccountModel; +using bank::dto::CloseAccount; +using bank::dto::GetAccount; +using bank::dto::ListAccounts; +using bank::dto::OpenAccount; + +BRIDGE_REGISTER_MODEL(AccountModel, "AccountModel") +BRIDGE_REGISTER_ACTION(AccountModel, OpenAccount, "OpenAccount") +BRIDGE_REGISTER_ACTION(AccountModel, ListAccounts, "ListAccounts") +BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount") +BRIDGE_REGISTER_ACTION(AccountModel, CloseAccount, "CloseAccount") diff --git a/examples/bank/gui_wasm/include/bank/models/auth_model.hpp b/examples/bank/gui_wasm/include/bank/models/auth_model.hpp new file mode 100644 index 0000000..3f444e7 --- /dev/null +++ b/examples/bank/gui_wasm/include/bank/models/auth_model.hpp @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +// WASM shadow of include/bank/models/auth_model.hpp: the SAME class + action +// registrations the controllers/QML expect, but with no Lightweight/ODBC +// dependency (no db_model.hpp). Persistence is the in-memory store. This header +// is placed first on the WASM include path so it wins over the native one. + +#include + +#include "bank/dto/auth_dto.hpp" +#include "bank/dto/common.hpp" + +namespace bank { + +/// @brief Manages user identities and authentication (in-memory). +class AuthModel { +public: + dto::AuthResult execute(const dto::RegisterUser& action); + dto::AuthResult execute(const dto::LoginRequest& action); + dto::CommandResult execute(const dto::ChangePassword& action); + dto::SessionInfo execute(const dto::WhoAmI& action); +}; + +} // namespace bank + +using bank::AuthModel; +using bank::dto::ChangePassword; +using bank::dto::LoginRequest; +using bank::dto::RegisterUser; +using bank::dto::WhoAmI; + +BRIDGE_REGISTER_MODEL(AuthModel, "AuthModel") +BRIDGE_REGISTER_ACTION(AuthModel, RegisterUser, "RegisterUser") +BRIDGE_REGISTER_ACTION(AuthModel, LoginRequest, "LoginRequest") +BRIDGE_REGISTER_ACTION(AuthModel, ChangePassword, "ChangePassword") +BRIDGE_REGISTER_ACTION(AuthModel, WhoAmI, "WhoAmI") diff --git a/examples/bank/gui_wasm/include/bank/models/card_model.hpp b/examples/bank/gui_wasm/include/bank/models/card_model.hpp new file mode 100644 index 0000000..e3b9cf4 --- /dev/null +++ b/examples/bank/gui_wasm/include/bank/models/card_model.hpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +// WASM shadow of include/bank/models/card_model.hpp (in-memory backend). + +#include + +#include "bank/dto/card_dto.hpp" +#include "bank/dto/common.hpp" + +namespace bank { + +/// @brief Issues and manages payment cards (in-memory). +class CardModel { +public: + dto::CardInfo execute(const dto::IssueCard& action); + dto::CommandResult execute(const dto::FreezeCard& action); + dto::CommandResult execute(const dto::UnfreezeCard& action); + dto::CommandResult execute(const dto::CancelCard& action); + dto::CommandResult execute(const dto::SetCardLimit& action); + dto::CommandResult execute(const dto::ChangePin& action); + dto::CardList execute(const dto::ListCards& action); +}; + +} // namespace bank + +using bank::CardModel; +using bank::dto::CancelCard; +using bank::dto::ChangePin; +using bank::dto::FreezeCard; +using bank::dto::IssueCard; +using bank::dto::ListCards; +using bank::dto::SetCardLimit; +using bank::dto::UnfreezeCard; + +BRIDGE_REGISTER_MODEL(CardModel, "CardModel") +BRIDGE_REGISTER_ACTION(CardModel, IssueCard, "IssueCard") +BRIDGE_REGISTER_ACTION(CardModel, FreezeCard, "FreezeCard") +BRIDGE_REGISTER_ACTION(CardModel, UnfreezeCard, "UnfreezeCard") +BRIDGE_REGISTER_ACTION(CardModel, CancelCard, "CancelCard") +BRIDGE_REGISTER_ACTION(CardModel, SetCardLimit, "SetCardLimit") +BRIDGE_REGISTER_ACTION(CardModel, ChangePin, "ChangePin") +BRIDGE_REGISTER_ACTION(CardModel, ListCards, "ListCards") diff --git a/examples/bank/gui_wasm/include/bank/models/loan_model.hpp b/examples/bank/gui_wasm/include/bank/models/loan_model.hpp new file mode 100644 index 0000000..c51e531 --- /dev/null +++ b/examples/bank/gui_wasm/include/bank/models/loan_model.hpp @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +// WASM shadow of include/bank/models/loan_model.hpp (in-memory backend). + +#include + +#include "bank/dto/loan_dto.hpp" + +namespace bank { + +/// @brief Originates and services loans (in-memory). +class LoanModel { +public: + dto::LoanInfo execute(const dto::ApplyLoan& action); + dto::LoanInfo execute(const dto::RepayLoan& action); + dto::LoanInfo execute(const dto::GetLoan& action); + dto::LoanList execute(const dto::ListLoans& action); + dto::LoanScheduleResult execute(const dto::LoanScheduleRequest& action); +}; + +} // namespace bank + +using bank::LoanModel; +using bank::dto::ApplyLoan; +using bank::dto::GetLoan; +using bank::dto::ListLoans; +using bank::dto::LoanScheduleRequest; +using bank::dto::RepayLoan; + +BRIDGE_REGISTER_MODEL(LoanModel, "LoanModel") +BRIDGE_REGISTER_ACTION(LoanModel, ApplyLoan, "ApplyLoan") +BRIDGE_REGISTER_ACTION(LoanModel, RepayLoan, "RepayLoan") +BRIDGE_REGISTER_ACTION(LoanModel, GetLoan, "GetLoan") +BRIDGE_REGISTER_ACTION(LoanModel, ListLoans, "ListLoans") +BRIDGE_REGISTER_ACTION(LoanModel, LoanScheduleRequest, "LoanScheduleRequest") diff --git a/examples/bank/gui_wasm/include/bank/models/payee_model.hpp b/examples/bank/gui_wasm/include/bank/models/payee_model.hpp new file mode 100644 index 0000000..08337f0 --- /dev/null +++ b/examples/bank/gui_wasm/include/bank/models/payee_model.hpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +// WASM shadow of include/bank/models/payee_model.hpp (in-memory backend). + +#include + +#include "bank/dto/common.hpp" +#include "bank/dto/payee_dto.hpp" + +namespace bank { + +/// @brief Stores and lists beneficiaries scoped to the session owner (in-memory). +class PayeeModel { +public: + dto::PayeeInfo execute(const dto::AddPayee& action); + dto::CommandResult execute(const dto::RemovePayee& action); + dto::PayeeList execute(const dto::ListPayees& action); +}; + +} // namespace bank + +using bank::PayeeModel; +using bank::dto::AddPayee; +using bank::dto::ListPayees; +using bank::dto::RemovePayee; + +BRIDGE_REGISTER_MODEL(PayeeModel, "PayeeModel") +BRIDGE_REGISTER_ACTION(PayeeModel, AddPayee, "AddPayee") +BRIDGE_REGISTER_ACTION(PayeeModel, RemovePayee, "RemovePayee") +BRIDGE_REGISTER_ACTION(PayeeModel, ListPayees, "ListPayees") diff --git a/examples/bank/gui_wasm/include/bank/models/payment_model.hpp b/examples/bank/gui_wasm/include/bank/models/payment_model.hpp new file mode 100644 index 0000000..b574680 --- /dev/null +++ b/examples/bank/gui_wasm/include/bank/models/payment_model.hpp @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +// WASM shadow of include/bank/models/payment_model.hpp (in-memory backend). + +#include + +#include "bank/dto/common.hpp" +#include "bank/dto/payment_dto.hpp" + +namespace bank { + +/// @brief Pays beneficiaries and manages scheduled / standing instructions (in-memory). +class PaymentModel { +public: + dto::PaymentInfo execute(const dto::PayBill& action); + dto::PaymentInfo execute(const dto::SchedulePayment& action); + dto::PaymentInfo execute(const dto::CreateStandingOrder& action); + dto::CommandResult execute(const dto::CancelPayment& action); + dto::PaymentList execute(const dto::ListPayments& action); +}; + +} // namespace bank + +using bank::PaymentModel; +using bank::dto::CancelPayment; +using bank::dto::CreateStandingOrder; +using bank::dto::ListPayments; +using bank::dto::PayBill; +using bank::dto::SchedulePayment; + +BRIDGE_REGISTER_MODEL(PaymentModel, "PaymentModel") +BRIDGE_REGISTER_ACTION(PaymentModel, PayBill, "PayBill") +BRIDGE_REGISTER_ACTION(PaymentModel, SchedulePayment, "SchedulePayment") +BRIDGE_REGISTER_ACTION(PaymentModel, CreateStandingOrder, "CreateStandingOrder") +BRIDGE_REGISTER_ACTION(PaymentModel, CancelPayment, "CancelPayment") +BRIDGE_REGISTER_ACTION(PaymentModel, ListPayments, "ListPayments") diff --git a/examples/bank/gui_wasm/include/bank/models/transaction_model.hpp b/examples/bank/gui_wasm/include/bank/models/transaction_model.hpp new file mode 100644 index 0000000..e6fd815 --- /dev/null +++ b/examples/bank/gui_wasm/include/bank/models/transaction_model.hpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +// WASM shadow of include/bank/models/transaction_model.hpp (in-memory backend). + +#include + +#include "bank/dto/transaction_dto.hpp" + +namespace bank { + +/// @brief Moves money and records the ledger (in-memory). +class TransactionModel { +public: + dto::TxnInfo execute(const dto::Deposit& action); + dto::TxnInfo execute(const dto::Withdraw& action); + dto::TransferResult execute(const dto::Transfer& action); + dto::HistoryPage execute(const dto::History& action); +}; + +} // namespace bank + +using bank::TransactionModel; +using bank::dto::Deposit; +using bank::dto::History; +using bank::dto::Transfer; +using bank::dto::Withdraw; + +BRIDGE_REGISTER_MODEL(TransactionModel, "TransactionModel") +BRIDGE_REGISTER_ACTION(TransactionModel, Deposit, "Deposit") +BRIDGE_REGISTER_ACTION(TransactionModel, Withdraw, "Withdraw") +BRIDGE_REGISTER_ACTION(TransactionModel, Transfer, "Transfer") +BRIDGE_REGISTER_ACTION(TransactionModel, History, "History") diff --git a/examples/bank/gui_wasm/include/bank/wasm/store.hpp b/examples/bank/gui_wasm/include/bank/wasm/store.hpp new file mode 100644 index 0000000..0342132 --- /dev/null +++ b/examples/bank/gui_wasm/include/bank/wasm/store.hpp @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +/// @file +/// A tiny in-memory data store for the WebAssembly build of the bank. +/// +/// Lightweight (ODBC/SQLite) cannot run in a browser, so the WASM models persist +/// here instead. morph runs each model on its own strand and WebAssembly is +/// single-threaded, so no locking is needed. Rows mirror the native entity +/// records (`include/bank/db/*_entity.hpp`) but with plain types and plain +/// integer foreign keys — the relations the native schema expresses with +/// `BelongsTo`/`HasMany` are just `*_id` fields filtered here. + +namespace bank::wasm { + +/// @brief A generic in-memory table keyed by an auto-incrementing id. +template +class Table { +public: + /// Inserts @p row, assigning it a fresh id, and returns that id. + std::uint64_t insert(Row row) { + const std::uint64_t id = _nextId++; + row.id = id; + _rows.emplace(id, std::move(row)); + return id; + } + + /// Returns a pointer to the row with @p id, or nullptr if absent. + [[nodiscard]] Row* find(std::uint64_t id) { + auto it = _rows.find(id); + return it == _rows.end() ? nullptr : &it->second; + } + [[nodiscard]] const Row* find(std::uint64_t id) const { + auto it = _rows.find(id); + return it == _rows.end() ? nullptr : &it->second; + } + + /// Overwrites the stored row that shares @p row's id. + void update(const Row& row) { _rows[row.id] = row; } + + /// Removes the row with @p id (no-op if absent). + void erase(std::uint64_t id) { _rows.erase(id); } + + /// Returns copies of all rows satisfying @p pred. + template + [[nodiscard]] std::vector where(Pred pred) const { + std::vector out; + for (const auto& [id, row] : _rows) { + if (pred(row)) { + out.push_back(row); + } + } + return out; + } + + /// Returns copies of every row. + [[nodiscard]] std::vector all() const { + return where([](const Row&) { return true; }); + } + +private: + std::unordered_map _rows; + std::uint64_t _nextId = 1; +}; + +// ── Row types (one per table) ──────────────────────────────────────────────── + +struct UserRow { + std::uint64_t id = 0; + std::string username; + std::string passwordHash; + std::string displayName; + int status = 0; +}; + +struct AccountRow { + std::uint64_t id = 0; + std::uint64_t userId = 0; + std::string number; + int kind = 0; + int currency = 0; + std::int64_t balanceMinor = 0; + std::int64_t overdraftMinor = 0; + int status = 0; + int interestBps = 0; +}; + +struct TxnRow { + std::uint64_t id = 0; + std::uint64_t accountId = 0; + std::int64_t counterpartyId = 0; ///< 0 = none (deposits/withdrawals) + int direction = 0; + int kind = 0; + std::int64_t amountMinor = 0; + int currency = 0; + std::int64_t balanceAfterMinor = 0; + std::string description; + std::int64_t createdAtMs = 0; +}; + +struct CardRow { + std::uint64_t id = 0; + std::uint64_t accountId = 0; + std::uint64_t userId = 0; + int kind = 0; + std::string panLast4; + int status = 0; + std::int64_t dailyLimitMinor = 0; + std::string pinHash; +}; + +struct PayeeRow { + std::uint64_t id = 0; + std::uint64_t userId = 0; + std::string name; + std::string iban; + std::string bankName; +}; + +struct PaymentRow { + std::uint64_t id = 0; + std::uint64_t userId = 0; + std::uint64_t fromAccountId = 0; + std::uint64_t payeeId = 0; + std::int64_t amountMinor = 0; + int currency = 0; + int schedule = 0; + int status = 0; + std::int64_t dueAtMs = 0; + int intervalDays = 0; + std::string description; +}; + +struct LoanRow { + std::uint64_t id = 0; + std::uint64_t userId = 0; + std::uint64_t accountId = 0; + std::int64_t principalMinor = 0; + std::int64_t outstandingMinor = 0; + int currency = 0; + int rateBps = 0; + int termMonths = 0; + int status = 0; + std::int64_t createdAtMs = 0; +}; + +/// @brief The whole database — one shared instance for all models. +struct Db { + Table users; + Table accounts; + Table txns; + Table cards; + Table payees; + Table payments; + Table loans; +}; + +/// @brief The process-wide store shared by every model (mirrors the single +/// on-disk SQLite file the native build shares across its models). +[[nodiscard]] inline Db& sharedDb() { + static Db db; + return db; +} + +} // namespace bank::wasm diff --git a/examples/bank/gui_wasm/include/bank/wasm/store_ops.hpp b/examples/bank/gui_wasm/include/bank/wasm/store_ops.hpp new file mode 100644 index 0000000..2c87dff --- /dev/null +++ b/examples/bank/gui_wasm/include/bank/wasm/store_ops.hpp @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/types.hpp" +#include "bank/wasm/store.hpp" + +/// @file +/// Store-level helpers mirroring the native `bank/db/user_ops.hpp` + +/// `bank/db/ledger_ops.hpp`: principal→user resolution, the ownership guard, and +/// the credit/debit/post-entry ledger primitives — reimplemented against the +/// in-memory `Db`. They throw the same `bank::` domain errors so the GUI +/// controllers classify failures identically. + +namespace bank::wasm { + +/// @brief Unix epoch milliseconds (ledger timestamp / ordering key). +[[nodiscard]] inline std::int64_t nowMillis() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +// ── Users ──────────────────────────────────────────────────────────────────── + +/// @brief Id of the user named @p username, if any. +[[nodiscard]] inline std::optional findUserId(Db& db, std::string_view username) { + auto rows = db.users.where([&](const UserRow& u) { return u.username == username; }); + if (rows.empty()) { + return std::nullopt; + } + return rows.front().id; +} + +/// @brief Resolves @p username to its id, or throws Unauthorized. +[[nodiscard]] inline std::uint64_t requireUserId(Db& db, std::string_view username) { + if (auto id = findUserId(db, username)) { + return *id; + } + throw Unauthorized{"unknown user: " + std::string{username}}; +} + +/// @brief Gets or creates the user named @p username; returns its id. +inline std::uint64_t ensureUser(Db& db, std::string_view username, std::string_view displayName = {}) { + if (auto id = findUserId(db, username)) { + return *id; + } + UserRow row; + row.username = std::string{username}; + row.displayName = std::string{displayName.empty() ? username : displayName}; + row.status = 0; + return db.users.insert(std::move(row)); +} + +// ── Ownership guards ───────────────────────────────────────────────────────── + +/// @brief Loads an account, requiring it to exist and be owned by @p ownerId. +/// @throws NotFound / Unauthorized. +[[nodiscard]] inline AccountRow loadOwnedAccount(Db& db, std::int64_t accountId, + std::uint64_t ownerId) { + auto* acct = db.accounts.find(static_cast(accountId)); + if (acct == nullptr) { + throw NotFound{"account not found"}; + } + if (acct->userId != ownerId) { + throw Unauthorized{"account belongs to a different owner"}; + } + return *acct; +} + +/// @brief Like loadOwnedAccount but also requires the account to be Open. +/// @throws NotFound / Unauthorized / ConflictError. +[[nodiscard]] inline AccountRow loadOwnedOpenAccount(Db& db, std::int64_t accountId, + std::uint64_t ownerId) { + auto acct = loadOwnedAccount(db, accountId, ownerId); + if (acct.status != static_cast(AccountStatus::Open)) { + throw ConflictError{"account is not open"}; + } + return acct; +} + +// ── Ledger primitives ──────────────────────────────────────────────────────── + +/// @brief Appends a ledger row reflecting @p account's *current* balance. +inline TxnRow postEntry(Db& db, const AccountRow& account, TxnDirection direction, TxnKind kind, + std::int64_t amountMinor, std::int64_t counterpartyId, + const std::string& description) { + TxnRow txn; + txn.accountId = account.id; + txn.counterpartyId = counterpartyId; + txn.direction = static_cast(direction); + txn.kind = static_cast(kind); + txn.amountMinor = amountMinor; + txn.currency = account.currency; + txn.balanceAfterMinor = account.balanceMinor; + txn.description = description; + txn.createdAtMs = nowMillis(); + txn.id = db.txns.insert(txn); + return txn; +} + +/// @brief Credits @p account, persists it, and posts a ledger entry. +inline TxnRow applyCredit(Db& db, AccountRow& account, std::int64_t amountMinor, TxnKind kind, + std::int64_t counterpartyId, const std::string& description) { + account.balanceMinor += amountMinor; + db.accounts.update(account); + return postEntry(db, account, TxnDirection::Credit, kind, amountMinor, counterpartyId, description); +} + +/// @brief Debits @p account (respecting overdraft), persists it, and posts an entry. +/// @throws InsufficientFunds if the debit would breach the overdraft limit. +inline TxnRow applyDebit(Db& db, AccountRow& account, std::int64_t amountMinor, TxnKind kind, + std::int64_t counterpartyId, const std::string& description) { + const std::int64_t projected = account.balanceMinor - amountMinor; + if (projected < -account.overdraftMinor) { + throw InsufficientFunds{"amount exceeds available balance plus overdraft"}; + } + account.balanceMinor = projected; + db.accounts.update(account); + return postEntry(db, account, TxnDirection::Debit, kind, amountMinor, counterpartyId, description); +} + +} // namespace bank::wasm diff --git a/examples/bank/gui_wasm/main_wasm.cpp b/examples/bank/gui_wasm/main_wasm.cpp new file mode 100644 index 0000000..d51428a --- /dev/null +++ b/examples/bank/gui_wasm/main_wasm.cpp @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Entry point for the WebAssembly bank GUI. Same controller/QML wiring as the +// native gui/main.cpp, but there is no database: the models persist to an +// in-memory store (see include/bank/wasm/). A demo user + a couple of accounts +// are seeded at startup and auto-signed-in so the hosted page is immediately +// usable. + +#include +#include +#include +#include +#include + +#include + +#include "BankClient.hpp" +#include "bank/core/demo_hash.hpp" +#include "bank/core/types.hpp" +#include "bank/wasm/store.hpp" +#include "bank/wasm/store_ops.hpp" +#include "controllers/AccountController.hpp" +#include "controllers/AppController.hpp" +#include "controllers/CardController.hpp" +#include "controllers/LoanController.hpp" +#include "controllers/PayeeController.hpp" +#include "controllers/TransactionController.hpp" + +namespace { + +/// The warm, "Claude-inspired" palette, handed to QML as the `theme` object. +QVariantMap makeTheme() { + return QVariantMap{ + {QStringLiteral("paper"), QStringLiteral("#FAF9F5")}, + {QStringLiteral("surface"), QStringLiteral("#FFFFFF")}, + {QStringLiteral("surfaceAlt"), QStringLiteral("#F2F0E9")}, + {QStringLiteral("ink"), QStringLiteral("#1F1E1D")}, + {QStringLiteral("inkSoft"), QStringLiteral("#6B6862")}, + {QStringLiteral("border"), QStringLiteral("#E7E4DB")}, + {QStringLiteral("accent"), QStringLiteral("#C96442")}, + {QStringLiteral("accentHover"), QStringLiteral("#B5572F")}, + {QStringLiteral("sidebar"), QStringLiteral("#262624")}, + {QStringLiteral("sidebarText"), QStringLiteral("#C9C6BE")}, + {QStringLiteral("sidebarHover"), QStringLiteral("#34322F")}, + {QStringLiteral("good"), QStringLiteral("#2F9E66")}, + {QStringLiteral("warn"), QStringLiteral("#C96442")}, + {QStringLiteral("bad"), QStringLiteral("#C0392B")}, + {QStringLiteral("goodBg"), QStringLiteral("#E5F4EC")}, + {QStringLiteral("warnBg"), QStringLiteral("#FBEDE8")}, + {QStringLiteral("badBg"), QStringLiteral("#FBEDEB")}, + {QStringLiteral("neutralBg"), QStringLiteral("#F2F0E9")}, + {QStringLiteral("dangerBorder"), QStringLiteral("#E7C9C5")}, + {QStringLiteral("radius"), 12}, + }; +} + +/// Seeds a demo user (password "demo1234"), two accounts, and an opening +/// balance directly in the in-memory store so the hosted demo has data. +void seedDemo() { + using namespace bank; + auto& db = wasm::sharedDb(); + if (!db.users.all().empty()) { + return; // already seeded + } + wasm::UserRow user; + user.username = "demo"; + user.displayName = "Demo User"; + user.passwordHash = demoHash(std::string{"demo"} + ":" + "demo1234" + ":morph-bank"); + const auto uid = db.users.insert(user); + + wasm::AccountRow checking; + checking.userId = uid; + checking.number = "DE00500700100200300400"; + checking.kind = static_cast(AccountKind::Checking); + checking.currency = static_cast(Currency::EUR); + checking.status = static_cast(AccountStatus::Open); + checking.overdraftMinor = 50000; + const auto chkId = db.accounts.insert(checking); + auto chkRow = *db.accounts.find(chkId); + wasm::applyCredit(db, chkRow, 480000, TxnKind::Deposit, 0, "opening deposit"); + + wasm::AccountRow savings; + savings.userId = uid; + savings.number = "DE00500700100900800700"; + savings.kind = static_cast(AccountKind::Savings); + savings.currency = static_cast(Currency::EUR); + savings.status = static_cast(AccountStatus::Open); + savings.interestBps = 150; + db.accounts.insert(savings); +} + +} // namespace + +int main(int argc, char* argv[]) { + QGuiApplication app{argc, argv}; + app.setApplicationName(QStringLiteral("Morph Bank")); + QQuickStyle::setStyle(QStringLiteral("Basic")); + + seedDemo(); + + // Connection string is ignored by the WASM BankClient (no ODBC). + bankgui::BankClient client{std::string{}}; + + bankgui::AppController appController{client}; + bankgui::AccountController accountController{client}; + bankgui::TransactionController transactionController{client}; + bankgui::CardController cardController{client}; + bankgui::PayeeController payeeController{client}; + bankgui::LoanController loanController{client}; + + QQmlApplicationEngine engine; + auto* ctx = engine.rootContext(); + ctx->setContextProperty(QStringLiteral("theme"), makeTheme()); + ctx->setContextProperty(QStringLiteral("app"), &appController); + ctx->setContextProperty(QStringLiteral("accounts"), &accountController); + ctx->setContextProperty(QStringLiteral("txns"), &transactionController); + ctx->setContextProperty(QStringLiteral("cards"), &cardController); + ctx->setContextProperty(QStringLiteral("payees"), &payeeController); + ctx->setContextProperty(QStringLiteral("loans"), &loanController); + + engine.loadFromModule("BankGui", "Main"); + if (engine.rootObjects().isEmpty()) { + return -1; + } + + // Sign the demo user in automatically (resolves on the event loop). + appController.login(QStringLiteral("demo"), QStringLiteral("demo1234")); + + return app.exec(); +} diff --git a/examples/bank/gui_wasm/src/models/account_model_wasm.cpp b/examples/bank/gui_wasm/src/models/account_model_wasm.cpp new file mode 100644 index 0000000..eb74080 --- /dev/null +++ b/examples/bank/gui_wasm/src/models/account_model_wasm.cpp @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// In-memory implementation of AccountModel for the WASM build. Mirrors the +// behaviour of src/models/account_model.cpp but persists to bank::wasm::Db. + +#include "bank/models/account_model.hpp" + +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/wasm/store.hpp" +#include "bank/wasm/store_ops.hpp" + +namespace bank { + +namespace { + +std::string generateAccountNumber() { + static thread_local std::mt19937_64 rng{std::random_device{}()}; + std::uniform_int_distribution digit{0, 9}; + std::string number = "DE"; + for (int idx = 0; idx < 20; ++idx) { + number.push_back(static_cast('0' + digit(rng))); + } + return number; +} + +dto::AccountInfo toInfo(const wasm::AccountRow& rec, const std::string& owner) { + return dto::AccountInfo{ + .id = static_cast(rec.id), + .owner = owner, + .number = rec.number, + .kind = rec.kind, + .currency = rec.currency, + .balanceMinor = rec.balanceMinor, + .overdraftMinor = rec.overdraftMinor, + .status = rec.status, + .interestBps = rec.interestBps, + }; +} + +int defaultInterestBps(int kind) { + return kind == static_cast(AccountKind::Savings) ? 150 : 0; +} + +} // namespace + +dto::AccountInfo AccountModel::execute(const dto::OpenAccount& action) { + if (!action.validate()) { + throw ValidationError{"invalid account kind/currency/overdraft"}; + } + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal to own the account"}; + } + auto& db = wasm::sharedDb(); + + wasm::AccountRow rec; + rec.userId = wasm::requireUserId(db, owner); + rec.number = generateAccountNumber(); + rec.kind = action.kind; + rec.currency = action.currency; + rec.balanceMinor = 0; + rec.overdraftMinor = action.overdraftMinor; + rec.status = static_cast(AccountStatus::Open); + rec.interestBps = defaultInterestBps(action.kind); + rec.id = db.accounts.insert(rec); + return toInfo(rec, owner); +} + +dto::AccountList AccountModel::execute(const dto::ListAccounts& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal to list accounts for"}; + } + auto& db = wasm::sharedDb(); + const auto userId = wasm::requireUserId(db, owner); + + dto::AccountList out; + for (const auto& rec : db.accounts.where([&](const wasm::AccountRow& a) { return a.userId == userId; })) { + out.accounts.push_back(toInfo(rec, owner)); + } + return out; +} + +dto::AccountInfo AccountModel::execute(const dto::GetAccount& action) { + const std::string owner = sessionPrincipal(); + auto& db = wasm::sharedDb(); + auto rec = wasm::loadOwnedAccount(db, action.id, wasm::requireUserId(db, owner)); + return toInfo(rec, owner); +} + +dto::CommandResult AccountModel::execute(const dto::CloseAccount& action) { + const std::string owner = sessionPrincipal(); + auto& db = wasm::sharedDb(); + auto rec = wasm::loadOwnedAccount(db, action.id, wasm::requireUserId(db, owner)); + if (rec.balanceMinor != 0) { + return dto::CommandResult{.ok = false, .message = "account balance must be zero before closing"}; + } + rec.status = static_cast(AccountStatus::Closed); + db.accounts.update(rec); + return dto::CommandResult{.ok = true, .message = "account closed"}; +} + +} // namespace bank diff --git a/examples/bank/gui_wasm/src/models/auth_model_wasm.cpp b/examples/bank/gui_wasm/src/models/auth_model_wasm.cpp new file mode 100644 index 0000000..a53ef50 --- /dev/null +++ b/examples/bank/gui_wasm/src/models/auth_model_wasm.cpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// In-memory implementation of AuthModel for the WASM build. Mirrors +// src/models/auth_model.cpp but persists to bank::wasm::Db. + +#include "bank/models/auth_model.hpp" + +#include +#include +#include + +#include "bank/core/demo_hash.hpp" +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/wasm/store.hpp" +#include "bank/wasm/store_ops.hpp" + +namespace bank { + +namespace { + +std::string hashPassword(std::string_view username, std::string_view password) { + return demoHash(std::string{username} + ":" + std::string{password} + ":morph-bank"); +} + +std::optional findUser(wasm::Db& db, const std::string& username) { + auto rows = db.users.where([&](const wasm::UserRow& u) { return u.username == username; }); + if (rows.empty()) { + return std::nullopt; + } + return rows.front(); +} + +} // namespace + +dto::AuthResult AuthModel::execute(const dto::RegisterUser& action) { + if (!action.validate()) { + throw ValidationError{"username required and password must be at least 4 characters"}; + } + auto& db = wasm::sharedDb(); + if (findUser(db, action.username).has_value()) { + return dto::AuthResult{.ok = false, .message = "username already taken"}; + } + wasm::UserRow rec; + rec.username = action.username; + rec.passwordHash = hashPassword(action.username, action.password); + rec.displayName = action.displayName.empty() ? action.username : action.displayName; + rec.status = 0; + db.users.insert(rec); + return dto::AuthResult{.ok = true, + .principal = action.username, + .displayName = rec.displayName, + .message = "registered"}; +} + +dto::AuthResult AuthModel::execute(const dto::LoginRequest& action) { + auto& db = wasm::sharedDb(); + auto user = findUser(db, action.username); + if (!user.has_value()) { + return dto::AuthResult{.ok = false, .message = "no such user"}; + } + if (user->status != 0) { + return dto::AuthResult{.ok = false, .message = "account disabled"}; + } + if (user->passwordHash != hashPassword(action.username, action.password)) { + return dto::AuthResult{.ok = false, .message = "invalid credentials"}; + } + return dto::AuthResult{.ok = true, + .principal = action.username, + .displayName = user->displayName, + .message = "welcome"}; +} + +dto::CommandResult AuthModel::execute(const dto::ChangePassword& action) { + auto& db = wasm::sharedDb(); + auto user = findUser(db, action.username); + if (!user.has_value()) { + throw NotFound{"no such user"}; + } + if (user->passwordHash != hashPassword(action.username, action.oldPassword)) { + throw Unauthorized{"current password does not match"}; + } + if (action.newPassword.size() < 4) { + throw ValidationError{"new password must be at least 4 characters"}; + } + user->passwordHash = hashPassword(action.username, action.newPassword); + db.users.update(*user); + return dto::CommandResult{.ok = true, .message = "password changed"}; +} + +dto::SessionInfo AuthModel::execute(const dto::WhoAmI& /*action*/) { + const std::string principal = sessionPrincipal(); + return dto::SessionInfo{.authenticated = !principal.empty(), .principal = principal}; +} + +} // namespace bank diff --git a/examples/bank/gui_wasm/src/models/card_model_wasm.cpp b/examples/bank/gui_wasm/src/models/card_model_wasm.cpp new file mode 100644 index 0000000..e6761af --- /dev/null +++ b/examples/bank/gui_wasm/src/models/card_model_wasm.cpp @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// In-memory implementation of CardModel for the WASM build. + +#include "bank/models/card_model.hpp" + +#include +#include +#include +#include + +#include "bank/core/demo_hash.hpp" +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/wasm/store.hpp" +#include "bank/wasm/store_ops.hpp" + +namespace bank { + +namespace { + +std::string randomLast4() { + static thread_local std::mt19937 rng{std::random_device{}()}; + std::uniform_int_distribution dist{0, 9999}; + return std::format("{:04}", dist(rng)); +} + +std::string hashPin(std::string_view pin) { + return demoHash(std::string{pin} + ":pin"); +} + +dto::CardInfo toInfo(const wasm::CardRow& rec, const std::string& owner) { + return dto::CardInfo{ + .id = static_cast(rec.id), + .owner = owner, + .accountId = static_cast(rec.accountId), + .kind = rec.kind, + .panLast4 = rec.panLast4, + .status = rec.status, + .dailyLimitMinor = rec.dailyLimitMinor, + }; +} + +/// Loads a card the current principal owns, or throws. +wasm::CardRow requireOwnedCard(wasm::Db& db, std::int64_t cardId) { + const auto ownerId = wasm::requireUserId(db, sessionPrincipal()); + auto* card = db.cards.find(static_cast(cardId)); + if (card == nullptr) { + throw NotFound{"card not found"}; + } + if (card->userId != ownerId) { + throw Unauthorized{"card belongs to a different owner"}; + } + return *card; +} + +} // namespace + +dto::CardInfo CardModel::execute(const dto::IssueCard& action) { + if (!action.validate()) { + throw ValidationError{"invalid card request"}; + } + const std::string owner = sessionPrincipal(); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto& db = wasm::sharedDb(); + const auto ownerId = wasm::requireUserId(db, owner); + auto account = wasm::loadOwnedOpenAccount(db, action.accountId, ownerId); + + wasm::CardRow card; + card.accountId = account.id; + card.userId = ownerId; + card.kind = action.kind; + card.panLast4 = randomLast4(); + card.status = static_cast(CardStatus::Active); + card.dailyLimitMinor = action.dailyLimitMinor; + card.pinHash = hashPin("0000"); + card.id = db.cards.insert(card); + return toInfo(card, owner); +} + +dto::CommandResult CardModel::execute(const dto::FreezeCard& action) { + auto& db = wasm::sharedDb(); + auto card = requireOwnedCard(db, action.id); + card.status = static_cast(CardStatus::Frozen); + db.cards.update(card); + return dto::CommandResult{.ok = true, .message = "card frozen"}; +} + +dto::CommandResult CardModel::execute(const dto::UnfreezeCard& action) { + auto& db = wasm::sharedDb(); + auto card = requireOwnedCard(db, action.id); + if (card.status == static_cast(CardStatus::Cancelled)) { + throw ConflictError{"cancelled cards cannot be reactivated"}; + } + card.status = static_cast(CardStatus::Active); + db.cards.update(card); + return dto::CommandResult{.ok = true, .message = "card active"}; +} + +dto::CommandResult CardModel::execute(const dto::CancelCard& action) { + auto& db = wasm::sharedDb(); + auto card = requireOwnedCard(db, action.id); + card.status = static_cast(CardStatus::Cancelled); + db.cards.update(card); + return dto::CommandResult{.ok = true, .message = "card cancelled"}; +} + +dto::CommandResult CardModel::execute(const dto::SetCardLimit& action) { + if (action.dailyLimitMinor < 0) { + throw ValidationError{"limit must be non-negative"}; + } + auto& db = wasm::sharedDb(); + auto card = requireOwnedCard(db, action.id); + card.dailyLimitMinor = action.dailyLimitMinor; + db.cards.update(card); + return dto::CommandResult{.ok = true, .message = "limit updated"}; +} + +dto::CommandResult CardModel::execute(const dto::ChangePin& action) { + if (!action.validate()) { + throw ValidationError{"PIN must be exactly 4 digits"}; + } + auto& db = wasm::sharedDb(); + auto card = requireOwnedCard(db, action.id); + card.pinHash = hashPin(action.newPin); + db.cards.update(card); + return dto::CommandResult{.ok = true, .message = "PIN changed"}; +} + +dto::CardList CardModel::execute(const dto::ListCards& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto& db = wasm::sharedDb(); + const auto ownerId = wasm::requireUserId(db, owner); + dto::CardList out; + for (const auto& rec : db.cards.where([&](const wasm::CardRow& c) { return c.userId == ownerId; })) { + out.cards.push_back(toInfo(rec, owner)); + } + return out; +} + +} // namespace bank diff --git a/examples/bank/gui_wasm/src/models/loan_model_wasm.cpp b/examples/bank/gui_wasm/src/models/loan_model_wasm.cpp new file mode 100644 index 0000000..2a31be4 --- /dev/null +++ b/examples/bank/gui_wasm/src/models/loan_model_wasm.cpp @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// In-memory implementation of LoanModel for the WASM build. + +#include "bank/models/loan_model.hpp" + +#include +#include +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/wasm/store.hpp" +#include "bank/wasm/store_ops.hpp" + +namespace bank { + +namespace { + +dto::LoanInfo toInfo(const wasm::LoanRow& rec, const std::string& owner) { + return dto::LoanInfo{ + .id = static_cast(rec.id), + .owner = owner, + .accountId = static_cast(rec.accountId), + .principalMinor = rec.principalMinor, + .outstandingMinor = rec.outstandingMinor, + .currency = rec.currency, + .rateBps = rec.rateBps, + .termMonths = rec.termMonths, + .status = rec.status, + .createdAtMs = rec.createdAtMs, + }; +} + +/// Fixed monthly payment (minor units) for an amortizing loan. +std::int64_t monthlyPayment(std::int64_t principalMinor, int rateBps, int termMonths) { + const double principal = static_cast(principalMinor); + const double monthlyRate = static_cast(rateBps) / 10000.0 / 12.0; + if (monthlyRate <= 0.0) { + return static_cast(std::llround(principal / termMonths)); + } + const double factor = std::pow(1.0 + monthlyRate, -termMonths); + return static_cast(std::llround(principal * monthlyRate / (1.0 - factor))); +} + +wasm::LoanRow requireOwnedLoan(wasm::Db& db, std::int64_t loanId) { + const auto ownerId = wasm::requireUserId(db, sessionPrincipal()); + auto* loan = db.loans.find(static_cast(loanId)); + if (loan == nullptr) { + throw NotFound{"loan not found"}; + } + if (loan->userId != ownerId) { + throw Unauthorized{"loan belongs to a different owner"}; + } + return *loan; +} + +} // namespace + +dto::LoanInfo LoanModel::execute(const dto::ApplyLoan& action) { + if (!action.validate()) { + throw ValidationError{"invalid loan application"}; + } + const std::string owner = sessionPrincipal(); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto& db = wasm::sharedDb(); + const auto ownerId = wasm::requireUserId(db, owner); + auto account = wasm::loadOwnedOpenAccount(db, action.accountId, ownerId); + + wasm::LoanRow loan; + loan.userId = ownerId; + loan.accountId = account.id; + loan.principalMinor = action.principalMinor; + loan.outstandingMinor = action.principalMinor; + loan.currency = account.currency; + loan.rateBps = action.rateBps; + loan.termMonths = action.termMonths; + loan.status = static_cast(LoanStatus::Active); + loan.createdAtMs = wasm::nowMillis(); + loan.id = db.loans.insert(loan); + + wasm::applyCredit(db, account, action.principalMinor, TxnKind::LoanDisbursement, 0, "loan disbursement"); + return toInfo(loan, owner); +} + +dto::LoanInfo LoanModel::execute(const dto::RepayLoan& action) { + if (!action.validate()) { + throw ValidationError{"invalid repayment"}; + } + const std::string owner = sessionPrincipal(); + auto& db = wasm::sharedDb(); + auto loan = requireOwnedLoan(db, action.loanId); + if (loan.status != static_cast(LoanStatus::Active)) { + throw ConflictError{"loan is not active"}; + } + auto account = wasm::loadOwnedOpenAccount(db, action.fromAccountId, wasm::requireUserId(db, owner)); + const std::int64_t payment = std::min(action.amountMinor, loan.outstandingMinor); + + wasm::applyDebit(db, account, payment, TxnKind::LoanRepayment, 0, "loan repayment"); + loan.outstandingMinor -= payment; + if (loan.outstandingMinor <= 0) { + loan.outstandingMinor = 0; + loan.status = static_cast(LoanStatus::PaidOff); + } + db.loans.update(loan); + return toInfo(loan, owner); +} + +dto::LoanInfo LoanModel::execute(const dto::GetLoan& action) { + const std::string owner = sessionPrincipal(); + return toInfo(requireOwnedLoan(wasm::sharedDb(), action.id), owner); +} + +dto::LoanList LoanModel::execute(const dto::ListLoans& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto& db = wasm::sharedDb(); + const auto ownerId = wasm::requireUserId(db, owner); + dto::LoanList out; + for (const auto& rec : db.loans.where([&](const wasm::LoanRow& l) { return l.userId == ownerId; })) { + out.loans.push_back(toInfo(rec, owner)); + } + return out; +} + +dto::LoanScheduleResult LoanModel::execute(const dto::LoanScheduleRequest& action) { + auto loan = requireOwnedLoan(wasm::sharedDb(), action.loanId); + + const std::int64_t principal = loan.principalMinor; + const int rateBps = loan.rateBps; + const int term = loan.termMonths; + const double monthlyRate = static_cast(rateBps) / 10000.0 / 12.0; + const std::int64_t payment = monthlyPayment(principal, rateBps, term); + + dto::LoanScheduleResult out; + out.loanId = action.loanId; + out.monthlyPaymentMinor = payment; + + std::int64_t remaining = principal; + for (int month = 1; month <= term && remaining > 0; ++month) { + const std::int64_t interest = + static_cast(std::llround(static_cast(remaining) * monthlyRate)); + std::int64_t principalPart = payment - interest; + if (month == term || principalPart > remaining) { + principalPart = remaining; + } + remaining -= principalPart; + out.installments.push_back(dto::Installment{ + .month = month, + .paymentMinor = principalPart + interest, + .principalMinor = principalPart, + .interestMinor = interest, + .remainingMinor = remaining, + }); + } + return out; +} + +} // namespace bank diff --git a/examples/bank/gui_wasm/src/models/payee_model_wasm.cpp b/examples/bank/gui_wasm/src/models/payee_model_wasm.cpp new file mode 100644 index 0000000..1171cd9 --- /dev/null +++ b/examples/bank/gui_wasm/src/models/payee_model_wasm.cpp @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// In-memory implementation of PayeeModel for the WASM build. + +#include "bank/models/payee_model.hpp" + +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/wasm/store.hpp" +#include "bank/wasm/store_ops.hpp" + +namespace bank { + +namespace { + +dto::PayeeInfo toInfo(const wasm::PayeeRow& rec, const std::string& owner) { + return dto::PayeeInfo{ + .id = static_cast(rec.id), + .owner = owner, + .name = rec.name, + .iban = rec.iban, + .bankName = rec.bankName, + }; +} + +} // namespace + +dto::PayeeInfo PayeeModel::execute(const dto::AddPayee& action) { + if (!action.validate()) { + throw ValidationError{"payee needs a name and a valid IBAN"}; + } + const std::string owner = sessionPrincipal(); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto& db = wasm::sharedDb(); + wasm::PayeeRow rec; + rec.userId = wasm::requireUserId(db, owner); + rec.name = action.name; + rec.iban = action.iban; + rec.bankName = action.bankName; + rec.id = db.payees.insert(rec); + return toInfo(rec, owner); +} + +dto::CommandResult PayeeModel::execute(const dto::RemovePayee& action) { + auto& db = wasm::sharedDb(); + const auto ownerId = wasm::requireUserId(db, sessionPrincipal()); + auto* payee = db.payees.find(static_cast(action.id)); + if (payee == nullptr) { + throw NotFound{"payee not found"}; + } + if (payee->userId != ownerId) { + throw Unauthorized{"payee belongs to a different owner"}; + } + db.payees.erase(payee->id); + return dto::CommandResult{.ok = true, .message = "payee removed"}; +} + +dto::PayeeList PayeeModel::execute(const dto::ListPayees& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto& db = wasm::sharedDb(); + const auto ownerId = wasm::requireUserId(db, owner); + dto::PayeeList out; + for (const auto& rec : db.payees.where([&](const wasm::PayeeRow& p) { return p.userId == ownerId; })) { + out.payees.push_back(toInfo(rec, owner)); + } + return out; +} + +} // namespace bank diff --git a/examples/bank/gui_wasm/src/models/payment_model_wasm.cpp b/examples/bank/gui_wasm/src/models/payment_model_wasm.cpp new file mode 100644 index 0000000..26ca518 --- /dev/null +++ b/examples/bank/gui_wasm/src/models/payment_model_wasm.cpp @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// In-memory implementation of PaymentModel for the WASM build. + +#include "bank/models/payment_model.hpp" + +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/wasm/store.hpp" +#include "bank/wasm/store_ops.hpp" + +namespace bank { + +namespace { + +dto::PaymentInfo toInfo(const wasm::PaymentRow& rec, const std::string& owner) { + return dto::PaymentInfo{ + .id = static_cast(rec.id), + .owner = owner, + .fromAccountId = static_cast(rec.fromAccountId), + .payeeId = static_cast(rec.payeeId), + .amountMinor = rec.amountMinor, + .currency = rec.currency, + .schedule = rec.schedule, + .status = rec.status, + .dueAtMs = rec.dueAtMs, + .intervalDays = rec.intervalDays, + .description = rec.description, + }; +} + +void requireOwnedPayee(wasm::Db& db, std::int64_t payeeId, std::uint64_t ownerId) { + auto* payee = db.payees.find(static_cast(payeeId)); + if (payee == nullptr) { + throw NotFound{"payee not found"}; + } + if (payee->userId != ownerId) { + throw Unauthorized{"payee belongs to a different owner"}; + } +} + +} // namespace + +dto::PaymentInfo PaymentModel::execute(const dto::PayBill& action) { + if (!action.validate()) { + throw ValidationError{"invalid bill payment"}; + } + const std::string owner = sessionPrincipal(); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto& db = wasm::sharedDb(); + const auto ownerId = wasm::requireUserId(db, owner); + auto account = wasm::loadOwnedOpenAccount(db, action.fromAccountId, ownerId); + requireOwnedPayee(db, action.payeeId, ownerId); + + wasm::PaymentRow payment; + payment.userId = ownerId; + payment.fromAccountId = static_cast(action.fromAccountId); + payment.payeeId = static_cast(action.payeeId); + payment.amountMinor = action.amountMinor; + payment.currency = account.currency; + payment.schedule = static_cast(PaymentSchedule::OneOff); + payment.status = static_cast(PaymentStatus::Completed); + payment.dueAtMs = wasm::nowMillis(); + payment.intervalDays = 0; + payment.description = action.description; + + wasm::applyDebit(db, account, action.amountMinor, TxnKind::Payment, action.payeeId, action.description); + payment.id = db.payments.insert(payment); + return toInfo(payment, owner); +} + +dto::PaymentInfo PaymentModel::execute(const dto::SchedulePayment& action) { + if (!action.validate()) { + throw ValidationError{"invalid scheduled payment"}; + } + const std::string owner = sessionPrincipal(); + auto& db = wasm::sharedDb(); + const auto ownerId = wasm::requireUserId(db, owner); + auto account = wasm::loadOwnedOpenAccount(db, action.fromAccountId, ownerId); + requireOwnedPayee(db, action.payeeId, ownerId); + + wasm::PaymentRow payment; + payment.userId = ownerId; + payment.fromAccountId = static_cast(action.fromAccountId); + payment.payeeId = static_cast(action.payeeId); + payment.amountMinor = action.amountMinor; + payment.currency = account.currency; + payment.schedule = static_cast(PaymentSchedule::Scheduled); + payment.status = static_cast(PaymentStatus::Pending); + payment.dueAtMs = action.dueAtMs; + payment.intervalDays = 0; + payment.description = action.description; + payment.id = db.payments.insert(payment); + return toInfo(payment, owner); +} + +dto::PaymentInfo PaymentModel::execute(const dto::CreateStandingOrder& action) { + if (!action.validate()) { + throw ValidationError{"invalid standing order"}; + } + const std::string owner = sessionPrincipal(); + auto& db = wasm::sharedDb(); + const auto ownerId = wasm::requireUserId(db, owner); + auto account = wasm::loadOwnedOpenAccount(db, action.fromAccountId, ownerId); + requireOwnedPayee(db, action.payeeId, ownerId); + + wasm::PaymentRow payment; + payment.userId = ownerId; + payment.fromAccountId = static_cast(action.fromAccountId); + payment.payeeId = static_cast(action.payeeId); + payment.amountMinor = action.amountMinor; + payment.currency = account.currency; + payment.schedule = static_cast(PaymentSchedule::Standing); + payment.status = static_cast(PaymentStatus::Pending); + payment.dueAtMs = action.firstDueAtMs; + payment.intervalDays = action.intervalDays; + payment.description = action.description; + payment.id = db.payments.insert(payment); + return toInfo(payment, owner); +} + +dto::CommandResult PaymentModel::execute(const dto::CancelPayment& action) { + auto& db = wasm::sharedDb(); + const auto ownerId = wasm::requireUserId(db, sessionPrincipal()); + auto* payment = db.payments.find(static_cast(action.id)); + if (payment == nullptr) { + throw NotFound{"payment not found"}; + } + if (payment->userId != ownerId) { + throw Unauthorized{"payment belongs to a different owner"}; + } + if (payment->status != static_cast(PaymentStatus::Pending)) { + throw ConflictError{"only pending payments can be cancelled"}; + } + payment->status = static_cast(PaymentStatus::Cancelled); + db.payments.update(*payment); + return dto::CommandResult{.ok = true, .message = "payment cancelled"}; +} + +dto::PaymentList PaymentModel::execute(const dto::ListPayments& action) { + const std::string owner = resolveOwner(action.owner); + if (owner.empty()) { + throw Unauthorized{"no session principal"}; + } + auto& db = wasm::sharedDb(); + const auto ownerId = wasm::requireUserId(db, owner); + dto::PaymentList out; + for (const auto& rec : + db.payments.where([&](const wasm::PaymentRow& p) { return p.userId == ownerId; })) { + out.payments.push_back(toInfo(rec, owner)); + } + return out; +} + +} // namespace bank diff --git a/examples/bank/gui_wasm/src/models/transaction_model_wasm.cpp b/examples/bank/gui_wasm/src/models/transaction_model_wasm.cpp new file mode 100644 index 0000000..c39bddc --- /dev/null +++ b/examples/bank/gui_wasm/src/models/transaction_model_wasm.cpp @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// In-memory implementation of TransactionModel for the WASM build. Mirrors +// src/models/transaction_model.cpp (single-threaded, so no SqlTransaction). + +#include "bank/models/transaction_model.hpp" + +#include +#include +#include +#include + +#include "bank/core/errors.hpp" +#include "bank/core/principal.hpp" +#include "bank/core/types.hpp" +#include "bank/wasm/store.hpp" +#include "bank/wasm/store_ops.hpp" + +namespace bank { + +namespace { + +dto::TxnInfo toTxnInfo(const wasm::TxnRow& rec) { + return dto::TxnInfo{ + .id = static_cast(rec.id), + .accountId = static_cast(rec.accountId), + .counterpartyId = rec.counterpartyId, + .direction = rec.direction, + .kind = rec.kind, + .amountMinor = rec.amountMinor, + .currency = rec.currency, + .balanceAfterMinor = rec.balanceAfterMinor, + .description = rec.description, + .createdAtMs = rec.createdAtMs, + }; +} + +} // namespace + +dto::TxnInfo TransactionModel::execute(const dto::Deposit& action) { + if (!action.validate()) { + throw ValidationError{"deposit amount must be positive"}; + } + auto& db = wasm::sharedDb(); + auto account = wasm::loadOwnedOpenAccount(db, action.accountId, + wasm::requireUserId(db, sessionPrincipal())); + return toTxnInfo( + wasm::applyCredit(db, account, action.amountMinor, TxnKind::Deposit, 0, action.description)); +} + +dto::TxnInfo TransactionModel::execute(const dto::Withdraw& action) { + if (!action.validate()) { + throw ValidationError{"withdrawal amount must be positive"}; + } + auto& db = wasm::sharedDb(); + auto account = wasm::loadOwnedOpenAccount(db, action.accountId, + wasm::requireUserId(db, sessionPrincipal())); + return toTxnInfo( + wasm::applyDebit(db, account, action.amountMinor, TxnKind::Withdrawal, 0, action.description)); +} + +dto::TransferResult TransactionModel::execute(const dto::Transfer& action) { + if (!action.validate()) { + throw ValidationError{"invalid transfer (accounts must differ and amount be positive)"}; + } + auto& db = wasm::sharedDb(); + const auto ownerId = wasm::requireUserId(db, sessionPrincipal()); + auto source = wasm::loadOwnedOpenAccount(db, action.fromAccountId, ownerId); + auto dest = wasm::loadOwnedOpenAccount(db, action.toAccountId, ownerId); + if (source.currency != dest.currency) { + throw ValidationError{"cross-currency transfers are not supported"}; + } + wasm::applyDebit(db, source, action.amountMinor, TxnKind::TransferOut, action.toAccountId, + action.description); + wasm::applyCredit(db, dest, action.amountMinor, TxnKind::TransferIn, action.fromAccountId, + action.description); + return dto::TransferResult{.fromBalanceMinor = source.balanceMinor, + .toBalanceMinor = dest.balanceMinor}; +} + +dto::HistoryPage TransactionModel::execute(const dto::History& action) { + dto::HistoryPage page; + page.accountId = action.accountId; + const auto offset = static_cast(std::max(0, action.offset)); + const auto limit = static_cast(std::max(0, action.limit)); + if (limit == 0) { + return page; + } + auto& db = wasm::sharedDb(); + auto rows = db.txns.where( + [&](const wasm::TxnRow& t) { return t.accountId == static_cast(action.accountId); }); + // Newest first. + std::ranges::sort(rows, [](const wasm::TxnRow& a, const wasm::TxnRow& b) { return a.id > b.id; }); + for (std::size_t i = offset; i < rows.size() && i < offset + limit; ++i) { + page.entries.push_back(toTxnInfo(rows[i])); + } + return page; +} + +} // namespace bank From 11e65d2b8aef5e31f08e0c9e67ec04d98815f61b Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 1 Jul 2026 08:20:18 +0200 Subject: [PATCH 07/10] [morph] ci: pin official Qt mirror for the WASM demo build The wasm_singlethread install failed on a fallback mirror ("Failed to locate XML data for Qt version 6.8.3"). Pin base: https://download.qt.io/ on both install-qt-action steps. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq --- .github/workflows/wasm-demo.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/wasm-demo.yml b/.github/workflows/wasm-demo.yml index 5696fb7..021dd0c 100644 --- a/.github/workflows/wasm-demo.yml +++ b/.github/workflows/wasm-demo.yml @@ -55,6 +55,7 @@ jobs: target: desktop arch: linux_gcc_64 dir: ${{ runner.temp }}/qt + base: https://download.qt.io/ # official mirror (avoids missing XML on fallbacks) - name: Install Qt (wasm, single-threaded) uses: jurplel/install-qt-action@v4 @@ -64,6 +65,7 @@ jobs: target: desktop arch: wasm_singlethread dir: ${{ runner.temp }}/qt + base: https://download.qt.io/ - name: Set up emsdk uses: mymindstorm/setup-emsdk@v14 From c47eb39d32779f3e181aea35eae84e7648310da2 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 1 Jul 2026 08:23:46 +0200 Subject: [PATCH 08/10] [morph] ci: install WASM Qt from host=all_os/target=wasm Qt 6.7+ publishes WebAssembly under host=all_os / target=wasm, not linux/desktop (where only linux_gcc_64 exists). Also drop the invalid `base` input (not supported by install-qt-action). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq --- .github/workflows/wasm-demo.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wasm-demo.yml b/.github/workflows/wasm-demo.yml index 021dd0c..3ed4b22 100644 --- a/.github/workflows/wasm-demo.yml +++ b/.github/workflows/wasm-demo.yml @@ -55,17 +55,17 @@ jobs: target: desktop arch: linux_gcc_64 dir: ${{ runner.temp }}/qt - base: https://download.qt.io/ # official mirror (avoids missing XML on fallbacks) + # Qt 6.7+ ships WebAssembly under host=all_os / target=wasm (not + # linux/desktop). The matching host desktop Qt above provides the tools. - name: Install Qt (wasm, single-threaded) uses: jurplel/install-qt-action@v4 with: version: ${{ env.QT_VERSION }} - host: linux - target: desktop + host: all_os + target: wasm arch: wasm_singlethread dir: ${{ runner.temp }}/qt - base: https://download.qt.io/ - name: Set up emsdk uses: mymindstorm/setup-emsdk@v14 From 99d0d1d7480b26f917eac965e0a1b12cb08f4fa8 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 1 Jul 2026 08:26:57 +0200 Subject: [PATCH 09/10] [morph] ci: chmod +x the wasm Qt bin before qt-cmake The all_os/wasm package extracts qt-cmake without the exec bit (exit 126, Permission denied). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq --- .github/workflows/wasm-demo.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/wasm-demo.yml b/.github/workflows/wasm-demo.yml index 3ed4b22..026597c 100644 --- a/.github/workflows/wasm-demo.yml +++ b/.github/workflows/wasm-demo.yml @@ -79,6 +79,8 @@ jobs: mkdir -p "$EM_CACHE" HOST=${{ runner.temp }}/qt/Qt/${{ env.QT_VERSION }}/gcc_64 WASM=${{ runner.temp }}/qt/Qt/${{ env.QT_VERSION }}/wasm_singlethread + # The all_os/wasm package extracts its scripts without the exec bit. + chmod +x "$WASM"/bin/* || true "$WASM/bin/qt-cmake" -S . -B build-wasm -G Ninja \ -DQT_HOST_PATH="$HOST" \ -DMORPH_BUILD_EXAMPLES=ON -DMORPH_BUILD_BANK_EXAMPLE=ON \ From 825f13e91ce5cc3637d38b0e7837f6470394aae9 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 1 Jul 2026 11:02:56 +0200 Subject: [PATCH 10/10] [morph] bank example: document the live WASM demo URL in the README Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq --- examples/bank/README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/examples/bank/README.md b/examples/bank/README.md index eeba370..adb33d2 100644 --- a/examples/bank/README.md +++ b/examples/bank/README.md @@ -11,7 +11,12 @@ A feature-rich demo banking application built on two libraries: schema is owned by Lightweight migrations. It ships the models, a full test suite, a scripted CLI driver, and a **Qt 6 -desktop GUI**. +desktop GUI** — which also builds to **WebAssembly** and runs entirely in the browser. + +> **▶ Try the live demo:** **https://lastrada-software.github.io/morph/demo/** +> It opens signed in as a seeded demo user (`demo` / `demo1234`) with two accounts. +> Everything runs client-side — no server. First load fetches a ~31 MB `.wasm`, so +> give it a few seconds. (See [WebAssembly demo](#webassembly-demo-self-contained-github-pages).) ## Architecture: two type layers @@ -157,6 +162,14 @@ and grabs a PNG of each page. ### WebAssembly demo (self-contained, GitHub Pages) +**Open it in a browser — nothing to install:** **https://lastrada-software.github.io/morph/demo/** + +You land signed in as the seeded demo user (`demo` / `demo1234`) with two accounts; +open accounts, deposit/withdraw/transfer, issue cards, add payees & pay bills, and take +a loan. It's a static page — the first load fetches a ~31 MB `.wasm` (streaming-compiled +by the browser), then it's instant. Published from `master` by +[`.github/workflows/wasm-demo.yml`](../../.github/workflows/wasm-demo.yml). + `gui_wasm/` is a **single-threaded WebAssembly** build of the same GUI that runs **entirely in the browser** — the morph model layer is the "server in the background," with no external process. Since Lightweight (ODBC/SQLite) can't run in a browser, the