mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -90,6 +111,9 @@ type MemoryStore struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -108,6 +132,9 @@ func NewMemoryStore() *MemoryStore {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user