mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
refactor: compliance imports from lux/compliance library (thin HTTP layer)
Replace local compliance types, store, MemoryStore, and jube package with
imports from github.com/luxfi/compliance. Broker's pkg/compliance/ is now a
thin HTTP adapter layer:
- compliance.go: type aliases re-exporting all types from library
- store.go, jube/: removed (moved to library)
- postgres_store.go: kept (pgx dependency stays in broker)
- All HTTP handlers unchanged (types are aliases, fully compatible)
- Router imports jube from library instead of local subpackage
go.mod adds: require github.com/luxfi/compliance v0.1.0
replace github.com/luxfi/compliance => ../compliance
All 50+ compliance tests pass. No handler code changes needed.
This commit is contained in:
@@ -94,24 +94,31 @@ to avoid import cycles: `provider/alpaca` has compile-time interface assertions
|
||||
import `provider`, so `provider` cannot import `provider/alpaca`.
|
||||
|
||||
### Compliance (pkg/compliance/)
|
||||
KYC/KYB, onboarding, fund management, eSign, RBAC, and reporting.
|
||||
Thin HTTP adapter layer over `github.com/luxfi/compliance` library.
|
||||
All domain types, store interface, MemoryStore, Jube client, RBAC, and onboarding
|
||||
logic live in the library. This package re-exports types as aliases and provides:
|
||||
- HTTP handlers (chi router with RBAC guards, rate limiting, CORS)
|
||||
- PostgresStore (pgx-backed, implements library's ComplianceStore interface)
|
||||
- Webhook dispatcher for cross-BD trade events (`pkg/compliance/webhooks/`)
|
||||
- Seed data for development mode
|
||||
|
||||
Mounted at `/compliance` on the main server. Supports in-memory store (default) or
|
||||
PostgreSQL via `DATABASE_URL` env var. Includes Jube AML/fraud client (`pkg/compliance/jube/`)
|
||||
and webhook dispatcher (`pkg/compliance/webhooks/`).
|
||||
PostgreSQL via `DATABASE_URL` env var. Jube client imported from library.
|
||||
|
||||
Key endpoint groups under `/compliance`:
|
||||
- `/kyc` -- Identity verification, document upload, status tracking (list by user, update status)
|
||||
- `/aml` -- AML screening (Jube integration), risk assessment, flagged review, sanctions checks
|
||||
- `/applications` -- 5-step onboarding flow (basic info, identity, documents, compliance, submit)
|
||||
- `/kyc` -- Identity verification, document upload, status tracking
|
||||
- `/aml` -- AML screening (Jube integration), risk assessment, flagged review
|
||||
- `/applications` -- 5-step onboarding flow (SSN hashed with HMAC-SHA256)
|
||||
- `/pipelines` + `/sessions` -- Configurable onboarding pipeline engine
|
||||
- `/funds` -- Fund management with investor tracking
|
||||
- `/esign` -- Envelope/template eSign workflow
|
||||
- `/roles` + `/modules` -- RBAC permission matrix
|
||||
|
||||
New files (2026-03-27):
|
||||
- `aml.go` -- AML screening/review/risk-assessment handlers
|
||||
- `application.go` -- 5-step onboarding application handlers (SSN hashed with SHA-256)
|
||||
- `schemas/compliance_tables.sql` -- PostgreSQL migration for aml_screenings, applications, document_uploads
|
||||
Library dependency (go.mod):
|
||||
```
|
||||
require github.com/luxfi/compliance v0.1.0
|
||||
replace github.com/luxfi/compliance => ../compliance
|
||||
```
|
||||
|
||||
### Database (pkg/db/)
|
||||
PostgreSQL connection pool and auto-migrations. Used by compliance when `DATABASE_URL` is set.
|
||||
|
||||
@@ -7,11 +7,14 @@ require (
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/hanzoai/commerce v1.36.5
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/luxfi/compliance v0.1.0
|
||||
github.com/nats-io/nats.go v1.49.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
google.golang.org/grpc v1.79.1
|
||||
)
|
||||
|
||||
replace github.com/luxfi/compliance => ../compliance
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/luxfi/broker/pkg/admin"
|
||||
"github.com/luxfi/broker/pkg/compliance/jube"
|
||||
"github.com/luxfi/compliance/pkg/jube"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
+98
-389
@@ -1,425 +1,134 @@
|
||||
// Package compliance provides KYC/KYB verification, investor onboarding
|
||||
// pipelines, fund management, eSign, and role-based access control for
|
||||
// broker-dealer compliance workflows.
|
||||
//
|
||||
// All domain types and the store interface are re-exported from
|
||||
// github.com/luxfi/compliance. This package adds only HTTP handlers
|
||||
// and broker-specific wiring.
|
||||
package compliance
|
||||
|
||||
import "time"
|
||||
|
||||
// --- KYC/KYB Status Types ---
|
||||
|
||||
// KYCStatus tracks identity verification state.
|
||||
type KYCStatus string
|
||||
|
||||
const (
|
||||
KYCPending KYCStatus = "pending"
|
||||
KYCVerified KYCStatus = "verified"
|
||||
KYCFailed KYCStatus = "failed"
|
||||
KYCExpired KYCStatus = "expired"
|
||||
import (
|
||||
"github.com/luxfi/compliance/pkg/store"
|
||||
"github.com/luxfi/compliance/pkg/types"
|
||||
)
|
||||
|
||||
// KYBStatus tracks business verification state.
|
||||
type KYBStatus string
|
||||
// --- Type aliases: re-export all types from the compliance library ---
|
||||
|
||||
type KYCStatus = types.KYCStatus
|
||||
|
||||
const (
|
||||
KYBPending KYBStatus = "pending"
|
||||
KYBApproved KYBStatus = "approved"
|
||||
KYBRejected KYBStatus = "rejected"
|
||||
KYCPending = types.KYCPending
|
||||
KYCVerified = types.KYCVerified
|
||||
KYCFailed = types.KYCFailed
|
||||
KYCExpired = types.KYCExpired
|
||||
)
|
||||
|
||||
// SessionStatus tracks onboarding session state.
|
||||
type SessionStatus string
|
||||
type KYBStatus = types.KYBStatus
|
||||
|
||||
const (
|
||||
SessionPending SessionStatus = "pending"
|
||||
SessionInProgress SessionStatus = "in_progress"
|
||||
SessionCompleted SessionStatus = "completed"
|
||||
SessionFailed SessionStatus = "failed"
|
||||
SessionArchived SessionStatus = "archived"
|
||||
KYBPending = types.KYBPending
|
||||
KYBApproved = types.KYBApproved
|
||||
KYBRejected = types.KYBRejected
|
||||
)
|
||||
|
||||
// --- Core Types ---
|
||||
|
||||
// Identity represents a KYC-verified individual identity.
|
||||
type Identity struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Provider string `json:"provider"` // onfido, berbix, idmerit, etc.
|
||||
Status KYCStatus `json:"status"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Document is a verification document (passport, license, etc.).
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // passport, drivers_license, utility_bill, ein_letter
|
||||
Name string `json:"name,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
Content string `json:"content,omitempty"` // base64
|
||||
Status string `json:"status,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// BusinessKYB represents a business verification record.
|
||||
type BusinessKYB struct {
|
||||
ID string `json:"id"`
|
||||
BusinessID string `json:"business_id"`
|
||||
Name string `json:"name"`
|
||||
EIN string `json:"ein"`
|
||||
Status KYBStatus `json:"status"`
|
||||
Documents []Document `json:"documents,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// --- Pipeline / Session Types ---
|
||||
|
||||
// PipelineStep defines a single step in an onboarding pipeline.
|
||||
type PipelineStep struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // kyc, accreditation, esign, payment, review
|
||||
Required bool `json:"required"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
// Pipeline is a configurable investor onboarding flow.
|
||||
type Pipeline struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BusinessID string `json:"business_id"`
|
||||
Steps []PipelineStep `json:"steps"`
|
||||
Status string `json:"status"` // active, draft, archived
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SessionStep tracks completion of a single pipeline step within a session.
|
||||
type SessionStep struct {
|
||||
StepID string `json:"step_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"` // pending, completed, failed, skipped
|
||||
CompletedAt time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
// Session is an investor's progress through an onboarding pipeline.
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
InvestorEmail string `json:"investor_email"`
|
||||
InvestorName string `json:"investor_name"`
|
||||
Status SessionStatus `json:"status"`
|
||||
KYCStatus KYCStatus `json:"kyc_status"`
|
||||
Steps []SessionStep `json:"steps,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CompletedAt time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
// --- Fund Types ---
|
||||
|
||||
// Fund represents an investment fund.
|
||||
type Fund struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BusinessID string `json:"business_id"`
|
||||
Type string `json:"type"` // equity, debt, real_estate
|
||||
MinInvestment float64 `json:"min_investment"`
|
||||
TotalRaised float64 `json:"total_raised"`
|
||||
InvestorCount int `json:"investor_count"`
|
||||
Status string `json:"status"` // open, closed, raising
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// FundInvestor represents an investor's participation in a fund.
|
||||
type FundInvestor struct {
|
||||
ID string `json:"id"`
|
||||
FundID string `json:"fund_id"`
|
||||
InvestorID string `json:"investor_id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"` // committed, funded, withdrawn
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// --- eSign Types ---
|
||||
|
||||
// EnvelopeStatus tracks document signing state.
|
||||
type EnvelopeStatus string
|
||||
type SessionStatus = types.SessionStatus
|
||||
|
||||
const (
|
||||
EnvelopePending EnvelopeStatus = "pending"
|
||||
EnvelopeSent EnvelopeStatus = "sent"
|
||||
EnvelopeViewed EnvelopeStatus = "viewed"
|
||||
EnvelopeSigned EnvelopeStatus = "signed"
|
||||
EnvelopeCompleted EnvelopeStatus = "completed"
|
||||
EnvelopeDeclined EnvelopeStatus = "declined"
|
||||
EnvelopeVoided EnvelopeStatus = "voided"
|
||||
SessionPending = types.SessionPending
|
||||
SessionInProgress = types.SessionInProgress
|
||||
SessionCompleted = types.SessionCompleted
|
||||
SessionFailed = types.SessionFailed
|
||||
SessionArchived = types.SessionArchived
|
||||
)
|
||||
|
||||
// Signer represents a person who must sign an envelope.
|
||||
type Signer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role,omitempty"` // investor, issuer, witness
|
||||
Status string `json:"status"` // pending, signed, declined
|
||||
SignedAt string `json:"signed_at,omitempty"`
|
||||
}
|
||||
|
||||
// Envelope is a document package sent for signatures.
|
||||
type Envelope struct {
|
||||
ID string `json:"id"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
Subject string `json:"subject"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Status EnvelopeStatus `json:"status"`
|
||||
Signers []Signer `json:"signers"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Template is a reusable eSign document template.
|
||||
type Template struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Content string `json:"content,omitempty"` // base64 PDF or URL
|
||||
Roles []string `json:"roles,omitempty"` // expected signer roles
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// --- RBAC Types ---
|
||||
|
||||
// Permission defines a single allowed action on a module.
|
||||
type Permission struct {
|
||||
Module string `json:"module"` // kyc, funds, esign, pipelines, sessions, roles
|
||||
Action string `json:"action"` // read, write, delete, admin
|
||||
}
|
||||
|
||||
// Role is a named set of permissions.
|
||||
type Role struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Permissions []Permission `json:"permissions"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Module describes a compliance module for the permission matrix.
|
||||
type Module struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Actions []string `json:"actions"` // available actions
|
||||
}
|
||||
|
||||
// --- User Management Types ---
|
||||
|
||||
// User represents an admin/compliance platform user.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // owner, admin, manager, developer, agent
|
||||
Status string `json:"status"` // active, inactive, suspended
|
||||
LastLogin time.Time `json:"last_login"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// --- Transaction Types ---
|
||||
|
||||
// Transaction represents a financial transaction record.
|
||||
type Transaction struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // deposit, withdrawal, trade, dividend
|
||||
Asset string `json:"asset"` // USD, BTC, ETH, etc.
|
||||
Amount float64 `json:"amount"`
|
||||
Fee float64 `json:"fee"`
|
||||
Status string `json:"status"` // pending, completed, failed, cancelled
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
// --- Settings Types ---
|
||||
|
||||
// Settings holds business configuration for the compliance tenant.
|
||||
type Settings struct {
|
||||
BusinessName string `json:"business_name"`
|
||||
Timezone string `json:"timezone"`
|
||||
Currency string `json:"currency"`
|
||||
NotificationEmail string `json:"notification_email"`
|
||||
}
|
||||
|
||||
// --- Credential Types ---
|
||||
|
||||
// Credential represents an API key visible to the compliance admin.
|
||||
type Credential struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyPrefix string `json:"key_prefix"` // first 8 chars, for display only
|
||||
Permissions []string `json:"permissions"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// --- AML Types ---
|
||||
|
||||
// AMLStatus tracks anti-money laundering screening state.
|
||||
type AMLStatus string
|
||||
type AMLStatus = types.AMLStatus
|
||||
|
||||
const (
|
||||
AMLPending AMLStatus = "pending"
|
||||
AMLCleared AMLStatus = "cleared"
|
||||
AMLFlagged AMLStatus = "flagged"
|
||||
AMLBlocked AMLStatus = "blocked"
|
||||
AMLExpired AMLStatus = "expired"
|
||||
AMLPending = types.AMLPending
|
||||
AMLCleared = types.AMLCleared
|
||||
AMLFlagged = types.AMLFlagged
|
||||
AMLBlocked = types.AMLBlocked
|
||||
AMLExpired = types.AMLExpired
|
||||
)
|
||||
|
||||
// RiskLevel categorizes account risk.
|
||||
type RiskLevel string
|
||||
type RiskLevel = types.RiskLevel
|
||||
|
||||
const (
|
||||
RiskLow RiskLevel = "low"
|
||||
RiskMedium RiskLevel = "medium"
|
||||
RiskHigh RiskLevel = "high"
|
||||
RiskCritical RiskLevel = "critical"
|
||||
RiskLow = types.RiskLow
|
||||
RiskMedium = types.RiskMedium
|
||||
RiskHigh = types.RiskHigh
|
||||
RiskCritical = types.RiskCritical
|
||||
)
|
||||
|
||||
// AMLScreening represents a single AML screening record for an account.
|
||||
type AMLScreening struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"account_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Type string `json:"type"` // sanctions, pep, adverse_media, transaction
|
||||
Status AMLStatus `json:"status"`
|
||||
RiskLevel RiskLevel `json:"risk_level"`
|
||||
RiskScore float64 `json:"risk_score"`
|
||||
Provider string `json:"provider"` // jube, manual
|
||||
Details string `json:"details,omitempty"`
|
||||
SanctionsHit bool `json:"sanctions_hit"`
|
||||
PEPMatch bool `json:"pep_match"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
ReviewedAt time.Time `json:"reviewed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// --- Onboarding Application Types ---
|
||||
|
||||
// ApplicationStatus tracks an onboarding application's overall state.
|
||||
type ApplicationStatus string
|
||||
type ApplicationStatus = types.ApplicationStatus
|
||||
|
||||
const (
|
||||
AppDraft ApplicationStatus = "draft"
|
||||
AppInProgress ApplicationStatus = "in_progress"
|
||||
AppSubmitted ApplicationStatus = "submitted"
|
||||
AppUnderReview ApplicationStatus = "under_review"
|
||||
AppApproved ApplicationStatus = "approved"
|
||||
AppRejected ApplicationStatus = "rejected"
|
||||
AppDraft = types.AppDraft
|
||||
AppInProgress = types.AppInProgress
|
||||
AppSubmitted = types.AppSubmitted
|
||||
AppUnderReview = types.AppUnderReview
|
||||
AppApproved = types.AppApproved
|
||||
AppRejected = types.AppRejected
|
||||
)
|
||||
|
||||
// ApplicationStep represents completion of a single onboarding step.
|
||||
type ApplicationStep struct {
|
||||
Step int `json:"step"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // pending, completed, failed, skipped
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
CompletedAt time.Time `json:"completed_at,omitempty"`
|
||||
type EnvelopeStatus = types.EnvelopeStatus
|
||||
|
||||
const (
|
||||
EnvelopePending = types.EnvelopePending
|
||||
EnvelopeSent = types.EnvelopeSent
|
||||
EnvelopeViewed = types.EnvelopeViewed
|
||||
EnvelopeSigned = types.EnvelopeSigned
|
||||
EnvelopeCompleted = types.EnvelopeCompleted
|
||||
EnvelopeDeclined = types.EnvelopeDeclined
|
||||
EnvelopeVoided = types.EnvelopeVoided
|
||||
)
|
||||
|
||||
type (
|
||||
Identity = types.Identity
|
||||
Document = types.Document
|
||||
BusinessKYB = types.BusinessKYB
|
||||
PipelineStep = types.PipelineStep
|
||||
Pipeline = types.Pipeline
|
||||
SessionStep = types.SessionStep
|
||||
Session = types.Session
|
||||
Fund = types.Fund
|
||||
FundInvestor = types.FundInvestor
|
||||
Signer = types.Signer
|
||||
Envelope = types.Envelope
|
||||
Template = types.Template
|
||||
Permission = types.Permission
|
||||
Role = types.Role
|
||||
Module = types.Module
|
||||
User = types.User
|
||||
Transaction = types.Transaction
|
||||
Settings = types.Settings
|
||||
Credential = types.Credential
|
||||
AMLScreening = types.AMLScreening
|
||||
ApplicationStep = types.ApplicationStep
|
||||
Application = types.Application
|
||||
DocumentUpload = types.DocumentUpload
|
||||
Invoice = types.Invoice
|
||||
BillingInfo = types.BillingInfo
|
||||
DashboardStats = types.DashboardStats
|
||||
ESignStats = types.ESignStats
|
||||
)
|
||||
|
||||
// --- Store: re-export from the compliance library ---
|
||||
|
||||
type ComplianceStore = store.ComplianceStore
|
||||
type MemoryStore = store.MemoryStore
|
||||
|
||||
// NewMemoryStore creates an in-memory compliance store.
|
||||
func NewMemoryStore() *store.MemoryStore {
|
||||
return store.NewMemoryStore()
|
||||
}
|
||||
|
||||
// Application is an investor's onboarding application through the 5-step flow:
|
||||
// Step 1: Basic info + Contact
|
||||
// Step 2: Identity verification
|
||||
// Step 3: Document upload
|
||||
// Step 4: Compliance/AML screening
|
||||
// Step 5: Review + Submit
|
||||
type Application struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
DateOfBirth string `json:"date_of_birth,omitempty"`
|
||||
SSNHash string `json:"-"` // hashed, never exposed via API
|
||||
SSNLast4 string `json:"ssn_last4,omitempty"` // last 4 digits for display
|
||||
AddressLine1 string `json:"address_line1,omitempty"`
|
||||
AddressLine2 string `json:"address_line2,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
ZipCode string `json:"zip_code,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Status ApplicationStatus `json:"status"`
|
||||
CurrentStep int `json:"current_step"`
|
||||
KYCStatus KYCStatus `json:"kyc_status"`
|
||||
AMLStatus AMLStatus `json:"aml_status"`
|
||||
Steps []ApplicationStep `json:"steps"`
|
||||
Documents []Document `json:"documents,omitempty"`
|
||||
RiskLevel RiskLevel `json:"risk_level,omitempty"`
|
||||
RiskScore float64 `json:"risk_score,omitempty"`
|
||||
SubmittedAt time.Time `json:"submitted_at,omitempty"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
ReviewedAt time.Time `json:"reviewed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// NewStore is an alias for NewMemoryStore, preserving backward compatibility.
|
||||
func NewStore() *store.MemoryStore {
|
||||
return store.NewMemoryStore()
|
||||
}
|
||||
|
||||
// DocumentUpload represents a document uploaded during onboarding.
|
||||
type DocumentUpload struct {
|
||||
ID string `json:"id"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Type string `json:"type"` // passport, drivers_license, utility_bill, proof_of_address, tax_return
|
||||
Name string `json:"name"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
Status string `json:"status"` // pending, accepted, rejected
|
||||
ReviewNote string `json:"review_note,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// --- Billing Types ---
|
||||
|
||||
// Invoice represents a billing invoice.
|
||||
type Invoice struct {
|
||||
ID string `json:"id"`
|
||||
Date string `json:"date"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"` // paid, pending, overdue
|
||||
}
|
||||
|
||||
// BillingInfo holds billing and subscription details.
|
||||
type BillingInfo struct {
|
||||
Plan string `json:"plan"` // starter, professional, enterprise
|
||||
PaymentMethod string `json:"payment_method"` // visa_4242, etc.
|
||||
NextBilling string `json:"next_billing"`
|
||||
MonthlyUsage float64 `json:"monthly_usage"`
|
||||
Invoices []Invoice `json:"invoices"`
|
||||
}
|
||||
|
||||
// --- Dashboard Types ---
|
||||
|
||||
// DashboardStats holds aggregate compliance dashboard statistics.
|
||||
type DashboardStats struct {
|
||||
ActiveSessions int `json:"activeSessions"`
|
||||
PendingKYC int `json:"pendingKYC"`
|
||||
TotalFunds int `json:"totalFunds"`
|
||||
MonthlyTransactions int `json:"monthlyTransactions"`
|
||||
RecentSessions []*Session `json:"recentSessions"`
|
||||
RecentTransactions []*Transaction `json:"recentTransactions"`
|
||||
}
|
||||
|
||||
// --- eSign Dashboard Types ---
|
||||
|
||||
// ESignStats holds aggregate eSign statistics.
|
||||
type ESignStats struct {
|
||||
Pending int `json:"pending"`
|
||||
Completed int `json:"completed"`
|
||||
Draft int `json:"draft"`
|
||||
Templates int `json:"templates"`
|
||||
Recent []*Envelope `json:"recent"`
|
||||
// generateID delegates to the library's GenerateID.
|
||||
func generateID() string {
|
||||
return store.GenerateID()
|
||||
}
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
package jube
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// DefaultBaseURL is the default Jube sidecar address.
|
||||
const DefaultBaseURL = "http://jube:5001"
|
||||
|
||||
// DefaultTimeout is the HTTP request timeout for Jube API calls.
|
||||
const DefaultTimeout = 10 * time.Second
|
||||
|
||||
// NATSSubjectAlerts is the NATS subject for async AML alerts from Jube.
|
||||
const NATSSubjectAlerts = "compliance.jube.alerts"
|
||||
|
||||
// Config holds Jube client configuration.
|
||||
type Config struct {
|
||||
BaseURL string // Jube sidecar URL (default: http://jube:5001)
|
||||
Timeout time.Duration // HTTP timeout (default: 10s)
|
||||
NATSAddr string // NATS server address for async alerts (e.g. nats://localhost:4222)
|
||||
}
|
||||
|
||||
// Client is an HTTP client for the Jube AML/fraud detection sidecar.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
natsConn *nats.Conn
|
||||
natsSub *nats.Subscription
|
||||
}
|
||||
|
||||
// New creates a new Jube client. If cfg.NATSAddr is set, it connects to NATS
|
||||
// for async alert subscriptions. Returns an error if NATS connection fails.
|
||||
func New(cfg Config) (*Client, error) {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.NATSAddr != "" {
|
||||
nc, err := nats.Connect(cfg.NATSAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jube: nats connect %s: %w", cfg.NATSAddr, err)
|
||||
}
|
||||
c.natsConn = nc
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Close releases resources held by the client.
|
||||
func (c *Client) Close() error {
|
||||
if c.natsSub != nil {
|
||||
if err := c.natsSub.Unsubscribe(); err != nil {
|
||||
return fmt.Errorf("jube: nats unsubscribe: %w", err)
|
||||
}
|
||||
}
|
||||
if c.natsConn != nil {
|
||||
c.natsConn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScreenTransaction submits a transaction to Jube for real-time risk scoring.
|
||||
// Calls POST /api/EntityAnalysisModel/Invoke.
|
||||
func (c *Client) ScreenTransaction(ctx context.Context, req TransactionRequest) (*TransactionResponse, error) {
|
||||
var resp TransactionResponse
|
||||
if err := c.post(ctx, "/api/EntityAnalysisModel/Invoke", req, &resp); err != nil {
|
||||
return nil, fmt.Errorf("jube: screen transaction: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CheckSanctions performs sanctions screening against Jube's lists.
|
||||
// Calls GET /api/Sanction with name and country query params.
|
||||
func (c *Client) CheckSanctions(ctx context.Context, name, country string) (*SanctionResult, error) {
|
||||
params := url.Values{}
|
||||
params.Set("name", name)
|
||||
if country != "" {
|
||||
params.Set("country", country)
|
||||
}
|
||||
|
||||
var resp SanctionResult
|
||||
if err := c.get(ctx, "/api/Sanction?"+params.Encode(), &resp); err != nil {
|
||||
return nil, fmt.Errorf("jube: check sanctions: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CreateCase creates a new compliance case in Jube.
|
||||
// Calls POST /api/CaseManagement.
|
||||
func (c *Client) CreateCase(ctx context.Context, req CaseRequest) (*Case, error) {
|
||||
var resp Case
|
||||
if err := c.post(ctx, "/api/CaseManagement", req, &resp); err != nil {
|
||||
return nil, fmt.Errorf("jube: create case: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetCases retrieves compliance cases matching the given filters.
|
||||
// Calls GET /api/CaseManagement with optional query params.
|
||||
func (c *Client) GetCases(ctx context.Context, filter CaseFilter) ([]Case, error) {
|
||||
params := url.Values{}
|
||||
if filter.AccountID != "" {
|
||||
params.Set("accountId", filter.AccountID)
|
||||
}
|
||||
if filter.Type != "" {
|
||||
params.Set("type", filter.Type)
|
||||
}
|
||||
if filter.Status != "" {
|
||||
params.Set("status", filter.Status)
|
||||
}
|
||||
|
||||
path := "/api/CaseManagement"
|
||||
if len(params) > 0 {
|
||||
path += "?" + params.Encode()
|
||||
}
|
||||
|
||||
var resp []Case
|
||||
if err := c.get(ctx, path, &resp); err != nil {
|
||||
return nil, fmt.Errorf("jube: get cases: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Search performs an exhaustive search across Jube entities.
|
||||
// Calls POST /api/ExhaustiveSearchInstance.
|
||||
func (c *Client) Search(ctx context.Context, req SearchRequest) ([]SearchResult, error) {
|
||||
var resp []SearchResult
|
||||
if err := c.post(ctx, "/api/ExhaustiveSearchInstance", req, &resp); err != nil {
|
||||
return nil, fmt.Errorf("jube: search: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// SubscribeAlerts subscribes to async AML/fraud alerts via NATS.
|
||||
// The handler is called for each alert received. Returns an error if no
|
||||
// NATS connection was configured.
|
||||
func (c *Client) SubscribeAlerts(handler func(Alert)) error {
|
||||
if c.natsConn == nil {
|
||||
return fmt.Errorf("jube: nats not configured, cannot subscribe to alerts")
|
||||
}
|
||||
|
||||
sub, err := c.natsConn.Subscribe(NATSSubjectAlerts, func(msg *nats.Msg) {
|
||||
var alert Alert
|
||||
if err := json.Unmarshal(msg.Data, &alert); err != nil {
|
||||
log.Error().Err(err).Msg("jube: failed to unmarshal NATS alert")
|
||||
return
|
||||
}
|
||||
handler(alert)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("jube: nats subscribe: %w", err)
|
||||
}
|
||||
c.natsSub = sub
|
||||
return nil
|
||||
}
|
||||
|
||||
// post sends a POST request with a JSON body and decodes the response.
|
||||
func (c *Client) post(ctx context.Context, path string, body, dst interface{}) error {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
return c.doJSON(req, dst)
|
||||
}
|
||||
|
||||
// get sends a GET request and decodes the JSON response.
|
||||
func (c *Client) get(ctx context.Context, path string, dst interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
return c.doJSON(req, dst)
|
||||
}
|
||||
|
||||
// doJSON executes an HTTP request and decodes the JSON response into dst.
|
||||
func (c *Client) doJSON(req *http.Request, dst interface{}) error {
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http %s %s: %w", req.Method, req.URL.Path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("http %s %s: status %d: %s", req.Method, req.URL.Path, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if dst != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,551 +0,0 @@
|
||||
package jube
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- Client Tests ---
|
||||
|
||||
func TestNewClientDefaults(t *testing.T) {
|
||||
c, err := New(Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if c.baseURL != DefaultBaseURL {
|
||||
t.Fatalf("baseURL = %q, want %q", c.baseURL, DefaultBaseURL)
|
||||
}
|
||||
if c.httpClient.Timeout != DefaultTimeout {
|
||||
t.Fatalf("timeout = %v, want %v", c.httpClient.Timeout, DefaultTimeout)
|
||||
}
|
||||
if c.natsConn != nil {
|
||||
t.Fatal("natsConn should be nil when NATSAddr not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientCustomBaseURL(t *testing.T) {
|
||||
c, err := New(Config{BaseURL: "http://localhost:9999", Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if c.baseURL != "http://localhost:9999" {
|
||||
t.Fatalf("baseURL = %q, want http://localhost:9999", c.baseURL)
|
||||
}
|
||||
if c.httpClient.Timeout != 5*time.Second {
|
||||
t.Fatalf("timeout = %v, want 5s", c.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientBadNATS(t *testing.T) {
|
||||
_, err := New(Config{NATSAddr: "nats://127.0.0.1:19999"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error connecting to bad NATS address")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nats connect") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenTransaction(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/EntityAnalysisModel/Invoke" {
|
||||
t.Errorf("path = %s, want /api/EntityAnalysisModel/Invoke", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Content-Type = %s, want application/json", r.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
var req TransactionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if req.EntityAnalysisModelID != 1 {
|
||||
t.Errorf("modelId = %d, want 1", req.EntityAnalysisModelID)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(TransactionResponse{
|
||||
Score: 0.85,
|
||||
Action: ActionBlock,
|
||||
Alerts: []Alert{{ID: "a1", RuleName: "high-value", Severity: "high"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{BaseURL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
resp, err := c.ScreenTransaction(context.Background(), TransactionRequest{
|
||||
EntityAnalysisModelID: 1,
|
||||
EntityInstanceEntryPayload: map[string]interface{}{
|
||||
"AccountId": "acct-123",
|
||||
"Amount": 50000,
|
||||
"Currency": "USD",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ScreenTransaction() error: %v", err)
|
||||
}
|
||||
if resp.Score != 0.85 {
|
||||
t.Fatalf("score = %f, want 0.85", resp.Score)
|
||||
}
|
||||
if resp.Action != ActionBlock {
|
||||
t.Fatalf("action = %q, want %q", resp.Action, ActionBlock)
|
||||
}
|
||||
if len(resp.Alerts) != 1 {
|
||||
t.Fatalf("alerts len = %d, want 1", len(resp.Alerts))
|
||||
}
|
||||
if resp.Alerts[0].RuleName != "high-value" {
|
||||
t.Fatalf("alert ruleName = %q, want high-value", resp.Alerts[0].RuleName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSanctions(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/Sanction") {
|
||||
t.Errorf("path = %s, want /api/Sanction", r.URL.Path)
|
||||
}
|
||||
name := r.URL.Query().Get("name")
|
||||
if name != "John Doe" {
|
||||
t.Errorf("name param = %q, want 'John Doe'", name)
|
||||
}
|
||||
country := r.URL.Query().Get("country")
|
||||
if country != "US" {
|
||||
t.Errorf("country param = %q, want 'US'", country)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(SanctionResult{
|
||||
Hit: true,
|
||||
Matches: []SanctionMatch{
|
||||
{ListName: "OFAC SDN", EntityName: "John Doe", Score: 0.95, Country: "US"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{BaseURL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
result, err := c.CheckSanctions(context.Background(), "John Doe", "US")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckSanctions() error: %v", err)
|
||||
}
|
||||
if !result.Hit {
|
||||
t.Fatal("expected sanctions hit")
|
||||
}
|
||||
if len(result.Matches) != 1 {
|
||||
t.Fatalf("matches len = %d, want 1", len(result.Matches))
|
||||
}
|
||||
if result.Matches[0].ListName != "OFAC SDN" {
|
||||
t.Fatalf("listName = %q, want 'OFAC SDN'", result.Matches[0].ListName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSanctionsNoCountry(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("country") != "" {
|
||||
t.Error("expected no country param when empty string passed")
|
||||
}
|
||||
json.NewEncoder(w).Encode(SanctionResult{Hit: false})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{BaseURL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
result, err := c.CheckSanctions(context.Background(), "Clean Person", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckSanctions() error: %v", err)
|
||||
}
|
||||
if result.Hit {
|
||||
t.Fatal("expected no sanctions hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCase(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/CaseManagement" {
|
||||
t.Errorf("path = %s, want /api/CaseManagement", r.URL.Path)
|
||||
}
|
||||
|
||||
var req CaseRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(Case{
|
||||
ID: "case-001",
|
||||
AccountID: req.AccountID,
|
||||
Type: req.Type,
|
||||
Severity: req.Severity,
|
||||
Status: "open",
|
||||
Description: req.Description,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{BaseURL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
cas, err := c.CreateCase(context.Background(), CaseRequest{
|
||||
AccountID: "acct-123",
|
||||
Type: "aml",
|
||||
Severity: "high",
|
||||
Description: "Suspicious large wire transfer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCase() error: %v", err)
|
||||
}
|
||||
if cas.ID != "case-001" {
|
||||
t.Fatalf("case ID = %q, want case-001", cas.ID)
|
||||
}
|
||||
if cas.Status != "open" {
|
||||
t.Fatalf("case status = %q, want open", cas.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCases(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if r.URL.Query().Get("accountId") != "acct-123" {
|
||||
t.Errorf("accountId = %q, want acct-123", r.URL.Query().Get("accountId"))
|
||||
}
|
||||
if r.URL.Query().Get("type") != "aml" {
|
||||
t.Errorf("type = %q, want aml", r.URL.Query().Get("type"))
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode([]Case{
|
||||
{ID: "case-001", AccountID: "acct-123", Type: "aml", Status: "open"},
|
||||
{ID: "case-002", AccountID: "acct-123", Type: "aml", Status: "investigating"},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{BaseURL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
cases, err := c.GetCases(context.Background(), CaseFilter{
|
||||
AccountID: "acct-123",
|
||||
Type: "aml",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetCases() error: %v", err)
|
||||
}
|
||||
if len(cases) != 2 {
|
||||
t.Fatalf("cases len = %d, want 2", len(cases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCasesNoFilters(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.RawQuery != "" {
|
||||
t.Errorf("expected no query params, got %q", r.URL.RawQuery)
|
||||
}
|
||||
json.NewEncoder(w).Encode([]Case{})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{BaseURL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
cases, err := c.GetCases(context.Background(), CaseFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetCases() error: %v", err)
|
||||
}
|
||||
if len(cases) != 0 {
|
||||
t.Fatalf("cases len = %d, want 0", len(cases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/ExhaustiveSearchInstance" {
|
||||
t.Errorf("path = %s, want /api/ExhaustiveSearchInstance", r.URL.Path)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode([]SearchResult{
|
||||
{EntityID: "e1", EntityType: "account", Score: 0.9},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{BaseURL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
results, err := c.Search(context.Background(), SearchRequest{
|
||||
Query: "suspicious activity",
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search() error: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
if results[0].EntityID != "e1" {
|
||||
t.Fatalf("entityId = %q, want e1", results[0].EntityID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenTransactionHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error":"model not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{BaseURL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
_, err = c.ScreenTransaction(context.Background(), TransactionRequest{EntityAnalysisModelID: 999})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status 500") {
|
||||
t.Fatalf("error should mention status 500, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenTransactionContextCancelled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(5 * time.Second)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{BaseURL: srv.URL, Timeout: 100 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err = c.ScreenTransaction(ctx, TransactionRequest{EntityAnalysisModelID: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeAlertsNoNATS(t *testing.T) {
|
||||
c, err := New(Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
err = c.SubscribeAlerts(func(a Alert) {})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when NATS not configured")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nats not configured") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Webhook Tests ---
|
||||
|
||||
func TestFireWebhookSuccess(t *testing.T) {
|
||||
var receivedSig string
|
||||
var receivedBody []byte
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedSig = r.Header.Get("X-Webhook-Signature")
|
||||
var err error
|
||||
receivedBody, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
event := WebhookEvent{
|
||||
Event: EventAMLFlagged,
|
||||
Timestamp: time.Now(),
|
||||
Data: AMLFlaggedData{
|
||||
AccountID: "acct-123",
|
||||
TransactionID: "tx-456",
|
||||
RiskScore: 0.92,
|
||||
SanctionsHit: true,
|
||||
Action: ActionBlock,
|
||||
},
|
||||
}
|
||||
|
||||
err := FireWebhook(context.Background(), event, srv.URL, "test-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("FireWebhook() error: %v", err)
|
||||
}
|
||||
|
||||
if receivedSig == "" {
|
||||
t.Fatal("expected X-Webhook-Signature header")
|
||||
}
|
||||
|
||||
if !VerifySignature(receivedBody, receivedSig, "test-secret") {
|
||||
t.Fatal("signature verification failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireWebhookRetries(t *testing.T) {
|
||||
var attempts int32
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&attempts, 1)
|
||||
if n < 3 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
event := WebhookEvent{
|
||||
Event: EventAMLCleared,
|
||||
Timestamp: time.Now(),
|
||||
Data: AMLClearedData{AccountID: "acct-123", CaseID: "case-001"},
|
||||
}
|
||||
|
||||
err := FireWebhook(context.Background(), event, srv.URL, "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("FireWebhook() error: %v (attempts: %d)", err, atomic.LoadInt32(&attempts))
|
||||
}
|
||||
|
||||
got := atomic.LoadInt32(&attempts)
|
||||
if got != 3 {
|
||||
t.Fatalf("attempts = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireWebhookAllRetriesFail(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
event := WebhookEvent{
|
||||
Event: EventAMLFlagged,
|
||||
Timestamp: time.Now(),
|
||||
Data: AMLFlaggedData{AccountID: "acct-123"},
|
||||
}
|
||||
|
||||
err := FireWebhook(context.Background(), event, srv.URL, "secret")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when all retries fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "delivery failed after") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireWebhookContextCancelled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
event := WebhookEvent{
|
||||
Event: EventAMLFlagged,
|
||||
Timestamp: time.Now(),
|
||||
Data: AMLFlaggedData{AccountID: "acct-123"},
|
||||
}
|
||||
|
||||
err := FireWebhook(ctx, event, srv.URL, "secret")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySignature(t *testing.T) {
|
||||
payload := []byte(`{"event":"aml.flagged","data":{"accountId":"123"}}`)
|
||||
secret := "my-hmac-secret"
|
||||
|
||||
sig := signPayload(payload, secret)
|
||||
|
||||
if !VerifySignature(payload, sig, secret) {
|
||||
t.Fatal("valid signature rejected")
|
||||
}
|
||||
if VerifySignature(payload, sig, "wrong-secret") {
|
||||
t.Fatal("invalid secret accepted")
|
||||
}
|
||||
if VerifySignature(payload, "deadbeef", secret) {
|
||||
t.Fatal("invalid signature accepted")
|
||||
}
|
||||
if VerifySignature([]byte("tampered"), sig, secret) {
|
||||
t.Fatal("tampered payload accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionConstants(t *testing.T) {
|
||||
if ActionAllow != "allow" {
|
||||
t.Fatalf("ActionAllow = %q", ActionAllow)
|
||||
}
|
||||
if ActionBlock != "block" {
|
||||
t.Fatalf("ActionBlock = %q", ActionBlock)
|
||||
}
|
||||
if ActionReview != "review" {
|
||||
t.Fatalf("ActionReview = %q", ActionReview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConstants(t *testing.T) {
|
||||
if EventAMLFlagged != "aml.flagged" {
|
||||
t.Fatalf("EventAMLFlagged = %q", EventAMLFlagged)
|
||||
}
|
||||
if EventAMLCleared != "aml.cleared" {
|
||||
t.Fatalf("EventAMLCleared = %q", EventAMLCleared)
|
||||
}
|
||||
if EventKYCApproved != "kyc.approved" {
|
||||
t.Fatalf("EventKYCApproved = %q", EventKYCApproved)
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package jube
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// jubeURL returns the Jube endpoint from env or defaults to http://jube:5001.
|
||||
func jubeURL() string {
|
||||
if u := os.Getenv("JUBE_URL"); u != "" {
|
||||
return u
|
||||
}
|
||||
return DefaultBaseURL
|
||||
}
|
||||
|
||||
// --- Smoke Tests ---
|
||||
|
||||
func TestSmoke_JubeReady(t *testing.T) {
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := c.Get(jubeURL() + "/api/Ready")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/Ready: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("GET /api/Ready: status %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
t.Log("Jube is ready")
|
||||
}
|
||||
|
||||
func TestSmoke_SanctionsHit(t *testing.T) {
|
||||
c := &http.Client{Timeout: 10 * time.Second}
|
||||
url := jubeURL() + "/api/Invoke/Sanction?multiPartString=OSAMA+BIN+LADEN&distance=1"
|
||||
resp, err := c.Post(url, "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST /api/Invoke/Sanction: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /api/Invoke/Sanction: status %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// The response should contain sanctions match data.
|
||||
// Jube returns different structures depending on config, so we just
|
||||
// verify we got a non-empty JSON response.
|
||||
if len(body) < 2 {
|
||||
t.Fatal("expected non-empty sanctions response")
|
||||
}
|
||||
t.Logf("Sanctions hit response: %s", string(body))
|
||||
}
|
||||
|
||||
// --- E2E: Structuring Detection ---
|
||||
|
||||
func TestE2E_StructuringDetection(t *testing.T) {
|
||||
client, err := New(Config{BaseURL: jubeURL(), Timeout: 15 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Submit a pattern of rapid-fire $9,500 transactions.
|
||||
// This is the classic structuring pattern: multiple transactions just
|
||||
// below the $10,000 CTR reporting threshold.
|
||||
accountID := fmt.Sprintf("e2e-structuring-%d", time.Now().UnixNano())
|
||||
|
||||
var highestScore float64
|
||||
var gotBlock bool
|
||||
var allAlerts []Alert
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
resp, err := client.ScreenTransaction(context.Background(), TransactionRequest{
|
||||
EntityAnalysisModelID: 1,
|
||||
EntityInstanceEntryPayload: map[string]interface{}{
|
||||
"AccountId": accountID,
|
||||
"TransactionId": fmt.Sprintf("tx-%s-%d", accountID, i),
|
||||
"Amount": 9500,
|
||||
"Currency": "USD",
|
||||
"Side": "buy",
|
||||
"Symbol": "BTC-USD",
|
||||
"IP": "192.168.1.100",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ScreenTransaction #%d error: %v", i, err)
|
||||
}
|
||||
|
||||
t.Logf("Transaction #%d: score=%.2f action=%s alerts=%d",
|
||||
i, resp.Score, resp.Action, len(resp.Alerts))
|
||||
|
||||
if resp.Score > highestScore {
|
||||
highestScore = resp.Score
|
||||
}
|
||||
if resp.Action == ActionBlock {
|
||||
gotBlock = true
|
||||
}
|
||||
allAlerts = append(allAlerts, resp.Alerts...)
|
||||
}
|
||||
|
||||
// Jube should detect structuring with escalating scores.
|
||||
// We require at least one of:
|
||||
// - A block action
|
||||
// - A high risk score (> 0.5)
|
||||
// - At least one alert
|
||||
detected := gotBlock || highestScore > 0.5 || len(allAlerts) > 0
|
||||
if !detected {
|
||||
t.Fatalf("structuring not detected: highest_score=%.2f blocked=%v alerts=%d",
|
||||
highestScore, gotBlock, len(allAlerts))
|
||||
}
|
||||
|
||||
t.Logf("Structuring detection: highest_score=%.2f blocked=%v total_alerts=%d",
|
||||
highestScore, gotBlock, len(allAlerts))
|
||||
}
|
||||
|
||||
// --- E2E: Pre-Trade Screen Integration ---
|
||||
|
||||
func TestE2E_PreTradeScreenBlock(t *testing.T) {
|
||||
client, err := New(Config{BaseURL: jubeURL(), Timeout: 15 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
screen := NewPreTradeScreen(client, PreTradeConfig{
|
||||
ModelID: 1,
|
||||
AllowOnReview: false, // strict mode
|
||||
AllowOnError: false,
|
||||
})
|
||||
|
||||
accountID := fmt.Sprintf("e2e-pretrade-%d", time.Now().UnixNano())
|
||||
|
||||
// Submit rapid structuring pattern through the pre-trade screen.
|
||||
var lastResult PreTradeResult
|
||||
for i := 0; i < 5; i++ {
|
||||
lastResult = screen.Screen(context.Background(), ScreenRequest{
|
||||
AccountID: accountID,
|
||||
OrderID: fmt.Sprintf("order-%s-%d", accountID, i),
|
||||
Provider: "alpaca",
|
||||
Symbol: "BTC-USD",
|
||||
Side: "buy",
|
||||
Qty: "1",
|
||||
Price: "9500",
|
||||
Currency: "USD",
|
||||
IP: "10.0.0.1",
|
||||
})
|
||||
|
||||
t.Logf("PreTrade #%d: action=%s allowed=%v score=%.2f errors=%v warnings=%v",
|
||||
i, lastResult.Action, lastResult.Allowed, lastResult.Score,
|
||||
lastResult.Errors, lastResult.Warnings)
|
||||
}
|
||||
|
||||
// After 5 structuring transactions, Jube should have escalated.
|
||||
if lastResult.Score < 0.3 {
|
||||
t.Logf("WARNING: Jube did not escalate score after 5 structuring txns (score=%.2f)", lastResult.Score)
|
||||
t.Log("This may indicate Jube's structuring model needs configuration.")
|
||||
t.Log("Ensure EntityAnalysisModel ID=1 has structuring rules enabled.")
|
||||
}
|
||||
}
|
||||
|
||||
// --- E2E: Webhook Fires on AML Flag ---
|
||||
|
||||
func TestE2E_WebhookFiresOnFlag(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var received []WebhookEvent
|
||||
|
||||
webhookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var event WebhookEvent
|
||||
json.NewDecoder(r.Body).Decode(&event)
|
||||
mu.Lock()
|
||||
received = append(received, event)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer webhookSrv.Close()
|
||||
|
||||
client, err := New(Config{BaseURL: jubeURL(), Timeout: 15 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
screen := NewPreTradeScreen(client, PreTradeConfig{
|
||||
ModelID: 1,
|
||||
AllowOnReview: true,
|
||||
AllowOnError: true,
|
||||
WebhookURL: webhookSrv.URL,
|
||||
WebhookHMACSecret: "e2e-test-secret",
|
||||
})
|
||||
|
||||
accountID := fmt.Sprintf("e2e-webhook-%d", time.Now().UnixNano())
|
||||
|
||||
// Submit structuring pattern to trigger flags.
|
||||
for i := 0; i < 5; i++ {
|
||||
screen.Screen(context.Background(), ScreenRequest{
|
||||
AccountID: accountID,
|
||||
OrderID: fmt.Sprintf("order-%s-%d", accountID, i),
|
||||
Provider: "alpaca",
|
||||
Symbol: "BTC-USD",
|
||||
Side: "buy",
|
||||
Qty: "1",
|
||||
Price: "9500",
|
||||
Currency: "USD",
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for async webhook deliveries.
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if len(received) == 0 {
|
||||
t.Log("No webhooks received. This is expected if Jube does not flag structuring by default.")
|
||||
t.Log("Configure Jube with structuring rules (EntityAnalysisModel ID=1) to trigger aml.flagged events.")
|
||||
return
|
||||
}
|
||||
|
||||
for i, event := range received {
|
||||
if event.Event != EventAMLFlagged {
|
||||
t.Errorf("webhook #%d: event=%q, want %q", i, event.Event, EventAMLFlagged)
|
||||
}
|
||||
}
|
||||
t.Logf("Received %d aml.flagged webhooks", len(received))
|
||||
}
|
||||
|
||||
// --- E2E: Sanctions Screen via Client ---
|
||||
|
||||
func TestE2E_SanctionsScreenSDN(t *testing.T) {
|
||||
client, err := New(Config{BaseURL: jubeURL(), Timeout: 15 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
result, err := client.CheckSanctions(context.Background(), "OSAMA BIN LADEN", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckSanctions() error: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Sanctions result: hit=%v matches=%d", result.Hit, len(result.Matches))
|
||||
|
||||
if !result.Hit {
|
||||
t.Log("WARNING: No sanctions hit for 'OSAMA BIN LADEN'. Check Jube sanctions list configuration.")
|
||||
}
|
||||
}
|
||||
|
||||
// --- E2E: Case Management ---
|
||||
|
||||
func TestE2E_CreateCaseForFlaggedTransaction(t *testing.T) {
|
||||
client, err := New(Config{BaseURL: jubeURL(), Timeout: 15 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
accountID := fmt.Sprintf("e2e-case-%d", time.Now().UnixNano())
|
||||
|
||||
cas, err := client.CreateCase(context.Background(), CaseRequest{
|
||||
AccountID: accountID,
|
||||
Type: "aml",
|
||||
Severity: "high",
|
||||
Description: "E2E test: structuring detected in rapid $9,500 transactions",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCase() error: %v", err)
|
||||
}
|
||||
|
||||
if cas.ID == "" {
|
||||
t.Fatal("expected case to have an ID")
|
||||
}
|
||||
if cas.Status != "open" {
|
||||
t.Logf("case status = %q (expected 'open', but Jube may use different defaults)", cas.Status)
|
||||
}
|
||||
|
||||
t.Logf("Created case: id=%s status=%s", cas.ID, cas.Status)
|
||||
|
||||
// Verify we can retrieve the case.
|
||||
cases, err := client.GetCases(context.Background(), CaseFilter{AccountID: accountID, Type: "aml"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetCases() error: %v", err)
|
||||
}
|
||||
if len(cases) == 0 {
|
||||
t.Log("WARNING: GetCases returned 0 results. Jube may not support case listing with these filters.")
|
||||
} else {
|
||||
t.Logf("Retrieved %d cases for account %s", len(cases), accountID)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// Package jube provides an HTTP client for the Jube AML/fraud detection sidecar.
|
||||
// Jube runs as a sidecar service and exposes REST endpoints for real-time
|
||||
// transaction risk scoring, sanctions screening, and case management.
|
||||
package jube
|
||||
|
||||
import "time"
|
||||
|
||||
// Action constants returned by Jube risk scoring.
|
||||
const (
|
||||
ActionAllow = "allow"
|
||||
ActionBlock = "block"
|
||||
ActionReview = "review"
|
||||
)
|
||||
|
||||
// TransactionRequest is sent to Jube for real-time risk scoring.
|
||||
type TransactionRequest struct {
|
||||
EntityAnalysisModelID int `json:"entityAnalysisModelId"`
|
||||
EntityInstanceEntryPayload map[string]interface{} `json:"entityInstanceEntryPayload"`
|
||||
}
|
||||
|
||||
// TransactionResponse is the risk assessment returned by Jube.
|
||||
type TransactionResponse struct {
|
||||
Score float64 `json:"score"`
|
||||
Alerts []Alert `json:"alerts"`
|
||||
Action string `json:"action"` // allow, block, review
|
||||
ModelTags map[string]string `json:"modelTags,omitempty"`
|
||||
}
|
||||
|
||||
// Alert represents a single AML/fraud alert from Jube.
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
RuleName string `json:"ruleName"`
|
||||
Severity string `json:"severity"` // low, medium, high, critical
|
||||
Description string `json:"description"`
|
||||
Score float64 `json:"score"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// SanctionCheckRequest contains the parameters for a sanctions screening query.
|
||||
type SanctionCheckRequest struct {
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country,omitempty"`
|
||||
}
|
||||
|
||||
// SanctionResult is the response from a sanctions screening query.
|
||||
type SanctionResult struct {
|
||||
Hit bool `json:"hit"`
|
||||
Matches []SanctionMatch `json:"matches,omitempty"`
|
||||
}
|
||||
|
||||
// SanctionMatch is a single match from the sanctions list.
|
||||
type SanctionMatch struct {
|
||||
ListName string `json:"listName"`
|
||||
EntityName string `json:"entityName"`
|
||||
Score float64 `json:"score"`
|
||||
Country string `json:"country,omitempty"`
|
||||
}
|
||||
|
||||
// CaseRequest is used to create a new compliance case.
|
||||
type CaseRequest struct {
|
||||
AccountID string `json:"accountId"`
|
||||
TransactionID string `json:"transactionId,omitempty"`
|
||||
Type string `json:"type"` // aml, fraud, sanctions
|
||||
Severity string `json:"severity"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Case represents a compliance case in Jube.
|
||||
type Case struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"accountId"`
|
||||
TransactionID string `json:"transactionId,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
Status string `json:"status"` // open, investigating, escalated, closed
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// CaseFilter contains query parameters for listing cases.
|
||||
type CaseFilter struct {
|
||||
AccountID string `json:"accountId,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// SearchRequest is sent to the exhaustive search endpoint.
|
||||
type SearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
Filters map[string]string `json:"filters,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// SearchResult is a single result from the exhaustive search.
|
||||
type SearchResult struct {
|
||||
EntityID string `json:"entityId"`
|
||||
EntityType string `json:"entityType"`
|
||||
Score float64 `json:"score"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package jube
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// PreTradeAction describes what the broker should do with the order.
|
||||
type PreTradeAction string
|
||||
|
||||
const (
|
||||
PreTradeAllow PreTradeAction = "allow"
|
||||
PreTradeBlock PreTradeAction = "block"
|
||||
PreTradeReview PreTradeAction = "review"
|
||||
)
|
||||
|
||||
// PreTradeConfig controls how the Jube pre-trade screen behaves.
|
||||
type PreTradeConfig struct {
|
||||
// ModelID is the Jube EntityAnalysisModel to invoke.
|
||||
ModelID int
|
||||
|
||||
// AllowOnReview controls whether orders flagged "review" are allowed
|
||||
// to proceed (true) or held (false). Default: true (allow, flag async).
|
||||
AllowOnReview bool
|
||||
|
||||
// AllowOnError controls whether orders proceed if Jube is unreachable.
|
||||
// Default: true (fail-open). Set false for fail-closed.
|
||||
AllowOnError bool
|
||||
|
||||
// WebhookURL is the target for aml.flagged webhook events. Empty = no webhook.
|
||||
WebhookURL string
|
||||
|
||||
// WebhookHMACSecret is the HMAC-SHA256 secret for signing webhooks.
|
||||
// Must come from KMS, never hardcoded.
|
||||
WebhookHMACSecret string
|
||||
}
|
||||
|
||||
// PreTradeResult is the outcome of a Jube AML screen on an order.
|
||||
type PreTradeResult struct {
|
||||
Action PreTradeAction `json:"action"`
|
||||
Allowed bool `json:"allowed"`
|
||||
Score float64 `json:"score"`
|
||||
Alerts []Alert `json:"alerts,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// PreTradeScreen wraps a Jube client and provides pre-trade AML screening
|
||||
// for the broker's order flow. Call Screen() before executing any trade.
|
||||
type PreTradeScreen struct {
|
||||
client *Client
|
||||
cfg PreTradeConfig
|
||||
}
|
||||
|
||||
// NewPreTradeScreen creates a pre-trade screener using the given Jube client.
|
||||
func NewPreTradeScreen(client *Client, cfg PreTradeConfig) *PreTradeScreen {
|
||||
if cfg.ModelID == 0 {
|
||||
cfg.ModelID = 1 // default model
|
||||
}
|
||||
return &PreTradeScreen{
|
||||
client: client,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Screen submits a transaction to Jube for AML/fraud scoring and returns
|
||||
// a decision. This should be called in the broker's order creation path
|
||||
// after risk checks pass but before the order reaches the provider.
|
||||
func (s *PreTradeScreen) Screen(ctx context.Context, req ScreenRequest) PreTradeResult {
|
||||
txReq := TransactionRequest{
|
||||
EntityAnalysisModelID: s.cfg.ModelID,
|
||||
EntityInstanceEntryPayload: map[string]interface{}{
|
||||
"AccountId": req.AccountID,
|
||||
"TransactionId": req.OrderID,
|
||||
"Amount": req.Amount(),
|
||||
"Currency": req.Currency,
|
||||
"Symbol": req.Symbol,
|
||||
"Side": req.Side,
|
||||
"Provider": req.Provider,
|
||||
"IP": req.IP,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := s.client.ScreenTransaction(ctx, txReq)
|
||||
if err != nil {
|
||||
log.Error().Err(err).
|
||||
Str("account", req.AccountID).
|
||||
Str("symbol", req.Symbol).
|
||||
Msg("jube: pre-trade screen failed")
|
||||
|
||||
if s.cfg.AllowOnError {
|
||||
return PreTradeResult{
|
||||
Action: PreTradeAllow,
|
||||
Allowed: true,
|
||||
Warnings: []string{fmt.Sprintf("jube unavailable: %v", err)},
|
||||
}
|
||||
}
|
||||
return PreTradeResult{
|
||||
Action: PreTradeBlock,
|
||||
Allowed: false,
|
||||
Errors: []string{fmt.Sprintf("compliance check unavailable: %v", err)},
|
||||
}
|
||||
}
|
||||
|
||||
result := PreTradeResult{
|
||||
Score: resp.Score,
|
||||
Alerts: resp.Alerts,
|
||||
}
|
||||
|
||||
switch resp.Action {
|
||||
case ActionBlock:
|
||||
result.Action = PreTradeBlock
|
||||
result.Allowed = false
|
||||
result.Errors = append(result.Errors, "transaction blocked by AML screening")
|
||||
for _, a := range resp.Alerts {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("[%s] %s (score=%.2f)", a.Severity, a.RuleName, a.Score))
|
||||
}
|
||||
|
||||
case ActionReview:
|
||||
result.Action = PreTradeReview
|
||||
result.Allowed = s.cfg.AllowOnReview
|
||||
result.Warnings = append(result.Warnings, "transaction flagged for manual review")
|
||||
for _, a := range resp.Alerts {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("[%s] %s (score=%.2f)", a.Severity, a.RuleName, a.Score))
|
||||
}
|
||||
|
||||
default: // allow
|
||||
result.Action = PreTradeAllow
|
||||
result.Allowed = true
|
||||
}
|
||||
|
||||
// Fire webhook async for flagged/blocked transactions.
|
||||
if resp.Action == ActionBlock || resp.Action == ActionReview {
|
||||
if s.cfg.WebhookURL != "" && s.cfg.WebhookHMACSecret != "" {
|
||||
go s.fireAMLWebhook(req, resp)
|
||||
}
|
||||
|
||||
// Publish to NATS if connected.
|
||||
if s.client.natsConn != nil && len(resp.Alerts) > 0 {
|
||||
go s.publishNATSAlert(req, resp)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ScreenRequest contains the order details for AML screening.
|
||||
type ScreenRequest struct {
|
||||
AccountID string
|
||||
OrderID string
|
||||
Provider string
|
||||
Symbol string
|
||||
Side string
|
||||
Qty string
|
||||
Price string
|
||||
Currency string
|
||||
IP string
|
||||
}
|
||||
|
||||
// Amount returns the estimated USD value of the order.
|
||||
func (r ScreenRequest) Amount() float64 {
|
||||
q, _ := strconv.ParseFloat(r.Qty, 64)
|
||||
p, _ := strconv.ParseFloat(r.Price, 64)
|
||||
if p == 0 {
|
||||
p = 1
|
||||
}
|
||||
return q * p
|
||||
}
|
||||
|
||||
// fireAMLWebhook sends an aml.flagged webhook event.
|
||||
func (s *PreTradeScreen) fireAMLWebhook(req ScreenRequest, resp *TransactionResponse) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
event := WebhookEvent{
|
||||
Event: EventAMLFlagged,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Data: AMLFlaggedData{
|
||||
AccountID: req.AccountID,
|
||||
TransactionID: req.OrderID,
|
||||
RiskScore: resp.Score,
|
||||
Alerts: resp.Alerts,
|
||||
Action: resp.Action,
|
||||
},
|
||||
}
|
||||
|
||||
if err := FireWebhook(ctx, event, s.cfg.WebhookURL, s.cfg.WebhookHMACSecret); err != nil {
|
||||
log.Error().Err(err).
|
||||
Str("account", req.AccountID).
|
||||
Msg("jube: failed to fire aml.flagged webhook")
|
||||
}
|
||||
}
|
||||
|
||||
// publishNATSAlert publishes an alert to NATS for async processing.
|
||||
func (s *PreTradeScreen) publishNATSAlert(req ScreenRequest, resp *TransactionResponse) {
|
||||
for _, alert := range resp.Alerts {
|
||||
data, err := json.Marshal(alert)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("jube: failed to marshal alert for NATS")
|
||||
continue
|
||||
}
|
||||
if err := s.client.natsConn.Publish(NATSSubjectAlerts, data); err != nil {
|
||||
log.Error().Err(err).
|
||||
Str("account", req.AccountID).
|
||||
Str("alert", alert.ID).
|
||||
Msg("jube: failed to publish NATS alert")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
package jube
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- PreTradeScreen Unit Tests ---
|
||||
|
||||
func newTestScreen(t *testing.T, handler http.HandlerFunc, cfg PreTradeConfig) (*PreTradeScreen, func()) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
c, err := New(Config{BaseURL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
screen := NewPreTradeScreen(c, cfg)
|
||||
return screen, func() {
|
||||
c.Close()
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenAllowCleanTransaction(t *testing.T) {
|
||||
screen, cleanup := newTestScreen(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(TransactionResponse{
|
||||
Score: 0.1,
|
||||
Action: ActionAllow,
|
||||
})
|
||||
}, PreTradeConfig{})
|
||||
defer cleanup()
|
||||
|
||||
result := screen.Screen(context.Background(), ScreenRequest{
|
||||
AccountID: "acct-clean",
|
||||
Symbol: "AAPL",
|
||||
Side: "buy",
|
||||
Qty: "10",
|
||||
Price: "150.00",
|
||||
Currency: "USD",
|
||||
})
|
||||
|
||||
if !result.Allowed {
|
||||
t.Fatalf("expected allowed=true, got false; errors: %v", result.Errors)
|
||||
}
|
||||
if result.Action != PreTradeAllow {
|
||||
t.Fatalf("action = %q, want %q", result.Action, PreTradeAllow)
|
||||
}
|
||||
if result.Score != 0.1 {
|
||||
t.Fatalf("score = %f, want 0.1", result.Score)
|
||||
}
|
||||
if len(result.Errors) != 0 {
|
||||
t.Fatalf("expected no errors, got %v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenBlockHighRisk(t *testing.T) {
|
||||
screen, cleanup := newTestScreen(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(TransactionResponse{
|
||||
Score: 0.95,
|
||||
Action: ActionBlock,
|
||||
Alerts: []Alert{
|
||||
{ID: "a1", RuleName: "structuring", Severity: "critical", Score: 0.95},
|
||||
},
|
||||
})
|
||||
}, PreTradeConfig{})
|
||||
defer cleanup()
|
||||
|
||||
result := screen.Screen(context.Background(), ScreenRequest{
|
||||
AccountID: "acct-sus",
|
||||
Symbol: "BTC-USD",
|
||||
Side: "buy",
|
||||
Qty: "1",
|
||||
Price: "9500",
|
||||
Currency: "USD",
|
||||
})
|
||||
|
||||
if result.Allowed {
|
||||
t.Fatal("expected allowed=false for blocked transaction")
|
||||
}
|
||||
if result.Action != PreTradeBlock {
|
||||
t.Fatalf("action = %q, want %q", result.Action, PreTradeBlock)
|
||||
}
|
||||
if result.Score != 0.95 {
|
||||
t.Fatalf("score = %f, want 0.95", result.Score)
|
||||
}
|
||||
if len(result.Errors) < 2 {
|
||||
t.Fatalf("expected at least 2 errors (block msg + alert), got %d: %v", len(result.Errors), result.Errors)
|
||||
}
|
||||
if len(result.Alerts) != 1 {
|
||||
t.Fatalf("expected 1 alert, got %d", len(result.Alerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenReviewAllowedByDefault(t *testing.T) {
|
||||
screen, cleanup := newTestScreen(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(TransactionResponse{
|
||||
Score: 0.65,
|
||||
Action: ActionReview,
|
||||
Alerts: []Alert{
|
||||
{ID: "a1", RuleName: "velocity", Severity: "medium", Score: 0.65},
|
||||
},
|
||||
})
|
||||
}, PreTradeConfig{AllowOnReview: true})
|
||||
defer cleanup()
|
||||
|
||||
result := screen.Screen(context.Background(), ScreenRequest{
|
||||
AccountID: "acct-review",
|
||||
Symbol: "MSFT",
|
||||
Side: "buy",
|
||||
Qty: "100",
|
||||
Price: "300",
|
||||
Currency: "USD",
|
||||
})
|
||||
|
||||
if !result.Allowed {
|
||||
t.Fatal("expected allowed=true when AllowOnReview=true")
|
||||
}
|
||||
if result.Action != PreTradeReview {
|
||||
t.Fatalf("action = %q, want %q", result.Action, PreTradeReview)
|
||||
}
|
||||
if len(result.Warnings) == 0 {
|
||||
t.Fatal("expected warnings for review action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenReviewBlockedWhenConfigured(t *testing.T) {
|
||||
screen, cleanup := newTestScreen(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(TransactionResponse{
|
||||
Score: 0.70,
|
||||
Action: ActionReview,
|
||||
Alerts: []Alert{
|
||||
{ID: "a1", RuleName: "suspicious-pattern", Severity: "high", Score: 0.70},
|
||||
},
|
||||
})
|
||||
}, PreTradeConfig{AllowOnReview: false})
|
||||
defer cleanup()
|
||||
|
||||
result := screen.Screen(context.Background(), ScreenRequest{
|
||||
AccountID: "acct-strict",
|
||||
Symbol: "ETH-USD",
|
||||
Side: "sell",
|
||||
Qty: "50",
|
||||
Price: "2000",
|
||||
Currency: "USD",
|
||||
})
|
||||
|
||||
if result.Allowed {
|
||||
t.Fatal("expected allowed=false when AllowOnReview=false")
|
||||
}
|
||||
if result.Action != PreTradeReview {
|
||||
t.Fatalf("action = %q, want %q", result.Action, PreTradeReview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenFailOpenOnError(t *testing.T) {
|
||||
screen, cleanup := newTestScreen(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error":"internal"}`))
|
||||
}, PreTradeConfig{AllowOnError: true})
|
||||
defer cleanup()
|
||||
|
||||
result := screen.Screen(context.Background(), ScreenRequest{
|
||||
AccountID: "acct-err",
|
||||
Symbol: "GOOG",
|
||||
Side: "buy",
|
||||
Qty: "5",
|
||||
Price: "100",
|
||||
Currency: "USD",
|
||||
})
|
||||
|
||||
if !result.Allowed {
|
||||
t.Fatal("expected allowed=true (fail-open) when Jube returns error")
|
||||
}
|
||||
if result.Action != PreTradeAllow {
|
||||
t.Fatalf("action = %q, want %q", result.Action, PreTradeAllow)
|
||||
}
|
||||
if len(result.Warnings) == 0 {
|
||||
t.Fatal("expected warning about Jube unavailability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenFailClosedOnError(t *testing.T) {
|
||||
screen, cleanup := newTestScreen(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error":"internal"}`))
|
||||
}, PreTradeConfig{AllowOnError: false})
|
||||
defer cleanup()
|
||||
|
||||
result := screen.Screen(context.Background(), ScreenRequest{
|
||||
AccountID: "acct-err",
|
||||
Symbol: "GOOG",
|
||||
Side: "buy",
|
||||
Qty: "5",
|
||||
Price: "100",
|
||||
Currency: "USD",
|
||||
})
|
||||
|
||||
if result.Allowed {
|
||||
t.Fatal("expected allowed=false (fail-closed) when Jube returns error")
|
||||
}
|
||||
if result.Action != PreTradeBlock {
|
||||
t.Fatalf("action = %q, want %q", result.Action, PreTradeBlock)
|
||||
}
|
||||
if len(result.Errors) == 0 {
|
||||
t.Fatal("expected error about compliance unavailability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenSendsCorrectPayload(t *testing.T) {
|
||||
var received TransactionRequest
|
||||
screen, cleanup := newTestScreen(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&received)
|
||||
json.NewEncoder(w).Encode(TransactionResponse{Action: ActionAllow, Score: 0.05})
|
||||
}, PreTradeConfig{ModelID: 42})
|
||||
defer cleanup()
|
||||
|
||||
screen.Screen(context.Background(), ScreenRequest{
|
||||
AccountID: "acct-payload",
|
||||
OrderID: "order-789",
|
||||
Provider: "alpaca",
|
||||
Symbol: "AAPL",
|
||||
Side: "buy",
|
||||
Qty: "10",
|
||||
Price: "150",
|
||||
Currency: "USD",
|
||||
IP: "10.0.0.1",
|
||||
})
|
||||
|
||||
if received.EntityAnalysisModelID != 42 {
|
||||
t.Fatalf("modelId = %d, want 42", received.EntityAnalysisModelID)
|
||||
}
|
||||
payload := received.EntityInstanceEntryPayload
|
||||
if payload["AccountId"] != "acct-payload" {
|
||||
t.Fatalf("AccountId = %v, want acct-payload", payload["AccountId"])
|
||||
}
|
||||
if payload["TransactionId"] != "order-789" {
|
||||
t.Fatalf("TransactionId = %v, want order-789", payload["TransactionId"])
|
||||
}
|
||||
if payload["Symbol"] != "AAPL" {
|
||||
t.Fatalf("Symbol = %v, want AAPL", payload["Symbol"])
|
||||
}
|
||||
// Amount should be 10 * 150 = 1500
|
||||
if amt, ok := payload["Amount"].(float64); !ok || amt != 1500 {
|
||||
t.Fatalf("Amount = %v, want 1500", payload["Amount"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenWebhookFiredOnBlock(t *testing.T) {
|
||||
var webhookReceived bool
|
||||
var webhookBody WebhookEvent
|
||||
var mu sync.Mutex
|
||||
|
||||
webhookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
webhookReceived = true
|
||||
json.NewDecoder(r.Body).Decode(&webhookBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer webhookSrv.Close()
|
||||
|
||||
screen, cleanup := newTestScreen(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(TransactionResponse{
|
||||
Score: 0.90,
|
||||
Action: ActionBlock,
|
||||
Alerts: []Alert{{ID: "a1", RuleName: "high-risk", Severity: "critical"}},
|
||||
})
|
||||
}, PreTradeConfig{
|
||||
WebhookURL: webhookSrv.URL,
|
||||
WebhookHMACSecret: "test-webhook-secret",
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
screen.Screen(context.Background(), ScreenRequest{
|
||||
AccountID: "acct-webhook",
|
||||
OrderID: "order-wh",
|
||||
Symbol: "BTC-USD",
|
||||
Side: "buy",
|
||||
Qty: "1",
|
||||
Price: "50000",
|
||||
Currency: "USD",
|
||||
})
|
||||
|
||||
// Wait for async webhook delivery.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !webhookReceived {
|
||||
t.Fatal("expected webhook to be fired on block")
|
||||
}
|
||||
if webhookBody.Event != EventAMLFlagged {
|
||||
t.Fatalf("webhook event = %q, want %q", webhookBody.Event, EventAMLFlagged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenDefaultModelID(t *testing.T) {
|
||||
screen := NewPreTradeScreen(&Client{}, PreTradeConfig{})
|
||||
if screen.cfg.ModelID != 1 {
|
||||
t.Fatalf("default ModelID = %d, want 1", screen.cfg.ModelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenCustomModelID(t *testing.T) {
|
||||
screen := NewPreTradeScreen(&Client{}, PreTradeConfig{ModelID: 99})
|
||||
if screen.cfg.ModelID != 99 {
|
||||
t.Fatalf("ModelID = %d, want 99", screen.cfg.ModelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenRequestAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
qty, price string
|
||||
want float64
|
||||
}{
|
||||
{"10", "150", 1500},
|
||||
{"0.5", "100", 50},
|
||||
{"100", "", 100}, // no price = market order, p defaults to 1
|
||||
{"", "100", 0}, // no qty = 0
|
||||
{"abc", "100", 0}, // invalid qty
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
r := ScreenRequest{Qty: tt.qty, Price: tt.price}
|
||||
got := r.Amount()
|
||||
if got != tt.want {
|
||||
t.Errorf("Amount(%q, %q) = %f, want %f", tt.qty, tt.price, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenMultipleAlerts(t *testing.T) {
|
||||
screen, cleanup := newTestScreen(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(TransactionResponse{
|
||||
Score: 0.88,
|
||||
Action: ActionBlock,
|
||||
Alerts: []Alert{
|
||||
{ID: "a1", RuleName: "structuring", Severity: "critical", Score: 0.88},
|
||||
{ID: "a2", RuleName: "velocity", Severity: "high", Score: 0.75},
|
||||
{ID: "a3", RuleName: "geography", Severity: "medium", Score: 0.60},
|
||||
},
|
||||
})
|
||||
}, PreTradeConfig{})
|
||||
defer cleanup()
|
||||
|
||||
result := screen.Screen(context.Background(), ScreenRequest{
|
||||
AccountID: "acct-multi",
|
||||
Symbol: "BTC-USD",
|
||||
Side: "buy",
|
||||
Qty: "1",
|
||||
Price: "9500",
|
||||
Currency: "USD",
|
||||
})
|
||||
|
||||
if result.Allowed {
|
||||
t.Fatal("expected blocked")
|
||||
}
|
||||
if len(result.Alerts) != 3 {
|
||||
t.Fatalf("expected 3 alerts, got %d", len(result.Alerts))
|
||||
}
|
||||
// 1 block message + 3 alert details = 4 errors
|
||||
if len(result.Errors) != 4 {
|
||||
t.Fatalf("expected 4 errors, got %d: %v", len(result.Errors), result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenContextCancelled(t *testing.T) {
|
||||
screen, cleanup := newTestScreen(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(5 * time.Second)
|
||||
}, PreTradeConfig{AllowOnError: false})
|
||||
defer cleanup()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
result := screen.Screen(ctx, ScreenRequest{
|
||||
AccountID: "acct-timeout",
|
||||
Symbol: "AAPL",
|
||||
Side: "buy",
|
||||
Qty: "1",
|
||||
Price: "100",
|
||||
Currency: "USD",
|
||||
})
|
||||
|
||||
if result.Allowed {
|
||||
t.Fatal("expected blocked on timeout with fail-closed")
|
||||
}
|
||||
if len(result.Errors) == 0 {
|
||||
t.Fatal("expected error message")
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package jube
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Webhook event type constants.
|
||||
const (
|
||||
EventAMLFlagged = "aml.flagged"
|
||||
EventAMLCleared = "aml.cleared"
|
||||
EventKYCApproved = "kyc.approved"
|
||||
EventTradeExecuted = "trade.executed"
|
||||
)
|
||||
|
||||
// maxWebhookRetries is the maximum number of delivery attempts.
|
||||
const maxWebhookRetries = 5
|
||||
|
||||
// webhookInitialBackoff is the base delay for exponential backoff.
|
||||
const webhookInitialBackoff = 500 * time.Millisecond
|
||||
|
||||
// WebhookEvent is the envelope sent to webhook targets.
|
||||
type WebhookEvent struct {
|
||||
Event string `json:"event"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// AMLFlaggedData is the payload for aml.flagged events.
|
||||
type AMLFlaggedData struct {
|
||||
AccountID string `json:"accountId"`
|
||||
TransactionID string `json:"transactionId"`
|
||||
RiskScore float64 `json:"riskScore"`
|
||||
Alerts []Alert `json:"alerts"`
|
||||
SanctionsHit bool `json:"sanctionsHit"`
|
||||
PEPMatch bool `json:"pepMatch"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// AMLClearedData is the payload for aml.cleared events.
|
||||
type AMLClearedData struct {
|
||||
AccountID string `json:"accountId"`
|
||||
TransactionID string `json:"transactionId"`
|
||||
CaseID string `json:"caseId"`
|
||||
ClearedBy string `json:"clearedBy"`
|
||||
}
|
||||
|
||||
// KYCApprovedData is the payload for kyc.approved events.
|
||||
type KYCApprovedData struct {
|
||||
AccountID string `json:"accountId"`
|
||||
UserID string `json:"userId"`
|
||||
Provider string `json:"provider"`
|
||||
VerifiedAt string `json:"verifiedAt"`
|
||||
}
|
||||
|
||||
// FireWebhook sends a signed webhook event to the target URL.
|
||||
// The payload is signed with HMAC-SHA256 using hmacSecret, and the signature
|
||||
// is sent in the X-Webhook-Signature header. Delivery is retried up to
|
||||
// maxWebhookRetries times with exponential backoff on failure.
|
||||
func FireWebhook(ctx context.Context, event WebhookEvent, targetURL string, hmacSecret string) error {
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jube webhook: marshal event: %w", err)
|
||||
}
|
||||
|
||||
sig := signPayload(payload, hmacSecret)
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxWebhookRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(float64(webhookInitialBackoff) * math.Pow(2, float64(attempt-1)))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("jube webhook: context cancelled after %d attempts: %w", attempt, ctx.Err())
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
lastErr = sendWebhook(ctx, targetURL, payload, sig)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("jube webhook: delivery failed after %d attempts: %w", maxWebhookRetries, lastErr)
|
||||
}
|
||||
|
||||
// sendWebhook performs a single webhook delivery attempt.
|
||||
func sendWebhook(ctx context.Context, targetURL string, payload []byte, signature string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Webhook-Signature", signature)
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http post: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("target returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// signPayload computes HMAC-SHA256 of payload using the given secret.
|
||||
func signPayload(payload []byte, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(payload)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// VerifySignature checks that the given signature matches the expected
|
||||
// HMAC-SHA256 of payload. Use this on the receiving end of webhooks.
|
||||
func VerifySignature(payload []byte, signature string, secret string) bool {
|
||||
expected := signPayload(payload, secret)
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/luxfi/broker/pkg/admin"
|
||||
"github.com/luxfi/broker/pkg/auth"
|
||||
"github.com/luxfi/broker/pkg/compliance/jube"
|
||||
"github.com/luxfi/compliance/pkg/jube"
|
||||
)
|
||||
|
||||
// RouterOption configures optional dependencies for the compliance router.
|
||||
|
||||
@@ -1,851 +0,0 @@
|
||||
package compliance
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ComplianceStore is the interface for compliance data persistence.
|
||||
// MemoryStore and PostgresStore both implement this interface.
|
||||
type ComplianceStore interface {
|
||||
// Identity
|
||||
SaveIdentity(id *Identity) error
|
||||
GetIdentity(id string) (*Identity, error)
|
||||
|
||||
// Pipeline
|
||||
SavePipeline(p *Pipeline) error
|
||||
GetPipeline(id string) (*Pipeline, error)
|
||||
ListPipelines() []*Pipeline
|
||||
DeletePipeline(id string) error
|
||||
|
||||
// Session
|
||||
SaveSession(sess *Session) error
|
||||
GetSession(id string) (*Session, error)
|
||||
ListSessions() []*Session
|
||||
|
||||
// Fund
|
||||
SaveFund(f *Fund) error
|
||||
GetFund(id string) (*Fund, error)
|
||||
ListFunds() []*Fund
|
||||
DeleteFund(id string) error
|
||||
AddFundInvestor(inv *FundInvestor) error
|
||||
ListFundInvestors(fundID string) []*FundInvestor
|
||||
|
||||
// Envelope
|
||||
SaveEnvelope(env *Envelope) error
|
||||
GetEnvelope(id string) (*Envelope, error)
|
||||
ListEnvelopes() []*Envelope
|
||||
|
||||
// Template
|
||||
SaveTemplate(t *Template) error
|
||||
GetTemplate(id string) (*Template, error)
|
||||
ListTemplates() []*Template
|
||||
|
||||
// Role
|
||||
SaveRole(role *Role) error
|
||||
GetRole(id string) (*Role, error)
|
||||
GetRoleByName(name string) (*Role, error)
|
||||
ListRoles() []*Role
|
||||
DeleteRole(id string) error
|
||||
|
||||
// User
|
||||
SaveUser(u *User) error
|
||||
GetUser(id string) (*User, error)
|
||||
ListUsers() []*User
|
||||
|
||||
// Transaction
|
||||
SaveTransaction(tx *Transaction) error
|
||||
ListTransactions() []*Transaction
|
||||
|
||||
// Credential
|
||||
SaveCredential(c *Credential) error
|
||||
ListCredentials() []*Credential
|
||||
DeleteCredential(id string) error
|
||||
|
||||
// AML Screening
|
||||
SaveAMLScreening(s *AMLScreening) error
|
||||
GetAMLScreening(id string) (*AMLScreening, error)
|
||||
ListAMLScreeningsByAccount(accountID string) []*AMLScreening
|
||||
ListAMLScreeningsByStatus(status AMLStatus) []*AMLScreening
|
||||
|
||||
// Application (onboarding)
|
||||
SaveApplication(app *Application) error
|
||||
GetApplication(id string) (*Application, error)
|
||||
GetApplicationByUser(userID string) (*Application, error)
|
||||
ListApplications() []*Application
|
||||
ListApplicationsByStatus(status ApplicationStatus) []*Application
|
||||
|
||||
// Document Upload
|
||||
SaveDocumentUpload(doc *DocumentUpload) error
|
||||
GetDocumentUpload(id string) (*DocumentUpload, error)
|
||||
ListDocumentUploads(applicationID string) []*DocumentUpload
|
||||
|
||||
// Identity listing
|
||||
ListIdentitiesByUser(userID string) []*Identity
|
||||
|
||||
// Settings
|
||||
GetSettings() *Settings
|
||||
SaveSettings(settings *Settings)
|
||||
|
||||
// Dashboard
|
||||
ComputeDashboard() *DashboardStats
|
||||
ComputeESignStats() *ESignStats
|
||||
ListEnvelopesByDirection(direction string) []*Envelope
|
||||
}
|
||||
|
||||
// MemoryStore is an in-memory compliance data store.
|
||||
// Use PostgresStore in production.
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
identities map[string]*Identity // id -> Identity
|
||||
businesses map[string]*BusinessKYB // id -> BusinessKYB
|
||||
pipelines map[string]*Pipeline // id -> Pipeline
|
||||
sessions map[string]*Session // id -> Session
|
||||
funds map[string]*Fund // id -> Fund
|
||||
investors map[string][]*FundInvestor // fundID -> investors
|
||||
envelopes map[string]*Envelope // id -> Envelope
|
||||
templates map[string]*Template // id -> Template
|
||||
roles map[string]*Role // id -> Role
|
||||
users map[string]*User // id -> User
|
||||
transactions map[string]*Transaction // id -> Transaction
|
||||
credentials map[string]*Credential // id -> Credential
|
||||
amlScreenings map[string]*AMLScreening // id -> AMLScreening
|
||||
applications map[string]*Application // id -> Application
|
||||
documentUploads map[string]*DocumentUpload // id -> DocumentUpload
|
||||
settings *Settings
|
||||
}
|
||||
|
||||
// NewMemoryStore creates an empty in-memory compliance store.
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
identities: make(map[string]*Identity),
|
||||
businesses: make(map[string]*BusinessKYB),
|
||||
pipelines: make(map[string]*Pipeline),
|
||||
sessions: make(map[string]*Session),
|
||||
funds: make(map[string]*Fund),
|
||||
investors: make(map[string][]*FundInvestor),
|
||||
envelopes: make(map[string]*Envelope),
|
||||
templates: make(map[string]*Template),
|
||||
roles: make(map[string]*Role),
|
||||
users: make(map[string]*User),
|
||||
transactions: make(map[string]*Transaction),
|
||||
credentials: make(map[string]*Credential),
|
||||
amlScreenings: make(map[string]*AMLScreening),
|
||||
applications: make(map[string]*Application),
|
||||
documentUploads: make(map[string]*DocumentUpload),
|
||||
settings: &Settings{
|
||||
BusinessName: "Your Company",
|
||||
Timezone: "America/New_York",
|
||||
Currency: "USD",
|
||||
NotificationEmail: "[email protected]",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewStore is an alias for NewMemoryStore, preserving backward compatibility.
|
||||
func NewStore() *MemoryStore {
|
||||
return NewMemoryStore()
|
||||
}
|
||||
|
||||
// --- Identity ---
|
||||
|
||||
func (s *MemoryStore) SaveIdentity(id *Identity) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if id.ID == "" {
|
||||
id.ID = generateID()
|
||||
}
|
||||
if id.CreatedAt.IsZero() {
|
||||
id.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
id.UpdatedAt = time.Now().UTC()
|
||||
s.identities[id.ID] = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetIdentity(id string) (*Identity, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
ident, ok := s.identities[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("identity not found")
|
||||
}
|
||||
return ident, nil
|
||||
}
|
||||
|
||||
// --- Pipeline ---
|
||||
|
||||
func (s *MemoryStore) SavePipeline(p *Pipeline) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if p.ID == "" {
|
||||
p.ID = generateID()
|
||||
}
|
||||
if p.CreatedAt.IsZero() {
|
||||
p.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
p.UpdatedAt = time.Now().UTC()
|
||||
s.pipelines[p.ID] = p
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetPipeline(id string) (*Pipeline, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
p, ok := s.pipelines[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("pipeline not found")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListPipelines() []*Pipeline {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Pipeline, 0, len(s.pipelines))
|
||||
for _, p := range s.pipelines {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MemoryStore) DeletePipeline(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.pipelines[id]; !ok {
|
||||
return fmt.Errorf("pipeline not found")
|
||||
}
|
||||
delete(s.pipelines, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Session ---
|
||||
|
||||
func (s *MemoryStore) SaveSession(sess *Session) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sess.ID == "" {
|
||||
sess.ID = generateID()
|
||||
}
|
||||
if sess.CreatedAt.IsZero() {
|
||||
sess.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
s.sessions[sess.ID] = sess
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetSession(id string) (*Session, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
sess, ok := s.sessions[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session not found")
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListSessions() []*Session {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Session, 0, len(s.sessions))
|
||||
for _, sess := range s.sessions {
|
||||
out = append(out, sess)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Fund ---
|
||||
|
||||
func (s *MemoryStore) SaveFund(f *Fund) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if f.ID == "" {
|
||||
f.ID = generateID()
|
||||
}
|
||||
if f.CreatedAt.IsZero() {
|
||||
f.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
f.UpdatedAt = time.Now().UTC()
|
||||
s.funds[f.ID] = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetFund(id string) (*Fund, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
f, ok := s.funds[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("fund not found")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListFunds() []*Fund {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Fund, 0, len(s.funds))
|
||||
for _, f := range s.funds {
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MemoryStore) DeleteFund(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.funds[id]; !ok {
|
||||
return fmt.Errorf("fund not found")
|
||||
}
|
||||
delete(s.funds, id)
|
||||
delete(s.investors, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) AddFundInvestor(inv *FundInvestor) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if inv.ID == "" {
|
||||
inv.ID = generateID()
|
||||
}
|
||||
if inv.CreatedAt.IsZero() {
|
||||
inv.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
f, ok := s.funds[inv.FundID]
|
||||
if !ok {
|
||||
return fmt.Errorf("fund not found")
|
||||
}
|
||||
s.investors[inv.FundID] = append(s.investors[inv.FundID], inv)
|
||||
f.InvestorCount = len(s.investors[inv.FundID])
|
||||
f.TotalRaised += inv.Amount
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListFundInvestors(fundID string) []*FundInvestor {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.investors[fundID]
|
||||
}
|
||||
|
||||
// --- Envelope ---
|
||||
|
||||
func (s *MemoryStore) SaveEnvelope(env *Envelope) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if env.ID == "" {
|
||||
env.ID = generateID()
|
||||
}
|
||||
if env.CreatedAt.IsZero() {
|
||||
env.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
env.UpdatedAt = time.Now().UTC()
|
||||
s.envelopes[env.ID] = env
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetEnvelope(id string) (*Envelope, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
env, ok := s.envelopes[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("envelope not found")
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListEnvelopes() []*Envelope {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Envelope, 0, len(s.envelopes))
|
||||
for _, env := range s.envelopes {
|
||||
out = append(out, env)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Template ---
|
||||
|
||||
func (s *MemoryStore) SaveTemplate(t *Template) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if t.ID == "" {
|
||||
t.ID = generateID()
|
||||
}
|
||||
if t.CreatedAt.IsZero() {
|
||||
t.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
s.templates[t.ID] = t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetTemplate(id string) (*Template, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
t, ok := s.templates[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("template not found")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListTemplates() []*Template {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Template, 0, len(s.templates))
|
||||
for _, t := range s.templates {
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Role ---
|
||||
|
||||
func (s *MemoryStore) SaveRole(role *Role) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if role.ID == "" {
|
||||
role.ID = generateID()
|
||||
}
|
||||
if role.CreatedAt.IsZero() {
|
||||
role.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
role.UpdatedAt = time.Now().UTC()
|
||||
s.roles[role.ID] = role
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetRole(id string) (*Role, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
role, ok := s.roles[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("role not found")
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetRoleByName(name string) (*Role, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, role := range s.roles {
|
||||
if role.Name == name {
|
||||
return role, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("role not found: %s", name)
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListRoles() []*Role {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Role, 0, len(s.roles))
|
||||
for _, role := range s.roles {
|
||||
out = append(out, role)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MemoryStore) DeleteRole(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.roles[id]; !ok {
|
||||
return fmt.Errorf("role not found")
|
||||
}
|
||||
delete(s.roles, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- User ---
|
||||
|
||||
func (s *MemoryStore) SaveUser(u *User) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if u.ID == "" {
|
||||
u.ID = generateID()
|
||||
}
|
||||
if u.CreatedAt.IsZero() {
|
||||
u.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
s.users[u.ID] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetUser(id string) (*User, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
u, ok := s.users[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListUsers() []*User {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*User, 0, len(s.users))
|
||||
for _, u := range s.users {
|
||||
out = append(out, u)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Transaction ---
|
||||
|
||||
func (s *MemoryStore) SaveTransaction(tx *Transaction) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if tx.ID == "" {
|
||||
tx.ID = generateID()
|
||||
}
|
||||
s.transactions[tx.ID] = tx
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListTransactions() []*Transaction {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Transaction, 0, len(s.transactions))
|
||||
for _, tx := range s.transactions {
|
||||
out = append(out, tx)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Credential ---
|
||||
|
||||
func (s *MemoryStore) SaveCredential(c *Credential) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if c.ID == "" {
|
||||
c.ID = generateID()
|
||||
}
|
||||
if c.CreatedAt.IsZero() {
|
||||
c.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
s.credentials[c.ID] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListCredentials() []*Credential {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Credential, 0, len(s.credentials))
|
||||
for _, c := range s.credentials {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MemoryStore) DeleteCredential(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.credentials[id]; !ok {
|
||||
return fmt.Errorf("credential not found")
|
||||
}
|
||||
delete(s.credentials, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
func (s *MemoryStore) GetSettings() *Settings {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cp := *s.settings
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (s *MemoryStore) SaveSettings(settings *Settings) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.settings = settings
|
||||
}
|
||||
|
||||
// --- Dashboard ---
|
||||
|
||||
// ComputeDashboard aggregates store data into dashboard statistics.
|
||||
func (s *MemoryStore) ComputeDashboard() *DashboardStats {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
stats := &DashboardStats{
|
||||
TotalFunds: len(s.funds),
|
||||
MonthlyTransactions: len(s.transactions),
|
||||
}
|
||||
|
||||
for _, sess := range s.sessions {
|
||||
if sess.Status == SessionPending || sess.Status == SessionInProgress {
|
||||
stats.ActiveSessions++
|
||||
}
|
||||
if sess.KYCStatus == KYCPending {
|
||||
stats.PendingKYC++
|
||||
}
|
||||
}
|
||||
|
||||
stats.RecentSessions = make([]*Session, 0, 5)
|
||||
count := 0
|
||||
for _, sess := range s.sessions {
|
||||
if count >= 5 {
|
||||
break
|
||||
}
|
||||
stats.RecentSessions = append(stats.RecentSessions, sess)
|
||||
count++
|
||||
}
|
||||
|
||||
stats.RecentTransactions = make([]*Transaction, 0, 5)
|
||||
count = 0
|
||||
for _, tx := range s.transactions {
|
||||
if count >= 5 {
|
||||
break
|
||||
}
|
||||
stats.RecentTransactions = append(stats.RecentTransactions, tx)
|
||||
count++
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// --- eSign Dashboard ---
|
||||
|
||||
// ComputeESignStats aggregates eSign envelope/template data.
|
||||
func (s *MemoryStore) ComputeESignStats() *ESignStats {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
stats := &ESignStats{
|
||||
Templates: len(s.templates),
|
||||
}
|
||||
|
||||
for _, env := range s.envelopes {
|
||||
switch env.Status {
|
||||
case EnvelopePending, EnvelopeSent, EnvelopeViewed:
|
||||
stats.Pending++
|
||||
case EnvelopeCompleted, EnvelopeSigned:
|
||||
stats.Completed++
|
||||
}
|
||||
}
|
||||
stats.Draft = stats.Pending
|
||||
|
||||
stats.Recent = make([]*Envelope, 0, 5)
|
||||
count := 0
|
||||
for _, env := range s.envelopes {
|
||||
if count >= 5 {
|
||||
break
|
||||
}
|
||||
stats.Recent = append(stats.Recent, env)
|
||||
count++
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// ListEnvelopesByDirection returns envelopes filtered by direction.
|
||||
// "inbox" returns envelopes with status sent/pending (received for signing).
|
||||
// "sent" returns envelopes that were created/sent out.
|
||||
func (s *MemoryStore) ListEnvelopesByDirection(direction string) []*Envelope {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Envelope, 0)
|
||||
for _, env := range s.envelopes {
|
||||
switch direction {
|
||||
case "inbox":
|
||||
if env.Status == EnvelopeSent || env.Status == EnvelopePending || env.Status == EnvelopeViewed {
|
||||
out = append(out, env)
|
||||
}
|
||||
case "sent":
|
||||
if env.Status == EnvelopeSigned || env.Status == EnvelopeCompleted || env.Status == EnvelopeDeclined || env.Status == EnvelopeVoided {
|
||||
out = append(out, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- AML Screening ---
|
||||
|
||||
func (s *MemoryStore) SaveAMLScreening(sc *AMLScreening) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sc.ID == "" {
|
||||
sc.ID = generateID()
|
||||
}
|
||||
if sc.CreatedAt.IsZero() {
|
||||
sc.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
sc.UpdatedAt = time.Now().UTC()
|
||||
s.amlScreenings[sc.ID] = sc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetAMLScreening(id string) (*AMLScreening, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
sc, ok := s.amlScreenings[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("aml screening not found")
|
||||
}
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListAMLScreeningsByAccount(accountID string) []*AMLScreening {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var out []*AMLScreening
|
||||
for _, sc := range s.amlScreenings {
|
||||
if sc.AccountID == accountID {
|
||||
out = append(out, sc)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = make([]*AMLScreening, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListAMLScreeningsByStatus(status AMLStatus) []*AMLScreening {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var out []*AMLScreening
|
||||
for _, sc := range s.amlScreenings {
|
||||
if sc.Status == status {
|
||||
out = append(out, sc)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = make([]*AMLScreening, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Application ---
|
||||
|
||||
func (s *MemoryStore) SaveApplication(app *Application) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if app.ID == "" {
|
||||
app.ID = generateID()
|
||||
}
|
||||
if app.CreatedAt.IsZero() {
|
||||
app.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
app.UpdatedAt = time.Now().UTC()
|
||||
s.applications[app.ID] = app
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetApplication(id string) (*Application, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
app, ok := s.applications[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("application not found")
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetApplicationByUser(userID string) (*Application, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, app := range s.applications {
|
||||
if app.UserID == userID {
|
||||
return app, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("application not found")
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListApplications() []*Application {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Application, 0, len(s.applications))
|
||||
for _, app := range s.applications {
|
||||
out = append(out, app)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListApplicationsByStatus(status ApplicationStatus) []*Application {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var out []*Application
|
||||
for _, app := range s.applications {
|
||||
if app.Status == status {
|
||||
out = append(out, app)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = make([]*Application, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Document Upload ---
|
||||
|
||||
func (s *MemoryStore) SaveDocumentUpload(doc *DocumentUpload) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if doc.ID == "" {
|
||||
doc.ID = generateID()
|
||||
}
|
||||
if doc.CreatedAt.IsZero() {
|
||||
doc.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
doc.UpdatedAt = time.Now().UTC()
|
||||
s.documentUploads[doc.ID] = doc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetDocumentUpload(id string) (*DocumentUpload, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
doc, ok := s.documentUploads[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("document not found")
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListDocumentUploads(applicationID string) []*DocumentUpload {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var out []*DocumentUpload
|
||||
for _, doc := range s.documentUploads {
|
||||
if doc.ApplicationID == applicationID {
|
||||
out = append(out, doc)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = make([]*DocumentUpload, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Identity listing ---
|
||||
|
||||
func (s *MemoryStore) ListIdentitiesByUser(userID string) []*Identity {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var out []*Identity
|
||||
for _, ident := range s.identities {
|
||||
if ident.UserID == userID {
|
||||
out = append(out, ident)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = make([]*Identity, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// generateID returns a random hex ID. Panics if the system CSPRNG fails,
|
||||
// which indicates a catastrophic OS-level problem.
|
||||
func generateID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
Reference in New Issue
Block a user