diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17a2d1d..57f4544 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,25 +5,19 @@ # The jobs run the scripts rather than repeating what the scripts do. A workflow that spelled the # steps out again would be a second copy of the setup instructions, free to drift from the ones a # reader follows, and drift is exactly what this is here to catch. -name: build +name: CI -# main and every pull request. A feature branch nobody has opened a pull request for is checked by -# hand through workflow_dispatch; running on every push as well would mean two identical runs for -# every branch that later becomes a pull request. on: push: branches: [main] pull_request: workflow_dispatch: -# Nothing here writes anything back. Stated rather than left to the repository default, which an -# organisation setting can widen without anyone touching this file. permissions: contents: read -# A newer commit on the same branch makes the run in progress pointless. concurrency: - group: build-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -32,37 +26,32 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v6 with: distribution: temurin - # 21 and not 17, because 21 is the JDK this project is developed and measured on. - # Nothing newer than 17 can slip into the sources anyway: maven-compiler-plugin pins - # release to 17, which bounds the API surface rather than only the bytecode version. - java-version: '21' + # 17, which is what maven-compiler-plugin pins release to. Building on the version the + # project is held to is the configuration its tests are green on. + java-version: '17' cache: maven # sh ./mvnw, not ./mvnw. The repository is worked on from Windows with core.filemode false, # so a file added there carries mode 100644 and the wrapper is not executable in a clone - # made here. Naming the interpreter costs nothing and does not depend on that mode. The - # ./ is not decoration: the wrapper reads its properties from ${0%/*}/.mvn/wrapper, and a - # bare mvnw has no slash to strip, so the path becomes mvnw/.mvn/wrapper and the read fails. + # made here. The ./ is not decoration: the wrapper reads its properties from + # ${0%/*}/.mvn/wrapper, and a bare mvnw has no slash to strip. - name: Maven wrapper run: sh ./mvnw -B -v - # The stack the README describes, started by the script the README names. Deliberately not - # a service container: db/init is mounted by docker-compose.yml and runs only when compose - # starts PostgreSQL, and it is what creates minibank_test and applies the schema to both - # databases. A service container would leave the second database missing and the SQL tests - # would report as skipped instead of failing. + # The stack the README describes, started by the script the README names. Deliberately not a + # service container: db/init is mounted by docker-compose.yml and runs only when compose + # starts PostgreSQL, and it is what creates minibank_test and applies the schema to it. - name: Start PostgreSQL run: sh scripts/up.sh # Every migration, against both databases, on a volume built moments ago from - # db/init/schema.sql. All of them are written to be safe to run twice, and this is what - # keeps that claim true: one that stopped being re-runnable fails here, rather than on the - # machine of whoever next resets a database. + # db/init/schema.sql. All of them are written to be safe to run twice, and this is what keeps + # that claim true. - name: Apply the migrations run: sh scripts/migrate.sh @@ -70,14 +59,10 @@ jobs: run: sh ./mvnw -B -DskipTests package # The test database is named in full, and that address is not optional. Without the property - # the SQL tests skip and the run goes green over untested code; with an address that names - # some other database they TRUNCATE it, because that is what they do to the database they - # are given. + # the SQL tests skip and the run goes green over untested code. - name: Test the backend run: sh ./mvnw -B test "-Dminibank.test.sql.url=jdbc:postgresql://localhost:55432/minibank_test" - # Surefire redirects test output to files, so a failure prints a summary and keeps the - # detail on disk. On the runner that disk is discarded with the job unless it is collected. - name: Keep the reports of a failed run if: failure() uses: actions/upload-artifact@v4 @@ -91,14 +76,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: - # The floor both package.json files declare, which is in turn the floor the installed - # Vite declares. Pinned to the minor rather than to '22' so that this file says which - # version the front ends were checked on. - node-version: '22.12' + node-version: '24' cache: npm cache-dependency-path: | minibank-web/package-lock.json @@ -109,8 +91,8 @@ jobs: run: npm ci # Installed even though nothing is run from this directory below: the test step reaches the - # workstation's sources through its own vite config, and that config imports the React - # plugin from here. + # workstation's sources through its own vite config, and that config imports the React plugin + # from here. - name: Install the workstation working-directory: minibank-fraud-web run: npm ci @@ -136,4 +118,4 @@ jobs: - name: Build the workstation working-directory: minibank-fraud-web - run: npm run build + run: npm run build \ No newline at end of file diff --git a/.gitignore b/.gitignore index 992d153..743b33c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ node_modules/ ### Runtime artifacts ### *.log +*.stackdump storage/ data/ diff --git a/README.md b/README.md index 93083d0..d22eba6 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,104 @@ # MiniBank -A small retail banking application written to show persistence patterns implemented by hand, with -no ORM anywhere in it: a Unit of Work that defers every write to commit, an Identity Map that makes -one transaction see one instance of a row, and Lazy Load behind the navigation properties. The -domain layer has no framework in it at all, and the same domain runs against two different stores. +[![CI](https://github.com/d3m1d0s/VIS_mini-bank/actions/workflows/ci.yml/badge.svg)](https://github.com/d3m1d0s/VIS_mini-bank/actions/workflows/ci.yml) -What it does: a customer signs in, lists their accounts, sends a payment, confirms it with a one -time password, or cancels it. A payment that looks risky is held for review, and a fraud analyst -approves it, records confirmed fraud, or leaves notes. +MiniBank is a full-stack retail banking application built to demonstrate production-style backend +design without an ORM. It combines a Java 17 and Spring Boot REST API, PostgreSQL/JDBC persistence, +and React/TypeScript clients with hand-written Unit of Work, Identity Map and Lazy Loading patterns. +Customers can review accounts and payment history, create and confirm payments with a one-time +password, and cancel pending transfers. Risky payments enter a separate fraud-review workflow where +an analyst can investigate the alert, leave notes and approve or decline the transfer. + +**Stack:** Java 17, Spring Boot, REST, PostgreSQL, JDBC, React, TypeScript, Vite, Maven, Docker, +JUnit 5, Mockito, Vitest and ESLint. + +![Customer creating a bank transfer](docs/screenshots/customer-new-payment.png) + +*Customer payment creation with beneficiary selection, real-time fee calculation and daily-limit tracking.* + +## Highlights + +- End-to-end payment flow with authentication, OTP authorization, cancellation and history. +- Rule-based risk checks and a dedicated fraud analyst workflow. +- Framework-independent domain model with repository and Unit of Work abstractions. +- JSON and PostgreSQL persistence adapters behind the same application services. +- Optimistic locking for accounts, transfers and fraud alerts to prevent lost updates. +- Parameterized JDBC queries, transactional writes and automated backend/frontend tests. + +## Product walkthrough + +### OTP payment authorization + +![Customer authorizing a pending payment using a one-time password](docs/screenshots/customer-otp-authorization.png) + +*Pending transfers require OTP confirmation and expose retry limits, expiration time and fraud-review status.* + +### Fraud review + +![Fraud analyst reviewing a risk alert](docs/screenshots/fraud-alert-review.png) + +*Rule-based alerts enter a dedicated analyst queue with assignment, customer history, case notes and approve/decline decisions.* + +
+View the complete fraud investigation workflow + +
+ +

+ Full fraud analyst workflow with alert details, customer history, case notes and decision form +

+ +

+ Complete analyst workspace with alert facts, transaction history, case notes and the final decision form. +

+ +
+ +> All accounts, transactions and credentials shown above are synthetic demo data. + +## Architecture + +```mermaid +flowchart TD + UI["React + TypeScript clients"] --> API["Spring Boot REST API"] + API --> APP["Application services"] + APP --> DOMAIN["Framework-free domain model"] + APP --> PORTS["Repository + Unit of Work interfaces"] + PORTS --> JSON["JSON adapter"] + PORTS --> JDBC["JDBC adapter"] + JDBC --> DB[(PostgreSQL)] +``` + +The domain and application layers do not depend on Spring or a persistence technology. Infrastructure +adapters implement the repository and Unit of Work interfaces for either a local JSON store or +PostgreSQL. See [`src/Project_structure.md`](src/Project_structure.md) for the complete package map. + +## Quick start + +The default demo uses the JSON store, so PostgreSQL and Docker are not required. Run these three +commands, using a second terminal for the web client: + +```bash +git clone https://github.com/d3m1d0s/VIS_mini-bank.git && cd VIS_mini-bank +mvn -B spring-boot:run +``` + +```bash +cd VIS_mini-bank/minibank-web && npm ci && npm run dev +``` + +Open `http://localhost:5173` and sign in as `alice / alice123` for the customer application or +`fraud / fraud123` for the analyst desk. The demo one-time password is `0000`. + +For PostgreSQL mode, both frontends, console applications and detailed configuration, continue with +[Running it](#running-it). + +## Design notes Every push to `main` and every pull request runs `.github/workflows/ci.yml`: the backend is built and tested against a real PostgreSQL started by docker compose, the migrations are applied to both databases, and both front ends are linted, tested and built. @@ -30,14 +120,11 @@ databases, and both front ends are linted, tested and built. is ever concatenated into SQL text. The only plain `Statement`s are the constant-text `nextval` queries that allocate ids. -`src/Project_structure.md` maps the tree. - ## Requirements - Java 17 or newer. The build holds the code to the Java 17 API, so a current JDK works too. - Docker, for PostgreSQL mode and for the SQL tests -- Node 20.19 or newer, or 22.12 or newer. That is the floor both `package.json` files declare, and - it is in turn the floor the installed Vite declares; the workflow runs 22.12. +- Node 20.19+ or 22.12+; Node 24 is recommended and used in CI Maven is not on that list. The repository carries the Maven wrapper, `mvnw`, `mvnw.cmd` and `.mvn/`, which downloads Maven 3.9.6 on first use, so nothing has to be on `PATH`. @@ -231,7 +318,7 @@ tables with `.\scripts\demo-reset.ps1`, then start the API again with the demo a them. JSON mode is untouched: its users live only in memory, so nothing hashed under the old parameters survives a restart. -## Tests +## Tests and quality checks ``` .\mvnw.cmd -B test @@ -255,8 +342,21 @@ There is one JavaScript test runner in the tree. vitest is installed only in `mi reaches the workstation and `frontend-shared` through the `projects` list in `minibank-web/vite.config.ts`: +```bash +cd minibank-web +npm ci +npm run lint +npm test +npm run build ``` -cd minibank-web; npm test + +The standalone fraud application has its own lint and production-build gates: + +```bash +cd minibank-fraud-web +npm ci +npm run lint +npm run build ``` `minibank-fraud-web` has no `test` script and is not meant to get one, so `npm test` inside that diff --git a/bash.exe.stackdump b/bash.exe.stackdump deleted file mode 100644 index 89172cd..0000000 --- a/bash.exe.stackdump +++ /dev/null @@ -1,30 +0,0 @@ -Stack trace: -Frame Function Args -0007FFFFB810 00021005FE8E (000210285F68, 00021026AB6E, 0007FFFFB810, 0007FFFFA710) msys-2.0.dll+0x1FE8E -0007FFFFB810 0002100467F9 (000000000000, 000000000000, 000000000000, 0007FFFFBAE8) msys-2.0.dll+0x67F9 -0007FFFFB810 000210046832 (000210286019, 0007FFFFB6C8, 0007FFFFB810, 000000000000) msys-2.0.dll+0x6832 -0007FFFFB810 000210068CF6 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28CF6 -0007FFFFB810 000210068E24 (0007FFFFB820, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28E24 -0007FFFFBAF0 00021006A225 (0007FFFFB820, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A225 -End of stack trace -Loaded modules: -000100400000 bash.exe -7FFD86A90000 ntdll.dll -7FFD847C0000 KERNEL32.DLL -7FFD83F60000 KERNELBASE.dll -7FFD7F9E0000 apphelp.dll -7FFD855A0000 USER32.dll -7FFD83E90000 win32u.dll -7FFD85BA0000 GDI32.dll -7FFD844C0000 gdi32full.dll -7FFD83EC0000 msvcp_win.dll -7FFD84320000 ucrtbase.dll -000210040000 msys-2.0.dll -7FFD85400000 advapi32.dll -7FFD84AE0000 msvcrt.dll -7FFD85190000 sechost.dll -7FFD83DA0000 bcrypt.dll -7FFD85D30000 RPCRT4.dll -7FFD835B0000 CRYPTBASE.DLL -7FFD84440000 bcryptPrimitives.dll -7FFD85B60000 IMM32.DLL diff --git a/docs/screenshots/customer-new-payment.png b/docs/screenshots/customer-new-payment.png new file mode 100644 index 0000000..5243955 Binary files /dev/null and b/docs/screenshots/customer-new-payment.png differ diff --git a/docs/screenshots/customer-otp-authorization.png b/docs/screenshots/customer-otp-authorization.png new file mode 100644 index 0000000..bdabb71 Binary files /dev/null and b/docs/screenshots/customer-otp-authorization.png differ diff --git a/docs/screenshots/fraud-alert-review.png b/docs/screenshots/fraud-alert-review.png new file mode 100644 index 0000000..5e5b090 Binary files /dev/null and b/docs/screenshots/fraud-alert-review.png differ diff --git a/docs/screenshots/fraud-more-details.png b/docs/screenshots/fraud-more-details.png new file mode 100644 index 0000000..4e0b245 Binary files /dev/null and b/docs/screenshots/fraud-more-details.png differ diff --git a/minibank-fraud-web/package-lock.json b/minibank-fraud-web/package-lock.json index 1602b12..9634593 100644 --- a/minibank-fraud-web/package-lock.json +++ b/minibank-fraud-web/package-lock.json @@ -3999,9 +3999,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/minibank-fraud-web/package.json b/minibank-fraud-web/package.json index 241c5e9..114bc6f 100644 --- a/minibank-fraud-web/package.json +++ b/minibank-fraud-web/package.json @@ -9,7 +9,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", + "lint": "eslint . --max-warnings=0", "preview": "vite preview" }, "dependencies": { diff --git a/minibank-fraud-web/src/FraudDesk.tsx b/minibank-fraud-web/src/FraudDesk.tsx index ec3a74d..66d0918 100644 --- a/minibank-fraud-web/src/FraudDesk.tsx +++ b/minibank-fraud-web/src/FraudDesk.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useEffectEvent, useRef, useState, type ReactNode } from 'react'; import { buildDecisionRequest, decisionAllowed, @@ -663,10 +663,14 @@ export default function FraudDesk(props: { ? historyErr.failure : null; + const reloadListForFilterChange = useEffectEvent(() => { + void reloadList(); + }); + // amountReason belongs in here beside filters. Typing something that cannot be read into an // already empty field leaves the filters untouched, so on filters alone nothing would re-run // and the analyst would be told nothing at all. - useEffect(() => { void reloadList(); }, [filters, amountReason]); + useEffect(() => { reloadListForFilterChange(); }, [filters, amountReason]); /* * The address, when it says something other than what is on screen. diff --git a/minibank-web/package-lock.json b/minibank-web/package-lock.json index 3af78fe..85741be 100644 --- a/minibank-web/package-lock.json +++ b/minibank-web/package-lock.json @@ -4203,9 +4203,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/minibank-web/src/HistoryPage.tsx b/minibank-web/src/HistoryPage.tsx index 6618ed3..308ae55 100644 --- a/minibank-web/src/HistoryPage.tsx +++ b/minibank-web/src/HistoryPage.tsx @@ -1,6 +1,6 @@ // src/HistoryPage.tsx -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useEffectEvent, useRef, useState, type ReactNode } from 'react'; import './App.css'; import { fetchMyTransfers, type HistoryItem, type Page } from './api'; import { formatMoney } from './money'; @@ -216,9 +216,13 @@ export default function HistoryPage({ role, brand, identity, onNavigate }: Props the loader below is called from the effect and from the retry beside its own error box. */ const alive = useRef(true); + const loadFirstOnMount = useEffectEvent(() => { + void loadFirst(); + }); + useEffect(() => { alive.current = true; - void loadFirst(); + loadFirstOnMount(); return () => { alive.current = false; }; diff --git a/minibank-web/src/NewPaymentPage.tsx b/minibank-web/src/NewPaymentPage.tsx index 750a098..6372be5 100644 --- a/minibank-web/src/NewPaymentPage.tsx +++ b/minibank-web/src/NewPaymentPage.tsx @@ -1,6 +1,6 @@ // src/NewPaymentPage.tsx -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useEffectEvent, useRef, useState, type ReactNode } from 'react'; import './App.css'; import { getMyAccounts, @@ -190,11 +190,15 @@ export default function NewPaymentPage({ role, brand, identity, onNavigate }: Pr */ const quoteToken = useRef(0); + const loadInitialData = useEffectEvent(() => { + void loadAccounts(); + void loadBeneficiaries(); + }); + // Load accounts when the component is mounted useEffect(() => { alive.current = true; - void loadAccounts(); - void loadBeneficiaries(); + loadInitialData(); return () => { alive.current = false; }; diff --git a/minibank-web/src/WaitingAuthorizationsPage.tsx b/minibank-web/src/WaitingAuthorizationsPage.tsx index f570533..796cce0 100644 --- a/minibank-web/src/WaitingAuthorizationsPage.tsx +++ b/minibank-web/src/WaitingAuthorizationsPage.tsx @@ -1,5 +1,5 @@ // src/WaitingAuthorizationsPage.tsx -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useEffectEvent, useRef, useState, type ReactNode } from 'react'; import './App.css'; import { fetchWaitingTransfers, @@ -304,8 +304,12 @@ export function WaitingAuthorizationsPage({ role, brand, identity, onNavigate }: */ const [now, setNow] = useState(() => new Date()); - useEffect(() => { + const loadInitialList = useEffectEvent(() => { void loadList(); + }); + + useEffect(() => { + loadInitialList(); }, []); /* The deadline of the payment on screen, which is what the tick below is started for. */ diff --git a/src/test/java/cz/vsb/minibank/infrastructure/json/JsonStoreAtomicSaveTest.java b/src/test/java/cz/vsb/minibank/infrastructure/json/JsonStoreAtomicSaveTest.java index 02a67a1..1b1debf 100644 --- a/src/test/java/cz/vsb/minibank/infrastructure/json/JsonStoreAtomicSaveTest.java +++ b/src/test/java/cz/vsb/minibank/infrastructure/json/JsonStoreAtomicSaveTest.java @@ -89,11 +89,12 @@ void whatTheStoreHoldsAfterASaveIsAWholeDocument() throws Exception { @Test void aPublishThatCannotCompleteCleansUpAfterItself() throws Exception { Path store = tempDir.resolve("data.json"); - Files.createDirectories(store); JsonDataStore s = new JsonDataStore(store.toString()); s.load(); + Files.createDirectories(store); + assertThrows(Exception.class, s::save, "renaming a file over a directory must fail"); assertEquals(List.of("data.json"), namesIn(tempDir),