diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5698431 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.idea +.env +.env.* +!.env.example + +# Generated build output +target/ + +# Local workflow guidance +GIT_WORKFLOW_CONVENTION.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f2bbabf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +# syntax=docker/dockerfile:1 + +# ─── Stage 1: Dependency Cache ──────────────────────────────────────────────── +FROM eclipse-temurin:25-jdk-alpine AS dependencies + +WORKDIR /app + +# Copy root and all module POM files for precise dependency caching +COPY pom.xml ./ +COPY payguard-proto/pom.xml ./payguard-proto/ +COPY payguard-api-gateway/pom.xml ./payguard-api-gateway/ +COPY payguard-wallet-service/pom.xml ./payguard-wallet-service/ +COPY payguard-loan-service/pom.xml ./payguard-loan-service/ +COPY payguard-collateral-service/pom.xml ./payguard-collateral-service/ + +COPY --chmod=0755 mvnw ./mvnw +COPY .mvn/ .mvn/ + +# Download dependencies offline to leverage Docker layer caching +RUN ./mvnw --batch-mode --no-transfer-progress dependency:go-offline -B || true + + +# ─── Stage 2: Application Build ─────────────────────────────────────────────── +FROM dependencies AS builder + +WORKDIR /app + +# Copy source code and configurations for all modules +COPY config/ ./config/ +COPY payguard-proto/ ./payguard-proto/ +COPY payguard-api-gateway/./payguard-api-gateway/ +COPY payguard-wallet-service/ ./payguard-wallet-service/ +COPY payguard-loan-service/ ./payguard-loan-service/ +COPY payguard-collateral-service/ ./payguard-collateral-service/ + +# Receive service name via Build Arg (defaults to wallet service) +ARG SERVICE_NAME=payguard-wallet-service + +# Build the target service package along with internal dependencies (-am) without running tests +RUN ./mvnw --batch-mode --no-transfer-progress clean package -pl :${SERVICE_NAME} -am -DskipTests + + +# ─── Stage 3: Runtime ───────────────────────────────────────────────────────── +FROM eclipse-temurin:25-jre-alpine AS runtime + +WORKDIR /app + +# High security: Create a non-root system user and isolate the data directory +RUN addgroup -S payguard \ + && adduser -S payguard -G payguard \ + && mkdir -p /var/lib/payguard \ + && chown -R payguard:payguard /var/lib/payguard + +ARG SERVICE_NAME=payguard-wallet-service + +# Copy the output JAR file from the build stage to the lightweight JRE runtime image +COPY --from=builder --chown=payguard:payguard /app/${SERVICE_NAME}/target/*.jar ./app.jar + +# Run the container as a non-privileged user +USER payguard + +EXPOSE 8080 + +# Java 25 runtime optimizations +ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..00f1ec3 --- /dev/null +++ b/README.md @@ -0,0 +1,305 @@ +
+ +# PayGuard + +**A modular fintech backend for wallet ledgering, loan origination, and digital-collateral risk management.** + +[![CI](https://img.shields.io/badge/CI-GitHub%20Actions-2088FF?style=flat-square&logo=githubactions&logoColor=white)](#) +[![Java](https://img.shields.io/badge/Java-25-orange?style=flat-square&logo=openjdk)](https://openjdk.org) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.0.1-brightgreen?style=flat-square&logo=springboot)](https://spring.io/projects/spring-boot) +[![Architecture](https://img.shields.io/badge/Architecture-Hexagonal%20%7C%20DDD-blueviolet?style=flat-square)](https://alistair.cockburn.us/hexagonal-architecture/) +[![License](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](LICENSE) + +[Overview](#overview) • +[Services](#services) • +[Architecture](#architecture) • +[Key Engineering Decisions](#key-engineering-decisions) • +[Getting Started](#getting-started) • +[API Documentation](#api-documentation) • +[Roadmap](#roadmap) + +
+ +--- + +## Overview + +PayGuard is a backend system for a single, coherent financial story: a customer locks digital assets as collateral, borrows against them, and pays the loan back — while the system continuously monitors the collateral's value and protects itself automatically if that value falls too far. + +The project is built as a set of **independently deployable microservices**, each owning its own database and communicating only through explicit contracts — never through a shared schema. It is a deliberate engineering exercise in the domain problems that sit underneath every regulated lending product: + +- Double-entry bookkeeping and derived, never-stored balances +- Loan amortization, repayment waterfalls, and delinquency state machines +- Loan-to-Value (LTV) risk monitoring, margin calls, and liquidation +- Idempotent financial operations under concurrent access +- Service-to-service communication boundaries (REST at the edge, gRPC where latency matters) + +It is not a payment-card switch or a full core-banking platform. The scope is intentionally narrow so that every part of it can be built, understood, and defended in depth. + +--- + +## Core Capabilities + +### Implemented / In Progress + +- [ ] Account creation and double-entry ledger posting (`wallet-service`) +- [ ] Hold → capture → release flow for provisional balance reservations +- [ ] Loan application, approval, and amortization-schedule generation (`loan-service`) +- [ ] Repayment waterfall (fees → interest → principal) +- [ ] Collateral locking and Loan-to-Value calculation (`collateral-service`) +- [ ] Margin-call and liquidation risk monitoring +- [ ] API Gateway routing (REST-in, gRPC-out to internal services) + +### Planned + +- [ ] Kafka-based domain events between services (e.g. loan approval → collateral linkage) +- [ ] Full CI pipeline (build, test, static analysis) via GitHub Actions +- [ ] Expanded integration and concurrency test coverage + +> This README describes the target architecture and is kept in sync with implementation status via the checkboxes above — see [Current Limitations](#current-limitations) for what is intentionally out of scope for now. + +--- + +## Services + +| Service | Responsibility | Persistence | Current State | +|---|---|---|---| +| `wallet-service` | Account balances, double-entry ledger, holds/captures | PostgreSQL/Oracle via **jOOQ** (append-only, no ORM overhead) | In development | +| `loan-service` | Loan lifecycle, amortization schedules, repayments | Oracle via JPA/Hibernate | In development | +| `collateral-service` | Collateral locking, price valuation, LTV monitoring, liquidation | Oracle via JPA/Hibernate | In development | +| `api-gateway` | Single entry point; REST from clients, gRPC to internal services | — | Planned | + +> `wallet-service` deliberately uses jOOQ instead of JPA: a ledger is append-only, and jOOQ gives explicit control over `INSERT`-only SQL with no risk of an accidental `UPDATE` slipping in through dirty-checking. `loan-service` and `collateral-service` manage mutable, CRUD-shaped state, where JPA is a better fit. + +--- + +## Architecture + +### System Overview + +```mermaid +flowchart LR + client["Client (REST)"] + gateway["API Gateway
(REST in, gRPC out)"] + wallet["wallet-service
(Ledger)"] + loan["loan-service"] + collateral["collateral-service"] + oracle["Oracle 23c"] + redis["Redis 7
(price cache, distributed locks)"] + kafka["Kafka (KRaft)
domain events"] + + client --> gateway + gateway -->|gRPC| wallet + gateway -->|gRPC| loan + gateway -->|gRPC| collateral + + loan -->|REST: disburse/repay| wallet + loan -->|REST: check LTV| collateral + collateral -->|REST: outstanding balance| loan + collateral --> redis + + wallet --> oracle + loan --> oracle + collateral --> oracle + + loan -.->|planned| kafka + collateral -.->|planned| kafka +``` + +### Hexagonal Structure (per service) + +```mermaid +flowchart LR + Inbound["Inbound Adapters
REST Controllers"] + Application["Application Layer
Use-Case Services"] + Domain["Domain Layer
Aggregates / Value Objects / Invariants"] + Ports["Outbound Ports"] + Outbound["Outbound Adapters
jOOQ / JPA / HTTP Clients"] + + Inbound --> Application + Application --> Domain + Application --> Ports + Outbound --> Ports +``` + +### Dependency Rules + +- Domain code has no Spring, persistence, or HTTP dependencies — invariants (e.g. debit = credit) are testable without a running application. +- Each service owns its own database schema; no service queries another's tables directly. +- Cross-service calls happen only through the declared client ports (`WalletServiceClient`, `LoanServiceClient`, `CollateralServiceClient`), each with a REST adapter today and a gRPC adapter available for the gateway path. +- Dependencies point inward, toward the domain core. + +--- + +## Key Engineering Decisions + +### No stored balance column + +`wallet-service` never persists a mutable `balance` field. Every balance is derived from the sum of immutable `ledger_entry` rows. This eliminates an entire class of race conditions that come from concurrent `UPDATE balance = balance - x` statements, at the cost of computing balances on read — a trade-off documented here rather than hidden. + +### Holds are not ledger entries + +A hold (reservation) reduces *available* balance without touching the *ledger* balance. Only capture — the actual settlement of a hold — creates real, immutable ledger entries. This mirrors the authorize-then-capture pattern used throughout the payments industry. + +### Concurrency: inserts don't need locks, holds do + +Posting a ledger transaction is a pure `INSERT` and needs no row locking. Creating a *hold* does need one (`SELECT ... FOR UPDATE` or a Redis-based distributed lock), because two concurrent hold requests could otherwise both read a stale available balance and jointly overdraw the account. + +### gRPC only at the gateway boundary + +Internal service-to-service calls use plain REST. gRPC is used exclusively between the API Gateway and the internal services, where the latency and schema-contract benefits are worth the added operational complexity — not applied uniformly out of habit. + +### Derived, never-stored LTV + +Loan-to-Value is calculated on demand from the latest price snapshot and the loan's current outstanding principal, never cached as a stored "current LTV" field. This keeps the number provably correct at the moment it's checked, which matters more here than raw read speed. + +--- + +## Getting Started + +### Prerequisites + +- Java 25 or newer +- Docker with Docker Compose +- Maven Wrapper (included, no separate Maven install required) + +```bash +java -version +docker --version +docker compose version +``` + +### 1. Clone the Repository + +```bash +git clone https://github.com//payguard.git +cd payguard +``` + +### 2. Configure the Environment + +Create a `.env` file in the repository root: + +```env +# Oracle +ORACLE_IMAGE=gvenzl/oracle-free:23-slim-faststart +ORACLE_HOST_PORT=1521 +ORACLE_PASSWORD=change-me-for-local-development + +# Redis +REDIS_IMAGE=redis:7-alpine +REDIS_HOST_PORT=6379 + +# Kafka (KRaft mode — no Zookeeper) +KAFKA_CLUSTER_ID=replace-with-a-generated-cluster-id + +# Per-service ports +WALLET_SERVICE_PORT=8081 +LOAN_SERVICE_PORT=8082 +COLLATERAL_SERVICE_PORT=8083 +API_GATEWAY_PORT=8080 +``` + +> Never commit real credentials. The values above are placeholders for local development. + +### 3. Start Infrastructure + +```bash +docker compose up -d payguard-oracle payguard-redis payguard-kafka --wait +``` + +### 4. Run a Service + +```bash +./mvnw spring-boot:run -pl payguard-wallet-service +``` + +### 5. Open the API Documentation + +```text +http://localhost:8081/swagger-ui/index.html +``` + +To stop the infrastructure: + +```bash +docker compose down +``` + +--- + +## API Documentation + +Each service exposes its own OpenAPI specification and Swagger UI at `/swagger-ui/index.html`. Representative endpoints: + +| Service | Method | Path | Purpose | +|---|---|---|---| +| wallet-service | `POST` | `/accounts/{id}/holds` | Reserve funds ahead of a capture | +| wallet-service | `GET` | `/accounts/{id}/balance` | Ledger and available balance | +| loan-service | `POST` | `/loans/{id}/approve` | Generate the amortization schedule | +| loan-service | `POST` | `/loans/{id}/repayments` | Apply a payment through the waterfall | +| collateral-service | `GET` | `/collateral/{id}` | Current position and live LTV | +| collateral-service | `POST` | `/collateral/refresh-prices` | Re-evaluate risk against latest prices | + +The OpenAPI document for each service is the source of truth for exact request/response schemas. + +--- + +## Code Quality + +| Check | Tool | Execution | +|---|---|---| +| Build & tests | Maven | CI and local | +| Formatting / static analysis | *(planned)* | CI | + +```bash +./mvnw clean verify +``` + +Install the local Git hook so every `git push` runs the same verification first: + +```bash +./mvnw -Pinstall-git-hooks initialize +``` + +--- + +## Roadmap + +### wallet-service +- [ ] Account CRUD +- [ ] Direct transfer flow (Flow A) +- [ ] Hold → capture/release flow (Flow B) +- [ ] Idempotency-key enforcement + +### loan-service +- [ ] Loan application and state machine +- [ ] Amortization schedule generation +- [ ] Repayment waterfall +- [ ] Delinquency/default detection + +### collateral-service +- [ ] Collateral locking +- [ ] Price-oracle integration +- [ ] LTV monitoring and margin calls +- [ ] Liquidation flow + +### Platform +- [ ] API Gateway (REST → gRPC) +- [ ] Kafka domain events +- [ ] Full CI via GitHub Actions + +--- + +## Current Limitations + +- PayGuard is an engineering-demonstration project, not a production lending platform. +- No real price feed, KYC, or payment-network integration is connected; these are simulated or stubbed. +- Continuous deployment is out of scope for the current phase — the focus is on correctness and depth of the core domain services. +- Each service currently assumes a single instance; distributed-lock behavior for holds is documented but not yet load-tested at scale. + +--- + +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..6bb8e6b --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,68 @@ +name: ${COMPOSE_PROJECT_NAME} + +services: + oracle: + image: gvenzl/oracle-free:23-slim-faststart + container_name: payguard-oracle + ports: + - "${ORACLE_PORT}:1521" + environment: + ORACLE_PASSWORD: ${ORACLE_PASSWORD} + APP_USER: ${ORACLE_APP_USER} + APP_PASSWORD: ${ORACLE_APP_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "/opt/oracle/checkDBStatus.sh"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + volumes: + - oracle_data:/opt/oracle/oradata + + redis: + image: redis:7-alpine + container_name: payguard-redis + ports: + - "${REDIS_PORT}:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 5s + volumes: + - redis_data:/data + + kafka: + image: apache/kafka:latest + container_name: payguard-kafka + ports: + - "${KAFKA_PORT}:9092" + environment: + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_LISTENERS: INTERNAL://0.0.0.0:29092,EXTERNAL://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://localhost:${KAFKA_PORT} + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_LOG_DIRS: /tmp/kraft-combined-logs + CLUSTER_ID: ${KAFKA_CLUSTER_ID} + healthcheck: + test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:29092 --list"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + volumes: + - kafka_data:/tmp/kraft-combined-logs + +volumes: + oracle_data: + redis_data: + kafka_data: diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..cd53718 --- /dev/null +++ b/renovate.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "baseBranchPatterns": [ + "main" + ], + "branchPrefix": "chore/update-", + "semanticCommits": "enabled", + "semanticCommitType": "chore", + "semanticCommitScope": "deps", + "commitMessageAction": "update", + "commitMessageTopic": "{{depName}}", + "commitMessageExtra": "to {{newVersion}}", + "prBodyTemplate": "## Summary\n\nAutomated dependency update managed by Renovate.\n\n## Verification\n\nReview the GitHub Actions results for the current commit.\n\n## Risks\n\nReview the release notes below for potential breaking changes.\n\n## Database Changes\n\nNone\n\n---\n\n### Renovate Details\n\n{{{table}}}\n\n{{{warnings}}}\n\n{{{notes}}}\n\n{{{changelogs}}}", + "packageRules": [ + { + "matchUpdateTypes": [ + "minor", + "patch" + ], + "groupName": "all minor and patch dependencies", + "commitMessageTopic": "non-major dependencies", + "matchPackageNames": [ + "*" + ] + } + ] +}