Nothing happened? Press Ctrl+P (⌘+P on Mac) and choose “Save as PDF”.

Academic prototype · Phase 0 foundation

RemitFlow

Cross-border payment prototype — complete Phase 0 project

Mock/sandbox provider only. Never real data, never real funds. Not a licensed money-transfer service.
Phase
0 — Foundation
Backend
Java 21 · Spring Boot 3.3.5
Data
Postgres 16 · Flyway V1–V7
Fraud service
Python 3.12 · FastAPI

Quickstart

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)
Before you run: edit the two /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.

Create these 26 files, in this order

#Path#Path
1.env.example14…/V1__baseline.sql
2.gitignore15…/V2__identity.sql
3README.md16…/V3__kyc.sql
4docker-compose.yml17…/V4__beneficiaries.sql
5.vscode/launch.json18…/V5__currencies_and_fx.sql
6.vscode/tasks.json19…/V6__fees.sql
7.vscode/settings.json20…/V7__transfers.sql
8.vscode/extensions.json21…/RemitFlowApplicationIT.java
9docs/api-conventions.md22fraud-service/Dockerfile
10backend/Dockerfile23fraud-service/requirements.txt
11backend/pom.xml24fraud-service/pytest.ini
12backend/src/main/resources/application.yml25fraud-service/app/__init__.py
13…/RemitFlowApplication.java26fraud-service/app/main.py
14…/web/PingController.java27fraud-service/tests/test_score.py
15…/web/ApiExceptionHandler.java28fraud-service/artifacts/.gitkeep (empty)
16mobile/README.md + admin-web/README.md
(one line each)

Contents


File 1.env.example

# 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

File 2.gitignore

target/
.idea/
*.iml
.vscode/
.env
__pycache__/
*.pyc
.venv/
node_modules/
*.joblib
.DS_Store
Note: this ignores .vscode/ while this project ships launch configs inside it. If you want them committed, add !.vscode/ plus !.vscode/*.json at the end.

File 3README.md

# 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 + docs

File 4docker-compose.yml

name: 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:

File 5.vscode/launch.json

{
  "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
    }
  ]
}

File 6.vscode/tasks.json

{
  "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": []
    }
  ]
}

File 7.vscode/settings.json — edit the two Java paths

{
  "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
}
On Windows the interpreter line becomes "${workspaceFolder}/fraud-service/.venv/Scripts/python.exe".

File 8.vscode/extensions.json

{
  "recommendations": [
    "vscjava.vscode-java-pack",
    "vmware.vscode-boot-dev-pack",
    "ms-python.python",
    "ms-python.debugpy",
    "ms-azuretools.vscode-docker",
    "redhat.vscode-xml"
  ]
}

File 9docs/api-conventions.md

# 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.

File 10backend/Dockerfile

# 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"]

File 11backend/pom.xml

<?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>

File 12backend/src/main/resources/application.yml

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: DEBUG

File 13backend/src/main/java/com/remitflow/backend/RemitFlowApplication.java

package 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);
    }
}

File 14backend/src/main/java/com/remitflow/backend/web/PingController.java

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) {
    }
}

File 15backend/src/main/java/com/remitflow/backend/web/ApiExceptionHandler.java

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;
    }
}

File 16backend/src/main/resources/db/migration/V1__baseline.sql

-- 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.';

File 17backend/src/main/resources/db/migration/V2__identity.sql

-- 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);

File 18backend/src/main/resources/db/migration/V3__kyc.sql

-- 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);

File 19backend/src/main/resources/db/migration/V4__beneficiaries.sql

-- 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();

File 20backend/src/main/resources/db/migration/V5__currencies_and_fx.sql

-- 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);

File 21backend/src/main/resources/db/migration/V6__fees.sql

-- 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;

File 22backend/src/main/resources/db/migration/V7__transfers.sql

-- 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);

File 23backend/src/test/java/com/remitflow/backend/RemitFlowApplicationIT.java

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");
    }
}

File 24fraud-service/Dockerfile

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"]

File 25fraud-service/requirements.txt

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

File 26fraud-service/pytest.ini

[pytest]
testpaths = tests
addopts = -q

File 27fraud-service/app/__init__.py

"""RemitFlow fraud service (Phase 0 heuristic stub). Mock/sandbox data only."""

File 28fraud-service/app/main.py

"""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)

File 29fraud-service/tests/test_score.py

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"}

File 30fraud-service/artifacts/.gitkeep

(empty file — keeps the directory so FRAUD_MODEL_PATH resolves cleanly; *.joblib itself is gitignored)

Files 31–32placeholders

mobile/README.md

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-web/README.md

Admin dashboard — **empty in Phase 0**, arrives in Phase 7.

Optional: scripts/dev_doctor.py

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.

Create the folders first (any OS)

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

What green looks like

CheckExpected
docker compose config -qsilent, exit 0
docker compose ps3 services healthy/running
/actuator/health{"status":"UP"}
/api/v1/ping{"app":"remitflow","service":"backend","phase":"phase-0",...}
flyway_schema_history7 rows, all success = true
fraud /health{"status":"ok","mode":"heuristic",...}
mvn verifyTests 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.