feat: add KYC/AML and onboarding endpoints to compliance module

Add full compliance onboarding flow supporting the 5-step application:
1. Basic info + contact (SSN hashed with SHA-256, never stored plaintext)
2. Identity verification (pluggable KYC provider)
3. Document upload with metadata tracking
4. AML/compliance screening (Jube sidecar integration)
5. Review + submit with admin approval/rejection

New endpoints:
- /aml/screen, /aml/risk-assessment, /aml/flagged, /aml/screenings/{id}/review
- /applications/ CRUD + /step/1-5 + /review + /documents
- /kyc/ list by user, PATCH status, POST documents

Includes MemoryStore + PostgresStore implementations, migration SQL,
107 passing tests (51 new).
This commit is contained in:
2025-12-23 16:06:32 -07:00
parent 3c79a26fd0
commit cc173c8f9f
11 changed files with 2445 additions and 27 deletions
+14
View File
@@ -99,6 +99,20 @@ Mounted at `/compliance` on the main server. Supports in-memory store (default)
PostgreSQL via `DATABASE_URL` env var. Includes Jube AML/fraud client (`pkg/compliance/jube/`)
and webhook dispatcher (`pkg/compliance/webhooks/`).
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)
- `/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
### Database (pkg/db/)
PostgreSQL connection pool and auto-migrations. Used by compliance when `DATABASE_URL` is set.
+236
View File
@@ -0,0 +1,236 @@
package compliance
import (
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/luxfi/broker/pkg/compliance/jube"
"github.com/rs/zerolog/log"
)
// amlHandler holds AML screening HTTP handler state.
type amlHandler struct {
store ComplianceStore
jubeClient *jube.Client
}
// handleScreen runs an AML screening for an account via the Jube sidecar.
// If the Jube client is nil, creates a manual pending screening record.
func (h *amlHandler) handleScreen(w http.ResponseWriter, r *http.Request) {
var req struct {
AccountID string `json:"account_id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Country string `json:"country,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.AccountID == "" {
writeError(w, http.StatusBadRequest, "account_id is required")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
screening := &AMLScreening{
AccountID: req.AccountID,
UserID: req.UserID,
Type: "sanctions",
Status: AMLPending,
RiskLevel: RiskLow,
Provider: "manual",
}
// If Jube is configured, run sanctions check.
if h.jubeClient != nil {
result, err := h.jubeClient.CheckSanctions(r.Context(), req.Name, req.Country)
if err != nil {
log.Error().Err(err).Str("account", req.AccountID).Msg("aml: jube sanctions check failed")
screening.Provider = "jube"
screening.Details = "screening service unavailable"
// Save as pending for manual review.
} else {
screening.Provider = "jube"
screening.SanctionsHit = result.Hit
if result.Hit {
screening.Status = AMLFlagged
screening.RiskLevel = RiskHigh
screening.RiskScore = 80.0
if len(result.Matches) > 0 {
screening.RiskScore = result.Matches[0].Score * 100
}
screening.Details = "sanctions list match found"
} else {
screening.Status = AMLCleared
screening.RiskLevel = RiskLow
screening.RiskScore = 0
}
}
}
if err := h.store.SaveAMLScreening(screening); err != nil {
log.Error().Err(err).Str("account", req.AccountID).Msg("aml: failed to save screening")
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, screening)
}
// handleGet returns a single AML screening by ID.
func (h *amlHandler) handleGet(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
screening, err := h.store.GetAMLScreening(id)
if err != nil {
writeError(w, http.StatusNotFound, "not found")
return
}
writeJSON(w, http.StatusOK, screening)
}
// handleListByAccount returns all AML screenings for a given account.
func (h *amlHandler) handleListByAccount(w http.ResponseWriter, r *http.Request) {
accountID := r.URL.Query().Get("account_id")
if accountID == "" {
writeError(w, http.StatusBadRequest, "account_id query parameter is required")
return
}
writeJSON(w, http.StatusOK, h.store.ListAMLScreeningsByAccount(accountID))
}
// handleListFlagged returns all AML screenings that are flagged and need review.
func (h *amlHandler) handleListFlagged(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.store.ListAMLScreeningsByStatus(AMLFlagged))
}
// handleReview marks a flagged AML screening as cleared or blocked after manual review.
func (h *amlHandler) handleReview(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing, err := h.store.GetAMLScreening(id)
if err != nil {
writeError(w, http.StatusNotFound, "not found")
return
}
var req struct {
Decision string `json:"decision"` // cleared, blocked
ReviewedBy string `json:"reviewed_by"`
Details string `json:"details,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Decision != "cleared" && req.Decision != "blocked" {
writeError(w, http.StatusBadRequest, "decision must be 'cleared' or 'blocked'")
return
}
if req.ReviewedBy == "" {
writeError(w, http.StatusBadRequest, "reviewed_by is required")
return
}
switch req.Decision {
case "cleared":
existing.Status = AMLCleared
case "blocked":
existing.Status = AMLBlocked
}
existing.ReviewedBy = req.ReviewedBy
existing.ReviewedAt = time.Now().UTC()
if req.Details != "" {
existing.Details = req.Details
}
if err := h.store.SaveAMLScreening(existing); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, existing)
}
// handleRiskAssessment runs a risk assessment by screening a transaction through Jube.
func (h *amlHandler) handleRiskAssessment(w http.ResponseWriter, r *http.Request) {
var req struct {
AccountID string `json:"account_id"`
UserID string `json:"user_id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Type string `json:"type"` // deposit, withdrawal, trade
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.AccountID == "" {
writeError(w, http.StatusBadRequest, "account_id is required")
return
}
if req.Amount <= 0 {
writeError(w, http.StatusBadRequest, "amount must be positive")
return
}
screening := &AMLScreening{
AccountID: req.AccountID,
UserID: req.UserID,
Type: "transaction",
Status: AMLPending,
RiskLevel: RiskLow,
Provider: "manual",
}
if h.jubeClient != nil {
txReq := jube.TransactionRequest{
EntityAnalysisModelID: 1,
EntityInstanceEntryPayload: map[string]interface{}{
"AccountId": req.AccountID,
"Amount": req.Amount,
"Currency": req.Currency,
"Type": req.Type,
},
}
resp, err := h.jubeClient.ScreenTransaction(r.Context(), txReq)
if err != nil {
log.Error().Err(err).Str("account", req.AccountID).Msg("aml: jube risk assessment failed")
} else {
screening.Provider = "jube"
screening.RiskScore = resp.Score
switch {
case resp.Score >= 80:
screening.RiskLevel = RiskCritical
case resp.Score >= 60:
screening.RiskLevel = RiskHigh
case resp.Score >= 30:
screening.RiskLevel = RiskMedium
default:
screening.RiskLevel = RiskLow
}
switch resp.Action {
case jube.ActionBlock:
screening.Status = AMLBlocked
case jube.ActionReview:
screening.Status = AMLFlagged
default:
screening.Status = AMLCleared
}
}
}
if err := h.store.SaveAMLScreening(screening); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, screening)
}
+444
View File
@@ -0,0 +1,444 @@
package compliance
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
)
// applicationHandler manages the 5-step investor onboarding flow.
type applicationHandler struct {
store ComplianceStore
}
// newApplicationSteps returns the 5 default onboarding steps.
func newApplicationSteps() []ApplicationStep {
return []ApplicationStep{
{Step: 1, Name: "Basic Info & Contact", Status: "pending"},
{Step: 2, Name: "Identity Verification", Status: "pending"},
{Step: 3, Name: "Document Upload", Status: "pending"},
{Step: 4, Name: "Compliance Screening", Status: "pending"},
{Step: 5, Name: "Review & Submit", Status: "pending"},
}
}
// handleCreate starts a new onboarding application.
func (h *applicationHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
var req struct {
UserID string `json:"user_id"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.UserID == "" {
writeError(w, http.StatusBadRequest, "user_id is required")
return
}
if req.Email == "" {
writeError(w, http.StatusBadRequest, "email is required")
return
}
app := &Application{
UserID: req.UserID,
Email: req.Email,
Status: AppDraft,
CurrentStep: 1,
KYCStatus: KYCPending,
AMLStatus: AMLPending,
Steps: newApplicationSteps(),
}
if err := h.store.SaveApplication(app); err != nil {
log.Error().Err(err).Str("user_id", req.UserID).Msg("application: failed to create")
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, app)
}
// handleGet returns an application by ID.
func (h *applicationHandler) handleGet(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
app, err := h.store.GetApplication(id)
if err != nil {
writeError(w, http.StatusNotFound, "not found")
return
}
writeJSON(w, http.StatusOK, app)
}
// handleGetByUser returns the application for a given user.
func (h *applicationHandler) handleGetByUser(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
if userID == "" {
writeError(w, http.StatusBadRequest, "user_id query parameter is required")
return
}
app, err := h.store.GetApplicationByUser(userID)
if err != nil {
writeError(w, http.StatusNotFound, "not found")
return
}
writeJSON(w, http.StatusOK, app)
}
// handleList returns all applications, optionally filtered by status.
func (h *applicationHandler) handleList(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
if status != "" {
writeJSON(w, http.StatusOK, h.store.ListApplicationsByStatus(ApplicationStatus(status)))
return
}
writeJSON(w, http.StatusOK, h.store.ListApplications())
}
// handleStep1 updates Step 1: Basic Info + Contact.
func (h *applicationHandler) handleStep1(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
app, err := h.store.GetApplication(id)
if err != nil {
writeError(w, http.StatusNotFound, "not found")
return
}
var req struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
DateOfBirth string `json:"date_of_birth,omitempty"`
SSN string `json:"ssn,omitempty"` // will be hashed, never stored raw
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"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.FirstName == "" {
writeError(w, http.StatusBadRequest, "first_name is required")
return
}
if req.LastName == "" {
writeError(w, http.StatusBadRequest, "last_name is required")
return
}
app.FirstName = req.FirstName
app.LastName = req.LastName
if req.Email != "" {
app.Email = req.Email
}
app.Phone = req.Phone
app.DateOfBirth = req.DateOfBirth
app.AddressLine1 = req.AddressLine1
app.AddressLine2 = req.AddressLine2
app.City = req.City
app.State = req.State
app.ZipCode = req.ZipCode
app.Country = req.Country
// Hash SSN if provided -- NEVER store plaintext.
if req.SSN != "" {
hash := sha256.Sum256([]byte(req.SSN))
app.SSNHash = hex.EncodeToString(hash[:])
if len(req.SSN) >= 4 {
app.SSNLast4 = req.SSN[len(req.SSN)-4:]
}
}
app.Status = AppInProgress
if app.CurrentStep < 2 {
app.CurrentStep = 2
}
markStepCompleted(app, 1)
if err := h.store.SaveApplication(app); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, app)
}
// handleStep2 updates Step 2: Identity Verification.
func (h *applicationHandler) handleStep2(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
app, err := h.store.GetApplication(id)
if err != nil {
writeError(w, http.StatusNotFound, "not found")
return
}
var req struct {
Provider string `json:"provider"` // onfido, berbix, idmerit
VerificationID string `json:"verification_id"` // external verification ID
Status string `json:"status,omitempty"` // pending, verified, failed
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Provider == "" {
writeError(w, http.StatusBadRequest, "provider is required")
return
}
// Create an identity record linked to this application.
ident := &Identity{
UserID: app.UserID,
Provider: req.Provider,
Status: KYCPending,
Data: map[string]interface{}{
"application_id": app.ID,
"verification_id": req.VerificationID,
},
}
if req.Status == "verified" {
ident.Status = KYCVerified
} else if req.Status == "failed" {
ident.Status = KYCFailed
}
if err := h.store.SaveIdentity(ident); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
app.KYCStatus = ident.Status
if ident.Status == KYCVerified {
markStepCompleted(app, 2)
if app.CurrentStep < 3 {
app.CurrentStep = 3
}
} else if ident.Status == KYCFailed {
markStepFailed(app, 2)
}
if err := h.store.SaveApplication(app); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, app)
}
// handleStep3 updates Step 3: Document Upload.
func (h *applicationHandler) handleStep3(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
app, err := h.store.GetApplication(id)
if err != nil {
writeError(w, http.StatusNotFound, "not found")
return
}
var req struct {
Documents []struct {
Type string `json:"type"`
Name string `json:"name"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
} `json:"documents"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.Documents) == 0 {
writeError(w, http.StatusBadRequest, "at least one document is required")
return
}
for _, d := range req.Documents {
if d.Type == "" {
writeError(w, http.StatusBadRequest, "document type is required")
return
}
if d.Name == "" {
writeError(w, http.StatusBadRequest, "document name is required")
return
}
doc := &DocumentUpload{
ApplicationID: app.ID,
UserID: app.UserID,
Type: d.Type,
Name: d.Name,
MimeType: d.MimeType,
Size: d.Size,
Status: "pending",
}
if err := h.store.SaveDocumentUpload(doc); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
}
markStepCompleted(app, 3)
if app.CurrentStep < 4 {
app.CurrentStep = 4
}
if err := h.store.SaveApplication(app); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, app)
}
// handleStep4 updates Step 4: Compliance/AML Screening.
func (h *applicationHandler) handleStep4(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
app, err := h.store.GetApplication(id)
if err != nil {
writeError(w, http.StatusNotFound, "not found")
return
}
// Create an AML screening record for this application.
screening := &AMLScreening{
AccountID: app.ID,
UserID: app.UserID,
Type: "sanctions",
Status: AMLPending,
RiskLevel: RiskLow,
Provider: "manual",
}
if err := h.store.SaveAMLScreening(screening); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
app.AMLStatus = AMLPending
markStepCompleted(app, 4)
if app.CurrentStep < 5 {
app.CurrentStep = 5
}
if err := h.store.SaveApplication(app); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, app)
}
// handleStep5 updates Step 5: Review + Submit.
func (h *applicationHandler) handleStep5(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
app, err := h.store.GetApplication(id)
if err != nil {
writeError(w, http.StatusNotFound, "not found")
return
}
var req struct {
Confirmed bool `json:"confirmed"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if !req.Confirmed {
writeError(w, http.StatusBadRequest, "confirmed must be true to submit")
return
}
markStepCompleted(app, 5)
app.Status = AppSubmitted
app.SubmittedAt = time.Now().UTC()
if err := h.store.SaveApplication(app); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, app)
}
// handleReview allows an admin to approve or reject a submitted application.
func (h *applicationHandler) handleReview(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
app, err := h.store.GetApplication(id)
if err != nil {
writeError(w, http.StatusNotFound, "not found")
return
}
if app.Status != AppSubmitted && app.Status != AppUnderReview {
writeError(w, http.StatusBadRequest, "application must be submitted or under review")
return
}
var req struct {
Decision string `json:"decision"` // approved, rejected
ReviewedBy string `json:"reviewed_by"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Decision != "approved" && req.Decision != "rejected" {
writeError(w, http.StatusBadRequest, "decision must be 'approved' or 'rejected'")
return
}
if req.ReviewedBy == "" {
writeError(w, http.StatusBadRequest, "reviewed_by is required")
return
}
switch req.Decision {
case "approved":
app.Status = AppApproved
case "rejected":
app.Status = AppRejected
}
app.ReviewedBy = req.ReviewedBy
app.ReviewedAt = time.Now().UTC()
if err := h.store.SaveApplication(app); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, app)
}
// handleGetDocuments lists documents for an application.
func (h *applicationHandler) handleGetDocuments(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Verify application exists.
if _, err := h.store.GetApplication(id); err != nil {
writeError(w, http.StatusNotFound, "not found")
return
}
writeJSON(w, http.StatusOK, h.store.ListDocumentUploads(id))
}
// markStepCompleted sets a step to completed.
func markStepCompleted(app *Application, step int) {
for i := range app.Steps {
if app.Steps[i].Step == step {
app.Steps[i].Status = "completed"
app.Steps[i].CompletedAt = time.Now().UTC()
return
}
}
}
// markStepFailed sets a step to failed.
func markStepFailed(app *Application, step int) {
for i := range app.Steps {
if app.Steps[i].Step == step {
app.Steps[i].Status = "failed"
return
}
}
}
+117
View File
@@ -265,6 +265,123 @@ type Credential struct {
ExpiresAt string `json:"expires_at,omitempty"`
}
// --- AML Types ---
// AMLStatus tracks anti-money laundering screening state.
type AMLStatus string
const (
AMLPending AMLStatus = "pending"
AMLCleared AMLStatus = "cleared"
AMLFlagged AMLStatus = "flagged"
AMLBlocked AMLStatus = "blocked"
AMLExpired AMLStatus = "expired"
)
// RiskLevel categorizes account risk.
type RiskLevel string
const (
RiskLow RiskLevel = "low"
RiskMedium RiskLevel = "medium"
RiskHigh RiskLevel = "high"
RiskCritical RiskLevel = "critical"
)
// 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
const (
AppDraft ApplicationStatus = "draft"
AppInProgress ApplicationStatus = "in_progress"
AppSubmitted ApplicationStatus = "submitted"
AppUnderReview ApplicationStatus = "under_review"
AppApproved ApplicationStatus = "approved"
AppRejected ApplicationStatus = "rejected"
)
// 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"`
}
// 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"`
}
// 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.
+746
View File
@@ -2055,3 +2055,749 @@ func TestSeedPopulatesNewTypes(t *testing.T) {
t.Fatal("expected seeded roles")
}
}
// ==========================================================================
// AML Screening Tests
// ==========================================================================
func TestAMLScreenAndGet(t *testing.T) {
r, _ := newTestRouter()
// Create an AML screening.
w := doRequest(r, "POST", "/aml/screen", map[string]string{
"account_id": "acct-123",
"user_id": "user-123",
"name": "John Doe",
"country": "US",
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var screening AMLScreening
decodeJSON(t, w, &screening)
if screening.AccountID != "acct-123" {
t.Fatalf("expected account_id acct-123, got %s", screening.AccountID)
}
if screening.Status != AMLPending {
t.Fatalf("expected status pending, got %s", screening.Status)
}
// Get screening by ID.
w = doRequest(r, "GET", "/aml/screenings/"+screening.ID, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var fetched AMLScreening
decodeJSON(t, w, &fetched)
if fetched.ID != screening.ID {
t.Fatalf("ID mismatch: %s != %s", fetched.ID, screening.ID)
}
}
func TestAMLScreenMissingAccountID(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/aml/screen", map[string]string{
"name": "John Doe",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestAMLScreenMissingName(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/aml/screen", map[string]string{
"account_id": "acct-1",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestAMLListByAccount(t *testing.T) {
r, store := newTestRouter()
store.SaveAMLScreening(&AMLScreening{AccountID: "acct-a", UserID: "u1", Type: "sanctions", Status: AMLPending, Provider: "manual"})
store.SaveAMLScreening(&AMLScreening{AccountID: "acct-a", UserID: "u1", Type: "pep", Status: AMLCleared, Provider: "manual"})
store.SaveAMLScreening(&AMLScreening{AccountID: "acct-b", UserID: "u2", Type: "sanctions", Status: AMLFlagged, Provider: "manual"})
w := doRequest(r, "GET", "/aml/screenings?account_id=acct-a", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var screenings []*AMLScreening
decodeJSON(t, w, &screenings)
if len(screenings) != 2 {
t.Fatalf("expected 2 screenings for acct-a, got %d", len(screenings))
}
}
func TestAMLListByAccountMissingParam(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/aml/screenings", nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestAMLListFlagged(t *testing.T) {
r, store := newTestRouter()
store.SaveAMLScreening(&AMLScreening{AccountID: "a1", Status: AMLFlagged, Provider: "jube"})
store.SaveAMLScreening(&AMLScreening{AccountID: "a2", Status: AMLCleared, Provider: "jube"})
store.SaveAMLScreening(&AMLScreening{AccountID: "a3", Status: AMLFlagged, Provider: "jube"})
w := doRequest(r, "GET", "/aml/flagged", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var flagged []*AMLScreening
decodeJSON(t, w, &flagged)
if len(flagged) != 2 {
t.Fatalf("expected 2 flagged, got %d", len(flagged))
}
}
func TestAMLReview(t *testing.T) {
r, store := newTestRouter()
sc := &AMLScreening{AccountID: "a1", Status: AMLFlagged, Provider: "jube"}
store.SaveAMLScreening(sc)
// Review and clear.
w := doRequest(r, "POST", "/aml/screenings/"+sc.ID+"/review", map[string]string{
"decision": "cleared",
"reviewed_by": "[email protected]",
"details": "false positive",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var reviewed AMLScreening
decodeJSON(t, w, &reviewed)
if reviewed.Status != AMLCleared {
t.Fatalf("expected cleared, got %s", reviewed.Status)
}
if reviewed.ReviewedBy != "[email protected]" {
t.Fatalf("expected reviewed_by [email protected], got %s", reviewed.ReviewedBy)
}
}
func TestAMLReviewBadDecision(t *testing.T) {
r, store := newTestRouter()
sc := &AMLScreening{AccountID: "a1", Status: AMLFlagged, Provider: "jube"}
store.SaveAMLScreening(sc)
w := doRequest(r, "POST", "/aml/screenings/"+sc.ID+"/review", map[string]string{
"decision": "maybe",
"reviewed_by": "admin",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestAMLReviewMissingReviewer(t *testing.T) {
r, store := newTestRouter()
sc := &AMLScreening{AccountID: "a1", Status: AMLFlagged, Provider: "jube"}
store.SaveAMLScreening(sc)
w := doRequest(r, "POST", "/aml/screenings/"+sc.ID+"/review", map[string]string{
"decision": "cleared",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestAMLReviewNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/aml/screenings/nonexistent/review", map[string]string{
"decision": "cleared",
"reviewed_by": "admin",
})
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestAMLGetNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/aml/screenings/nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestAMLRiskAssessment(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/aml/risk-assessment", map[string]interface{}{
"account_id": "acct-1",
"user_id": "user-1",
"amount": 50000.0,
"currency": "USD",
"type": "deposit",
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var screening AMLScreening
decodeJSON(t, w, &screening)
if screening.Type != "transaction" {
t.Fatalf("expected type transaction, got %s", screening.Type)
}
}
func TestAMLRiskAssessmentMissingAmount(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/aml/risk-assessment", map[string]interface{}{
"account_id": "acct-1",
"amount": 0,
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
// ==========================================================================
// Application (5-Step Onboarding) Tests
// ==========================================================================
func TestApplicationFullOnboardingFlow(t *testing.T) {
r, _ := newTestRouter()
// Create application.
w := doRequest(r, "POST", "/applications/", map[string]string{
"user_id": "user-onboard",
"email": "[email protected]",
})
if w.Code != http.StatusCreated {
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var app Application
decodeJSON(t, w, &app)
if app.Status != AppDraft {
t.Fatalf("expected status draft, got %s", app.Status)
}
if app.CurrentStep != 1 {
t.Fatalf("expected current_step 1, got %d", app.CurrentStep)
}
if len(app.Steps) != 5 {
t.Fatalf("expected 5 steps, got %d", len(app.Steps))
}
// Step 1: Basic info + Contact.
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/1", map[string]interface{}{
"first_name": "John",
"last_name": "Doe",
"phone": "+1-555-0100",
"date_of_birth": "1990-01-15",
"ssn": "123-45-6789",
"address_line1": "123 Main St",
"city": "New York",
"state": "NY",
"zip_code": "10001",
"country": "US",
})
if w.Code != http.StatusOK {
t.Fatalf("step1: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &app)
if app.FirstName != "John" {
t.Fatalf("expected first_name John, got %s", app.FirstName)
}
if app.CurrentStep != 2 {
t.Fatalf("expected current_step 2, got %d", app.CurrentStep)
}
if app.Status != AppInProgress {
t.Fatalf("expected status in_progress, got %s", app.Status)
}
// SSN must be hashed, not exposed via JSON.
if app.SSNLast4 != "6789" {
t.Fatalf("expected ssn_last4 6789, got %s", app.SSNLast4)
}
// Step 2: Identity verification.
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/2", map[string]string{
"provider": "onfido",
"verification_id": "ver-abc-123",
"status": "verified",
})
if w.Code != http.StatusOK {
t.Fatalf("step2: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &app)
if app.KYCStatus != KYCVerified {
t.Fatalf("expected kyc_status verified, got %s", app.KYCStatus)
}
if app.CurrentStep != 3 {
t.Fatalf("expected current_step 3, got %d", app.CurrentStep)
}
// Step 3: Document upload.
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/3", map[string]interface{}{
"documents": []map[string]interface{}{
{"type": "passport", "name": "passport.pdf", "mime_type": "application/pdf", "size": 102400},
{"type": "utility_bill", "name": "utility.pdf", "mime_type": "application/pdf", "size": 51200},
},
})
if w.Code != http.StatusOK {
t.Fatalf("step3: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &app)
if app.CurrentStep != 4 {
t.Fatalf("expected current_step 4, got %d", app.CurrentStep)
}
// Check documents were saved.
w = doRequest(r, "GET", "/applications/"+app.ID+"/documents", nil)
if w.Code != http.StatusOK {
t.Fatalf("get docs: expected 200, got %d", w.Code)
}
var docs []*DocumentUpload
decodeJSON(t, w, &docs)
if len(docs) != 2 {
t.Fatalf("expected 2 documents, got %d", len(docs))
}
// Step 4: Compliance/AML screening.
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/4", nil)
if w.Code != http.StatusOK {
t.Fatalf("step4: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &app)
if app.CurrentStep != 5 {
t.Fatalf("expected current_step 5, got %d", app.CurrentStep)
}
// Step 5: Review + Submit.
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/5", map[string]bool{
"confirmed": true,
})
if w.Code != http.StatusOK {
t.Fatalf("step5: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &app)
if app.Status != AppSubmitted {
t.Fatalf("expected status submitted, got %s", app.Status)
}
if app.SubmittedAt.IsZero() {
t.Fatal("expected submitted_at to be set")
}
// Admin review: approve.
w = doRequest(r, "POST", "/applications/"+app.ID+"/review", map[string]string{
"decision": "approved",
"reviewed_by": "[email protected]",
})
if w.Code != http.StatusOK {
t.Fatalf("review: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &app)
if app.Status != AppApproved {
t.Fatalf("expected status approved, got %s", app.Status)
}
}
func TestApplicationCreateMissingUserID(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/applications/", map[string]string{
"email": "[email protected]",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationCreateMissingEmail(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/applications/", map[string]string{
"user_id": "user-1",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationGetNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/applications/nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestApplicationLookupByUser(t *testing.T) {
r, store := newTestRouter()
app := &Application{
UserID: "user-lookup",
Email: "[email protected]",
Status: AppDraft,
Steps: newApplicationSteps(),
}
store.SaveApplication(app)
w := doRequest(r, "GET", "/applications/lookup?user_id=user-lookup", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var fetched Application
decodeJSON(t, w, &fetched)
if fetched.UserID != "user-lookup" {
t.Fatalf("expected user_id user-lookup, got %s", fetched.UserID)
}
}
func TestApplicationLookupByUserNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/applications/lookup?user_id=nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestApplicationLookupMissingParam(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/applications/lookup", nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationListByStatus(t *testing.T) {
r, store := newTestRouter()
store.SaveApplication(&Application{UserID: "u1", Email: "[email protected]", Status: AppDraft, Steps: newApplicationSteps()})
store.SaveApplication(&Application{UserID: "u2", Email: "[email protected]", Status: AppSubmitted, Steps: newApplicationSteps()})
store.SaveApplication(&Application{UserID: "u3", Email: "[email protected]", Status: AppSubmitted, Steps: newApplicationSteps()})
// All applications.
w := doRequest(r, "GET", "/applications/", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var all []*Application
decodeJSON(t, w, &all)
if len(all) != 3 {
t.Fatalf("expected 3 applications, got %d", len(all))
}
// Filter by status.
w = doRequest(r, "GET", "/applications/?status=submitted", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var submitted []*Application
decodeJSON(t, w, &submitted)
if len(submitted) != 2 {
t.Fatalf("expected 2 submitted, got %d", len(submitted))
}
}
func TestApplicationStep1MissingName(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppDraft, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/step/1", map[string]string{
"last_name": "Doe",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationStep2MissingProvider(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppDraft, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/step/2", map[string]string{})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationStep3NoDocuments(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppDraft, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/step/3", map[string]interface{}{
"documents": []interface{}{},
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationStep5NotConfirmed(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppDraft, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/step/5", map[string]bool{
"confirmed": false,
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationReviewBadDecision(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppSubmitted, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/review", map[string]string{
"decision": "maybe",
"reviewed_by": "admin",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationReviewNotSubmitted(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppDraft, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/review", map[string]string{
"decision": "approved",
"reviewed_by": "admin",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationReviewReject(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppSubmitted, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/review", map[string]string{
"decision": "rejected",
"reviewed_by": "[email protected]",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var reviewed Application
decodeJSON(t, w, &reviewed)
if reviewed.Status != AppRejected {
t.Fatalf("expected rejected, got %s", reviewed.Status)
}
}
func TestApplicationDocumentsNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/applications/nonexistent/documents", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
// ==========================================================================
// KYC Extended Endpoint Tests
// ==========================================================================
func TestKYCListByUser(t *testing.T) {
r, store := newTestRouter()
store.SaveIdentity(&Identity{UserID: "user-multi", Provider: "onfido", Status: KYCPending, Data: map[string]interface{}{}})
store.SaveIdentity(&Identity{UserID: "user-multi", Provider: "berbix", Status: KYCVerified, Data: map[string]interface{}{}})
store.SaveIdentity(&Identity{UserID: "user-other", Provider: "onfido", Status: KYCPending, Data: map[string]interface{}{}})
w := doRequest(r, "GET", "/kyc/?user_id=user-multi", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var identities []*Identity
decodeJSON(t, w, &identities)
if len(identities) != 2 {
t.Fatalf("expected 2 identities for user-multi, got %d", len(identities))
}
}
func TestKYCListByUserMissingParam(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/kyc/", nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestKYCUpdateStatus(t *testing.T) {
r, store := newTestRouter()
ident := &Identity{UserID: "user-update", Provider: "onfido", Status: KYCPending, Data: map[string]interface{}{}}
store.SaveIdentity(ident)
w := doRequest(r, "PATCH", "/kyc/"+ident.ID, map[string]string{
"status": "verified",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var updated Identity
decodeJSON(t, w, &updated)
if updated.Status != KYCVerified {
t.Fatalf("expected verified, got %s", updated.Status)
}
}
func TestKYCUpdateStatusInvalid(t *testing.T) {
r, store := newTestRouter()
ident := &Identity{UserID: "user-bad", Provider: "onfido", Status: KYCPending, Data: map[string]interface{}{}}
store.SaveIdentity(ident)
w := doRequest(r, "PATCH", "/kyc/"+ident.ID, map[string]string{
"status": "nonexistent_status",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestKYCUpdateStatusNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "PATCH", "/kyc/nonexistent", map[string]string{
"status": "verified",
})
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestKYCDocumentUpload(t *testing.T) {
r, store := newTestRouter()
ident := &Identity{UserID: "user-doc", Provider: "onfido", Status: KYCPending, Data: map[string]interface{}{}}
store.SaveIdentity(ident)
w := doRequest(r, "POST", "/kyc/"+ident.ID+"/documents", map[string]string{
"type": "passport",
"name": "passport-scan.jpg",
"mime_type": "image/jpeg",
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var doc Document
decodeJSON(t, w, &doc)
if doc.Type != "passport" {
t.Fatalf("expected type passport, got %s", doc.Type)
}
if doc.Status != "pending" {
t.Fatalf("expected status pending, got %s", doc.Status)
}
}
func TestKYCDocumentUploadMissingType(t *testing.T) {
r, store := newTestRouter()
ident := &Identity{UserID: "user-doc2", Provider: "onfido", Status: KYCPending, Data: map[string]interface{}{}}
store.SaveIdentity(ident)
w := doRequest(r, "POST", "/kyc/"+ident.ID+"/documents", map[string]string{
"name": "something.pdf",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestKYCDocumentUploadIdentityNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/kyc/nonexistent/documents", map[string]string{
"type": "passport",
"name": "test.pdf",
})
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
// ==========================================================================
// Modules Test (updated count)
// ==========================================================================
func TestListModulesIncludesAMLAndApplications(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/modules", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var modules []Module
decodeJSON(t, w, &modules)
found := map[string]bool{}
for _, m := range modules {
found[m.Name] = true
}
for _, name := range []string{"aml", "applications"} {
if !found[name] {
t.Fatalf("expected module %q in list", name)
}
}
}
// ==========================================================================
// Store Unit Tests (new types)
// ==========================================================================
func TestStoreAMLScreeningNotFound(t *testing.T) {
s := NewStore()
_, err := s.GetAMLScreening("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent aml screening")
}
}
func TestStoreApplicationNotFound(t *testing.T) {
s := NewStore()
_, err := s.GetApplication("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent application")
}
}
func TestStoreApplicationByUserNotFound(t *testing.T) {
s := NewStore()
_, err := s.GetApplicationByUser("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent user application")
}
}
func TestStoreDocumentUploadNotFound(t *testing.T) {
s := NewStore()
_, err := s.GetDocumentUpload("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent document")
}
}
func TestStoreIdentitiesByUserEmpty(t *testing.T) {
s := NewStore()
results := s.ListIdentitiesByUser("nonexistent")
if len(results) != 0 {
t.Fatalf("expected 0 results, got %d", len(results))
}
}
+107
View File
@@ -91,3 +91,110 @@ func (h *kycHandler) handleGet(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, ident)
}
// handleListByUser returns all KYC identity records for a given user.
func (h *kycHandler) handleListByUser(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
if userID == "" {
writeError(w, http.StatusBadRequest, "user_id query parameter is required")
return
}
writeJSON(w, http.StatusOK, h.store.ListIdentitiesByUser(userID))
}
// handleUpdateStatus allows updating a KYC identity's status (for admin review).
func (h *kycHandler) handleUpdateStatus(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
ident, err := h.store.GetIdentity(id)
if err != nil {
writeError(w, http.StatusNotFound, "not found")
return
}
var req struct {
Status KYCStatus `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Status == "" {
writeError(w, http.StatusBadRequest, "status is required")
return
}
if req.Status != KYCPending && req.Status != KYCVerified && req.Status != KYCFailed && req.Status != KYCExpired {
writeError(w, http.StatusBadRequest, "invalid status value")
return
}
ident.Status = req.Status
if err := h.store.SaveIdentity(ident); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, ident)
}
// handleUploadDocument saves a document metadata record linked to a KYC identity.
func (h *kycHandler) handleUploadDocument(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
ident, err := h.store.GetIdentity(id)
if err != nil {
writeError(w, http.StatusNotFound, "identity not found")
return
}
var req struct {
Type string `json:"type"`
Name string `json:"name"`
MimeType string `json:"mime_type"`
Content string `json:"content,omitempty"` // base64
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Type == "" {
writeError(w, http.StatusBadRequest, "type is required")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
doc := Document{
ID: generateID(),
Type: req.Type,
Name: req.Name,
MimeType: req.MimeType,
Content: req.Content,
Status: "pending",
}
// Store document reference in identity data.
if ident.Data == nil {
ident.Data = make(map[string]interface{})
}
var docs []interface{}
if existing, ok := ident.Data["documents"]; ok {
if arr, ok := existing.([]interface{}); ok {
docs = arr
}
}
docs = append(docs, map[string]interface{}{
"id": doc.ID,
"type": doc.Type,
"name": doc.Name,
"mime_type": doc.MimeType,
"status": doc.Status,
})
ident.Data["documents"] = docs
if err := h.store.SaveIdentity(ident); err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, doc)
}
+426
View File
@@ -1023,3 +1023,429 @@ func (s *PostgresStore) ListEnvelopesByDirection(direction string) []*Envelope {
}
return out
}
// --- AML Screening ---
func (s *PostgresStore) SaveAMLScreening(sc *AMLScreening) error {
ctx := context.Background()
if sc.ID == "" {
sc.ID = generateID()
}
if sc.CreatedAt.IsZero() {
sc.CreatedAt = time.Now().UTC()
}
sc.UpdatedAt = time.Now().UTC()
var reviewedAt *time.Time
if !sc.ReviewedAt.IsZero() {
reviewedAt = &sc.ReviewedAt
}
_, err := s.pool.Exec(ctx, `
INSERT INTO aml_screenings (id, account_id, user_id, type, status, risk_level, risk_score,
provider, details, sanctions_hit, pep_match, reviewed_by, reviewed_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
risk_level = EXCLUDED.risk_level,
risk_score = EXCLUDED.risk_score,
details = EXCLUDED.details,
reviewed_by = EXCLUDED.reviewed_by,
reviewed_at = EXCLUDED.reviewed_at,
updated_at = EXCLUDED.updated_at
`, sc.ID, sc.AccountID, sc.UserID, sc.Type, string(sc.Status), string(sc.RiskLevel),
sc.RiskScore, sc.Provider, sc.Details, sc.SanctionsHit, sc.PEPMatch,
sc.ReviewedBy, reviewedAt, sc.CreatedAt, sc.UpdatedAt)
return err
}
func (s *PostgresStore) GetAMLScreening(id string) (*AMLScreening, error) {
ctx := context.Background()
var sc AMLScreening
var status, riskLevel string
var reviewedAt *time.Time
err := s.pool.QueryRow(ctx, `
SELECT id, account_id, user_id, type, status, risk_level, risk_score,
provider, details, sanctions_hit, pep_match, reviewed_by, reviewed_at, created_at, updated_at
FROM aml_screenings WHERE id = $1
`, id).Scan(&sc.ID, &sc.AccountID, &sc.UserID, &sc.Type, &status, &riskLevel,
&sc.RiskScore, &sc.Provider, &sc.Details, &sc.SanctionsHit, &sc.PEPMatch,
&sc.ReviewedBy, &reviewedAt, &sc.CreatedAt, &sc.UpdatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("aml screening not found")
}
return nil, err
}
sc.Status = AMLStatus(status)
sc.RiskLevel = RiskLevel(riskLevel)
if reviewedAt != nil {
sc.ReviewedAt = *reviewedAt
}
return &sc, nil
}
func (s *PostgresStore) ListAMLScreeningsByAccount(accountID string) []*AMLScreening {
ctx := context.Background()
rows, err := s.pool.Query(ctx, `
SELECT id, account_id, user_id, type, status, risk_level, risk_score,
provider, details, sanctions_hit, pep_match, reviewed_by, reviewed_at, created_at, updated_at
FROM aml_screenings WHERE account_id = $1 ORDER BY created_at DESC
`, accountID)
if err != nil {
return make([]*AMLScreening, 0)
}
defer rows.Close()
return scanAMLScreenings(rows)
}
func (s *PostgresStore) ListAMLScreeningsByStatus(status AMLStatus) []*AMLScreening {
ctx := context.Background()
rows, err := s.pool.Query(ctx, `
SELECT id, account_id, user_id, type, status, risk_level, risk_score,
provider, details, sanctions_hit, pep_match, reviewed_by, reviewed_at, created_at, updated_at
FROM aml_screenings WHERE status = $1 ORDER BY created_at DESC
`, string(status))
if err != nil {
return make([]*AMLScreening, 0)
}
defer rows.Close()
return scanAMLScreenings(rows)
}
func scanAMLScreenings(rows pgx.Rows) []*AMLScreening {
var out []*AMLScreening
for rows.Next() {
var sc AMLScreening
var status, riskLevel string
var reviewedAt *time.Time
if err := rows.Scan(&sc.ID, &sc.AccountID, &sc.UserID, &sc.Type, &status, &riskLevel,
&sc.RiskScore, &sc.Provider, &sc.Details, &sc.SanctionsHit, &sc.PEPMatch,
&sc.ReviewedBy, &reviewedAt, &sc.CreatedAt, &sc.UpdatedAt); err != nil {
continue
}
sc.Status = AMLStatus(status)
sc.RiskLevel = RiskLevel(riskLevel)
if reviewedAt != nil {
sc.ReviewedAt = *reviewedAt
}
out = append(out, &sc)
}
if out == nil {
out = make([]*AMLScreening, 0)
}
return out
}
// --- Application ---
func (s *PostgresStore) SaveApplication(app *Application) error {
ctx := context.Background()
if app.ID == "" {
app.ID = generateID()
}
if app.CreatedAt.IsZero() {
app.CreatedAt = time.Now().UTC()
}
app.UpdatedAt = time.Now().UTC()
stepsJSON, err := json.Marshal(app.Steps)
if err != nil {
return fmt.Errorf("marshal application steps: %w", err)
}
docsJSON, err := json.Marshal(app.Documents)
if err != nil {
return fmt.Errorf("marshal application documents: %w", err)
}
var submittedAt, reviewedAt *time.Time
if !app.SubmittedAt.IsZero() {
submittedAt = &app.SubmittedAt
}
if !app.ReviewedAt.IsZero() {
reviewedAt = &app.ReviewedAt
}
_, err = s.pool.Exec(ctx, `
INSERT INTO applications (id, user_id, email, first_name, last_name, phone, date_of_birth,
ssn_hash, ssn_last4, address_line1, address_line2, city, state, zip_code, country,
status, current_step, kyc_status, aml_status, steps, documents,
risk_level, risk_score, submitted_at, reviewed_by, reviewed_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
phone = EXCLUDED.phone,
date_of_birth = EXCLUDED.date_of_birth,
ssn_hash = EXCLUDED.ssn_hash,
ssn_last4 = EXCLUDED.ssn_last4,
address_line1 = EXCLUDED.address_line1,
address_line2 = EXCLUDED.address_line2,
city = EXCLUDED.city,
state = EXCLUDED.state,
zip_code = EXCLUDED.zip_code,
country = EXCLUDED.country,
status = EXCLUDED.status,
current_step = EXCLUDED.current_step,
kyc_status = EXCLUDED.kyc_status,
aml_status = EXCLUDED.aml_status,
steps = EXCLUDED.steps,
documents = EXCLUDED.documents,
risk_level = EXCLUDED.risk_level,
risk_score = EXCLUDED.risk_score,
submitted_at = EXCLUDED.submitted_at,
reviewed_by = EXCLUDED.reviewed_by,
reviewed_at = EXCLUDED.reviewed_at,
updated_at = EXCLUDED.updated_at
`, app.ID, app.UserID, app.Email, app.FirstName, app.LastName, app.Phone, app.DateOfBirth,
app.SSNHash, app.SSNLast4, app.AddressLine1, app.AddressLine2, app.City, app.State,
app.ZipCode, app.Country, string(app.Status), app.CurrentStep,
string(app.KYCStatus), string(app.AMLStatus), stepsJSON, docsJSON,
string(app.RiskLevel), app.RiskScore, submittedAt, app.ReviewedBy, reviewedAt,
app.CreatedAt, app.UpdatedAt)
return err
}
func (s *PostgresStore) GetApplication(id string) (*Application, error) {
ctx := context.Background()
return s.scanApplication(ctx, `
SELECT id, user_id, email, first_name, last_name, phone, date_of_birth,
ssn_hash, ssn_last4, address_line1, address_line2, city, state, zip_code, country,
status, current_step, kyc_status, aml_status, steps, documents,
risk_level, risk_score, submitted_at, reviewed_by, reviewed_at, created_at, updated_at
FROM applications WHERE id = $1
`, id)
}
func (s *PostgresStore) GetApplicationByUser(userID string) (*Application, error) {
ctx := context.Background()
return s.scanApplication(ctx, `
SELECT id, user_id, email, first_name, last_name, phone, date_of_birth,
ssn_hash, ssn_last4, address_line1, address_line2, city, state, zip_code, country,
status, current_step, kyc_status, aml_status, steps, documents,
risk_level, risk_score, submitted_at, reviewed_by, reviewed_at, created_at, updated_at
FROM applications WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1
`, userID)
}
func (s *PostgresStore) scanApplication(ctx context.Context, query string, args ...interface{}) (*Application, error) {
var app Application
var status, kycStatus, amlStatus, riskLevel string
var stepsJSON, docsJSON []byte
var submittedAt, reviewedAt *time.Time
err := s.pool.QueryRow(ctx, query, args...).Scan(
&app.ID, &app.UserID, &app.Email, &app.FirstName, &app.LastName,
&app.Phone, &app.DateOfBirth, &app.SSNHash, &app.SSNLast4,
&app.AddressLine1, &app.AddressLine2, &app.City, &app.State,
&app.ZipCode, &app.Country, &status, &app.CurrentStep,
&kycStatus, &amlStatus, &stepsJSON, &docsJSON,
&riskLevel, &app.RiskScore, &submittedAt, &app.ReviewedBy,
&reviewedAt, &app.CreatedAt, &app.UpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("application not found")
}
return nil, err
}
app.Status = ApplicationStatus(status)
app.KYCStatus = KYCStatus(kycStatus)
app.AMLStatus = AMLStatus(amlStatus)
app.RiskLevel = RiskLevel(riskLevel)
if submittedAt != nil {
app.SubmittedAt = *submittedAt
}
if reviewedAt != nil {
app.ReviewedAt = *reviewedAt
}
if len(stepsJSON) > 0 {
json.Unmarshal(stepsJSON, &app.Steps)
}
if len(docsJSON) > 0 {
json.Unmarshal(docsJSON, &app.Documents)
}
return &app, nil
}
func (s *PostgresStore) ListApplications() []*Application {
ctx := context.Background()
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, email, first_name, last_name, phone, date_of_birth,
ssn_hash, ssn_last4, address_line1, address_line2, city, state, zip_code, country,
status, current_step, kyc_status, aml_status, steps, documents,
risk_level, risk_score, submitted_at, reviewed_by, reviewed_at, created_at, updated_at
FROM applications ORDER BY created_at DESC
`)
if err != nil {
return make([]*Application, 0)
}
defer rows.Close()
return s.scanApplicationRows(rows)
}
func (s *PostgresStore) ListApplicationsByStatus(status ApplicationStatus) []*Application {
ctx := context.Background()
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, email, first_name, last_name, phone, date_of_birth,
ssn_hash, ssn_last4, address_line1, address_line2, city, state, zip_code, country,
status, current_step, kyc_status, aml_status, steps, documents,
risk_level, risk_score, submitted_at, reviewed_by, reviewed_at, created_at, updated_at
FROM applications WHERE status = $1 ORDER BY created_at DESC
`, string(status))
if err != nil {
return make([]*Application, 0)
}
defer rows.Close()
return s.scanApplicationRows(rows)
}
func (s *PostgresStore) scanApplicationRows(rows pgx.Rows) []*Application {
var out []*Application
for rows.Next() {
var app Application
var status, kycStatus, amlStatus, riskLevel string
var stepsJSON, docsJSON []byte
var submittedAt, reviewedAt *time.Time
if err := rows.Scan(
&app.ID, &app.UserID, &app.Email, &app.FirstName, &app.LastName,
&app.Phone, &app.DateOfBirth, &app.SSNHash, &app.SSNLast4,
&app.AddressLine1, &app.AddressLine2, &app.City, &app.State,
&app.ZipCode, &app.Country, &status, &app.CurrentStep,
&kycStatus, &amlStatus, &stepsJSON, &docsJSON,
&riskLevel, &app.RiskScore, &submittedAt, &app.ReviewedBy,
&reviewedAt, &app.CreatedAt, &app.UpdatedAt,
); err != nil {
continue
}
app.Status = ApplicationStatus(status)
app.KYCStatus = KYCStatus(kycStatus)
app.AMLStatus = AMLStatus(amlStatus)
app.RiskLevel = RiskLevel(riskLevel)
if submittedAt != nil {
app.SubmittedAt = *submittedAt
}
if reviewedAt != nil {
app.ReviewedAt = *reviewedAt
}
if len(stepsJSON) > 0 {
json.Unmarshal(stepsJSON, &app.Steps)
}
if len(docsJSON) > 0 {
json.Unmarshal(docsJSON, &app.Documents)
}
out = append(out, &app)
}
if out == nil {
out = make([]*Application, 0)
}
return out
}
// --- Document Upload ---
func (s *PostgresStore) SaveDocumentUpload(doc *DocumentUpload) error {
ctx := context.Background()
if doc.ID == "" {
doc.ID = generateID()
}
if doc.CreatedAt.IsZero() {
doc.CreatedAt = time.Now().UTC()
}
doc.UpdatedAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO document_uploads (id, application_id, user_id, type, name, mime_type, size, status, review_note, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
review_note = EXCLUDED.review_note,
updated_at = EXCLUDED.updated_at
`, doc.ID, doc.ApplicationID, doc.UserID, doc.Type, doc.Name, doc.MimeType,
doc.Size, doc.Status, doc.ReviewNote, doc.CreatedAt, doc.UpdatedAt)
return err
}
func (s *PostgresStore) GetDocumentUpload(id string) (*DocumentUpload, error) {
ctx := context.Background()
var doc DocumentUpload
err := s.pool.QueryRow(ctx, `
SELECT id, application_id, user_id, type, name, mime_type, size, status, review_note, created_at, updated_at
FROM document_uploads WHERE id = $1
`, id).Scan(&doc.ID, &doc.ApplicationID, &doc.UserID, &doc.Type, &doc.Name,
&doc.MimeType, &doc.Size, &doc.Status, &doc.ReviewNote, &doc.CreatedAt, &doc.UpdatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("document not found")
}
return nil, err
}
return &doc, nil
}
func (s *PostgresStore) ListDocumentUploads(applicationID string) []*DocumentUpload {
ctx := context.Background()
rows, err := s.pool.Query(ctx, `
SELECT id, application_id, user_id, type, name, mime_type, size, status, review_note, created_at, updated_at
FROM document_uploads WHERE application_id = $1 ORDER BY created_at DESC
`, applicationID)
if err != nil {
return make([]*DocumentUpload, 0)
}
defer rows.Close()
var out []*DocumentUpload
for rows.Next() {
var doc DocumentUpload
if err := rows.Scan(&doc.ID, &doc.ApplicationID, &doc.UserID, &doc.Type, &doc.Name,
&doc.MimeType, &doc.Size, &doc.Status, &doc.ReviewNote, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
continue
}
out = append(out, &doc)
}
if out == nil {
out = make([]*DocumentUpload, 0)
}
return out
}
// --- Identity listing ---
func (s *PostgresStore) ListIdentitiesByUser(userID string) []*Identity {
ctx := context.Background()
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, provider, status, data, created_at, updated_at
FROM identities WHERE user_id = $1 ORDER BY created_at DESC
`, userID)
if err != nil {
return make([]*Identity, 0)
}
defer rows.Close()
var out []*Identity
for rows.Next() {
var ident Identity
var status string
var dataJSON []byte
if err := rows.Scan(&ident.ID, &ident.UserID, &ident.Provider, &status, &dataJSON, &ident.CreatedAt, &ident.UpdatedAt); err != nil {
continue
}
ident.Status = KYCStatus(status)
if len(dataJSON) > 0 {
json.Unmarshal(dataJSON, &ident.Data)
}
if ident.Data == nil {
ident.Data = make(map[string]interface{})
}
out = append(out, &ident)
}
if out == nil {
out = make([]*Identity, 0)
}
return out
}
+2
View File
@@ -88,6 +88,8 @@ func (h *rolesHandler) handleDeleteRole(w http.ResponseWriter, r *http.Request)
func (h *rolesHandler) handleListModules(w http.ResponseWriter, r *http.Request) {
modules := []Module{
{Name: "kyc", Description: "KYC identity verification", Actions: []string{"read", "write", "admin"}},
{Name: "aml", Description: "AML screening and monitoring", Actions: []string{"read", "write", "admin"}},
{Name: "applications", Description: "Onboarding applications", Actions: []string{"read", "write", "admin"}},
{Name: "funds", Description: "Fund management", Actions: []string{"read", "write", "delete", "admin"}},
{Name: "esign", Description: "Electronic signatures", Actions: []string{"read", "write", "admin"}},
{Name: "pipelines", Description: "Onboarding pipelines", Actions: []string{"read", "write", "delete", "admin"}},
+50 -1
View File
@@ -10,8 +10,27 @@ 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"
)
// RouterOption configures optional dependencies for the compliance router.
type RouterOption func(*routerConfig)
type routerConfig struct {
authStore *auth.Store
jubeClient *jube.Client
}
// WithAuthStore adds API key management to the compliance router.
func WithAuthStore(s *auth.Store) RouterOption {
return func(cfg *routerConfig) { cfg.authStore = s }
}
// WithJubeClient adds the Jube AML sidecar client for live screening.
func WithJubeClient(c *jube.Client) RouterOption {
return func(cfg *routerConfig) { cfg.jubeClient = c }
}
// NewRouter creates a chi sub-router with all compliance endpoints.
// Mount this under /compliance on the main router.
// The adminStore parameter provides authentication and RBAC. When non-nil,
@@ -43,6 +62,8 @@ func NewRouter(store ComplianceStore, adminStore *admin.Store, authStore ...*aut
settings := &settingsHandler{store: store}
creds := &credentialsHandler{store: store, authStore: as}
billing := &billingHandler{}
aml := &amlHandler{store: store}
apps := &applicationHandler{store: store}
// guard wraps a handler with RBAC.
guard := func(module, action string, h http.HandlerFunc) http.HandlerFunc {
@@ -53,7 +74,7 @@ func NewRouter(store ComplianceStore, adminStore *admin.Store, authStore ...*aut
// CORS — allow the admin frontend at specific ports and production origins.
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000", "http://localhost:3100", "https://*.example.com"},
AllowedOrigins: []string{"http://localhost:3000", "http://localhost:3100", "https://*.lux.exchange.com", "https://*.lux.exchange"},
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: true,
@@ -82,7 +103,35 @@ func NewRouter(store ComplianceStore, adminStore *admin.Store, authStore ...*aut
// KYC
r.Route("/kyc", func(r chi.Router) {
r.Post("/verify", guard("kyc", "write", kyc.handleVerify))
r.Get("/", kyc.handleListByUser)
r.Get("/{id}", kyc.handleGet)
r.Patch("/{id}", guard("kyc", "write", kyc.handleUpdateStatus))
r.Post("/{id}/documents", guard("kyc", "write", kyc.handleUploadDocument))
})
// AML
r.Route("/aml", func(r chi.Router) {
r.Post("/screen", guard("aml", "write", aml.handleScreen))
r.Post("/risk-assessment", guard("aml", "write", aml.handleRiskAssessment))
r.Get("/screenings", aml.handleListByAccount)
r.Get("/flagged", aml.handleListFlagged)
r.Get("/screenings/{id}", aml.handleGet)
r.Post("/screenings/{id}/review", guard("aml", "write", aml.handleReview))
})
// Onboarding Applications
r.Route("/applications", func(r chi.Router) {
r.Get("/", apps.handleList)
r.Post("/", guard("applications", "write", apps.handleCreate))
r.Get("/lookup", apps.handleGetByUser)
r.Get("/{id}", apps.handleGet)
r.Post("/{id}/step/1", guard("applications", "write", apps.handleStep1))
r.Post("/{id}/step/2", guard("applications", "write", apps.handleStep2))
r.Post("/{id}/step/3", guard("applications", "write", apps.handleStep3))
r.Post("/{id}/step/4", guard("applications", "write", apps.handleStep4))
r.Post("/{id}/step/5", guard("applications", "write", apps.handleStep5))
r.Post("/{id}/review", guard("applications", "write", apps.handleReview))
r.Get("/{id}/documents", apps.handleGetDocuments)
})
// Pipelines
+229 -26
View File
@@ -64,6 +64,27 @@ type ComplianceStore interface {
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)
@@ -77,37 +98,43 @@ type ComplianceStore interface {
// 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
settings *Settings
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),
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",
@@ -625,6 +652,182 @@ func (s *MemoryStore) ListEnvelopesByDirection(direction string) []*Envelope {
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 {
+74
View File
@@ -0,0 +1,74 @@
-- Compliance module database tables.
-- Run against the broker's PostgreSQL database.
-- All tables use upsert (ON CONFLICT DO UPDATE) so this migration is idempotent.
CREATE TABLE IF NOT EXISTS aml_screenings (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
user_id TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'sanctions',
status TEXT NOT NULL DEFAULT 'pending',
risk_level TEXT NOT NULL DEFAULT 'low',
risk_score DOUBLE PRECISION NOT NULL DEFAULT 0,
provider TEXT NOT NULL DEFAULT 'manual',
details TEXT NOT NULL DEFAULT '',
sanctions_hit BOOLEAN NOT NULL DEFAULT FALSE,
pep_match BOOLEAN NOT NULL DEFAULT FALSE,
reviewed_by TEXT NOT NULL DEFAULT '',
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_aml_screenings_account ON aml_screenings(account_id);
CREATE INDEX IF NOT EXISTS idx_aml_screenings_status ON aml_screenings(status);
CREATE TABLE IF NOT EXISTS applications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
date_of_birth TEXT NOT NULL DEFAULT '',
ssn_hash TEXT NOT NULL DEFAULT '',
ssn_last4 TEXT NOT NULL DEFAULT '',
address_line1 TEXT NOT NULL DEFAULT '',
address_line2 TEXT NOT NULL DEFAULT '',
city TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
zip_code TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
current_step INTEGER NOT NULL DEFAULT 1,
kyc_status TEXT NOT NULL DEFAULT 'pending',
aml_status TEXT NOT NULL DEFAULT 'pending',
steps JSONB NOT NULL DEFAULT '[]',
documents JSONB NOT NULL DEFAULT '[]',
risk_level TEXT NOT NULL DEFAULT '',
risk_score DOUBLE PRECISION NOT NULL DEFAULT 0,
submitted_at TIMESTAMPTZ,
reviewed_by TEXT NOT NULL DEFAULT '',
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_applications_user ON applications(user_id);
CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status);
CREATE TABLE IF NOT EXISTS document_uploads (
id TEXT PRIMARY KEY,
application_id TEXT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
user_id TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL,
name TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT '',
size BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
review_note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_document_uploads_application ON document_uploads(application_id);