Academic prototype · Phase 0 foundation
Cross-border payment prototype — complete Phase 0 project
From inside the folder, after creating the files below:
cp .env.example .env docker compose up -d --build # or Ctrl+Shift+B in VS Code # 8080/actuator/health · 8080/swagger-ui.html · 8000/docs mvn -B -f backend/pom.xml verify # needs Docker running (Testcontainers)
/path/to/jdk-21 values in
.vscode/settings.json. Maven inherits that JDK; a stale path gives a
confusing failure instead of an obvious one.
| # | Path | # | Path |
|---|---|---|---|
| 1 | .env.example | 14 | …/V1__baseline.sql |
| 2 | .gitignore | 15 | …/V2__identity.sql |
| 3 | README.md | 16 | …/V3__kyc.sql |
| 4 | docker-compose.yml | 17 | …/V4__beneficiaries.sql |
| 5 | .vscode/launch.json | 18 | …/V5__currencies_and_fx.sql |
| 6 | .vscode/tasks.json | 19 | …/V6__fees.sql |
| 7 | .vscode/settings.json | 20 | …/V7__transfers.sql |
| 8 | .vscode/extensions.json | 21 | …/RemitFlowApplicationIT.java |
| 9 | docs/api-conventions.md | 22 | fraud-service/Dockerfile |
| 10 | backend/Dockerfile | 23 | fraud-service/requirements.txt |
| 11 | backend/pom.xml | 24 | fraud-service/pytest.ini |
| 12 | backend/src/main/resources/application.yml | 25 | fraud-service/app/__init__.py |
| 13 | …/RemitFlowApplication.java | 26 | fraud-service/app/main.py |
| 14 | …/web/PingController.java | 27 | fraud-service/tests/test_score.py |
| 15 | …/web/ApiExceptionHandler.java | 28 | fraud-service/artifacts/.gitkeep (empty) |
| 16 | mobile/README.md + admin-web/README.md(one line each) |
# Dev-only defaults. This prototype must never carry real secrets. POSTGRES_DB=remitflow POSTGRES_USER=remitflow POSTGRES_PASSWORD=remitflow POSTGRES_PORT=5432 SERVER_PORT=8080 FRAUD_PORT=8000 # Host-side URL (used when the backend runs from VS Code, not in Docker) FRAUD_SERVICE_URL=http://localhost:8000 FRAUD_MODEL_PATH= PROVIDER_MODE=sandbox
target/ .idea/ *.iml .vscode/ .env __pycache__/ *.pyc .venv/ node_modules/ *.joblib .DS_Store
.vscode/ while this project ships launch configs
inside it. If you want them committed, add !.vscode/ plus
!.vscode/*.json at the end.
# RemitFlow: cross-border payment prototype
Academic prototype of a cross-border money transfer app. It uses a **mock/sandbox payment provider only**
and is **not** a licensed money-transfer service. Never use real customer data or real funds.
## Repo layout
| Path | Purpose |
|---|---|
| `backend/` | Java 21 + Spring Boot 3.3 modular monolith, Flyway migrations |
| `fraud-service/` | Python + FastAPI fraud scoring service (stub in Phase 0) |
| `mobile/` | React Native app (Phase 1b onward) |
| `admin-web/` | Admin dashboard (Phase 7) |
| `docs/` | Architecture and API conventions |
## Run everything
docker compose up --build
- API: http://localhost:8080 (Swagger UI at `/swagger-ui.html`, health at `/actuator/health`)
- Fraud service: http://localhost:8000/docs
- Postgres: localhost:5432 (defaults in `docker-compose.yml`; override with a `.env` copied from `.env.example`)
## Run tests
cd backend && mvn verify # needs Docker running (Testcontainers)
cd fraud-service && pip install -r requirements.txt && pytest
## Database
Schema is owned by Flyway (`backend/src/main/resources/db/migration`, V1 to V7). Hibernate only validates.
Never edit an applied migration; add a new `V<n>__*.sql`.
## Roadmap
0 Foundation (this) - 1 Identity - 2 KYC + beneficiaries - 3 FX + fees - 4 Transfers + provider adapter -
5 AI fraud - 6 Refunds + reconciliation - 7 Admin dashboard - 8 Hardening - 9 Deploy + docsname: remitflow
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-remitflow}
POSTGRES_USER: ${POSTGRES_USER:-remitflow}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-remitflow}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remitflow} -d ${POSTGRES_DB:-remitflow}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
fraud-service:
build: ./fraud-service
ports:
- "${FRAUD_PORT:-8000}:8000"
environment:
FRAUD_MODEL_PATH: /app/artifacts/fraud_model.joblib
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health', timeout=2).status == 200 else 1)"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
backend:
build: ./backend
ports:
- "${SERVER_PORT:-8080}:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-remitflow}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-remitflow}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-remitflow}
FRAUD_SERVICE_URL: http://fraud-service:8000
depends_on:
postgres:
condition: service_healthy
fraud-service:
condition: service_started
volumes:
pgdata:{
"version": "0.2.0",
"configurations": [
{
"name": "Doctor: full run (trace everything)",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/scripts/dev_doctor.py",
"console": "integratedTerminal",
"cwd": "${workspaceFolder}",
"justMyCode": false
},
{
"name": "fraud-service: uvicorn (reload)",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": ["app.main:app", "--reload", "--port", "8000"],
"cwd": "${workspaceFolder}/fraud-service",
"console": "integratedTerminal",
"justMyCode": false
},
{
"name": "fraud-service: pytest (current test)",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"args": ["-q", "${file}"],
"cwd": "${workspaceFolder}/fraud-service",
"console": "integratedTerminal",
"justMyCode": false
},
{
"name": "backend: Spring Boot (needs Postgres up)",
"type": "java",
"request": "launch",
"mainClass": "com.remitflow.backend.RemitFlowApplication",
"projectName": "backend",
"envFile": "${workspaceFolder}/.env",
"console": "internalConsole"
}
],
"compounds": [
{
"name": "Stack: doctor + fraud-service",
"configurations": [
"Doctor: full run (trace everything)",
"fraud-service: uvicorn (reload)"
],
"stopAll": true
}
]
}{
"version": "2.0.0",
"tasks": [
{
"label": "compose: up",
"type": "shell",
"command": "docker compose up -d --build",
"problemMatcher": [],
"group": { "kind": "build", "isDefault": true }
},
{
"label": "compose: logs (follow)",
"type": "shell",
"command": "docker compose logs -f --no-color",
"problemMatcher": []
},
{
"label": "compose: reset dev db (deletes volumes)",
"type": "shell",
"command": "docker compose down -v",
"problemMatcher": []
},
{
"label": "backend: mvn verify",
"type": "shell",
"command": "mvn -B -f backend/pom.xml verify",
"problemMatcher": []
}
]
}{
"java.configuration.runtimes": [
{ "name": "JavaSE-21", "path": "/path/to/jdk-21", "default": true }
],
"java.jdt.ls.java.home": "/path/to/jdk-21",
"python.defaultInterpreterPath": "${workspaceFolder}/fraud-service/.venv/bin/python",
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": ["tests"],
"python.testing.cwd": "${workspaceFolder}/fraud-service",
"files.exclude": {
"**/__pycache__": true,
"**/.venv": true,
"**/target": true
},
"editor.formatOnSave": true
}"${workspaceFolder}/fraud-service/.venv/Scripts/python.exe".
{
"recommendations": [
"vscjava.vscode-java-pack",
"vmware.vscode-boot-dev-pack",
"ms-python.python",
"ms-python.debugpy",
"ms-azuretools.vscode-docker",
"redhat.vscode-xml"
]
}# API conventions Applies to `backend/` now, and to `mobile/` + `admin-web/` when they land. ## Paths and versioning - Everything is versioned under `/api/v1`. A breaking change means `/api/v2`, never a silent change to an existing payload. - Ops endpoints are not versioned: `/actuator/health`, `/actuator/info`. ## Money - Amounts are **integer minor units** (`bigint`, e.g. `10050` = 100.50), in a field ending `_minor`. Never floats. - Currencies are ISO-4217 alpha-3 (`USD`, `INR`). Countries are ISO-3166-1 alpha-2 (`US`, `IN`). ## Time - All timestamps are ISO-8601 **UTC** (`2026-09-29T14:03:00Z`). No local offsets, no epoch integers. ## Errors - Errors use RFC 7807 `application/problem+json` with `title`, `detail`, `status`. `ApiExceptionHandler` is the single place that builds them. ## Transfers - `POST /api/v1/transfers` requires an `Idempotency-Key` header. Replaying the same key returns the original transfer, never a second one. The partial unique index in `V7__transfers.sql` enforces this, not application code. - Status transitions are append-only: every change writes a `transfer_events` row. Never update a status without an event. ## Data - Mock/sandbox provider responses only. No real customer PII, no real funds, no real account numbers in any environment.
# Multi-stage: no local Maven or JDK needed to build the image. FROM maven:3.9-eclipse-temurin-21 AS build WORKDIR /workspace COPY pom.xml ./ COPY src ./src RUN mvn -B -DskipTests package FROM eclipse-temurin:21-jre WORKDIR /app RUN useradd --system --uid 1001 appuser COPY --from=build /workspace/target/*.jar /app/app.jar USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app/app.jar"]
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>com.remitflow</groupId>
<artifactId>backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>remitflow-backend</name>
<description>RemitFlow modular monolith (Phase 0 foundation). Mock provider only.</description>
<properties>
<java.version>21</java.version>
<springdoc.version>2.6.0</springdoc.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Flyway 10 needs the vendor module for Postgres, not just flyway-core -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- *IT classes run on `mvn verify`; surefire only sees *Test -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>spring:
application:
name: remitflow-backend
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/remitflow}
username: ${SPRING_DATASOURCE_USERNAME:remitflow}
password: ${SPRING_DATASOURCE_PASSWORD:remitflow}
hikari:
maximum-pool-size: 10
jpa:
# Flyway owns the schema. Hibernate only validates. Never "update" here.
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
jdbc:
time_zone: UTC
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: false
jackson:
default-property-inclusion: non_null
server:
port: ${SERVER_PORT:8080}
shutdown: graceful
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: always
probes:
enabled: true
springdoc:
swagger-ui:
path: /swagger-ui.html
logging:
level:
org.flywaydb: INFO
com.remitflow: DEBUGpackage com.remitflow.backend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RemitFlowApplication {
public static void main(String[] args) {
SpringApplication.run(RemitFlowApplication.class, args);
}
}package com.remitflow.backend.web;
import java.time.Duration;
import java.time.Instant;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Phase 0 liveness endpoint: proves the web layer, the datasource (via
* /actuator/health) and the docs are wired before any domain code exists.
*/
@RestController
@RequestMapping("/api/v1")
public class PingController {
private static final Instant STARTED_AT = Instant.now();
@GetMapping("/ping")
public Ping ping() {
return new Ping("remitflow", "backend", "phase-0",
Duration.between(STARTED_AT, Instant.now()).toMillis());
}
public record Ping(String app, String service, String phase, long uptimeMillis) {
}
}package com.remitflow.backend.web;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** Single place that turns failures into RFC 7807 problem+json (docs/api-conventions.md). */
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail onValidationFailure(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Request validation failed");
problem.setTitle("Validation failed");
List<String> errors = ex.getBindingResult().getFieldErrors().stream()
.map(field -> field.getField() + ": " + field.getDefaultMessage())
.toList();
problem.setProperty("errors", errors);
return problem;
}
}-- Phase 0 foundation: extensions and the shared updated_at trigger function.
-- Money convention: bigint minor units + ISO-4217 char(3). Ids: uuid via pgcrypto.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION set_updated_at() IS
'Phase 0 helper: attach as a BEFORE UPDATE trigger so updated_at is never set by hand.';-- Phase 1 (Identity) schema only. No JPA entities yet, so Hibernate validate has
-- nothing to compare against until you add them in Phase 1.
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL,
password_hash text NOT NULL,
full_name text NOT NULL,
country char(2) NOT NULL,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'locked', 'closed')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- case-insensitive uniqueness without needing citext
CREATE UNIQUE INDEX users_email_lower_uidx ON users (lower(email));
CREATE TRIGGER users_set_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE user_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
refresh_hash text NOT NULL,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX user_sessions_user_idx ON user_sessions (user_id, expires_at DESC);-- Phase 2 (KYC) schema only. Documents store a storage reference and a hash,
-- never the document bytes.
CREATE TABLE kyc_checks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider text NOT NULL DEFAULT 'sandbox',
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'in_review', 'approved', 'rejected', 'expired')),
reason text,
submitted_at timestamptz NOT NULL DEFAULT now(),
decided_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX kyc_checks_user_idx ON kyc_checks (user_id, submitted_at DESC);
CREATE TRIGGER kyc_checks_set_updated_at
BEFORE UPDATE ON kyc_checks
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE kyc_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kyc_check_id uuid NOT NULL REFERENCES kyc_checks(id) ON DELETE CASCADE,
doc_type text NOT NULL CHECK (doc_type IN ('passport', 'national_id', 'drivers_licence', 'proof_of_address')),
storage_ref text NOT NULL,
sha256 char(64) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX kyc_documents_check_idx ON kyc_documents (kyc_check_id);-- Phase 2 (beneficiaries). account_ref is a sandbox placeholder -- never a real
-- account number.
CREATE TABLE beneficiaries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
display_name text NOT NULL,
account_ref text NOT NULL,
country char(2) NOT NULL,
currency char(3) NOT NULL,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'archived', 'blocked')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, account_ref, currency)
);
CREATE INDEX beneficiaries_user_idx ON beneficiaries (user_id, status);
CREATE TRIGGER beneficiaries_set_updated_at
BEFORE UPDATE ON beneficiaries
FOR EACH ROW EXECUTE FUNCTION set_updated_at();-- Phase 3 (FX). minor_units drives formatting everywhere: 2 for USD, 0 for JPY.
CREATE TABLE currencies (
code char(3) PRIMARY KEY,
name text NOT NULL,
minor_units smallint NOT NULL CHECK (minor_units BETWEEN 0 AND 3),
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO currencies (code, name, minor_units) VALUES
('USD', 'US dollar', 2),
('EUR', 'Euro', 2),
('GBP', 'Pound sterling', 2),
('INR', 'Indian rupee', 2),
('AED', 'UAE dirham', 2),
('PHP', 'Philippine peso', 2),
('NGN', 'Nigerian naira', 2)
ON CONFLICT (code) DO NOTHING;
CREATE TABLE fx_rates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
base_code char(3) NOT NULL REFERENCES currencies(code),
quote_code char(3) NOT NULL REFERENCES currencies(code),
rate numeric(24,10) NOT NULL CHECK (rate > 0),
source text NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (base_code <> quote_code)
);
CREATE INDEX fx_rates_lookup_idx ON fx_rates (base_code, quote_code, fetched_at DESC);-- Phase 3 (fees). Charge = fixed_minor + percent_bps of the send amount.
-- 1 basis point = 0.01%.
CREATE TABLE fee_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
corridor_from char(2) NOT NULL,
corridor_to char(2) NOT NULL,
currency char(3) NOT NULL,
fixed_minor bigint NOT NULL DEFAULT 0 CHECK (fixed_minor >= 0),
percent_bps integer NOT NULL DEFAULT 0 CHECK (percent_bps BETWEEN 0 AND 5000),
active boolean NOT NULL DEFAULT true,
effective_from timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (corridor_from <> corridor_to)
);
CREATE UNIQUE INDEX fee_rules_effective_uidx
ON fee_rules (corridor_from, corridor_to, currency, effective_from);
CREATE INDEX fee_rules_lookup_idx
ON fee_rules (corridor_from, corridor_to, currency, effective_from DESC)
WHERE active;-- Phase 4 (transfers + provider adapter). Status changes are append-only: every
-- transition also writes a transfer_events row.
CREATE TABLE transfers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
public_ref text NOT NULL UNIQUE,
user_id uuid NOT NULL REFERENCES users(id),
beneficiary_id uuid NOT NULL REFERENCES beneficiaries(id),
send_currency char(3) NOT NULL REFERENCES currencies(code),
receive_currency char(3) NOT NULL REFERENCES currencies(code),
send_amount_minor bigint NOT NULL CHECK (send_amount_minor > 0),
fee_minor bigint NOT NULL DEFAULT 0 CHECK (fee_minor >= 0),
fx_rate numeric(24,10) CHECK (fx_rate IS NULL OR fx_rate > 0),
receive_amount_minor bigint CHECK (receive_amount_minor IS NULL OR receive_amount_minor >= 0),
status text NOT NULL DEFAULT 'created'
CHECK (status IN ('created', 'quoted', 'awaiting_funds', 'funded',
'submitted', 'paid_out', 'failed', 'cancelled', 'refunded')),
provider text NOT NULL DEFAULT 'sandbox',
provider_ref text,
idempotency_key text,
risk_score numeric(5,4) CHECK (risk_score IS NULL OR (risk_score >= 0 AND risk_score <= 1)),
failure_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX transfers_user_idx ON transfers (user_id, created_at DESC);
CREATE INDEX transfers_status_idx ON transfers (status, created_at DESC);
CREATE INDEX transfers_provider_ref_idx ON transfers (provider, provider_ref);
-- Retrying a POST with the same key must never create a second transfer.
CREATE UNIQUE INDEX transfers_idempotency_uidx
ON transfers (user_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE TRIGGER transfers_set_updated_at
BEFORE UPDATE ON transfers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE transfer_events (
id bigserial PRIMARY KEY,
transfer_id uuid NOT NULL REFERENCES transfers(id) ON DELETE CASCADE,
event_type text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX transfer_events_transfer_idx ON transfer_events (transfer_id, id);package com.remitflow.backend;
import static org.assertj.core.api.Assertions.assertThat;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/** Needs Docker running: Testcontainers starts a throwaway Postgres. */
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RemitFlowApplicationIT {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("remitflow")
.withUsername("remitflow")
.withPassword("remitflow");
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
@Autowired
private TestRestTemplate rest;
@Autowired
private DataSource dataSource;
@Test
void flywayAppliedEveryMigration() throws Exception {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement();
ResultSet rows = statement.executeQuery(
"SELECT count(*) FROM flyway_schema_history WHERE success = true")) {
assertThat(rows.next()).isTrue();
assertThat(rows.getInt(1)).as("V1..V7 must all be applied").isGreaterThanOrEqualTo(7);
}
}
@Test
void healthEndpointReportsUp() {
var response = rest.getForEntity("/actuator/health", String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).contains("\"status\":\"UP\"");
}
@Test
void pingEndpointAnswers() {
assertThat(rest.getForObject("/api/v1/ping", String.class)).contains("remitflow");
}
}FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY tests ./tests
COPY pytest.ini ./
RUN mkdir -p /app/artifacts
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]fastapi>=0.115 uvicorn[standard]>=0.30 pydantic>=2.7 joblib>=1.4 pytest>=8.0 httpx>=0.27 # required by fastapi.testclient # scikit-learn>=1.5 # uncomment in Phase 5, when a real model is trained
[pytest] testpaths = tests addopts = -q
"""RemitFlow fraud service (Phase 0 heuristic stub). Mock/sandbox data only."""
"""Phase 0 scoring stub.
Boots with NO model artifact present (*.joblib is gitignored, so a fresh clone
has none) and degrades to a deterministic heuristic. Phase 5 swaps the heuristic
for a trained model loaded from FRAUD_MODEL_PATH.
Academic prototype: mock/sandbox data only. Not a licensed service, not a real
fraud model, never real customer data or funds.
"""
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator
SERVICE_VERSION = "0.1.0-phase0"
DEFAULT_MODEL = Path(__file__).resolve().parents[1] / "artifacts" / "fraud_model.joblib"
MODEL_PATH = Path(os.getenv("FRAUD_MODEL_PATH") or DEFAULT_MODEL)
# Phase 0 placeholder corridor list; Phase 5 replaces this with a real risk feed.
HIGH_RISK_CORRIDORS = {("IN", "AE"), ("US", "NG"), ("GB", "PK"), ("AE", "PK")}
STATE: dict[str, object] = {"model": None, "model_version": "heuristic-0.1.0", "mode": "heuristic"}
def _load_model() -> None:
"""Load the joblib model if it exists. A missing artifact is NOT an error in Phase 0."""
if not MODEL_PATH.exists():
print(f"[fraud] no model at {MODEL_PATH} -> heuristic mode (expected in Phase 0)")
return
try:
import joblib # lazy: the service must boot even without scikit-learn installed
bundle = joblib.load(MODEL_PATH)
STATE["model"] = bundle
STATE["model_version"] = str(getattr(bundle, "version", "joblib-unknown"))
STATE["mode"] = "model"
print(f"[fraud] loaded model {MODEL_PATH} ({STATE['model_version']})")
except Exception as exc: # noqa: BLE001 - a broken artifact must not stop the API
print(f"[fraud] could not load {MODEL_PATH}: {exc!r} -> heuristic mode")
@asynccontextmanager
async def lifespan(_: FastAPI):
_load_model()
yield
STATE["model"] = None
app = FastAPI(
title="RemitFlow fraud service",
version=SERVICE_VERSION,
description="Phase 0 heuristic stub. Mock/sandbox data only - not a real fraud model.",
lifespan=lifespan,
)
class ScoreRequest(BaseModel):
model_config = {"extra": "forbid"} # reject misspelled fields instead of ignoring them
transfer_id: str | None = None
amount_minor: int = Field(..., gt=0, description="amount in minor units, e.g. cents")
currency: str = Field(..., min_length=3, max_length=3)
sender_country: str = Field(..., min_length=2, max_length=2)
receiver_country: str = Field(..., min_length=2, max_length=2)
channel: Literal["mobile", "web", "api"] = "mobile"
new_beneficiary: bool = False
sender_account_age_days: int = Field(0, ge=0, le=36500)
transfers_last_24h: int = Field(0, ge=0, le=1000)
@field_validator("currency", "sender_country", "receiver_country")
@classmethod
def _normalise(cls, value: str) -> str:
return value.strip().upper()
class ScoreResponse(BaseModel):
transfer_id: str | None
score: float = Field(..., ge=0.0, le=1.0)
band: Literal["low", "medium", "high"]
decision: Literal["allow", "review", "block"]
reasons: list[str]
mode: Literal["model", "heuristic"]
model_version: str
scored_at: datetime
class HealthResponse(BaseModel):
status: Literal["ok"]
mode: Literal["model", "heuristic"]
model_version: str
version: str
def _heuristic(req: ScoreRequest) -> tuple[float, list[str]]:
score = 0.02
reasons: list[str] = []
if req.amount_minor >= 500_000: # >= 5,000.00 in minor units
score += 0.30
reasons.append("amount above 5,000 corridor limit")
elif req.amount_minor >= 100_000:
score += 0.15
reasons.append("amount above 1,000")
if req.new_beneficiary:
score += 0.20
reasons.append("first transfer to this beneficiary")
if req.sender_account_age_days < 3:
score += 0.20
reasons.append("account younger than 3 days")
elif req.sender_account_age_days < 30:
score += 0.08
reasons.append("account younger than 30 days")
if req.transfers_last_24h >= 10:
score += 0.30
reasons.append("10+ transfers in the last 24h")
elif req.transfers_last_24h >= 5:
score += 0.15
reasons.append("5+ transfers in the last 24h")
if (req.sender_country, req.receiver_country) in HIGH_RISK_CORRIDORS:
score += 0.15
reasons.append(f"elevated-risk corridor {req.sender_country}->{req.receiver_country}")
if req.channel == "api":
score += 0.05
reasons.append("programmatic channel")
return round(min(score, 0.99), 4), reasons or ["no risk factors matched"]
def _band(score: float) -> tuple[str, str]:
if score < 0.35:
return "low", "allow"
if score < 0.70:
return "medium", "review"
return "high", "block"
@app.get("/health", response_model=HealthResponse, tags=["ops"])
def health() -> HealthResponse:
return HealthResponse(
status="ok",
mode=STATE["mode"], # type: ignore[arg-type]
model_version=str(STATE["model_version"]),
version=SERVICE_VERSION,
)
@app.get("/version", tags=["ops"])
def version() -> dict[str, object]:
return {
"service": "fraud-service",
"version": SERVICE_VERSION,
"mode": STATE["mode"],
"model_path": str(MODEL_PATH),
"model_present": MODEL_PATH.exists(),
}
@app.post("/score", response_model=ScoreResponse, tags=["scoring"])
def score(req: ScoreRequest) -> ScoreResponse:
"""Score one transfer. Deterministic and safe to call from tests."""
model = STATE["model"]
if model is not None and hasattr(model, "predict_proba"):
features = [[req.amount_minor, int(req.new_beneficiary),
req.sender_account_age_days, req.transfers_last_24h]]
score_value = float(model.predict_proba(features)[0][1])
reasons = ["model prediction"]
else:
score_value, reasons = _heuristic(req)
band, decision = _band(score_value)
return ScoreResponse(
transfer_id=req.transfer_id,
score=score_value,
band=band, # type: ignore[arg-type]
decision=decision, # type: ignore[arg-type]
reasons=reasons,
mode=STATE["mode"], # type: ignore[arg-type]
model_version=str(STATE["model_version"]),
scored_at=datetime.now(timezone.utc),
)
if __name__ == "__main__": # lets VS Code F5 run this file directly
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
LOW = {
"amount_minor": 25_000,
"currency": "usd",
"sender_country": "us",
"receiver_country": "mx",
"sender_account_age_days": 400,
}
def test_health_is_ok_without_a_model_artifact():
body = client.get("/health").json()
assert body["status"] == "ok"
assert body["mode"] in {"heuristic", "model"} # boots even with no *.joblib present
def test_low_risk_transfer_is_allowed():
response = client.post("/score", json=LOW)
assert response.status_code == 200, response.text
body = response.json()
assert body["decision"] == "allow"
assert 0.0 <= body["score"] <= 1.0
assert body["reasons"]
def test_high_risk_transfer_escalates():
risky = {
**LOW,
"amount_minor": 900_000,
"new_beneficiary": True,
"sender_account_age_days": 1,
"transfers_last_24h": 12,
"receiver_country": "ng",
}
body = client.post("/score", json=risky).json()
assert body["decision"] in {"review", "block"}
assert body["band"] in {"medium", "high"}
def test_unknown_field_is_rejected_not_ignored():
response = client.post("/score", json={**LOW, "amount": "typo"})
assert response.status_code == 422
def test_countries_and_currency_are_normalised():
body = client.post("/score", json={**LOW, "currency": " inr", "sender_country": "in"}).json()
assert body["mode"] in {"heuristic", "model"}(empty file — keeps the directory so FRAUD_MODEL_PATH resolves cleanly; *.joblib itself is gitignored)
React Native app — **empty in Phase 0**, arrives in Phase 1b onward. Nothing to install here yet; Node tooling is not part of the Phase 0 stack.
Admin dashboard — **empty in Phase 0**, arrives in Phase 7.
Not required to run the project. It’s the run-once + error-tracer from an earlier
message — copy it in and press F5 on Doctor: full run if a step fails.
mkdir -p .vscode docs scripts \
backend/src/main/java/com/remitflow/backend/web \
backend/src/main/resources/db/migration \
backend/src/test/java/com/remitflow/backend \
fraud-service/app fraud-service/tests fraud-service/artifacts \
mobile admin-web| Check | Expected |
|---|---|
docker compose config -q | silent, exit 0 |
docker compose ps | 3 services healthy/running |
/actuator/health | {"status":"UP"} |
/api/v1/ping | {"app":"remitflow","service":"backend","phase":"phase-0",...} |
flyway_schema_history | 7 rows, all success = true |
fraud /health | {"status":"ok","mode":"heuristic",...} |
mvn verify | Tests run: 3, Failures: 0 |
RemitFlow — Phase 0 foundation. Academic prototype; mock/sandbox payment provider only. Never real customer data, never real funds. Not a licensed money-transfer service.