Full-stack platform for the buy-back and re-commercialisation of refurbished electronics. A device is dropped off, diagnosed, repaired if needed, then monetised (resale or rental via DAAS) with a transparent net payout to the client.
🔗 The app was deployed and live during the project demo (Railway · Vercel · Neon Postgres). The hosting has since been taken down to save costs, so the public URLs are no longer reachable - everything runs locally (see Running locally).
Phoenix Exchange is an end-to-end web application I designed and built solo as my final Bachelor project in computer science. It was deployed in production during the project demo (Railway, Vercel and Neon), and the hosting has since been taken down to save costs. It models a real circular-economy business: collecting dormant devices, grading them, and routing each one to its most profitable outcome through an automated decision engine.
What it demonstrates
- Backend architecture: A Spring Boot 3 REST API (~22 controllers, ~30 services) with a clean layered design (controller → service → repository), JPA/Hibernate persistence on PostgreSQL, and centralised error handling.
- Stateless security: JWT authentication (HMAC-SHA256), BCrypt password hashing, and role-based authorization (
CLIENT,OPERATOR,TECHNICIAN,ADMIN) enforced on both the API and the SPA. - A real business engine: A
DecisionEngineServicethat scores each device and computes the financials of four scenarios (direct sale, value-add sale, rental/DAAS, return), then recommends the most profitable one. The money logic (SettlementService) is the part I prioritised for unit testing. - Real-time UX: In-app notifications persisted in the database and pushed live over WebSocket (STOMP/SockJS) to the right roles at every workflow step.
- Modern frontend: Angular 21 SPA built entirely with Signals (no NgZone), standalone components,
OnPushchange detection, lazy-loaded role workspaces, route guards, and HTTP interceptors. PWA-ready. - Third-party integrations: Stripe (checkout + signed webhooks), eBay (market pricing + listing publication), Brevo (transactional email), and a HuggingFace-powered AI assistant for scan-time price estimation and client support.
- DevOps: Dockerised local stack, GitHub Actions CI (build + tests on every push), and continuous deployment during the demo on Railway (API), Neon (PostgreSQL) and Vercel (frontend).
Stack: Java 17 · Spring Boot 3.5 · Spring Security · Spring Data JPA · WebSocket · PostgreSQL · Angular 21 · RxJS · TypeScript · Stripe · eBay API · HuggingFace · Docker · GitHub Actions · Railway · Neon · Vercel
- Architecture
- Tech stack
- Project structure
- Data model
- Device lifecycle
- Decision engine
- Business model
- Roles & permissions
- REST API reference
- Angular frontend
- JWT authentication
- Real-time notifications
- External integrations
- Environment variables
- Running locally
- Tests
- Deployment
- Security
- Error handling
phoenix-exchange/
├── phoenix-api/ # Spring Boot 3 backend (REST + WebSocket)
├── phoenix-ui/ # Angular 21 frontend (SPA / PWA)
└── phoenix-doc/ # UML diagrams and documentation
The backend exposes a JWT-secured REST API. The frontend communicates exclusively through this API and receives real-time notifications over WebSocket (STOMP over SockJS). The modules are deployed independently.
Angular client ──HTTPS──▶ Spring Boot API ──JDBC──▶ PostgreSQL
│
├──▶ eBay API (pricing + listings)
├──▶ Stripe API (client payments)
├──▶ HuggingFace (AI estimation + assistant)
└──▶ Brevo (SMTP) (transactional email)
| Technology | Version | Role |
|---|---|---|
| Java | 17 | Language |
| Spring Boot | 3.5 | Application framework |
| Spring Security | 6 | Authentication / authorization |
| Spring Data JPA | n/a | ORM (Hibernate 6) |
| Spring WebSocket | n/a | Real-time notifications (STOMP) |
| PostgreSQL | n/a | Database |
| Springdoc OpenAPI | n/a | API documentation (Swagger UI) |
| Stripe Java SDK | n/a | Payments |
| JJWT | n/a | JWT generation / validation |
| Lombok | n/a | Boilerplate reduction |
| Technology | Version | Role |
|---|---|---|
| Angular | 21 | SPA framework |
| Angular Signals | n/a | Fine-grained reactivity (no NgZone) |
| Angular Router | n/a | Navigation with view transitions |
| RxJS | n/a | Async streams |
| @stomp/stompjs | n/a | STOMP WebSocket client |
| SockJS | n/a | WebSocket fallback |
| Angular PWA | n/a | Service Worker |
| Tailwind CSS | n/a | Utility-first styling |
phoenix-api/src/main/java/com/phoenix/exchange/
├── config/
│ ├── SecurityConfig.java # Spring Security, CORS, JWT filters
│ ├── WebSocketConfig.java # STOMP broker, SockJS endpoints
│ ├── OpenApiConfig.java # Swagger UI
│ ├── AsyncConfig.java # Async thread pool
│ ├── DataInitializer.java # Seed data (admin, categories, demo accounts)
│ └── RoleMigration.java # Role migration in DB
├── controller/ # REST layer (one controller per resource)
├── service/ # Business logic (~30 services)
├── model/ # JPA entities
├── repository/ # Spring Data repositories
├── dto/ # Request/Response records
├── enums/ # Domain enumerations
├── exception/ # GlobalExceptionHandler + domain exceptions
├── security/ # JwtFilter, JwtService, UserDetailsService
└── ebay/ # eBay integration (pricing + listings + sync)
phoenix-ui/src/app/
├── app.config.ts # Angular configuration (router, HTTP, i18n)
├── app.routes.ts # Root routes with lazy loading
├── core/
│ ├── guards/ # AuthGuard, RoleGuard
│ ├── interceptors/ # AuthInterceptor (JWT), ErrorInterceptor
│ ├── models/ # TypeScript interfaces
│ └── services/ # HTTP services (one per resource)
├── features/
│ ├── auth/ # Login, Register, Reset Password
│ ├── client/ # Client workspace (dashboard, devices, revenue…)
│ ├── operator/ # Operator workspace (devices, diagnostics, decisions…)
│ ├── technician/ # Technician workspace (repairs, reports)
│ ├── admin/ # Administration (users, categories, stats)
│ └── partner/ # Repair-partner workspace
├── layout/
│ ├── client-layout/ # Client shell (sidebar + topbar + mobile bottom-nav)
│ └── backoffice-layout/ # Back-office shell (sidebar + topbar)
└── shared/
└── components/ # phoenix-icon, phoenix-logo, phoenix-toast,
# phoenix-notification-bell, user-menu…
User: User account with role(s) (CLIENT, OPERATOR, TECHNICIAN, ADMIN). Stores banking details (IBAN) for payouts.
Device: A device submitted by a client. Tracked across its whole lifecycle via DeviceStatus.
DiagnosticReport: Diagnostic report issued by a technician. Contains the Phoenix score (0–100), the estimated repair cost, and the list of DiagnosticItem (state of each component).
Decision: Financial decision generated automatically by the engine after diagnosis. Holds the estimates for the four possible scenarios and the scenario ultimately chosen by the operator.
RepairOrder: Repair order created following a VALUE_ADD_SALE decision. Linked to a RepairPartner.
Listing: Sale listing published on the internal channel or eBay.
Subscription: DAAS (Device as a Service) contract. Manages the tenant, monthly rent, duration, security deposit and client signature.
Settlement: Frozen financial statement between Phoenix Exchange and the owner. Can be a return after diagnosis, after repair, a sale, or a DAAS instalment. clientNet > 0 = amount owed to the client; clientNet < 0 = amount owed by the client.
Appointment: Physical handover appointment (drop-off or delivery).
UserNotification: Persisted in-app notification linked to a user.
SupportTicket: Client support ticket.
Warranty: Warranty attached to a device after sale.
RepairPartner: External repair partner, linked to technicians.
PENDING_REVIEW → RECEIVED → ACQUIRED → DIAGNOSED → DECISION_PENDING
→ SENT_TO_REPAIR → IN_REPAIR → REPAIRED
→ RETRIEVAL_PENDING | LISTED → SOLD | DAAS_ACTIVE → DAAS_ENDED
NOT_RECEIVED (parcel lost / not delivered)
ARCHIVED
1. SUBMISSION Client submits a device via the client workspace or QR scan
2. RECEPTION Operator physically receives the device → RECEIVED
3. ACQUISITION Operator validates acquisition → ACQUIRED
4. DIAGNOSIS Technician submits the diagnostic report → DIAGNOSED
5. DECISION Engine generates estimates → DECISION_PENDING
6. EXECUTION Operator picks and confirms a scenario:
├─ DIRECT SALE Listing created and published → LISTED → SOLD
├─ VALUE-ADD SALE Repair order → repair → listing → LISTED → SOLD
├─ DAAS Rental contract created → DAAS_ACTIVE → DAAS_ENDED
└─ RETURN Settlement generated, client notified → RETRIEVAL_PENDING → ARCHIVED
Every step triggers automatic notifications to the right roles (client, operator, technician of the relevant agency) through a centralised matrix (WorkflowNotificationService).
DecisionEngineService analyses the diagnostic report and produces financial estimates for each scenario, then recommends the most profitable one.
Score ≥ 70 → DAAS if 24-month rental revenue > direct sale, otherwise DIRECT_SALE
Score 40–69 → VALUE_ADD_SALE if sale after repair > direct sale, otherwise DIRECT_SALE
Score < 40 → DIRECT_SALE
- Market price: queries the eBay API for recent sales of the same model.
- Direct sale:
marketPrice × (1 − acquisitionRatio)adjusted by condition. - Value-add sale:
(marketPrice × 0.85) − repairCost − acquisitionCost. - Monthly DAAS:
(marketPrice × commissionRate) / durationMonths.
{
"scenario": "VENTE_DIRECTE | VENTE_VALORISEE | DAAS | RESTITUTION",
"salePrice": 299.00,
"listingTitle": "...",
"listingDescription": "...",
"channel": "INTERNAL | EBAY | OTHER",
"monthlyRent": 19.90,
"durationMonths": 12,
"daasStartDate": "2026-01-01",
"tenantName": "...",
"tenantEmail": "...",
"rentalTarget": "B2B",
"depositAmount": 50.00,
"contractClauses": "...",
"clientSignatureRequired": true
}The client settlement (Settlement) is frozen in the database at each financial event:
| Scenario | Rule |
|---|---|
| Sale | price − repairs − €10 grading − 15% commission |
| Return after diagnosis | €15 diagnostic fee |
| Return after repair | €15 + actual repair cost |
| DAAS instalment | 15% of the rent (fixed fees deducted from the first instalment) |
Diagnosis is free when the device goes through the full flow. The fee schedule is configurable via environment variables (FEE_*) without redeployment. Fees are paid online through Stripe Checkout; a signed webhook confirms the payment automatically.
| Role | Access |
|---|---|
CLIENT |
Client workspace: submit devices, track lifecycle, revenue, support |
OPERATOR |
Operator workspace: receive, decide, publish, manage contracts |
TECHNICIAN |
Technician workspace: diagnose, create repair orders |
ADMIN |
Global administration: users, categories, stats, partners |
A user can hold multiple roles (stored as Set<UserRole>). The frontend uses a RoleGuard to protect each workspace.
Local base URL:
http://localhost:8080/apiLocal Swagger UI:http://localhost:8080/swagger-ui/index.html(During the demo the API was hosted on Railway; that deployment has been taken down.)
All endpoints are JWT-protected except /api/auth/** and explicitly opened public routes.
| Method | Endpoint | Description |
|---|---|---|
| POST | /auth/login |
Login → JWT |
| POST | /auth/register |
Registration |
| POST | /auth/forgot-password |
Send reset email |
| POST | /auth/reset-password |
Reset with token |
| Method | Endpoint | Description |
|---|---|---|
| GET | /devices |
All devices (OPERATOR/ADMIN) |
| GET | /devices/{id} |
Device detail |
| POST | /devices |
Create a device (back office) |
| POST | /devices/submit |
Submit from the client workspace |
| PATCH | /devices/{id}/status |
Change status |
| PATCH | /devices/{id}/receive |
Mark as received |
| GET | /devices/status/{status} |
Filter by status |
| Method | Endpoint | Description |
|---|---|---|
| POST | /diagnostics |
Create a diagnostic report |
| GET | /diagnostics/device/{deviceId} |
Reports for a device |
| GET | /diagnostics/{id} |
Report detail |
| POST | /diagnostic-items |
Add an item to a diagnosis |
| Method | Endpoint | Description |
|---|---|---|
| POST | /decisions/generate/{deviceId} |
Generate estimates via the engine |
| POST | /decisions/{id}/execute |
Execute the chosen scenario |
| GET | /decisions/device/{deviceId} |
Decision for a device |
| GET | /decisions/{id} |
Decision detail |
| POST | /decisions/simulate |
Simulation without persistence |
| Method | Endpoint | Description |
|---|---|---|
| POST | /repair-orders |
Create a repair order |
| GET | /repair-orders/device/{deviceId} |
Orders for a device |
| PATCH | /repair-orders/{id}/status |
Update status |
| Method | Endpoint | Description |
|---|---|---|
| POST | /listings |
Create a listing |
| POST | /listings/{id}/publish |
Publish (→ eBay if channel=EBAY) |
| POST | /listings/{id}/sold |
Mark as sold |
| GET | /listings |
All listings |
| Method | Endpoint | Description |
|---|---|---|
| POST | /subscriptions |
Create a contract |
| POST | /subscriptions/{id}/approve |
Approve (client) |
| GET | /subscriptions/device/{deviceId} |
Contract for a device |
| Method | Endpoint | Description |
|---|---|---|
| GET | /settlements/mine |
My settlements (client workspace) |
| GET | /settlements/device/{deviceId} |
Settlements for a device |
| GET | /settlements/preview |
Preview without persistence |
| Method | Endpoint | Description |
|---|---|---|
| POST | /appointments |
Create an appointment |
| GET | /appointments |
List all appointments |
| POST | /appointments/{id}/complete |
Mark as completed |
| Method | Endpoint | Description |
|---|---|---|
| GET | /notifications/recent |
10 most recent notifications |
| GET | /notifications/unread-count |
Unread count |
| PATCH | /notifications/{id}/read |
Mark as read |
| Method | Endpoint | Description |
|---|---|---|
| POST | /payments/checkout/{settlementId} |
Create a Stripe Checkout session |
| POST | /payments/webhook |
Stripe webhook (signature verified) |
| Method | Endpoint | Description |
|---|---|---|
| GET | /admin/stats |
Global statistics |
| GET | /users |
All users (ADMIN) |
| POST | /users |
Create a user |
| GET | /categories |
Device categories |
| POST | /categories |
Create a category |
Signals: Reactivity is handled exclusively via signal(), computed() and toSignal(). No async pipe and no manual subscriptions in views. ChangeDetectionStrategy.OnPush everywhere.
Standalone components: No NgModule. Each component declares its own imports.
Lazy loading: Each workspace (client, operator, technician, admin, partner) is a routed module loaded on demand via loadChildren.
Guards: AuthGuard redirects to /login if there is no valid token; RoleGuard redirects to /unauthorized if the role does not match.
HTTP interceptors:
AuthInterceptor: addsAuthorization: Bearer <token>to every request.ErrorInterceptor: shows a toast for 4xx/5xx errors (except requests markedSKIP_ERROR_TOAST).
Dropdowns: Floating menus (user-menu, notification bell) use position: fixed with coordinates computed via getBoundingClientRect() to escape overflow: hidden ancestors.
| Workspace | Routes | Description |
|---|---|---|
| Client | /dashboard, /devices, /revenues, /appointments, /scan, /support |
Track own devices and revenue |
| Operator | /operator/devices, /operator/diagnostics, /operator/daas, /operator/listings |
Full workflow management |
| Technician | /technician/repair-orders, /technician/diagnostics |
Repairs and reports |
| Admin | /admin/users, /admin/categories, /admin/partners |
Administration |
| Partner | /partner/repair-orders |
Incoming repair orders |
OperatorDeviceDetailComponent is the heart of the operator interface. It shows the device's current state with a status badge, the event history (DeviceEvent), the diagnostic report with the Phoenix score, the four decision scenarios with their estimates, the context-aware form for the selected scenario (price, rent, duration…), and the execution button that calls POST /api/decisions/{id}/execute.
The JWT is signed with HMAC-SHA256. Its lifetime is 24 hours (app.jwt.expiration-ms=86400000).
Backend: The JwtFilter (Spring Security) extracts the token from the Authorization: Bearer ... header, validates the signature with JWT_SECRET, and loads the UserDetails from the database. No server session (stateless).
Frontend: The token is stored in TokenStorageService (localStorage). AuthService exposes a user() signal populated at startup from /api/auth/me.
⚠️ JWT_SECRETmust be provided via an environment variable. No default value is defined inapplication.properties: startup fails if it is missing.
In-app notifications are persisted in the database (UserNotification) and delivered in real time over WebSocket STOMP.
Backend: WebSocketConfig configures an in-memory broker on /topic and /queue. Each relevant business action (WorkflowAction) triggers WorkflowNotificationService.dispatch(), which notifies the concerned recipients (device owner, operators, partner technicians).
Frontend: PhoenixNotificationBellComponent subscribes to the logged-in user's topic at login. The unread counter updates in real time.
| Action | Recipients |
|---|---|
DEVICE_RECEIVED |
Client (owner) + all operators |
DIAGNOSTIC_SUBMITTED |
Client + operators |
REPAIR_ORDER_CREATED |
Client + partner technicians + operators |
REPAIR_SENT |
Client + partner technicians |
REPAIR_STARTED |
Client + operators |
REPAIR_COMPLETED |
Client + operators |
REPAIR_VALIDATED |
Client |
LISTING_PUBLISHED |
Client |
DEVICE_SOLD |
Client + operators |
DAAS_CONTRACT_CREATED |
Client + operators |
RETRIEVAL_REQUESTED |
Client + operators |
The ebay/ module queries the eBay API to:
- Pricing: fetch a model's market price from recent sales.
- Listings: publish a listing directly on eBay from the operator workflow, with order synchronisation (
EbayOrderSyncService).
Required configuration: EBAY_CLIENT_ID, EBAY_CLIENT_SECRET. Webhook callback URL: https://<your-domain>/api/ebay/webhook (during the demo this pointed to the Railway deployment).
Used for settlement payments (returns, instalments):
- The operator / system generates a
Settlementfor the client. - The client starts the payment →
POST /api/payments/checkout/{settlementId}creates a Stripe Checkout session and returns the URL. - Stripe sends a signed webhook to
POST /api/payments/webhook→ the settlement is markedPAID.
Required configuration: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET.
An AI assistant (AssistantService, HuggingFaceService, SupportChatService) provides a value estimate when a device is scanned and powers the client support chat. Configured via HF_API_KEY (cleanly disabled if absent).
Transactional emails (password reset, notifications). Configured via BREVO_API_KEY or the standard Spring Mail variables (SPRING_MAIL_*).
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL JDBC URL (e.g. jdbc:postgresql://host:5432/db) |
DATABASE_USERNAME |
PostgreSQL user |
DATABASE_PASSWORD |
PostgreSQL password |
JWT_SECRET |
HMAC-SHA256 secret to sign JWTs (≥ 32 chars, never commit) |
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Server port |
JPA_DDL_AUTO |
update |
Hibernate DDL strategy |
EBAY_CLIENT_ID / EBAY_CLIENT_SECRET |
(empty) | eBay publication (disabled if absent) |
STRIPE_SECRET_KEY |
(empty) | Stripe secret key |
STRIPE_WEBHOOK_SECRET |
(empty) | Stripe webhook validation secret |
BREVO_API_KEY |
(empty) | Transactional emails |
HF_API_KEY |
(empty) | AI estimation / assistant |
FEE_DIAGNOSTIC |
15.0 |
Diagnostic fee (€) |
FEE_GRADING |
10.0 |
Grading fee (€) |
FEE_COMMISSION |
0.15 |
Phoenix commission rate (15%) |
WARRANTY_MONTHS |
12 |
Default warranty duration (months) |
The API URL is configured in src/environments/environment.ts (development) and environment.prod.ts (production). In development, environment.ts uses the relative paths /api and /ws, which are proxied to http://localhost:8080 by proxy.conf.json — so no change is needed to run locally.
- Java 17+ · Maven 3.8+ · Node.js 20+ and npm 10+ · PostgreSQL 14+ (or Docker)
# 1. Start only the database (the compose file uses phoenix / phoenix / phoenix_exchange)
docker-compose up -d db
# 2. Environment variables
# JWT_SECRET is the only one strictly required. The database defaults already
# match the Docker container, so the DATABASE_* lines are optional.
export JWT_SECRET=your_secret_at_least_32_characters
# export DATABASE_URL=jdbc:postgresql://localhost:5432/phoenix_exchange
# export DATABASE_USERNAME=phoenix
# export DATABASE_PASSWORD=phoenix
# 3. Start the API at http://localhost:8080/api (Swagger: /swagger-ui/index.html)
cd phoenix-api && ./mvnw spring-boot:runThe external integrations (eBay, Stripe, Brevo, HuggingFace) are optional and stay disabled if their variables are unset, so the API boots with JWT_SECRET alone. Demo accounts are created on first startup (see DataInitializer).
Alternatively, run the whole stack in containers with
docker-compose up -d(API + database + UI). Theapiservice already sets a devJWT_SECRET.
cd phoenix-ui
npm install
npm start # http://localhost:4200No configuration is needed: npm start serves the app on http://localhost:4200 and proxies /api and /ws to the backend on port 8080 via proxy.conf.json.
cd phoenix-api && ./mvnw testThe settlement calculation rules (SettlementService), the core of the business model, are covered by targeted unit tests. The GitHub Actions CI replays build and tests on every push.
For the project demo the app was continuously deployed: Railway hosted the API, Neon hosted the PostgreSQL database, Vercel hosted the frontend, and GitHub Actions replayed build and tests on every push. The hosting has since been taken down to save costs, but the configuration below documents how it was set up and remains reproducible.
Railway auto-detected the Maven project. Start command:
java -jar target/phoenix-0.0.1-SNAPSHOT.jar
The DATABASE_URL, DATABASE_USERNAME and DATABASE_PASSWORD variables pointed to the Neon PostgreSQL instance; the other variables were set in the service settings.
Build command : npm run build
Output dir : dist/phoenix-ui/browser
CORS is configured in SecurityConfig.java to allow the frontend origin.
JWT_SECREThas no default value. If the variable is not set, startup fails immediately.- The
.envfile is in.gitignoreand must never be committed. - Stripe webhooks are validated by signature (
Stripe-Signature) before any processing. - All routes are secured by default; public endpoints (
/auth/**,/swagger-ui/**) are explicitly opened inSecurityConfig. - Passwords are hashed with BCrypt.
- The JWT expires after 24 hours. No refresh-token strategy is implemented (re-login required on expiry).
GlobalExceptionHandler centralises all error responses:
| Exception | HTTP code | Use |
|---|---|---|
MethodArgumentNotValidException |
400 | Bean Validation failed |
BusinessException |
400 | Business rule violated |
IllegalArgumentException |
400 | Invalid argument |
ResourceNotFoundException |
404 | Entity not found |
DuplicateResourceException |
409 | Resource already exists |
BadCredentialsException |
401 | Wrong email / password |
AccessDeniedException |
403 | Insufficient role |
Exception (catch-all) |
500 | Internal error (logged server-side) |
All error responses follow this format:
{
"status": 400,
"error": "Bad Request",
"message": "Descriptive message",
"timestamp": "2026-06-13T12:34:56.789"
}Final-year Bachelor project in computer science · designed and built by Yann