Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ playwright-report
test-results
pnpm-lock.yaml
apps/ssi-control-tower
apps/payment-intelligence-pilot
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ The root suite remains static and browser-only. Standalone adjacent applications

## Modules

| Module | Route | Status | What it does |
| --------------------------- | -------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Scrubber** | `/scrubber` | Available | Strip personally identifying fields from `pacs.*` / `camt.*` XML before sharing with peers or vendors. Produces a privacy-safe mapping summary. |
| **Storyteller** | `/storyteller` | Available | Turn a `pacs.002 / 004 / 008 / 009` or `camt.052 / 053 / 054` message into a plain-language narrative plus a structured field projection and quick insights. |
| **IBAN Workbench** | `/iban` | Available | Validate, build, catalogue, and trace provenance for IBANs from bundled SWIFT IBAN Registry-derived data. Builder computes MOD-97 check digits from exact-length BBAN fields; no live BIC, VOP, account existence, or reachability checks. |
| **BIC Validator\*** | `/bic` | Demo | ISO 9362 syntax checks plus a tiny bundled snapshot lookup. **Demonstration only:** bundled BIC data is not accurate/current enough for production, routing, compliance, reachability, or payment decisions. |
| **CBPR+ Readiness Checker** | `/cbpr` | Available | Browser-only AppHdr / Document namespace / CBPR+ schema-profile coverage checks, plus UETR, BIC syntax, and IBAN syntax/checksum hints. Not a certified validator or MyStandards usage-rule engine. |
| **Payment Insights Lite** | `/insights` | Available | Local lifecycle insight over ACK/NACK, `pacs.*`, and `camt.*` files you provide. Groups files by identifiers in memory; not live payment tracking, VOP, reachability, or settlement monitoring. |
| Vault | | Planned | Planned encrypted local export bundle: user-controlled download/import, no cloud vault, no server storage, and no persistent browser storage by default. Not built yet. |
| Module | Route | Status | What it does |
| --------------------------- | -------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Scrubber** | `/scrubber` | Available | Strip personally identifying fields from `pacs.*` / `camt.*` XML before sharing with peers or vendors. Produces a privacy-safe mapping summary. |
| **Storyteller** | `/storyteller` | Available | Turn a `pacs.002 / 004 / 008 / 009` or `camt.052 / 053 / 054` message into a plain-language narrative plus a structured field projection and quick insights. |
| **IBAN Workbench** | `/iban` | Available | Validate, build, catalogue, and trace provenance for IBANs from bundled SWIFT IBAN Registry-derived data. Builder computes MOD-97 check digits from exact-length BBAN fields; no live BIC, VOP, account existence, or reachability checks. |
| **BIC Validator\*** | `/bic` | Demo | ISO 9362 syntax checks plus a tiny bundled snapshot lookup. **Demonstration only:** bundled BIC data is not accurate/current enough for production, routing, compliance, reachability, or payment decisions. |
| **CBPR+ Readiness Checker** | `/cbpr` | Available | Browser-only AppHdr / Document namespace / CBPR+ schema-profile coverage checks, plus UETR, BIC syntax, and IBAN syntax/checksum hints. Not a certified validator or MyStandards usage-rule engine. |
| **Payment Insights Lite** | `/insights` | Available | Local lifecycle insight over ACK/NACK, `pacs.*`, and `camt.*` files you provide. Groups files by identifiers in memory; not live payment tracking, VOP, reachability, or settlement monitoring. |
| Vault | `/vault` | Prototype | User-controlled encrypted local export/import prototype using browser crypto and file download/import only; no cloud vault, no server storage, and no persistent browser storage by default. Security threat model still required before sensitive use. |

\*The BIC module intentionally does **not** perform live BIC Directory lookup,
current bank-directory enrichment, current SEPA reachability checks, SWIFT FIN
Expand Down
7 changes: 7 additions & 0 deletions apps/payment-intelligence-pilot/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__/
*.db
.pytest_cache/
exports/
.venv/
*.egg-info/
data/
21 changes: 21 additions & 0 deletions apps/payment-intelligence-pilot/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
PYTHON ?= python3.11
VENV := .venv
PIP := $(VENV)/bin/pip
PY := $(VENV)/bin/python
UVICORN := $(VENV)/bin/uvicorn

$(PY): pyproject.toml
$(PYTHON) -m venv $(VENV)
$(PIP) install --upgrade pip
$(PIP) install -e ".[test]"

.PHONY: run test clean
run: $(PY)
$(UVICORN) app.main:app --host 0.0.0.0 --port 8100 --reload

test: $(PY)
$(PY) -m pytest -q

clean:
rm -rf $(VENV) .pytest_cache exports *.db __pycache__
find . -name "__pycache__" -type d -prune -exec rm -rf {} +
137 changes: 137 additions & 0 deletions apps/payment-intelligence-pilot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Payment Intelligence Pilot (DEMO / PROTOTYPE)

> **READ THIS FIRST.** This app is an **honest, gated prototype**. It is **NOT
> production**, **NOT the public static root suite**, and it performs **NO real
> external provider calls**. Every connector is **disabled**
> (`live_integration = False`). Authentication is a **DEMO STUB — NOT PRODUCTION
> AUTH**. All data is **synthetic**.

A small, self-contained FastAPI app scaffolding the **P2 (hosted private-pilot)**
and **P3 (enterprise / live-data readiness)** tiers of a payment-intelligence
workbench. It exists to *show the shape* of an operator workflow and a live-data
connector spine — with every dangerous capability deliberately gated off.

## What this is (and is not)

| | |
|---|---|
| **Is** | A local-only, offline, synthetic FastAPI prototype of an operator workbench (review queue, four-eyes approvals, immutable audit, readiness scoring) plus a **disabled** connector registry (P3). |
| **Is NOT** | Production. A live integration. The public static "root suite". A real authentication system. A source of real BIC/VOP/reachability/settlement/payment data. |

### Separation from the root suite

This app lives entirely under `apps/payment-intelligence-pilot/` and is a **fully
independent** application. It is **not** wired into, and shares no code or data
with, the public static root suite or `apps/ssi-control-tower/`. It only mirrors
the *patterns* of `ssi-control-tower` conceptually (app factory, immutable audit,
four-eyes, disabled provider registry); it does not import from it.

## The gate model (hard gates)

These are non-negotiable and enforced in code, tests, and API responses:

1. **No real external calls.** No `app/` runtime code imports any HTTP/socket/
SFTP/MQ client. (`httpx` is declared only as the transport FastAPI's
`TestClient` needs for the test suite — it is never imported by the app.) The
only connector "adapter" is `invoke_connector(...)`, a stub that **always
raises** `ConnectorDisabledError`.
2. **Every connector defaults `live_integration = False` and `enabled = False`.**
`register_connector()` *refuses* to register anything claiming to be live or
enabled. `gate_matrix()` asserts `all_disabled` and `live_integration_count == 0`.
3. **DEMO STUB auth.** `current_actor()` trusts the `X-User-Email` header verbatim
and maps it to a seeded demo role. No password, token, signature, or session.
It is labelled `DEMO STUB — NOT PRODUCTION AUTH` in code, README, and API
responses (`/`, `/health`, dashboard).
4. **Synthetic data only.** Fake BICs (e.g. `TESTGB2LXXX`), pre-masked accounts
(e.g. `****1111`), and explicit `_synthetic_notice` / `synthetic_notice`
fields. Creating an analysis with an **unmasked account-like** value is
rejected with HTTP 400 (`UNMASKED_ACCOUNT_FIELD`).
5. **Scrub-before-store.** Records hold only aggregates / metadata / masked refs.
No raw message payloads or PII are ever persisted.
6. **No deploy / push / real DB.** SQLite is a local, ephemeral file under the app
dir (or a tmp path via `PIP_DB_PATH`). No migrations against any real database.

### How `live_integration` flags work

Each connector profile (`app/services/connectors/profiles.py`) carries
`live_integration=False`, `enabled=False`, and a `ConnectorCapabilities` block
(also `live_integration=False`, `provides_live_data=False`). The registry will not
accept any other value. The health endpoint reports
`live_integrations_enabled: 0`, derived from the persisted `ConnectorState` rows —
it is **not** a hard-coded literal, it is the honest count, and that count is
always zero because nothing can be enabled.

The seven connector **tracks** (all disabled) model the *class* of system a future
production build *could* integrate with:

| Track | Illustrative class (no affiliation, no live data) |
|---|---|
| `BIC_DIRECTORY` | SwiftRef SSI Plus / BIC Directory |
| `VOP` | EBA/EPC Verification of Payee scheme |
| `REACHABILITY` | SEPA + FIN reachability |
| `MQ` | IBM MQ message feed |
| `DIRECTORY_FILE_DROP` | DTCC ALERT-style directory file drop |
| `PAYMENT_MONITOR` | payment / settlement monitor |
| `CERTIFIED_CBPR` | MyStandards / CBPR+ certified cross-border |

## API surfaces

- `GET /health`, `GET /api/v1/health` — honest service status, `environment: "prototype"`, `live_integrations_enabled: 0`, disclaimer.
- `GET /api/v1/dashboard/operator-summary` — aggregate counts only (review queue, pending approvals, readiness, connector gate summary).
- `GET /api/v1/dashboard/readiness` — weighted readiness score + band.
- `GET/POST /api/v1/analyses` — list / create aggregate analysis records (rejects unmasked account-like fields).
- `GET/POST /api/v1/review`, `POST /api/v1/review/{id}/assign|resolve` — review queue.
- `GET/POST /api/v1/approvals`, `POST /api/v1/approvals/{id}/approve|reject` — four-eyes (self-approval → 403).
- `GET /api/v1/audit` — immutable audit trail.
- `GET /api/v1/connectors`, `GET /api/v1/connectors/{id}`, `POST /api/v1/connectors/{id}/invoke` — P3 spine. **Invoke always returns 409** (`CONNECTOR_DISABLED`).

## Run

```bash
cd apps/payment-intelligence-pilot
make run # uvicorn app.main:app --port 8100 (creates .venv on first run)
# open http://127.0.0.1:8100/docs
```

## Test

```bash
cd apps/payment-intelligence-pilot
make test # python -m pytest -q (creates .venv on first run)
# or, if deps already installed:
python -m pytest -q
```

Tests use a per-test temporary SQLite file (via `PIP_DB_PATH`) and never touch a
real database.

## Configuration

| Env var | Default | Purpose |
|---|---|---|
| `PIP_DB_PATH` | `data/payment_intelligence_pilot.db` | Local SQLite file (ephemeral). |
| `PIP_EXPORT_DIR` | `exports/` | Local export dir (unused in normal flows). |

No secrets, credentials, or env secrets are read anywhere.

## NOT implemented — requires Raf sign-off before any of this is real

This prototype intentionally stops at the gate. The following are **out of scope**
and **must not** be inferred to exist:

- **Real authentication / authorization** (OIDC/SAML/mTLS, sessions, RBAC of
record). The header trust is a stub only.
- **Any live connector** — BIC directory, VOP, reachability, MQ, directory file
drop, payment/settlement monitor, certified CBPR+. All require, per track:
- a **signed data/usage license** countersigned by the vendor,
- **compliance sign-off** (sanctions/PEP posture, scheme rulebook adherence),
- an independent **security review + penetration test**,
- scheme **certification / conformance** (e.g. MyStandards / CBPR+),
- a **Data Processing Agreement + data-residency review**.
- **Signed data licenses** for any reference directory.
- **Certification** against any payment scheme.
- **Penetration testing** sign-off.
- **DPA / data-residency** approval for processing any real party/payment data.

Nothing here grants, implies, or stages any of the above. Enabling a live
integration is deliberately impossible from this code path.
5 changes: 5 additions & 0 deletions apps/payment-intelligence-pilot/app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Payment Intelligence Pilot — honest, gated P2/P3 prototype.

Local-only, synthetic, offline. No real external provider calls. All connectors
are disabled with ``live_integration = False``. Auth is a loud DEMO STUB.
"""
10 changes: 10 additions & 0 deletions apps/payment-intelligence-pilot/app/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from __future__ import annotations

from typing import Any

from sqlalchemy.inspection import inspect


def as_dict(obj: Any) -> dict[str, Any]:
"""Serialize a SQLAlchemy row to a plain dict of its mapped columns."""
return {column.key: getattr(obj, column.key) for column in inspect(obj).mapper.column_attrs}
73 changes: 73 additions & 0 deletions apps/payment-intelligence-pilot/app/api/analyses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from __future__ import annotations

import re

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.api import as_dict
from app.config import utc_now
from app.deps import current_actor, get_db
from app.errors import raise_app_error
from app.models import AnalysisRecord
from app.schemas import AnalysisCreate
from app.seed import SYNTHETIC_NOTICE
from app.services.audit import stable_id, write_audit

router = APIRouter(prefix="/api/v1/analyses", tags=["analyses"])

# Account-like = a run of 8+ digits (optionally IBAN-ish). Reject if unmasked.
_ACCOUNT_LIKE = re.compile(r"\b\d{8,}\b")
_IBAN_LIKE = re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{10,}\b")


def _reject_unmasked(field_name: str, value: str | None) -> None:
if not value:
return
text = str(value)
if "****" in text:
return # masked references are allowed
if _ACCOUNT_LIKE.search(text) or _IBAN_LIKE.search(text):
raise_app_error(
400,
f"{field_name} appears to contain an unmasked account-like value; mask it (e.g. ****1111) before storing",
"UNMASKED_ACCOUNT_FIELD",
)


@router.get("")
def list_analyses(session: Session = Depends(get_db)):
"""Aggregate/metadata analysis records only. No raw payloads are ever stored."""
records = session.query(AnalysisRecord).order_by(AnalysisRecord.created_at.desc()).all()
return {"count": len(records), "synthetic_notice": SYNTHETIC_NOTICE, "items": [as_dict(r) for r in records]}


@router.post("", status_code=201)
def create_analysis(body: AnalysisCreate, session: Session = Depends(get_db), actor: str = Depends(current_actor)):
"""Create an aggregate analysis record. Unmasked account-like fields → 400."""
# Scrub-before-store guard: no field may carry an unmasked account number.
_reject_unmasked("summary", body.summary)
_reject_unmasked("masked_reference", body.masked_reference)
now = utc_now()
record = AnalysisRecord(
id=stable_id("analysis", body.module, body.message_family, body.summary, now),
module=body.module,
message_family=body.message_family,
result_status=body.result_status,
finding_count=body.finding_count,
summary=body.summary,
masked_reference=body.masked_reference,
synthetic_notice=SYNTHETIC_NOTICE,
created_at=now,
)
session.add(record)
write_audit(
session,
entity_type="analysis",
entity_id=record.id,
action="analysis.created",
actor_user_email=actor,
new_value={"module": body.module, "result_status": body.result_status, "finding_count": body.finding_count},
)
session.commit()
return as_dict(record)
44 changes: 44 additions & 0 deletions apps/payment-intelligence-pilot/app/api/approvals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.api import as_dict
from app.deps import current_actor, get_db
from app.models import ApprovalRequest
from app.schemas import ApprovalDecision, ApprovalSubmit
from app.services import approvals as approvals_service

router = APIRouter(prefix="/api/v1/approvals", tags=["approvals"])


@router.get("")
def list_approvals(session: Session = Depends(get_db)):
items = session.query(ApprovalRequest).order_by(ApprovalRequest.created_at.desc()).all()
return {"count": len(items), "items": [as_dict(i) for i in items]}


@router.post("", status_code=201)
def submit_approval(body: ApprovalSubmit, session: Session = Depends(get_db), actor: str = Depends(current_actor)):
approval = approvals_service.submit_for_approval(
session,
subject=body.subject,
change_summary=body.change_summary,
actor=actor,
review_item_id=body.review_item_id,
risk_level=body.risk_level,
)
return as_dict(approval)


@router.post("/{approval_id}/approve")
def approve_approval(approval_id: str, body: ApprovalDecision, session: Session = Depends(get_db), actor: str = Depends(current_actor)):
"""Four-eyes approve. Self-approval (maker == checker) returns 403."""
approval = approvals_service.approve(session, approval_id, actor, body.reason)
return as_dict(approval)


@router.post("/{approval_id}/reject")
def reject_approval(approval_id: str, body: ApprovalDecision, session: Session = Depends(get_db), actor: str = Depends(current_actor)):
approval = approvals_service.reject(session, approval_id, actor, body.reason)
return as_dict(approval)
17 changes: 17 additions & 0 deletions apps/payment-intelligence-pilot/app/api/audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from __future__ import annotations

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.api import as_dict
from app.deps import get_db
from app.models import AuditEvent

router = APIRouter(prefix="/api/v1/audit", tags=["audit"])


@router.get("")
def list_audit(session: Session = Depends(get_db)):
"""List the immutable (insert-only) audit trail. UPDATE/DELETE blocked by DB triggers."""
events = session.query(AuditEvent).order_by(AuditEvent.created_at.asc()).all()
return {"count": len(events), "immutable": True, "items": [as_dict(e) for e in events]}
Loading
Loading