Compare commits

..

53 Commits

Author SHA1 Message Date
eneller
f708b2e84f feat(client): custom colors 2026-08-02 11:49:06 +02:00
eneller
0aea64383a refactor(client): rename screen 2026-08-02 10:47:31 +02:00
eneller
64fa5eb54d refactor: Modal in own component 2026-08-02 10:37:20 +02:00
eneller
b47cc5e90e doc: use cases 2026-07-30 17:31:15 +02:00
eneller
0619428e07 feat: account switcher 2026-07-23 20:19:53 +02:00
eneller
ed6c8cc413 feat(client): show real data 2026-07-23 18:49:44 +02:00
eneller
e8f8cfbe99 doc: README 2026-07-23 17:33:45 +02:00
eneller
cb9728a90b feat: notifications 2026-07-23 16:29:03 +02:00
eneller
c282e275ef fix: delayed displaying of messages, transactions using signals 2026-07-23 15:17:29 +02:00
eneller
0f4474d29b feat(server): business owned by user data model 2026-07-23 14:28:20 +02:00
eneller
c876a45ef8 feat(client): auth interceptor 2026-07-23 13:02:58 +02:00
eneller
4f734fff30 feat (server): user scopes 2026-07-23 12:11:42 +02:00
eneller
a3158894c8 feat: hash passwords 2026-07-23 11:23:53 +02:00
eneller
ceab2dee0a refactor: messaging 2026-06-26 13:05:10 +02:00
eneller
37fdc28e1f fix: auth guard 2026-06-26 11:50:15 +02:00
eneller
38dedfc829 fix: nav 2026-04-03 08:47:39 +02:00
eneller
4f8da032b7 feat: frontend send 2026-03-18 15:30:39 +01:00
eneller
7f3326c4a5 fix: send api 2026-03-18 10:27:26 +01:00
eneller
823d696d86 feat: api send 2026-03-18 09:18:27 +01:00
eneller
c6629234e1 feat: auth middleware 2026-03-17 00:37:56 +01:00
eneller
8be35e9403 feat: jwt auth 2026-03-16 23:45:09 +01:00
eneller
fa2203927a fix: save and send cookie in dev setup 2026-03-12 07:46:25 +01:00
eneller
c9a2cd8d66 auth guard 2026-03-08 19:27:18 +01:00
eneller
cd568f0a63 begin auth cookie 2026-03-08 19:02:08 +01:00
eneller
f5ae9ac9e6 basic login/logout 2026-03-07 14:48:19 +01:00
eneller
111d1a7b48 auth frontend 2026-03-07 13:53:46 +01:00
eneller
3300622191 begin auth 2026-03-07 00:43:40 +01:00
eneller
80a260f515 fix: currency display 2026-03-06 19:53:04 +01:00
eneller
308225f190 fix: transactions display 2026-03-06 19:34:38 +01:00
eneller
a618ce5e1e feat: db 2026-03-06 19:22:29 +01:00
eneller
0dabdaa136 database models 2026-03-06 11:16:22 +01:00
eneller
d58b0babd2 begin sequelize 2026-03-06 10:24:47 +01:00
eneller
fb0fc59240 feat: logging 2026-03-06 01:41:49 +01:00
eneller
599ed86c52 feat: basic db connection 2026-03-06 00:57:19 +01:00
eneller
4f206d9ad9 build: tsconfig shared/ 2026-03-05 23:39:42 +01:00
eneller
0dd4b6590d feat: basic api 2026-03-05 23:31:56 +01:00
eneller
cef3474c3d feat: profile screen 2026-03-05 22:34:50 +01:00
eneller
427064a24c feat: receive screen qr code 2026-03-05 22:19:58 +01:00
eneller
9444145bf5 feat: receive screen 2026-03-05 21:23:16 +01:00
eneller
6af1a8a3e9 feat: send screen 2026-03-05 21:08:31 +01:00
eneller
886903e402 feat: working nav 2026-03-05 20:09:15 +01:00
eneller
a6b9a5860c fix: ng build 2026-03-05 19:56:30 +01:00
eneller
a704c7a4e2 fix: dependencies 2026-02-28 13:30:08 +01:00
eneller
7d73d35aa4 client: begin frontend 2026-02-28 13:20:09 +01:00
eneller
75100e689c fix: server run config 2026-02-28 10:33:20 +01:00
eneller
6b7ef0114f doc: README 2026-02-28 09:41:17 +01:00
eneller
096af359f0 build: fix tsconfig 2026-02-28 02:27:48 +01:00
eneller
c4efd793e2 build: fix tsconfig 2026-02-28 01:35:01 +01:00
eneller
05090ebb14 build: fix tsconfig 2026-02-28 01:19:48 +01:00
eneller
e64a77a0d7 chore: add sequelize 2026-02-28 00:35:25 +01:00
eneller
9f6c9b569c chore: add ng-bootstrap+icons, qrcode 2026-02-28 00:31:16 +01:00
eneller
40b020455e chore: angular init 2026-02-28 00:26:00 +01:00
eneller
378366e5f5 chore: backend setup 2026-02-28 00:21:44 +01:00
91 changed files with 13484 additions and 42 deletions

2
.gitignore vendored
View File

@@ -192,4 +192,4 @@ dist
.ionide
# End of https://www.toptal.com/developers/gitignore/api/nextjs,node,visualstudiocode
.docker/

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# FakeMoney
**A PayPal-like payment processor for virtual money, intended to be used for [simulation games](https://de.wikipedia.org/wiki/Schule_als_Staat).**
Send and receive money from your account and businesses you own using simple URLs and QR codes.
Install as a [PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)
from your browser (Firefox/Chrome/Safari) with just 3 taps and bypass tedious app store processes.
<img src="docs/img/screenshot-login.png" width="32%" alt="Login view">&nbsp;
<img src="docs/img/screenshot-send.png" width="32%" alt="Send view">&nbsp;
<img src="docs/img/screenshot-receive.png" width="32%" alt="Receive view">&nbsp;
## Deployment
Is simplified using Docker and by making a few assumptions, e.g. that API and frontend are available under the same domain.
## Development
### Frontend
Is written in angular + bootstrap.
### Backend
Uses NextJS + PostgreSQL with JWT as cookies.

17
client/.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

44
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

12
client/.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

4
client/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
client/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

9
client/.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

42
client/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

59
client/README.md Normal file
View File

@@ -0,0 +1,59 @@
# Client
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.0.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

88
client/angular.json Normal file
View File

@@ -0,0 +1,88 @@
{
"$schema": "../node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "less"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "../dist/out-tsc/client",
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "less",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"../node_modules/bootstrap/dist/css/bootstrap.min.css",
"../node_modules/bootstrap-icons/font/bootstrap-icons.min.css",
"src/styles.less"
],
"scripts": [
"../node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
],
"polyfills": ["@angular/localize/init"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"options": {
"proxyConfig": "proxy.json"
},
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "client:build:production"
},
"development": {
"buildTarget": "client:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

View File

@@ -1,12 +1,43 @@
{
"name": "client",
"version": "1.0.0",
"description": "",
"license": "GPL-3.0",
"author": "",
"type": "commonjs",
"main": "index.js",
"version": "0.0.0",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"ng": "ng",
"start": "ng serve",
"serve": "ng serve --host 0.0.0.0",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.10.0",
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"ng-qrcode": "^21.0.0",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.0",
"@angular/cli": "^21.2.0",
"@angular/compiler-cli": "^21.2.0",
"@angular/localize": "^21.2.0",
"@types/qrcode": "^1.5.6",
"jsdom": "^28.0.0",
"less": "^4.2.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}

8
client/proxy.json Normal file
View File

@@ -0,0 +1,8 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true,
"withCredentials": true
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,17 @@
import { ApplicationConfig, DEFAULT_CURRENCY_CODE, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { DATE_PIPE_DEFAULT_OPTIONS } from '@angular/common';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './services/auth-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
{provide: DEFAULT_CURRENCY_CODE, useValue: ''},
{provide: DATE_PIPE_DEFAULT_OPTIONS, useValue: {dateFormat: 'shortDate'}},
provideHttpClient(withInterceptors([authInterceptor])),
]
};

17
client/src/app/app.html Normal file
View File

@@ -0,0 +1,17 @@
<main class="main">
<app-toast-container></app-toast-container>
<router-outlet></router-outlet>
</main>
<nav>
<ul ngbNav [(activeId)]="active" class="nav-tabs custom-navbar bg-body-secondary">
<li ngbNavItem="/send" class="custom-navitem"><a ngbNavLink routerLink="/send">
<i class="bi bi-cash"></i>
</a></li>
<li ngbNavItem="/receive" class="custom-navitem"><a ngbNavLink routerLink="/receive">
<i class="bi bi-piggy-bank"></i>
</a></li>
<li ngbNavItem="/profile" class="custom-navitem"><a ngbNavLink routerLink="/profile">
<i class="bi bi-person"></i>
</a></li>
</ul>
</nav>

35
client/src/app/app.less Normal file
View File

@@ -0,0 +1,35 @@
html, body{
max-width: 100%;
overflow-x: hidden;
}
*{
text-align: center;
}
.custom-table {
//table-layout: fixed;
max-width: 100%;
}
tr{
padding-right: .2em;
}
.wrap-cell{
word-break: break-all;
white-space: normal;
}
.main{
margin-bottom: 5rem;
padding: 1rem;
overflow-y: auto;
}
.custom-navbar{
width: 100vw;
position: fixed;
bottom: 0;
display: flex;
justify-content: space-around; /* Distribute items evenly */
z-index: 1000;
}
.custom-navitem{
text-align: center; /* Center the content of each item */
flex: 1;
}

View File

@@ -0,0 +1,33 @@
import { Routes } from '@angular/router';
import { ScreenSend } from './screens/screen-send/screen-send';
import { ScreenRequest } from './screens/screen-request/screen-request';
import { ScreenProfile } from './screens/screen-profile/screen-profile';
import { ScreenLogin } from './screens/screen-login/screen-login';
import { authGuard } from './services/auth-guard';
export const routes: Routes = [
{
path: '',
pathMatch:'full',
redirectTo: '/send',
},
{
path: 'login',
component: ScreenLogin,
},
{
path:'send',
component: ScreenSend,
canActivate: [authGuard],
},
{
path:'receive',
component: ScreenRequest,
canActivate: [authGuard],
},
{
path:'profile',
component: ScreenProfile,
canActivate: [authGuard],
},
];

View File

@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, client');
});
});

39
client/src/app/app.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { RouterOutlet, RouterLinkWithHref, Router, NavigationEnd } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import {
NgbNav,
NgbNavItem,
NgbNavItemRole,
NgbNavLinkBase,
} from '@ng-bootstrap/ng-bootstrap/nav';
import { filter } from 'rxjs';
import { APIService } from './services/api';
import { ToastContainer } from "./components/toast-container/toast-container";
@Component({
selector: 'app-root',
imports: [RouterOutlet, NgbModule, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLinkBase, RouterLinkWithHref, ToastContainer],
templateUrl: './app.html',
styleUrl: './app.less'
})
export class App implements OnInit{
protected readonly title = signal('client');
protected api = inject(APIService);
active = '/';
constructor(
private router: Router
){}
ngOnInit(): void {
this.api.checkAuthStatus().subscribe();
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() =>{
this.active = this.router.url;
console.log(this.active);
});
}
}

View File

@@ -0,0 +1,7 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{ title }}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="activeModal.dismiss()"></button>
</div>
<div class="modal-body text-center">
<ng-container *ngTemplateOutlet="body"></ng-container>
</div>

View File

@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Modal } from './modal';
describe('Modal', () => {
let component: Modal;
let fixture: ComponentFixture<Modal>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Modal],
}).compileComponents();
fixture = TestBed.createComponent(Modal);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,19 @@
import { NgTemplateOutlet } from '@angular/common';
import { Component, Input, TemplateRef } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
@Component({
selector: 'app-modal',
imports: [NgTemplateOutlet],
templateUrl: './modal.html',
styleUrl: './modal.less',
})
export class Modal {
@Input({required: true}) title!: string;
@Input({required: true}) body!: TemplateRef<any>;
constructor(
public activeModal: NgbActiveModal
){}
}

View File

@@ -0,0 +1,13 @@
<div class="toast-container position-fixed top-0 end-0 p-3">
@for (notification of notifications.notifications(); track notification) {
<ngb-toast
[autohide]="true"
[delay]="notification.delay ?? 3000"
(hidden)="notifications.remove(notification)"
>
<div class="text-{{ notification.type }}">
{{ notification.message }}
</div>
</ngb-toast>
}
</div>

View File

@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ToastContainer } from './toast-container';
describe('ToastContainer', () => {
let component: ToastContainer;
let fixture: ComponentFixture<ToastContainer>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToastContainer],
}).compileComponents();
fixture = TestBed.createComponent(ToastContainer);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,13 @@
import { Component, inject } from '@angular/core';
import { NgbToastModule } from '@ng-bootstrap/ng-bootstrap';
import { NotificationService } from '../../services/notification';
@Component({
selector: 'app-toast-container',
imports: [NgbToastModule],
templateUrl: './toast-container.html',
styleUrl: './toast-container.less',
})
export class ToastContainer {
notifications = inject(NotificationService);
}

View File

@@ -0,0 +1,77 @@
<div class="d-flex align-items-center min-vh-100 bg-light">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-4 col-md-6 col-sm-8">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="text-center mb-4">
<i class="bi bi-lock-fill fs-1 text-primary"></i>
<h3 class="mt-2">Sign In</h3>
</div>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" novalidate>
<!-- Email -->
<div class="mb-3">
<label for="email" class="form-label">Username</label>
<input
type="text"
id="username"
class="form-control"
formControlName="username"
placeholder="Enter your username"
>
</div>
<!-- Password -->
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<input
[type]="showPassword ? 'text' : 'password'"
id="password"
class="form-control"
formControlName="password"
placeholder="Enter your password"
>
<button
class="btn btn-outline-secondary"
type="button"
(click)="showPassword = !showPassword"
>
<i class="bi" [class.bi-eye-fill]="showPassword" [class.bi-eye-slash-fill]="!showPassword"></i>
</button>
</div>
</div>
<!-- Submit Button -->
<button
type="submit"
class="btn btn-primary w-100 mb-3"
>
@if (loading()) {
<span> Signing In... </span>
}@else {
<span>Sign In</span>
}
</button>
<!-- Error Alert -->
@if (error()) {
<ngb-alert
type="danger"
(closed)="error.set(null)"
[dismissible]="true"
>
{{ error() }}
</ngb-alert>
}
</form>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenLogin } from './screen-login';
describe('ScreenLogin', () => {
let component: ScreenLogin;
let fixture: ComponentFixture<ScreenLogin>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScreenLogin],
}).compileComponents();
fixture = TestBed.createComponent(ScreenLogin);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,54 @@
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
import { Validators, FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Form } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { APIService } from '../../services/api';
import { GenericMessage } from '@message/Message';
import { NotificationService } from '../../services/notification';
@Component({
selector: 'app-screen-login',
imports: [FormsModule, NgbModule, ReactiveFormsModule, CommonModule],
templateUrl: './screen-login.html',
styleUrl: './screen-login.less',
})
export class ScreenLogin {
loginForm: FormGroup;
submitted = false;
loading = signal(false);
showPassword = false;
error = signal<string|null>(null);
constructor(
private api: APIService,
private notify: NotificationService,
private router: Router,
private route: ActivatedRoute,
private fb: FormBuilder,
) {
this.loginForm = this.fb.group({
username: ['', [Validators.required]],
password: ['', [Validators.required]],
});
}
onSubmit() {
this.submitted = true;
this.error.set(null);
this.loading.set(true)
this.api.login(this.loginForm.value.username, this.loginForm.value.password).subscribe({
next: () => {
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
this.router.navigateByUrl(returnUrl);
},
error: (resp) => {
let msg: GenericMessage = resp.error;
this.notify.error(msg.message || 'Login failed. Please try again.');
this.loading.set(false);
}
});
}
}

View File

@@ -0,0 +1,71 @@
<div class="container py-4">
<!-- Profile Header -->
<div class="card mb-4 shadow-sm">
<div class="card-body text-center p-4">
<div class="avatar avatar-xl mb-3">
<i class="bi bi-person-circle fs-1 text-primary"></i>
<b> {{this.api.loggedInUser.displayName}} </b>
</div>
<div ngbDropdown class="d-inline-block">
<button type="button" class="btn btn-outline-primary" id="dropdownBasic1" ngbDropdownToggle>
{{ this.api.currentUser.displayName }}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
@for (acc of this.api.ownedAccounts; track $index) {
<button ngbDropdownItem (click)="this.api.currentUser = acc">{{ acc.displayName }}</button>
}
</div>
</div>
<h3 class="mb-1"></h3>
</div>
<div class="d-flex align-items-center justify-content-center gap-2 mb-1">
<i class="bi bi-wallet2 fs-4"></i>
<h3 class="mb-0">{{ this.api.currentUser.balance | currency}}</h3>
</div>
<button type="button" (click)="logOut()" class="btn btn-outline-secondary">Log out</button>
</div>
</div>
<!-- Recent Transactions -->
<div class="card mb-4 shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Recent Transactions</h5>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
<!-- Transaction Item -->
@for (transaction of transactions(); track $index) {
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="me-3">
<i class="bi bi-person-fill fs-4 text-secondary"></i>
</div>
<div class="text-start">
@if (transaction.receiverID == this.api.currentUser.id) {
<h6 class="mb-0">{{ transaction.senderID }}</h6>
}@else {
<h6 class="mb-0">{{ transaction.receiverID }}</h6>
}
<small class="text-muted">{{ transaction.date | date:'medium' }}</small>
</div>
</div>
<div class="text-end">
@if (transaction.receiverID == this.api.currentUser.id) {
<h6 class="mb-0 text-success">
{{ transaction.amount | currency }}
</h6>
}@else {
<h6 class="mb-0 text-danger">
{{ - transaction.amount | currency }}
</h6>
}
</div>
</div>
</div>
}
@empty {<p class="text-muted">No transactions yet</p>}
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenProfile } from './screen-profile';
describe('ScreenProfile', () => {
let component: ScreenProfile;
let fixture: ComponentFixture<ScreenProfile>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScreenProfile],
}).compileComponents();
fixture = TestBed.createComponent(ScreenProfile);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,43 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, inject, OnInit, signal } from '@angular/core';
import { APIService } from '../../services/api';
import Transaction from '@model/transaction';
import { Router } from '@angular/router';
import { NgbAccordionToggle, NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle } from "@ng-bootstrap/ng-bootstrap";
@Component({
selector: 'app-screen-profile',
imports: [CurrencyPipe, DatePipe, CommonModule, NgbDropdown, NgbDropdownMenu, NgbDropdownToggle, NgbDropdownItem],
templateUrl: './screen-profile.html',
styleUrl: './screen-profile.less',
})
export class ScreenProfile implements OnInit{
transactions = signal<Transaction[]>([])
constructor(
protected api: APIService,
private router: Router,
){}
ngOnInit(): void {
this.api.getTransactions().subscribe({
next: (transactions) => {
this.transactions.set(transactions);
},
error: (err) => {
console.error('Error fetching transactions:', err);
},
})
}
logOut(){
this.api.logout().subscribe({
next: () => {
this.router.navigate(['/'])
},
error: (err) => {
console.error('Error logging out:', err)
}
})
}
}

View File

@@ -0,0 +1,57 @@
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-success text-white">
<h3 class="mb-0">Receive Money</h3>
</div>
<div class="card-body p-4 text-center">
<!-- Large Amount Display -->
<div class="mb-4">
<label class="form-label h5">Amount to Receive</label>
<div class="input-group input-group-lg mb-3">
<span class="input-group-text"><i class="bi bi-wallet2"></i></span>
<input
type="number"
class="form-control text-center fs-2"
placeholder="0.00"
[(ngModel)]="amount"
/>
</div>
</div>
<!-- Shareable Link -->
<div class="mb-4">
<p class="text-muted">Share this link to receive money:</p>
<div class="input-group mb-3">
<input
type="text"
class="form-control"
[value]="shareableLink"
readonly
/>
<button class="btn btn-outline-secondary" (click)="copyLink()">
Copy
</button>
</div>
</div>
<!-- Share Button -->
<ng-template #qrTemplate>
<qr-code [value]="shareableLink"
size="300"
errorCorrectionLevel="M" />
</ng-template>
<div class="d-grid gap-2">
<button class="btn btn-primary btn-lg" (click)="openModal()">
<i class="bi bi-qr-code-scan me-2"></i> Show QR Code
</button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenRequest } from './screen-request';
describe('ScreenReceive', () => {
let component: ScreenRequest;
let fixture: ComponentFixture<ScreenRequest>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScreenRequest],
}).compileComponents();
fixture = TestBed.createComponent(ScreenRequest);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,33 @@
import { Component, inject, TemplateRef, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { QrCodeComponent } from 'ng-qrcode';
import { APIService } from '../../services/api';
import { Modal } from '../../components/modal/modal';
@Component({
selector: 'app-screen-request',
imports: [FormsModule, QrCodeComponent],
templateUrl: './screen-request.html',
styleUrl: './screen-request.less',
})
export class ScreenRequest {
private modalService = inject(NgbModal);
api = inject(APIService);
@ViewChild('qrTemplate') qrTemplate !: TemplateRef<any>;
amount: number = 0;
get shareableLink(): string {
const currentDomain = window.location.origin;
return `${currentDomain}/send/${this.api.currentUser.id}?amount=${this.amount}`;
}
copyLink() {
navigator.clipboard.writeText(this.shareableLink);
}
openModal() {
const modalRef = this.modalService.open(Modal);
modalRef.componentInstance.title = `Pay ${this.amount} to ${this.api.currentUser.id}`
modalRef.componentInstance.body = this.qrTemplate;
}
}

View File

@@ -0,0 +1,62 @@
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">Send Money</h3>
</div>
<div class="card-body p-4">
<!-- Amount Input (Centered Large Field) -->
<div class="text-center mb-4">
<label class="form-label h5">Amount</label>
<div class="input-group input-group-lg mb-3">
<span class="input-group-text"><i class="bi bi-wallet2"></i></span>
<input
type="number"
class="form-control text-center fs-2"
placeholder="0.00"
[(ngModel)]="amount"
/>
</div>
</div>
<!-- Recipient Field -->
<div class="mb-3">
<label class="form-label">To</label>
<div class="input-group">
<span class="input-group-text">@</span>
<input
type="text"
class="form-control"
placeholder="username"
[(ngModel)]="recipient"
/>
<button (click)="openScanner()" class="btn btn-primary"><i class="bi bi-qr-code-scan"></i></button>
</div>
</div>
<!-- Note Field -->
<div class="mb-4">
<label class="form-label">Note (Optional)</label>
<textarea class="form-control" rows="2" [(ngModel)]="reference"></textarea>
</div>
<!-- Action Buttons -->
<div class="d-grid gap-2">
<button class="btn btn-primary btn-lg" (click)="sendMoney()">
Send Money
</button>
<button class="btn btn-outline-secondary btn-lg" (click)="clear()">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<ng-template #qrScanner>
Abc
</ng-template>

View File

@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenSend } from './screen-send';
describe('ScreenSend', () => {
let component: ScreenSend;
let fixture: ComponentFixture<ScreenSend>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScreenSend],
}).compileComponents();
fixture = TestBed.createComponent(ScreenSend);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,59 @@
import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { APIService } from '../../services/api';
import { NotificationService } from '../../services/notification';
import QrScanner from 'qr-scanner';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Modal } from '../../components/modal/modal';
@Component({
selector: 'app-screen-send',
imports: [FormsModule],
templateUrl: './screen-send.html',
styleUrl: './screen-send.less',
})
export class ScreenSend {
amount: number = 0;
recipient: string = '';
reference: string = '';
scanner!: QrScanner;
@ViewChild('qrScanner') templScanner!: TemplateRef<any>;
constructor(
private api: APIService,
private notify: NotificationService,
private modalService: NgbModal,
){}
sendMoney() {
this.api.send(this.amount, this.recipient, this.reference).subscribe({
next:()=> {
this.notify.success(`Sent ${this.amount} to ${this.recipient}`);
this.clear()
},
error:(err)=> {
if(err.status == 404){
this.notify.error(`Invalid recipient "${this.recipient}"`);
}
else if(err.status == 402){
this.notify.error(`Insufficient funds.`);
}
else{
this.notify.error(`An error occurred during payment: ${err.status}`);
}
}
});
}
clear() {
this.amount = 0;
this.recipient = '';
this.reference = '';
}
openScanner(){
const modalRef = this.modalService.open(Modal);
modalRef.componentInstance.title = 'Scan a QR Code';
modalRef.componentInstance.body = this.templScanner;
}
closeScanner(){}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { APIService } from './api';
describe('Api', () => {
let service: APIService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(APIService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,66 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { BehaviorSubject, catchError, map, Observable, of, tap } from 'rxjs';
import Transaction from '@model/transaction'
import { SendRequest, SendResponse } from '@message/Send';
import { LoginResponse } from '@message/Login';
import Account from '@model/user';
@Injectable({
providedIn: 'root',
})
export class APIService {
private apiUrl = '/api';
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
// data holding
loggedInUser!: Account;
currentUser!: Account;
ownedAccounts!: Account[];
constructor(private http: HttpClient){}
login(username: string, password: string): Observable<any>{
return this.http.post<LoginResponse>(`${this.apiUrl}/auth/login`,{ 'username': username, 'password': password}).pipe(
tap({
next: (resp) => {
this.isAuthenticatedSubject.next(true);
this.loggedInUser = resp.user;
this.currentUser = this.loggedInUser;
this.ownedAccounts = resp.ownedAccounts;
this.ownedAccounts.push(this.loggedInUser);
},
error: () => this.isAuthenticatedSubject.next(false)
})
);
}
logout(): Observable<any>{
return this.http.post(`${this.apiUrl}/auth/logout`, {}).pipe(
tap({
next: () => this.isAuthenticatedSubject.next(false),
error: () => this.isAuthenticatedSubject.next(false),
})
);
}
clearAuthState() {
this.isAuthenticatedSubject.next(false);
}
checkAuthStatus(): Observable<boolean> {
return this.http.get(`${this.apiUrl}/auth/status`).pipe(
map(() => true),
catchError(() => of(false)),
tap( authenticated => {
this.isAuthenticatedSubject.next(authenticated);
})
);
}
getTransactions(): Observable<Transaction[]>{
return this.http.get<Transaction[]>(`${this.apiUrl}/transactions`);
}
send(amount: number, recipientID: string, reference: string = ""): Observable<SendResponse>{
let request: SendRequest = {senderID: this.currentUser.id, amount, recipientID, reference};
return this.http.post<SendResponse>(`${this.apiUrl}/send`, request);
}
}

View File

@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { authGuard } from './auth-guard';
describe('authGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeGuard).toBeTruthy();
});
});

View File

@@ -0,0 +1,21 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { APIService } from './api';
import { map, take } from 'rxjs/operators';
export const authGuard: CanActivateFn = (route, state) => {
const api = inject(APIService);
const router = inject(Router);
return api.isAuthenticated$.pipe(
take(1),
map((isAuthenticated) => {
if (isAuthenticated) {
return true;
} else {
router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
return false;
}
})
);
};

View File

@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { HttpInterceptorFn } from '@angular/common/http';
import { authInterceptor } from './auth-interceptor';
describe('authInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => authInterceptor(req, next));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});

View File

@@ -0,0 +1,19 @@
import { HttpErrorResponse, HttpEventType, HttpInterceptorFn } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
import { APIService } from './api';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const api = inject(APIService);
const router = inject(Router)
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401){
api.clearAuthState();
router.createUrlTree(['/login']);
}
return throwError(() => err);
})
)
};

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { DataService } from './data';
describe('Data', () => {
let service: DataService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(DataService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,6 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class DataService {}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { NotificationService } from './notification';
describe('Notification', () => {
let service: NotificationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(NotificationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,41 @@
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class NotificationService {
notifications = signal<Notification[]>([]);
show(notification: Notification) {
this.notifications.update(items => [
...items,
notification,
]);
}
remove(notification: Notification) {
this.notifications.update(items =>
items.filter(item => item !== notification)
);
}
success(message: string) {
this.show({ type: 'success', message});
}
error(message: string) {
this.show({ type: 'danger', message});
}
warn(message: string) {
this.show({ type: 'warning', message});
}
info(message: string) {
this.show({ type: 'info', message});
}
}
export interface Notification {
type: 'success' | 'warning' | 'info' | 'danger';
message: string;
delay?: number;
}

13
client/src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Client</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

7
client/src/main.ts Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="@angular/localize" />
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));

67
client/src/styles.less Normal file
View File

@@ -0,0 +1,67 @@
:root {
--bs-primary: #2f745f;
--bs-primary-rgb: 47, 116, 95;
--bs-primary-bg-subtle: #9dbbb2;
--bs-primary-border-subtle: #9dbbb2;
--bs-primary-text-emphasis: #114334;
--bs-link-color: #2f745f;
--bs-link-color-rgb: 47, 116, 95;
--bs-link-hover-color: #114334;
--bs-success: #114334;
--bs-success-rgb: 17, 67, 52;
--bs-success-bg-subtle: #9dbbb2;
--bs-success-border-subtle: #9dbbb2;
--bs-success-text-emphasis: #114334;
}
.btn-primary {
--bs-btn-bg: #2f745f;
--bs-btn-border-color: #2f745f;
--bs-btn-hover-bg: #114334;
--bs-btn-hover-border-color: #114334;
--bs-btn-focus-shadow-rgb: 47, 116, 95;
--bs-btn-active-bg: #114334;
--bs-btn-active-border-color: #114334;
--bs-btn-disabled-bg: #2f745f;
--bs-btn-disabled-border-color: #2f745f;
}
.btn-outline-primary {
--bs-btn-color: #2f745f;
--bs-btn-border-color: #2f745f;
--bs-btn-hover-bg: #2f745f;
--bs-btn-hover-border-color: #2f745f;
--bs-btn-focus-shadow-rgb: 47, 116, 95;
--bs-btn-active-bg: #114334;
--bs-btn-active-border-color: #114334;
--bs-btn-disabled-color: #2f745f;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #2f745f;
}
.btn-success {
--bs-btn-bg: #114334;
--bs-btn-border-color: #114334;
--bs-btn-hover-bg: #0e392c;
--bs-btn-hover-border-color: #0e392c;
--bs-btn-focus-shadow-rgb: 17, 67, 52;
--bs-btn-active-bg: #0e392c;
--bs-btn-active-border-color: #0e392c;
--bs-btn-disabled-bg: #114334;
--bs-btn-disabled-border-color: #114334;
}
.nav-pills .nav-link.active {
--bs-nav-pills-link-active-bg: #2f745f;
}
.dropdown-menu {
--bs-dropdown-link-active-bg: #2f745f;
}
.form-control:focus,
.form-select:focus {
border-color: #9dbbb2;
box-shadow: 0 0 0 0.25rem rgba(47, 116, 95, 0.25);
}

11
client/tsconfig.app.json Normal file
View File

@@ -0,0 +1,11 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc/client",
"types": ["@angular/localize"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
}

31
client/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "../tsconfig.json",
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"importHelpers": true,
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

10
client/tsconfig.spec.json Normal file
View File

@@ -0,0 +1,10 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc/spec",
"types": ["vitest/globals", "@angular/localize"]
},
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
}

19
docs/design/use-cases.md Normal file
View File

@@ -0,0 +1,19 @@
# Use Cases
## Pay someone
The client pays a business or peer by scanning their QR code or clicking their URL.
They are then shown the payment (send) screen that already contains the prefilled recipient and currency amount.
The client then confirms payment with a button and optional confirmation dialog.
## Request payment
The business requests payment from a client by sharing their URL / QR code.
The client proceeds with the `Pay someone` flow.
##
## Visitor
Visitors obtain access by receiving credentials of a predefined visitor account.
Upon login, they participate freely in the payment process as described by `Pay someone` and `Request payment`.
## Onboarding users
An administrator performs onboarding using a batch import feature that makes use of `.csv` or a similarly simple format.

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

11359
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,22 @@
"license": "GPL-3.0",
"author": "",
"type": "commonjs",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"server": "npm run dev --workspace=server",
"client": "npm start --prefix client",
"dev": "concurrently \"npm run server\" \"npm run client\"",
"teardown": "npm run teardown --workspace=server"
},
"workspaces": [
"client",
"server",
"shared"
]
"server"
],
"devDependencies": {
"@types/node": "^25.3.2",
"concurrently": "^9.2.1",
"nodemon": "^3.1.14",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}

7
server/.env.example Normal file
View File

@@ -0,0 +1,7 @@
FM_PRIVATE_KEY='{"kty":"oct","k":"WoX65SSouJ4c8l_OxwWspo9H5dhPBq9sW0lsOJB6Ygc"}'
NODE_ENV='dev'
FM_DB_HOST
FM_DB_PORT
FM_DB_NAME
FM_DB_USER
FM_DB_PASSWORD

19
server/compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
postgres:
image: postgres
environment:
POSTGRES_PASSWORD: pass
ports:
- "5432:5432"
volumes:
- ./.docker:/var/lib/postgresql
adminer:
image: adminer
restart: always
environment:
ADMINER_DEFAULT_SERVER: postgres
ports:
- "8080:8080"
depends_on:
- postgres

6
server/nodemon.json Normal file
View File

@@ -0,0 +1,6 @@
{
"watch": ["src"],
"ext": "ts,json",
"exec": "ts-node src/index.ts"
}

View File

@@ -5,9 +5,48 @@
"license": "GPL-3.0",
"author": "",
"type": "commonjs",
"main": "index.js",
"main": "../dist/out-tsc/server/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"node": "nodemon",
"start": "ts-node src/index.ts",
"build": "tsc",
"serve": "node ../dist/out-tsc/server/index.js",
"setup": "docker compose up -d",
"teardown": "docker compose down",
"keygen": "ts-node src/scripts/keygen.ts",
"hash": "ts-node src/scripts/hash.ts",
"verify-hash": "ts-node src/scripts/verify-hash.ts",
"create-user": "ts-node src/scripts/create-user.ts",
"dev": "npm run setup && npm run node; npm run teardown"
},
"devDependencies": {}
"dependencies": {
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"jose": "^6.2.0",
"pg": "^8.20.0",
"pg-hstore": "^2.3.4",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.7",
"sequelize-typescript": "^2.1.6",
"winston": "^3.19.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.3.5",
"@types/pg": "^8.16.0",
"@types/validator": "^13.15.10",
"concurrently": "^9.2.1",
"eslint": "^10.0.2",
"nodemon": "^3.1.14",
"prettier": "^3.8.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}

43
server/src/index.ts Normal file
View File

@@ -0,0 +1,43 @@
import express, { Express, Request, Response } from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import transactionsRouter from './routes/transactions';
import authRouter from './routes/auth';
import sendRouter from './routes/send';
import dotenv from 'dotenv';
dotenv.config({quiet: true});
import { db, testConnection } from "./util/db";
import { logger } from "./util/logging";
import { setKeyFromEnv } from "./util/auth";
const app: Express = express();
app.use(cors());
app.use(cookieParser());
app.use(express.json());
app.get("/api/health", (req: Request, res: Response) => {
res.json({ status: "OK" });
});
app.use('/api/auth', authRouter);
app.use('/api/transactions', transactionsRouter);
app.use('/api/send', sendRouter);
const PORT: number = parseInt(process.env.FM_PORT as string) || 3000;
async function startServer() {
await testConnection();
// Sync models (use migrations in production!)
// Use { force: true } to drop and recreate tables (development only!)
await db.sync({ alter: true });
await setKeyFromEnv();
app.listen(PORT, () => {
logger.info(`🚀 Backend Server running on http://localhost:${PORT}`);
});
}
startServer().catch((err) => {
logger.error('Failed to start server:', err);
process.exit(1);
});

View File

@@ -0,0 +1,13 @@
import Account from "../model/user";
export class LoginRequest{
constructor(
public username: string,
public password: string
){}
}
export class LoginResponse{
constructor(
public user: Account,
public ownedAccounts: Account[],
){}
}

View File

@@ -0,0 +1,5 @@
export class GenericMessage{
constructor(
public message: string
){}
}

View File

@@ -0,0 +1,14 @@
export class SendRequest{
constructor(
public senderID: string,
public recipientID: string,
public amount: number,
public reference: string
){}
}
export class SendResponse{
constructor(
public balance: number,
){}
}

View File

@@ -0,0 +1,15 @@
import Transaction from "../model/transaction";
export class TransactionsRequest{
constructor(
public forId: string,
public offset = 0,
public count = 50,
){}
}
export class TransactionsResponse{
constructor(
public transactions: Transaction[],
){}
}

View File

@@ -0,0 +1,11 @@
import Account from "../model/user";
export class UserRequest{
constructor(
){}
}
export class UserResponse{
constructor(
public user: Account
){}
}

View File

@@ -0,0 +1,28 @@
import { Table, Column, Model, CreatedAt, ForeignKey, BelongsTo} from 'sequelize-typescript';
import Account from './user';
@Table
export default class Transaction extends Model{
@Column
declare amount: number;
@Column
declare reference: string;
@Column
@ForeignKey(()=> Account)
declare senderID: string;
@BelongsTo(() => Account, 'senderID')
declare sender: Account;
@Column
@ForeignKey(()=> Account)
declare receiverID: string;
@BelongsTo(() => Account, 'receiverID')
declare receiver: Account;
@CreatedAt
declare date: Date;
}

65
server/src/model/user.ts Normal file
View File

@@ -0,0 +1,65 @@
import { Table, Column, Model, DataType, Scopes, DefaultScope, DeletedAt, BelongsToMany, ForeignKey} from 'sequelize-typescript';
@Table
@DefaultScope(() => ({
attributes:{ exclude: ['password']}
}))
@Scopes(() => ({
withPassword: {
attributes: {include: ['password']}
}
}))
export default class Account extends Model{
@Column({primaryKey: true, unique: true, allowNull: false})
declare id: string;
@Column
declare isBusiness: boolean;
@Column
declare displayName: string;
@Column(DataType.DECIMAL(20,2))
declare balance: number;
@Column
declare password: string;
@DeletedAt
declare deletedAt: Date | null;
@BelongsToMany(() => Account, () => BusinessOwnership, 'ownerAccountId', 'ownedAccountId')
declare ownedBusinesses: Account[];
@BelongsToMany(() => Account, () => BusinessOwnership, 'ownedAccountId', 'ownerAccountId')
declare owners: Account[];
override toJSON() {
const values = { ...this.get() };
delete values.password;
return values;
}
}
@Table
export class BusinessOwnership extends Model {
@ForeignKey(() => Account)
@Column
declare ownerAccountId: string;
@ForeignKey(() => Account)
@Column
declare ownedAccountId: string;
@DeletedAt
declare deletedAt: Date | null;
}
export async function getOwnedAccounts(user: Account){
let q = await Account.findByPk(user.id, {
include: ['ownedBusinesses'],
});
let ownedAccounts = q?.ownedBusinesses;
return ownedAccounts ?? [];
}

44
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,44 @@
import { compare } from 'bcrypt';
import express from 'express';
import { logger } from '../util/logging';
import Account, { getOwnedAccounts } from '../model/user';
import { Scope } from '../util/db';
import { getJWT, requireAuth } from '../util/auth';
import { LoginRequest, LoginResponse } from '../messages/Login';
import { GenericMessage as Msg } from '../messages/Message';
const router = express.Router();
router.post('/login', async (req, res) => {
try {
const data : LoginRequest = req.body;
const user = await Account.scope(Scope.withPassword).findOne({where: { id: data.username}});
if (!user) return res.status(401).json(new Msg('Invalid credentials'));
const isMatch = await compare(data.password, user.password);
if (!isMatch) return res.status(401).json(new Msg('Invalid credentials'));
// successfully authenticated
let jwt = await getJWT(user);
res.cookie('jwt', jwt, {
httpOnly: true, // Prevent XSS
secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 86400000, // 1 day
});
res.json(new LoginResponse(user, await getOwnedAccounts(user)));
}catch (err) {
logger.error('Failed to authenticate:', err);
res.status(500).json(new Msg('Failed to authenticate'));
}
});
router.post('/logout', (req, res) => {
res.clearCookie('jwt');
res.json({ message: 'Logged out successfully' });
});
router.get('/status',requireAuth , async (req, res) => {
return res.status(200).json({authenticated: true});
})
export default router;

56
server/src/routes/send.ts Normal file
View File

@@ -0,0 +1,56 @@
import express from 'express';
import { logger } from '../util/logging';
import { requireAuth } from '../util/auth';
import Account, { BusinessOwnership, getOwnedAccounts } from '../model/user';
import { db } from '../util/db';
import Transaction from '../model/transaction';
import { SendRequest, SendResponse} from '../messages/Send';
import { GenericMessage as Err} from '../messages/Message';
const router = express.Router();
router.post('/', requireAuth, async (req, res) => {
try {
const user = res.locals.user as Account;
const data : SendRequest = req.body;
if ( Number(data.amount) <= 0) {
return res.status(400).json(new Err('Invalid transfer amount'));
}
const recipient = await Account.findOne({where: {id: data.recipientID}})
if (!recipient) {
return res.status(404).json(new Err('Recipient not found' ));
}
const sender = await Account.findOne({where: {id: data.senderID}})
if (!sender) {
return res.status(404).json(new Err('Sender not found' ));
}
let ownsAccount = (await getOwnedAccounts(user))?.some(
business => business.id == data.senderID
);
if(!(user.equals(sender) || ownsAccount )){
return res.status(403).json(new Err('Unauthorized sender'));
}
if (Number(sender.balance) < Number(data.amount)){
logger.debug(`Insufficient balance: ${sender.balance} < ${data.amount}`)
return res.status(402).json(new Err('Insufficient balance'))
}
await db.transaction(async (t) =>{
await sender.decrement({balance: data.amount});
await recipient.increment({balance: data.amount});
await Transaction.create({
amount: data.amount,
senderID: sender.id,
receiverID: recipient.id,
reference: data.reference
});
})
return res.status(200).json(new SendResponse(sender.balance))
} catch (err) {
logger.error('Failed to commit transaction:', err);
return res.status(500).json(new Err('Failed to commit transaction' ));
}
});
export default router;

View File

@@ -0,0 +1,21 @@
import express from 'express';
import { logger } from '../util/logging';
import Transaction from '../model/transaction';
import { requireAuth } from '../util/auth';
const router = express.Router();
router.get('/', requireAuth, async (req, res) => {
try {
const transactions = await Transaction.findAll({
limit: 100,
order: [['date', 'DESC']]
});
res.json(transactions);
} catch (err) {
logger.error('Failed to fetch transactions:', err);
res.status(500).json({ error: 'Failed to fetch transactions' });
}
});
export default router;

View File

@@ -0,0 +1,12 @@
import { db, testConnection } from "../util/db";
import { hash } from "bcrypt";
import Account from '../model/user';
(async () =>{
await testConnection();
await Account.create({
id: process.argv[2],
displayName: process.argv[3],
balance: process.argv[4],
password: await hash(process.argv[5], 10),
})
})();

View File

@@ -0,0 +1,5 @@
import {hash} from 'bcrypt';
let pass = process.argv[2];
hash(pass, 10, function(err, hash) {
console.log(hash);
});

View File

@@ -0,0 +1,11 @@
import { exportJWK, generateSecret } from "jose"
async function printSecret(secret){
const jwk = await exportJWK(secret);
console.log('🔑Generated Secret:');
console.log(JSON.stringify(jwk));
}
(async () => {
const secret = await generateSecret('HS256', {extractable: true});
await printSecret(secret);
})();

View File

@@ -0,0 +1,6 @@
import {compare} from 'bcrypt';
let pass = process.argv[2];
let hash = process.argv[3];
compare(pass,hash, function(err, result){
console.log(result);
});

53
server/src/util/auth.ts Normal file
View File

@@ -0,0 +1,53 @@
import { NextFunction, Request, Response } from "express";
import Account from "../model/user"
import { importJWK, SignJWT, jwtVerify } from "jose";
let key;
async function setKeyFromEnv() {
key = await importJWK(JSON.parse(process.env.FM_PRIVATE_KEY));
}
async function getJWT(user: Account){
let jwt = await new SignJWT()
.setSubject(user.id)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.sign(key);
return jwt
}
async function requireAuth(req: Request, res: Response, next: NextFunction) {
try {
const token = req.cookies.jwt; // Or req.headers.authorization
if (!token) {
return res.status(401).json({ error: 'Unauthorized: No token provided' });
}
const jwt= await jwtVerify(token, key);
const user = await Account.findOne({where: { id: jwt.payload.sub}});
if (!user) {
return res.status(401).json({ error: 'Unauthorized: User not found' });
}
//TODO extend req instead of using res.locals
res.locals.user = user;
next();
} catch (err) {
return res.status(401).json({ error: 'Unauthorized: Invalid token' });
}
}
function requireRole(role: string) {
return async (req: Request, res: Response, next: NextFunction) => {
// First, run requireAuth to ensure the user is authenticated
await requireAuth(req, res, () => {
if (res.locals.role !== role) {
return res.status(403).json({ error: 'Forbidden: Insufficient permissions' });
}
next(); // User is authenticated and has the required role
});
};
}
export {getJWT, setKeyFromEnv, requireAuth}

36
server/src/util/db.ts Normal file
View File

@@ -0,0 +1,36 @@
import { Sequelize } from 'sequelize-typescript';
import { logger } from './logging';
import Account, { BusinessOwnership } from '../model/user';
import Transaction from '../model/transaction';
enum Scope{
// for User
withPassword = 'withPassword',
}
// Initialize Sequelize
const db = new Sequelize({
dialect: 'postgres',
host: process.env.FM_DB_HOST || 'localhost',
port: parseInt(process.env.FM_DB_PORT || '5432'),
database: process.env.FM_DB_NAME || 'postgres',
username: process.env.FM_DB_USER || 'postgres',
password: process.env.FM_DB_PASSWORD || 'pass',
logging: logger.debug.bind(logger),
});
db.addModels([Account, BusinessOwnership, Transaction ])
// Test the connection
async function testConnection() {
try {
await db.authenticate();
logger.info('✅ Database connection established.');
} catch (err) {
logger.error('❌ Unable to connect to the database:', err);
process.exit(1); // Exit if DB connection fails
}
}
// Export Sequelize instance and models
export { logger, db, testConnection, Scope };

View File

@@ -0,0 +1,17 @@
import winston, { format } from "winston";
const logger = winston.createLogger({
level:'info',
})
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: format.combine(
format.colorize({all: true}),
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.printf((info) => `${info.timestamp} [${info.level}]: ${info.message}`),
)
}));
}
// Export Sequelize instance and models
export { logger };

17
server/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc/server",
"esModuleInterop": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "nodenext",
"moduleResolution": "nodenext"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,13 +0,0 @@
{
"name": "shared",
"version": "1.0.0",
"description": "",
"license": "GPL-3.0",
"author": "",
"type": "commonjs",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {}
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"sourceMap": true,
"declaration": false,
"moduleResolution": "bundler",
"target": "ES2022",
"module": "es2022",
"lib": ["es2022", "dom"],
"paths": {
"@model/*": ["server/src/model/*"],
"@message/*": ["server/src/messages/*"],
}
},
"exclude": ["node_modules"]
}