Skip to content
Open
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
177 changes: 177 additions & 0 deletions migrations/20260528000000_sar_full_schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
-- Full SAR (Suspicious Activity Report) schema
-- Replaces the minimal sar_workflow migration with the complete schema.

-- Drop old tables if they exist (idempotent re-run)
DROP TABLE IF EXISTS sar_audit_log CASCADE;
DROP TABLE IF EXISTS sar_narratives CASCADE;
DROP TABLE IF EXISTS sar_transactions CASCADE;
DROP TABLE IF EXISTS sar_subjects CASCADE;
DROP TABLE IF EXISTS sar_reports CASCADE;

-- ── Core SAR record ──────────────────────────────────────────────────────────
CREATE TABLE sar_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

-- Classification
sar_type TEXT NOT NULL
CHECK (sar_type IN ('transaction_based','activity_based','threshold_based')),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','under_review','approved','filed','acknowledged','rejected','returned_for_revision')),
subject_type TEXT NOT NULL
CHECK (subject_type IN ('individual','entity')),
detection_method TEXT NOT NULL
CHECK (detection_method IN ('aml_rule_trigger','compliance_officer_judgment','law_enforcement_request','sanctions_match')),

-- Subject linkage
subject_kyc_id UUID,
subject_wallet_addresses TEXT[] NOT NULL DEFAULT '{}',

-- Activity details
suspicious_activity_description TEXT NOT NULL,
activity_start_date DATE NOT NULL,
activity_end_date DATE NOT NULL,
total_amount_ngn NUMERIC(20,2) NOT NULL DEFAULT 0,
transaction_count INT NOT NULL DEFAULT 0,
linked_transaction_ids UUID[] NOT NULL DEFAULT '{}',

-- AML trigger data (pre-populated from AML engine)
aml_case_id UUID,
aml_risk_score NUMERIC(5,4),
triggered_rules JSONB NOT NULL DEFAULT '[]',

-- Workflow actors
detecting_officer_id UUID,
assigned_investigator_id UUID,
reviewing_officer_id UUID,
approving_officer_id UUID,

-- Investigation checklist (JSON flags)
investigation_checklist JSONB NOT NULL DEFAULT '{
"subject_identity_verified": false,
"transaction_records_reviewed": false,
"aml_rules_documented": false,
"narrative_complete": false,
"supporting_docs_attached": false,
"legal_review_complete": false
}',

-- Filing
filing_deadline DATE NOT NULL,
filing_timestamp TIMESTAMPTZ,
filing_method TEXT,
regulatory_reference_number TEXT,
rejection_reason TEXT,

-- Acknowledgement
acknowledged_at TIMESTAMPTZ,
acknowledgement_reference TEXT,

-- Regulatory authority
authority TEXT NOT NULL DEFAULT 'NFIU'
CHECK (authority IN ('NFIU','CBN')),

-- Generated document (stored as JSON string for NFIU, XML for CBN)
generated_document TEXT,
document_generated_at TIMESTAMPTZ,

-- Confidentiality: data retention
retention_expires_at DATE NOT NULL DEFAULT (CURRENT_DATE + INTERVAL '5 years'),

created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_sar_status ON sar_reports (status, created_at DESC);
CREATE INDEX idx_sar_aml_case ON sar_reports (aml_case_id) WHERE aml_case_id IS NOT NULL;
CREATE INDEX idx_sar_subject_kyc ON sar_reports (subject_kyc_id) WHERE subject_kyc_id IS NOT NULL;
CREATE INDEX idx_sar_deadline ON sar_reports (filing_deadline, status);
CREATE INDEX idx_sar_detection ON sar_reports (detection_method, created_at DESC);

-- ── SAR subjects (one or more per SAR) ──────────────────────────────────────
CREATE TABLE sar_subjects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sar_id UUID NOT NULL REFERENCES sar_reports(id) ON DELETE CASCADE,
full_name TEXT NOT NULL,
date_of_birth DATE,
nationality TEXT,
identification_docs JSONB NOT NULL DEFAULT '[]', -- [{type, number, issuer, expiry}]
address TEXT,
contact_info JSONB NOT NULL DEFAULT '{}', -- {phone, email}
platform_relationship TEXT NOT NULL DEFAULT 'account_holder',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_sar_subjects_sar ON sar_subjects (sar_id);

-- ── SAR transactions (linked suspicious transactions) ────────────────────────
CREATE TABLE sar_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sar_id UUID NOT NULL REFERENCES sar_reports(id) ON DELETE CASCADE,
transaction_id UUID NOT NULL,
transaction_date TIMESTAMPTZ NOT NULL,
amount_ngn NUMERIC(20,2) NOT NULL,
transaction_type TEXT NOT NULL,
counterparty_details JSONB NOT NULL DEFAULT '{}',
suspicious_element TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_sar_txns_sar ON sar_transactions (sar_id);
CREATE UNIQUE INDEX idx_sar_txns_unique ON sar_transactions (sar_id, transaction_id);

-- ── SAR narratives (versioned) ───────────────────────────────────────────────
CREATE TABLE sar_narratives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sar_id UUID NOT NULL REFERENCES sar_reports(id) ON DELETE CASCADE,
version INT NOT NULL,
narrative_text TEXT NOT NULL,
author_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (sar_id, version)
);

CREATE INDEX idx_sar_narratives_sar ON sar_narratives (sar_id, version DESC);

-- ── SAR audit log (immutable) ────────────────────────────────────────────────
CREATE TABLE sar_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sar_id UUID NOT NULL REFERENCES sar_reports(id),
actor_id TEXT NOT NULL,
action TEXT NOT NULL,
from_status TEXT NOT NULL DEFAULT '',
to_status TEXT NOT NULL DEFAULT '',
notes TEXT,
-- Confidentiality: record who accessed the SAR
access_type TEXT NOT NULL DEFAULT 'write', -- 'read' | 'write'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_sar_audit_sar_id ON sar_audit_log (sar_id, created_at ASC);

-- Immutability trigger
CREATE OR REPLACE FUNCTION sar_audit_log_immutable()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'sar_audit_log is immutable';
END;
$$;

CREATE TRIGGER trg_sar_audit_immutable
BEFORE UPDATE OR DELETE ON sar_audit_log
FOR EACH ROW EXECUTE FUNCTION sar_audit_log_immutable();

-- updated_at trigger
CREATE OR REPLACE FUNCTION update_sar_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$;

CREATE TRIGGER sar_reports_updated_at
BEFORE UPDATE ON sar_reports
FOR EACH ROW EXECUTE FUNCTION update_sar_updated_at();

COMMENT ON TABLE sar_reports IS 'Full SAR lifecycle: draft→under_review→approved→filed→acknowledged';
COMMENT ON TABLE sar_subjects IS 'Subjects named in a SAR (individual or entity)';
COMMENT ON TABLE sar_transactions IS 'Suspicious transactions linked to a SAR';
COMMENT ON TABLE sar_narratives IS 'Versioned narrative text for each SAR';
COMMENT ON TABLE sar_audit_log IS 'Immutable audit trail — every SAR access and state change';
58 changes: 58 additions & 0 deletions migrations/20260530000000_aml_case_records.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
-- Migration: AML case records storage
-- Stores full AMLCaseRecord payloads as JSONB for flexible persistence
CREATE TABLE IF NOT EXISTS aml_case_records (
id UUID PRIMARY KEY,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_aml_case_records_updated_at ON aml_case_records (updated_at DESC);

-- Checklist items completion per case
CREATE TABLE IF NOT EXISTS aml_case_checklist_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_id UUID NOT NULL REFERENCES aml_case_records(id) ON DELETE CASCADE,
item_id UUID NOT NULL,
completed_by TEXT NOT NULL,
completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_aml_case_checklist_case ON aml_case_checklist_items (case_id);

-- Evidence, notes, and actions for cases
CREATE TABLE IF NOT EXISTS aml_case_evidence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_id UUID NOT NULL REFERENCES aml_case_records(id) ON DELETE CASCADE,
payload JSONB NOT NULL,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS aml_case_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_id UUID NOT NULL REFERENCES aml_case_records(id) ON DELETE CASCADE,
payload JSONB NOT NULL,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS aml_case_actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_id UUID NOT NULL REFERENCES aml_case_records(id) ON DELETE CASCADE,
action_type TEXT NOT NULL,
action_detail TEXT NOT NULL,
performed_by TEXT NOT NULL,
action_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_aml_case_actions_case ON aml_case_actions (case_id, action_timestamp DESC);

-- updated_at trigger
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS aml_case_records_updated_at ON aml_case_records;
CREATE TRIGGER aml_case_records_updated_at
BEFORE UPDATE ON aml_case_records
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
32 changes: 26 additions & 6 deletions src/aml/case_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,36 @@ impl AmlCaseManager {
if let Some(ref sar_svc) = self.sar_svc {
let svc = Arc::clone(sar_svc);
let case_id = case.id;
let tx_id = result.transaction_id;
let wallet = wallet_address.to_owned();
let risk_score = result.risk_score;
let tx_id = result.transaction_id;
let flags_json_clone = serde_json::to_value(&result.flags).unwrap_or_default();
let detection_method = if result.flags.iter().any(|f| matches!(f, AmlFlag::SanctionsHit { .. })) {
crate::sar::DetectionMethod::SanctionsMatch
} else {
crate::sar::DetectionMethod::AmlRuleTrigger
};
let today = chrono::Utc::now().date_naive();
tokio::spawn(async move {
match svc.auto_draft(case_id, tx_id, &wallet).await {
match svc.auto_initiate(
case_id,
detection_method,
None,
vec![wallet],
format!("Automated SAR from AML engine. Risk score: {risk_score:.2}"),
today - chrono::Duration::days(1),
today,
rust_decimal::Decimal::ZERO,
0,
vec![tx_id],
flags_json_clone,
Some(risk_score),
None,
).await {
Ok(sar) => {
if let Err(e) = svc.submit_for_review(sar.id).await {
error!(sar_id = %sar.id, error = %e, "Failed to submit SAR for review");
}
tracing::info!(sar_id = %sar.id, aml_case_id = %case_id, "SAR auto-initiated from AML case");
}
Err(e) => error!(aml_case_id = %case_id, error = %e, "SAR auto-draft failed"),
Err(e) => error!(aml_case_id = %case_id, error = %e, "SAR auto-initiation failed"),
}
});
}
Expand Down
Loading