diff --git a/.env.example b/.env.example index 10b16e8..13c76a8 100644 --- a/.env.example +++ b/.env.example @@ -18,5 +18,36 @@ SUMSUB_APP_TOKEN= SUMSUB_SECRET_KEY= SUMSUB_JWT_SECRET_KEY= +VERIFICATION_PROVIDER=sumsub +LEGACY_SUMSUB_WEBHOOKS_ENABLED=true + +DIDIT_API_URL= +DIDIT_API_KEY= +DIDIT_ENVIRONMENT=sandbox +DIDIT_APPLICATION_ID= +DIDIT_CALLBACK_URL= +DIDIT_KYC_WORKFLOW_ID= +DIDIT_KYC_WORKFLOW_VERSION= +DIDIT_KYB_WORKFLOW_ID= +DIDIT_KYB_WORKFLOW_VERSION= +DIDIT_WEBHOOK_SECRET= +DIDIT_PREVIOUS_WEBHOOK_SECRET= + +DIDIT_KYC_QUESTIONNAIRE_ID= +DIDIT_KYC_QUESTIONNAIRE_VERSION= +DIDIT_KYC_FIRST_NAME_QUESTION_ID= +DIDIT_KYC_LAST_NAME_QUESTION_ID= +DIDIT_KYC_TAX_ID_QUESTION_ID= +DIDIT_KYC_ADDRESS_QUESTION_ID= +DIDIT_KYC_CITY_QUESTION_ID= +DIDIT_KYC_POSTAL_CODE_QUESTION_ID= +DIDIT_KYC_STATE_QUESTION_ID= +DIDIT_KYC_COUNTRY_QUESTION_ID= +DIDIT_KYC_ADDITIONAL_REQUIRED_QUESTION_IDS= + +DIDIT_KYB_QUESTIONNAIRE_ID= +DIDIT_KYB_QUESTIONNAIRE_VERSION= +DIDIT_KYB_ADDITIONAL_REQUIRED_QUESTION_IDS= + EMAIL_TEMPLATES_PATH="./templates/html/" EE_CHAINSTORE_API_URL= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b5ed76b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,63 @@ +name: Test + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: ratio1_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d ratio1_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + RATIO1_STORAGE_TEST_DATABASE: "1" + RATIO1_SERVICE_TEST_DATABASE: "1" + RATIO1_TEST_DATABASE_HOST: 127.0.0.1 + RATIO1_TEST_DATABASE_PORT: "5432" + RATIO1_TEST_DATABASE_USER: postgres + RATIO1_TEST_DATABASE_PASSWORD: postgres + RATIO1_TEST_DATABASE_NAME: ratio1_test + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Download dependencies + run: go mod download + + - name: Run model and storage tests + run: go test ./model ./storage -count=1 + + - name: Run verification policy and HTTP contract tests + run: | + go test ./config -count=1 + go test ./service -run 'Test(Didit|NewDidit|MapDidit|Verification|GrandfatheredSumsub|SumsubMonitoring|ProjectDidit)' -count=1 + go test ./proxy/handlers -run 'Test(DiditWebhook|SumsubWebhook)' -count=1 + + - name: Run verification workflow database tests + run: go test ./service -run 'Test(RetryableTerminalDiditSessionCreatesOneReplacementConcurrently|StoredSumsubMonitoringEventIsRestartRecoverable)' -count=1 + + - name: Compile runtime packages + run: go test ./model ./config ./storage ./service ./proxy/handlers ./proxy -run '^$' + + - name: Run persistence contention tests + run: | + go test -race ./storage -run 'Test(UpdateAccountAndCreateKyc|CreateOrUpdateKycConcurrentCreateKeepsOneRow|CreateVerificationWebhookEventConcurrentDuplicateHasOneWinner|WithVerificationCreationLockSerializesSameVerification)' -count=3 + go test -race ./service -run '^TestRetryableTerminalDiditSessionCreatesOneReplacementConcurrently$' -count=3 diff --git a/cmd/main.go b/cmd/main.go index 03bd828..3f1b299 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "os/signal" + "syscall" "github.com/NaeuralEdgeProtocol/ratio1-backend/config" "github.com/NaeuralEdgeProtocol/ratio1-backend/proxy" @@ -116,20 +117,19 @@ func startApi(ctx *cli.Context) error { } server := api.Run() - waitForGracefulShutdown(server) + waitForGracefulShutdown(api, server) return nil } -func waitForGracefulShutdown(server *http.Server) { +func waitForGracefulShutdown(api *proxy.WebServer, server *http.Server) { quit := make(chan os.Signal) - signal.Notify(quit, os.Interrupt, os.Kill) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) <-quit ctx, cancel := context.WithTimeout(context.Background(), backgroundContextTimeout) defer cancel() - if err := server.Shutdown(ctx); err != nil { + if err := api.Shutdown(ctx, server); err != nil { panic(err) } - _ = server.Close() } diff --git a/cmd/verification-cutover/cutover.go b/cmd/verification-cutover/cutover.go new file mode 100644 index 0000000..f06fa1c --- /dev/null +++ b/cmd/verification-cutover/cutover.go @@ -0,0 +1,676 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io" + "net" + "net/url" + "os" + "sort" + "strconv" + "strings" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + _ "github.com/lib/pq" +) + +const ( + databaseLinkEnvironment = "DATABASE_LINK" + cutoverApplicationName = "ratio1_verification_cutover" +) + +type cutoverOptions struct { + Apply bool + ExpectedTotal int64 + ExpectedPreserved int64 + ExpectedReset int64 + ExpectedResetUserInfos int64 +} + +type databaseConfig struct { + Host string + Port int + Database string + User string + Password string + SSLMode string +} + +type cutoverAggregate struct { + Total int64 + Approved int64 + FinalRejected int64 + Preserved int64 + Reset int64 + UserInfos int64 + ResetUserInfos int64 + ApprovedMissingUserInfos int64 + DuplicateUuidGroups int64 + DuplicateEmailGroups int64 + InvalidIdentityRows int64 + ResetRowsWithLegacyData int64 + Statuses map[string]int64 + Providers map[string]int64 +} + +type queryer interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func validateOptions(opts cutoverOptions) error { + expected := []int64{ + opts.ExpectedTotal, + opts.ExpectedPreserved, + opts.ExpectedReset, + opts.ExpectedResetUserInfos, + } + provided := 0 + for _, value := range expected { + if value >= 0 { + provided++ + } + } + if opts.Apply && provided != len(expected) { + return errors.New("--apply requires --expected-total, --expected-preserved, --expected-reset, and --expected-reset-user-infos") + } + if !opts.Apply && provided != 0 && provided != len(expected) { + return errors.New("expected counts must either all be supplied or all be omitted") + } + if provided == len(expected) && opts.ExpectedPreserved+opts.ExpectedReset != opts.ExpectedTotal { + return errors.New("expected preserved plus expected reset must equal expected total") + } + return nil +} + +func loadDatabaseConfigFromEnvironment() (databaseConfig, error) { + if databaseLink := os.Getenv(databaseLinkEnvironment); databaseLink != "" { + return parseDatabaseLink(databaseLink, os.Getenv("DATABASE_SSLMODE")) + } + + port, err := strconv.Atoi(os.Getenv("DATABASE_PORT")) + if err != nil || port <= 0 { + return databaseConfig{}, errors.New("DATABASE_PORT must be a positive integer") + } + cfg := databaseConfig{ + Host: strings.TrimSpace(os.Getenv("DATABASE_HOST")), + Port: port, + Database: strings.TrimSpace(os.Getenv("DATABASE_NAME")), + User: strings.TrimSpace(os.Getenv("DATABASE_USER")), + Password: os.Getenv("DATABASE_PASSWORD"), + SSLMode: strings.TrimSpace(os.Getenv("DATABASE_SSLMODE")), + } + return validateDatabaseConfig(cfg) +} + +func parseDatabaseLink(databaseLink, sslMode string) (databaseConfig, error) { + parts := strings.SplitN(databaseLink, ":", 5) + if len(parts) != 5 { + return databaseConfig{}, errors.New("DATABASE_LINK must have host:port:database:user:password form") + } + port, err := strconv.Atoi(parts[1]) + if err != nil || port <= 0 { + return databaseConfig{}, errors.New("DATABASE_LINK port must be a positive integer") + } + return validateDatabaseConfig(databaseConfig{ + Host: strings.TrimSpace(parts[0]), + Port: port, + Database: strings.TrimSpace(parts[2]), + User: strings.TrimSpace(parts[3]), + Password: parts[4], + SSLMode: strings.TrimSpace(sslMode), + }) +} + +func validateDatabaseConfig(cfg databaseConfig) (databaseConfig, error) { + if cfg.Host == "" || cfg.Database == "" || cfg.User == "" || cfg.Password == "" { + return databaseConfig{}, errors.New("database host, name, user, and password are required") + } + if cfg.SSLMode == "" { + if isLoopbackHost(cfg.Host) { + cfg.SSLMode = "disable" + } else { + cfg.SSLMode = "require" + } + } + if !isLoopbackHost(cfg.Host) && !isSecureSSLMode(cfg.SSLMode) { + return databaseConfig{}, errors.New("non-loopback database connections require sslmode=require, verify-ca, or verify-full") + } + return cfg, nil +} + +func isLoopbackHost(host string) bool { + host = strings.Trim(host, "[]") + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func isSecureSSLMode(sslMode string) bool { + switch sslMode { + case "require", "verify-ca", "verify-full": + return true + default: + return false + } +} + +func openDatabase(cfg databaseConfig) (*sql.DB, error) { + dsn := (&url.URL{ + Scheme: "postgres", + User: url.UserPassword(cfg.User, cfg.Password), + Host: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)), + Path: cfg.Database, + RawQuery: url.Values{ + "application_name": {cutoverApplicationName}, + "connect_timeout": {"10"}, + "sslmode": {cfg.SSLMode}, + }.Encode(), + }).String() + return sql.Open("postgres", dsn) +} + +func runCutover(ctx context.Context, db *sql.DB, opts cutoverOptions, output io.Writer) error { + if err := db.PingContext(ctx); err != nil { + return fmt.Errorf("connect: %w", err) + } + if !opts.Apply { + return runDryRun(ctx, db, opts, output) + } + return runApply(ctx, db, opts, output) +} + +func runDryRun(ctx context.Context, db *sql.DB, opts cutoverOptions, output io.Writer) error { + tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return fmt.Errorf("begin read-only transaction: %w", err) + } + defer tx.Rollback() + + aggregate, err := collectCutoverAggregate(ctx, tx) + if err != nil { + return err + } + if err = validatePreCutoverAggregate(aggregate, opts); err != nil { + printAggregate(output, "dry-run", aggregate) + return err + } + printAggregate(output, "dry-run", aggregate) + fmt.Fprintln(output, "result=ready_for_reviewed_apply") + if err = tx.Rollback(); err != nil { + return fmt.Errorf("end read-only transaction: %w", err) + } + return nil +} + +func runApply(ctx context.Context, db *sql.DB, opts cutoverOptions, output io.Writer) error { + tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return fmt.Errorf("begin serializable transaction: %w", err) + } + defer tx.Rollback() + + lockedRows, err := tx.QueryContext(ctx, `SELECT uuid FROM kycs ORDER BY uuid FOR UPDATE`) + if err != nil { + return fmt.Errorf("lock KYC rows: %w", err) + } + var lockedCount int64 + for lockedRows.Next() { + var uuid string + if err = lockedRows.Scan(&uuid); err != nil { + lockedRows.Close() + return fmt.Errorf("scan locked KYC row: %w", err) + } + lockedCount++ + } + if err = lockedRows.Err(); err != nil { + lockedRows.Close() + return fmt.Errorf("iterate locked KYC rows: %w", err) + } + if err = lockedRows.Close(); err != nil { + return fmt.Errorf("close locked KYC rows: %w", err) + } + + before, err := collectCutoverAggregate(ctx, tx) + if err != nil { + return err + } + if lockedCount != before.Total { + return fmt.Errorf("locked KYC count %d does not match aggregate total %d", lockedCount, before.Total) + } + if err = validatePreCutoverAggregate(before, opts); err != nil { + printAggregate(output, "before", before) + return err + } + + preservedResult, err := tx.ExecContext( + ctx, + `UPDATE kycs + SET verification_provider = $1 + WHERE kyc_status IN ($2, $3)`, + model.VerificationProviderSumsub, + model.StatusApproved, + model.StatusFinalRejected, + ) + if err != nil { + return fmt.Errorf("backfill preserved Sumsub provider: %w", err) + } + preservedRows, err := preservedResult.RowsAffected() + if err != nil { + return fmt.Errorf("read preserved update count: %w", err) + } + if preservedRows != before.Preserved { + return fmt.Errorf("preserved update affected %d rows, expected %d", preservedRows, before.Preserved) + } + + resetResult, err := tx.ExecContext( + ctx, + `UPDATE kycs + SET kyc_status = $1, + applicant_id = '', + applicant_type = '', + country = '', + vies_registered = FALSE, + verification_provider = $2 + WHERE kyc_status NOT IN ($3, $4)`, + model.StatusAccountCreated, + model.VerificationProviderDidit, + model.StatusApproved, + model.StatusFinalRejected, + ) + if err != nil { + return fmt.Errorf("reset non-preserved KYC rows: %w", err) + } + resetRows, err := resetResult.RowsAffected() + if err != nil { + return fmt.Errorf("read reset update count: %w", err) + } + if resetRows != before.Reset { + return fmt.Errorf("reset update affected %d rows, expected %d", resetRows, before.Reset) + } + + resetUserInfosResult, err := tx.ExecContext( + ctx, + `DELETE FROM user_infos ui + WHERE EXISTS ( + SELECT 1 + FROM kycs k + LEFT JOIN accounts a ON a.email = k.email + WHERE k.verification_provider = $1 + AND ( + LOWER(BTRIM(ui.email)) = LOWER(BTRIM(k.email)) + OR (a.address IS NOT NULL AND ui.blockchain_address = a.address) + ) + )`, + model.VerificationProviderDidit, + ) + if err != nil { + return fmt.Errorf("delete reset-cohort UserInfo rows: %w", err) + } + resetUserInfos, err := resetUserInfosResult.RowsAffected() + if err != nil { + return fmt.Errorf("read reset UserInfo delete count: %w", err) + } + if resetUserInfos != before.ResetUserInfos { + return fmt.Errorf( + "reset UserInfo delete affected %d rows, expected %d", + resetUserInfos, + before.ResetUserInfos, + ) + } + + after, err := collectCutoverAggregate(ctx, tx) + if err != nil { + return err + } + if err = validatePostCutoverAggregate(before, after); err != nil { + return err + } + if err = tx.Commit(); err != nil { + return fmt.Errorf("commit cutover transaction: %w", err) + } + + printAggregate(output, "before", before) + printAggregate(output, "after", after) + fmt.Fprintln(output, "result=applied_and_committed") + return nil +} + +func collectCutoverAggregate(ctx context.Context, q queryer) (cutoverAggregate, error) { + aggregate := cutoverAggregate{ + Statuses: make(map[string]int64), + Providers: make(map[string]int64), + } + + err := q.QueryRowContext( + ctx, + `SELECT + COUNT(*), + COUNT(*) FILTER (WHERE kyc_status = $1), + COUNT(*) FILTER (WHERE kyc_status = $2), + COUNT(*) FILTER (WHERE kyc_status IN ($1, $2)), + COUNT(*) FILTER (WHERE kyc_status NOT IN ($1, $2)), + COUNT(*) FILTER ( + WHERE kyc_status NOT IN ($1, $2) + AND ( + COALESCE(applicant_id, '') <> '' + OR COALESCE(applicant_type, '') <> '' + OR COALESCE(country, '') <> '' + OR vies_registered + ) + ), + COUNT(*) FILTER ( + WHERE uuid IS NULL + OR BTRIM(CAST(uuid AS text)) = '' + OR email IS NULL + OR BTRIM(email) = '' + OR BTRIM(email) <> email + ) + FROM kycs`, + model.StatusApproved, + model.StatusFinalRejected, + ).Scan( + &aggregate.Total, + &aggregate.Approved, + &aggregate.FinalRejected, + &aggregate.Preserved, + &aggregate.Reset, + &aggregate.ResetRowsWithLegacyData, + &aggregate.InvalidIdentityRows, + ) + if err != nil { + return cutoverAggregate{}, fmt.Errorf("read KYC aggregate: %w", err) + } + + if err = q.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_infos`).Scan(&aggregate.UserInfos); err != nil { + return cutoverAggregate{}, fmt.Errorf("read UserInfo aggregate: %w", err) + } + if err = q.QueryRowContext( + ctx, + `SELECT COUNT(*) + FROM user_infos ui + WHERE EXISTS ( + SELECT 1 + FROM kycs k + LEFT JOIN accounts a ON a.email = k.email + WHERE k.kyc_status NOT IN ($1, $2) + AND ( + LOWER(BTRIM(ui.email)) = LOWER(BTRIM(k.email)) + OR (a.address IS NOT NULL AND ui.blockchain_address = a.address) + ) + )`, + model.StatusApproved, + model.StatusFinalRejected, + ).Scan(&aggregate.ResetUserInfos); err != nil { + return cutoverAggregate{}, fmt.Errorf("read reset-cohort UserInfo aggregate: %w", err) + } + if err = q.QueryRowContext( + ctx, + `SELECT COUNT(*) + FROM kycs k + WHERE k.kyc_status = $1 + AND NOT EXISTS ( + SELECT 1 + FROM user_infos ui + LEFT JOIN accounts a ON a.address = ui.blockchain_address + WHERE LOWER(BTRIM(ui.email)) = LOWER(BTRIM(k.email)) + OR LOWER(BTRIM(a.email)) = LOWER(BTRIM(k.email)) + )`, + model.StatusApproved, + ).Scan(&aggregate.ApprovedMissingUserInfos); err != nil { + return cutoverAggregate{}, fmt.Errorf("read approved missing UserInfo aggregate: %w", err) + } + if err = q.QueryRowContext( + ctx, + `SELECT COUNT(*) FROM ( + SELECT uuid FROM kycs GROUP BY uuid HAVING COUNT(*) > 1 + ) duplicate_uuids`, + ).Scan(&aggregate.DuplicateUuidGroups); err != nil { + return cutoverAggregate{}, fmt.Errorf("read duplicate UUID aggregate: %w", err) + } + if err = q.QueryRowContext( + ctx, + `SELECT COUNT(*) FROM ( + SELECT LOWER(BTRIM(email)) + FROM kycs + WHERE email IS NOT NULL + GROUP BY LOWER(BTRIM(email)) + HAVING COUNT(*) > 1 + ) duplicate_emails`, + ).Scan(&aggregate.DuplicateEmailGroups); err != nil { + return cutoverAggregate{}, fmt.Errorf("read duplicate email aggregate: %w", err) + } + + statusRows, err := q.QueryContext(ctx, `SELECT kyc_status, COUNT(*) FROM kycs GROUP BY kyc_status ORDER BY kyc_status`) + if err != nil { + return cutoverAggregate{}, fmt.Errorf("read status aggregates: %w", err) + } + for statusRows.Next() { + var status string + var count int64 + if err = statusRows.Scan(&status, &count); err != nil { + statusRows.Close() + return cutoverAggregate{}, fmt.Errorf("scan status aggregate: %w", err) + } + aggregate.Statuses[status] = count + } + if err = statusRows.Err(); err != nil { + statusRows.Close() + return cutoverAggregate{}, fmt.Errorf("iterate status aggregates: %w", err) + } + if err = statusRows.Close(); err != nil { + return cutoverAggregate{}, fmt.Errorf("close status aggregates: %w", err) + } + + providerRows, err := q.QueryContext( + ctx, + `SELECT COALESCE(verification_provider, ''), COUNT(*) + FROM kycs + GROUP BY COALESCE(verification_provider, '') + ORDER BY COALESCE(verification_provider, '')`, + ) + if err != nil { + return cutoverAggregate{}, fmt.Errorf("read provider aggregates: %w", err) + } + for providerRows.Next() { + var provider string + var count int64 + if err = providerRows.Scan(&provider, &count); err != nil { + providerRows.Close() + return cutoverAggregate{}, fmt.Errorf("scan provider aggregate: %w", err) + } + aggregate.Providers[provider] = count + } + if err = providerRows.Err(); err != nil { + providerRows.Close() + return cutoverAggregate{}, fmt.Errorf("iterate provider aggregates: %w", err) + } + if err = providerRows.Close(); err != nil { + return cutoverAggregate{}, fmt.Errorf("close provider aggregates: %w", err) + } + + return aggregate, nil +} + +func validatePreCutoverAggregate(aggregate cutoverAggregate, opts cutoverOptions) error { + if aggregate.Preserved+aggregate.Reset != aggregate.Total { + return errors.New("preserved plus reset count does not equal total") + } + if aggregate.Approved+aggregate.FinalRejected != aggregate.Preserved { + return errors.New("approved plus finalRejected count does not equal preserved") + } + if aggregate.DuplicateUuidGroups != 0 || aggregate.DuplicateEmailGroups != 0 { + return fmt.Errorf( + "duplicate identities found: uuid_groups=%d email_groups=%d", + aggregate.DuplicateUuidGroups, + aggregate.DuplicateEmailGroups, + ) + } + if aggregate.InvalidIdentityRows != 0 { + return fmt.Errorf("invalid UUID/email identities found: rows=%d", aggregate.InvalidIdentityRows) + } + if aggregate.ApprovedMissingUserInfos != 0 { + return fmt.Errorf( + "approved KYC rows missing UserInfo: rows=%d", + aggregate.ApprovedMissingUserInfos, + ) + } + + allowedStatuses := map[string]struct{}{ + model.StatusAccountCreated: {}, + model.StatusInit: {}, + model.StatusPending: {}, + model.StatusPrechecked: {}, + model.StatusQueued: {}, + model.StatusCompleted: {}, + model.StatusApproved: {}, + model.StatusOnHold: {}, + model.StatusRejected: {}, + model.StatusFinalRejected: {}, + } + for status := range aggregate.Statuses { + if _, allowed := allowedStatuses[status]; !allowed { + return fmt.Errorf("unexpected KYC status %q", status) + } + } + for provider := range aggregate.Providers { + if provider != "" && provider != model.VerificationProviderSumsub { + return fmt.Errorf( + "unexpected pre-cutover verification provider %q; refuse to run after Didit traffic starts", + provider, + ) + } + } + + if opts.ExpectedTotal >= 0 && + (opts.ExpectedTotal != aggregate.Total || + opts.ExpectedPreserved != aggregate.Preserved || + opts.ExpectedReset != aggregate.Reset || + opts.ExpectedResetUserInfos != aggregate.ResetUserInfos) { + return fmt.Errorf( + "expected counts do not match: expected total=%d preserved=%d reset=%d reset_user_infos=%d; actual total=%d preserved=%d reset=%d reset_user_infos=%d", + opts.ExpectedTotal, + opts.ExpectedPreserved, + opts.ExpectedReset, + opts.ExpectedResetUserInfos, + aggregate.Total, + aggregate.Preserved, + aggregate.Reset, + aggregate.ResetUserInfos, + ) + } + return nil +} + +func validatePostCutoverAggregate(before, after cutoverAggregate) error { + if after.Total != before.Total { + return fmt.Errorf("KYC total changed from %d to %d", before.Total, after.Total) + } + if after.UserInfos != before.UserInfos-before.ResetUserInfos { + return fmt.Errorf( + "UserInfo total changed from %d to %d; expected removal of %d reset-cohort rows", + before.UserInfos, + after.UserInfos, + before.ResetUserInfos, + ) + } + if after.ResetUserInfos != 0 { + return fmt.Errorf("reset-cohort UserInfo rows remain after cutover: %d", after.ResetUserInfos) + } + if after.ApprovedMissingUserInfos != 0 { + return fmt.Errorf( + "approved KYC rows missing UserInfo after cutover: %d", + after.ApprovedMissingUserInfos, + ) + } + if after.Approved != before.Approved || after.FinalRejected != before.FinalRejected { + return errors.New("preserved approved/finalRejected totals changed") + } + if after.Statuses[model.StatusAccountCreated] != before.Reset { + return fmt.Errorf( + "post-cutover accCreated count is %d, expected %d", + after.Statuses[model.StatusAccountCreated], + before.Reset, + ) + } + for status, count := range after.Statuses { + if status != model.StatusAccountCreated && + status != model.StatusApproved && + status != model.StatusFinalRejected && + count != 0 { + return fmt.Errorf("post-cutover status %q still has %d rows", status, count) + } + } + if after.Providers[model.VerificationProviderSumsub] != before.Preserved { + return fmt.Errorf( + "post-cutover Sumsub provider count is %d, expected %d", + after.Providers[model.VerificationProviderSumsub], + before.Preserved, + ) + } + if after.Providers[model.VerificationProviderDidit] != before.Reset { + return fmt.Errorf( + "post-cutover Didit provider count is %d, expected %d", + after.Providers[model.VerificationProviderDidit], + before.Reset, + ) + } + if len(after.Providers) != expectedProviderGroupCount(before) { + return fmt.Errorf("unexpected post-cutover provider groups: %v", sortedKeys(after.Providers)) + } + if after.ResetRowsWithLegacyData != 0 { + return fmt.Errorf("post-cutover reset rows still contain legacy fields: %d", after.ResetRowsWithLegacyData) + } + return nil +} + +func expectedProviderGroupCount(before cutoverAggregate) int { + count := 0 + if before.Preserved > 0 { + count++ + } + if before.Reset > 0 { + count++ + } + return count +} + +func printAggregate(output io.Writer, label string, aggregate cutoverAggregate) { + fmt.Fprintf(output, "aggregate=%s\n", label) + fmt.Fprintf(output, "total=%d\n", aggregate.Total) + fmt.Fprintf(output, "approved=%d\n", aggregate.Approved) + fmt.Fprintf(output, "final_rejected=%d\n", aggregate.FinalRejected) + fmt.Fprintf(output, "preserved=%d\n", aggregate.Preserved) + fmt.Fprintf(output, "reset=%d\n", aggregate.Reset) + fmt.Fprintf(output, "user_infos=%d\n", aggregate.UserInfos) + fmt.Fprintf(output, "reset_user_infos=%d\n", aggregate.ResetUserInfos) + fmt.Fprintf(output, "approved_missing_user_infos=%d\n", aggregate.ApprovedMissingUserInfos) + fmt.Fprintf(output, "duplicate_uuid_groups=%d\n", aggregate.DuplicateUuidGroups) + fmt.Fprintf(output, "duplicate_email_groups=%d\n", aggregate.DuplicateEmailGroups) + fmt.Fprintf(output, "invalid_identity_rows=%d\n", aggregate.InvalidIdentityRows) + fmt.Fprintf(output, "reset_rows_with_legacy_data=%d\n", aggregate.ResetRowsWithLegacyData) + for _, status := range sortedKeys(aggregate.Statuses) { + fmt.Fprintf(output, "status[%s]=%d\n", status, aggregate.Statuses[status]) + } + for _, provider := range sortedKeys(aggregate.Providers) { + display := provider + if display == "" { + display = "unassigned" + } + fmt.Fprintf(output, "provider[%s]=%d\n", display, aggregate.Providers[provider]) + } +} + +func sortedKeys(values map[string]int64) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/verification-cutover/cutover_integration_test.go b/cmd/verification-cutover/cutover_integration_test.go new file mode 100644 index 0000000..d5ea9af --- /dev/null +++ b/cmd/verification-cutover/cutover_integration_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "io" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" + _ "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func TestCutoverApplyIntegration(t *testing.T) { + if os.Getenv("RATIO1_CUTOVER_TEST_DATABASE") != "1" { + t.Skip("set RATIO1_CUTOVER_TEST_DATABASE=1 to run the PostgreSQL integration test") + } + + dsn := cutoverTestDatabaseDsn(t) + admin, err := sql.Open("postgres", dsn) + require.NoError(t, err) + defer admin.Close() + + schema := "ratio1_cutover_test_" + strings.ReplaceAll(uuid.NewString(), "-", "") + _, err = admin.Exec("CREATE SCHEMA " + schema) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = admin.Exec("DROP SCHEMA " + schema + " CASCADE") + }) + + schemaDsn, err := url.Parse(dsn) + require.NoError(t, err) + query := schemaDsn.Query() + query.Set("search_path", schema) + schemaDsn.RawQuery = query.Encode() + + db, err := sql.Open("postgres", schemaDsn.String()) + require.NoError(t, err) + defer db.Close() + + _, err = db.Exec(` + CREATE TABLE kycs ( + uuid text PRIMARY KEY, + applicant_id text, + applicant_type text, + verification_provider text NOT NULL DEFAULT '', + email text NOT NULL UNIQUE, + kyc_status text NOT NULL, + last_updated timestamptz, + is_active boolean NOT NULL DEFAULT true, + has_been_deleted boolean NOT NULL DEFAULT false, + receive_updates boolean NOT NULL DEFAULT false, + country text, + vies_registered boolean NOT NULL DEFAULT false + ); + CREATE TABLE user_infos ( + blockchain_address text PRIMARY KEY, + email text + ); + CREATE TABLE accounts ( + address text PRIMARY KEY, + email text + ); + `) + require.NoError(t, err) + + emails := make([]string, 0, 4) + for index, status := range []string{ + model.StatusApproved, + model.StatusFinalRejected, + model.StatusPending, + model.StatusRejected, + } { + email := fmt.Sprintf("cutover-%d@example.test", index) + emails = append(emails, email) + _, err = db.Exec( + `INSERT INTO kycs ( + uuid, applicant_id, applicant_type, email, kyc_status, country, vies_registered + ) VALUES ($1, $2, $3, $4, $5, $6, TRUE)`, + uuid.NewString(), + fmt.Sprintf("sumsub-%d", index), + model.IndividualCustomer, + email, + status, + "ROU", + ) + require.NoError(t, err) + _, err = db.Exec( + `INSERT INTO accounts (address, email) VALUES ($1, $2)`, + fmt.Sprintf("0x%d", index+1), + email, + ) + require.NoError(t, err) + } + _, err = db.Exec( + `INSERT INTO user_infos (blockchain_address, email) + VALUES ('0x1', $1), ('0x2', $2), ('0x3', $3)`, + emails[0], + emails[1], + emails[2], + ) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + require.NoError(t, runCutover(ctx, db, cutoverOptions{ + Apply: true, + ExpectedTotal: 4, + ExpectedPreserved: 2, + ExpectedReset: 2, + ExpectedResetUserInfos: 1, + }, io.Discard)) + + var preserved, reset, userInfos, resetWithLegacyFields int64 + require.NoError(t, db.QueryRow( + `SELECT COUNT(*) FROM kycs + WHERE kyc_status IN ($1, $2) AND verification_provider = $3`, + model.StatusApproved, + model.StatusFinalRejected, + model.VerificationProviderSumsub, + ).Scan(&preserved)) + require.NoError(t, db.QueryRow( + `SELECT COUNT(*) FROM kycs + WHERE kyc_status = $1 + AND verification_provider = $2 + AND applicant_id = '' + AND applicant_type = '' + AND country = '' + AND vies_registered = FALSE`, + model.StatusAccountCreated, + model.VerificationProviderDidit, + ).Scan(&reset)) + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM user_infos`).Scan(&userInfos)) + require.NoError(t, db.QueryRow( + `SELECT COUNT(*) FROM kycs + WHERE kyc_status = $1 + AND (applicant_id <> '' OR applicant_type <> '' OR country <> '' OR vies_registered)`, + model.StatusAccountCreated, + ).Scan(&resetWithLegacyFields)) + + require.Equal(t, int64(2), preserved) + require.Equal(t, int64(2), reset) + require.Equal(t, int64(2), userInfos) + require.Zero(t, resetWithLegacyFields) +} + +func cutoverTestDatabaseDsn(t *testing.T) string { + t.Helper() + host := envOr("RATIO1_TEST_DATABASE_HOST", "127.0.0.1") + port := envOr("RATIO1_TEST_DATABASE_PORT", "5432") + user := envOr("RATIO1_TEST_DATABASE_USER", "postgres") + password := envOr("RATIO1_TEST_DATABASE_PASSWORD", "postgres") + database := envOr("RATIO1_TEST_DATABASE_NAME", "ratio1_test") + if !isLoopbackHost(host) { + t.Fatal("cutover integration tests only permit a loopback PostgreSQL host") + } + return (&url.URL{ + Scheme: "postgres", + User: url.UserPassword(user, password), + Host: host + ":" + port, + Path: database, + RawQuery: url.Values{ + "sslmode": {"disable"}, + }.Encode(), + }).String() +} + +func envOr(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/cmd/verification-cutover/cutover_test.go b/cmd/verification-cutover/cutover_test.go new file mode 100644 index 0000000..3cfbc94 --- /dev/null +++ b/cmd/verification-cutover/cutover_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "testing" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/stretchr/testify/require" +) + +func TestValidateOptions(t *testing.T) { + require.NoError(t, validateOptions(cutoverOptions{})) + require.NoError(t, validateOptions(cutoverOptions{ + ExpectedTotal: 10, + ExpectedPreserved: 4, + ExpectedReset: 6, + ExpectedResetUserInfos: 2, + })) + require.NoError(t, validateOptions(cutoverOptions{ + Apply: true, + ExpectedTotal: 10, + ExpectedPreserved: 4, + ExpectedReset: 6, + ExpectedResetUserInfos: 2, + })) + + require.Error(t, validateOptions(cutoverOptions{ + Apply: true, + ExpectedTotal: -1, + ExpectedPreserved: -1, + ExpectedReset: -1, + ExpectedResetUserInfos: -1, + })) + require.Error(t, validateOptions(cutoverOptions{ + ExpectedTotal: 10, + ExpectedPreserved: -1, + ExpectedReset: -1, + ExpectedResetUserInfos: -1, + })) + require.Error(t, validateOptions(cutoverOptions{ + Apply: true, + ExpectedTotal: 10, + ExpectedPreserved: 4, + ExpectedReset: 5, + ExpectedResetUserInfos: 2, + })) +} + +func TestParseDatabaseLink(t *testing.T) { + cfg, err := parseDatabaseLink("db.example:5432:ratio1:cutover:password:with:colons", "") + require.NoError(t, err) + require.Equal(t, "db.example", cfg.Host) + require.Equal(t, 5432, cfg.Port) + require.Equal(t, "ratio1", cfg.Database) + require.Equal(t, "cutover", cfg.User) + require.Equal(t, "password:with:colons", cfg.Password) + require.Equal(t, "require", cfg.SSLMode) + + _, err = parseDatabaseLink("db.example:not-a-port:ratio1:cutover:secret", "") + require.Error(t, err) + _, err = parseDatabaseLink("db.example:5432:ratio1:cutover", "") + require.Error(t, err) + _, err = parseDatabaseLink("db.example:5432:ratio1:cutover:secret", "disable") + require.Error(t, err) +} + +func TestValidatePreCutoverAggregate(t *testing.T) { + valid := validPreCutoverAggregate() + require.NoError(t, validatePreCutoverAggregate(valid, cutoverOptions{ + ExpectedTotal: 4, + ExpectedPreserved: 2, + ExpectedReset: 2, + ExpectedResetUserInfos: 1, + })) + + tests := []struct { + name string + mutate func(*cutoverAggregate) + }{ + { + name: "duplicate email", + mutate: func(value *cutoverAggregate) { + value.DuplicateEmailGroups = 1 + }, + }, + { + name: "invalid identity", + mutate: func(value *cutoverAggregate) { + value.InvalidIdentityRows = 1 + }, + }, + { + name: "unknown status", + mutate: func(value *cutoverAggregate) { + value.Statuses["mystery"] = 1 + }, + }, + { + name: "Didit traffic already exists", + mutate: func(value *cutoverAggregate) { + value.Providers = map[string]int64{model.VerificationProviderDidit: 1} + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value := validPreCutoverAggregate() + test.mutate(&value) + require.Error(t, validatePreCutoverAggregate(value, cutoverOptions{})) + }) + } + + require.Error(t, validatePreCutoverAggregate(valid, cutoverOptions{ + ExpectedTotal: 5, + ExpectedPreserved: 2, + ExpectedReset: 3, + ExpectedResetUserInfos: 1, + })) +} + +func TestValidatePostCutoverAggregate(t *testing.T) { + before := validPreCutoverAggregate() + after := cutoverAggregate{ + Total: 4, + Approved: 1, + FinalRejected: 1, + Preserved: 2, + Reset: 2, + UserInfos: 2, + ResetUserInfos: 0, + Statuses: map[string]int64{ + model.StatusAccountCreated: 2, + model.StatusApproved: 1, + model.StatusFinalRejected: 1, + }, + Providers: map[string]int64{ + model.VerificationProviderSumsub: 2, + model.VerificationProviderDidit: 2, + }, + } + require.NoError(t, validatePostCutoverAggregate(before, after)) + + after.UserInfos = 3 + require.Error(t, validatePostCutoverAggregate(before, after)) +} + +func validPreCutoverAggregate() cutoverAggregate { + return cutoverAggregate{ + Total: 4, + Approved: 1, + FinalRejected: 1, + Preserved: 2, + Reset: 2, + UserInfos: 3, + ResetUserInfos: 1, + ResetRowsWithLegacyData: 2, + Statuses: map[string]int64{ + model.StatusApproved: 1, + model.StatusFinalRejected: 1, + model.StatusPending: 1, + model.StatusRejected: 1, + }, + Providers: map[string]int64{"": 4}, + } +} diff --git a/cmd/verification-cutover/main.go b/cmd/verification-cutover/main.go new file mode 100644 index 0000000..cf8e759 --- /dev/null +++ b/cmd/verification-cutover/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" +) + +func main() { + var opts cutoverOptions + var timeout time.Duration + + flag.BoolVar(&opts.Apply, "apply", false, "apply the cutover transaction; omitted means read-only dry-run") + flag.Int64Var(&opts.ExpectedTotal, "expected-total", -1, "required with --apply") + flag.Int64Var(&opts.ExpectedPreserved, "expected-preserved", -1, "required with --apply") + flag.Int64Var(&opts.ExpectedReset, "expected-reset", -1, "required with --apply") + flag.Int64Var(&opts.ExpectedResetUserInfos, "expected-reset-user-infos", -1, "required with --apply") + flag.DurationVar(&timeout, "timeout", 30*time.Second, "overall database operation timeout") + flag.Parse() + + if err := validateOptions(opts); err != nil { + fmt.Fprintln(os.Stderr, "invalid arguments:", err) + os.Exit(2) + } + + databaseConfig, err := loadDatabaseConfigFromEnvironment() + if err != nil { + fmt.Fprintln(os.Stderr, "database configuration:", err) + os.Exit(2) + } + + db, err := openDatabase(databaseConfig) + if err != nil { + fmt.Fprintln(os.Stderr, "open database:", err) + os.Exit(1) + } + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err = runCutover(ctx, db, opts, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "verification cutover:", err) + os.Exit(1) + } +} diff --git a/config/config.devnet.json b/config/config.devnet.json index 59d7ae0..d767fe3 100644 --- a/config/config.devnet.json +++ b/config/config.devnet.json @@ -27,6 +27,13 @@ "ConfirmUrl": "https://devnet-app.ratio1.ai/confirm-email?token=%s", "FromEmail": "noreply@ratio1.ai" }, + "Verification": { + "Provider": "sumsub", + "LegacySumsubWebhooksEnabled": true, + "WorkerPollSeconds": 2, + "WorkerBatchSize": 20, + "WorkerMaxAttempts": 10 + }, "Sumsub": { "ApiUrl": "https://api.sumsub.com", "ApiEndpoint": "/resources/accessTokens/sdk", @@ -36,6 +43,42 @@ "SumsubSecretKey": "", "SumsubJwtSecretKey": "" }, + "Didit": { + "ApiUrl": "https://verification.didit.me", + "Environment": "sandbox", + "CallbackUrl": "https://devnet-app.ratio1.ai/profile", + "KycWorkflowId": "e7ea8679-898e-4439-bbd0-79246d6eb628", + "KycWorkflowVersion": 1, + "KybWorkflowId": "44fe0ecb-9a11-4717-aed5-c11b73c8a07c", + "KybWorkflowVersion": 1, + "KycQuestionnaire": { + "QuestionnaireId": "b271880d-15f5-4621-a851-a193b4aebddd", + "QuestionnaireVersion": 2, + "FirstNameQuestionId": "5ab7c537-32b2-4792-b522-b9065da0df3a", + "LastNameQuestionId": "6828a11b-1ebf-4999-a942-bfd77dfa948c", + "TaxIdQuestionId": "18777afd-6fb6-4a8e-817b-0d7be1229dc5", + "AddressQuestionId": "fb48c0f2-46e8-4bcf-88f3-298c37178acb", + "CityQuestionId": "b8d3600e-f281-4e2a-a964-8ccde079f40a", + "PostalCodeQuestionId": "ac420e57-2192-4457-9f72-8ebcf1615cba", + "StateQuestionId": "9a0cbc08-9e41-4347-8ef3-5e0c0d4a5459", + "CountryQuestionId": "96646c0d-9650-4844-bd5e-85971060131e" + }, + "KybQuestionnaire": { + "QuestionnaireId": "e6c959b9-b5e2-4ba5-9217-92ebe8613f5a", + "QuestionnaireVersion": 1, + "AdditionalRequiredQuestionIds": [ + "1c2fd10b-c439-4f53-ba4a-d4581563b52d", + "4ec8a1c8-1b79-4aeb-a91e-2ed2a24552a0", + "ecac1d83-3413-4c5d-871a-a492f1a1be5b", + "a714b441-bfd2-4d35-9248-716e668ce2b9", + "f15f7874-69a4-45de-9cfe-c7fa854b85f6", + "bfde3e4c-6148-45cd-a04c-c151dd789930", + "82cfd962-7414-4f8c-8e8d-9cf1d46589de", + "77250407-9e4c-4dfe-80a8-3f091890f217", + "d7517e61-084e-4e8d-b96c-9f137c5b7c81" + ] + } + }, "MailerLite": { "Url": "https://connect.mailerlite.com/api", "GroupId": "", diff --git a/config/config.go b/config/config.go index 8c2fafc..089028c 100644 --- a/config/config.go +++ b/config/config.go @@ -22,7 +22,9 @@ type GeneralConfig struct { Database DatabaseConfig Jwt JwtConfig Mail MailConfig + Verification VerificationConfig Sumsub SumsubConfig + Didit DiditConfig MailerLite MailerLiteConfig AcceptedDomains AcceptedDomains ChainID int @@ -93,6 +95,47 @@ type SumsubConfig struct { SumsubSecretKey string SumsubJwtSecretKey string } + +type VerificationConfig struct { + Provider string + LegacySumsubWebhooksEnabled bool + WorkerPollSeconds int + WorkerBatchSize int + WorkerMaxAttempts int +} + +type DiditQuestionnaireConfig struct { + QuestionnaireId string + QuestionnaireVersion int + FirstNameQuestionId string + LastNameQuestionId string + CompanyNameQuestionId string + TaxIdQuestionId string + VatNumberQuestionId string + AddressQuestionId string + CityQuestionId string + PostalCodeQuestionId string + StateQuestionId string + CountryQuestionId string + AdditionalRequiredQuestionIds []string +} + +type DiditConfig struct { + ApiUrl string + Environment string + ApplicationId string + CallbackUrl string + KycWorkflowId string + KycWorkflowVersion int + KybWorkflowId string + KybWorkflowVersion int + KycQuestionnaire DiditQuestionnaireConfig + KybQuestionnaire DiditQuestionnaireConfig + ApiKey string `json:"-"` + WebhookSecret string `json:"-"` + PreviousWebhookSecret string `json:"-"` +} + type MailerLiteConfig struct { Url string GroupId string @@ -200,19 +243,67 @@ func LoadConfig(filePath string) (*GeneralConfig, error) { } /* SUMSUB ENV VARIABLES */ + cfg.Verification.Provider = strings.ToLower(strings.TrimSpace(os.Getenv("VERIFICATION_PROVIDER"))) + if cfg.Verification.Provider == "" { + cfg.Verification.Provider = "sumsub" + } + if cfg.Verification.Provider != "sumsub" && cfg.Verification.Provider != "didit" { + return nil, errors.New("VERIFICATION_PROVIDER must be sumsub or didit") + } + cfg.Verification.LegacySumsubWebhooksEnabled = true + if value := strings.TrimSpace(os.Getenv("LEGACY_SUMSUB_WEBHOOKS_ENABLED")); value != "" { + enabled, parseErr := strconv.ParseBool(value) + if parseErr != nil { + return nil, errors.New("LEGACY_SUMSUB_WEBHOOKS_ENABLED must be true or false") + } + cfg.Verification.LegacySumsubWebhooksEnabled = enabled + } + if cfg.Verification.WorkerPollSeconds <= 0 { + cfg.Verification.WorkerPollSeconds = 2 + } + if cfg.Verification.WorkerBatchSize <= 0 { + cfg.Verification.WorkerBatchSize = 20 + } + if cfg.Verification.WorkerMaxAttempts <= 0 { + cfg.Verification.WorkerMaxAttempts = 10 + } + cfg.Sumsub.SumsubAppToken = os.Getenv("SUMSUB_APP_TOKEN") - if cfg.Sumsub.SumsubAppToken == "" { + if cfg.Sumsub.SumsubAppToken == "" && cfg.Verification.Provider == "sumsub" { return nil, errors.New("SUMSUB_APP_TOKEN is not set") } cfg.Sumsub.SumsubSecretKey = os.Getenv("SUMSUB_SECRET_KEY") - if cfg.Sumsub.SumsubSecretKey == "" { + if cfg.Sumsub.SumsubSecretKey == "" && cfg.Verification.Provider == "sumsub" { return nil, errors.New("SUMSUB_SECRET_KEY is not set") } cfg.Sumsub.SumsubJwtSecretKey = os.Getenv("SUMSUB_JWT_SECRET_KEY") - if cfg.Sumsub.SumsubJwtSecretKey == "" { + if cfg.Sumsub.SumsubJwtSecretKey == "" && + (cfg.Verification.Provider == "sumsub" || cfg.Verification.LegacySumsubWebhooksEnabled) { return nil, errors.New("SUMSUB_JWT_SECRET_KEY is not set") } + /* + DIDIT ENV VARIABLES + + Didit remains optional until the provider cutover. The client constructor + validates these values when the Didit integration is instantiated. + */ + cfg.Didit.ApiKey = os.Getenv("DIDIT_API_KEY") + cfg.Didit.WebhookSecret = os.Getenv("DIDIT_WEBHOOK_SECRET") + cfg.Didit.PreviousWebhookSecret = os.Getenv("DIDIT_PREVIOUS_WEBHOOK_SECRET") + if apiUrl := os.Getenv("DIDIT_API_URL"); apiUrl != "" { + cfg.Didit.ApiUrl = apiUrl + } + if workflowId := os.Getenv("DIDIT_KYC_WORKFLOW_ID"); workflowId != "" { + cfg.Didit.KycWorkflowId = workflowId + } + if workflowId := os.Getenv("DIDIT_KYB_WORKFLOW_ID"); workflowId != "" { + cfg.Didit.KybWorkflowId = workflowId + } + if err := overrideDiditConfigFromEnvironment(&cfg.Didit); err != nil { + return nil, err + } + /* INFURA ENV VARIABLES */ cfg.Infura.Secret = os.Getenv("INFURA_SECRET") if cfg.Infura.Secret == "" { @@ -280,6 +371,91 @@ func LoadConfig(filePath string) (*GeneralConfig, error) { return cfg, nil } +func overrideDiditConfigFromEnvironment(cfg *DiditConfig) error { + setStringFromEnv(&cfg.Environment, "DIDIT_ENVIRONMENT") + setStringFromEnv(&cfg.ApplicationId, "DIDIT_APPLICATION_ID") + setStringFromEnv(&cfg.CallbackUrl, "DIDIT_CALLBACK_URL") + if err := setPositiveIntFromEnv(&cfg.KycWorkflowVersion, "DIDIT_KYC_WORKFLOW_VERSION"); err != nil { + return err + } + if err := setPositiveIntFromEnv(&cfg.KybWorkflowVersion, "DIDIT_KYB_WORKFLOW_VERSION"); err != nil { + return err + } + + if err := overrideDiditQuestionnaireFromEnvironment( + &cfg.KycQuestionnaire, + "DIDIT_KYC", + ); err != nil { + return err + } + if err := overrideDiditQuestionnaireFromEnvironment( + &cfg.KybQuestionnaire, + "DIDIT_KYB", + ); err != nil { + return err + } + return nil +} + +func overrideDiditQuestionnaireFromEnvironment( + cfg *DiditQuestionnaireConfig, + prefix string, +) error { + setStringFromEnv(&cfg.QuestionnaireId, prefix+"_QUESTIONNAIRE_ID") + if err := setPositiveIntFromEnv( + &cfg.QuestionnaireVersion, + prefix+"_QUESTIONNAIRE_VERSION", + ); err != nil { + return err + } + setStringFromEnv(&cfg.FirstNameQuestionId, prefix+"_FIRST_NAME_QUESTION_ID") + setStringFromEnv(&cfg.LastNameQuestionId, prefix+"_LAST_NAME_QUESTION_ID") + setStringFromEnv(&cfg.CompanyNameQuestionId, prefix+"_COMPANY_NAME_QUESTION_ID") + setStringFromEnv(&cfg.TaxIdQuestionId, prefix+"_TAX_ID_QUESTION_ID") + setStringFromEnv(&cfg.VatNumberQuestionId, prefix+"_VAT_NUMBER_QUESTION_ID") + setStringFromEnv(&cfg.AddressQuestionId, prefix+"_ADDRESS_QUESTION_ID") + setStringFromEnv(&cfg.CityQuestionId, prefix+"_CITY_QUESTION_ID") + setStringFromEnv(&cfg.PostalCodeQuestionId, prefix+"_POSTAL_CODE_QUESTION_ID") + setStringFromEnv(&cfg.StateQuestionId, prefix+"_STATE_QUESTION_ID") + setStringFromEnv(&cfg.CountryQuestionId, prefix+"_COUNTRY_QUESTION_ID") + if value := strings.TrimSpace(os.Getenv(prefix + "_ADDITIONAL_REQUIRED_QUESTION_IDS")); value != "" { + if questionIds := splitNonEmpty(value); len(questionIds) > 0 { + cfg.AdditionalRequiredQuestionIds = questionIds + } + } + return nil +} + +func setStringFromEnv(destination *string, name string) { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + *destination = value + } +} + +func setPositiveIntFromEnv(destination *int, name string) error { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return nil + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return fmt.Errorf("%s must be a positive integer", name) + } + *destination = parsed + return nil +} + +func splitNonEmpty(value string) []string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if normalized := strings.TrimSpace(part); normalized != "" { + result = append(result, normalized) + } + } + return result +} + func (c *GeneralConfig) GetBuyLicenseInvoiceCronJobTiming(nodeAddress string) (string, bool) { nodeTiming, found := c.BuyLicenseInvoiceCronJobTiming[nodeAddress] return nodeTiming, found diff --git a/config/config.mainnet.json b/config/config.mainnet.json index b14bc8a..809cbc8 100644 --- a/config/config.mainnet.json +++ b/config/config.mainnet.json @@ -27,6 +27,13 @@ "ConfirmUrl": "https://app.ratio1.ai/confirm-email?token=%s", "FromEmail": "noreply@ratio1.ai" }, + "Verification": { + "Provider": "sumsub", + "LegacySumsubWebhooksEnabled": true, + "WorkerPollSeconds": 2, + "WorkerBatchSize": 20, + "WorkerMaxAttempts": 10 + }, "Sumsub": { "ApiUrl": "https://api.sumsub.com", "ApiEndpoint": "/resources/accessTokens/sdk", @@ -36,6 +43,15 @@ "SumsubSecretKey": "", "SumsubJwtSecretKey": "" }, + "Didit": { + "ApiUrl": "https://verification.didit.me", + "Environment": "production", + "CallbackUrl": "https://app.ratio1.ai/profile", + "KycWorkflowId": "", + "KycWorkflowVersion": 0, + "KybWorkflowId": "", + "KybWorkflowVersion": 0 + }, "MailerLite": { "Url": "https://connect.mailerlite.com/api", "GroupId": "", diff --git a/config/config.testnet.json b/config/config.testnet.json index be6868f..0e6a41b 100644 --- a/config/config.testnet.json +++ b/config/config.testnet.json @@ -27,6 +27,13 @@ "ConfirmUrl": "https://testnet-app.ratio1.ai/confirm-email?token=%s", "FromEmail": "noreply@ratio1.ai" }, + "Verification": { + "Provider": "sumsub", + "LegacySumsubWebhooksEnabled": true, + "WorkerPollSeconds": 2, + "WorkerBatchSize": 20, + "WorkerMaxAttempts": 10 + }, "Sumsub": { "ApiUrl": "https://api.sumsub.com", "ApiEndpoint": "/resources/accessTokens/sdk", @@ -36,6 +43,15 @@ "SumsubSecretKey": "", "SumsubJwtSecretKey": "" }, + "Didit": { + "ApiUrl": "https://verification.didit.me", + "Environment": "sandbox", + "CallbackUrl": "https://testnet-app.ratio1.ai/profile", + "KycWorkflowId": "e7ea8679-898e-4439-bbd0-79246d6eb628", + "KycWorkflowVersion": 1, + "KybWorkflowId": "44fe0ecb-9a11-4717-aed5-c11b73c8a07c", + "KybWorkflowVersion": 1 + }, "MailerLite": { "Url": "https://connect.mailerlite.com/api", "GroupId": "", diff --git a/config/didit_verification_config_test.go b/config/didit_verification_config_test.go new file mode 100644 index 0000000..ed08af3 --- /dev/null +++ b/config/didit_verification_config_test.go @@ -0,0 +1,84 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOverrideDiditConfigFromEnvironmentMapsWorkflowAndQuestionnaireFields(t *testing.T) { + t.Setenv("DIDIT_APPLICATION_ID", " application-id ") + t.Setenv("DIDIT_ENVIRONMENT", " sandbox ") + t.Setenv("DIDIT_CALLBACK_URL", " https://app.example.test/verification ") + t.Setenv("DIDIT_KYC_WORKFLOW_VERSION", "7") + t.Setenv("DIDIT_KYB_WORKFLOW_VERSION", "8") + t.Setenv("DIDIT_KYC_QUESTIONNAIRE_ID", " kyc-questionnaire ") + t.Setenv("DIDIT_KYC_QUESTIONNAIRE_VERSION", "9") + t.Setenv("DIDIT_KYC_FIRST_NAME_QUESTION_ID", " first-name ") + t.Setenv("DIDIT_KYC_LAST_NAME_QUESTION_ID", " last-name ") + t.Setenv("DIDIT_KYB_COMPANY_NAME_QUESTION_ID", " company-name ") + t.Setenv("DIDIT_KYB_TAX_ID_QUESTION_ID", " tax-id ") + t.Setenv("DIDIT_KYB_ADDRESS_QUESTION_ID", " address ") + t.Setenv("DIDIT_KYB_ADDITIONAL_REQUIRED_QUESTION_IDS", " first-extra, , second-extra ") + + cfg := DiditConfig{} + require.NoError(t, overrideDiditConfigFromEnvironment(&cfg)) + + require.Equal(t, "application-id", cfg.ApplicationId) + require.Equal(t, "sandbox", cfg.Environment) + require.Equal(t, "https://app.example.test/verification", cfg.CallbackUrl) + require.Equal(t, 7, cfg.KycWorkflowVersion) + require.Equal(t, 8, cfg.KybWorkflowVersion) + require.Equal(t, "kyc-questionnaire", cfg.KycQuestionnaire.QuestionnaireId) + require.Equal(t, 9, cfg.KycQuestionnaire.QuestionnaireVersion) + require.Equal(t, "first-name", cfg.KycQuestionnaire.FirstNameQuestionId) + require.Equal(t, "last-name", cfg.KycQuestionnaire.LastNameQuestionId) + require.Equal(t, "company-name", cfg.KybQuestionnaire.CompanyNameQuestionId) + require.Equal(t, "tax-id", cfg.KybQuestionnaire.TaxIdQuestionId) + require.Equal(t, "address", cfg.KybQuestionnaire.AddressQuestionId) + require.Equal(t, []string{"first-extra", "second-extra"}, cfg.KybQuestionnaire.AdditionalRequiredQuestionIds) +} + +func TestOverrideDiditConfigRejectsMalformedOrNonPositiveNumericOverrides(t *testing.T) { + tests := []struct { + name string + value string + }{ + {name: "DIDIT_KYC_WORKFLOW_VERSION", value: "invalid"}, + {name: "DIDIT_KYB_WORKFLOW_VERSION", value: "0"}, + {name: "DIDIT_KYC_QUESTIONNAIRE_VERSION", value: "-1"}, + {name: "DIDIT_KYB_QUESTIONNAIRE_VERSION", value: "1.5"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv(test.name, test.value) + + err := overrideDiditConfigFromEnvironment(&DiditConfig{}) + require.ErrorContains(t, err, test.name+" must be a positive integer") + }) + } +} + +func TestOverrideDiditConfigIgnoresEmptyOverrides(t *testing.T) { + t.Setenv("DIDIT_APPLICATION_ID", " ") + t.Setenv("DIDIT_KYB_ADDITIONAL_REQUIRED_QUESTION_IDS", " , ") + + cfg := DiditConfig{ + ApplicationId: "existing-application", + KycWorkflowVersion: 3, + KybWorkflowVersion: 4, + KycQuestionnaire: DiditQuestionnaireConfig{ + QuestionnaireVersion: 5, + }, + KybQuestionnaire: DiditQuestionnaireConfig{ + AdditionalRequiredQuestionIds: []string{"existing-extra"}, + }, + } + require.NoError(t, overrideDiditConfigFromEnvironment(&cfg)) + + require.Equal(t, "existing-application", cfg.ApplicationId) + require.Equal(t, 3, cfg.KycWorkflowVersion) + require.Equal(t, 4, cfg.KybWorkflowVersion) + require.Equal(t, 5, cfg.KycQuestionnaire.QuestionnaireVersion) + require.Equal(t, []string{"existing-extra"}, cfg.KybQuestionnaire.AdditionalRequiredQuestionIds) +} diff --git a/deployScripts/deploy_devnet.py b/deployScripts/deploy_devnet.py index 002b31b..90e10e5 100644 --- a/deployScripts/deploy_devnet.py +++ b/deployScripts/deploy_devnet.py @@ -41,6 +41,35 @@ 'SUMSUB_SECRET_KEY':'', 'SUMSUB_JWT_SECRET_KEY':'', + 'VERIFICATION_PROVIDER':'sumsub', + 'LEGACY_SUMSUB_WEBHOOKS_ENABLED':'true', + + 'DIDIT_API_URL':'https://verification.didit.me', + 'DIDIT_API_KEY':'', + 'DIDIT_ENVIRONMENT':'sandbox', + 'DIDIT_APPLICATION_ID':'', + 'DIDIT_CALLBACK_URL':'https://devnet-app.ratio1.ai/profile', + 'DIDIT_KYC_WORKFLOW_ID':'', + 'DIDIT_KYC_WORKFLOW_VERSION':'', + 'DIDIT_KYB_WORKFLOW_ID':'', + 'DIDIT_KYB_WORKFLOW_VERSION':'', + 'DIDIT_WEBHOOK_SECRET':'', + 'DIDIT_PREVIOUS_WEBHOOK_SECRET':'', + 'DIDIT_KYC_QUESTIONNAIRE_ID':'', + 'DIDIT_KYC_QUESTIONNAIRE_VERSION':'', + 'DIDIT_KYC_FIRST_NAME_QUESTION_ID':'', + 'DIDIT_KYC_LAST_NAME_QUESTION_ID':'', + 'DIDIT_KYC_TAX_ID_QUESTION_ID':'', + 'DIDIT_KYC_ADDRESS_QUESTION_ID':'', + 'DIDIT_KYC_CITY_QUESTION_ID':'', + 'DIDIT_KYC_POSTAL_CODE_QUESTION_ID':'', + 'DIDIT_KYC_STATE_QUESTION_ID':'', + 'DIDIT_KYC_COUNTRY_QUESTION_ID':'', + 'DIDIT_KYC_ADDITIONAL_REQUIRED_QUESTION_IDS':'', + 'DIDIT_KYB_QUESTIONNAIRE_ID':'', + 'DIDIT_KYB_QUESTIONNAIRE_VERSION':'', + 'DIDIT_KYB_ADDITIONAL_REQUIRED_QUESTION_IDS':'', + 'EMAIL_TEMPLATES_PATH':'./templates/html/', 'EE_CHAINSTORE_API_URL':'', } diff --git a/deployScripts/deploy_mainnet.py b/deployScripts/deploy_mainnet.py index d3c64e6..d114508 100644 --- a/deployScripts/deploy_mainnet.py +++ b/deployScripts/deploy_mainnet.py @@ -41,6 +41,35 @@ 'SUMSUB_SECRET_KEY':'', 'SUMSUB_JWT_SECRET_KEY':'', + 'VERIFICATION_PROVIDER':'sumsub', + 'LEGACY_SUMSUB_WEBHOOKS_ENABLED':'true', + + 'DIDIT_API_URL':'https://verification.didit.me', + 'DIDIT_API_KEY':'', + 'DIDIT_ENVIRONMENT':'production', + 'DIDIT_APPLICATION_ID':'', + 'DIDIT_CALLBACK_URL':'https://app.ratio1.ai/profile', + 'DIDIT_KYC_WORKFLOW_ID':'', + 'DIDIT_KYC_WORKFLOW_VERSION':'', + 'DIDIT_KYB_WORKFLOW_ID':'', + 'DIDIT_KYB_WORKFLOW_VERSION':'', + 'DIDIT_WEBHOOK_SECRET':'', + 'DIDIT_PREVIOUS_WEBHOOK_SECRET':'', + 'DIDIT_KYC_QUESTIONNAIRE_ID':'', + 'DIDIT_KYC_QUESTIONNAIRE_VERSION':'', + 'DIDIT_KYC_FIRST_NAME_QUESTION_ID':'', + 'DIDIT_KYC_LAST_NAME_QUESTION_ID':'', + 'DIDIT_KYC_TAX_ID_QUESTION_ID':'', + 'DIDIT_KYC_ADDRESS_QUESTION_ID':'', + 'DIDIT_KYC_CITY_QUESTION_ID':'', + 'DIDIT_KYC_POSTAL_CODE_QUESTION_ID':'', + 'DIDIT_KYC_STATE_QUESTION_ID':'', + 'DIDIT_KYC_COUNTRY_QUESTION_ID':'', + 'DIDIT_KYC_ADDITIONAL_REQUIRED_QUESTION_IDS':'', + 'DIDIT_KYB_QUESTIONNAIRE_ID':'', + 'DIDIT_KYB_QUESTIONNAIRE_VERSION':'', + 'DIDIT_KYB_ADDITIONAL_REQUIRED_QUESTION_IDS':'', + 'MAILERLITE_GROUP_ID':'', 'MAILERLITE_API_KEY':'', diff --git a/deployScripts/deploy_testnet.py b/deployScripts/deploy_testnet.py index 002b31b..35de59d 100644 --- a/deployScripts/deploy_testnet.py +++ b/deployScripts/deploy_testnet.py @@ -41,6 +41,35 @@ 'SUMSUB_SECRET_KEY':'', 'SUMSUB_JWT_SECRET_KEY':'', + 'VERIFICATION_PROVIDER':'sumsub', + 'LEGACY_SUMSUB_WEBHOOKS_ENABLED':'true', + + 'DIDIT_API_URL':'https://verification.didit.me', + 'DIDIT_API_KEY':'', + 'DIDIT_ENVIRONMENT':'sandbox', + 'DIDIT_APPLICATION_ID':'', + 'DIDIT_CALLBACK_URL':'https://testnet-app.ratio1.ai/profile', + 'DIDIT_KYC_WORKFLOW_ID':'', + 'DIDIT_KYC_WORKFLOW_VERSION':'', + 'DIDIT_KYB_WORKFLOW_ID':'', + 'DIDIT_KYB_WORKFLOW_VERSION':'', + 'DIDIT_WEBHOOK_SECRET':'', + 'DIDIT_PREVIOUS_WEBHOOK_SECRET':'', + 'DIDIT_KYC_QUESTIONNAIRE_ID':'', + 'DIDIT_KYC_QUESTIONNAIRE_VERSION':'', + 'DIDIT_KYC_FIRST_NAME_QUESTION_ID':'', + 'DIDIT_KYC_LAST_NAME_QUESTION_ID':'', + 'DIDIT_KYC_TAX_ID_QUESTION_ID':'', + 'DIDIT_KYC_ADDRESS_QUESTION_ID':'', + 'DIDIT_KYC_CITY_QUESTION_ID':'', + 'DIDIT_KYC_POSTAL_CODE_QUESTION_ID':'', + 'DIDIT_KYC_STATE_QUESTION_ID':'', + 'DIDIT_KYC_COUNTRY_QUESTION_ID':'', + 'DIDIT_KYC_ADDITIONAL_REQUIRED_QUESTION_IDS':'', + 'DIDIT_KYB_QUESTIONNAIRE_ID':'', + 'DIDIT_KYB_QUESTIONNAIRE_VERSION':'', + 'DIDIT_KYB_ADDITIONAL_REQUIRED_QUESTION_IDS':'', + 'EMAIL_TEMPLATES_PATH':'./templates/html/', 'EE_CHAINSTORE_API_URL':'', } diff --git a/docs/didit-migration-runbook.md b/docs/didit-migration-runbook.md new file mode 100644 index 0000000..094e0c6 --- /dev/null +++ b/docs/didit-migration-runbook.md @@ -0,0 +1,244 @@ +# Didit KYC/KYB migration runbook + +This runbook prepares the one-time Ratio1 cutover from Sumsub onboarding to Didit. It does not authorize a production deployment or database write. Production execution requires an approved change window, a fresh backup, reviewed dry-run evidence, named operators, and explicit approval for the exact counts. + +## Safety invariants + +- New applicants use exactly one provider. Sumsub session creation is disabled when Didit is activated. +- Existing `approved` and `finalRejected` records are preserved and marked as Sumsub. +- Every other KYC state is reset to `accCreated`, marked as Didit, and starts a new Didit session. +- `user_infos` are preserved for the approved/final-rejected cohort and deleted only for the reset cohort, so stale invoicing identities cannot survive a new verification. +- The internal KYC UUID is Didit `vendor_data`. +- Webhook arrival order is never state truth. Store and deduplicate `event_id`, then reconcile the authoritative Didit decision. +- Grandfathered Sumsub monitoring events persist only the normalized transition + fields required for restart recovery; raw webhook bodies are not stored. +- Country restrictions remain the approved Sumsub-equivalent lists. +- Minimum age remains jurisdiction-specific, including country and state overrides. Do not introduce a global age of 18. + +## Preconditions + +Before scheduling the cutover: + +1. Merge and deploy the schema and backend support with Didit session creation inactive. +2. Confirm the `kycs.verification_provider` column exists. The cutover CLI deliberately does not run `AutoMigrate`. +3. Publish and lock the production KYC and KYB workflows and record their exact versions. +4. Confirm all KYC/KYB features and questionnaire versions. KYC billing fields remain questionnaire-backed; the KYB questionnaire contains Source-of-Funds fields only. +5. Confirm document, residence and company-country restrictions. +6. In both sandbox and production KYC workflows, click Didit's **Apply age of majority**, review every country and state/region override, and configure below-minimum results to **Decline**. The current sandbox workflow was observed with a global age of 18 and is an explicit activation blocker until corrected. +7. In the native KYB Registry Fields configuration, confirm company name, country and region are collected; set `legal_address` and `tax_number` to Required and `vat_number` to Required (Didit enforces it conditionally for EU companies). Do not duplicate these fields in the Source-of-Funds questionnaire. +8. Complete Sandbox acceptance and production Try Webhook tests. +9. Confirm a stable public HTTPS webhook URL and fixed dApp callback URL. +10. Confirm provider billing balance, ongoing-monitoring settings and alerts. +11. Announce a verification maintenance window that prevents KYC creation and mutation during the transaction. + +## Production secrets and provider configuration + +Keep all values in the production secret store. Never put values in Git, a PR, Notion, chat, shell history or frontend `VITE_` variables. + +Required runtime configuration: + +- Active verification provider: `didit` +- `DIDIT_API_URL` +- Didit production environment/application identifier +- `DIDIT_API_KEY` +- production KYC workflow ID and expected version +- production KYB workflow ID and expected version +- production KYC questionnaire billing-field IDs and expected version +- production KYB Source-of-Funds questionnaire ID, expected version and required answer IDs +- `DIDIT_WEBHOOK_SECRET` +- stable Didit webhook URL +- fixed Ratio1 callback URL + +Use a least-privilege runtime API key. Keep management, workflow-edit and bulk-delete permissions on a separate operator credential. Keep Sumsub credentials only for the restricted grandfathered-monitoring path. + +Create a V3 Didit webhook destination and subscribe to: + +- `status.updated` +- `data.updated` +- `user.status.updated` +- `user.data.updated` +- `business.status.updated` +- `business.data.updated` + +Store the destination secret when it is created; it is shown once. Didit currently documents `18.203.201.92` as its delivery IP. Allow it through the WAF if required, but never replace timestamp and HMAC verification with an IP check. Prefer `X-Signature-V2`; raw HMAC is an authenticated fallback. A Simple signature may only trigger an authoritative re-fetch and must not make a decision payload trusted. + +## Backup and read-only dry run + +Take a fresh, restorable database backup and record its identifier outside this repository. Confirm restoration has been rehearsed. + +The CLI reads either the existing ignored `DATABASE_LINK` value in `host:port:database:user:password` form or the five standard `DATABASE_*` variables. Remote connections require `DATABASE_SSLMODE=require`, `verify-ca`, or `verify-full`. Do not print or source credentials. + +Build and run the dry-run from the repository root: + +```bash +go build -o /tmp/ratio1-verification-cutover ./cmd/verification-cutover +/tmp/ratio1-verification-cutover +``` + +Dry-run is the default. It starts a read-only transaction, prints aggregates only, and exits without updating data. It refuses: + +- unknown KYC statuses; +- duplicate UUID or email groups; +- null, blank or whitespace-altered identities; +- any pre-existing Didit provider row, because that indicates Didit traffic has already started; +- any approved KYC row without a matching `user_infos` identity; +- supplied expected counts that do not exactly match. + +Optionally compare a reviewed expectation during dry-run: + +```bash +/tmp/ratio1-verification-cutover \ + --expected-total \ + --expected-preserved \ + --expected-reset \ + --expected-reset-user-infos +``` + +A prior read-only snapshot contained 215 KYC rows: 84 preserved and 131 reset. This is a historical example only, not an apply default or an acceptable substitute for a fresh cutover-window reading. + +Review and archive: + +- total, approved, final-rejected, preserved and reset counts; +- every status aggregate; +- current provider aggregates; +- duplicate and invalid-identity counts; +- total `user_infos`, reset-cohort `user_infos`, and approved rows missing `user_infos`; +- number of reset rows containing legacy applicant/country/VIES data. + +## Apply transaction + +Do not apply while the API can create or mutate KYC records. + +The apply command requires all four reviewed counts: + +```bash +/tmp/ratio1-verification-cutover \ + --apply \ + --expected-total \ + --expected-preserved \ + --expected-reset \ + --expected-reset-user-infos +``` + +Apply behavior: + +1. Opens a serializable transaction. +2. Locks all current KYC rows. +3. Re-runs every preflight check and exact-count comparison. +4. Marks preserved `approved`/`finalRejected` records as `sumsub`. +5. Resets all other records to `accCreated`. +6. Clears reset rows' Sumsub applicant ID, applicant type, country and VIES flag. +7. Marks reset rows as `didit`. +8. Deletes only `user_infos` matched to reset-cohort KYC records and verifies the exact reviewed deletion count. +9. Re-runs aggregates and verifies preserved identities remain while no reset-cohort `user_infos` remain. +10. Commits only if every invariant passes. + +Archive the before/after output and transaction result. A non-zero exit means no successful commit unless the output explicitly contains `result=applied_and_committed`; investigate before any retry. + +## Maintenance-window cutover + +1. Enter verification maintenance mode. +2. Confirm no KYC writes or new provider sessions are occurring. +3. Take the final backup. +4. Run and approve the final dry-run. +5. Apply with the exact final counts. +6. Activate the server-side Didit provider switch. +7. Disable Sumsub session creation. +8. Keep Didit webhook processing active. +9. Keep only grandfathered Sumsub ongoing monitoring active. +10. Deploy/activate the provider-neutral dApp. +11. Complete the smoke tests below. +12. Exit maintenance and begin heightened monitoring. + +Opening or merging the migration PRs must not itself activate production traffic. + +## Acceptance and smoke tests + +At minimum verify: + +- Existing approved individual and business accounts remain approved, can read their profile, and retain license/purchase access. +- Existing final-rejected accounts remain blocked and cannot start a session. +- A reset individual can choose KYC, receives one Didit session, completes the hosted flow, and reaches the expected internal state. +- A reset company can choose KYB, supplies full invoicing identity, completes the hosted flow, and reaches the expected internal state. +- Duplicate clicks/reloads resume one active session. +- A terminal declined, expired or abandoned Didit session remains blocked until + authoritative reconciliation marks both the session and local KYC retryable; + the next request then creates one replacement session. `finalRejected` never + becomes retryable. +- Restricted document, residence and company countries fail as designed. +- The Didit **Apply age of majority** policy is active in sandbox and production, below-minimum action is Decline, and representative 18/19/20/21 country/state boundaries behave as configured. +- Retryable document, liveness, proof-of-address and KYB-document failures permit retry. +- Confirmed AML/sanctions, blocked entities and jurisdictional minimum-age failures revoke access. +- `FLAGGED` maps to `onHold`; `BLOCKED` maps to `finalRejected`; `ACTIVE` never grants approval by itself. +- V2/raw/Simple webhook paths, exact replay, payload mismatch, out-of-order events and provider timeouts behave safely. +- Approval creates complete `UserInfo`; tax/VAT and VIES behavior match invoicing requirements. +- KYB invoicing data comes from approved native registry company data; the questionnaire contains only Source-of-Funds answers. +- Approved/rejected email notifications use a durable transition key and are + normally deduplicated, but delivery is at-least-once: a process crash after + the mail provider accepts a message and before the database marks it sent can + produce a duplicate. +- Mobile/desktop callback, refresh, back navigation and delayed webhook UX work. + +## Monitoring and alerts + +Monitor without logging webhook bodies or names, emails, documents, addresses or tax IDs: + +- session creation successes, latency and errors by environment/type; +- signature failures, stale timestamps and oversized webhook bodies; +- received, duplicate, mismatch, processed, failed and dead-letter events; +- webhook queue depth and oldest-event age; +- reconciliation latency and Didit `401`, `403`, `429` and `5xx`; +- policy/workflow mismatch, incomplete evidence and unknown warning projections; +- abnormal approval, rejection and on-hold rates; +- notification failures, retries, deduplication and sent-but-unacknowledged + crash windows; manually reconcile ambiguous deliveries with the mail + provider before replaying; +- no-webhook-with-active-sessions; +- provider balance/credit; +- grandfathered Sumsub monitoring events and approaching retirement dates. + +Keep runbooks for event replay, stuck queue recovery, manual reconciliation, secret rotation, policy drift, provider outage and compliance review of `onHold`. + +## Rollback + +Before any Didit session is created: + +1. Re-enter maintenance. +2. Disable Didit. +3. Restore the reviewed database backup or rollback the verified migration according to the approved change plan. +4. Re-enable Sumsub onboarding only after confirming the dApp/backend contract and legacy state. + +After any Didit traffic exists: + +1. Disable new verification session creation. +2. Continue accepting and processing existing Didit webhooks. +3. Keep affected users in their current safe internal states. +4. Fix forward or perform reviewed manual remediation. + +Never send pending Didit applicants back into Sumsub automatically. Doing so creates overlapping provider identities and ambiguous state. + +## Privacy, legal and retention gates + +Before production approval: + +- Execute/review the Didit DPA, technical and organizational measures, subprocessors, processing region and incident terms. +- Confirm Ratio1's legal basis and controller responsibilities. +- Update the Ratio1 privacy notice and verification disclosures, including links to Didit's end-user privacy notice and terms. +- Document data categories, AML/KYB processing, automated checks, transfers, appeal/human-review paths and data-subject contacts. +- Configure an explicit Didit retention period; do not leave the unlimited default unintentionally. +- Decide whether to process and purge Didit sessions after extracting the minimum compliance/invoicing evidence. +- Document erasure, export and provider-session deletion procedures. +- Define Ratio1 retention for provider/session IDs, event hashes, projection/audit metadata and `UserInfo`. +- Define rejected/minor-applicant retention. +- Confirm the compliance basis, duration and cost of ongoing monitoring. + +## Grandfathered Sumsub monitoring retirement + +Sumsub remains only for ongoing monitoring of preserved approved users until their approved expiry/re-verification policy is satisfied. + +- Do not create new Sumsub sessions. +- Do not let legacy Sumsub events approve, reset or make a final rejection retryable. +- Allow validated monitoring events only to suspend or revoke access. +- Track the remaining grandfathered population and its retirement date. +- Re-verify in Didit when policy requires. +- When the last grandfathered record is retired, disable the Sumsub destination, rotate/remove credentials, delete the legacy monitoring code in a reviewed follow-up, and confirm no Sumsub traffic remains. diff --git a/go.mod b/go.mod index b875649..4c8976f 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/ElrondNetwork/elrond-go-logger v1.0.6 github.com/ElrondNetwork/elrond-sdk-erdgo v1.1.4 github.com/GoRoadster/go-log v1.0.0 + github.com/biter777/countries v1.7.5 github.com/gin-contrib/cors v1.4.0 github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v4 v4.5.0 diff --git a/go.sum b/go.sum index e5c1b36..d9e83f8 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,6 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/Ratio1/edge_sdk_go v0.0.12 h1:95YfmKQ1r/99EsErfHzBrFX1CTEdU0or6nEG7NmTlqo= github.com/Ratio1/edge_sdk_go v0.0.12/go.mod h1:DBxREssSVfJlwiYihzmteevk8VuxFtWuEhiJQqjwOac= -github.com/Ratio1/ratio1_sdk_go v0.0.11 h1:u5RMxSC8IUaHgV+8efYlAyzMIasBjg880UDSqM9Cts4= -github.com/Ratio1/ratio1_sdk_go v0.0.11/go.mod h1:SyxrKWkJpU1h/2Cs6ssNLZfv5qasCUATYNullCXaCwc= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= @@ -93,6 +91,8 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q= +github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E= github.com/bits-and-blooms/bitset v1.10.0 h1:ePXTeiPEazB5+opbv5fr8umg2R/1NlzgDsyepwsSr88= github.com/bits-and-blooms/bitset v1.10.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= diff --git a/model/didit.go b/model/didit.go new file mode 100644 index 0000000..95945aa --- /dev/null +++ b/model/didit.go @@ -0,0 +1,271 @@ +package model + +import ( + "encoding/json" + + "github.com/google/uuid" +) + +type DiditSessionKind string + +const ( + DiditSessionKindUser DiditSessionKind = "user" + DiditSessionKindBusiness DiditSessionKind = "business" +) + +type DiditSessionStatus string + +const ( + DiditStatusNotStarted DiditSessionStatus = "Not Started" + DiditStatusInProgress DiditSessionStatus = "In Progress" + DiditStatusAwaitingUser DiditSessionStatus = "Awaiting User" + DiditStatusInReview DiditSessionStatus = "In Review" + DiditStatusApproved DiditSessionStatus = "Approved" + DiditStatusDeclined DiditSessionStatus = "Declined" + DiditStatusResubmitted DiditSessionStatus = "Resubmitted" + DiditStatusExpired DiditSessionStatus = "Expired" + DiditStatusKycExpired DiditSessionStatus = "Kyc Expired" + DiditStatusAbandoned DiditSessionStatus = "Abandoned" +) + +type DiditCreateSessionRequest struct { + WorkflowId uuid.UUID `json:"workflow_id"` + VendorData string `json:"vendor_data"` + ExpectedSessionKind DiditSessionKind `json:"-"` + Callback string `json:"callback,omitempty"` + CallbackMethod string `json:"callback_method,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + Language string `json:"language,omitempty"` + ContactDetails *DiditContactDetails `json:"contact_details,omitempty"` + ExpectedDetails *DiditExpectedDetails `json:"expected_details,omitempty"` + SandboxScenario string `json:"sandbox_scenario,omitempty"` +} + +type DiditContactDetails struct { + Email string `json:"email,omitempty"` + SendNotificationEmails bool `json:"send_notification_emails,omitempty"` + EmailLang string `json:"email_lang,omitempty"` + Phone string `json:"phone,omitempty"` +} + +type DiditExpectedDetails struct { + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + DateOfBirth string `json:"date_of_birth,omitempty"` + Nationality string `json:"nationality,omitempty"` + Country string `json:"country,omitempty"` + IdCountry string `json:"id_country,omitempty"` + PoaCountry string `json:"poa_country,omitempty"` + Address string `json:"address,omitempty"` + IdentificationNumber string `json:"identification_number,omitempty"` + ExpectedDocumentTypes []string `json:"expected_document_types,omitempty"` + CompanyName string `json:"company_name,omitempty"` + RegistryCountry string `json:"registry_country,omitempty"` + RegistrationNumber string `json:"registration_number,omitempty"` +} + +type DiditCreateSessionResponse struct { + SessionId uuid.UUID `json:"session_id"` + SessionKind DiditSessionKind `json:"session_kind"` + SessionNumber int64 `json:"session_number"` + SessionToken string `json:"session_token"` + Url string `json:"url"` + VendorData string `json:"vendor_data"` + Metadata json.RawMessage `json:"metadata"` + Status DiditSessionStatus `json:"status"` + WorkflowId uuid.UUID `json:"workflow_id"` + WorkflowVersion int `json:"workflow_version"` + Callback *string `json:"callback"` +} + +type DiditDecisionExpectation struct { + VendorData string + WorkflowId uuid.UUID + SessionKind DiditSessionKind +} + +type DiditDecision struct { + SessionId uuid.UUID `json:"session_id"` + SessionKind DiditSessionKind `json:"session_kind"` + SessionNumber *int64 `json:"session_number"` + SessionUrl string `json:"session_url"` + Status DiditSessionStatus `json:"status"` + DecisionReasonCode string `json:"decision_reason_code"` + RiskLevel string `json:"risk_level"` + OwnershipComplexity string `json:"ownership_complexity"` + WorkflowId uuid.UUID `json:"workflow_id"` + VendorData string `json:"vendor_data"` + Features []string `json:"features"` + Metadata json.RawMessage `json:"metadata"` + ExpectedDetails json.RawMessage `json:"expected_details"` + ContactDetails json.RawMessage `json:"contact_details"` + Callback *string `json:"callback"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + Environment string `json:"environment"` + IdVerifications []DiditIdVerification `json:"id_verifications"` + NfcVerifications []DiditFeatureResult `json:"nfc_verifications"` + LivenessChecks []DiditFeatureResult `json:"liveness_checks"` + FaceMatches []DiditFeatureResult `json:"face_matches"` + PoaVerifications []DiditPoaVerification `json:"poa_verifications"` + PhoneVerifications []DiditFeatureResult `json:"phone_verifications"` + EmailVerifications []DiditFeatureResult `json:"email_verifications"` + DocumentAiDocuments []DiditDocumentAiResult `json:"document_ai_documents"` + AmlScreenings []DiditAmlScreening `json:"aml_screenings"` + IpAnalyses []DiditFeatureResult `json:"ip_analyses"` + DatabaseValidations []DiditFeatureResult `json:"database_validations"` + QuestionnaireResponses []DiditQuestionnaireResponse `json:"questionnaire_responses"` + RegistryChecks []DiditRegistryCheck `json:"registry_checks"` + DocumentVerifications []DiditDocumentVerification `json:"document_verifications"` + KeyPeopleChecks []DiditKeyPeopleCheck `json:"key_people_checks"` + Reviews []DiditReview `json:"reviews"` +} + +type DiditWarning struct { + Feature string `json:"feature"` + Risk string `json:"risk"` + AdditionalData json.RawMessage `json:"additional_data"` + LogType string `json:"log_type"` + ShortDescription string `json:"short_description"` + LongDescription string `json:"long_description"` + NodeId string `json:"node_id"` +} + +type DiditFeatureResult struct { + NodeId string `json:"node_id"` + Status string `json:"status"` + Warnings []DiditWarning `json:"warnings"` +} + +type DiditIdVerification struct { + NodeId string `json:"node_id"` + Status string `json:"status"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + FullName string `json:"full_name"` + DateOfBirth string `json:"date_of_birth"` + Age *int `json:"age"` + Nationality string `json:"nationality"` + IssuingState string `json:"issuing_state"` + ExpirationDate string `json:"expiration_date"` + DocumentType string `json:"document_type"` + DocumentSubtype string `json:"document_subtype"` + Warnings []DiditWarning `json:"warnings"` +} + +type DiditPoaVerification struct { + NodeId string `json:"node_id"` + Status string `json:"status"` + NameOnDocument string `json:"name_on_document"` + PoaAddress string `json:"poa_address"` + PoaFormattedAddress string `json:"poa_formatted_address"` + IssuingState string `json:"issuing_state"` + IssueDate string `json:"issue_date"` + ExpirationDate string `json:"expiration_date"` + Warnings []DiditWarning `json:"warnings"` +} + +type DiditDocumentAiResult struct { + NodeId string `json:"node_id"` + Status string `json:"status"` + Items json.RawMessage `json:"items"` + Warnings []DiditWarning `json:"warnings"` +} + +type DiditAmlScreening struct { + NodeId string `json:"node_id"` + Status string `json:"status"` + TotalHits *int `json:"total_hits"` + EntityType string `json:"entity_type"` + Hits []DiditAmlHit `json:"hits"` + Score *float64 `json:"score"` + ScreenedData json.RawMessage `json:"screened_data"` + IsOngoingMonitoringEnabled bool `json:"is_ongoing_monitoring_enabled"` + NextOngoingMonitoringBillDate *string `json:"next_ongoing_monitoring_bill_date"` + Warnings []DiditWarning `json:"warnings"` +} + +type DiditAmlHit struct { + Id string `json:"id"` + Url string `json:"url"` + Match bool `json:"match"` + Score *float64 `json:"score"` + Target *bool `json:"target"` + Caption string `json:"caption"` + Datasets []string `json:"datasets"` + MatchScore *float64 `json:"match_score"` + RiskScore float64 `json:"risk_score"` + ReviewStatus string `json:"review_status"` + SanctionMatches json.RawMessage `json:"sanction_matches"` + PepMatches json.RawMessage `json:"pep_matches"` + WarningMatches json.RawMessage `json:"warning_matches"` + AdverseMediaMatches json.RawMessage `json:"adverse_media_matches"` +} + +type DiditQuestionnaireResponse struct { + NodeId string `json:"node_id"` + Status string `json:"status"` + QuestionnaireId uuid.UUID `json:"questionnaire_id"` + QuestionnaireGroupId uuid.UUID `json:"questionnaire_group_id"` + Version int `json:"version"` + Sections []DiditQuestionnaireSection `json:"sections"` +} + +type DiditQuestionnaireSection struct { + Title json.RawMessage `json:"title"` + Description json.RawMessage `json:"description"` + Items []DiditQuestionnaireResponseItem `json:"items"` +} + +type DiditQuestionnaireResponseItem struct { + Uuid uuid.UUID `json:"uuid"` + ElementType string `json:"element_type"` + IsRequired bool `json:"is_required"` + Answer *DiditQuestionnaireResponseAnswer `json:"answer"` +} + +type DiditQuestionnaireResponseAnswer struct { + Value *string `json:"value"` + Text *string `json:"text"` + Files []string `json:"files"` +} + +type DiditRegistryCheck struct { + NodeId string `json:"node_id"` + Status string `json:"status"` + Company json.RawMessage `json:"company"` + OwnershipStructure json.RawMessage `json:"ownership_structure"` + Warnings []DiditWarning `json:"warnings"` +} + +type DiditDocumentVerification struct { + NodeId string `json:"node_id"` + Status string `json:"status"` + Items json.RawMessage `json:"items"` + Groups json.RawMessage `json:"groups"` + RequiredGroups []string `json:"required_groups"` + Warnings []DiditWarning `json:"warnings"` +} + +type DiditKeyPeopleCheck struct { + NodeId string `json:"node_id"` + Status string `json:"status"` + Registry json.RawMessage `json:"registry"` + Submitted json.RawMessage `json:"submitted"` + UboKycSummary json.RawMessage `json:"ubo_kyc_summary"` + Warnings []DiditWarning `json:"warnings"` +} + +type DiditReview struct { + Comment string `json:"comment"` + CreatedAt string `json:"created_at"` + NewStatus string `json:"new_status"` + User json.RawMessage `json:"user"` +} + +type DiditEntity struct { + DiditInternalId uuid.UUID `json:"didit_internal_id"` + VendorData string `json:"vendor_data"` + Status string `json:"status"` + Features json.RawMessage `json:"features"` +} diff --git a/model/kyc.go b/model/kyc.go index c6f1aeb..3b51b98 100644 --- a/model/kyc.go +++ b/model/kyc.go @@ -7,17 +7,18 @@ import ( ) type Kyc struct { - Uuid uuid.UUID `gorm:"primarykey;unique" json:"uuid"` - ApplicantId string `json:"applicant_id"` - ApplicantType string `json:"applicant_type"` - Email string `json:"email"` - KycStatus string `json:"kyc_status"` - LastUpdated time.Time `json:"last_updated"` - IsActive bool `gorm:"not null;default:true" json:"is_active"` - HasBeenDeleted bool `gorm:"not null;default:false" json:"has_been_deleted"` - ReceiveUpdates *bool `gorm:"not null;default:false" json:"receiveUpdates"` - Country string `json:"country"` - ViesRegistered bool `json:"vies_registered"` + Uuid uuid.UUID `gorm:"primarykey;unique" json:"uuid"` + ApplicantId string `json:"applicant_id"` + ApplicantType string `json:"applicant_type"` + VerificationProvider string `gorm:"type:varchar(32);not null;default:'';index" json:"verification_provider"` + Email string `gorm:"uniqueIndex" json:"email"` + KycStatus string `json:"kyc_status"` + LastUpdated time.Time `json:"last_updated"` + IsActive bool `gorm:"not null;default:true" json:"is_active"` + HasBeenDeleted bool `gorm:"not null;default:false" json:"has_been_deleted"` + ReceiveUpdates *bool `gorm:"not null;default:false" json:"receiveUpdates"` + Country string `json:"country"` + ViesRegistered bool `json:"vies_registered"` } const ( @@ -54,3 +55,7 @@ const ( IndividualCustomer = "individual" BusinessCustomer = "company" ) + +func ShouldPreserveKycOnProviderCutover(status string) bool { + return status == StatusApproved || status == StatusFinalRejected +} diff --git a/model/kyc_test.go b/model/kyc_test.go new file mode 100644 index 0000000..3573448 --- /dev/null +++ b/model/kyc_test.go @@ -0,0 +1,33 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestShouldPreserveKycOnProviderCutover(t *testing.T) { + tests := []struct { + name string + status string + expected bool + }{ + {name: "approved", status: StatusApproved, expected: true}, + {name: "final rejected", status: StatusFinalRejected, expected: true}, + {name: "account created", status: StatusAccountCreated, expected: false}, + {name: "init", status: StatusInit, expected: false}, + {name: "pending", status: StatusPending, expected: false}, + {name: "prechecked", status: StatusPrechecked, expected: false}, + {name: "queued", status: StatusQueued, expected: false}, + {name: "completed", status: StatusCompleted, expected: false}, + {name: "on hold", status: StatusOnHold, expected: false}, + {name: "rejected", status: StatusRejected, expected: false}, + {name: "unknown", status: "unknown", expected: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.expected, ShouldPreserveKycOnProviderCutover(test.status)) + }) + } +} diff --git a/model/verification.go b/model/verification.go new file mode 100644 index 0000000..f39aa59 --- /dev/null +++ b/model/verification.go @@ -0,0 +1,89 @@ +package model + +import ( + "time" + + "github.com/google/uuid" +) + +const ( + VerificationProviderSumsub = "sumsub" + VerificationProviderDidit = "didit" + + VerificationEnvironmentSandbox = "sandbox" + VerificationEnvironmentProduction = "production" + + VerificationEventReceived = "received" + VerificationEventProcessing = "processing" + VerificationEventProcessed = "processed" + VerificationEventFailed = "failed" + VerificationEventDeadLetter = "dead_letter" + + VerificationNotificationPending = "pending" + VerificationNotificationProcessing = "processing" + VerificationNotificationSent = "sent" + VerificationNotificationFailed = "failed" + + VerificationNotificationApproved = "approved" + VerificationNotificationFinalRejected = "final_rejected" + VerificationNotificationRetry = "retry" +) + +type VerificationSession struct { + Uuid uuid.UUID `gorm:"primaryKey;type:text" json:"uuid"` + KycUuid uuid.UUID `gorm:"type:text;not null;index" json:"kyc_uuid"` + Kyc Kyc `gorm:"foreignKey:KycUuid;references:Uuid;constraint:OnUpdate:RESTRICT,OnDelete:RESTRICT" json:"-"` + Provider string `gorm:"type:varchar(32);not null;uniqueIndex:idx_verification_provider_session,priority:1" json:"provider"` + Environment string `gorm:"type:varchar(16);not null;uniqueIndex:idx_verification_provider_session,priority:2" json:"environment"` + ProviderSessionId string `gorm:"type:text;not null;uniqueIndex:idx_verification_provider_session,priority:3" json:"provider_session_id"` + ProviderApplicationId string `gorm:"type:text;index" json:"provider_application_id"` + WorkflowId string `gorm:"type:text" json:"workflow_id"` + WorkflowVersion string `gorm:"type:text" json:"workflow_version"` + ApplicantType string `gorm:"type:varchar(16);not null" json:"applicant_type"` + KycStatus string `gorm:"type:varchar(32);not null" json:"kyc_status"` + ProviderStatus string `gorm:"type:text;not null" json:"provider_status"` + StatusReason string `gorm:"type:text" json:"status_reason"` + DecisionAt *time.Time `json:"decision_at"` + LastReconciledAt *time.Time `json:"last_reconciled_at"` + CreatedAt time.Time `gorm:"not null" json:"created_at"` + UpdatedAt time.Time `gorm:"not null" json:"updated_at"` +} + +type VerificationWebhookEvent struct { + Uuid uuid.UUID `gorm:"primaryKey;type:text" json:"uuid"` + Provider string `gorm:"type:varchar(32);not null;uniqueIndex:idx_verification_provider_event,priority:1" json:"provider"` + Environment string `gorm:"type:varchar(16);not null;uniqueIndex:idx_verification_provider_event,priority:2" json:"environment"` + EventId string `gorm:"type:text;not null;uniqueIndex:idx_verification_provider_event,priority:3" json:"event_id"` + EventType string `gorm:"type:text;not null" json:"event_type"` + ProviderSessionId string `gorm:"type:text;index" json:"provider_session_id"` + ProviderApplicationId string `gorm:"type:text;index" json:"provider_application_id"` + VendorData string `gorm:"type:text;index" json:"vendor_data"` + ProviderStatus string `gorm:"type:text" json:"provider_status"` + StatusReason string `gorm:"type:text" json:"status_reason"` + OccurredAt *time.Time `json:"occurred_at"` + ReceivedAt time.Time `gorm:"not null" json:"received_at"` + PayloadSha256 string `gorm:"type:char(64);not null" json:"payload_sha256"` + ProcessingStatus string `gorm:"type:varchar(16);not null" json:"processing_status"` + Attempts uint `gorm:"not null;default:0" json:"attempts"` + NextAttemptAt *time.Time `gorm:"index" json:"next_attempt_at"` + ClaimedAt *time.Time `gorm:"index" json:"claimed_at"` + ProcessedAt *time.Time `json:"processed_at"` + LastError string `gorm:"type:varchar(1024)" json:"last_error"` + UpdatedAt time.Time `gorm:"not null" json:"updated_at"` +} + +type VerificationNotification struct { + Uuid uuid.UUID `gorm:"primaryKey;type:text" json:"uuid"` + VerificationSessionUuid uuid.UUID `gorm:"type:text;not null;index" json:"verification_session_uuid"` + TransitionKey string `gorm:"type:text;not null;uniqueIndex" json:"transition_key"` + Email string `gorm:"type:text;not null" json:"email"` + NotificationType string `gorm:"type:varchar(32);not null" json:"notification_type"` + ProcessingStatus string `gorm:"type:varchar(16);not null;index" json:"processing_status"` + Attempts uint `gorm:"not null;default:0" json:"attempts"` + NextAttemptAt *time.Time `gorm:"index" json:"next_attempt_at"` + ClaimedAt *time.Time `gorm:"index" json:"claimed_at"` + SentAt *time.Time `json:"sent_at"` + LastError string `gorm:"type:varchar(1024)" json:"last_error"` + CreatedAt time.Time `gorm:"not null" json:"created_at"` + UpdatedAt time.Time `gorm:"not null" json:"updated_at"` +} diff --git a/model/verification_test.go b/model/verification_test.go new file mode 100644 index 0000000..a3ebbb9 --- /dev/null +++ b/model/verification_test.go @@ -0,0 +1,40 @@ +package model + +import ( + "sync" + "testing" + + "gorm.io/gorm/schema" + + "github.com/stretchr/testify/require" +) + +func TestVerificationSessionModelHasProviderScopedUniqueIndex(t *testing.T) { + parsed, err := schema.Parse(&VerificationSession{}, &sync.Map{}, schema.NamingStrategy{}) + require.NoError(t, err) + + indexes := parsed.ParseIndexes() + index, found := indexes["idx_verification_provider_session"] + require.True(t, found) + require.True(t, index.Class == "UNIQUE" || index.Option == "UNIQUE") + require.Equal(t, []string{"provider", "environment", "provider_session_id"}, indexFields(index)) +} + +func TestVerificationWebhookEventModelHasProviderScopedUniqueIndex(t *testing.T) { + parsed, err := schema.Parse(&VerificationWebhookEvent{}, &sync.Map{}, schema.NamingStrategy{}) + require.NoError(t, err) + + indexes := parsed.ParseIndexes() + index, found := indexes["idx_verification_provider_event"] + require.True(t, found) + require.True(t, index.Class == "UNIQUE" || index.Option == "UNIQUE") + require.Equal(t, []string{"provider", "environment", "event_id"}, indexFields(index)) +} + +func indexFields(index schema.Index) []string { + fields := make([]string, 0, len(index.Fields)) + for _, field := range index.Fields { + fields = append(fields, field.DBName) + } + return fields +} diff --git a/proxy/handlers/health.go b/proxy/handlers/health.go new file mode 100644 index 0000000..5dc20c0 --- /dev/null +++ b/proxy/handlers/health.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/config" + "github.com/NaeuralEdgeProtocol/ratio1-backend/storage" + "github.com/gin-gonic/gin" +) + +const baseHealthEndpoint = "/health" + +func NewHealthHandler(groupHandler *groupHandler) { + groupHandler.AddEndpointGroupHandler(EndpointGroupHandler{ + Root: baseHealthEndpoint, + Middleware: []gin.HandlerFunc{}, + EndpointHandlers: []EndpointHandler{ + {Method: http.MethodGet, Path: "/live", HandlerFunc: healthLive}, + {Method: http.MethodGet, Path: "/ready", HandlerFunc: healthReady}, + }, + }) +} + +func healthLive(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "live"}) +} + +func healthReady(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + if err := storage.Ping(ctx); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"status": "not_ready"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "status": "ready", + "verificationProvider": config.Config.Verification.Provider, + }) +} diff --git a/proxy/handlers/sumsub.go b/proxy/handlers/sumsub.go index 549b571..1b0246f 100644 --- a/proxy/handlers/sumsub.go +++ b/proxy/handlers/sumsub.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "strings" + "time" "github.com/NaeuralEdgeProtocol/ratio1-backend/config" "github.com/NaeuralEdgeProtocol/ratio1-backend/model" @@ -21,9 +22,10 @@ import ( ) const ( - baseSumsubEndpoint = "/sumsub" - kycInitEndpoint = "/init/Kyc" - hookEndpoint = "/hook" + baseSumsubEndpoint = "/sumsub" + kycInitEndpoint = "/init/Kyc" + hookEndpoint = "/hook" + maxSumsubWebhookBodyBytes = int64(1 << 20) ) type initSessionRequest struct { @@ -67,6 +69,10 @@ func (h *sumsubHandler) initSession(c *gin.Context) { model.JsonResponse(c, http.StatusInternalServerError, nil, "", err.Error()) return } + if config.Config.Verification.Provider != model.VerificationProviderSumsub { + model.JsonResponse(c, http.StatusGone, nil, nodeAddress, "Sumsub onboarding is inactive") + return + } address, err := middleware.AddressFromBearer(c) if err != nil { @@ -124,6 +130,17 @@ func (h *sumsubHandler) initSession(c *gin.Context) { model.JsonResponse(c, http.StatusBadRequest, nil, nodeAddress, "user is final rejected, cannot retry") return } + if kyc.VerificationProvider != "" && + kyc.VerificationProvider != model.VerificationProviderSumsub { + model.JsonResponse( + c, + http.StatusConflict, + nil, + nodeAddress, + "verification is owned by another provider and requires an explicit rollback", + ) + return + } //User never init kyc if kyc.ApplicantType == "" { @@ -156,6 +173,7 @@ func (h *sumsubHandler) initSession(c *gin.Context) { return } + kyc.VerificationProvider = model.VerificationProviderSumsub err = storage.CreateOrUpdateKyc(kyc) if err != nil { log.Error("error while saving kyc information in storage: " + err.Error()) @@ -167,19 +185,30 @@ func (h *sumsubHandler) initSession(c *gin.Context) { } func (h *sumsubHandler) processEvents(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSumsubWebhookBodyBytes) + body, err := io.ReadAll(c.Request.Body) + if err != nil { + status := http.StatusBadRequest + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + status = http.StatusRequestEntityTooLarge + } + model.JsonResponse(c, status, nil, "", "invalid Sumsub webhook body") + return + } + nodeAddress, err := service.GetAddress() if err != nil { log.Error("error while retrieving node address: " + err.Error()) model.JsonResponse(c, http.StatusInternalServerError, nil, "", err.Error()) return } - - body, err := io.ReadAll(c.Request.Body) - if err != nil { - log.Error("error while parsing request body: " + err.Error()) - model.JsonResponse(c, http.StatusBadRequest, nil, nodeAddress, err.Error()) + if config.Config.Verification.Provider != model.VerificationProviderSumsub && + !config.Config.Verification.LegacySumsubWebhooksEnabled { + model.JsonResponse(c, http.StatusGone, nil, nodeAddress, "Sumsub monitoring is inactive") return } + err = h.validateSecret(c, body) if err != nil { log.Error("error while validating secret: " + err.Error()) @@ -197,6 +226,7 @@ func (h *sumsubHandler) processEvents(c *gin.Context) { if checkIfBeneficiaryUUID(kycEvent.ExternalUserID) { model.JsonResponse(c, http.StatusOK, "External user id found", nodeAddress, "") + return } uuid, err := uuid.Parse(kycEvent.ExternalUserID) @@ -217,30 +247,97 @@ func (h *sumsubHandler) processEvents(c *gin.Context) { return } - if kyc.KycStatus == model.StatusFinalRejected && kycEvent.Type != model.ApplicantReset { - log.Error("user is final rejected, cannot retry") - model.JsonResponse(c, http.StatusBadRequest, nil, nodeAddress, "user is final rejected, cannot retry") + if config.Config.Verification.Provider == model.VerificationProviderSumsub { + fullKyc, shouldProcess := service.PrepareSumsubKycForFullProcessing(*kyc) + if !shouldProcess { + model.JsonResponse(c, http.StatusOK, "", nodeAddress, "") + return + } + if fullKyc.KycStatus == model.StatusFinalRejected && + kycEvent.Type != model.ApplicantReset { + model.JsonResponse(c, http.StatusBadRequest, nil, nodeAddress, "user is final rejected, cannot retry") + return + } + user, accountFound, accountErr := storage.GetAccountByEmail(fullKyc.Email) + if accountErr != nil { + log.Error("error while retrieving account information from storage: " + accountErr.Error()) + model.JsonResponse(c, http.StatusInternalServerError, nil, nodeAddress, accountErr.Error()) + return + } + if !accountFound { + model.JsonResponse(c, http.StatusInternalServerError, nil, nodeAddress, "user email not found") + return + } + if err = service.ProcessKycEvent(kycEvent, fullKyc, user.Address); err != nil { + log.Error("error while processing Sumsub event: " + err.Error()) + model.JsonResponse(c, http.StatusInternalServerError, nil, nodeAddress, err.Error()) + return + } + model.JsonResponse(c, http.StatusOK, "", nodeAddress, "") return } - user, found, err := storage.GetAccountByEmail(kyc.Email) - if err != nil { - log.Error("error while retrieving account information from storage: " + err.Error()) - model.JsonResponse(c, http.StatusInternalServerError, nil, nodeAddress, err.Error()) + if kyc.KycStatus == model.StatusFinalRejected { + model.JsonResponse(c, http.StatusOK, "", nodeAddress, "") return - } else if !found { - log.Error("account not found in storage") - model.JsonResponse(c, http.StatusInternalServerError, nil, nodeAddress, "user email not found") + } + if kyc.VerificationProvider != model.VerificationProviderSumsub || + (kyc.KycStatus != model.StatusApproved && kyc.KycStatus != model.StatusOnHold) || + kyc.ApplicantId == "" || + kyc.ApplicantId != kycEvent.ApplicantID { + model.JsonResponse(c, http.StatusOK, "", nodeAddress, "") return } - err = service.ProcessKycEvent(kycEvent, *kyc, user.Address) + eventId := strings.TrimSpace(kycEvent.CorrelationID) + if eventId == "" { + digest := sha256.Sum256(body) + eventId = "payload:" + hex.EncodeToString(digest[:]) + } + payloadDigest := sha256.Sum256(body) + environment := model.VerificationEnvironmentProduction + if kycEvent.SandboxMode { + environment = model.VerificationEnvironmentSandbox + } + occurredAt, err := service.ParseSumsubMonitoringOccurredAt(kycEvent.CreatedAtMs) + if err != nil { + model.JsonResponse(c, http.StatusBadRequest, nil, nodeAddress, err.Error()) + return + } + created, err := storage.CreateVerificationWebhookEvent(&model.VerificationWebhookEvent{ + Provider: model.VerificationProviderSumsub, + Environment: environment, + EventId: eventId, + EventType: kycEvent.Type, + ProviderSessionId: kycEvent.ApplicantID, + VendorData: kycEvent.ExternalUserID, + ProviderStatus: kycEvent.ReviewResult.ReviewAnswer, + StatusReason: kycEvent.ReviewResult.ReviewRejectType, + OccurredAt: &occurredAt, + ReceivedAt: time.Now().UTC(), + PayloadSha256: hex.EncodeToString(payloadDigest[:]), + ProcessingStatus: model.VerificationEventReceived, + }) if err != nil { - log.Error("error whil eprocessing event: " + err.Error()) + log.Error("error while persisting Sumsub monitoring event: " + err.Error()) model.JsonResponse(c, http.StatusInternalServerError, nil, nodeAddress, err.Error()) return } - + if !created { + storedEvent, found, readErr := storage.GetVerificationWebhookEvent( + model.VerificationProviderSumsub, + environment, + eventId, + ) + if readErr != nil { + model.JsonResponse(c, http.StatusInternalServerError, nil, nodeAddress, readErr.Error()) + return + } + if found && storedEvent.ProcessingStatus == model.VerificationEventProcessed { + model.JsonResponse(c, http.StatusOK, "", nodeAddress, "") + return + } + } model.JsonResponse(c, http.StatusOK, "", nodeAddress, "") } diff --git a/proxy/handlers/sumsub_test.go b/proxy/handlers/sumsub_test.go new file mode 100644 index 0000000..05006c7 --- /dev/null +++ b/proxy/handlers/sumsub_test.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSumsubWebhookRejectsOversizedBodyBeforeDependencies(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest( + http.MethodPost, + baseSumsubEndpoint+hookEndpoint, + bytes.NewReader(make([]byte, maxSumsubWebhookBodyBytes+1)), + ) + + handler := &sumsubHandler{} + handler.processEvents(context) + + require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code) + require.Contains(t, recorder.Body.String(), "invalid Sumsub webhook body") +} diff --git a/proxy/handlers/verification.go b/proxy/handlers/verification.go new file mode 100644 index 0000000..46a8af4 --- /dev/null +++ b/proxy/handlers/verification.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "context" + "errors" + "io" + "net/http" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/config" + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/NaeuralEdgeProtocol/ratio1-backend/proxy/middleware" + "github.com/NaeuralEdgeProtocol/ratio1-backend/service" + "github.com/gin-gonic/gin" +) + +const ( + baseVerificationEndpoint = "/verification" + verificationSessionEndpoint = "/session" + diditWebhookEndpoint = "/webhooks/didit" + maxDiditWebhookBodyBytes = int64(1 << 20) +) + +type verificationSessionRequest struct { + UserType string `json:"type" binding:"required"` +} + +type verificationService interface { + CreateOrResumeSession( + context.Context, + string, + string, + ) (*service.VerificationSessionResponse, error) + ReceiveDiditWebhook( + []byte, + service.DiditWebhookHeaders, + time.Time, + ) (service.DiditWebhookReceipt, error) +} + +type verificationHandler struct { + service verificationService +} + +func NewVerificationHandler( + groupHandler *groupHandler, + verificationService *service.VerificationService, +) { + h := verificationHandler{service: verificationService} + auth := middleware.Authorization(config.Config.Jwt.Secret) + groupHandler.AddEndpointGroupHandler(EndpointGroupHandler{ + Root: baseVerificationEndpoint, + Middleware: []gin.HandlerFunc{auth}, + EndpointHandlers: []EndpointHandler{ + {Method: http.MethodPost, Path: verificationSessionEndpoint, HandlerFunc: h.createOrResumeSession}, + }, + }) + groupHandler.AddEndpointGroupHandler(EndpointGroupHandler{ + Root: baseVerificationEndpoint, + Middleware: []gin.HandlerFunc{}, + EndpointHandlers: []EndpointHandler{ + {Method: http.MethodPost, Path: diditWebhookEndpoint, HandlerFunc: h.processDiditWebhook}, + }, + }) +} + +func (h verificationHandler) createOrResumeSession(c *gin.Context) { + nodeAddress, err := service.GetAddress() + if err != nil { + model.JsonResponse(c, http.StatusInternalServerError, nil, "", err.Error()) + return + } + address, err := middleware.AddressFromBearer(c) + if err != nil { + model.JsonResponse(c, http.StatusBadRequest, nil, nodeAddress, err.Error()) + return + } + var request verificationSessionRequest + if err := c.ShouldBindJSON(&request); err != nil { + model.JsonResponse(c, http.StatusBadRequest, nil, nodeAddress, "invalid verification session request") + return + } + response, err := h.service.CreateOrResumeSession(c.Request.Context(), address, request.UserType) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, service.ErrVerificationReconciliationPending) { + status = http.StatusConflict + } + model.JsonResponse(c, status, nil, nodeAddress, err.Error()) + return + } + if err := response.Validate(); err != nil { + model.JsonResponse(c, http.StatusBadGateway, nil, nodeAddress, err.Error()) + return + } + model.JsonResponse(c, http.StatusOK, response, nodeAddress, "") +} + +func (h verificationHandler) processDiditWebhook(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxDiditWebhookBodyBytes) + body, err := io.ReadAll(c.Request.Body) + if err != nil { + status := http.StatusBadRequest + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + status = http.StatusRequestEntityTooLarge + } + c.JSON(status, gin.H{"accepted": false}) + return + } + receipt, err := h.service.ReceiveDiditWebhook(body, service.DiditWebhookHeaders{ + Timestamp: c.GetHeader("X-Timestamp"), + SignatureV2: c.GetHeader("X-Signature-V2"), + Signature: c.GetHeader("X-Signature"), + Simple: c.GetHeader("X-Signature-Simple"), + TestWebhook: c.GetHeader("X-Didit-Test-Webhook") == "true", + }, time.Now().UTC()) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, service.ErrDiditInvalidSignature) || + errors.Is(err, service.ErrDiditStaleWebhook) { + status = http.StatusUnauthorized + } + c.JSON(status, gin.H{"accepted": false}) + return + } + c.JSON(http.StatusAccepted, gin.H{ + "accepted": true, + "duplicate": receipt.Duplicate, + "testOnly": receipt.TestOnly, + }) +} diff --git a/proxy/handlers/verification_test.go b/proxy/handlers/verification_test.go new file mode 100644 index 0000000..418b711 --- /dev/null +++ b/proxy/handlers/verification_test.go @@ -0,0 +1,54 @@ +package handlers + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type fakeVerificationService struct { + webhookCalls int +} + +func (fake *fakeVerificationService) CreateOrResumeSession( + context.Context, + string, + string, +) (*service.VerificationSessionResponse, error) { + return nil, nil +} + +func (fake *fakeVerificationService) ReceiveDiditWebhook( + []byte, + service.DiditWebhookHeaders, + time.Time, +) (service.DiditWebhookReceipt, error) { + fake.webhookCalls++ + return service.DiditWebhookReceipt{}, nil +} + +func TestDiditWebhookRejectsOversizedBodyBeforeService(t *testing.T) { + gin.SetMode(gin.TestMode) + fake := &fakeVerificationService{} + handler := verificationHandler{service: fake} + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest( + http.MethodPost, + "/verification/webhooks/didit", + bytes.NewReader(make([]byte, maxDiditWebhookBodyBytes+1)), + ) + + handler.processDiditWebhook(context) + + require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code) + require.Zero(t, fake.webhookCalls) + require.JSONEq(t, `{"accepted":false}`, recorder.Body.String()) +} diff --git a/proxy/server.go b/proxy/server.go index 6de19fe..f4abc01 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -1,6 +1,7 @@ package proxy import ( + "context" "net/http" "strings" "time" @@ -20,7 +21,9 @@ var corsHeaders = []string{ } type WebServer struct { - router *gin.Engine + router *gin.Engine + verificationService *service.VerificationService + verificationWorker *service.VerificationWorker } func NewWebServer() (*WebServer, error) { @@ -35,11 +38,17 @@ func NewWebServer() (*WebServer, error) { groupHandler := handlers.NewGroupHandler() service.NewAuthService() + verificationService, err := service.NewVerificationService(config.Config) + if err != nil { + return nil, err + } handlers.NewAuthHandler(groupHandler) + handlers.NewHealthHandler(groupHandler) handlers.NewLaunchpadHandler(groupHandler) handlers.NewAccountHandler(groupHandler) handlers.NewSumsubHandler(groupHandler) + handlers.NewVerificationHandler(groupHandler, verificationService) handlers.NewTokenHandler(groupHandler) handlers.NewSellerHandler(groupHandler) handlers.NewAdminHandler(groupHandler) @@ -50,11 +59,13 @@ func NewWebServer() (*WebServer, error) { groupHandler.RegisterEndpoints(router) return &WebServer{ - router: router, + router: router, + verificationService: verificationService, }, nil } func (w *WebServer) Run() *http.Server { + w.verificationWorker = w.verificationService.StartWorker(context.Background()) address := config.Config.Api.Address if !strings.Contains(address, ":") { panic("bad address") @@ -82,3 +93,10 @@ func (w *WebServer) Run() *http.Server { return server } + +func (w *WebServer) Shutdown(ctx context.Context, server *http.Server) error { + if w.verificationWorker != nil { + w.verificationWorker.Stop() + } + return server.Shutdown(ctx) +} diff --git a/service/accountService.go b/service/accountService.go index f3b7ff9..3dfd23a 100644 --- a/service/accountService.go +++ b/service/accountService.go @@ -138,14 +138,9 @@ func confirmPrimaryEmail(account *model.Account, email string) (*model.Account, } account.PendingReceiveUpdates = false - err := storage.UpdateAccount(account) + err := storage.UpdateAccountAndCreateKyc(account, &kyc) if err != nil { - return nil, errors.New("error while updating account on storage: " + err.Error()) - } - - err = storage.CreateOrUpdateKyc(&kyc) - if err != nil { - return nil, errors.New("error while updating kyc on storage: " + err.Error()) + return nil, errors.New("error while confirming account email on storage: " + err.Error()) } if *kyc.ReceiveUpdates { diff --git a/service/diditClient.go b/service/diditClient.go new file mode 100644 index 0000000..123ca6c --- /dev/null +++ b/service/diditClient.go @@ -0,0 +1,476 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/config" + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" +) + +const ( + defaultDiditTimeout = 15 * time.Second + defaultDiditMaxBodyBytes = int64(8 << 20) +) + +var ( + ErrDiditResponseTooLarge = errors.New("didit response exceeds limit") + ErrDiditVendorMismatch = errors.New("didit vendor data mismatch") + ErrDiditWorkflowMismatch = errors.New("didit workflow mismatch") + ErrDiditSessionMismatch = errors.New("didit session mismatch") + ErrDiditKindMismatch = errors.New("didit session kind mismatch") + ErrDiditEnvironmentMismatch = errors.New("didit environment mismatch") + ErrDiditInvalidResponse = errors.New("invalid didit response") +) + +type DiditAPIError struct { + Operation string + StatusCode int + Detail string + FieldErrors map[string][]string + RetryAfter time.Duration +} + +func (e *DiditAPIError) Error() string { + return fmt.Sprintf("didit %s failed with status %d", e.Operation, e.StatusCode) +} + +func (e *DiditAPIError) Retryable() bool { + return e.StatusCode == http.StatusTooManyRequests || e.StatusCode >= http.StatusInternalServerError +} + +type DiditClient struct { + baseURL *url.URL + apiKey string + environment string + workflowKinds map[uuid.UUID]model.DiditSessionKind + httpClient *http.Client + maxBodyBytes int64 +} + +func NewDiditClient(cfg config.DiditConfig, httpClient *http.Client) (*DiditClient, error) { + return newDiditClient(cfg, httpClient, defaultDiditMaxBodyBytes) +} + +func newDiditClient(cfg config.DiditConfig, httpClient *http.Client, maxBodyBytes int64) (*DiditClient, error) { + if strings.TrimSpace(cfg.ApiKey) == "" { + return nil, errors.New("DIDIT_API_KEY is not set") + } + if maxBodyBytes <= 0 { + return nil, errors.New("didit response limit must be positive") + } + + baseURL, err := url.Parse(strings.TrimSpace(cfg.ApiUrl)) + if err != nil { + return nil, errors.New("DIDIT_API_URL is invalid") + } + if baseURL.Scheme != "http" && baseURL.Scheme != "https" { + return nil, errors.New("DIDIT_API_URL must use http or https") + } + if baseURL.Host == "" || baseURL.RawQuery != "" || baseURL.Fragment != "" { + return nil, errors.New("DIDIT_API_URL is invalid") + } + baseURL.Path = strings.TrimRight(baseURL.Path, "/") + "/" + if baseURL.Scheme != "https" && !isLoopbackHost(baseURL.Hostname()) { + return nil, errors.New("DIDIT_API_URL must use https outside loopback tests") + } + + apiEnvironment, err := diditAPIEnvironment(cfg.Environment) + if err != nil { + return nil, err + } + workflowKinds, err := diditWorkflowKinds(cfg) + if err != nil { + return nil, err + } + + if httpClient == nil { + httpClient = &http.Client{Timeout: defaultDiditTimeout} + } + httpClientCopy := *httpClient + if httpClientCopy.Timeout <= 0 { + httpClientCopy.Timeout = defaultDiditTimeout + } + httpClientCopy.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + + return &DiditClient{ + baseURL: baseURL, + apiKey: strings.TrimSpace(cfg.ApiKey), + environment: apiEnvironment, + workflowKinds: workflowKinds, + httpClient: &httpClientCopy, + maxBodyBytes: maxBodyBytes, + }, nil +} + +func (c *DiditClient) CreateSession(ctx context.Context, request model.DiditCreateSessionRequest) (*model.DiditCreateSessionResponse, error) { + if request.WorkflowId == uuid.Nil { + return nil, errors.New("didit workflow ID is required") + } + if _, err := uuid.Parse(request.VendorData); err != nil { + return nil, errors.New("didit vendor data must be the KYC UUID") + } + if !isKnownDiditSessionKind(request.ExpectedSessionKind) { + return nil, errors.New("expected didit session kind is invalid") + } + if configuredKind, found := c.workflowKinds[request.WorkflowId]; !found || configuredKind != request.ExpectedSessionKind { + return nil, ErrDiditKindMismatch + } + + requestBody, err := json.Marshal(request) + if err != nil { + return nil, errors.New("could not encode didit create-session request") + } + + body, err := c.doJSON(ctx, http.MethodPost, "v3/session/", requestBody, http.StatusCreated, "create session") + if err != nil { + return nil, err + } + + var response model.DiditCreateSessionResponse + if err = json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("%w: create-session JSON", ErrDiditInvalidResponse) + } + if err = validateDiditCreateSessionResponse(response, request); err != nil { + return nil, err + } + if response.SessionKind == "" { + response.SessionKind = request.ExpectedSessionKind + } + + return &response, nil +} + +func (c *DiditClient) RetrieveDecision( + ctx context.Context, + sessionId uuid.UUID, + expected model.DiditDecisionExpectation, +) (*model.DiditDecision, error) { + if sessionId == uuid.Nil { + return nil, errors.New("didit session ID is required") + } + if _, err := uuid.Parse(expected.VendorData); err != nil { + return nil, errors.New("expected didit vendor data must be the KYC UUID") + } + if expected.WorkflowId == uuid.Nil { + return nil, errors.New("expected didit workflow ID is required") + } + if !isKnownDiditSessionKind(expected.SessionKind) { + return nil, errors.New("expected didit session kind is invalid") + } + if configuredKind, found := c.workflowKinds[expected.WorkflowId]; !found || configuredKind != expected.SessionKind { + return nil, ErrDiditKindMismatch + } + + path := "v3/session/" + url.PathEscape(sessionId.String()) + "/decision/" + body, err := c.doJSON(ctx, http.MethodGet, path, nil, http.StatusOK, "retrieve decision") + if err != nil { + return nil, err + } + + var decision model.DiditDecision + if err = json.Unmarshal(body, &decision); err != nil { + return nil, fmt.Errorf("%w: decision JSON", ErrDiditInvalidResponse) + } + if err = validateDiditDecision(decision, sessionId, expected, c.environment); err != nil { + return nil, err + } + + return &decision, nil +} + +func (c *DiditClient) RetrieveEntity( + ctx context.Context, + sessionKind model.DiditSessionKind, + vendorData string, +) (*model.DiditEntity, error) { + if _, err := uuid.Parse(vendorData); err != nil { + return nil, errors.New("Didit entity vendor data must be the KYC UUID") + } + var resource string + switch sessionKind { + case model.DiditSessionKindUser: + resource = "users" + case model.DiditSessionKindBusiness: + resource = "businesses" + default: + return nil, errors.New("Didit entity session kind is invalid") + } + + path := "v3/" + resource + "/" + url.PathEscape(vendorData) + "/" + body, err := c.doJSON(ctx, http.MethodGet, path, nil, http.StatusOK, "retrieve entity") + if err != nil { + return nil, err + } + + var entity model.DiditEntity + if err = json.Unmarshal(body, &entity); err != nil { + return nil, fmt.Errorf("%w: entity JSON", ErrDiditInvalidResponse) + } + if entity.DiditInternalId == uuid.Nil || + entity.VendorData != vendorData || + strings.TrimSpace(entity.Status) == "" { + return nil, ErrDiditInvalidResponse + } + return &entity, nil +} + +func (c *DiditClient) doJSON( + ctx context.Context, + method string, + path string, + body []byte, + expectedStatus int, + operation string, +) ([]byte, error) { + endpoint := c.baseURL.ResolveReference(&url.URL{Path: path}) + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + + request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader) + if err != nil { + return nil, errors.New("could not create didit request") + } + request.Header.Set("x-api-key", c.apiKey) + request.Header.Set("Accept", "application/json") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + + response, err := c.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("didit %s transport failed: %w", operation, err) + } + defer response.Body.Close() + + responseBody, err := readDiditResponseBody(response, c.maxBodyBytes) + if err != nil { + return nil, err + } + if response.StatusCode != expectedStatus { + return nil, newDiditAPIError(operation, response, responseBody) + } + + return responseBody, nil +} + +func readDiditResponseBody(response *http.Response, maxBodyBytes int64) ([]byte, error) { + if response.ContentLength > maxBodyBytes { + return nil, ErrDiditResponseTooLarge + } + + body, err := io.ReadAll(io.LimitReader(response.Body, maxBodyBytes+1)) + if err != nil { + return nil, errors.New("could not read didit response") + } + if int64(len(body)) > maxBodyBytes { + return nil, ErrDiditResponseTooLarge + } + + return body, nil +} + +func newDiditAPIError(operation string, response *http.Response, body []byte) *DiditAPIError { + apiError := &DiditAPIError{ + Operation: operation, + StatusCode: response.StatusCode, + FieldErrors: make(map[string][]string), + RetryAfter: parseDiditRetryAfter(response.Header.Get("Retry-After"), time.Now()), + } + + var fields map[string]interface{} + if json.Unmarshal(body, &fields) != nil { + return apiError + } + for field, value := range fields { + switch typed := value.(type) { + case string: + if field == "detail" { + apiError.Detail = typed + } else { + apiError.FieldErrors[field] = []string{typed} + } + case []interface{}: + messages := make([]string, 0, len(typed)) + for _, item := range typed { + if message, ok := item.(string); ok { + messages = append(messages, message) + } + } + if len(messages) > 0 { + apiError.FieldErrors[field] = messages + } + } + } + + return apiError +} + +func parseDiditRetryAfter(value string, now time.Time) time.Duration { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil && seconds > 0 { + return time.Duration(seconds) * time.Second + } + if retryAt, err := http.ParseTime(value); err == nil && retryAt.After(now) { + return retryAt.Sub(now) + } + return 0 +} + +func validateDiditCreateSessionResponse( + response model.DiditCreateSessionResponse, + request model.DiditCreateSessionRequest, +) error { + if response.SessionId == uuid.Nil || + response.SessionNumber <= 0 || + response.SessionToken == "" || + response.Url == "" || + response.WorkflowVersion <= 0 || + !isAllowedDiditCreateStatus(response.Status) { + return ErrDiditInvalidResponse + } + if response.VendorData != request.VendorData { + return ErrDiditVendorMismatch + } + if response.WorkflowId != request.WorkflowId { + return ErrDiditWorkflowMismatch + } + if response.SessionKind != "" && response.SessionKind != request.ExpectedSessionKind { + return ErrDiditKindMismatch + } + if !isAllowedDiditHostedURL(response.Url) { + return ErrDiditInvalidResponse + } + return nil +} + +func isAllowedDiditHostedURL(value string) bool { + sessionURL, err := url.Parse(strings.TrimSpace(value)) + if err != nil || + sessionURL.Scheme != "https" || + sessionURL.Host != "verify.didit.me" || + sessionURL.User != nil || + sessionURL.Fragment != "" { + return false + } + token := strings.TrimPrefix(sessionURL.EscapedPath(), "/session/") + return token != "" && token != sessionURL.EscapedPath() && !strings.Contains(token, "/") +} + +func validateDiditDecision( + decision model.DiditDecision, + sessionId uuid.UUID, + expected model.DiditDecisionExpectation, + expectedEnvironment string, +) error { + if decision.SessionId == uuid.Nil || + !isKnownDiditSessionKind(decision.SessionKind) || + !isKnownDiditSessionStatus(decision.Status) { + return ErrDiditInvalidResponse + } + if decision.SessionId != sessionId { + return ErrDiditSessionMismatch + } + if decision.VendorData != expected.VendorData { + return ErrDiditVendorMismatch + } + if decision.WorkflowId != expected.WorkflowId { + return ErrDiditWorkflowMismatch + } + if decision.SessionKind != expected.SessionKind { + return ErrDiditKindMismatch + } + if decision.Environment != expectedEnvironment { + return ErrDiditEnvironmentMismatch + } + return nil +} + +func diditAPIEnvironment(environment string) (string, error) { + switch environment { + case model.VerificationEnvironmentSandbox: + return "sandbox", nil + case model.VerificationEnvironmentProduction: + return "live", nil + default: + return "", errors.New("didit environment must be sandbox or production") + } +} + +func diditWorkflowKinds(cfg config.DiditConfig) (map[uuid.UUID]model.DiditSessionKind, error) { + kycWorkflowId, err := uuid.Parse(cfg.KycWorkflowId) + if err != nil { + return nil, errors.New("DIDIT_KYC_WORKFLOW_ID is invalid") + } + kybWorkflowId, err := uuid.Parse(cfg.KybWorkflowId) + if err != nil { + return nil, errors.New("DIDIT_KYB_WORKFLOW_ID is invalid") + } + if kycWorkflowId == kybWorkflowId { + return nil, errors.New("Didit KYC and KYB workflow IDs must differ") + } + return map[uuid.UUID]model.DiditSessionKind{ + kycWorkflowId: model.DiditSessionKindUser, + kybWorkflowId: model.DiditSessionKindBusiness, + }, nil +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func isKnownDiditSessionKind(kind model.DiditSessionKind) bool { + return kind == model.DiditSessionKindUser || kind == model.DiditSessionKindBusiness +} + +func isKnownDiditSessionStatus(status model.DiditSessionStatus) bool { + switch status { + case model.DiditStatusNotStarted, + model.DiditStatusInProgress, + model.DiditStatusAwaitingUser, + model.DiditStatusInReview, + model.DiditStatusApproved, + model.DiditStatusDeclined, + model.DiditStatusResubmitted, + model.DiditStatusExpired, + model.DiditStatusKycExpired, + model.DiditStatusAbandoned: + return true + default: + return false + } +} + +func isAllowedDiditCreateStatus(status model.DiditSessionStatus) bool { + switch status { + case model.DiditStatusNotStarted, + model.DiditStatusInProgress, + model.DiditStatusAwaitingUser, + model.DiditStatusResubmitted: + return true + default: + return false + } +} diff --git a/service/diditClient_sandbox_test.go b/service/diditClient_sandbox_test.go new file mode 100644 index 0000000..24c48c8 --- /dev/null +++ b/service/diditClient_sandbox_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "context" + "os" + "testing" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/config" + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestDiditClientSandboxContract(t *testing.T) { + if os.Getenv("DIDIT_RUN_SANDBOX_TESTS") != "1" { + t.Skip("set DIDIT_RUN_SANDBOX_TESTS=1 to exercise the live Didit Sandbox contract") + } + + apiUrl := os.Getenv("DIDIT_API_URL") + apiKey := os.Getenv("DIDIT_API_KEY") + require.NotEmpty(t, apiUrl) + require.NotEmpty(t, apiKey) + + client, err := NewDiditClient(config.DiditConfig{ + ApiUrl: apiUrl, + ApiKey: apiKey, + Environment: model.VerificationEnvironmentSandbox, + KycWorkflowId: os.Getenv("DIDIT_KYC_WORKFLOW_ID"), + KybWorkflowId: os.Getenv("DIDIT_KYB_WORKFLOW_ID"), + }, nil) + require.NoError(t, err) + + tests := []struct { + name string + workflowEnv string + kind model.DiditSessionKind + vendorId string + }{ + { + name: "KYC", + workflowEnv: "DIDIT_KYC_WORKFLOW_ID", + kind: model.DiditSessionKindUser, + vendorId: "00000000-0000-4000-8000-000000000901", + }, + { + name: "KYB", + workflowEnv: "DIDIT_KYB_WORKFLOW_ID", + kind: model.DiditSessionKindBusiness, + vendorId: "00000000-0000-4000-8000-000000000902", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + workflowId, err := uuid.Parse(os.Getenv(test.workflowEnv)) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + session, err := client.CreateSession(ctx, model.DiditCreateSessionRequest{ + WorkflowId: workflowId, + VendorData: test.vendorId, + ExpectedSessionKind: test.kind, + Metadata: map[string]interface{}{ + "contract_test": true, + }, + }) + require.NoError(t, err) + require.Equal(t, workflowId, session.WorkflowId) + require.Equal(t, test.vendorId, session.VendorData) + + decision, err := client.RetrieveDecision(ctx, session.SessionId, model.DiditDecisionExpectation{ + VendorData: test.vendorId, + WorkflowId: workflowId, + SessionKind: test.kind, + }) + require.NoError(t, err) + require.Equal(t, session.SessionId, decision.SessionId) + require.Equal(t, test.kind, decision.SessionKind) + require.Equal(t, model.DiditStatusNotStarted, decision.Status) + }) + } +} diff --git a/service/diditClient_test.go b/service/diditClient_test.go new file mode 100644 index 0000000..33b165b --- /dev/null +++ b/service/diditClient_test.go @@ -0,0 +1,565 @@ +package service + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/config" + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +const ( + diditTestAPIKey = "didit_test_api_key" + diditTestToken = "test-token-only" +) + +var ( + diditTestSessionId = uuid.MustParse("00000000-0000-4000-8000-000000000101") + diditTestBusinessSessionId = uuid.MustParse("00000000-0000-4000-8000-000000000102") + diditTestWorkflowId = uuid.MustParse("00000000-0000-4000-8000-000000000201") + diditTestBusinessWorkflowId = uuid.MustParse("00000000-0000-4000-8000-000000000202") + diditTestVendorId = "00000000-0000-4000-8000-000000000301" + diditTestBusinessVendorId = "00000000-0000-4000-8000-000000000302" +) + +func TestDiditClientCreateSession(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + require.Equal(t, http.MethodPost, request.Method) + require.Equal(t, "/v3/session/", request.URL.Path) + require.Equal(t, diditTestAPIKey, request.Header.Get("x-api-key")) + require.Equal(t, "application/json", request.Header.Get("Accept")) + require.Equal(t, "application/json", request.Header.Get("Content-Type")) + + requestBody, err := io.ReadAll(request.Body) + require.NoError(t, err) + require.NotContains(t, string(requestBody), "session_kind") + require.JSONEq(t, `{ + "workflow_id":"00000000-0000-4000-8000-000000000201", + "vendor_data":"00000000-0000-4000-8000-000000000301", + "callback":"https://app.invalid/verification-complete", + "metadata":{"applicant_type":"individual"} + }`, string(requestBody)) + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write(readDiditFixture(t, "create_session_201.json")) + })) + defer server.Close() + + client, err := NewDiditClient(diditTestConfig(server.URL), server.Client()) + require.NoError(t, err) + + response, err := client.CreateSession(context.Background(), model.DiditCreateSessionRequest{ + WorkflowId: diditTestWorkflowId, + VendorData: diditTestVendorId, + ExpectedSessionKind: model.DiditSessionKindUser, + Callback: "https://app.invalid/verification-complete", + Metadata: map[string]interface{}{ + "applicant_type": "individual", + }, + }) + require.NoError(t, err) + require.Equal(t, diditTestSessionId, response.SessionId) + require.Equal(t, diditTestWorkflowId, response.WorkflowId) + require.Equal(t, model.DiditSessionKindUser, response.SessionKind) + require.Equal(t, model.DiditStatusNotStarted, response.Status) + require.Equal(t, diditTestVendorId, response.VendorData) + require.Equal(t, diditTestToken, response.SessionToken) +} + +func TestDiditClientCreateSessionRejectsInvalidRoutingIdentity(t *testing.T) { + valid := string(readDiditFixture(t, "create_session_201.json")) + tests := []struct { + name string + response string + expectedErr error + }{ + { + name: "vendor mismatch", + response: strings.Replace(valid, diditTestVendorId, diditTestBusinessVendorId, 1), + expectedErr: ErrDiditVendorMismatch, + }, + { + name: "workflow mismatch", + response: strings.Replace(valid, diditTestWorkflowId.String(), diditTestBusinessWorkflowId.String(), 1), + expectedErr: ErrDiditWorkflowMismatch, + }, + { + name: "missing session", + response: strings.Replace(valid, diditTestSessionId.String(), uuid.Nil.String(), 1), + expectedErr: ErrDiditInvalidResponse, + }, + { + name: "unknown status", + response: strings.Replace(valid, string(model.DiditStatusNotStarted), "Unexpected", 1), + expectedErr: ErrDiditInvalidResponse, + }, + { + name: "terminal create status", + response: strings.Replace(valid, string(model.DiditStatusNotStarted), string(model.DiditStatusApproved), 1), + expectedErr: ErrDiditInvalidResponse, + }, + { + name: "response kind mismatch", + response: strings.Replace(valid, "{", `{"session_kind":"business",`, 1), + expectedErr: ErrDiditKindMismatch, + }, + { + name: "insecure hosted URL", + response: strings.Replace(valid, "https://verify.didit.me", "http://verify.didit.me", 1), + expectedErr: ErrDiditInvalidResponse, + }, + { + name: "unexpected hosted URL origin", + response: strings.Replace(valid, "verify.didit.me", "attacker.invalid", 1), + expectedErr: ErrDiditInvalidResponse, + }, + { + name: "unexpected hosted URL path", + response: strings.Replace(valid, "/session/", "/other/", 1), + expectedErr: ErrDiditInvalidResponse, + }, + { + name: "malformed JSON", + response: `{`, + expectedErr: ErrDiditInvalidResponse, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write([]byte(test.response)) + })) + defer server.Close() + + client, err := NewDiditClient(diditTestConfig(server.URL), server.Client()) + require.NoError(t, err) + + _, err = client.CreateSession(context.Background(), model.DiditCreateSessionRequest{ + WorkflowId: diditTestWorkflowId, + VendorData: diditTestVendorId, + ExpectedSessionKind: model.DiditSessionKindUser, + }) + require.ErrorIs(t, err, test.expectedErr) + }) + } +} + +func TestDiditClientRejectsWorkflowKindMismatchBeforeRequest(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests++ + })) + defer server.Close() + + client, err := NewDiditClient(diditTestConfig(server.URL), server.Client()) + require.NoError(t, err) + + _, err = client.CreateSession(context.Background(), model.DiditCreateSessionRequest{ + WorkflowId: diditTestWorkflowId, + VendorData: diditTestVendorId, + ExpectedSessionKind: model.DiditSessionKindBusiness, + }) + require.ErrorIs(t, err, ErrDiditKindMismatch) + + _, err = client.RetrieveDecision(context.Background(), diditTestSessionId, model.DiditDecisionExpectation{ + VendorData: diditTestVendorId, + WorkflowId: diditTestWorkflowId, + SessionKind: model.DiditSessionKindBusiness, + }) + require.ErrorIs(t, err, ErrDiditKindMismatch) + require.Zero(t, requests) +} + +func TestDiditClientCreateSessionReturnsTypedAPIErrors(t *testing.T) { + tests := []struct { + name string + status int + body []byte + retryAfter string + expectedRetry time.Duration + retryable bool + expectedField string + }{ + { + name: "invalid workflow", + status: http.StatusBadRequest, + body: readDiditFixture(t, "error_invalid_workflow_400.json"), + retryable: false, + expectedField: "workflow_id", + }, + { + name: "unauthorized", + status: http.StatusUnauthorized, + body: []byte(`{"detail":"Unauthorized."}`), + retryable: false, + }, + { + name: "forbidden", + status: http.StatusForbidden, + body: []byte(`{"detail":"Permission denied."}`), + retryable: false, + }, + { + name: "rate limited", + status: http.StatusTooManyRequests, + body: readDiditFixture(t, "error_rate_limited_429.json"), + retryAfter: "42", + expectedRetry: 42 * time.Second, + retryable: true, + }, + { + name: "server error", + status: http.StatusServiceUnavailable, + body: []byte(`{"detail":"Unavailable."}`), + retryable: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + if test.retryAfter != "" { + writer.Header().Set("Retry-After", test.retryAfter) + } + writer.WriteHeader(test.status) + _, _ = writer.Write(test.body) + })) + defer server.Close() + + client, err := NewDiditClient(diditTestConfig(server.URL), server.Client()) + require.NoError(t, err) + + _, err = client.CreateSession(context.Background(), model.DiditCreateSessionRequest{ + WorkflowId: diditTestWorkflowId, + VendorData: diditTestVendorId, + ExpectedSessionKind: model.DiditSessionKindUser, + }) + + var apiError *DiditAPIError + require.ErrorAs(t, err, &apiError) + require.Equal(t, test.status, apiError.StatusCode) + require.Equal(t, test.expectedRetry, apiError.RetryAfter) + require.Equal(t, test.retryable, apiError.Retryable()) + if test.expectedField != "" { + require.NotEmpty(t, apiError.FieldErrors[test.expectedField]) + } + require.NotContains(t, err.Error(), diditTestAPIKey) + require.NotContains(t, err.Error(), diditTestToken) + }) + } +} + +func TestDiditClientRetrieveDecision(t *testing.T) { + tests := []struct { + name string + fixture string + sessionId uuid.UUID + expected model.DiditDecisionExpectation + expectedKind model.DiditSessionKind + expectedIdChecks int + expectedRegistries int + expectedPhoneChecks int + expectedEmailChecks int + expectedDocumentAI int + expectedIPChecks int + expectedDBChecks int + expectedReviews int + }{ + { + name: "complete user decision", + fixture: "decision_user_approved.json", + sessionId: diditTestSessionId, + expected: model.DiditDecisionExpectation{ + VendorData: diditTestVendorId, + WorkflowId: diditTestWorkflowId, + SessionKind: model.DiditSessionKindUser, + }, + expectedKind: model.DiditSessionKindUser, + expectedIdChecks: 1, + expectedPhoneChecks: 1, + expectedEmailChecks: 1, + expectedDocumentAI: 1, + expectedIPChecks: 1, + expectedDBChecks: 1, + expectedReviews: 1, + }, + { + name: "incomplete approved user parses without granting evidence", + fixture: "decision_user_approved_incomplete.json", + sessionId: diditTestSessionId, + expected: model.DiditDecisionExpectation{ + VendorData: diditTestVendorId, + WorkflowId: diditTestWorkflowId, + SessionKind: model.DiditSessionKindUser, + }, + expectedKind: model.DiditSessionKindUser, + }, + { + name: "complete business decision", + fixture: "decision_business_approved.json", + sessionId: diditTestBusinessSessionId, + expected: model.DiditDecisionExpectation{ + VendorData: diditTestBusinessVendorId, + WorkflowId: diditTestBusinessWorkflowId, + SessionKind: model.DiditSessionKindBusiness, + }, + expectedKind: model.DiditSessionKindBusiness, + expectedRegistries: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + require.Equal(t, http.MethodGet, request.Method) + require.Equal(t, "/v3/session/"+test.sessionId.String()+"/decision/", request.URL.Path) + require.Equal(t, diditTestAPIKey, request.Header.Get("x-api-key")) + require.Equal(t, int64(0), request.ContentLength) + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(readDiditFixture(t, test.fixture)) + })) + defer server.Close() + + client, err := NewDiditClient(diditTestConfig(server.URL), server.Client()) + require.NoError(t, err) + + decision, err := client.RetrieveDecision(context.Background(), test.sessionId, test.expected) + require.NoError(t, err) + require.Equal(t, test.expectedKind, decision.SessionKind) + require.Equal(t, model.DiditStatusApproved, decision.Status) + require.Len(t, decision.IdVerifications, test.expectedIdChecks) + require.Len(t, decision.RegistryChecks, test.expectedRegistries) + require.Len(t, decision.PhoneVerifications, test.expectedPhoneChecks) + require.Len(t, decision.EmailVerifications, test.expectedEmailChecks) + require.Len(t, decision.DocumentAiDocuments, test.expectedDocumentAI) + require.Len(t, decision.IpAnalyses, test.expectedIPChecks) + require.Len(t, decision.DatabaseValidations, test.expectedDBChecks) + require.Len(t, decision.Reviews, test.expectedReviews) + }) + } +} + +func TestDiditClientRetrieveDecisionRejectsRoutingMismatches(t *testing.T) { + valid := string(readDiditFixture(t, "decision_user_approved.json")) + tests := []struct { + name string + response string + expectedErr error + }{ + { + name: "session mismatch", + response: strings.Replace(valid, diditTestSessionId.String(), diditTestBusinessSessionId.String(), 1), + expectedErr: ErrDiditSessionMismatch, + }, + { + name: "vendor mismatch", + response: strings.Replace(valid, diditTestVendorId, diditTestBusinessVendorId, 1), + expectedErr: ErrDiditVendorMismatch, + }, + { + name: "workflow mismatch", + response: strings.Replace(valid, diditTestWorkflowId.String(), diditTestBusinessWorkflowId.String(), 1), + expectedErr: ErrDiditWorkflowMismatch, + }, + { + name: "kind mismatch", + response: strings.Replace(valid, `"session_kind": "user"`, `"session_kind": "business"`, 1), + expectedErr: ErrDiditKindMismatch, + }, + { + name: "unknown kind", + response: strings.Replace(valid, `"session_kind": "user"`, `"session_kind": "unknown"`, 1), + expectedErr: ErrDiditInvalidResponse, + }, + { + name: "environment mismatch", + response: strings.Replace(valid, `"environment": "sandbox"`, `"environment": "live"`, 1), + expectedErr: ErrDiditEnvironmentMismatch, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(test.response)) + })) + defer server.Close() + + client, err := NewDiditClient(diditTestConfig(server.URL), server.Client()) + require.NoError(t, err) + + _, err = client.RetrieveDecision(context.Background(), diditTestSessionId, model.DiditDecisionExpectation{ + VendorData: diditTestVendorId, + WorkflowId: diditTestWorkflowId, + SessionKind: model.DiditSessionKindUser, + }) + require.ErrorIs(t, err, test.expectedErr) + }) + } +} + +func TestDiditClientResponseLimitAndCancellation(t *testing.T) { + t.Run("oversized response", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write(bytes.Repeat([]byte("x"), 65)) + })) + defer server.Close() + + client, err := newDiditClient(diditTestConfig(server.URL), server.Client(), 64) + require.NoError(t, err) + + _, err = client.CreateSession(context.Background(), model.DiditCreateSessionRequest{ + WorkflowId: diditTestWorkflowId, + VendorData: diditTestVendorId, + ExpectedSessionKind: model.DiditSessionKindUser, + }) + require.ErrorIs(t, err, ErrDiditResponseTooLarge) + }) + + t.Run("cancelled context", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + <-request.Context().Done() + writer.WriteHeader(http.StatusGatewayTimeout) + })) + defer server.Close() + + client, err := NewDiditClient(diditTestConfig(server.URL), server.Client()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = client.CreateSession(ctx, model.DiditCreateSessionRequest{ + WorkflowId: diditTestWorkflowId, + VendorData: diditTestVendorId, + ExpectedSessionKind: model.DiditSessionKindUser, + }) + require.ErrorIs(t, err, context.Canceled) + }) +} + +func TestDiditClientRejectsRedirectsWithoutForwardingAPIKey(t *testing.T) { + redirectTargetCalled := false + redirectTarget := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + redirectTargetCalled = true + require.Empty(t, request.Header.Get("x-api-key")) + })) + defer redirectTarget.Close() + + redirectSource := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Redirect(writer, request, redirectTarget.URL, http.StatusTemporaryRedirect) + })) + defer redirectSource.Close() + + client, err := NewDiditClient(diditTestConfig(redirectSource.URL), redirectSource.Client()) + require.NoError(t, err) + + _, err = client.CreateSession(context.Background(), model.DiditCreateSessionRequest{ + WorkflowId: diditTestWorkflowId, + VendorData: diditTestVendorId, + ExpectedSessionKind: model.DiditSessionKindUser, + }) + var apiError *DiditAPIError + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusTemporaryRedirect, apiError.StatusCode) + require.False(t, redirectTargetCalled) +} + +func TestDiditClientAlwaysClosesResponseBody(t *testing.T) { + trackedBody := &trackedReadCloser{ + Reader: bytes.NewBuffer(readDiditFixture(t, "create_session_201.json")), + } + httpClient := &http.Client{ + Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusCreated, + Body: trackedBody, + Header: make(http.Header), + ContentLength: -1, + }, nil + }), + } + + client, err := NewDiditClient(diditTestConfig("https://verification.invalid"), httpClient) + require.NoError(t, err) + + _, err = client.CreateSession(context.Background(), model.DiditCreateSessionRequest{ + WorkflowId: diditTestWorkflowId, + VendorData: diditTestVendorId, + ExpectedSessionKind: model.DiditSessionKindUser, + }) + require.NoError(t, err) + require.True(t, trackedBody.closed) +} + +func TestNewDiditClientValidation(t *testing.T) { + missingKeyConfig := diditTestConfig("https://verification.invalid") + missingKeyConfig.ApiKey = "" + _, err := NewDiditClient(missingKeyConfig, nil) + require.ErrorContains(t, err, "DIDIT_API_KEY") + + _, err = NewDiditClient(diditTestConfig(":::"), nil) + require.ErrorContains(t, err, "DIDIT_API_URL") + + _, err = NewDiditClient(diditTestConfig("http://verification.invalid"), nil) + require.ErrorContains(t, err, "https") + + client, err := NewDiditClient(diditTestConfig("https://verification.invalid"), nil) + require.NoError(t, err) + require.Equal(t, defaultDiditTimeout, client.httpClient.Timeout) + + noTimeoutClient := &http.Client{} + client, err = NewDiditClient(diditTestConfig("https://verification.invalid"), noTimeoutClient) + require.NoError(t, err) + require.Equal(t, defaultDiditTimeout, client.httpClient.Timeout) + require.Zero(t, noTimeoutClient.Timeout) +} + +func readDiditFixture(t *testing.T, name string) []byte { + t.Helper() + body, err := os.ReadFile("testdata/didit/" + name) + require.NoError(t, err) + return body +} + +func diditTestConfig(apiUrl string) config.DiditConfig { + return config.DiditConfig{ + ApiUrl: apiUrl, + ApiKey: diditTestAPIKey, + Environment: model.VerificationEnvironmentSandbox, + KycWorkflowId: diditTestWorkflowId.String(), + KybWorkflowId: diditTestBusinessWorkflowId.String(), + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (function roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} + +type trackedReadCloser struct { + io.Reader + closed bool +} + +func (body *trackedReadCloser) Close() error { + if body.closed { + return errors.New("response body closed twice") + } + body.closed = true + return nil +} diff --git a/service/diditPolicy.go b/service/diditPolicy.go new file mode 100644 index 0000000..b16a732 --- /dev/null +++ b/service/diditPolicy.go @@ -0,0 +1,400 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/config" + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/biter777/countries" + "github.com/google/uuid" +) + +type DiditQuestionnaireFields struct { + FirstName uuid.UUID + LastName uuid.UUID + CompanyName uuid.UUID + TaxId uuid.UUID + VatNumber uuid.UUID + Address uuid.UUID + City uuid.UUID + PostalCode uuid.UUID + State uuid.UUID + Country uuid.UUID +} + +type DiditVerificationPolicy struct { + ApprovalPolicy DiditApprovalPolicy + QuestionnaireFields DiditQuestionnaireFields +} + +type DiditPolicySet struct { + Kyc DiditVerificationPolicy + Kyb DiditVerificationPolicy +} + +func NewDiditPolicySet(cfg config.DiditConfig) (DiditPolicySet, error) { + kyc, err := newDiditVerificationPolicy( + cfg.KycWorkflowId, + cfg.KycWorkflowVersion, + model.DiditSessionKindUser, + cfg.KycQuestionnaire, + []string{"ID_VERIFICATION", "LIVENESS", "FACE_MATCH", "POA", "QUESTIONNAIRE", "AML"}, + ) + if err != nil { + return DiditPolicySet{}, fmt.Errorf("invalid Didit KYC policy: %w", err) + } + kyb, err := newDiditVerificationPolicy( + cfg.KybWorkflowId, + cfg.KybWorkflowVersion, + model.DiditSessionKindBusiness, + cfg.KybQuestionnaire, + []string{"KYB_REGISTRY", "AML", "KYB_DOCUMENTS", "KYB_KEY_PEOPLE", "QUESTIONNAIRE"}, + ) + if err != nil { + return DiditPolicySet{}, fmt.Errorf("invalid Didit KYB policy: %w", err) + } + kyc.ApprovalPolicy.ResidenceCountryQuestionId = kyc.QuestionnaireFields.Country + kyc.ApprovalPolicy.RestrictedResidenceCountries = Ratio1RestrictedResidenceCountries() + kyb.ApprovalPolicy.RequireCompanyRegistrationNumber = true + return DiditPolicySet{Kyc: kyc, Kyb: kyb}, nil +} + +func (p DiditPolicySet) ForApplicantType(applicantType string) (DiditVerificationPolicy, error) { + switch applicantType { + case model.IndividualCustomer: + return p.Kyc, nil + case model.BusinessCustomer: + return p.Kyb, nil + default: + return DiditVerificationPolicy{}, errors.New("unsupported applicant type") + } +} + +func newDiditVerificationPolicy( + workflowIdValue string, + workflowVersion int, + sessionKind model.DiditSessionKind, + questionnaire config.DiditQuestionnaireConfig, + requiredFeatures []string, +) (DiditVerificationPolicy, error) { + workflowId, err := uuid.Parse(workflowIdValue) + if err != nil || workflowVersion <= 0 { + return DiditVerificationPolicy{}, errors.New("workflow id and version are required") + } + questionnaireId, err := uuid.Parse(questionnaire.QuestionnaireId) + if err != nil || questionnaire.QuestionnaireVersion <= 0 { + return DiditVerificationPolicy{}, errors.New("questionnaire id and version are required") + } + + fields := DiditQuestionnaireFields{} + if sessionKind == model.DiditSessionKindUser { + fields.FirstName, err = requiredDiditQuestionId(questionnaire.FirstNameQuestionId, "first name") + if err != nil { + return DiditVerificationPolicy{}, err + } + fields.LastName, err = requiredDiditQuestionId(questionnaire.LastNameQuestionId, "last name") + if err != nil { + return DiditVerificationPolicy{}, err + } + fields.TaxId, err = requiredDiditQuestionId(questionnaire.TaxIdQuestionId, "tax id") + if err != nil { + return DiditVerificationPolicy{}, err + } + fields.Address, err = requiredDiditQuestionId(questionnaire.AddressQuestionId, "address") + if err != nil { + return DiditVerificationPolicy{}, err + } + fields.City, err = requiredDiditQuestionId(questionnaire.CityQuestionId, "city") + if err != nil { + return DiditVerificationPolicy{}, err + } + fields.PostalCode, err = requiredDiditQuestionId(questionnaire.PostalCodeQuestionId, "postal code") + if err != nil { + return DiditVerificationPolicy{}, err + } + fields.State, err = requiredDiditQuestionId(questionnaire.StateQuestionId, "state") + if err != nil { + return DiditVerificationPolicy{}, err + } + fields.Country, err = requiredDiditQuestionId(questionnaire.CountryQuestionId, "country") + if err != nil { + return DiditVerificationPolicy{}, err + } + } + + requiredItems := make([]uuid.UUID, 0, 8+len(questionnaire.AdditionalRequiredQuestionIds)) + if sessionKind == model.DiditSessionKindUser { + requiredItems = append( + requiredItems, + fields.TaxId, + fields.Address, + fields.City, + fields.PostalCode, + fields.State, + fields.Country, + ) + requiredItems = append(requiredItems, fields.FirstName, fields.LastName) + } + for _, value := range questionnaire.AdditionalRequiredQuestionIds { + id, parseErr := requiredDiditQuestionId(value, "additional required") + if parseErr != nil { + return DiditVerificationPolicy{}, parseErr + } + requiredItems = append(requiredItems, id) + } + seenQuestionIds := make(map[uuid.UUID]struct{}, len(requiredItems)) + for _, id := range requiredItems { + if _, duplicate := seenQuestionIds[id]; duplicate { + return DiditVerificationPolicy{}, fmt.Errorf( + "questionnaire question id %s is configured more than once", + id, + ) + } + seenQuestionIds[id] = struct{}{} + } + + return DiditVerificationPolicy{ + QuestionnaireFields: fields, + ApprovalPolicy: DiditApprovalPolicy{ + WorkflowId: workflowId, + WorkflowVersion: workflowVersion, + SessionKind: sessionKind, + RequiredFeatures: requiredFeatures, + QuestionnaireId: questionnaireId, + QuestionnaireVersion: questionnaire.QuestionnaireVersion, + RequiredQuestionnaireItems: requiredItems, + }, + }, nil +} + +func requiredDiditQuestionId(value, label string) (uuid.UUID, error) { + id, err := uuid.Parse(strings.TrimSpace(value)) + if err != nil { + return uuid.Nil, fmt.Errorf("%s question id is required", label) + } + return id, nil +} + +func MapDiditDecisionToUserInfo( + decision model.DiditDecision, + policy DiditVerificationPolicy, + blockchainAddress, email string, +) (*model.UserInfo, string, bool, error) { + if EvaluateDiditApprovalEvidence( + decision, + policy.ApprovalPolicy.WorkflowVersion, + policy.ApprovalPolicy, + ) != DiditEvidenceEligible { + return nil, "", false, errors.New("Didit approval evidence is incomplete") + } + answers := diditQuestionnaireAnswers(decision) + value := func(id uuid.UUID) (string, error) { + answer := strings.TrimSpace(answers[id]) + if answer == "" { + return "", errors.New("required Didit questionnaire answer is missing") + } + return answer, nil + } + + userInfo := &model.UserInfo{ + BlockchainAddress: blockchainAddress, + Email: email, + } + country := "" + viesRegistered := false + switch decision.SessionKind { + case model.DiditSessionKindUser: + address, addressErr := value(policy.QuestionnaireFields.Address) + if addressErr != nil { + return nil, "", false, addressErr + } + city, cityErr := value(policy.QuestionnaireFields.City) + if cityErr != nil { + return nil, "", false, cityErr + } + if _, postalErr := value(policy.QuestionnaireFields.PostalCode); postalErr != nil { + return nil, "", false, postalErr + } + state, stateErr := value(policy.QuestionnaireFields.State) + if stateErr != nil { + return nil, "", false, stateErr + } + var countryErr error + country, countryErr = value(policy.QuestionnaireFields.Country) + if countryErr != nil { + return nil, "", false, countryErr + } + country = strings.ToUpper(country) + if len(country) != 3 { + return nil, "", false, errors.New("billing country must use ISO alpha-3") + } + firstName, nameErr := value(policy.QuestionnaireFields.FirstName) + if nameErr != nil { + return nil, "", false, nameErr + } + lastName, nameErr := value(policy.QuestionnaireFields.LastName) + if nameErr != nil { + return nil, "", false, nameErr + } + if len(decision.IdVerifications) != 1 || + !strings.EqualFold(strings.TrimSpace(decision.IdVerifications[0].FirstName), firstName) || + !strings.EqualFold(strings.TrimSpace(decision.IdVerifications[0].LastName), lastName) { + return nil, "", false, errors.New("billing name does not match verified identity") + } + taxId, taxErr := value(policy.QuestionnaireFields.TaxId) + if taxErr != nil { + return nil, "", false, taxErr + } + userInfo.Name = &firstName + userInfo.Surname = &lastName + userInfo.IdentificationCode = taxId + userInfo.Address = address + userInfo.City = city + userInfo.State = state + userInfo.Country = country + case model.DiditSessionKindBusiness: + company, companyErr := diditApprovedCompany(decision) + if companyErr != nil { + return nil, "", false, companyErr + } + submitted := diditKybSubmittedCompany(company.UserProvidedData) + companyName := firstNonEmpty(company.CompanyName, submitted.CompanyName) + taxId := firstNonEmpty(company.TaxNumber, submitted.TaxNumber) + vatNumber := firstNonEmpty(company.VatNumber, submitted.VatNumber) + address := firstNonEmpty(company.RegisteredAddress, submitted.LegalAddress) + city := firstNonEmpty(company.LocationOfRegistration, submitted.City) + country, companyErr = normalizeDiditCountryCode( + firstNonEmpty(company.CountryCode, submitted.CountryCode), + ) + if companyErr != nil { + return nil, "", false, companyErr + } + region := firstNonEmpty(submitted.Region, submitted.State) + if region == "" { + region = city + } + if companyName == "" || + taxId == "" || + address == "" || + city == "" || + region == "" || + len(country) != 3 { + return nil, "", false, errors.New("Didit native KYB invoicing data is incomplete") + } + identificationCode := taxId + if isUeCountry(country) { + if vatNumber == "" { + return nil, "", false, errors.New("Didit native KYB VAT number is required for an EU company") + } + identificationCode = vatNumber + viesRegistered = strings.EqualFold(company.VatValidationStatus, "valid") + } + userInfo.CompanyName = &companyName + userInfo.IdentificationCode = identificationCode + userInfo.Address = address + userInfo.City = city + userInfo.State = region + userInfo.Country = country + userInfo.IsCompany = true + default: + return nil, "", false, errors.New("unsupported Didit session kind") + } + if err := ValidateData(*userInfo); err != nil { + return nil, "", false, err + } + return userInfo, country, viesRegistered, nil +} + +type diditCompanyProjection struct { + CompanyName string `json:"company_name"` + CountryCode string `json:"country_code"` + RegistrationNumber string `json:"registration_number"` + RegisteredAddress string `json:"registered_address"` + LocationOfRegistration string `json:"location_of_registration"` + TaxNumber string `json:"tax_number"` + VatNumber string `json:"vat_number"` + VatValidationStatus string `json:"vat_validation_status"` + UserProvidedData json.RawMessage `json:"user_provided_data"` +} + +type diditKybSubmittedCompanyProjection struct { + CompanyName string `json:"company_name"` + CountryCode string `json:"country_code"` + Region string `json:"region"` + State string `json:"state"` + City string `json:"city"` + LegalAddress string `json:"legal_address"` + TaxNumber string `json:"tax_number"` + VatNumber string `json:"vat_number"` +} + +func diditKybSubmittedCompany(raw json.RawMessage) diditKybSubmittedCompanyProjection { + var data diditKybSubmittedCompanyProjection + if len(raw) == 0 || json.Unmarshal(raw, &data) != nil { + return diditKybSubmittedCompanyProjection{} + } + return data +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if normalized := strings.TrimSpace(value); normalized != "" { + return normalized + } + } + return "" +} + +func normalizeDiditCountryCode(value string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(value)) + if len(normalized) != 2 && len(normalized) != 3 { + return "", errors.New("Didit company country code must use ISO 3166-1 alpha-2 or alpha-3") + } + for _, character := range normalized { + if character < 'A' || character > 'Z' { + return "", errors.New("Didit company country code must use ISO 3166-1 alpha-2 or alpha-3") + } + } + code := countries.ByName(normalized) + if code == countries.Unknown { + return "", errors.New("Didit company country code is not a valid ISO 3166-1 code") + } + return code.Alpha3(), nil +} + +func diditApprovedCompany(decision model.DiditDecision) (diditCompanyProjection, error) { + if len(decision.RegistryChecks) != 1 { + return diditCompanyProjection{}, errors.New("Didit company registry evidence is missing") + } + var company diditCompanyProjection + if err := json.Unmarshal(decision.RegistryChecks[0].Company, &company); err != nil || + strings.TrimSpace(company.CompanyName) == "" || + strings.TrimSpace(company.CountryCode) == "" || + strings.TrimSpace(company.RegistrationNumber) == "" { + return diditCompanyProjection{}, errors.New("Didit company registry evidence is incomplete") + } + return company, nil +} + +func diditQuestionnaireAnswers(decision model.DiditDecision) map[uuid.UUID]string { + answers := make(map[uuid.UUID]string) + for _, response := range decision.QuestionnaireResponses { + for _, section := range response.Sections { + for _, item := range section.Items { + if item.Answer == nil { + continue + } + switch { + case item.Answer.Value != nil: + answers[item.Uuid] = *item.Answer.Value + case item.Answer.Text != nil: + answers[item.Uuid] = *item.Answer.Text + } + } + } + } + return answers +} diff --git a/service/diditPolicy_test.go b/service/diditPolicy_test.go new file mode 100644 index 0000000..3ea5f79 --- /dev/null +++ b/service/diditPolicy_test.go @@ -0,0 +1,281 @@ +package service + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/config" + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestNewDiditPolicySetBuildsRuntimeKycAndKybPolicies(t *testing.T) { + cfg := diditPolicyTestConfig() + + policies, err := NewDiditPolicySet(cfg) + require.NoError(t, err) + + require.Equal(t, model.DiditSessionKindUser, policies.Kyc.ApprovalPolicy.SessionKind) + require.Equal(t, uuid.MustParse(cfg.KycWorkflowId), policies.Kyc.ApprovalPolicy.WorkflowId) + require.Equal(t, cfg.KycWorkflowVersion, policies.Kyc.ApprovalPolicy.WorkflowVersion) + require.Equal(t, uuid.MustParse(cfg.KycQuestionnaire.CountryQuestionId), policies.Kyc.ApprovalPolicy.ResidenceCountryQuestionId) + require.NotEmpty(t, policies.Kyc.ApprovalPolicy.RestrictedResidenceCountries) + require.ElementsMatch(t, []string{ + "ID_VERIFICATION", + "LIVENESS", + "FACE_MATCH", + "POA", + "QUESTIONNAIRE", + "AML", + }, policies.Kyc.ApprovalPolicy.RequiredFeatures) + + require.Equal(t, model.DiditSessionKindBusiness, policies.Kyb.ApprovalPolicy.SessionKind) + require.Equal(t, uuid.MustParse(cfg.KybWorkflowId), policies.Kyb.ApprovalPolicy.WorkflowId) + require.Equal(t, cfg.KybWorkflowVersion, policies.Kyb.ApprovalPolicy.WorkflowVersion) + require.True(t, policies.Kyb.ApprovalPolicy.RequireCompanyRegistrationNumber) + require.ElementsMatch(t, []string{ + "KYB_REGISTRY", + "AML", + "KYB_DOCUMENTS", + "KYB_KEY_PEOPLE", + "QUESTIONNAIRE", + }, policies.Kyb.ApprovalPolicy.RequiredFeatures) + require.Contains( + t, + policies.Kyb.ApprovalPolicy.RequiredQuestionnaireItems, + uuid.MustParse(cfg.KybQuestionnaire.AdditionalRequiredQuestionIds[0]), + ) +} + +func TestNewDiditPolicySetDoesNotRequireDuplicatedKybBillingQuestions(t *testing.T) { + cfg := diditPolicyTestConfig() + cfg.KybQuestionnaire.CompanyNameQuestionId = "" + cfg.KybQuestionnaire.TaxIdQuestionId = "" + cfg.KybQuestionnaire.VatNumberQuestionId = "" + cfg.KybQuestionnaire.AddressQuestionId = "" + cfg.KybQuestionnaire.CityQuestionId = "" + cfg.KybQuestionnaire.PostalCodeQuestionId = "" + cfg.KybQuestionnaire.StateQuestionId = "" + cfg.KybQuestionnaire.CountryQuestionId = "" + + policies, err := NewDiditPolicySet(cfg) + require.NoError(t, err) + require.Equal(t, uuid.Nil, policies.Kyb.QuestionnaireFields.CompanyName) + require.Len( + t, + policies.Kyb.ApprovalPolicy.RequiredQuestionnaireItems, + len(cfg.KybQuestionnaire.AdditionalRequiredQuestionIds), + ) +} + +func TestNewDiditPolicySetRejectsDuplicateQuestionIds(t *testing.T) { + cfg := diditPolicyTestConfig() + cfg.KybQuestionnaire.AdditionalRequiredQuestionIds = []string{ + cfg.KybQuestionnaire.AdditionalRequiredQuestionIds[0], + cfg.KybQuestionnaire.AdditionalRequiredQuestionIds[0], + } + + _, err := NewDiditPolicySet(cfg) + require.ErrorContains(t, err, "configured more than once") +} + +func TestDiditPolicySetSelectsOnlySupportedApplicantTypes(t *testing.T) { + policies, err := NewDiditPolicySet(diditPolicyTestConfig()) + require.NoError(t, err) + + kyc, err := policies.ForApplicantType(model.IndividualCustomer) + require.NoError(t, err) + require.Equal(t, model.DiditSessionKindUser, kyc.ApprovalPolicy.SessionKind) + + kyb, err := policies.ForApplicantType(model.BusinessCustomer) + require.NoError(t, err) + require.Equal(t, model.DiditSessionKindBusiness, kyb.ApprovalPolicy.SessionKind) + + _, err = policies.ForApplicantType("unknown") + require.ErrorContains(t, err, "unsupported applicant type") +} + +func TestMapDiditDecisionToUserInfoFailsClosedWithoutKybTaxOrAddress(t *testing.T) { + cfg := diditPolicyTestConfig() + var fixture model.DiditDecision + require.NoError(t, json.Unmarshal(readDiditFixture(t, "decision_business_approved.json"), &fixture)) + cfg.KybWorkflowId = fixture.WorkflowId.String() + cfg.KybQuestionnaire.QuestionnaireId = fixture.QuestionnaireResponses[0].QuestionnaireId.String() + cfg.KybQuestionnaire.QuestionnaireVersion = fixture.QuestionnaireResponses[0].Version + + policies, err := NewDiditPolicySet(cfg) + require.NoError(t, err) + + validDecision := func(t *testing.T) model.DiditDecision { + t.Helper() + var decision model.DiditDecision + require.NoError(t, json.Unmarshal(readDiditFixture(t, "decision_business_approved.json"), &decision)) + sourceAnswers := make(map[uuid.UUID]string) + for _, id := range policies.Kyb.ApprovalPolicy.RequiredQuestionnaireItems { + sourceAnswers[id] = "source-of-funds" + } + appendDiditPolicyTestAnswers(&decision, sourceAnswers) + decision.RegistryChecks[0].Company = json.RawMessage(`{ + "company_name":"Example SRL", + "country_code":"IT", + "registration_number":"REG-001", + "registered_address":"Via Test 1", + "location_of_registration":"Rome", + "tax_number":"IT-TAX-001", + "vat_number":"IT12345678901", + "vat_validation_status":"valid", + "user_provided_data":{"region":"RM"} + }`) + return decision + } + + userInfo, country, viesRegistered, err := MapDiditDecisionToUserInfo( + validDecision(t), + policies.Kyb, + "0x0000000000000000000000000000000000000001", + "company@example.test", + ) + require.NoError(t, err) + require.Equal(t, "IT12345678901", userInfo.IdentificationCode) + require.Equal(t, "Via Test 1", userInfo.Address) + require.Equal(t, "ITA", country) + require.True(t, viesRegistered) + + tests := []struct { + name string + mutate func(*model.DiditDecision) + }{ + { + name: "missing native tax id", + mutate: func(decision *model.DiditDecision) { + decision.RegistryChecks[0].Company = json.RawMessage(`{ + "company_name":"Example SRL", + "country_code":"ITA", + "registration_number":"REG-001", + "registered_address":"Via Test 1", + "location_of_registration":"Rome", + "vat_number":"IT12345678901", + "vat_validation_status":"valid", + "user_provided_data":{"region":"RM"} + }`) + }, + }, + { + name: "missing native legal address", + mutate: func(decision *model.DiditDecision) { + decision.RegistryChecks[0].Company = json.RawMessage(`{ + "company_name":"Example SRL", + "country_code":"ITA", + "registration_number":"REG-001", + "location_of_registration":"Rome", + "tax_number":"IT-TAX-001", + "vat_number":"IT12345678901", + "vat_validation_status":"valid", + "user_provided_data":{"region":"RM"} + }`) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + decision := validDecision(t) + test.mutate(&decision) + + userInfo, country, viesRegistered, err := MapDiditDecisionToUserInfo( + decision, + policies.Kyb, + "0x0000000000000000000000000000000000000001", + "company@example.test", + ) + require.Error(t, err) + require.Nil(t, userInfo) + require.Empty(t, country) + require.False(t, viesRegistered) + }) + } +} + +func TestNormalizeDiditCountryCodeUsesUntouchedSandboxKybFixture(t *testing.T) { + var decision model.DiditDecision + require.NoError(t, json.Unmarshal( + readDiditFixture(t, "decision_business_approved.json"), + &decision, + )) + company, err := diditApprovedCompany(decision) + require.NoError(t, err) + require.Equal(t, "IT", company.CountryCode) + + tests := []struct { + name string + input string + expected string + eu bool + }{ + {name: "sandbox EU alpha-2", input: company.CountryCode, expected: "ITA", eu: true}, + {name: "non-EU alpha-2", input: "US", expected: "USA", eu: false}, + {name: "alpha-3 remains canonical", input: "gbr", expected: "GBR", eu: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + country, normalizeErr := normalizeDiditCountryCode(test.input) + require.NoError(t, normalizeErr) + require.Equal(t, test.expected, country) + require.Equal(t, test.eu, isUeCountry(country)) + }) + } + + for _, invalid := range []string{"ZZ", "Italy", "380", "I1", ""} { + _, err = normalizeDiditCountryCode(invalid) + require.Error(t, err, invalid) + } +} + +func diditPolicyTestConfig() config.DiditConfig { + return config.DiditConfig{ + KycWorkflowId: diditPolicyTestUuid(1).String(), + KycWorkflowVersion: 3, + KybWorkflowId: diditPolicyTestUuid(2).String(), + KybWorkflowVersion: 4, + KycQuestionnaire: config.DiditQuestionnaireConfig{ + QuestionnaireId: diditPolicyTestUuid(10).String(), + QuestionnaireVersion: 5, + FirstNameQuestionId: diditPolicyTestUuid(11).String(), + LastNameQuestionId: diditPolicyTestUuid(12).String(), + TaxIdQuestionId: diditPolicyTestUuid(13).String(), + AddressQuestionId: diditPolicyTestUuid(14).String(), + CityQuestionId: diditPolicyTestUuid(15).String(), + PostalCodeQuestionId: diditPolicyTestUuid(16).String(), + StateQuestionId: diditPolicyTestUuid(17).String(), + CountryQuestionId: diditPolicyTestUuid(18).String(), + }, + KybQuestionnaire: config.DiditQuestionnaireConfig{ + QuestionnaireId: diditPolicyTestUuid(20).String(), + QuestionnaireVersion: 6, + AdditionalRequiredQuestionIds: []string{ + diditPolicyTestUuid(29).String(), + diditPolicyTestUuid(30).String(), + }, + }, + } +} + +func diditPolicyTestUuid(value int) uuid.UUID { + return uuid.MustParse(fmt.Sprintf("00000000-0000-4000-8000-%012d", value)) +} + +func appendDiditPolicyTestAnswers(decision *model.DiditDecision, answers map[uuid.UUID]string) { + items := make([]model.DiditQuestionnaireResponseItem, 0, len(answers)) + for id, value := range answers { + answer := value + items = append(items, model.DiditQuestionnaireResponseItem{ + Uuid: id, + Answer: &model.DiditQuestionnaireResponseAnswer{Value: &answer}, + }) + } + decision.QuestionnaireResponses[0].Sections = append( + decision.QuestionnaireResponses[0].Sections, + model.DiditQuestionnaireSection{Items: items}, + ) +} diff --git a/service/diditProjection.go b/service/diditProjection.go new file mode 100644 index 0000000..c58d3ea --- /dev/null +++ b/service/diditProjection.go @@ -0,0 +1,592 @@ +package service + +import ( + "encoding/json" + "strings" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" +) + +type DiditEntityStatus string + +const ( + DiditEntityActive DiditEntityStatus = "ACTIVE" + DiditEntityFlagged DiditEntityStatus = "FLAGGED" + DiditEntityBlocked DiditEntityStatus = "BLOCKED" +) + +type DiditEvidenceVerdict uint8 + +const ( + DiditEvidenceUnknown DiditEvidenceVerdict = iota + DiditEvidenceIncomplete + DiditEvidenceEligible +) + +type DiditDeclineDisposition uint8 + +const ( + DiditDeclineUnknown DiditDeclineDisposition = iota + DiditDeclineRetryable + DiditDeclineFinal +) + +type DiditLifecycleProjectionInput struct { + SessionStatus model.DiditSessionStatus + EntityStatus DiditEntityStatus + Evidence DiditEvidenceVerdict + DeclineDisposition DiditDeclineDisposition +} + +type DiditProjectionReason string + +const ( + DiditReasonEntityBlocked DiditProjectionReason = "entity_blocked" + DiditReasonEntityFlagged DiditProjectionReason = "entity_flagged" + DiditReasonUnknownEntityStatus DiditProjectionReason = "unknown_entity_status" + DiditReasonSessionNotStarted DiditProjectionReason = "session_not_started" + DiditReasonSessionPending DiditProjectionReason = "session_pending" + DiditReasonSessionInReview DiditProjectionReason = "session_in_review" + DiditReasonSessionNeedsRetry DiditProjectionReason = "session_needs_retry" + DiditReasonApprovalEvidenceReady DiditProjectionReason = "approval_evidence_ready" + DiditReasonApprovalEvidenceMissing DiditProjectionReason = "approval_evidence_missing" + DiditReasonApprovalPolicyRejected DiditProjectionReason = "approval_policy_rejected" + DiditReasonDeclineRetryable DiditProjectionReason = "decline_retryable" + DiditReasonDeclineFinal DiditProjectionReason = "decline_final" + DiditReasonDeclineUnclassified DiditProjectionReason = "decline_unclassified" + DiditReasonUnknownProviderStatus DiditProjectionReason = "unknown_provider_status" +) + +type DiditLifecycleProjection struct { + KycStatus string + Reason DiditProjectionReason +} + +func (projection DiditLifecycleProjection) GrantsAccess() bool { + return projection.KycStatus == model.StatusApproved +} + +type DiditApprovalPolicy struct { + WorkflowId uuid.UUID + WorkflowVersion int + SessionKind model.DiditSessionKind + RequiredFeatures []string + QuestionnaireId uuid.UUID + QuestionnaireVersion int + RequiredQuestionnaireItems []uuid.UUID + ResidenceCountryQuestionId uuid.UUID + RestrictedResidenceCountries map[string]struct{} + RequireCompanyRegistrationNumber bool +} + +// EvaluateDiditApprovalEvidence verifies the feature-level results required by +// the Ratio1 KYC and KYB workflows. A provider-level Approved status alone is +// not sufficient to grant access. +func EvaluateDiditApprovalEvidence( + decision model.DiditDecision, + workflowVersion int, + policy DiditApprovalPolicy, +) DiditEvidenceVerdict { + if !diditApprovalPolicyIsValid(policy) || + decision.WorkflowId != policy.WorkflowId || + decision.SessionKind != policy.SessionKind || + workflowVersion != policy.WorkflowVersion { + return DiditEvidenceUnknown + } + if decision.Status != model.DiditStatusApproved { + return DiditEvidenceUnknown + } + if diditDecisionHasErrorWarning(decision) || + !diditHasExactFeatureSet(decision.Features, policy.RequiredFeatures) || + !diditRequiredFeatureEvidenceApproved(decision, policy.RequiredFeatures) || + !diditQuestionnaireEvidenceComplete(decision, policy) { + return DiditEvidenceIncomplete + } + + switch decision.SessionKind { + case model.DiditSessionKindUser: + for _, verification := range decision.IdVerifications { + if strings.TrimSpace(verification.FullName) == "" || + strings.TrimSpace(verification.DateOfBirth) == "" || + verification.Age == nil || + *verification.Age <= 0 || + strings.TrimSpace(verification.Nationality) == "" || + strings.TrimSpace(verification.IssuingState) == "" || + strings.TrimSpace(verification.DocumentType) == "" { + return DiditEvidenceIncomplete + } + } + for _, verification := range decision.PoaVerifications { + if strings.TrimSpace(verification.NameOnDocument) == "" || + strings.TrimSpace(verification.PoaAddress) == "" || + strings.TrimSpace(verification.IssuingState) == "" || + strings.TrimSpace(verification.IssueDate) == "" { + return DiditEvidenceIncomplete + } + } + for _, screening := range decision.AmlScreenings { + if screening.TotalHits == nil || *screening.TotalHits != 0 { + return DiditEvidenceIncomplete + } + } + case model.DiditSessionKindBusiness: + for _, registry := range decision.RegistryChecks { + if !diditKybCompanyEvidenceComplete( + registry.Company, + policy.RequireCompanyRegistrationNumber, + ) { + return DiditEvidenceIncomplete + } + } + for _, verification := range decision.DocumentVerifications { + if !diditKybDocumentEvidenceComplete(verification) { + return DiditEvidenceIncomplete + } + } + for _, check := range decision.KeyPeopleChecks { + if !diditKybKeyPeopleEvidenceComplete(check) { + return DiditEvidenceIncomplete + } + } + for _, screening := range decision.AmlScreenings { + if screening.TotalHits == nil || *screening.TotalHits != 0 { + return DiditEvidenceIncomplete + } + } + default: + return DiditEvidenceUnknown + } + + return DiditEvidenceEligible +} + +func diditApprovalPolicyIsValid(policy DiditApprovalPolicy) bool { + if policy.WorkflowId == uuid.Nil || + policy.WorkflowVersion <= 0 || + !isKnownDiditSessionKind(policy.SessionKind) || + len(policy.RequiredFeatures) == 0 || + policy.QuestionnaireId == uuid.Nil || + policy.QuestionnaireVersion <= 0 || + len(policy.RequiredQuestionnaireItems) == 0 { + return false + } + requiredItems := make(map[uuid.UUID]struct{}, len(policy.RequiredQuestionnaireItems)) + for _, itemId := range policy.RequiredQuestionnaireItems { + if itemId == uuid.Nil { + return false + } + requiredItems[itemId] = struct{}{} + } + if len(requiredItems) != len(policy.RequiredQuestionnaireItems) { + return false + } + for _, feature := range policy.RequiredFeatures { + if !diditFeatureSupportedForSession(feature, policy.SessionKind) { + return false + } + } + if policy.ResidenceCountryQuestionId != uuid.Nil { + if _, required := requiredItems[policy.ResidenceCountryQuestionId]; !required || + len(policy.RestrictedResidenceCountries) == 0 { + return false + } + } + return true +} + +func diditHasExactFeatureSet(actual []string, expected []string) bool { + if len(actual) != len(expected) { + return false + } + expectedSet := make(map[string]struct{}, len(expected)) + for _, feature := range expected { + normalized := strings.ToUpper(strings.TrimSpace(feature)) + if normalized == "" { + return false + } + expectedSet[normalized] = struct{}{} + } + if len(expectedSet) != len(expected) { + return false + } + for _, feature := range actual { + normalized := strings.ToUpper(strings.TrimSpace(feature)) + if _, found := expectedSet[normalized]; !found { + return false + } + delete(expectedSet, normalized) + } + return len(expectedSet) == 0 +} + +func diditFeatureSupportedForSession(feature string, sessionKind model.DiditSessionKind) bool { + normalized := strings.ToUpper(strings.TrimSpace(feature)) + switch normalized { + case "AML", "PHONE", "EMAIL_VERIFICATION", "IP_ANALYSIS", "QUESTIONNAIRE": + return true + case "ID_VERIFICATION", "NFC", "LIVENESS", "FACE_MATCH", "POA", "DOCUMENT_AI", "DATABASE_VALIDATION": + return sessionKind == model.DiditSessionKindUser + case "KYB_REGISTRY", "KYB_DOCUMENTS", "KYB_KEY_PEOPLE": + return sessionKind == model.DiditSessionKindBusiness + default: + return false + } +} + +func diditRequiredFeatureEvidenceApproved(decision model.DiditDecision, requiredFeatures []string) bool { + for _, feature := range requiredFeatures { + switch strings.ToUpper(strings.TrimSpace(feature)) { + case "ID_VERIFICATION": + if !diditAllApproved(decision.IdVerifications, func(result model.DiditIdVerification) string { return result.Status }) { + return false + } + case "NFC": + if !diditAllApproved(decision.NfcVerifications, func(result model.DiditFeatureResult) string { return result.Status }) { + return false + } + case "LIVENESS": + if !diditAllApproved(decision.LivenessChecks, func(result model.DiditFeatureResult) string { return result.Status }) { + return false + } + case "FACE_MATCH": + if !diditAllApproved(decision.FaceMatches, func(result model.DiditFeatureResult) string { return result.Status }) { + return false + } + case "POA": + if !diditAllApproved(decision.PoaVerifications, func(result model.DiditPoaVerification) string { return result.Status }) { + return false + } + case "PHONE": + if !diditAllApproved(decision.PhoneVerifications, func(result model.DiditFeatureResult) string { return result.Status }) { + return false + } + case "EMAIL_VERIFICATION": + if !diditAllApproved(decision.EmailVerifications, func(result model.DiditFeatureResult) string { return result.Status }) { + return false + } + case "DOCUMENT_AI": + if !diditAllApproved(decision.DocumentAiDocuments, func(result model.DiditDocumentAiResult) string { return result.Status }) { + return false + } + case "AML": + if !diditAllApproved(decision.AmlScreenings, func(result model.DiditAmlScreening) string { return result.Status }) { + return false + } + case "IP_ANALYSIS": + if !diditAllApproved(decision.IpAnalyses, func(result model.DiditFeatureResult) string { return result.Status }) { + return false + } + case "DATABASE_VALIDATION": + if !diditAllApproved(decision.DatabaseValidations, func(result model.DiditFeatureResult) string { return result.Status }) { + return false + } + case "QUESTIONNAIRE": + if !diditAllApproved(decision.QuestionnaireResponses, func(result model.DiditQuestionnaireResponse) string { return result.Status }) { + return false + } + case "KYB_REGISTRY": + if !diditAllApproved(decision.RegistryChecks, func(result model.DiditRegistryCheck) string { return result.Status }) { + return false + } + case "KYB_DOCUMENTS": + if !diditAllApproved(decision.DocumentVerifications, func(result model.DiditDocumentVerification) string { return result.Status }) { + return false + } + case "KYB_KEY_PEOPLE": + if !diditAllApproved(decision.KeyPeopleChecks, func(result model.DiditKeyPeopleCheck) string { return result.Status }) { + return false + } + default: + return false + } + } + return true +} + +func diditQuestionnaireEvidenceComplete(decision model.DiditDecision, policy DiditApprovalPolicy) bool { + if len(decision.QuestionnaireResponses) != 1 { + return false + } + response := decision.QuestionnaireResponses[0] + if response.QuestionnaireId != policy.QuestionnaireId || + response.Version != policy.QuestionnaireVersion || + !diditStatusApproved(response.Status) { + return false + } + + items := make(map[uuid.UUID]model.DiditQuestionnaireResponseItem) + for _, section := range response.Sections { + for _, item := range section.Items { + if item.Uuid == uuid.Nil { + return false + } + if _, duplicate := items[item.Uuid]; duplicate { + return false + } + items[item.Uuid] = item + } + } + for _, requiredItemId := range policy.RequiredQuestionnaireItems { + item, found := items[requiredItemId] + if !found || !diditQuestionnaireAnswerComplete(item.Answer) { + return false + } + } + + if policy.ResidenceCountryQuestionId != uuid.Nil { + countryItem, found := items[policy.ResidenceCountryQuestionId] + if !found || countryItem.Answer == nil || countryItem.Answer.Value == nil { + return false + } + country := strings.ToUpper(strings.TrimSpace(*countryItem.Answer.Value)) + if country == "" { + return false + } + if _, restricted := policy.RestrictedResidenceCountries[country]; restricted { + return false + } + } + return true +} + +func diditQuestionnaireAnswerComplete(answer *model.DiditQuestionnaireResponseAnswer) bool { + if answer == nil { + return false + } + if answer.Value != nil && strings.TrimSpace(*answer.Value) != "" { + return true + } + if answer.Text != nil && strings.TrimSpace(*answer.Text) != "" { + return true + } + for _, file := range answer.Files { + if strings.TrimSpace(file) != "" { + return true + } + } + return false +} + +func diditQuestionnaireAnswerValue( + decision model.DiditDecision, + questionId uuid.UUID, +) (string, bool) { + if questionId == uuid.Nil { + return "", false + } + for _, response := range decision.QuestionnaireResponses { + for _, section := range response.Sections { + for _, item := range section.Items { + if item.Uuid == questionId && + item.Answer != nil && + item.Answer.Value != nil { + value := strings.TrimSpace(*item.Answer.Value) + return value, value != "" + } + } + } + } + return "", false +} + +type diditKybCompanyEvidence struct { + CompanyName string `json:"company_name"` + RegistrationNumber string `json:"registration_number"` + CountryCode string `json:"country_code"` +} + +func diditKybCompanyEvidenceComplete( + value json.RawMessage, + requireRegistrationNumber bool, +) bool { + var company diditKybCompanyEvidence + if json.Unmarshal(value, &company) != nil || + strings.TrimSpace(company.CompanyName) == "" || + strings.TrimSpace(company.CountryCode) == "" { + return false + } + return !requireRegistrationNumber || strings.TrimSpace(company.RegistrationNumber) != "" +} + +type diditKybDocumentGroupEvidence struct { + Total *int `json:"total"` + Approved int `json:"approved"` + Pending int `json:"pending"` + Declined int `json:"declined"` + InReview int `json:"in_review"` + Other int `json:"other"` + Missing int `json:"missing"` +} + +func diditKybDocumentEvidenceComplete(verification model.DiditDocumentVerification) bool { + var items []map[string]json.RawMessage + var groups map[string]diditKybDocumentGroupEvidence + if json.Unmarshal(verification.Items, &items) != nil || + json.Unmarshal(verification.Groups, &groups) != nil || + len(items) == 0 || + len(verification.RequiredGroups) == 0 { + return false + } + for _, item := range items { + if len(item) == 0 { + return false + } + } + for _, requiredGroup := range verification.RequiredGroups { + group, found := groups[requiredGroup] + if !found { + group, found = groups[strings.ToLower(requiredGroup)] + } + if !found || + group.Approved <= 0 || + (group.Total != nil && (*group.Total <= 0 || group.Approved != *group.Total)) || + group.Pending != 0 || + group.Declined != 0 || + group.InReview != 0 || + group.Other != 0 || + group.Missing != 0 { + return false + } + } + return true +} + +type diditKybPartyEvidence struct { + Name string `json:"name"` + Role string `json:"role"` + Roles []json.RawMessage `json:"roles"` + KycStatus string `json:"kyc_status"` + KycSessionStatus string `json:"kyc_session_status"` + KybSubSessionStatus string `json:"kyb_sub_session_status"` + RequiresVerification bool `json:"requires_verification"` +} + +type diditKybRegistryPeopleEvidence struct { + Officers []diditKybPartyEvidence `json:"officers"` + BeneficialOwners []diditKybPartyEvidence `json:"beneficial_owners"` +} + +type diditKybSubmittedPeopleEvidence struct { + Parties []diditKybPartyEvidence `json:"parties"` +} + +func diditKybKeyPeopleEvidenceComplete(check model.DiditKeyPeopleCheck) bool { + var registry diditKybRegistryPeopleEvidence + var submitted diditKybSubmittedPeopleEvidence + if json.Unmarshal(check.Registry, ®istry) != nil || + json.Unmarshal(check.Submitted, &submitted) != nil { + return false + } + parties := append(registry.Officers, registry.BeneficialOwners...) + parties = append(parties, submitted.Parties...) + if len(parties) == 0 { + return false + } + for _, party := range parties { + if strings.TrimSpace(party.Name) == "" || + !diditKybPartyHasRole(party) { + return false + } + status := party.KycSessionStatus + if strings.TrimSpace(status) == "" { + status = party.KybSubSessionStatus + } + if strings.TrimSpace(status) == "" { + status = party.KycStatus + } + if party.RequiresVerification && !diditStatusApproved(status) { + return false + } + } + return true +} + +func diditKybPartyHasRole(party diditKybPartyEvidence) bool { + if strings.TrimSpace(party.Role) != "" { + return true + } + for _, rawRole := range party.Roles { + var roleName string + if json.Unmarshal(rawRole, &roleName) == nil && strings.TrimSpace(roleName) != "" { + return true + } + var roleObject struct { + Role string `json:"role"` + } + if json.Unmarshal(rawRole, &roleObject) == nil && strings.TrimSpace(roleObject.Role) != "" { + return true + } + } + return false +} + +func diditAllApproved[T any](results []T, status func(T) string) bool { + if len(results) == 0 { + return false + } + for _, result := range results { + if !diditStatusApproved(status(result)) { + return false + } + } + return true +} + +func diditStatusApproved(status string) bool { + return strings.EqualFold(strings.TrimSpace(status), string(model.DiditStatusApproved)) +} + +func diditDecisionHasErrorWarning(decision model.DiditDecision) bool { + for _, warning := range diditDecisionWarnings(decision) { + if strings.EqualFold(strings.TrimSpace(warning.LogType), "error") { + return true + } + } + return false +} + +func ProjectDiditLifecycle(input DiditLifecycleProjectionInput) DiditLifecycleProjection { + switch input.EntityStatus { + case DiditEntityBlocked: + return DiditLifecycleProjection{KycStatus: model.StatusFinalRejected, Reason: DiditReasonEntityBlocked} + case DiditEntityFlagged: + return DiditLifecycleProjection{KycStatus: model.StatusOnHold, Reason: DiditReasonEntityFlagged} + case DiditEntityActive, "": + default: + return DiditLifecycleProjection{KycStatus: model.StatusOnHold, Reason: DiditReasonUnknownEntityStatus} + } + + switch input.SessionStatus { + case model.DiditStatusNotStarted: + return DiditLifecycleProjection{KycStatus: model.StatusInit, Reason: DiditReasonSessionNotStarted} + case model.DiditStatusInProgress, model.DiditStatusAwaitingUser: + return DiditLifecycleProjection{KycStatus: model.StatusPending, Reason: DiditReasonSessionPending} + case model.DiditStatusInReview: + return DiditLifecycleProjection{KycStatus: model.StatusOnHold, Reason: DiditReasonSessionInReview} + case model.DiditStatusResubmitted, + model.DiditStatusExpired, + model.DiditStatusAbandoned, + model.DiditStatusKycExpired: + return DiditLifecycleProjection{KycStatus: model.StatusRejected, Reason: DiditReasonSessionNeedsRetry} + case model.DiditStatusApproved: + if input.DeclineDisposition == DiditDeclineFinal { + return DiditLifecycleProjection{KycStatus: model.StatusFinalRejected, Reason: DiditReasonApprovalPolicyRejected} + } + if input.Evidence == DiditEvidenceEligible { + return DiditLifecycleProjection{KycStatus: model.StatusApproved, Reason: DiditReasonApprovalEvidenceReady} + } + return DiditLifecycleProjection{KycStatus: model.StatusOnHold, Reason: DiditReasonApprovalEvidenceMissing} + case model.DiditStatusDeclined: + switch input.DeclineDisposition { + case DiditDeclineRetryable: + return DiditLifecycleProjection{KycStatus: model.StatusRejected, Reason: DiditReasonDeclineRetryable} + case DiditDeclineFinal: + return DiditLifecycleProjection{KycStatus: model.StatusFinalRejected, Reason: DiditReasonDeclineFinal} + default: + return DiditLifecycleProjection{KycStatus: model.StatusOnHold, Reason: DiditReasonDeclineUnclassified} + } + default: + return DiditLifecycleProjection{KycStatus: model.StatusOnHold, Reason: DiditReasonUnknownProviderStatus} + } +} diff --git a/service/diditProjection_test.go b/service/diditProjection_test.go new file mode 100644 index 0000000..634ec98 --- /dev/null +++ b/service/diditProjection_test.go @@ -0,0 +1,719 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestEvaluateDiditApprovalEvidence(t *testing.T) { + tests := []struct { + name string + fixture string + policy DiditApprovalPolicy + workflowVersion int + mutate func(*model.DiditDecision) + expected DiditEvidenceVerdict + }{ + { + name: "complete KYC evidence", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + expected: DiditEvidenceEligible, + }, + { + name: "incomplete KYC evidence", + fixture: "decision_user_approved_incomplete.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + expected: DiditEvidenceIncomplete, + }, + { + name: "complete KYB evidence", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + expected: DiditEvidenceEligible, + }, + { + name: "provider approval with AML hit fails closed", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.AmlScreenings[0].TotalHits = diditIntPointer(1) + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "provider approval without AML hit count fails closed", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.AmlScreenings[0].TotalHits = nil + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "provider approval with error warning fails closed", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.IdVerifications[0].Warnings = []model.DiditWarning{{ + Risk: "FUTURE_PROVIDER_RISK", + LogType: "error", + }} + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "provider approval without required questionnaire fails closed", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.QuestionnaireResponses = nil + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "empty questionnaire sections fail closed", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.QuestionnaireResponses[0].Sections = nil + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "missing required questionnaire answer fails closed", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.QuestionnaireResponses[0].Sections[0].Items[0].Answer = nil + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "restricted residence country fails approval evidence", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + diditSetQuestionnaireAnswer(t, decision, diditTestResidenceCountryQuestionId, "USA") + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "missing mapped identity fails closed", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.IdVerifications[0].DateOfBirth = "" + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "legal mononym remains eligible", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.IdVerifications[0].FirstName = "" + decision.IdVerifications[0].LastName = "" + decision.IdVerifications[0].FullName = "Sukarno" + }, + expected: DiditEvidenceEligible, + }, + { + name: "missing mapped proof of address fails closed", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.PoaVerifications[0].PoaAddress = "" + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "empty KYB company evidence fails closed", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.RegistryChecks[0].Company = json.RawMessage(`{}`) + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "KYB company without registration number fails closed", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.RegistryChecks[0].Company = json.RawMessage( + `{"company_name":"Example SRL","country_code":"IT","registration_number":""}`, + ) + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "optional KYB registration number may be absent", + fixture: "decision_business_approved.json", + policy: func() DiditApprovalPolicy { + policy := diditTestKybApprovalPolicy() + policy.RequireCompanyRegistrationNumber = false + return policy + }(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.RegistryChecks[0].Company = json.RawMessage( + `{"company_name":"Example SRL","country_code":"IT","registration_number":null}`, + ) + }, + expected: DiditEvidenceEligible, + }, + { + name: "empty KYB document evidence fails closed", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.DocumentVerifications[0].Items = json.RawMessage(`[]`) + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "KYB item status defers to approved required group counters", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.DocumentVerifications[0].Items = json.RawMessage( + `[{"document_type":"LEGAL_PRESENCE","status":"Declined"}]`, + ) + }, + expected: DiditEvidenceEligible, + }, + { + name: "declined optional KYB document does not block approval", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.DocumentVerifications[0].Items = json.RawMessage( + `[{"document_type":"LEGAL_PRESENCE","status":"Approved"},{"document_type":"OTHER","status":"Declined"}]`, + ) + }, + expected: DiditEvidenceEligible, + }, + { + name: "incomplete required KYB document group fails closed", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.DocumentVerifications[0].Groups = json.RawMessage( + `{"LEGAL_PRESENCE":{"total":1,"approved":0,"declined":1},"OWNERSHIP_STRUCTURE":{"total":1,"approved":1}}`, + ) + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "partially approved required KYB document group fails closed", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.DocumentVerifications[0].Groups = json.RawMessage( + `{"LEGAL_PRESENCE":{"total":2,"approved":1,"pending":0,"missing":0},"OWNERSHIP_STRUCTURE":{"approved":1,"pending":0,"missing":0}}`, + ) + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "empty KYB key people evidence fails closed", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.KeyPeopleChecks[0].Registry = json.RawMessage(`[]`) + decision.KeyPeopleChecks[0].Submitted = json.RawMessage(`[]`) + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "blank KYB key person fails closed", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.KeyPeopleChecks[0].Registry = json.RawMessage(`{"officers":[{}]}`) + decision.KeyPeopleChecks[0].Submitted = json.RawMessage(`{"parties":[]}`) + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "object-valued KYB roles are accepted", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.KeyPeopleChecks[0].Registry = json.RawMessage(`{"officers":[]}`) + decision.KeyPeopleChecks[0].Submitted = json.RawMessage( + `{"parties":[{"name":"Ada Example","roles":[{"role":"ubo","ownership_percent":100}],"requires_verification":true,"kyc_session_status":"Approved"}]}`, + ) + }, + expected: DiditEvidenceEligible, + }, + { + name: "declined required KYB key person fails closed", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.KeyPeopleChecks[0].Submitted = json.RawMessage( + `{"parties":[{"name":"Ada Example","role":"ubo","requires_verification":true,"kyc_session_status":"Declined"}]}`, + ) + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "skipped optional KYB key person does not block approval", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.KeyPeopleChecks[0].Registry = json.RawMessage( + `{"officers":[{"name":"Optional Secretary","role":"secretary","requires_verification":false,"is_skipped":true,"kyc_session_status":"Declined"}]}`, + ) + decision.KeyPeopleChecks[0].Submitted = json.RawMessage(`{"parties":[]}`) + decision.KeyPeopleChecks[0].UboKycSummary = json.RawMessage( + `{"total":1,"approved":0,"flagged":0,"pending":1}`, + ) + }, + expected: DiditEvidenceEligible, + }, + { + name: "pending UBO verification fails closed", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.KeyPeopleChecks[0].Submitted = json.RawMessage( + `{"parties":[{"name":"Ada Example","role":"ubo","requires_verification":true,"kyc_session_status":"In Progress"}]}`, + ) + decision.KeyPeopleChecks[0].UboKycSummary = json.RawMessage( + `{"total":1,"approved":0,"flagged":0,"pending":1}`, + ) + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "KYB without linked UBO sessions remains eligible", + fixture: "decision_business_approved.json", + policy: diditTestKybApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.KeyPeopleChecks[0].UboKycSummary = json.RawMessage(`null`) + }, + expected: DiditEvidenceEligible, + }, + { + name: "declined newly required phone feature fails closed", + fixture: "decision_user_approved.json", + policy: func() DiditApprovalPolicy { + policy := diditTestKycApprovalPolicy() + policy.RequiredFeatures = append(policy.RequiredFeatures, "PHONE") + return policy + }(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.Features = append(decision.Features, "PHONE") + decision.PhoneVerifications = []model.DiditFeatureResult{{Status: "Declined"}} + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "approved newly required phone feature is eligible", + fixture: "decision_user_approved.json", + policy: func() DiditApprovalPolicy { + policy := diditTestKycApprovalPolicy() + policy.RequiredFeatures = append(policy.RequiredFeatures, "PHONE") + return policy + }(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.Features = append(decision.Features, "PHONE") + decision.PhoneVerifications = []model.DiditFeatureResult{{Status: "Approved"}} + }, + expected: DiditEvidenceEligible, + }, + { + name: "approved standard email verification feature is eligible", + fixture: "decision_user_approved.json", + policy: func() DiditApprovalPolicy { + policy := diditTestKycApprovalPolicy() + policy.RequiredFeatures = append(policy.RequiredFeatures, "EMAIL_VERIFICATION") + return policy + }(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.Features = append(decision.Features, "EMAIL_VERIFICATION") + decision.EmailVerifications = []model.DiditFeatureResult{{Status: "Approved"}} + }, + expected: DiditEvidenceEligible, + }, + { + name: "unsupported policy feature fails closed", + fixture: "decision_user_approved.json", + policy: func() DiditApprovalPolicy { + policy := diditTestKycApprovalPolicy() + policy.RequiredFeatures = append(policy.RequiredFeatures, "FUTURE_FEATURE") + return policy + }(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.Features = append(decision.Features, "FUTURE_FEATURE") + }, + expected: DiditEvidenceUnknown, + }, + { + name: "unknown enabled feature fails closed", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.Features = append(decision.Features, "FUTURE_FEATURE") + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "missing expected feature fails closed", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.Features = decision.Features[:len(decision.Features)-1] + }, + expected: DiditEvidenceIncomplete, + }, + { + name: "workflow version drift fails closed", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 2, + expected: DiditEvidenceUnknown, + }, + { + name: "unknown session kind fails closed", + fixture: "decision_user_approved.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + mutate: func(decision *model.DiditDecision) { + decision.SessionKind = "" + }, + expected: DiditEvidenceUnknown, + }, + { + name: "non-approved session has no approval evidence", + fixture: "decision_user_declined_minimum_age.json", + policy: diditTestKycApprovalPolicy(), + workflowVersion: 1, + expected: DiditEvidenceUnknown, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var decision model.DiditDecision + require.NoError(t, json.Unmarshal(readDiditFixture(t, test.fixture), &decision)) + if test.mutate != nil { + test.mutate(&decision) + } + require.Equal( + t, + test.expected, + EvaluateDiditApprovalEvidence(decision, test.workflowVersion, test.policy), + ) + }) + } +} + +var ( + diditTestKycQuestionnaireId = uuid.MustParse("b271880d-15f5-4621-a851-a193b4aebddd") + diditTestKybQuestionnaireId = uuid.MustParse("e6c959b9-b5e2-4ba5-9217-92ebe8613f5a") + diditTestResidenceCountryQuestionId = uuid.MustParse("96646c0d-9650-4844-bd5e-85971060131e") +) + +func diditTestKycApprovalPolicy() DiditApprovalPolicy { + return DiditApprovalPolicy{ + WorkflowId: diditTestWorkflowId, + WorkflowVersion: 1, + SessionKind: model.DiditSessionKindUser, + RequiredFeatures: []string{"ID_VERIFICATION", "LIVENESS", "FACE_MATCH", "POA", "QUESTIONNAIRE", "AML"}, + QuestionnaireId: diditTestKycQuestionnaireId, + QuestionnaireVersion: 2, + RequiredQuestionnaireItems: []uuid.UUID{ + uuid.MustParse("5ab7c537-32b2-4792-b522-b9065da0df3a"), + uuid.MustParse("6828a11b-1ebf-4999-a942-bfd77dfa948c"), + uuid.MustParse("18777afd-6fb6-4a8e-817b-0d7be1229dc5"), + uuid.MustParse("fb48c0f2-46e8-4bcf-88f3-298c37178acb"), + uuid.MustParse("b8d3600e-f281-4e2a-a964-8ccde079f40a"), + uuid.MustParse("ac420e57-2192-4457-9f72-8ebcf1615cba"), + uuid.MustParse("9a0cbc08-9e41-4347-8ef3-5e0c0d4a5459"), + diditTestResidenceCountryQuestionId, + }, + ResidenceCountryQuestionId: diditTestResidenceCountryQuestionId, + RestrictedResidenceCountries: Ratio1RestrictedResidenceCountries(), + } +} + +func diditTestKybApprovalPolicy() DiditApprovalPolicy { + return DiditApprovalPolicy{ + WorkflowId: diditTestBusinessWorkflowId, + WorkflowVersion: 1, + SessionKind: model.DiditSessionKindBusiness, + RequiredFeatures: []string{"KYB_REGISTRY", "AML", "KYB_DOCUMENTS", "KYB_KEY_PEOPLE", "QUESTIONNAIRE"}, + QuestionnaireId: diditTestKybQuestionnaireId, + QuestionnaireVersion: 1, + RequiredQuestionnaireItems: []uuid.UUID{ + uuid.MustParse("1c2fd10b-c439-4f53-ba4a-d4581563b52d"), + uuid.MustParse("4ec8a1c8-1b79-4aeb-a91e-2ed2a24552a0"), + uuid.MustParse("ecac1d83-3413-4c5d-871a-a492f1a1be5b"), + uuid.MustParse("a714b441-bfd2-4d35-9248-716e668ce2b9"), + uuid.MustParse("f15f7874-69a4-45de-9cfe-c7fa854b85f6"), + uuid.MustParse("bfde3e4c-6148-45cd-a04c-c151dd789930"), + uuid.MustParse("82cfd962-7414-4f8c-8e8d-9cf1d46589de"), + uuid.MustParse("77250407-9e4c-4dfe-80a8-3f091890f217"), + uuid.MustParse("d7517e61-084e-4e8d-b96c-9f137c5b7c81"), + }, + RequireCompanyRegistrationNumber: true, + } +} + +func diditSetQuestionnaireAnswer( + t *testing.T, + decision *model.DiditDecision, + questionId uuid.UUID, + value string, +) { + t.Helper() + for responseIndex := range decision.QuestionnaireResponses { + for sectionIndex := range decision.QuestionnaireResponses[responseIndex].Sections { + items := decision.QuestionnaireResponses[responseIndex].Sections[sectionIndex].Items + for itemIndex := range items { + if items[itemIndex].Uuid == questionId { + items[itemIndex].Answer = &model.DiditQuestionnaireResponseAnswer{Value: &value} + return + } + } + } + } + t.Fatalf("questionnaire item %s not found", questionId) +} + +func diditIntPointer(value int) *int { + return &value +} + +func TestProjectDiditLifecycle(t *testing.T) { + tests := []struct { + name string + input DiditLifecycleProjectionInput + expectedStatus string + expectedReason DiditProjectionReason + grantsAccess bool + }{ + { + name: "zero value fails closed", + input: DiditLifecycleProjectionInput{}, + expectedStatus: model.StatusOnHold, + expectedReason: DiditReasonUnknownProviderStatus, + }, + { + name: "not started", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusNotStarted, + }, + expectedStatus: model.StatusInit, + expectedReason: DiditReasonSessionNotStarted, + }, + { + name: "in progress", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusInProgress, + }, + expectedStatus: model.StatusPending, + expectedReason: DiditReasonSessionPending, + }, + { + name: "awaiting user", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusAwaitingUser, + }, + expectedStatus: model.StatusPending, + expectedReason: DiditReasonSessionPending, + }, + { + name: "in review", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusInReview, + }, + expectedStatus: model.StatusOnHold, + expectedReason: DiditReasonSessionInReview, + }, + { + name: "approved without evidence", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusApproved, + Evidence: DiditEvidenceUnknown, + }, + expectedStatus: model.StatusOnHold, + expectedReason: DiditReasonApprovalEvidenceMissing, + }, + { + name: "approved with incomplete evidence", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusApproved, + Evidence: DiditEvidenceIncomplete, + }, + expectedStatus: model.StatusOnHold, + expectedReason: DiditReasonApprovalEvidenceMissing, + }, + { + name: "approved with eligible evidence", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusApproved, + Evidence: DiditEvidenceEligible, + }, + expectedStatus: model.StatusApproved, + expectedReason: DiditReasonApprovalEvidenceReady, + grantsAccess: true, + }, + { + name: "approved but backend policy rejects", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusApproved, + Evidence: DiditEvidenceIncomplete, + DeclineDisposition: DiditDeclineFinal, + }, + expectedStatus: model.StatusFinalRejected, + expectedReason: DiditReasonApprovalPolicyRejected, + }, + { + name: "declined unknown", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusDeclined, + }, + expectedStatus: model.StatusOnHold, + expectedReason: DiditReasonDeclineUnclassified, + }, + { + name: "declined retryable", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusDeclined, + DeclineDisposition: DiditDeclineRetryable, + }, + expectedStatus: model.StatusRejected, + expectedReason: DiditReasonDeclineRetryable, + }, + { + name: "declined final", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusDeclined, + DeclineDisposition: DiditDeclineFinal, + }, + expectedStatus: model.StatusFinalRejected, + expectedReason: DiditReasonDeclineFinal, + }, + { + name: "resubmitted", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusResubmitted, + }, + expectedStatus: model.StatusRejected, + expectedReason: DiditReasonSessionNeedsRetry, + }, + { + name: "expired", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusExpired, + }, + expectedStatus: model.StatusRejected, + expectedReason: DiditReasonSessionNeedsRetry, + }, + { + name: "KYC expired revokes approval", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusKycExpired, + Evidence: DiditEvidenceEligible, + }, + expectedStatus: model.StatusRejected, + expectedReason: DiditReasonSessionNeedsRetry, + }, + { + name: "abandoned", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusAbandoned, + }, + expectedStatus: model.StatusRejected, + expectedReason: DiditReasonSessionNeedsRetry, + }, + { + name: "flagged overrides eligible approval", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusApproved, + EntityStatus: DiditEntityFlagged, + Evidence: DiditEvidenceEligible, + }, + expectedStatus: model.StatusOnHold, + expectedReason: DiditReasonEntityFlagged, + }, + { + name: "blocked overrides every session outcome", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusInProgress, + EntityStatus: DiditEntityBlocked, + }, + expectedStatus: model.StatusFinalRejected, + expectedReason: DiditReasonEntityBlocked, + }, + { + name: "unknown entity status fails closed", + input: DiditLifecycleProjectionInput{ + SessionStatus: model.DiditStatusApproved, + EntityStatus: "UNKNOWN", + Evidence: DiditEvidenceEligible, + }, + expectedStatus: model.StatusOnHold, + expectedReason: DiditReasonUnknownEntityStatus, + }, + { + name: "unknown session status fails closed", + input: DiditLifecycleProjectionInput{ + SessionStatus: "Unexpected", + }, + expectedStatus: model.StatusOnHold, + expectedReason: DiditReasonUnknownProviderStatus, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + projection := ProjectDiditLifecycle(test.input) + require.Equal(t, test.expectedStatus, projection.KycStatus) + require.Equal(t, test.expectedReason, projection.Reason) + require.Equal(t, test.grantsAccess, projection.GrantsAccess()) + }) + } +} diff --git a/service/diditRisk.go b/service/diditRisk.go new file mode 100644 index 0000000..aa7e6e2 --- /dev/null +++ b/service/diditRisk.go @@ -0,0 +1,196 @@ +package service + +import ( + "strings" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" +) + +type DiditDeclinePolicy struct { + RetryableReasonCodes map[string]struct{} + FinalReasonCodes map[string]struct{} + RetryableWarningRisks map[string]struct{} + FinalWarningRisks map[string]struct{} +} + +func DefaultDiditDeclinePolicy() DiditDeclinePolicy { + return DiditDeclinePolicy{ + RetryableReasonCodes: stringSet( + "registry_company_not_found", + "registry_mismatch", + "documents_failed_ocr", + "documents_incomplete", + "key_people_incomplete", + "timeout_abandoned", + ), + FinalReasonCodes: stringSet( + "blocked_country", + "blocked_business", + "registry_company_dissolved", + "aml_confirmed_sanction", + "aml_confirmed_pep", + "analyst_rejected", + ), + RetryableWarningRisks: stringSet( + "DOCUMENT_EXPIRED", + "COULD_NOT_RECOGNIZE_DOCUMENT", + "DOCUMENT_NOT_FULLY_VISIBLE", + "IMAGE_QUALITY_TOO_LOW", + "IMAGE_TOO_BLURRY", + "IMAGE_TOO_BRIGHT", + "IMAGE_TOO_DARK", + "NO_FACE_DETECTED", + "LOW_LIVENESS_SCORE", + "MISSING_ADDRESS_INFORMATION", + "POA_DOCUMENT_EXPIRED", + "INVALID_DOCUMENT_TYPE", + "UNABLE_TO_VALIDATE_DOCUMENT_AGE", + "POA_MAX_ATTEMPTS_EXCEEDED", + "POA_DOCUMENT_NOT_SUPPORTED_FOR_APPLICATION", + "KYB_DOCUMENT_EXPIRED", + "KYB_DOCUMENT_MAX_ATTEMPTS_EXCEEDED", + "KYB_DOCUMENT_MISSING_REQUIRED_FIELD", + ), + FinalWarningRisks: stringSet( + "MINIMUM_AGE_NOT_MET", + "AGE_BELOW_MINIMUM", + "COUNTRY_NOT_ALLOWED", + "KYB_COUNTRY_RESTRICTED", + ), + } +} + +func ClassifyDiditDecline( + decision model.DiditDecision, + policy DiditDeclinePolicy, + approvalPolicy DiditApprovalPolicy, +) DiditDeclineDisposition { + if country, found := diditQuestionnaireAnswerValue( + decision, + approvalPolicy.ResidenceCountryQuestionId, + ); found { + if _, restricted := approvalPolicy.RestrictedResidenceCountries[strings.ToUpper(country)]; restricted { + return DiditDeclineFinal + } + } + + reasonCode := strings.ToLower(strings.TrimSpace(decision.DecisionReasonCode)) + if _, found := policy.FinalReasonCodes[reasonCode]; found { + return DiditDeclineFinal + } + + for _, screening := range decision.AmlScreenings { + if strings.EqualFold(screening.Status, "Declined") && + screening.TotalHits != nil && + *screening.TotalHits > 0 { + return DiditDeclineFinal + } + } + + warnings := diditDecisionWarnings(decision) + for _, warning := range warnings { + risk := strings.ToUpper(strings.TrimSpace(warning.Risk)) + if risk == "" || (warning.LogType != "" && !strings.EqualFold(warning.LogType, "error")) { + continue + } + if _, found := policy.FinalWarningRisks[risk]; found { + return DiditDeclineFinal + } + } + + if decision.Status != model.DiditStatusDeclined { + return DiditDeclineUnknown + } + + retryableSignalFound := false + unknownSignalFound := reasonCode != "" + if _, found := policy.RetryableReasonCodes[reasonCode]; found { + retryableSignalFound = true + unknownSignalFound = false + } + + for _, warning := range warnings { + risk := strings.ToUpper(strings.TrimSpace(warning.Risk)) + if risk == "" || (warning.LogType != "" && !strings.EqualFold(warning.LogType, "error")) { + continue + } + if _, found := policy.RetryableWarningRisks[risk]; found { + retryableSignalFound = true + } else { + unknownSignalFound = true + } + } + + if unknownSignalFound || !retryableSignalFound { + return DiditDeclineUnknown + } + return DiditDeclineRetryable +} + +// Ratio1RestrictedResidenceCountries mirrors the approved Sumsub deny list +// replicated into the current Didit KYC document-country policy. +func Ratio1RestrictedResidenceCountries() map[string]struct{} { + return stringSet( + "AFG", "BDI", "BFA", "BLR", "BRB", "CAF", "CCK", "CMR", "COD", "COG", + "COK", "CXR", "CYM", "EGY", "FLK", "GIN", "GNB", "GRL", "HTI", "IND", + "IRN", "IRQ", "JAM", "JOR", "KHM", "LBN", "LBY", "LKA", "MAR", "MLI", + "MMR", "MOZ", "NCL", "NGA", "NIC", "NIU", "PAK", "PAN", "PRK", "PYF", + "RUS", "SDN", "SEN", "SOM", "SSD", "SYR", "TRN", "TTO", "TUN", "TUR", + "UGA", "UKR", "USA", "VEN", "VUT", "YEM", "ZWE", + ) +} + +func diditDecisionWarnings(decision model.DiditDecision) []model.DiditWarning { + warnings := make([]model.DiditWarning, 0) + for _, result := range decision.IdVerifications { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.NfcVerifications { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.LivenessChecks { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.FaceMatches { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.PoaVerifications { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.PhoneVerifications { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.EmailVerifications { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.DocumentAiDocuments { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.AmlScreenings { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.IpAnalyses { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.DatabaseValidations { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.RegistryChecks { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.DocumentVerifications { + warnings = append(warnings, result.Warnings...) + } + for _, result := range decision.KeyPeopleChecks { + warnings = append(warnings, result.Warnings...) + } + return warnings +} + +func stringSet(values ...string) map[string]struct{} { + set := make(map[string]struct{}, len(values)) + for _, value := range values { + set[value] = struct{}{} + } + return set +} diff --git a/service/diditRisk_test.go b/service/diditRisk_test.go new file mode 100644 index 0000000..96c3a90 --- /dev/null +++ b/service/diditRisk_test.go @@ -0,0 +1,293 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/stretchr/testify/require" +) + +func TestClassifyDiditDecline(t *testing.T) { + policy := DefaultDiditDeclinePolicy() + tests := []struct { + name string + fixture string + decision model.DiditDecision + expected DiditDeclineDisposition + projection string + reason DiditProjectionReason + }{ + { + name: "jurisdiction minimum age failure is final", + fixture: "decision_user_declined_minimum_age.json", + expected: DiditDeclineFinal, + projection: model.StatusFinalRejected, + reason: DiditReasonDeclineFinal, + }, + { + name: "expired identity document can retry", + fixture: "decision_user_declined_expired_document.json", + expected: DiditDeclineRetryable, + projection: model.StatusRejected, + reason: DiditReasonDeclineRetryable, + }, + { + name: "blocked business country is final", + fixture: "decision_business_declined_blocked_country.json", + expected: DiditDeclineFinal, + projection: model.StatusFinalRejected, + reason: DiditReasonDeclineFinal, + }, + { + name: "confirmed AML hit is final", + decision: model.DiditDecision{ + Status: model.DiditStatusDeclined, + AmlScreenings: []model.DiditAmlScreening{{ + Status: "Declined", + TotalHits: diditIntPointer(1), + }}, + }, + expected: DiditDeclineFinal, + projection: model.StatusFinalRejected, + reason: DiditReasonDeclineFinal, + }, + { + name: "approved decision with final reason code is final", + decision: model.DiditDecision{ + Status: model.DiditStatusApproved, + DecisionReasonCode: "blocked_business", + }, + expected: DiditDeclineFinal, + projection: model.StatusFinalRejected, + reason: DiditReasonApprovalPolicyRejected, + }, + { + name: "approved decision with minimum age warning is final", + decision: model.DiditDecision{ + Status: model.DiditStatusApproved, + IdVerifications: []model.DiditIdVerification{{ + Warnings: []model.DiditWarning{{ + Risk: "MINIMUM_AGE_NOT_MET", + LogType: "error", + }}, + }}, + }, + expected: DiditDeclineFinal, + projection: model.StatusFinalRejected, + reason: DiditReasonApprovalPolicyRejected, + }, + { + name: "approved decision with confirmed AML hit is final", + decision: model.DiditDecision{ + Status: model.DiditStatusApproved, + AmlScreenings: []model.DiditAmlScreening{{ + Status: "Declined", + TotalHits: diditIntPointer(1), + }}, + }, + expected: DiditDeclineFinal, + projection: model.StatusFinalRejected, + reason: DiditReasonApprovalPolicyRejected, + }, + { + name: "incomplete business documents can retry", + decision: model.DiditDecision{ + Status: model.DiditStatusDeclined, + DecisionReasonCode: "documents_incomplete", + }, + expected: DiditDeclineRetryable, + projection: model.StatusRejected, + reason: DiditReasonDeclineRetryable, + }, + { + name: "unknown reason fails closed", + decision: model.DiditDecision{ + Status: model.DiditStatusDeclined, + DecisionReasonCode: "future_provider_reason", + }, + expected: DiditDeclineUnknown, + projection: model.StatusOnHold, + reason: DiditReasonDeclineUnclassified, + }, + { + name: "unknown error warning overrides retry signal", + decision: model.DiditDecision{ + Status: model.DiditStatusDeclined, + DecisionReasonCode: "documents_incomplete", + IdVerifications: []model.DiditIdVerification{{ + Warnings: []model.DiditWarning{{ + Risk: "FUTURE_PROVIDER_RISK", + LogType: "error", + }}, + }}, + }, + expected: DiditDeclineUnknown, + projection: model.StatusOnHold, + reason: DiditReasonDeclineUnclassified, + }, + { + name: "non-error warning does not classify decline", + decision: model.DiditDecision{ + Status: model.DiditStatusDeclined, + IdVerifications: []model.DiditIdVerification{{ + Warnings: []model.DiditWarning{{ + Risk: "DOCUMENT_EXPIRED", + LogType: "warning", + }}, + }}, + }, + expected: DiditDeclineUnknown, + projection: model.StatusOnHold, + reason: DiditReasonDeclineUnclassified, + }, + { + name: "non-declined decision is not classified", + decision: model.DiditDecision{ + Status: model.DiditStatusApproved, + }, + expected: DiditDeclineUnknown, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + decision := test.decision + if test.fixture != "" { + require.NoError(t, json.Unmarshal(readDiditFixture(t, test.fixture), &decision)) + } + + disposition := ClassifyDiditDecline(decision, policy, DiditApprovalPolicy{}) + require.Equal(t, test.expected, disposition) + if test.projection == "" { + return + } + + projection := ProjectDiditLifecycle(DiditLifecycleProjectionInput{ + SessionStatus: decision.Status, + DeclineDisposition: disposition, + }) + require.Equal(t, test.projection, projection.KycStatus) + require.Equal(t, test.reason, projection.Reason) + require.False(t, projection.GrantsAccess()) + }) + } +} + +func TestClassifyDiditResidenceCountryPolicy(t *testing.T) { + var decision model.DiditDecision + require.NoError(t, json.Unmarshal(readDiditFixture(t, "decision_user_approved.json"), &decision)) + diditSetQuestionnaireAnswer(t, &decision, diditTestResidenceCountryQuestionId, "USA") + + disposition := ClassifyDiditDecline( + decision, + DefaultDiditDeclinePolicy(), + diditTestKycApprovalPolicy(), + ) + require.Equal(t, DiditDeclineFinal, disposition) + + projection := ProjectDiditLifecycle(DiditLifecycleProjectionInput{ + SessionStatus: decision.Status, + Evidence: DiditEvidenceIncomplete, + DeclineDisposition: disposition, + }) + require.Equal(t, model.StatusFinalRejected, projection.KycStatus) + require.Equal(t, DiditReasonApprovalPolicyRejected, projection.Reason) + require.False(t, projection.GrantsAccess()) +} + +func TestDefaultDiditDeclinePolicyKnownKYBReasonCodes(t *testing.T) { + policy := DefaultDiditDeclinePolicy() + for _, reasonCode := range []string{ + "blocked_country", + "blocked_business", + "registry_company_dissolved", + "aml_confirmed_sanction", + "aml_confirmed_pep", + "analyst_rejected", + } { + require.Equal(t, DiditDeclineFinal, ClassifyDiditDecline(model.DiditDecision{ + Status: model.DiditStatusDeclined, + DecisionReasonCode: reasonCode, + }, policy, DiditApprovalPolicy{}), reasonCode) + } +} + +func TestDefaultDiditDeclinePolicyKnownRetryableWarnings(t *testing.T) { + policy := DefaultDiditDeclinePolicy() + tests := []struct { + risk string + feature string + }{ + {risk: "NO_FACE_DETECTED", feature: "liveness"}, + {risk: "LOW_LIVENESS_SCORE", feature: "liveness"}, + {risk: "MISSING_ADDRESS_INFORMATION", feature: "proof of address"}, + {risk: "POA_DOCUMENT_EXPIRED", feature: "proof of address"}, + {risk: "INVALID_DOCUMENT_TYPE", feature: "proof of address"}, + {risk: "UNABLE_TO_VALIDATE_DOCUMENT_AGE", feature: "proof of address"}, + {risk: "POA_MAX_ATTEMPTS_EXCEEDED", feature: "proof of address"}, + } + for _, test := range tests { + decision := model.DiditDecision{Status: model.DiditStatusDeclined} + warning := model.DiditWarning{Risk: test.risk, LogType: "error"} + switch test.feature { + case "liveness": + decision.LivenessChecks = []model.DiditFeatureResult{{Warnings: []model.DiditWarning{warning}}} + case "proof of address": + decision.PoaVerifications = []model.DiditPoaVerification{{Warnings: []model.DiditWarning{warning}}} + default: + t.Fatalf("unknown test feature %q", test.feature) + } + require.Equal( + t, + DiditDeclineRetryable, + ClassifyDiditDecline(decision, policy, DiditApprovalPolicy{}), + test.risk, + ) + } +} + +func TestDefaultDiditDeclinePolicyMissingDocumentsCanRetry(t *testing.T) { + policy := DefaultDiditDeclinePolicy() + for _, reasonCode := range []string{ + "documents_incomplete", + "key_people_incomplete", + } { + require.Equal(t, DiditDeclineRetryable, ClassifyDiditDecline(model.DiditDecision{ + Status: model.DiditStatusDeclined, + DecisionReasonCode: reasonCode, + }, policy, DiditApprovalPolicy{}), reasonCode) + } +} + +func TestRatio1RestrictedResidenceCountries(t *testing.T) { + restricted := Ratio1RestrictedResidenceCountries() + require.Len(t, restricted, 57) + for _, country := range []string{"AFG", "IRN", "PRK", "RUS", "USA", "VEN"} { + _, found := restricted[country] + require.True(t, found, country) + } + for _, country := range []string{"ITA", "ESP", "GBR"} { + _, found := restricted[country] + require.False(t, found, country) + } +} + +func TestDefaultDiditDeclinePolicyKnownFinalKYCWarnings(t *testing.T) { + policy := DefaultDiditDeclinePolicy() + for _, risk := range []string{ + "MINIMUM_AGE_NOT_MET", + "AGE_BELOW_MINIMUM", + "COUNTRY_NOT_ALLOWED", + } { + require.Equal(t, DiditDeclineFinal, ClassifyDiditDecline(model.DiditDecision{ + Status: model.DiditStatusDeclined, + IdVerifications: []model.DiditIdVerification{{ + Warnings: []model.DiditWarning{{ + Risk: risk, + LogType: "error", + }}, + }}, + }, policy, DiditApprovalPolicy{}), risk) + } +} diff --git a/service/diditSignature.go b/service/diditSignature.go new file mode 100644 index 0000000..e611279 --- /dev/null +++ b/service/diditSignature.go @@ -0,0 +1,298 @@ +package service + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "math/big" + "sort" + "strconv" + "strings" + "time" +) + +const diditWebhookTimestampWindow = 5 * time.Minute + +var ( + ErrDiditInvalidSignature = errors.New("invalid didit webhook signature") + ErrDiditStaleWebhook = errors.New("didit webhook timestamp is outside the allowed window") + ErrDiditWebhookEnvelope = errors.New("invalid didit webhook envelope") +) + +type DiditSignatureMethod string + +const ( + DiditSignatureV2 DiditSignatureMethod = "v2" + DiditSignatureRaw DiditSignatureMethod = "raw" + DiditSignatureSimple DiditSignatureMethod = "simple" +) + +type DiditWebhookSignatureHeaders struct { + Timestamp string + V2 string + Raw string + Simple string +} + +type DiditSignatureVerification struct { + Method DiditSignatureMethod + DecisionTrusted bool +} + +func VerifyDiditWebhookSignatures( + body []byte, + headers DiditWebhookSignatureHeaders, + secret string, + now time.Time, +) (DiditSignatureVerification, error) { + if secret == "" { + return DiditSignatureVerification{}, ErrDiditInvalidSignature + } + + headerTimestamp, err := strconv.ParseInt(strings.TrimSpace(headers.Timestamp), 10, 64) + if err != nil { + return DiditSignatureVerification{}, ErrDiditWebhookEnvelope + } + delta := now.Unix() - headerTimestamp + if delta > int64(diditWebhookTimestampWindow/time.Second) || + delta < -int64(diditWebhookTimestampWindow/time.Second) { + return DiditSignatureVerification{}, ErrDiditStaleWebhook + } + + envelope, canonicalBody, err := decodeDiditWebhookEnvelope(body) + if err != nil { + return DiditSignatureVerification{}, err + } + if envelope.Timestamp != headerTimestamp { + return DiditSignatureVerification{}, ErrDiditWebhookEnvelope + } + + if headers.V2 != "" && diditSignatureMatches(canonicalBody, headers.V2, secret) { + return DiditSignatureVerification{ + Method: DiditSignatureV2, + DecisionTrusted: true, + }, nil + } + if headers.Raw != "" && diditSignatureMatches(body, headers.Raw, secret) { + return DiditSignatureVerification{ + Method: DiditSignatureRaw, + DecisionTrusted: true, + }, nil + } + if headers.Simple != "" { + simplePayload := fmt.Sprintf( + "%d:%s:%s:%s", + envelope.Timestamp, + envelope.SessionId, + envelope.Status, + envelope.WebhookType, + ) + if diditSignatureMatches([]byte(simplePayload), headers.Simple, secret) { + return DiditSignatureVerification{ + Method: DiditSignatureSimple, + DecisionTrusted: false, + }, nil + } + } + + return DiditSignatureVerification{}, ErrDiditInvalidSignature +} + +func CanonicalizeDiditWebhookJSON(body []byte) ([]byte, error) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + + var value interface{} + if err := decoder.Decode(&value); err != nil { + return nil, ErrDiditWebhookEnvelope + } + var trailing interface{} + if err := decoder.Decode(&trailing); err != io.EOF { + return nil, ErrDiditWebhookEnvelope + } + + var canonical bytes.Buffer + if err := writeDiditCanonicalJSON(&canonical, value); err != nil { + return nil, ErrDiditWebhookEnvelope + } + return canonical.Bytes(), nil +} + +type diditWebhookEnvelope struct { + Timestamp int64 + SessionId string + Status string + WebhookType string +} + +func decodeDiditWebhookEnvelope(body []byte) (diditWebhookEnvelope, []byte, error) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + + var fields map[string]interface{} + if err := decoder.Decode(&fields); err != nil { + return diditWebhookEnvelope{}, nil, ErrDiditWebhookEnvelope + } + var trailing interface{} + if err := decoder.Decode(&trailing); err != io.EOF { + return diditWebhookEnvelope{}, nil, ErrDiditWebhookEnvelope + } + + timestampNumber, ok := fields["timestamp"].(json.Number) + if !ok { + return diditWebhookEnvelope{}, nil, ErrDiditWebhookEnvelope + } + normalizedTimestamp, err := normalizeDiditJSONNumber(timestampNumber) + if err != nil || strings.ContainsAny(normalizedTimestamp, ".eE") { + return diditWebhookEnvelope{}, nil, ErrDiditWebhookEnvelope + } + timestamp, err := strconv.ParseInt(normalizedTimestamp, 10, 64) + if err != nil { + return diditWebhookEnvelope{}, nil, ErrDiditWebhookEnvelope + } + + sessionId, _ := fields["session_id"].(string) + status, ok := fields["status"].(string) + if !ok || status == "" { + return diditWebhookEnvelope{}, nil, ErrDiditWebhookEnvelope + } + webhookType, ok := fields["webhook_type"].(string) + if !ok || webhookType == "" { + return diditWebhookEnvelope{}, nil, ErrDiditWebhookEnvelope + } + + var canonical bytes.Buffer + if err = writeDiditCanonicalJSON(&canonical, fields); err != nil { + return diditWebhookEnvelope{}, nil, ErrDiditWebhookEnvelope + } + + return diditWebhookEnvelope{ + Timestamp: timestamp, + SessionId: sessionId, + Status: status, + WebhookType: webhookType, + }, canonical.Bytes(), nil +} + +func diditSignatureMatches(payload []byte, providedHex string, secret string) bool { + provided, err := hex.DecodeString(strings.TrimSpace(providedHex)) + if err != nil || len(provided) != sha256.Size { + return false + } + + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write(payload) + return hmac.Equal(mac.Sum(nil), provided) +} + +func writeDiditCanonicalJSON(destination *bytes.Buffer, value interface{}) error { + switch typed := value.(type) { + case nil: + destination.WriteString("null") + case bool: + destination.WriteString(strconv.FormatBool(typed)) + case string: + writeDiditCanonicalJSONString(destination, typed) + case json.Number: + normalized, err := normalizeDiditJSONNumber(typed) + if err != nil { + return err + } + destination.WriteString(normalized) + case []interface{}: + destination.WriteByte('[') + for index, item := range typed { + if index > 0 { + destination.WriteByte(',') + } + if err := writeDiditCanonicalJSON(destination, item); err != nil { + return err + } + } + destination.WriteByte(']') + case map[string]interface{}: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + + destination.WriteByte('{') + for index, key := range keys { + if index > 0 { + destination.WriteByte(',') + } + if err := writeDiditCanonicalJSON(destination, key); err != nil { + return err + } + destination.WriteByte(':') + if err := writeDiditCanonicalJSON(destination, typed[key]); err != nil { + return err + } + } + destination.WriteByte('}') + default: + return errors.New("unsupported JSON value") + } + return nil +} + +func writeDiditCanonicalJSONString(destination *bytes.Buffer, value string) { + const hexCharacters = "0123456789abcdef" + + destination.WriteByte('"') + for _, character := range value { + switch character { + case '"', '\\': + destination.WriteByte('\\') + destination.WriteRune(character) + case '\b': + destination.WriteString(`\b`) + case '\f': + destination.WriteString(`\f`) + case '\n': + destination.WriteString(`\n`) + case '\r': + destination.WriteString(`\r`) + case '\t': + destination.WriteString(`\t`) + default: + if character < 0x20 { + destination.WriteString(`\u00`) + destination.WriteByte(hexCharacters[byte(character)>>4]) + destination.WriteByte(hexCharacters[byte(character)&0x0f]) + } else { + destination.WriteRune(character) + } + } + } + destination.WriteByte('"') +} + +func normalizeDiditJSONNumber(number json.Number) (string, error) { + value := number.String() + if !strings.ContainsAny(value, ".eE") { + if _, ok := new(big.Int).SetString(value, 10); !ok { + return "", errors.New("invalid JSON number") + } + return value, nil + } + + floatValue, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsInf(floatValue, 0) || math.IsNaN(floatValue) { + return "", errors.New("invalid JSON number") + } + if math.Trunc(floatValue) == floatValue { + if floatValue == 0 { + return "0", nil + } + return strconv.FormatFloat(floatValue, 'f', -1, 64), nil + } + return strconv.FormatFloat(floatValue, 'g', -1, 64), nil +} diff --git a/service/diditSignature_test.go b/service/diditSignature_test.go new file mode 100644 index 0000000..cfe9fd1 --- /dev/null +++ b/service/diditSignature_test.go @@ -0,0 +1,291 @@ +package service + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + diditSignatureTestSecret = "whsec_test_only_not_a_real_secret" + diditSignatureTimestamp = int64(1774970000) + diditSignatureRawBody = `{"status":"Approved","session_id":"00000000-0000-4000-8000-000000000101","metadata":{"name":"José","tier":"test"},"timestamp":1774970000,"webhook_type":"status.updated","event_id":"00000000-0000-4000-8000-000000000401"}` + diditSignatureCanonical = `{"event_id":"00000000-0000-4000-8000-000000000401","metadata":{"name":"José","tier":"test"},"session_id":"00000000-0000-4000-8000-000000000101","status":"Approved","timestamp":1774970000,"webhook_type":"status.updated"}` + diditSignatureRawHex = "ebff26da50aad3564b93322eed0cc99e7bfd03ea0b13747a593dc6c27600743b" + diditSignatureV2Hex = "5ba5e3d645df15ebf923030c4ae804d51e948e1a00697550e60a110b36d389c5" + diditSignatureSimpleHex = "74330b52437647bd22ad95d4fcf1c1dc24e70d1a3c4218b5d58e0000766786c8" +) + +func TestVerifyDiditWebhookSignatures(t *testing.T) { + now := time.Unix(diditSignatureTimestamp, 0) + timestamp := "1774970000" + + tests := []struct { + name string + body string + headers DiditWebhookSignatureHeaders + expectedMethod DiditSignatureMethod + decisionTrusted bool + }{ + { + name: "v2", + body: diditSignatureRawBody, + headers: DiditWebhookSignatureHeaders{ + Timestamp: timestamp, + V2: diditSignatureV2Hex, + }, + expectedMethod: DiditSignatureV2, + decisionTrusted: true, + }, + { + name: "v2 accepts reordered pretty JSON", + body: `{ + "event_id":"00000000-0000-4000-8000-000000000401", + "webhook_type":"status.updated", + "timestamp":1774970000, + "metadata":{"tier":"test","name":"José"}, + "session_id":"00000000-0000-4000-8000-000000000101", + "status":"Approved" + }`, + headers: DiditWebhookSignatureHeaders{ + Timestamp: timestamp, + V2: diditSignatureV2Hex, + }, + expectedMethod: DiditSignatureV2, + decisionTrusted: true, + }, + { + name: "raw", + body: diditSignatureRawBody, + headers: DiditWebhookSignatureHeaders{ + Timestamp: timestamp, + Raw: diditSignatureRawHex, + }, + expectedMethod: DiditSignatureRaw, + decisionTrusted: true, + }, + { + name: "simple is envelope only", + body: diditSignatureRawBody, + headers: DiditWebhookSignatureHeaders{ + Timestamp: timestamp, + Simple: diditSignatureSimpleHex, + }, + expectedMethod: DiditSignatureSimple, + decisionTrusted: false, + }, + { + name: "preference order", + body: diditSignatureRawBody, + headers: DiditWebhookSignatureHeaders{ + Timestamp: timestamp, + V2: diditSignatureV2Hex, + Raw: diditSignatureRawHex, + Simple: diditSignatureSimpleHex, + }, + expectedMethod: DiditSignatureV2, + decisionTrusted: true, + }, + { + name: "uppercase hex", + body: diditSignatureRawBody, + headers: DiditWebhookSignatureHeaders{ + Timestamp: timestamp, + V2: strings.ToUpper(diditSignatureV2Hex), + }, + expectedMethod: DiditSignatureV2, + decisionTrusted: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := VerifyDiditWebhookSignatures( + []byte(test.body), + test.headers, + diditSignatureTestSecret, + now, + ) + require.NoError(t, err) + require.Equal(t, test.expectedMethod, result.Method) + require.Equal(t, test.decisionTrusted, result.DecisionTrusted) + }) + } +} + +func TestVerifyDiditWebhookSignaturesRejectsInvalidInputs(t *testing.T) { + now := time.Unix(diditSignatureTimestamp, 0) + validHeaders := DiditWebhookSignatureHeaders{ + Timestamp: "1774970000", + V2: diditSignatureV2Hex, + } + + tests := []struct { + name string + body string + headers DiditWebhookSignatureHeaders + secret string + expectedErr error + }{ + { + name: "wrong secret", + body: diditSignatureRawBody, + headers: validHeaders, + secret: "wrong", + expectedErr: ErrDiditInvalidSignature, + }, + { + name: "mutated decision", + body: strings.Replace(diditSignatureRawBody, `"Approved"`, `"Declined"`, 1), + headers: validHeaders, + secret: diditSignatureTestSecret, + expectedErr: ErrDiditInvalidSignature, + }, + { + name: "missing signatures", + body: diditSignatureRawBody, + headers: DiditWebhookSignatureHeaders{ + Timestamp: "1774970000", + }, + secret: diditSignatureTestSecret, + expectedErr: ErrDiditInvalidSignature, + }, + { + name: "malformed digest", + body: diditSignatureRawBody, + headers: DiditWebhookSignatureHeaders{ + Timestamp: "1774970000", + V2: "not-hex", + }, + secret: diditSignatureTestSecret, + expectedErr: ErrDiditInvalidSignature, + }, + { + name: "short digest", + body: diditSignatureRawBody, + headers: DiditWebhookSignatureHeaders{ + Timestamp: "1774970000", + V2: "abcd", + }, + secret: diditSignatureTestSecret, + expectedErr: ErrDiditInvalidSignature, + }, + { + name: "missing timestamp header", + body: diditSignatureRawBody, + headers: DiditWebhookSignatureHeaders{ + V2: diditSignatureV2Hex, + }, + secret: diditSignatureTestSecret, + expectedErr: ErrDiditWebhookEnvelope, + }, + { + name: "header and body timestamp mismatch", + body: diditSignatureRawBody, + headers: DiditWebhookSignatureHeaders{ + Timestamp: "1774970001", + V2: diditSignatureV2Hex, + }, + secret: diditSignatureTestSecret, + expectedErr: ErrDiditWebhookEnvelope, + }, + { + name: "trailing JSON", + body: diditSignatureRawBody + `{}`, + headers: validHeaders, + secret: diditSignatureTestSecret, + expectedErr: ErrDiditWebhookEnvelope, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := VerifyDiditWebhookSignatures( + []byte(test.body), + test.headers, + test.secret, + now, + ) + require.ErrorIs(t, err, test.expectedErr) + }) + } +} + +func TestVerifyDiditWebhookSignaturesTimestampWindow(t *testing.T) { + tests := []struct { + name string + now time.Time + expectedErr error + }{ + {name: "exactly 300 seconds old", now: time.Unix(diditSignatureTimestamp+300, 0)}, + {name: "exactly 300 seconds future", now: time.Unix(diditSignatureTimestamp-300, 0)}, + {name: "301 seconds old", now: time.Unix(diditSignatureTimestamp+301, 0), expectedErr: ErrDiditStaleWebhook}, + {name: "301 seconds future", now: time.Unix(diditSignatureTimestamp-301, 0), expectedErr: ErrDiditStaleWebhook}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := VerifyDiditWebhookSignatures( + []byte(diditSignatureRawBody), + DiditWebhookSignatureHeaders{ + Timestamp: "1774970000", + V2: diditSignatureV2Hex, + }, + diditSignatureTestSecret, + test.now, + ) + if test.expectedErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.expectedErr) + } + }) + } +} + +func TestCanonicalizeDiditWebhookJSON(t *testing.T) { + canonical, err := CanonicalizeDiditWebhookJSON([]byte(diditSignatureRawBody)) + require.NoError(t, err) + require.Equal(t, diditSignatureCanonical, string(canonical)) + + body := []byte(`{ + "z":[{"whole":99.0,"fraction":99.5}], + "html":"A&B ", + "unicode":"José", + "separator":"line\u2028separator", + "negative_zero":-0.0, + "a":{"b":2,"a":1} + }`) + canonical, err = CanonicalizeDiditWebhookJSON(body) + require.NoError(t, err) + require.Equal( + t, + "{\"a\":{\"a\":1,\"b\":2},\"html\":\"A&B \",\"negative_zero\":0,\"separator\":\"line\u2028separator\",\"unicode\":\"José\",\"z\":[{\"fraction\":99.5,\"whole\":99}]}", + string(canonical), + ) + + canonical, err = CanonicalizeDiditWebhookJSON([]byte(`{"small":1e-7,"large":1e21}`)) + require.NoError(t, err) + require.Equal(t, `{"large":1000000000000000000000,"small":1e-07}`, string(canonical)) +} + +func TestDiditSignatureVectorsAreStable(t *testing.T) { + require.Equal(t, diditSignatureRawHex, signDiditTestPayload([]byte(diditSignatureRawBody))) + require.Equal(t, diditSignatureV2Hex, signDiditTestPayload([]byte(diditSignatureCanonical))) + require.Equal( + t, + diditSignatureSimpleHex, + signDiditTestPayload([]byte("1774970000:00000000-0000-4000-8000-000000000101:Approved:status.updated")), + ) +} + +func signDiditTestPayload(payload []byte) string { + mac := hmac.New(sha256.New, []byte(diditSignatureTestSecret)) + _, _ = mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/service/diditWebhookValidation_test.go b/service/diditWebhookValidation_test.go new file mode 100644 index 0000000..cd587f5 --- /dev/null +++ b/service/diditWebhookValidation_test.go @@ -0,0 +1,191 @@ +package service + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "testing" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/config" + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +const diditWebhookValidationSecret = "webhook_test_secret_not_for_runtime" + +func TestDiditWebhookRejectsUnsupportedEventBeforePersistence(t *testing.T) { + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + timestamp := now.Unix() + body := fmt.Sprintf(`{ + "event_id":"00000000-0000-4000-8000-000000000401", + "webhook_type":"account.status.updated", + "timestamp":%d, + "created_at":%d, + "application_id":"00000000-0000-4000-8000-000000000501", + "environment":"sandbox", + "session_id":"00000000-0000-4000-8000-000000000101", + "session_kind":"user", + "vendor_data":"00000000-0000-4000-8000-000000000301", + "status":"Approved" + }`, timestamp, timestamp) + simplePayload := fmt.Sprintf( + "%d:%s:%s:%s", + timestamp, + "00000000-0000-4000-8000-000000000101", + "Approved", + "account.status.updated", + ) + service := diditWebhookValidationService() + + _, err := service.ReceiveDiditWebhook( + []byte(body), + DiditWebhookHeaders{ + Timestamp: fmt.Sprintf("%d", timestamp), + Simple: signDiditWebhookValidationPayload(simplePayload), + }, + now, + ) + require.ErrorContains(t, err, "unsupported Didit verification event") +} + +func TestDiditSimpleSignatureCannotBypassRequiredSessionEnvelope(t *testing.T) { + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + timestamp := now.Unix() + body := fmt.Sprintf(`{ + "event_id":"00000000-0000-4000-8000-000000000401", + "webhook_type":"status.updated", + "timestamp":%d, + "created_at":%d, + "application_id":"00000000-0000-4000-8000-000000000501", + "environment":"sandbox", + "session_kind":"user", + "vendor_data":"00000000-0000-4000-8000-000000000301", + "status":"Approved" + }`, timestamp, timestamp) + simplePayload := fmt.Sprintf("%d::Approved:status.updated", timestamp) + service := diditWebhookValidationService() + + _, err := service.ReceiveDiditWebhook( + []byte(body), + DiditWebhookHeaders{ + Timestamp: fmt.Sprintf("%d", timestamp), + Simple: signDiditWebhookValidationPayload(simplePayload), + }, + now, + ) + require.ErrorIs(t, err, ErrDiditWebhookEnvelope) +} + +func TestDiditWebhookRejectsEventFamilySessionKindMismatch(t *testing.T) { + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + timestamp := now.Unix() + body := fmt.Sprintf(`{ + "event_id":"00000000-0000-4000-8000-000000000401", + "webhook_type":"user.status.updated", + "timestamp":%d, + "created_at":%d, + "application_id":"00000000-0000-4000-8000-000000000501", + "environment":"sandbox", + "session_kind":"business", + "vendor_data":"00000000-0000-4000-8000-000000000301", + "status":"Approved" + }`, timestamp, timestamp) + simplePayload := fmt.Sprintf("%d::Approved:user.status.updated", timestamp) + service := diditWebhookValidationService() + + _, err := service.ReceiveDiditWebhook( + []byte(body), + DiditWebhookHeaders{ + Timestamp: fmt.Sprintf("%d", timestamp), + Simple: signDiditWebhookValidationPayload(simplePayload), + }, + now, + ) + require.ErrorIs(t, err, ErrDiditWebhookEnvelope) +} + +func TestDiditWebhookEventFamilyClassification(t *testing.T) { + require.True(t, isDiditSessionEvent("status.updated")) + require.True(t, isDiditSessionEvent("data.updated")) + require.False(t, isDiditSessionEvent("user.status.updated")) + require.False(t, isDiditSessionEvent("user.data.updated")) + require.False(t, isDiditSessionEvent("business.status.updated")) + require.False(t, isDiditSessionEvent("business.data.updated")) + require.False(t, isDiditSessionEvent("unknown")) + + require.Equal(t, model.DiditSessionKindUser, diditSessionKindForEvent("user.status.updated")) + require.Equal(t, model.DiditSessionKindBusiness, diditSessionKindForEvent("business.data.updated")) + require.Empty(t, diditSessionKindForEvent("status.updated")) +} + +func TestInternalDiditEnvironmentNormalizesKnownProviderValues(t *testing.T) { + tests := []struct { + input string + expected string + valid bool + }{ + {input: " sandbox ", expected: model.VerificationEnvironmentSandbox, valid: true}, + {input: "live", expected: model.VerificationEnvironmentProduction, valid: true}, + {input: "PRODUCTION", expected: model.VerificationEnvironmentProduction, valid: true}, + {input: "test", valid: false}, + {input: "", valid: false}, + } + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + environment, err := internalDiditEnvironment(test.input) + if test.valid { + require.NoError(t, err) + require.Equal(t, test.expected, environment) + } else { + require.Error(t, err) + require.Empty(t, environment) + } + }) + } +} + +func diditWebhookValidationService() *VerificationService { + return &VerificationService{ + cfg: config.GeneralConfig{Didit: config.DiditConfig{ + ApplicationId: "00000000-0000-4000-8000-000000000501", + Environment: model.VerificationEnvironmentSandbox, + WebhookSecret: diditWebhookValidationSecret, + }}, + didit: &diditWebhookValidationClient{}, + } +} + +type diditWebhookValidationClient struct{} + +func (*diditWebhookValidationClient) CreateSession( + context.Context, + model.DiditCreateSessionRequest, +) (*model.DiditCreateSessionResponse, error) { + panic("unexpected CreateSession call") +} + +func (*diditWebhookValidationClient) RetrieveDecision( + context.Context, + uuid.UUID, + model.DiditDecisionExpectation, +) (*model.DiditDecision, error) { + panic("unexpected RetrieveDecision call") +} + +func (*diditWebhookValidationClient) RetrieveEntity( + context.Context, + model.DiditSessionKind, + string, +) (*model.DiditEntity, error) { + panic("unexpected RetrieveEntity call") +} + +func signDiditWebhookValidationPayload(payload string) string { + mac := hmac.New(sha256.New, []byte(diditWebhookValidationSecret)) + _, _ = mac.Write([]byte(payload)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/service/sumsubMonitoringPolicy_test.go b/service/sumsubMonitoringPolicy_test.go new file mode 100644 index 0000000..68c894c --- /dev/null +++ b/service/sumsubMonitoringPolicy_test.go @@ -0,0 +1,170 @@ +package service + +import ( + "testing" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/stretchr/testify/require" +) + +func TestPrepareSumsubKycForFullProcessingPreservesPreCutoverOwnership(t *testing.T) { + for _, provider := range []string{"", model.VerificationProviderSumsub} { + kyc := model.Kyc{VerificationProvider: provider} + prepared, process := PrepareSumsubKycForFullProcessing(kyc) + require.True(t, process) + require.Equal(t, model.VerificationProviderSumsub, prepared.VerificationProvider) + } + + diditOwned := model.Kyc{VerificationProvider: model.VerificationProviderDidit} + prepared, process := PrepareSumsubKycForFullProcessing(diditOwned) + require.False(t, process) + require.Equal(t, diditOwned, prepared) +} + +func TestGrandfatheredSumsubMonitoringNeverReapprovesOrResets(t *testing.T) { + lastUpdated := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + base := model.Kyc{ + ApplicantId: "sumsub-applicant", + VerificationProvider: model.VerificationProviderSumsub, + KycStatus: model.StatusOnHold, + LastUpdated: lastUpdated, + IsActive: false, + } + baseEvent := model.SumsubEvent{ + ApplicantID: "sumsub-applicant", + CreatedAtMs: "2026-07-29 11:00:00.000", + } + + tests := []struct { + name string + event model.SumsubEvent + }{ + { + name: "green review cannot reapprove", + event: func() model.SumsubEvent { + event := baseEvent + event.Type = model.ApplicantReviewed + event.ReviewResult.ReviewAnswer = "GREEN" + return event + }(), + }, + { + name: "applicant reset is ignored", + event: func() model.SumsubEvent { + event := baseEvent + event.Type = model.ApplicantReset + return event + }(), + }, + { + name: "activation is ignored", + event: func() model.SumsubEvent { + event := baseEvent + event.Type = model.ApplicantActivated + return event + }(), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, changed, err := grandfatheredSumsubMonitoringTransition(test.event, base) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, base, result) + }) + } +} + +func TestGrandfatheredSumsubMonitoringAllowsOnlySuspensionOrRevocation(t *testing.T) { + lastUpdated := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + base := model.Kyc{ + ApplicantId: "sumsub-applicant", + VerificationProvider: model.VerificationProviderSumsub, + KycStatus: model.StatusApproved, + LastUpdated: lastUpdated, + IsActive: true, + } + baseEvent := model.SumsubEvent{ + ApplicantID: "sumsub-applicant", + CreatedAtMs: "2026-07-29 11:00:00.000", + } + + suspended := baseEvent + suspended.Type = model.ApplicantReviewed + suspended.ReviewResult.ReviewAnswer = "RED" + suspended.ReviewResult.ReviewRejectType = "RETRY" + result, changed, err := grandfatheredSumsubMonitoringTransition(suspended, base) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, model.StatusOnHold, result.KycStatus) + + revoked := baseEvent + revoked.Type = model.ApplicantActionReviewed + revoked.ReviewResult.ReviewAnswer = "RED" + revoked.ReviewResult.ReviewRejectType = "FINAL" + result, changed, err = grandfatheredSumsubMonitoringTransition(revoked, base) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, model.StatusFinalRejected, result.KycStatus) + + deactivated := baseEvent + deactivated.Type = model.ApplicantDeactivated + result, changed, err = grandfatheredSumsubMonitoringTransition(deactivated, base) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, model.StatusOnHold, result.KycStatus) + require.False(t, result.IsActive) + + deleted := baseEvent + deleted.Type = model.ApplicantDeleted + result, changed, err = grandfatheredSumsubMonitoringTransition(deleted, base) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, model.StatusOnHold, result.KycStatus) + require.True(t, result.HasBeenDeleted) +} + +func TestGrandfatheredSumsubMonitoringRejectsStaleOrMismatchedEvents(t *testing.T) { + base := model.Kyc{ + ApplicantId: "sumsub-applicant", + VerificationProvider: model.VerificationProviderSumsub, + KycStatus: model.StatusApproved, + LastUpdated: time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC), + } + event := model.SumsubEvent{ + ApplicantID: "sumsub-applicant", + Type: model.ApplicantReviewed, + CreatedAtMs: "2026-07-29 11:00:00.000", + } + event.ReviewResult.ReviewAnswer = "RED" + event.ReviewResult.ReviewRejectType = "FINAL" + + result, changed, err := grandfatheredSumsubMonitoringTransition(event, base) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, base, result) + + event.CreatedAtMs = "2026-07-29 13:00:00.000" + event.ApplicantID = "different-applicant" + result, changed, err = grandfatheredSumsubMonitoringTransition(event, base) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, base, result) + + diditOwned := base + diditOwned.VerificationProvider = model.VerificationProviderDidit + event.ApplicantID = diditOwned.ApplicantId + result, changed, err = grandfatheredSumsubMonitoringTransition(event, diditOwned) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, diditOwned, result) + + event.ApplicantID = base.ApplicantId + event.CreatedAtMs = "2026-07-29 12:00:00.000" + result, changed, err = grandfatheredSumsubMonitoringTransition(event, base) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, base, result) +} diff --git a/service/sumsubService.go b/service/sumsubService.go index f957495..0b807c7 100644 --- a/service/sumsubService.go +++ b/service/sumsubService.go @@ -48,6 +48,7 @@ func ProcessKycEvent(event model.SumsubEvent, kyc model.Kyc, userAddress string) } kyc.LastUpdated = time.Now().UTC() + kyc.VerificationProvider = model.VerificationProviderSumsub switch event.Type { case model.ApplicantCreated: @@ -136,6 +137,99 @@ func ProcessKycEvent(event model.SumsubEvent, kyc model.Kyc, userAddress string) return nil } +// PrepareSumsubKycForFullProcessing preserves the pre-cutover/default Sumsub +// lifecycle, including rows created before provider ownership was persisted. +// Didit-owned rows are never handed back to Sumsub implicitly. +func PrepareSumsubKycForFullProcessing(kyc model.Kyc) (model.Kyc, bool) { + if kyc.VerificationProvider != "" && + kyc.VerificationProvider != model.VerificationProviderSumsub { + return kyc, false + } + kyc.VerificationProvider = model.VerificationProviderSumsub + return kyc, true +} + +// ProcessGrandfatheredSumsubMonitoringEvent is intentionally revoke-only. +// Sumsub remains connected after the cutover solely to receive monitoring +// changes for the explicitly Sumsub-owned approved cohort. It never onboards, +// resets, retries, or re-approves an account. +func ProcessGrandfatheredSumsubMonitoringEvent(event model.SumsubEvent, kyc model.Kyc) error { + updatedKyc, changed, err := grandfatheredSumsubMonitoringTransition(event, kyc) + if err != nil || !changed { + return err + } + previousStatus := kyc.KycStatus + if err := storage.CreateOrUpdateKyc(&updatedKyc); err != nil { + return err + } + if previousStatus != updatedKyc.KycStatus { + switch updatedKyc.KycStatus { + case model.StatusFinalRejected: + if err := SendKycFinalRejectedEmail(updatedKyc.Email); err != nil { + log.Warn("could not send Sumsub monitoring final-rejection email: " + err.Error()) + } + case model.StatusOnHold: + if err := SendStepRejectedEmail(updatedKyc.Email); err != nil { + log.Warn("could not send Sumsub monitoring suspension email: " + err.Error()) + } + } + } + return nil +} + +func ParseSumsubMonitoringOccurredAt(value string) (time.Time, error) { + const layout = "2006-01-02 15:04:05.000" + occurredAt, err := time.ParseInLocation(layout, value, time.UTC) + if err != nil { + return time.Time{}, errors.New("error while parsing Sumsub monitoring time: " + err.Error()) + } + return occurredAt, nil +} + +func grandfatheredSumsubMonitoringTransition( + event model.SumsubEvent, + kyc model.Kyc, +) (model.Kyc, bool, error) { + if kyc.VerificationProvider != model.VerificationProviderSumsub || + (kyc.KycStatus != model.StatusApproved && kyc.KycStatus != model.StatusOnHold) || + kyc.ApplicantId == "" || + kyc.ApplicantId != event.ApplicantID { + return kyc, false, nil + } + occurredAt, err := ParseSumsubMonitoringOccurredAt(event.CreatedAtMs) + if err != nil { + return kyc, false, err + } + if !kyc.LastUpdated.IsZero() && !occurredAt.After(kyc.LastUpdated) { + return kyc, false, nil + } + + switch event.Type { + case model.ApplicantDeactivated: + kyc.IsActive = false + kyc.KycStatus = model.StatusOnHold + case model.ApplicantDeleted: + kyc.HasBeenDeleted = true + kyc.KycStatus = model.StatusOnHold + case model.ApplicantReviewed, + model.ApplicantOnHold, + model.ApplicantActionReviewed, + model.ApplicantActionOnHold: + if event.ReviewResult.ReviewAnswer != "RED" { + return kyc, false, nil + } + if event.ReviewResult.ReviewRejectType == "FINAL" { + kyc.KycStatus = model.StatusFinalRejected + } else { + kyc.KycStatus = model.StatusOnHold + } + default: + return kyc, false, nil + } + kyc.LastUpdated = occurredAt + return kyc, true, nil +} + func createOrUpdateApprovedUserInfo(kyc *model.Kyc, userAddress string) error { userInfo, err := fetchUserInfo(kyc) if err != nil { diff --git a/service/testdata/didit/create_session_201.json b/service/testdata/didit/create_session_201.json new file mode 100644 index 0000000..b139b12 --- /dev/null +++ b/service/testdata/didit/create_session_201.json @@ -0,0 +1,14 @@ +{ + "session_id": "00000000-0000-4000-8000-000000000101", + "session_number": 101, + "session_token": "test-token-only", + "url": "https://verify.didit.me/session/test-token-only", + "vendor_data": "00000000-0000-4000-8000-000000000301", + "metadata": { + "applicant_type": "individual" + }, + "status": "Not Started", + "workflow_id": "00000000-0000-4000-8000-000000000201", + "workflow_version": 1, + "callback": "https://app.invalid/verification-complete" +} diff --git a/service/testdata/didit/decision_business_approved.json b/service/testdata/didit/decision_business_approved.json new file mode 100644 index 0000000..39ecab6 --- /dev/null +++ b/service/testdata/didit/decision_business_approved.json @@ -0,0 +1,204 @@ +{ + "session_id": "00000000-0000-4000-8000-000000000102", + "session_kind": "business", + "session_number": 102, + "session_url": "https://verify.didit.me/session/test-business-token", + "status": "Approved", + "workflow_id": "00000000-0000-4000-8000-000000000202", + "vendor_data": "00000000-0000-4000-8000-000000000302", + "features": [ + "KYB_REGISTRY", + "KYB_DOCUMENTS", + "KYB_KEY_PEOPLE", + "AML", + "QUESTIONNAIRE" + ], + "metadata": { + "applicant_type": "company" + }, + "environment": "sandbox", + "registry_checks": [ + { + "node_id": "registry", + "status": "Approved", + "company": { + "company_name": "Example SRL", + "country_code": "IT", + "registration_number": "TEST-001" + }, + "ownership_structure": {}, + "warnings": [] + } + ], + "document_verifications": [ + { + "node_id": "company-documents", + "status": "Approved", + "items": [ + { + "document_type": "CERTIFICATE_OF_INCORPORATION", + "status": "Approved" + }, + { + "document_type": "OWNERSHIP_STRUCTURE", + "status": "Approved" + } + ], + "groups": { + "LEGAL_PRESENCE": { + "approved": 1, + "pending": 0, + "declined": 0, + "in_review": 0, + "other": 0, + "missing": 0 + }, + "OWNERSHIP_STRUCTURE": { + "approved": 1, + "pending": 0, + "declined": 0, + "in_review": 0, + "other": 0, + "missing": 0 + } + }, + "required_groups": [ + "LEGAL_PRESENCE", + "OWNERSHIP_STRUCTURE" + ], + "warnings": [] + } + ], + "key_people_checks": [ + { + "node_id": "key-people", + "status": "Approved", + "registry": { + "officers": [ + { + "name": "Ada Example", + "role": "director", + "requires_verification": true, + "kyc_status": "Approved" + } + ] + }, + "submitted": { + "parties": [ + { + "name": "Ada Example", + "role": "ubo", + "ownership_percent": 100, + "requires_verification": true, + "kyc_session_status": "Approved" + } + ] + }, + "ubo_kyc_summary": { + "total": 1, + "approved": 1, + "flagged": 0, + "pending": 0 + }, + "warnings": [] + } + ], + "aml_screenings": [ + { + "node_id": "company-aml", + "status": "Approved", + "total_hits": 0, + "warnings": [] + } + ], + "questionnaire_responses": [ + { + "node_id": "source-of-funds", + "status": "Approved", + "questionnaire_id": "e6c959b9-b5e2-4ba5-9217-92ebe8613f5a", + "questionnaire_group_id": "00000000-0000-4000-8000-000000000504", + "version": 1, + "sections": [ + { + "items": [ + { + "uuid": "1c2fd10b-c439-4f53-ba4a-d4581563b52d", + "element_type": "DROPDOWN", + "is_required": true, + "answer": { + "value": "business_income" + } + }, + { + "uuid": "4ec8a1c8-1b79-4aeb-a91e-2ed2a24552a0", + "element_type": "LONG_TEXT", + "is_required": true, + "answer": { + "value": "Synthetic business revenue" + } + }, + { + "uuid": "ecac1d83-3413-4c5d-871a-a492f1a1be5b", + "element_type": "DROPDOWN", + "is_required": true, + "answer": { + "value": "100k-250k" + } + }, + { + "uuid": "a714b441-bfd2-4d35-9248-716e668ce2b9", + "element_type": "DROPDOWN", + "is_required": true, + "answer": { + "value": "250k-1m" + } + }, + { + "uuid": "f15f7874-69a4-45de-9cfe-c7fa854b85f6", + "element_type": "LONG_TEXT", + "is_required": true, + "answer": { + "value": "Synthetic retained earnings, 100000 EUR" + } + }, + { + "uuid": "bfde3e4c-6148-45cd-a04c-c151dd789930", + "element_type": "LONG_TEXT", + "is_required": true, + "answer": { + "value": "Business income 100%" + } + }, + { + "uuid": "82cfd962-7414-4f8c-8e8d-9cf1d46589de", + "element_type": "SINGLE_CHOICE", + "is_required": true, + "answer": { + "value": "no" + } + }, + { + "uuid": "77250407-9e4c-4dfe-80a8-3f091890f217", + "element_type": "SINGLE_CHOICE", + "is_required": true, + "answer": { + "value": "yes", + "text": "Synthetic test assets" + } + }, + { + "uuid": "d7517e61-084e-4e8d-b96c-9f137c5b7c81", + "element_type": "FILE_UPLOAD", + "is_required": true, + "answer": { + "files": [ + "https://media.invalid/synthetic-proof.pdf" + ] + } + } + ] + } + ] + } + ] +} diff --git a/service/testdata/didit/decision_business_declined_blocked_country.json b/service/testdata/didit/decision_business_declined_blocked_country.json new file mode 100644 index 0000000..9f2468d --- /dev/null +++ b/service/testdata/didit/decision_business_declined_blocked_country.json @@ -0,0 +1,20 @@ +{ + "session_id": "00000000-0000-4000-8000-000000000113", + "session_kind": "business", + "status": "Declined", + "decision_reason_code": "blocked_country", + "workflow_id": "00000000-0000-4000-8000-000000000202", + "vendor_data": "00000000-0000-4000-8000-000000000313", + "environment": "sandbox", + "registry_checks": [ + { + "node_id": "registry", + "status": "Declined", + "company": { + "country": "XX" + }, + "ownership_structure": [], + "warnings": [] + } + ] +} diff --git a/service/testdata/didit/decision_user_approved.json b/service/testdata/didit/decision_user_approved.json new file mode 100644 index 0000000..5a7c61c --- /dev/null +++ b/service/testdata/didit/decision_user_approved.json @@ -0,0 +1,195 @@ +{ + "session_id": "00000000-0000-4000-8000-000000000101", + "session_kind": "user", + "session_number": 101, + "session_url": "https://verify.didit.me/session/test-token-only", + "status": "Approved", + "workflow_id": "00000000-0000-4000-8000-000000000201", + "vendor_data": "00000000-0000-4000-8000-000000000301", + "features": [ + "ID_VERIFICATION", + "LIVENESS", + "FACE_MATCH", + "POA", + "AML", + "QUESTIONNAIRE" + ], + "metadata": { + "applicant_type": "individual" + }, + "environment": "sandbox", + "id_verifications": [ + { + "node_id": "id-document", + "status": "Approved", + "first_name": "Ada", + "last_name": "Example", + "full_name": "Ada Example", + "date_of_birth": "1990-01-01", + "age": 36, + "nationality": "ITA", + "issuing_state": "ITA", + "expiration_date": "2030-01-01", + "document_type": "ID", + "warnings": [] + } + ], + "liveness_checks": [ + { + "node_id": "liveness", + "status": "Approved", + "warnings": [] + } + ], + "face_matches": [ + { + "node_id": "face-match", + "status": "Approved", + "warnings": [] + } + ], + "poa_verifications": [ + { + "node_id": "proof-of-address", + "status": "Approved", + "name_on_document": "Ada Example", + "poa_address": "1 Example Street", + "poa_formatted_address": "1 Example Street, Rome, Italy", + "issuing_state": "ITA", + "issue_date": "2026-01-01", + "warnings": [] + } + ], + "phone_verifications": [ + { + "node_id": "phone", + "status": "Approved", + "warnings": [] + } + ], + "email_verifications": [ + { + "node_id": "email", + "status": "Approved", + "warnings": [] + } + ], + "document_ai_documents": [ + { + "node_id": "document-ai", + "status": "Approved", + "items": [], + "warnings": [] + } + ], + "aml_screenings": [ + { + "node_id": "aml", + "status": "Approved", + "total_hits": 0, + "warnings": [] + } + ], + "ip_analyses": [ + { + "node_id": "ip-analysis", + "status": "Approved", + "warnings": [] + } + ], + "database_validations": [ + { + "node_id": "database-validation", + "status": "Approved", + "warnings": [] + } + ], + "questionnaire_responses": [ + { + "node_id": "questionnaire", + "status": "Approved", + "questionnaire_id": "b271880d-15f5-4621-a851-a193b4aebddd", + "questionnaire_group_id": "00000000-0000-4000-8000-000000000502", + "version": 2, + "sections": [ + { + "title": "Personal and billing information", + "items": [ + { + "uuid": "5ab7c537-32b2-4792-b522-b9065da0df3a", + "element_type": "SHORT_TEXT", + "is_required": true, + "answer": { + "value": "Ada" + } + }, + { + "uuid": "6828a11b-1ebf-4999-a942-bfd77dfa948c", + "element_type": "SHORT_TEXT", + "is_required": true, + "answer": { + "value": "Example" + } + }, + { + "uuid": "18777afd-6fb6-4a8e-817b-0d7be1229dc5", + "element_type": "SHORT_TEXT", + "is_required": true, + "answer": { + "value": "TEST-TIN-001" + } + }, + { + "uuid": "fb48c0f2-46e8-4bcf-88f3-298c37178acb", + "element_type": "SHORT_TEXT", + "is_required": true, + "answer": { + "value": "1 Example Street" + } + }, + { + "uuid": "b8d3600e-f281-4e2a-a964-8ccde079f40a", + "element_type": "SHORT_TEXT", + "is_required": true, + "answer": { + "value": "Rome" + } + }, + { + "uuid": "ac420e57-2192-4457-9f72-8ebcf1615cba", + "element_type": "SHORT_TEXT", + "is_required": true, + "answer": { + "value": "00100" + } + }, + { + "uuid": "9a0cbc08-9e41-4347-8ef3-5e0c0d4a5459", + "element_type": "SHORT_TEXT", + "is_required": true, + "answer": { + "value": "RM" + } + }, + { + "uuid": "96646c0d-9650-4844-bd5e-85971060131e", + "element_type": "COUNTRY", + "is_required": true, + "answer": { + "value": "ITA" + } + } + ] + } + ] + } + ], + "reviews": [ + { + "comment": "Automated contract fixture", + "created_at": "2026-07-29T10:00:00Z", + "new_status": "Approved", + "user": null + } + ] +} diff --git a/service/testdata/didit/decision_user_approved_incomplete.json b/service/testdata/didit/decision_user_approved_incomplete.json new file mode 100644 index 0000000..bc0a84b --- /dev/null +++ b/service/testdata/didit/decision_user_approved_incomplete.json @@ -0,0 +1,25 @@ +{ + "session_id": "00000000-0000-4000-8000-000000000101", + "session_kind": "user", + "session_number": 101, + "session_url": "https://verify.didit.me/session/test-token-only", + "status": "Approved", + "workflow_id": "00000000-0000-4000-8000-000000000201", + "vendor_data": "00000000-0000-4000-8000-000000000301", + "features": [ + "ID_VERIFICATION", + "LIVENESS", + "FACE_MATCH", + "POA", + "AML", + "QUESTIONNAIRE" + ], + "metadata": null, + "environment": "sandbox", + "id_verifications": [], + "liveness_checks": null, + "face_matches": [], + "poa_verifications": null, + "aml_screenings": [], + "questionnaire_responses": [] +} diff --git a/service/testdata/didit/decision_user_declined_expired_document.json b/service/testdata/didit/decision_user_declined_expired_document.json new file mode 100644 index 0000000..4865d56 --- /dev/null +++ b/service/testdata/didit/decision_user_declined_expired_document.json @@ -0,0 +1,25 @@ +{ + "session_id": "00000000-0000-4000-8000-000000000112", + "session_kind": "user", + "status": "Declined", + "workflow_id": "00000000-0000-4000-8000-000000000201", + "vendor_data": "00000000-0000-4000-8000-000000000312", + "environment": "sandbox", + "id_verifications": [ + { + "node_id": "id-document", + "status": "Declined", + "warnings": [ + { + "feature": "ID_VERIFICATION", + "risk": "DOCUMENT_EXPIRED", + "additional_data": null, + "log_type": "error", + "short_description": "Document expired", + "long_description": "The document expiration date has passed.", + "node_id": "id-document" + } + ] + } + ] +} diff --git a/service/testdata/didit/decision_user_declined_minimum_age.json b/service/testdata/didit/decision_user_declined_minimum_age.json new file mode 100644 index 0000000..c3e9700 --- /dev/null +++ b/service/testdata/didit/decision_user_declined_minimum_age.json @@ -0,0 +1,28 @@ +{ + "session_id": "00000000-0000-4000-8000-000000000111", + "session_kind": "user", + "status": "Declined", + "workflow_id": "00000000-0000-4000-8000-000000000201", + "vendor_data": "00000000-0000-4000-8000-000000000311", + "environment": "sandbox", + "id_verifications": [ + { + "node_id": "id-document", + "status": "Declined", + "issuing_state": "CAN", + "warnings": [ + { + "feature": "ID_VERIFICATION", + "risk": "MINIMUM_AGE_NOT_MET", + "additional_data": { + "configured_minimum_age": 19 + }, + "log_type": "error", + "short_description": "Minimum age not met", + "long_description": "The holder is below the configured minimum age.", + "node_id": "id-document" + } + ] + } + ] +} diff --git a/service/testdata/didit/error_invalid_workflow_400.json b/service/testdata/didit/error_invalid_workflow_400.json new file mode 100644 index 0000000..30d19b1 --- /dev/null +++ b/service/testdata/didit/error_invalid_workflow_400.json @@ -0,0 +1,5 @@ +{ + "workflow_id": [ + "Invalid workflow_id." + ] +} diff --git a/service/testdata/didit/error_rate_limited_429.json b/service/testdata/didit/error_rate_limited_429.json new file mode 100644 index 0000000..606d2a4 --- /dev/null +++ b/service/testdata/didit/error_rate_limited_429.json @@ -0,0 +1,3 @@ +{ + "detail": "Session creation rate limit exceeded." +} diff --git a/service/verificationService.go b/service/verificationService.go new file mode 100644 index 0000000..5583d46 --- /dev/null +++ b/service/verificationService.go @@ -0,0 +1,578 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/config" + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/NaeuralEdgeProtocol/ratio1-backend/storage" + "github.com/google/uuid" +) + +type VerificationSessionResponse struct { + Provider string `json:"provider"` + ApplicantType string `json:"applicantType"` + Status string `json:"status"` + SessionId string `json:"sessionId,omitempty"` + Url string `json:"url,omitempty"` + AccessToken string `json:"accessToken,omitempty"` +} + +var ErrVerificationReconciliationPending = errors.New( + "Didit session is complete and pending authoritative reconciliation", +) + +func (response VerificationSessionResponse) Validate() error { + switch response.Provider { + case model.VerificationProviderDidit: + if strings.TrimSpace(response.SessionId) == "" || + strings.TrimSpace(response.Url) == "" || + strings.TrimSpace(response.AccessToken) != "" { + return errors.New("Didit session response must contain only sessionId and url credentials") + } + case model.VerificationProviderSumsub: + if strings.TrimSpace(response.AccessToken) == "" || + strings.TrimSpace(response.SessionId) != "" || + strings.TrimSpace(response.Url) != "" { + return errors.New("Sumsub session response must contain only an accessToken credential") + } + default: + return errors.New("verification session response has an unsupported provider") + } + if response.ApplicantType != model.IndividualCustomer && + response.ApplicantType != model.BusinessCustomer { + return errors.New("verification session response has an unsupported applicant type") + } + if strings.TrimSpace(response.Status) == "" { + return errors.New("verification session response status is required") + } + return nil +} + +type diditSessionClient interface { + CreateSession(context.Context, model.DiditCreateSessionRequest) (*model.DiditCreateSessionResponse, error) + RetrieveDecision(context.Context, uuid.UUID, model.DiditDecisionExpectation) (*model.DiditDecision, error) + RetrieveEntity(context.Context, model.DiditSessionKind, string) (*model.DiditEntity, error) +} + +type VerificationService struct { + cfg config.GeneralConfig + didit diditSessionClient + diditPolicies DiditPolicySet + sumsubInitSession func(string, string) (*string, error) +} + +type DiditWebhookHeaders struct { + Timestamp string + SignatureV2 string + Signature string + Simple string + TestWebhook bool +} + +type DiditWebhookReceipt struct { + Duplicate bool + TestOnly bool +} + +type diditWebhookPayload struct { + EventId uuid.UUID `json:"event_id"` + WebhookType string `json:"webhook_type"` + Timestamp int64 `json:"timestamp"` + CreatedAt int64 `json:"created_at"` + ApplicationId uuid.UUID `json:"application_id"` + Environment string `json:"environment"` + SessionId uuid.UUID `json:"session_id"` + SessionKind model.DiditSessionKind `json:"session_kind"` + VendorData string `json:"vendor_data"` + Status string `json:"status"` +} + +var supportedDiditVerificationEvents = map[string]struct{}{ + "status.updated": {}, + "data.updated": {}, + "user.status.updated": {}, + "user.data.updated": {}, + "business.status.updated": {}, + "business.data.updated": {}, +} + +func NewVerificationService(cfg config.GeneralConfig) (*VerificationService, error) { + service := &VerificationService{ + cfg: cfg, + sumsubInitSession: InitNewSession, + } + diditConfigured := strings.TrimSpace(cfg.Didit.ApiKey) != "" || + strings.TrimSpace(cfg.Didit.WebhookSecret) != "" || + strings.TrimSpace(cfg.Didit.ApplicationId) != "" + if cfg.Verification.Provider == model.VerificationProviderDidit || diditConfigured { + client, err := NewDiditClient(cfg.Didit, nil) + if err != nil { + return nil, err + } + policies, err := NewDiditPolicySet(cfg.Didit) + if err != nil { + return nil, err + } + if _, err := uuid.Parse(cfg.Didit.ApplicationId); err != nil { + return nil, errors.New("DIDIT_APPLICATION_ID is invalid") + } + if strings.TrimSpace(cfg.Didit.WebhookSecret) == "" { + return nil, errors.New("DIDIT_WEBHOOK_SECRET is not set") + } + if err := validateDiditCallbackUrl(cfg.Didit.CallbackUrl); err != nil { + return nil, err + } + service.didit = client + service.diditPolicies = policies + } + switch cfg.Verification.Provider { + case model.VerificationProviderSumsub, model.VerificationProviderDidit: + return service, nil + default: + return nil, errors.New("unsupported verification provider") + } +} + +func (s *VerificationService) ReceiveDiditWebhook( + body []byte, + headers DiditWebhookHeaders, + now time.Time, +) (DiditWebhookReceipt, error) { + if s.didit == nil { + return DiditWebhookReceipt{}, errors.New("Didit integration is inactive") + } + signatureHeaders := DiditWebhookSignatureHeaders{ + Timestamp: headers.Timestamp, + V2: headers.SignatureV2, + Raw: headers.Signature, + Simple: headers.Simple, + } + _, signatureErr := VerifyDiditWebhookSignatures( + body, + signatureHeaders, + s.cfg.Didit.WebhookSecret, + now, + ) + if signatureErr != nil && strings.TrimSpace(s.cfg.Didit.PreviousWebhookSecret) != "" { + _, signatureErr = VerifyDiditWebhookSignatures( + body, + signatureHeaders, + s.cfg.Didit.PreviousWebhookSecret, + now, + ) + } + if signatureErr != nil { + return DiditWebhookReceipt{}, signatureErr + } + + var payload diditWebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + return DiditWebhookReceipt{}, ErrDiditWebhookEnvelope + } + if headers.TestWebhook { + if s.cfg.Didit.Environment != model.VerificationEnvironmentSandbox { + return DiditWebhookReceipt{}, errors.New("Didit test webhooks are disabled outside sandbox") + } + return DiditWebhookReceipt{TestOnly: true}, nil + } + if payload.EventId == uuid.Nil || + payload.ApplicationId == uuid.Nil || + strings.TrimSpace(payload.VendorData) == "" || + payload.Timestamp == 0 || + payload.CreatedAt == 0 { + return DiditWebhookReceipt{}, ErrDiditWebhookEnvelope + } + if _, allowed := supportedDiditVerificationEvents[payload.WebhookType]; !allowed { + return DiditWebhookReceipt{}, errors.New("unsupported Didit verification event") + } + expectedApplicationId, _ := uuid.Parse(s.cfg.Didit.ApplicationId) + if payload.ApplicationId != expectedApplicationId { + return DiditWebhookReceipt{}, ErrDiditEnvironmentMismatch + } + environment, err := internalDiditEnvironment(payload.Environment) + if err != nil || environment != s.cfg.Didit.Environment { + return DiditWebhookReceipt{}, ErrDiditEnvironmentMismatch + } + if _, err := uuid.Parse(payload.VendorData); err != nil { + return DiditWebhookReceipt{}, ErrDiditWebhookEnvelope + } + if isDiditSessionEvent(payload.WebhookType) && payload.SessionId == uuid.Nil { + return DiditWebhookReceipt{}, ErrDiditWebhookEnvelope + } + expectedKind := diditSessionKindForEvent(payload.WebhookType) + if expectedKind != "" { + if payload.SessionKind != "" && payload.SessionKind != expectedKind { + return DiditWebhookReceipt{}, ErrDiditWebhookEnvelope + } + payload.SessionKind = expectedKind + } else if payload.SessionKind != "" && + payload.SessionKind != model.DiditSessionKindUser && + payload.SessionKind != model.DiditSessionKindBusiness { + return DiditWebhookReceipt{}, ErrDiditWebhookEnvelope + } + + digest := sha256.Sum256(body) + occurredAt := time.Unix(payload.CreatedAt, 0).UTC() + created, err := storage.CreateVerificationWebhookEvent(&model.VerificationWebhookEvent{ + Provider: model.VerificationProviderDidit, + Environment: environment, + EventId: payload.EventId.String(), + EventType: payload.WebhookType, + ProviderSessionId: nilUuidString(payload.SessionId), + ProviderApplicationId: payload.ApplicationId.String(), + VendorData: payload.VendorData, + OccurredAt: &occurredAt, + ReceivedAt: now.UTC(), + PayloadSha256: hex.EncodeToString(digest[:]), + ProcessingStatus: model.VerificationEventReceived, + }) + if err != nil { + return DiditWebhookReceipt{}, err + } + return DiditWebhookReceipt{Duplicate: !created}, nil +} + +func (s *VerificationService) CreateOrResumeSession( + ctx context.Context, + address, applicantType string, +) (*VerificationSessionResponse, error) { + if applicantType != model.IndividualCustomer && applicantType != model.BusinessCustomer { + return nil, errors.New("type must be individual or company") + } + account, err := GetOrCreateAccount(address) + if err != nil { + return nil, err + } + if account == nil || account.Email == nil || !account.EmailConfirmed { + return nil, errors.New("email is not confirmed") + } + if account.IsBlacklisted { + return nil, errors.New("account is blacklisted") + } + kyc, found, err := storage.GetKycByEmail(*account.Email) + if err != nil { + return nil, err + } + if !found { + return nil, ErrorKycNotFound + } + if kyc.KycStatus == model.StatusApproved { + return nil, errors.New("verification is already approved") + } + if kyc.KycStatus == model.StatusFinalRejected { + return nil, errors.New("verification is final rejected and cannot be retried") + } + if kyc.ApplicantType != "" && kyc.ApplicantType != applicantType { + return nil, errors.New("verification type cannot be changed after verification starts") + } + if kyc.VerificationProvider != "" && + kyc.VerificationProvider != s.cfg.Verification.Provider { + return nil, fmt.Errorf( + "verification is owned by provider %q and requires an explicit cutover", + kyc.VerificationProvider, + ) + } + + switch s.cfg.Verification.Provider { + case model.VerificationProviderSumsub: + return s.createSumsubSession(kyc, applicantType) + case model.VerificationProviderDidit: + return s.createOrResumeDiditSession(ctx, kyc, applicantType) + default: + return nil, errors.New("unsupported verification provider") + } +} + +func (s *VerificationService) createSumsubSession( + kyc *model.Kyc, + applicantType string, +) (*VerificationSessionResponse, error) { + level := s.cfg.Sumsub.CustomerLevelName + if applicantType == model.BusinessCustomer { + level = s.cfg.Sumsub.BusinessLevelName + } + token, err := s.sumsubInitSession(kyc.Uuid.String(), level) + if err != nil { + return nil, err + } + if token == nil || strings.TrimSpace(*token) == "" { + return nil, errors.New("Sumsub returned an empty access token") + } + kyc.ApplicantType = applicantType + kyc.VerificationProvider = model.VerificationProviderSumsub + if kyc.KycStatus == model.StatusAccountCreated { + kyc.KycStatus = model.StatusInit + kyc.LastUpdated = time.Now().UTC() + } + if err := storage.CreateOrUpdateKyc(kyc); err != nil { + return nil, err + } + result := &VerificationSessionResponse{ + Provider: model.VerificationProviderSumsub, + ApplicantType: applicantType, + Status: kyc.KycStatus, + AccessToken: *token, + } + if err := result.Validate(); err != nil { + return nil, err + } + return result, nil +} + +func (s *VerificationService) createOrResumeDiditSession( + ctx context.Context, + kyc *model.Kyc, + applicantType string, +) (*VerificationSessionResponse, error) { + if s.didit == nil { + return nil, errors.New("Didit integration is unavailable") + } + policy, err := s.diditPolicies.ForApplicantType(applicantType) + if err != nil { + return nil, err + } + + var result *VerificationSessionResponse + err = storage.WithVerificationCreationLock( + ctx, + kyc.Uuid, + model.VerificationProviderDidit, + s.cfg.Didit.Environment, + func() error { + var lockedErr error + result, lockedErr = s.createOrResumeDiditSessionLocked(ctx, kyc, applicantType, policy) + return lockedErr + }, + ) + return result, err +} + +func (s *VerificationService) createOrResumeDiditSessionLocked( + ctx context.Context, + kyc *model.Kyc, + applicantType string, + policy DiditVerificationPolicy, +) (*VerificationSessionResponse, error) { + latest, found, err := storage.GetLatestVerificationSession( + kyc.Uuid, + applicantType, + model.VerificationProviderDidit, + s.cfg.Didit.Environment, + ) + if err != nil { + return nil, err + } + if found { + sessionId, parseErr := uuid.Parse(latest.ProviderSessionId) + if parseErr != nil { + return nil, errors.New("stored Didit session id is invalid") + } + decision, retrieveErr := s.didit.RetrieveDecision(ctx, sessionId, model.DiditDecisionExpectation{ + VendorData: kyc.Uuid.String(), + WorkflowId: policy.ApprovalPolicy.WorkflowId, + SessionKind: policy.ApprovalPolicy.SessionKind, + }) + if retrieveErr != nil { + return nil, retrieveErr + } + if diditSessionCanResume(decision.Status) && isAllowedDiditHostedURL(decision.SessionUrl) { + result := &VerificationSessionResponse{ + Provider: model.VerificationProviderDidit, + ApplicantType: applicantType, + Status: latest.KycStatus, + SessionId: sessionId.String(), + Url: decision.SessionUrl, + } + if err := result.Validate(); err != nil { + return nil, err + } + return result, nil + } + if diditSessionCanResume(decision.Status) { + return nil, errors.New("Didit returned an invalid hosted session URL") + } + if err := enqueueDiditPollReconciliation(latest, decision); err != nil { + return nil, err + } + reconciledKyc, kycFound, readErr := storage.GetKycByUuid(kyc.Uuid) + if readErr != nil { + return nil, readErr + } + if !kycFound { + return nil, ErrorKycNotFound + } + switch reconciledKyc.KycStatus { + case model.StatusApproved: + return nil, errors.New("verification is already approved") + case model.StatusFinalRejected: + return nil, errors.New("verification is final rejected and cannot be retried") + } + if !diditTerminalSessionWasReconciledAsRetryable(latest, decision, reconciledKyc) { + return nil, ErrVerificationReconciliationPending + } + kyc = reconciledKyc + } + + response, err := s.didit.CreateSession(ctx, diditCreateSessionRequest( + s.cfg.Didit.CallbackUrl, + kyc, + applicantType, + policy, + )) + if err != nil { + return nil, err + } + session, err := storage.AssignVerificationSession(&model.VerificationSession{ + KycUuid: kyc.Uuid, + Provider: model.VerificationProviderDidit, + Environment: s.cfg.Didit.Environment, + ProviderSessionId: response.SessionId.String(), + WorkflowId: response.WorkflowId.String(), + WorkflowVersion: fmt.Sprintf("%d", response.WorkflowVersion), + ApplicantType: applicantType, + KycStatus: model.StatusInit, + ProviderStatus: string(response.Status), + }) + if err != nil { + return nil, err + } + result := &VerificationSessionResponse{ + Provider: model.VerificationProviderDidit, + ApplicantType: applicantType, + Status: session.KycStatus, + SessionId: response.SessionId.String(), + Url: response.Url, + } + if err := result.Validate(); err != nil { + return nil, err + } + return result, nil +} + +func diditTerminalSessionWasReconciledAsRetryable( + session *model.VerificationSession, + decision *model.DiditDecision, + kyc *model.Kyc, +) bool { + return session.LastReconciledAt != nil && + session.KycStatus == model.StatusRejected && + kyc.KycStatus == model.StatusRejected && + diditDecisionIsTerminal(decision.Status) && + session.ProviderStatus == string(decision.Status) +} + +func diditCreateSessionRequest( + callbackUrl string, + kyc *model.Kyc, + applicantType string, + policy DiditVerificationPolicy, +) model.DiditCreateSessionRequest { + return model.DiditCreateSessionRequest{ + WorkflowId: policy.ApprovalPolicy.WorkflowId, + VendorData: kyc.Uuid.String(), + ExpectedSessionKind: policy.ApprovalPolicy.SessionKind, + Callback: callbackUrl, + CallbackMethod: "initiator", + Metadata: map[string]interface{}{ + "ratio1_applicant_type": applicantType, + }, + ContactDetails: &model.DiditContactDetails{ + Email: kyc.Email, + SendNotificationEmails: false, + }, + } +} + +func diditSessionCanResume(status model.DiditSessionStatus) bool { + switch status { + case model.DiditStatusNotStarted, + model.DiditStatusInProgress, + model.DiditStatusAwaitingUser, + model.DiditStatusInReview, + model.DiditStatusResubmitted: + return true + default: + return false + } +} + +func enqueueDiditPollReconciliation( + session *model.VerificationSession, + decision *model.DiditDecision, +) error { + payload, err := json.Marshal(decision) + if err != nil { + return err + } + digest := sha256.Sum256(payload) + eventId := "poll:" + + session.ProviderSessionId + ":" + + strings.ToLower(strings.ReplaceAll(string(decision.Status), " ", "_")) + ":" + + hex.EncodeToString(digest[:8]) + _, err = storage.CreateVerificationWebhookEvent(&model.VerificationWebhookEvent{ + Provider: model.VerificationProviderDidit, + Environment: session.Environment, + EventId: eventId, + EventType: "internal.reconcile", + ProviderSessionId: session.ProviderSessionId, + VendorData: decision.VendorData, + ReceivedAt: time.Now().UTC(), + PayloadSha256: hex.EncodeToString(digest[:]), + ProcessingStatus: model.VerificationEventReceived, + }) + return err +} + +func validateDiditCallbackUrl(value string) error { + value = strings.TrimSpace(value) + callback, err := url.Parse(value) + if err != nil || + callback.Scheme != "https" || + callback.Host == "" || + callback.User != nil { + return errors.New("DIDIT_CALLBACK_URL must use https") + } + return nil +} + +func internalDiditEnvironment(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "sandbox": + return model.VerificationEnvironmentSandbox, nil + case "live", "production": + return model.VerificationEnvironmentProduction, nil + default: + return "", errors.New("unsupported Didit webhook environment") + } +} + +func isDiditSessionEvent(eventType string) bool { + return eventType == "status.updated" || eventType == "data.updated" +} + +func diditSessionKindForEvent(eventType string) model.DiditSessionKind { + switch { + case strings.HasPrefix(eventType, "user."): + return model.DiditSessionKindUser + case strings.HasPrefix(eventType, "business."): + return model.DiditSessionKindBusiness + default: + return "" + } +} + +func nilUuidString(id uuid.UUID) string { + if id == uuid.Nil { + return "" + } + return id.String() +} diff --git a/service/verificationService_test.go b/service/verificationService_test.go new file mode 100644 index 0000000..4db5206 --- /dev/null +++ b/service/verificationService_test.go @@ -0,0 +1,250 @@ +package service + +import ( + "encoding/json" + "testing" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestVerificationSessionResponseRequiresProviderSpecificCredential(t *testing.T) { + tests := []struct { + name string + response VerificationSessionResponse + valid bool + }{ + { + name: "Didit credential", + response: VerificationSessionResponse{ + Provider: model.VerificationProviderDidit, + ApplicantType: model.IndividualCustomer, + Status: model.StatusInit, + SessionId: uuid.NewString(), + Url: "https://verify.didit.me/session", + }, + valid: true, + }, + { + name: "Sumsub credential", + response: VerificationSessionResponse{ + Provider: model.VerificationProviderSumsub, + ApplicantType: model.BusinessCustomer, + Status: model.StatusInit, + AccessToken: "token", + }, + valid: true, + }, + { + name: "Didit must include URL", + response: VerificationSessionResponse{ + Provider: model.VerificationProviderDidit, + ApplicantType: model.IndividualCustomer, + Status: model.StatusInit, + SessionId: uuid.NewString(), + }, + }, + { + name: "provider credentials cannot overlap", + response: VerificationSessionResponse{ + Provider: model.VerificationProviderDidit, + ApplicantType: model.IndividualCustomer, + Status: model.StatusInit, + SessionId: uuid.NewString(), + Url: "https://verify.didit.me/session", + AccessToken: "token", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.response.Validate() + if test.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func TestDiditCreateSessionUsesFixedProfileInitiatorCallback(t *testing.T) { + policy := DiditVerificationPolicy{ + ApprovalPolicy: DiditApprovalPolicy{ + WorkflowId: uuid.New(), + SessionKind: model.DiditSessionKindBusiness, + }, + } + kyc := &model.Kyc{ + Uuid: uuid.New(), + Email: "company@example.com", + } + + request := diditCreateSessionRequest( + "https://app.ratio1.ai/profile", + kyc, + model.BusinessCustomer, + policy, + ) + + require.Equal(t, "https://app.ratio1.ai/profile", request.Callback) + require.Equal(t, "initiator", request.CallbackMethod) + require.Equal(t, kyc.Uuid.String(), request.VendorData) + require.Equal(t, model.DiditSessionKindBusiness, request.ExpectedSessionKind) + require.Equal(t, model.BusinessCustomer, request.Metadata["ratio1_applicant_type"]) + require.NotContains(t, request.Callback, "url=") + require.NotContains(t, request.Callback, "token=") +} + +func TestDiditHostedUrlAllowlist(t *testing.T) { + require.True(t, isAllowedDiditHostedURL("https://verify.didit.me/session/token")) + require.False(t, isAllowedDiditHostedURL("http://verify.didit.me/session/token")) + require.False(t, isAllowedDiditHostedURL("https://verify.didit.me.attacker.invalid/session/token")) + require.False(t, isAllowedDiditHostedURL("https://verify.didit.me/other/token")) + require.False(t, isAllowedDiditHostedURL("https://verify.didit.me/session/")) + require.False(t, isAllowedDiditHostedURL("https://user@verify.didit.me/session/token")) +} + +func TestDiditReplacementRequiresReconciledRetryableSessionAndKyc(t *testing.T) { + reconciledAt := time.Now().UTC() + session := &model.VerificationSession{ + KycStatus: model.StatusRejected, + ProviderStatus: string(model.DiditStatusDeclined), + LastReconciledAt: &reconciledAt, + } + decision := &model.DiditDecision{Status: model.DiditStatusDeclined} + kyc := &model.Kyc{KycStatus: model.StatusRejected} + require.True(t, diditTerminalSessionWasReconciledAsRetryable(session, decision, kyc)) + + finalRejected := *kyc + finalRejected.KycStatus = model.StatusFinalRejected + require.False(t, diditTerminalSessionWasReconciledAsRetryable( + session, + decision, + &finalRejected, + )) + + session.LastReconciledAt = nil + require.False(t, diditTerminalSessionWasReconciledAsRetryable(session, decision, kyc)) +} + +func TestMapDiditDecisionToUserInfoRequiresStructuredKybBillingAnswers(t *testing.T) { + var decision model.DiditDecision + require.NoError(t, json.Unmarshal(readDiditFixture(t, "decision_business_approved.json"), &decision)) + + sourceQuestion := uuid.New() + policy := DiditVerificationPolicy{ + ApprovalPolicy: diditTestKybApprovalPolicy(), + } + policy.ApprovalPolicy.RequiredQuestionnaireItems = append( + policy.ApprovalPolicy.RequiredQuestionnaireItems, + sourceQuestion, + ) + appendDiditAnswers(&decision, map[uuid.UUID]string{ + sourceQuestion: "business-income", + }) + decision.RegistryChecks[0].Company = json.RawMessage(`{ + "company_name":"Example SRL", + "country_code":"ITA", + "registration_number":"REG-001", + "registered_address":"Via Test 1", + "location_of_registration":"Rome", + "tax_number":"IT-TAX-001", + "vat_number":"IT12345678901", + "vat_validation_status":"valid", + "user_provided_data":{"region":"RM"} + }`) + + userInfo, country, viesRegistered, err := MapDiditDecisionToUserInfo( + decision, + policy, + "0x0000000000000000000000000000000000000001", + "company@example.com", + ) + require.NoError(t, err) + require.Equal(t, "IT12345678901", userInfo.IdentificationCode) + require.Equal(t, "ITA", country) + require.True(t, viesRegistered) + + missingTax := decision + missingTax.RegistryChecks[0].Company = json.RawMessage(`{ + "company_name":"Example SRL", + "country_code":"ITA", + "registration_number":"REG-001", + "registered_address":"Via Test 1", + "location_of_registration":"Rome", + "vat_number":"IT12345678901", + "vat_validation_status":"valid", + "user_provided_data":{"region":"RM"} + }`) + _, _, _, err = MapDiditDecisionToUserInfo( + missingTax, + policy, + "0x0000000000000000000000000000000000000001", + "company@example.com", + ) + require.ErrorContains(t, err, "invoicing data is incomplete") +} + +func TestGrandfatheredSumsubMonitoringIsRevokeOnly(t *testing.T) { + updatedAt := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + baseKyc := model.Kyc{ + ApplicantId: "sumsub-applicant", + VerificationProvider: model.VerificationProviderSumsub, + KycStatus: model.StatusApproved, + IsActive: true, + LastUpdated: updatedAt, + } + baseEvent := model.SumsubEvent{ + ApplicantID: "sumsub-applicant", + Type: model.ApplicantReviewed, + CreatedAtMs: "2026-07-29 11:00:00.000", + } + + green := baseEvent + green.ReviewResult.ReviewAnswer = "GREEN" + result, changed, err := grandfatheredSumsubMonitoringTransition(green, baseKyc) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, model.StatusApproved, result.KycStatus) + + finalRed := baseEvent + finalRed.ReviewResult.ReviewAnswer = "RED" + finalRed.ReviewResult.ReviewRejectType = "FINAL" + result, changed, err = grandfatheredSumsubMonitoringTransition(finalRed, baseKyc) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, model.StatusFinalRejected, result.KycStatus) + + reset := baseEvent + reset.Type = model.ApplicantReset + result, changed, err = grandfatheredSumsubMonitoringTransition(reset, baseKyc) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, model.StatusApproved, result.KycStatus) + + diditOwned := baseKyc + diditOwned.VerificationProvider = model.VerificationProviderDidit + result, changed, err = grandfatheredSumsubMonitoringTransition(finalRed, diditOwned) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, model.StatusApproved, result.KycStatus) +} + +func appendDiditAnswers(decision *model.DiditDecision, answers map[uuid.UUID]string) { + items := make([]model.DiditQuestionnaireResponseItem, 0, len(answers)) + for id, value := range answers { + answer := value + items = append(items, model.DiditQuestionnaireResponseItem{ + Uuid: id, + Answer: &model.DiditQuestionnaireResponseAnswer{Value: &answer}, + }) + } + decision.QuestionnaireResponses[0].Sections = append( + decision.QuestionnaireResponses[0].Sections, + model.DiditQuestionnaireSection{Items: items}, + ) +} diff --git a/service/verificationSessionIntegration_test.go b/service/verificationSessionIntegration_test.go new file mode 100644 index 0000000..e185bce --- /dev/null +++ b/service/verificationSessionIntegration_test.go @@ -0,0 +1,311 @@ +package service + +import ( + "context" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/config" + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/NaeuralEdgeProtocol/ratio1-backend/storage" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var serviceTestDatabaseOnce sync.Once + +func TestRetryableTerminalDiditSessionCreatesOneReplacementConcurrently(t *testing.T) { + requireServiceTestDatabase(t) + + db, err := storage.GetDB() + require.NoError(t, err) + kycUuid := uuid.New() + workflowId := uuid.New() + oldSessionId := uuid.New() + newSessionId := uuid.New() + email := "didit-retry-" + kycUuid.String() + "@example.test" + reconciledAt := time.Now().UTC().Add(-time.Minute) + receiveUpdates := false + require.NoError(t, db.Create(&model.Kyc{ + Uuid: kycUuid, + Email: email, + ApplicantType: model.IndividualCustomer, + KycStatus: model.StatusRejected, + VerificationProvider: model.VerificationProviderDidit, + ReceiveUpdates: &receiveUpdates, + IsActive: true, + }).Error) + oldSession := &model.VerificationSession{ + Uuid: uuid.New(), + KycUuid: kycUuid, + Provider: model.VerificationProviderDidit, + Environment: model.VerificationEnvironmentSandbox, + ProviderSessionId: oldSessionId.String(), + WorkflowId: workflowId.String(), + WorkflowVersion: "1", + ApplicantType: model.IndividualCustomer, + KycStatus: model.StatusRejected, + ProviderStatus: string(model.DiditStatusDeclined), + LastReconciledAt: &reconciledAt, + CreatedAt: reconciledAt, + UpdatedAt: reconciledAt, + } + require.NoError(t, storage.CreateVerificationSession(oldSession)) + t.Cleanup(func() { + require.NoError(t, db.Where("vendor_data = ?", kycUuid.String()). + Delete(&model.VerificationWebhookEvent{}).Error) + require.NoError(t, db.Where("kyc_uuid = ?", kycUuid). + Delete(&model.VerificationSession{}).Error) + require.NoError(t, db.Where("uuid = ?", kycUuid).Delete(&model.Kyc{}).Error) + }) + + client := &retryableDiditSessionClient{ + oldSessionId: oldSessionId, + newSessionId: newSessionId, + workflowId: workflowId, + kycUuid: kycUuid, + } + verificationService := &VerificationService{ + cfg: config.GeneralConfig{ + Verification: config.VerificationConfig{ + Provider: model.VerificationProviderDidit, + }, + Didit: config.DiditConfig{ + Environment: model.VerificationEnvironmentSandbox, + CallbackUrl: "https://app.ratio1.ai/profile", + }, + }, + didit: client, + diditPolicies: DiditPolicySet{Kyc: DiditVerificationPolicy{ + ApprovalPolicy: DiditApprovalPolicy{ + WorkflowId: workflowId, + WorkflowVersion: 1, + SessionKind: model.DiditSessionKindUser, + }, + }}, + } + kyc, found, err := storage.GetKycByUuid(kycUuid) + require.NoError(t, err) + require.True(t, found) + + start := make(chan struct{}) + results := make(chan *VerificationSessionResponse, 2) + errorsChannel := make(chan error, 2) + var waitGroup sync.WaitGroup + for index := 0; index < 2; index++ { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + <-start + result, createErr := verificationService.createOrResumeDiditSession( + context.Background(), + kyc, + model.IndividualCustomer, + ) + results <- result + errorsChannel <- createErr + }() + } + close(start) + waitGroup.Wait() + close(results) + close(errorsChannel) + + for createErr := range errorsChannel { + require.NoError(t, createErr) + } + for result := range results { + require.NotNil(t, result) + require.Equal(t, newSessionId.String(), result.SessionId) + require.Equal(t, "https://verify.didit.me/session/retry-token", result.Url) + } + require.Equal(t, int32(1), client.createCalls.Load()) + + var sessionCount int64 + require.NoError(t, db.Model(&model.VerificationSession{}). + Where("kyc_uuid = ?", kycUuid). + Count(&sessionCount).Error) + require.Equal(t, int64(2), sessionCount) + persistedKyc, found, err := storage.GetKycByUuid(kycUuid) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, model.StatusInit, persistedKyc.KycStatus) +} + +func TestStoredSumsubMonitoringEventIsRestartRecoverable(t *testing.T) { + requireServiceTestDatabase(t) + + db, err := storage.GetDB() + require.NoError(t, err) + kycUuid := uuid.New() + eventId := "sumsub-recovery-" + uuid.NewString() + receiveUpdates := false + occurredAt := time.Now().UTC().Truncate(time.Millisecond) + require.NoError(t, db.Create(&model.Kyc{ + Uuid: kycUuid, + Email: "sumsub-recovery-" + kycUuid.String() + "@example.test", + ApplicantId: "sumsub-applicant-" + kycUuid.String(), + ApplicantType: model.IndividualCustomer, + KycStatus: model.StatusApproved, + VerificationProvider: model.VerificationProviderSumsub, + ReceiveUpdates: &receiveUpdates, + IsActive: true, + LastUpdated: occurredAt.Add(-time.Minute), + }).Error) + event := &model.VerificationWebhookEvent{ + Provider: model.VerificationProviderSumsub, + Environment: model.VerificationEnvironmentSandbox, + EventId: eventId, + EventType: model.ApplicantDeactivated, + ProviderSessionId: "sumsub-applicant-" + kycUuid.String(), + VendorData: kycUuid.String(), + OccurredAt: &occurredAt, + ReceivedAt: occurredAt, + PayloadSha256: strings.Repeat("a", 64), + ProcessingStatus: model.VerificationEventReceived, + } + created, err := storage.CreateVerificationWebhookEvent(event) + require.NoError(t, err) + require.True(t, created) + t.Cleanup(func() { + require.NoError(t, db.Where("uuid = ?", event.Uuid). + Delete(&model.VerificationWebhookEvent{}).Error) + require.NoError(t, db.Where("uuid = ?", kycUuid).Delete(&model.Kyc{}).Error) + }) + + claimed, err := storage.ClaimVerificationWebhookEvents( + occurredAt.Add(time.Second), + time.Minute, + 10, + 3, + ) + require.NoError(t, err) + var recovered *model.VerificationWebhookEvent + for index := range claimed { + if claimed[index].Uuid == event.Uuid { + recovered = &claimed[index] + break + } + } + require.NotNil(t, recovered) + + verificationService := &VerificationService{cfg: config.GeneralConfig{ + Verification: config.VerificationConfig{ + Provider: model.VerificationProviderDidit, + LegacySumsubWebhooksEnabled: true, + }, + }} + require.NoError(t, verificationService.reconcileGrandfatheredSumsubEvent(recovered)) + + storedKyc, found, err := storage.GetKycByUuid(kycUuid) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, model.StatusOnHold, storedKyc.KycStatus) + require.False(t, storedKyc.IsActive) + storedEvent, found, err := storage.GetVerificationWebhookEvent( + model.VerificationProviderSumsub, + model.VerificationEnvironmentSandbox, + eventId, + ) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, model.VerificationEventProcessed, storedEvent.ProcessingStatus) +} + +type retryableDiditSessionClient struct { + oldSessionId uuid.UUID + newSessionId uuid.UUID + workflowId uuid.UUID + kycUuid uuid.UUID + createCalls atomic.Int32 +} + +func (client *retryableDiditSessionClient) CreateSession( + _ context.Context, + _ model.DiditCreateSessionRequest, +) (*model.DiditCreateSessionResponse, error) { + client.createCalls.Add(1) + return &model.DiditCreateSessionResponse{ + SessionId: client.newSessionId, + SessionKind: model.DiditSessionKindUser, + Url: "https://verify.didit.me/session/retry-token", + VendorData: client.kycUuid.String(), + Status: model.DiditStatusNotStarted, + WorkflowId: client.workflowId, + WorkflowVersion: 1, + }, nil +} + +func (client *retryableDiditSessionClient) RetrieveDecision( + _ context.Context, + sessionId uuid.UUID, + _ model.DiditDecisionExpectation, +) (*model.DiditDecision, error) { + if sessionId == client.oldSessionId { + return &model.DiditDecision{ + SessionId: sessionId, + SessionKind: model.DiditSessionKindUser, + Status: model.DiditStatusDeclined, + WorkflowId: client.workflowId, + VendorData: client.kycUuid.String(), + }, nil + } + return &model.DiditDecision{ + SessionId: sessionId, + SessionKind: model.DiditSessionKindUser, + SessionUrl: "https://verify.didit.me/session/retry-token", + Status: model.DiditStatusNotStarted, + WorkflowId: client.workflowId, + VendorData: client.kycUuid.String(), + }, nil +} + +func (*retryableDiditSessionClient) RetrieveEntity( + context.Context, + model.DiditSessionKind, + string, +) (*model.DiditEntity, error) { + panic("unexpected RetrieveEntity call") +} + +func requireServiceTestDatabase(t *testing.T) { + t.Helper() + if os.Getenv("RATIO1_SERVICE_TEST_DATABASE") != "1" { + t.Skip("set RATIO1_SERVICE_TEST_DATABASE=1 to run PostgreSQL service tests") + } + host := serviceTestEnvOrDefault("RATIO1_TEST_DATABASE_HOST", "127.0.0.1") + if host != "127.0.0.1" && host != "localhost" && host != "::1" { + t.Fatal("service integration tests require a loopback database host") + } + dbName := serviceTestEnvOrDefault("RATIO1_TEST_DATABASE_NAME", "ratio1_test") + if !strings.Contains(strings.ToLower(dbName), "test") { + t.Fatal("service integration test database name must contain test") + } + port, err := strconv.Atoi(serviceTestEnvOrDefault("RATIO1_TEST_DATABASE_PORT", "5432")) + require.NoError(t, err) + serviceTestDatabaseOnce.Do(func() { + config.Config.Database = config.DatabaseConfig{ + Host: host, + Port: port, + User: serviceTestEnvOrDefault("RATIO1_TEST_DATABASE_USER", "postgres"), + Password: serviceTestEnvOrDefault("RATIO1_TEST_DATABASE_PASSWORD", "postgres"), + DbName: dbName, + SslMode: "disable", + MaxOpenConns: 10, + MaxIdleConns: 10, + } + storage.Connect() + }) +} + +func serviceTestEnvOrDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/service/verificationWorker.go b/service/verificationWorker.go new file mode 100644 index 0000000..9129788 --- /dev/null +++ b/service/verificationWorker.go @@ -0,0 +1,421 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/NaeuralEdgeProtocol/ratio1-backend/storage" + "github.com/google/uuid" +) + +const verificationClaimLease = 2 * time.Minute + +type VerificationWorker struct { + service *VerificationService + cancel context.CancelFunc + done chan struct{} + once sync.Once +} + +func (s *VerificationService) StartWorker(parent context.Context) *VerificationWorker { + worker := &VerificationWorker{service: s, done: make(chan struct{})} + if s.didit == nil { + close(worker.done) + return worker + } + ctx, cancel := context.WithCancel(parent) + worker.cancel = cancel + go worker.run(ctx) + return worker +} + +func (w *VerificationWorker) Stop() { + w.once.Do(func() { + if w.cancel != nil { + w.cancel() + } + }) + <-w.done +} + +func (w *VerificationWorker) run(ctx context.Context) { + defer close(w.done) + interval := time.Duration(w.service.cfg.Verification.WorkerPollSeconds) * time.Second + if interval <= 0 { + interval = 2 * time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + w.processBatch(ctx) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func (w *VerificationWorker) processBatch(ctx context.Context) { + now := time.Now().UTC() + batchSize := w.service.cfg.Verification.WorkerBatchSize + maxAttempts := uint(w.service.cfg.Verification.WorkerMaxAttempts) + if err := w.service.enqueueStaleDiditSessions(now, batchSize); err != nil { + log.Error("could not enqueue stale Didit sessions: " + err.Error()) + } + events, err := storage.ClaimVerificationWebhookEvents( + now, + verificationClaimLease, + batchSize, + maxAttempts, + ) + if err != nil { + log.Error("could not claim verification webhook events: " + err.Error()) + return + } + for index := range events { + event := events[index] + var processErr error + switch event.Provider { + case model.VerificationProviderDidit: + processErr = w.service.reconcileDiditEvent(ctx, &event) + case model.VerificationProviderSumsub: + processErr = w.service.reconcileGrandfatheredSumsubEvent(&event) + default: + processErr = errors.New("unsupported verification event provider") + } + if processErr != nil { + nextAttempt := time.Now().UTC().Add(verificationRetryDelay(event.Attempts)) + if markErr := storage.MarkVerificationWebhookEventFailed( + event.Uuid, + processErr, + nextAttempt, + maxAttempts, + ); markErr != nil { + log.Error("could not mark verification webhook event failed: " + markErr.Error()) + } + } + } + w.processNotifications(now, batchSize, maxAttempts) +} + +func (s *VerificationService) reconcileGrandfatheredSumsubEvent( + event *model.VerificationWebhookEvent, +) error { + if s.cfg.Verification.Provider != model.VerificationProviderDidit || + !s.cfg.Verification.LegacySumsubWebhooksEnabled { + return errors.New("grandfathered Sumsub monitoring is inactive") + } + if event.OccurredAt == nil || + strings.TrimSpace(event.ProviderSessionId) == "" || + strings.TrimSpace(event.VendorData) == "" { + return errors.New("stored Sumsub monitoring event is incomplete") + } + kycUuid, err := uuid.Parse(event.VendorData) + if err != nil { + return errors.New("stored Sumsub monitoring KYC id is invalid") + } + kyc, found, err := storage.GetKycByUuid(kycUuid) + if err != nil { + return err + } + if !found { + return ErrorKycNotFound + } + sumsubEvent := model.SumsubEvent{ + ApplicantID: event.ProviderSessionId, + ExternalUserID: event.VendorData, + Type: event.EventType, + CreatedAtMs: event.OccurredAt.UTC().Format("2006-01-02 15:04:05.000"), + ReviewResult: model.ReviewResult{ + ReviewAnswer: event.ProviderStatus, + ReviewRejectType: event.StatusReason, + }, + } + if err := ProcessGrandfatheredSumsubMonitoringEvent(sumsubEvent, *kyc); err != nil { + return err + } + return storage.MarkVerificationWebhookEventProcessed(event.Uuid) +} + +func (s *VerificationService) enqueueStaleDiditSessions(now time.Time, batchSize int) error { + sessions, err := storage.ListStaleDiditVerificationSessions( + s.cfg.Didit.Environment, + now.Add(-5*time.Minute), + batchSize, + ) + if err != nil { + return err + } + bucket := now.Unix() / int64((5*time.Minute)/time.Second) + for index := range sessions { + session := sessions[index] + eventId := fmt.Sprintf("sweep:%s:%d", session.ProviderSessionId, bucket) + digest := sha256.Sum256([]byte(eventId)) + _, createErr := storage.CreateVerificationWebhookEvent(&model.VerificationWebhookEvent{ + Provider: model.VerificationProviderDidit, + Environment: session.Environment, + EventId: eventId, + EventType: "internal.reconcile", + ProviderSessionId: session.ProviderSessionId, + VendorData: session.KycUuid.String(), + ReceivedAt: now, + PayloadSha256: hex.EncodeToString(digest[:]), + ProcessingStatus: model.VerificationEventReceived, + }) + if createErr != nil && !errors.Is(createErr, storage.ErrVerificationEventPayloadMismatch) { + return createErr + } + } + return nil +} + +func (s *VerificationService) reconcileDiditEvent( + ctx context.Context, + event *model.VerificationWebhookEvent, +) error { + if event.Provider != model.VerificationProviderDidit { + return storage.MarkVerificationWebhookEventProcessed(event.Uuid) + } + session, err := s.verificationSessionForEvent(event) + if err != nil { + return err + } + sessionId, err := uuid.Parse(session.ProviderSessionId) + if err != nil { + return errors.New("stored Didit session id is invalid") + } + if event.VendorData != "" && event.VendorData != session.KycUuid.String() { + return errors.New("Didit event vendor data does not match stored KYC") + } + workflowId, err := uuid.Parse(session.WorkflowId) + if err != nil { + return errors.New("stored Didit workflow id is invalid") + } + workflowVersion, err := strconv.Atoi(session.WorkflowVersion) + if err != nil || workflowVersion <= 0 { + return errors.New("stored Didit workflow version is invalid") + } + policy, err := s.diditPolicies.ForApplicantType(session.ApplicantType) + if err != nil { + return err + } + if policy.ApprovalPolicy.WorkflowId != workflowId || + policy.ApprovalPolicy.WorkflowVersion != workflowVersion { + return errors.New("stored Didit session policy does not match configured workflow") + } + + reconciliationStartedAt := time.Now().UTC() + decision, err := s.didit.RetrieveDecision(ctx, sessionId, model.DiditDecisionExpectation{ + VendorData: session.KycUuid.String(), + WorkflowId: workflowId, + SessionKind: policy.ApprovalPolicy.SessionKind, + }) + if err != nil { + return err + } + entity, err := s.didit.RetrieveEntity( + ctx, + policy.ApprovalPolicy.SessionKind, + session.KycUuid.String(), + ) + if err != nil { + return err + } + entityStatus, err := projectableDiditEntityStatus(entity.Status) + if err != nil { + return err + } + evidence := EvaluateDiditApprovalEvidence(*decision, workflowVersion, policy.ApprovalPolicy) + decline := ClassifyDiditDecline(*decision, DefaultDiditDeclinePolicy(), policy.ApprovalPolicy) + projection := ProjectDiditLifecycle(DiditLifecycleProjectionInput{ + SessionStatus: decision.Status, + EntityStatus: entityStatus, + Evidence: evidence, + DeclineDisposition: decline, + }) + + kyc, found, err := storage.GetKycByUuid(session.KycUuid) + if err != nil { + return err + } + if !found { + return ErrorKycNotFound + } + var userInfo *model.UserInfo + country := "" + viesRegistered := false + if projection.KycStatus == model.StatusApproved { + account, found, accountErr := storage.GetAccountByEmail(kyc.Email) + if accountErr != nil { + return accountErr + } + if !found { + return ErrorAccountNotFound + } + userInfo, country, viesRegistered, err = MapDiditDecisionToUserInfo( + *decision, + policy, + account.Address, + kyc.Email, + ) + if err != nil { + return err + } + } + + var decisionAt *time.Time + if diditDecisionIsTerminal(decision.Status) { + terminalAt := reconciliationStartedAt + decisionAt = &terminalAt + } + notificationType := diditNotificationType(projection.KycStatus) + _, err = storage.ApplyVerificationProjection(storage.VerificationProjectionUpdate{ + EventUuid: event.Uuid, + SessionUuid: session.Uuid, + KycStatus: projection.KycStatus, + ProviderStatus: string(decision.Status), + StatusReason: string(projection.Reason), + DecisionAt: decisionAt, + ReconciledAt: reconciliationStartedAt, + Country: country, + ViesRegistered: viesRegistered, + UserInfo: userInfo, + NotificationType: notificationType, + NotificationTransitionKey: session.Uuid.String() + ":" + projection.KycStatus, + }) + return err +} + +func (s *VerificationService) verificationSessionForEvent( + event *model.VerificationWebhookEvent, +) (*model.VerificationSession, error) { + if event.ProviderSessionId != "" { + session, found, err := storage.GetVerificationSession( + model.VerificationProviderDidit, + event.Environment, + event.ProviderSessionId, + ) + if err != nil { + return nil, err + } + if !found { + return nil, errors.New("Didit webhook references an unknown session") + } + return session, nil + } + kycUuid, err := uuid.Parse(event.VendorData) + if err != nil { + return nil, errors.New("Didit entity webhook vendor data is invalid") + } + session, found, err := storage.GetLatestVerificationSessionForKyc( + kycUuid, + model.VerificationProviderDidit, + event.Environment, + ) + if err != nil { + return nil, err + } + if !found { + return nil, errors.New("Didit entity webhook has no local verification session") + } + return session, nil +} + +func (w *VerificationWorker) processNotifications( + now time.Time, + batchSize int, + maxAttempts uint, +) { + notifications, err := storage.ClaimVerificationNotifications( + now, + verificationClaimLease, + batchSize, + maxAttempts, + ) + if err != nil { + log.Error("could not claim verification notifications: " + err.Error()) + return + } + for index := range notifications { + notification := notifications[index] + var sendErr error + switch notification.NotificationType { + case model.VerificationNotificationApproved: + sendErr = SendKycConfirmedEmail(notification.Email) + case model.VerificationNotificationFinalRejected: + sendErr = SendKycFinalRejectedEmail(notification.Email) + case model.VerificationNotificationRetry: + sendErr = SendStepRejectedEmail(notification.Email) + default: + sendErr = fmt.Errorf("unsupported verification notification type %q", notification.NotificationType) + } + nextAttempt := time.Now().UTC().Add(verificationRetryDelay(notification.Attempts)) + if err := storage.CompleteVerificationNotification( + notification.Uuid, + sendErr, + nextAttempt, + maxAttempts, + ); err != nil { + log.Error("could not complete verification notification: " + err.Error()) + } + } +} + +func projectableDiditEntityStatus(value string) (DiditEntityStatus, error) { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "ACTIVE", "APPROVED": + return DiditEntityActive, nil + case "FLAGGED", "IN REVIEW", "PENDING": + return DiditEntityFlagged, nil + case "BLOCKED", "DECLINED": + return DiditEntityBlocked, nil + default: + return "", errors.New("unknown Didit entity status") + } +} + +func diditDecisionIsTerminal(status model.DiditSessionStatus) bool { + switch status { + case model.DiditStatusApproved, + model.DiditStatusDeclined, + model.DiditStatusExpired, + model.DiditStatusKycExpired, + model.DiditStatusAbandoned: + return true + default: + return false + } +} + +func diditNotificationType(status string) string { + switch status { + case model.StatusApproved: + return model.VerificationNotificationApproved + case model.StatusFinalRejected: + return model.VerificationNotificationFinalRejected + case model.StatusRejected: + return model.VerificationNotificationRetry + default: + return "" + } +} + +func verificationRetryDelay(attempt uint) time.Duration { + if attempt == 0 { + attempt = 1 + } + if attempt > 8 { + attempt = 8 + } + return time.Duration(1< 1 + ) duplicate_emails + `).Scan(&duplicateGroups).Error + if err != nil { + return fmt.Errorf("preflight KYC email uniqueness: %w", err) + } + if duplicateGroups > 0 { + return fmt.Errorf( + "cannot add the KYC email unique index: found %d duplicate email groups; run a reviewed data migration first", + duplicateGroups, + ) + } + + return nil +} + func GetDB() (*gorm.DB, error) { if database == nil { return nil, NoDBError diff --git a/storage/healthStorer.go b/storage/healthStorer.go new file mode 100644 index 0000000..d5f0a1d --- /dev/null +++ b/storage/healthStorer.go @@ -0,0 +1,15 @@ +package storage + +import "context" + +func Ping(ctx context.Context) error { + db, err := GetDB() + if err != nil { + return err + } + sqlDb, err := db.DB() + if err != nil { + return err + } + return sqlDb.PingContext(ctx) +} diff --git a/storage/init_test.go b/storage/init_test.go index 7dd344d..2c78689 100644 --- a/storage/init_test.go +++ b/storage/init_test.go @@ -1,10 +1,163 @@ package storage -import "github.com/NaeuralEdgeProtocol/ratio1-backend/config" +import ( + "database/sql" + "fmt" + "net" + "net/url" + "os" + "strconv" + "strings" + "testing" -var dbConfig = config.DatabaseConfig{} + "github.com/google/uuid" + _ "github.com/lib/pq" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) -func init() { - config.Config.Database = dbConfig - Connect() +var storageTestDatabaseAvailable bool + +func TestMain(m *testing.M) { + if os.Getenv("RATIO1_STORAGE_TEST_DATABASE") != "1" { + os.Exit(m.Run()) + } + + host := envOrDefault("RATIO1_TEST_DATABASE_HOST", "127.0.0.1") + if !isLoopbackTestHost(host) && os.Getenv("RATIO1_STORAGE_TEST_ALLOW_REMOTE") != "1" { + fmt.Fprintln(os.Stderr, "storage tests refuse a non-local database host") + os.Exit(1) + } + if !isLoopbackTestHost(host) && !isSecureRemoteTestSslMode(envOrDefault("RATIO1_TEST_DATABASE_SSLMODE", "require")) { + fmt.Fprintln(os.Stderr, "remote storage tests require sslmode=require, verify-ca, or verify-full") + os.Exit(1) + } + + port := envOrDefault("RATIO1_TEST_DATABASE_PORT", "5432") + if _, err := strconv.Atoi(port); err != nil { + fmt.Fprintln(os.Stderr, "invalid storage test database port:", err) + os.Exit(1) + } + + user := envOrDefault("RATIO1_TEST_DATABASE_USER", "postgres") + password := envOrDefault("RATIO1_TEST_DATABASE_PASSWORD", "postgres") + dbName := envOrDefault("RATIO1_TEST_DATABASE_NAME", "ratio1_test") + if !strings.Contains(strings.ToLower(dbName), "test") { + fmt.Fprintln(os.Stderr, "storage test database name must contain 'test'") + os.Exit(1) + } + + adminDsn := postgresTestDsn(host, port, user, password, dbName, "") + adminDb, err := sql.Open("postgres", adminDsn) + if err != nil { + fmt.Fprintln(os.Stderr, "open storage test database:", err) + os.Exit(1) + } + + schemaName := "ratio1_storage_test_" + strings.ReplaceAll(uuid.NewString(), "-", "") + if _, err = adminDb.Exec("CREATE SCHEMA " + schemaName); err != nil { + _ = adminDb.Close() + fmt.Fprintln(os.Stderr, "create storage test schema:", err) + os.Exit(1) + } + + testDsn := postgresTestDsn(host, port, user, password, dbName, schemaName) + sqlDb, err := sql.Open("postgres", testDsn) + if err != nil { + _, _ = adminDb.Exec("DROP SCHEMA " + schemaName + " CASCADE") + _ = adminDb.Close() + fmt.Fprintln(os.Stderr, "open isolated storage test database:", err) + os.Exit(1) + } + sqlDb.SetMaxOpenConns(10) + sqlDb.SetMaxIdleConns(10) + + database, err = gorm.Open(postgres.New(postgres.Config{Conn: sqlDb})) + if err == nil { + err = TryMigrate() + } + if err != nil { + _ = sqlDb.Close() + _, _ = adminDb.Exec("DROP SCHEMA " + schemaName + " CASCADE") + _ = adminDb.Close() + fmt.Fprintln(os.Stderr, "migrate isolated storage test database:", err) + os.Exit(1) + } + + storageTestDatabaseAvailable = true + code := m.Run() + storageTestDatabaseAvailable = false + + _ = sqlDb.Close() + if _, err = adminDb.Exec("DROP SCHEMA " + schemaName + " CASCADE"); err != nil { + fmt.Fprintln(os.Stderr, "drop storage test schema:", err) + code = 1 + } + _ = adminDb.Close() + os.Exit(code) +} + +func requireStorageTestDatabase(t *testing.T) { + t.Helper() + if !storageTestDatabaseAvailable { + t.Skip("set RATIO1_STORAGE_TEST_DATABASE=1 to run PostgreSQL storage tests") + } +} + +func envOrDefault(key, fallback string) string { + value := os.Getenv(key) + if value == "" { + return fallback + } + return value +} + +func postgresTestDsn(host, port, user, password, dbName, schemaName string) string { + sslMode := "disable" + if !isLoopbackTestHost(host) { + sslMode = envOrDefault("RATIO1_TEST_DATABASE_SSLMODE", "require") + } + query := url.Values{"sslmode": []string{sslMode}} + if schemaName != "" { + query.Set("search_path", schemaName) + } + + return (&url.URL{ + Scheme: "postgres", + User: url.UserPassword(user, password), + Host: net.JoinHostPort(host, port), + Path: dbName, + RawQuery: query.Encode(), + }).String() +} + +func isLoopbackTestHost(host string) bool { + return host == "127.0.0.1" || host == "localhost" || host == "::1" +} + +func isSecureRemoteTestSslMode(sslMode string) bool { + switch sslMode { + case "require", "verify-ca", "verify-full": + return true + default: + return false + } +} + +func TestSecureRemoteTestSslMode(t *testing.T) { + for _, sslMode := range []string{"require", "verify-ca", "verify-full"} { + t.Run(sslMode, func(t *testing.T) { + if !isSecureRemoteTestSslMode(sslMode) { + t.Fatalf("expected %q to be accepted", sslMode) + } + }) + } + + for _, sslMode := range []string{"disable", "allow", "prefer", ""} { + t.Run("reject_"+sslMode, func(t *testing.T) { + if isSecureRemoteTestSslMode(sslMode) { + t.Fatalf("expected %q to be rejected", sslMode) + } + }) + } } diff --git a/storage/kycStorer.go b/storage/kycStorer.go index 1cc25bb..3bc28d3 100644 --- a/storage/kycStorer.go +++ b/storage/kycStorer.go @@ -1,12 +1,17 @@ package storage import ( - "gorm.io/gorm" + "errors" + "strings" "github.com/NaeuralEdgeProtocol/ratio1-backend/model" "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" ) +var ErrKycIdentityConflict = errors.New("kyc email belongs to a different uuid") + func GetKycByEmail(email string) (*model.Kyc, bool, error) { db, err := GetDB() if err != nil { @@ -67,23 +72,58 @@ func CreateOrUpdateKyc(kyc *model.Kyc) error { return err } - var existingKyc model.Kyc - err = db.Where("email = ?", kyc.Email).First(&existingKyc).Error - if err == gorm.ErrRecordNotFound { - err = db.Create(kyc).Error - if err != nil { - return err - } - } else if err == nil { - err = db.Model(&existingKyc).Where("email = ?", existingKyc.Email).Updates(kyc).Error - if err != nil { - return err - } + return createOrUpdateKyc(db, kyc) +} + +func createOrUpdateKyc(db *gorm.DB, kyc *model.Kyc) error { + if kyc == nil { + return errors.New("kyc is nil") + } + if kyc.Uuid == uuid.Nil { + return errors.New("kyc uuid is required") + } + if strings.TrimSpace(kyc.Email) == "" || strings.TrimSpace(kyc.Email) != kyc.Email { + return errors.New("kyc email must be non-empty and must not contain surrounding whitespace") + } + if kyc.KycStatus == "" { + return errors.New("kyc status is required") + } + if kyc.ReceiveUpdates == nil { + return errors.New("kyc receive-updates preference is required") + } + + txUpdate := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "email"}}, + DoUpdates: clause.Assignments(kycUpdateAssignments(kyc)), + Where: clause.Where{Exprs: []clause.Expression{ + clause.Expr{SQL: "kycs.uuid = EXCLUDED.uuid"}, + }}, + }).Create(kyc) + if txUpdate.Error != nil { + return txUpdate.Error + } + if txUpdate.RowsAffected == 0 { + return ErrKycIdentityConflict } return nil } +func kycUpdateAssignments(kyc *model.Kyc) map[string]any { + return map[string]any{ + "applicant_id": kyc.ApplicantId, + "applicant_type": kyc.ApplicantType, + "verification_provider": kyc.VerificationProvider, + "kyc_status": kyc.KycStatus, + "last_updated": kyc.LastUpdated, + "is_active": kyc.IsActive, + "has_been_deleted": kyc.HasBeenDeleted, + "receive_updates": kyc.ReceiveUpdates, + "country": kyc.Country, + "vies_registered": kyc.ViesRegistered, + } +} + func GetAllUsersEmails() ([]string, error) { db, err := GetDB() if err != nil { diff --git a/storage/kycStorer_test.go b/storage/kycStorer_test.go new file mode 100644 index 0000000..76ccf17 --- /dev/null +++ b/storage/kycStorer_test.go @@ -0,0 +1,278 @@ +package storage + +import ( + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestCreateOrUpdateKycPersistsZeroValuesAndPreservesUuid(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + + email := fmt.Sprintf("kyc-zero-values-%s@example.com", uuid.NewString()) + t.Cleanup(func() { + require.NoError(t, db.Where("email = ?", email).Delete(&model.Kyc{}).Error) + }) + + receiveUpdates := true + original := &model.Kyc{ + Uuid: uuid.New(), + ApplicantId: "sumsub-applicant", + ApplicantType: model.BusinessCustomer, + Email: email, + KycStatus: model.StatusApproved, + LastUpdated: time.Now().UTC().Truncate(time.Microsecond), + IsActive: true, + HasBeenDeleted: true, + ReceiveUpdates: &receiveUpdates, + Country: "ROU", + ViesRegistered: true, + } + require.NoError(t, CreateOrUpdateKyc(original)) + + receiveUpdates = false + update := &model.Kyc{ + Uuid: original.Uuid, + Email: email, + KycStatus: model.StatusInit, + LastUpdated: time.Time{}, + IsActive: false, + HasBeenDeleted: false, + ReceiveUpdates: &receiveUpdates, + ViesRegistered: false, + } + require.NoError(t, CreateOrUpdateKyc(update)) + + stored, found, err := GetKycByEmail(email) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, original.Uuid, stored.Uuid) + require.Empty(t, stored.ApplicantId) + require.Empty(t, stored.ApplicantType) + require.Equal(t, model.StatusInit, stored.KycStatus) + require.True(t, stored.LastUpdated.IsZero()) + require.False(t, stored.IsActive) + require.False(t, stored.HasBeenDeleted) + require.NotNil(t, stored.ReceiveUpdates) + require.False(t, *stored.ReceiveUpdates) + require.Empty(t, stored.Country) + require.False(t, stored.ViesRegistered) +} + +func TestCreateOrUpdateKycRejectsIncompleteRecord(t *testing.T) { + requireStorageTestDatabase(t) + + receiveUpdates := false + tests := []struct { + name string + kyc *model.Kyc + }{ + { + name: "nil uuid", + kyc: &model.Kyc{ + Email: "nil-uuid@example.com", + KycStatus: model.StatusInit, + ReceiveUpdates: &receiveUpdates, + }, + }, + { + name: "blank email", + kyc: &model.Kyc{ + Uuid: uuid.New(), + Email: " ", + KycStatus: model.StatusInit, + ReceiveUpdates: &receiveUpdates, + }, + }, + { + name: "missing status", + kyc: &model.Kyc{ + Uuid: uuid.New(), + Email: "missing-status@example.com", + ReceiveUpdates: &receiveUpdates, + }, + }, + { + name: "missing preference", + kyc: &model.Kyc{ + Uuid: uuid.New(), + Email: "missing-preference@example.com", + KycStatus: model.StatusInit, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Error(t, CreateOrUpdateKyc(test.kyc)) + }) + } +} + +func TestCreateOrUpdateKycRejectsEmailReassignmentToDifferentUuid(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + + email := fmt.Sprintf("kyc-identity-conflict-%s@example.com", uuid.NewString()) + t.Cleanup(func() { + require.NoError(t, db.Where("email = ?", email).Delete(&model.Kyc{}).Error) + }) + + receiveUpdates := false + approved := &model.Kyc{ + Uuid: uuid.New(), + Email: email, + KycStatus: model.StatusApproved, + IsActive: true, + ReceiveUpdates: &receiveUpdates, + } + require.NoError(t, CreateOrUpdateKyc(approved)) + + reassignment := &model.Kyc{ + Uuid: uuid.New(), + Email: email, + KycStatus: model.StatusInit, + ReceiveUpdates: &receiveUpdates, + } + require.ErrorIs(t, CreateOrUpdateKyc(reassignment), ErrKycIdentityConflict) + + stored, found, err := GetKycByEmail(email) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, approved.Uuid, stored.Uuid) + require.Equal(t, model.StatusApproved, stored.KycStatus) + require.True(t, stored.IsActive) +} + +func TestTryMigrateAddsKycEmailUniqueIndex(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + + require.NoError(t, db.Migrator().DropIndex(&model.Kyc{}, "idx_kycs_email")) + require.False(t, db.Migrator().HasIndex(&model.Kyc{}, "idx_kycs_email")) + + require.NoError(t, db.AutoMigrate(&model.Kyc{})) + require.True(t, db.Migrator().HasIndex(&model.Kyc{}, "idx_kycs_email")) +} + +func TestTryMigrateRejectsLegacyDuplicateKycEmailsBeforeAddingIndex(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + + email := fmt.Sprintf("kyc-duplicate-preflight-%s@example.com", uuid.NewString()) + nullEmailUuidOne := uuid.New() + nullEmailUuidTwo := uuid.New() + require.NoError(t, db.Migrator().DropIndex(&model.Kyc{}, "idx_kycs_email")) + t.Cleanup(func() { + require.NoError(t, db.Where("email = ?", email).Delete(&model.Kyc{}).Error) + require.NoError(t, db.Where("uuid IN ?", []uuid.UUID{nullEmailUuidOne, nullEmailUuidTwo}).Delete(&model.Kyc{}).Error) + require.NoError(t, db.AutoMigrate(&model.Kyc{})) + }) + + receiveUpdates := false + require.NoError(t, db.Create([]model.Kyc{ + { + Uuid: uuid.New(), + Email: email, + KycStatus: model.StatusApproved, + ReceiveUpdates: &receiveUpdates, + }, + { + Uuid: uuid.New(), + Email: email, + KycStatus: model.StatusFinalRejected, + ReceiveUpdates: &receiveUpdates, + }, + }).Error) + + err = TryMigrate() + require.ErrorContains(t, err, "found 1 duplicate email groups") + require.False(t, db.Migrator().HasIndex(&model.Kyc{}, "idx_kycs_email")) + + require.NoError(t, db.Where("email = ?", email).Delete(&model.Kyc{}).Error) + require.NoError(t, db.Exec( + "INSERT INTO kycs (uuid, email) VALUES (?, NULL), (?, NULL)", + nullEmailUuidOne, + nullEmailUuidTwo, + ).Error) + require.NoError(t, validateExistingKycEmailsAreUnique(db)) + require.NoError(t, db.AutoMigrate(&model.Kyc{})) + require.True(t, db.Migrator().HasIndex(&model.Kyc{}, "idx_kycs_email")) +} + +func TestCreateOrUpdateKycConcurrentCreateKeepsOneRow(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + + email := fmt.Sprintf("kyc-concurrent-%s@example.com", uuid.NewString()) + t.Cleanup(func() { + require.NoError(t, db.Where("email = ?", email).Delete(&model.Kyc{}).Error) + }) + + const workers = 16 + var winners atomic.Int32 + var identityConflicts atomic.Int32 + var unexpectedFailures atomic.Int32 + ready := make(chan struct{}, workers) + start := make(chan struct{}) + var waitGroup sync.WaitGroup + waitGroup.Add(workers) + + for i := 0; i < workers; i++ { + go func() { + defer waitGroup.Done() + ready <- struct{}{} + <-start + receiveUpdates := false + err := CreateOrUpdateKyc(&model.Kyc{ + Uuid: uuid.New(), + Email: email, + KycStatus: model.StatusInit, + ReceiveUpdates: &receiveUpdates, + }) + switch { + case err == nil: + winners.Add(1) + case errors.Is(err, ErrKycIdentityConflict): + identityConflicts.Add(1) + default: + unexpectedFailures.Add(1) + } + }() + } + for i := 0; i < workers; i++ { + <-ready + } + close(start) + waitGroup.Wait() + require.Equal(t, int32(1), winners.Load()) + require.Equal(t, int32(workers-1), identityConflicts.Load()) + require.Zero(t, unexpectedFailures.Load()) + + var count int64 + require.NoError(t, db.Model(&model.Kyc{}).Where("email = ?", email).Count(&count).Error) + require.Equal(t, int64(1), count) + + stored, found, err := GetKycByEmail(email) + require.NoError(t, err) + require.True(t, found) + require.NotEqual(t, uuid.Nil, stored.Uuid) +} diff --git a/storage/verificationStorer.go b/storage/verificationStorer.go new file mode 100644 index 0000000..51c8030 --- /dev/null +++ b/storage/verificationStorer.go @@ -0,0 +1,268 @@ +package storage + +import ( + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ErrVerificationEventPayloadMismatch = errors.New("verification webhook event id was reused with a different payload") + +func CreateVerificationSession(session *model.VerificationSession) error { + db, err := GetDB() + if err != nil { + return err + } + if session == nil { + return errors.New("verification session is nil") + } + if session.KycUuid == uuid.Nil || + session.ProviderSessionId == "" || + session.KycStatus == "" || + session.ProviderStatus == "" { + return errors.New("verification session identity and status fields are required") + } + if err := validateVerificationProvider(session.Provider); err != nil { + return err + } + if err := validateVerificationEnvironment(session.Environment); err != nil { + return err + } + if err := validateApplicantType(session.ApplicantType); err != nil { + return err + } + if session.Provider == model.VerificationProviderDidit && session.WorkflowId == "" { + return errors.New("Didit verification sessions require a workflow id") + } + if session.Uuid == uuid.Nil { + session.Uuid = uuid.New() + } + + return db.Create(session).Error +} + +func UpdateVerificationSession(session *model.VerificationSession) error { + db, err := GetDB() + if err != nil { + return err + } + if session == nil { + return errors.New("verification session is nil") + } + if session.Uuid == uuid.Nil || + session.KycStatus == "" || + session.ProviderStatus == "" { + return errors.New("verification session uuid and status fields are required") + } + session.UpdatedAt = time.Now().UTC() + + txUpdate := db.Model(&model.VerificationSession{}). + Where("uuid = ?", session.Uuid). + Updates(map[string]any{ + "provider_application_id": session.ProviderApplicationId, + "kyc_status": session.KycStatus, + "provider_status": session.ProviderStatus, + "status_reason": session.StatusReason, + "decision_at": session.DecisionAt, + "last_reconciled_at": session.LastReconciledAt, + "updated_at": session.UpdatedAt, + }) + if txUpdate.Error != nil { + return txUpdate.Error + } + if txUpdate.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + + return nil +} + +func GetVerificationSession(provider, environment, providerSessionId string) (*model.VerificationSession, bool, error) { + db, err := GetDB() + if err != nil { + return nil, false, err + } + + var session model.VerificationSession + txRead := db.Where( + "provider = ? AND environment = ? AND provider_session_id = ?", + provider, + environment, + providerSessionId, + ).First(&session) + if errors.Is(txRead.Error, gorm.ErrRecordNotFound) { + return nil, false, nil + } + if txRead.Error != nil { + return nil, false, txRead.Error + } + + return &session, true, nil +} + +func GetLatestVerificationSession( + kycUuid uuid.UUID, + applicantType, provider, environment string, +) (*model.VerificationSession, bool, error) { + db, err := GetDB() + if err != nil { + return nil, false, err + } + + var session model.VerificationSession + txRead := db. + Where( + "kyc_uuid = ? AND applicant_type = ? AND provider = ? AND environment = ?", + kycUuid, + applicantType, + provider, + environment, + ). + Order("created_at DESC, uuid DESC"). + First(&session) + if errors.Is(txRead.Error, gorm.ErrRecordNotFound) { + return nil, false, nil + } + if txRead.Error != nil { + return nil, false, txRead.Error + } + + return &session, true, nil +} + +func CreateVerificationWebhookEvent(event *model.VerificationWebhookEvent) (bool, error) { + db, err := GetDB() + if err != nil { + return false, err + } + if event == nil { + return false, errors.New("verification webhook event is nil") + } + if event.EventId == "" || + event.EventType == "" { + return false, errors.New("verification webhook provider, environment, event id, and event type are required") + } + if err := validateVerificationProvider(event.Provider); err != nil { + return false, err + } + if err := validateVerificationEnvironment(event.Environment); err != nil { + return false, err + } + payloadDigest, err := hex.DecodeString(event.PayloadSha256) + if err != nil || len(payloadDigest) != 32 { + return false, errors.New("verification webhook payload sha256 must be a 64-character hexadecimal digest") + } + event.PayloadSha256 = hex.EncodeToString(payloadDigest) + if event.Uuid == uuid.Nil { + event.Uuid = uuid.New() + } + if event.ReceivedAt.IsZero() { + event.ReceivedAt = time.Now().UTC() + } + if event.ProcessingStatus == "" { + event.ProcessingStatus = model.VerificationEventReceived + } + if err := validateVerificationEventStatus(event.ProcessingStatus); err != nil { + return false, err + } + + txCreate := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "provider"}, + {Name: "environment"}, + {Name: "event_id"}, + }, + DoNothing: true, + }).Create(event) + if txCreate.Error != nil { + return false, txCreate.Error + } + if txCreate.RowsAffected == 0 { + var stored model.VerificationWebhookEvent + txRead := db.Where( + "provider = ? AND environment = ? AND event_id = ?", + event.Provider, + event.Environment, + event.EventId, + ).First(&stored) + if txRead.Error != nil { + return false, txRead.Error + } + if stored.PayloadSha256 != event.PayloadSha256 { + return false, ErrVerificationEventPayloadMismatch + } + } + + return txCreate.RowsAffected == 1, nil +} + +func GetVerificationWebhookEvent( + provider, environment, eventId string, +) (*model.VerificationWebhookEvent, bool, error) { + db, err := GetDB() + if err != nil { + return nil, false, err + } + + var event model.VerificationWebhookEvent + txRead := db.Where( + "provider = ? AND environment = ? AND event_id = ?", + provider, + environment, + eventId, + ).First(&event) + if errors.Is(txRead.Error, gorm.ErrRecordNotFound) { + return nil, false, nil + } + if txRead.Error != nil { + return nil, false, txRead.Error + } + + return &event, true, nil +} + +func validateVerificationProvider(provider string) error { + switch provider { + case model.VerificationProviderSumsub, model.VerificationProviderDidit: + return nil + default: + return fmt.Errorf("unsupported verification provider %q", provider) + } +} + +func validateVerificationEnvironment(environment string) error { + switch environment { + case model.VerificationEnvironmentSandbox, model.VerificationEnvironmentProduction: + return nil + default: + return fmt.Errorf("unsupported verification environment %q", environment) + } +} + +func validateApplicantType(applicantType string) error { + switch applicantType { + case model.IndividualCustomer, model.BusinessCustomer: + return nil + default: + return fmt.Errorf("unsupported verification applicant type %q", applicantType) + } +} + +func validateVerificationEventStatus(status string) error { + switch status { + case model.VerificationEventReceived, + model.VerificationEventProcessing, + model.VerificationEventProcessed, + model.VerificationEventFailed, + model.VerificationEventDeadLetter: + return nil + default: + return fmt.Errorf("unsupported verification event status %q", status) + } +} diff --git a/storage/verificationStorer_test.go b/storage/verificationStorer_test.go new file mode 100644 index 0000000..9f9b5cb --- /dev/null +++ b/storage/verificationStorer_test.go @@ -0,0 +1,359 @@ +package storage + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestVerificationSessionProviderScopedUniquenessAndLatestLookup(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + + kycUuid := uuid.New() + providerSessionId := "session-" + uuid.NewString() + createVerificationTestKyc(t, db, kycUuid) + t.Cleanup(func() { + require.NoError(t, db.Where("kyc_uuid = ?", kycUuid).Delete(&model.VerificationSession{}).Error) + require.NoError(t, db.Where("uuid = ?", kycUuid).Delete(&model.Kyc{}).Error) + }) + + first := verificationSessionFixture(kycUuid, providerSessionId, model.VerificationEnvironmentSandbox) + require.NoError(t, CreateVerificationSession(first)) + require.ErrorContains( + t, + db.Where("uuid = ?", kycUuid).Delete(&model.Kyc{}).Error, + "violates foreign key constraint", + ) + + duplicate := verificationSessionFixture(kycUuid, providerSessionId, model.VerificationEnvironmentSandbox) + require.Error(t, CreateVerificationSession(duplicate)) + + production := verificationSessionFixture(kycUuid, providerSessionId, model.VerificationEnvironmentProduction) + production.CreatedAt = first.CreatedAt.Add(time.Minute) + require.NoError(t, CreateVerificationSession(production)) + + latestSandbox := verificationSessionFixture( + kycUuid, + "session-"+uuid.NewString(), + model.VerificationEnvironmentSandbox, + ) + latestSandbox.CreatedAt = first.CreatedAt.Add(2 * time.Minute) + require.NoError(t, CreateVerificationSession(latestSandbox)) + + stored, found, err := GetVerificationSession( + model.VerificationProviderDidit, + model.VerificationEnvironmentSandbox, + providerSessionId, + ) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, first.Uuid, stored.Uuid) + + latest, found, err := GetLatestVerificationSession( + kycUuid, + model.IndividualCustomer, + model.VerificationProviderDidit, + model.VerificationEnvironmentSandbox, + ) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, latestSandbox.Uuid, latest.Uuid) +} + +func TestUpdateVerificationSessionPersistsEmptyAndNilValues(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + + kycUuid := uuid.New() + createVerificationTestKyc(t, db, kycUuid) + session := verificationSessionFixture( + kycUuid, + "session-"+uuid.NewString(), + model.VerificationEnvironmentSandbox, + ) + decisionAt := time.Now().UTC().Truncate(time.Microsecond) + session.ProviderApplicationId = "application" + session.WorkflowId = "workflow" + session.WorkflowVersion = "1" + session.ProviderStatus = "Approved" + session.DecisionAt = &decisionAt + session.LastReconciledAt = &decisionAt + require.NoError(t, CreateVerificationSession(session)) + t.Cleanup(func() { + require.NoError(t, db.Where("uuid = ?", session.Uuid).Delete(&model.VerificationSession{}).Error) + require.NoError(t, db.Where("uuid = ?", kycUuid).Delete(&model.Kyc{}).Error) + }) + + session.ProviderApplicationId = "" + session.WorkflowId = "" + session.WorkflowVersion = "" + session.ApplicantType = model.BusinessCustomer + session.DecisionAt = nil + session.LastReconciledAt = nil + session.UpdatedAt = decisionAt.Add(time.Minute) + require.NoError(t, UpdateVerificationSession(session)) + + stored, found, err := GetVerificationSession(session.Provider, session.Environment, session.ProviderSessionId) + require.NoError(t, err) + require.True(t, found) + require.Empty(t, stored.ProviderApplicationId) + require.Equal(t, "workflow", stored.WorkflowId) + require.Equal(t, "1", stored.WorkflowVersion) + require.Equal(t, model.IndividualCustomer, stored.ApplicantType) + require.Equal(t, model.StatusInit, stored.KycStatus) + require.Equal(t, "Approved", stored.ProviderStatus) + require.Nil(t, stored.DecisionAt) + require.Nil(t, stored.LastReconciledAt) +} + +func TestCreateVerificationSessionRejectsMissingIdentity(t *testing.T) { + requireStorageTestDatabase(t) + + session := verificationSessionFixture( + uuid.Nil, + "", + model.VerificationEnvironmentSandbox, + ) + require.Error(t, CreateVerificationSession(session)) +} + +func TestCreateVerificationSessionRejectsDiditWithoutWorkflowId(t *testing.T) { + requireStorageTestDatabase(t) + + session := verificationSessionFixture( + uuid.New(), + "session-"+uuid.NewString(), + model.VerificationEnvironmentSandbox, + ) + session.WorkflowId = "" + require.ErrorContains(t, CreateVerificationSession(session), "require a workflow id") +} + +func TestCreateVerificationSessionRejectsUnknownKycUuid(t *testing.T) { + requireStorageTestDatabase(t) + + session := verificationSessionFixture( + uuid.New(), + "session-"+uuid.NewString(), + model.VerificationEnvironmentSandbox, + ) + require.ErrorContains(t, CreateVerificationSession(session), "violates foreign key constraint") +} + +func TestCreateVerificationWebhookEventIsIdempotent(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + + eventId := "event-" + uuid.NewString() + t.Cleanup(func() { + require.NoError(t, db.Where("event_id = ?", eventId).Delete(&model.VerificationWebhookEvent{}).Error) + }) + + first := verificationWebhookEventFixture(eventId, model.VerificationEnvironmentSandbox, "first") + created, err := CreateVerificationWebhookEvent(first) + require.NoError(t, err) + require.True(t, created) + + duplicate := verificationWebhookEventFixture(eventId, model.VerificationEnvironmentSandbox, "first") + duplicate.PayloadSha256 = strings.ToUpper(duplicate.PayloadSha256) + created, err = CreateVerificationWebhookEvent(duplicate) + require.NoError(t, err) + require.False(t, created) + + production := verificationWebhookEventFixture(eventId, model.VerificationEnvironmentProduction, "production") + created, err = CreateVerificationWebhookEvent(production) + require.NoError(t, err) + require.True(t, created) + + stored, found, err := GetVerificationWebhookEvent( + model.VerificationProviderDidit, + model.VerificationEnvironmentSandbox, + eventId, + ) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, first.Uuid, stored.Uuid) + require.Equal(t, first.PayloadSha256, stored.PayloadSha256) +} + +func TestCreateVerificationWebhookEventRejectsEventIdWithDifferentPayload(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + + eventId := "event-payload-mismatch-" + uuid.NewString() + t.Cleanup(func() { + require.NoError(t, db.Where("event_id = ?", eventId).Delete(&model.VerificationWebhookEvent{}).Error) + }) + + first := verificationWebhookEventFixture(eventId, model.VerificationEnvironmentSandbox, "first") + created, err := CreateVerificationWebhookEvent(first) + require.NoError(t, err) + require.True(t, created) + + differentPayload := verificationWebhookEventFixture(eventId, model.VerificationEnvironmentSandbox, "different") + created, err = CreateVerificationWebhookEvent(differentPayload) + require.False(t, created) + require.ErrorIs(t, err, ErrVerificationEventPayloadMismatch) + + stored, found, err := GetVerificationWebhookEvent( + model.VerificationProviderDidit, + model.VerificationEnvironmentSandbox, + eventId, + ) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, first.PayloadSha256, stored.PayloadSha256) +} + +func TestCreateVerificationWebhookEventConcurrentDuplicateHasOneWinner(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + + eventId := "event-concurrent-" + uuid.NewString() + t.Cleanup(func() { + require.NoError(t, db.Where("event_id = ?", eventId).Delete(&model.VerificationWebhookEvent{}).Error) + }) + + const workers = 16 + var createdCount atomic.Int32 + var failures atomic.Int32 + ready := make(chan struct{}, workers) + start := make(chan struct{}) + var waitGroup sync.WaitGroup + waitGroup.Add(workers) + + for i := 0; i < workers; i++ { + go func() { + defer waitGroup.Done() + ready <- struct{}{} + <-start + event := verificationWebhookEventFixture( + eventId, + model.VerificationEnvironmentSandbox, + "same-payload", + ) + created, err := CreateVerificationWebhookEvent(event) + if err != nil { + failures.Add(1) + return + } + if created { + createdCount.Add(1) + } + }() + } + for i := 0; i < workers; i++ { + <-ready + } + close(start) + waitGroup.Wait() + + require.Zero(t, failures.Load()) + require.Equal(t, int32(1), createdCount.Load()) + + var count int64 + require.NoError(t, db.Model(&model.VerificationWebhookEvent{}). + Where("provider = ? AND environment = ? AND event_id = ?", + model.VerificationProviderDidit, + model.VerificationEnvironmentSandbox, + eventId, + ). + Count(&count).Error) + require.Equal(t, int64(1), count) +} + +func TestCreateVerificationWebhookEventRejectsMissingIdempotencyKey(t *testing.T) { + requireStorageTestDatabase(t) + + event := verificationWebhookEventFixture("", model.VerificationEnvironmentSandbox, "missing") + created, err := CreateVerificationWebhookEvent(event) + require.Error(t, err) + require.False(t, created) +} + +func TestVerificationPersistenceRejectsUnsupportedScopedValues(t *testing.T) { + requireStorageTestDatabase(t) + + session := verificationSessionFixture( + uuid.New(), + "session-"+uuid.NewString(), + model.VerificationEnvironmentSandbox, + ) + session.Provider = "unknown" + require.ErrorContains(t, CreateVerificationSession(session), "unsupported verification provider") + + event := verificationWebhookEventFixture( + "event-"+uuid.NewString(), + model.VerificationEnvironmentSandbox, + "unsupported-status", + ) + event.ProcessingStatus = "unknown" + created, err := CreateVerificationWebhookEvent(event) + require.False(t, created) + require.ErrorContains(t, err, "unsupported verification event status") +} + +func verificationSessionFixture( + kycUuid uuid.UUID, + providerSessionId, environment string, +) *model.VerificationSession { + now := time.Now().UTC().Truncate(time.Microsecond) + return &model.VerificationSession{ + KycUuid: kycUuid, + Provider: model.VerificationProviderDidit, + Environment: environment, + ProviderSessionId: providerSessionId, + WorkflowId: "workflow-" + uuid.NewString(), + ApplicantType: model.IndividualCustomer, + KycStatus: model.StatusInit, + ProviderStatus: "Not Started", + CreatedAt: now, + UpdatedAt: now, + } +} + +func verificationWebhookEventFixture( + eventId, environment, payloadMarker string, +) *model.VerificationWebhookEvent { + payloadDigest := sha256.Sum256([]byte(payloadMarker)) + return &model.VerificationWebhookEvent{ + Provider: model.VerificationProviderDidit, + Environment: environment, + EventId: eventId, + EventType: "status.updated", + PayloadSha256: hex.EncodeToString(payloadDigest[:]), + ProcessingStatus: model.VerificationEventReceived, + } +} + +func createVerificationTestKyc(t *testing.T, db *gorm.DB, kycUuid uuid.UUID) { + t.Helper() + receiveUpdates := false + require.NoError(t, db.Create(&model.Kyc{ + Uuid: kycUuid, + Email: fmt.Sprintf("verification-kyc-%s@example.com", kycUuid), + KycStatus: model.StatusInit, + ReceiveUpdates: &receiveUpdates, + }).Error) +} diff --git a/storage/verificationWorkflowStorer.go b/storage/verificationWorkflowStorer.go new file mode 100644 index 0000000..7bd792d --- /dev/null +++ b/storage/verificationWorkflowStorer.go @@ -0,0 +1,673 @@ +package storage + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type VerificationProjectionUpdate struct { + EventUuid uuid.UUID + SessionUuid uuid.UUID + KycStatus string + ProviderStatus string + StatusReason string + DecisionAt *time.Time + ReconciledAt time.Time + Country string + ViesRegistered bool + UserInfo *model.UserInfo + NotificationType string + NotificationTransitionKey string +} + +func WithVerificationCreationLock( + ctx context.Context, + kycUuid uuid.UUID, + provider, environment string, + action func() error, +) error { + db, err := GetDB() + if err != nil { + return err + } + sqlDb, err := db.DB() + if err != nil { + return err + } + connection, err := sqlDb.Conn(ctx) + if err != nil { + return err + } + defer connection.Close() + + lockKey := strings.Join([]string{ + "verification-session", + kycUuid.String(), + provider, + environment, + }, ":") + if _, err := connection.ExecContext(ctx, "SELECT pg_advisory_lock(hashtext($1))", lockKey); err != nil { + return err + } + defer func() { + _, _ = connection.ExecContext(context.Background(), "SELECT pg_advisory_unlock(hashtext($1))", lockKey) + }() + return action() +} + +func AssignVerificationSession(session *model.VerificationSession) (*model.VerificationSession, error) { + db, err := GetDB() + if err != nil { + return nil, err + } + if session == nil { + return nil, errors.New("verification session is nil") + } + if err := validateVerificationSession(session); err != nil { + return nil, err + } + + var stored model.VerificationSession + err = db.Transaction(func(tx *gorm.DB) error { + var kyc model.Kyc + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("uuid = ?", session.KycUuid). + First(&kyc).Error; err != nil { + return err + } + if kyc.VerificationProvider != "" && kyc.VerificationProvider != session.Provider { + return fmt.Errorf( + "kyc is owned by verification provider %q", + kyc.VerificationProvider, + ) + } + if kyc.ApplicantType != "" && kyc.ApplicantType != session.ApplicantType { + return errors.New("kyc applicant type cannot be changed after verification starts") + } + + if session.Uuid == uuid.Nil { + session.Uuid = uuid.New() + } + now := time.Now().UTC() + if session.CreatedAt.IsZero() { + session.CreatedAt = now + } + session.UpdatedAt = now + + create := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "provider"}, + {Name: "environment"}, + {Name: "provider_session_id"}, + }, + DoNothing: true, + }).Create(session) + if create.Error != nil { + return create.Error + } + if err := tx.Where( + "provider = ? AND environment = ? AND provider_session_id = ?", + session.Provider, + session.Environment, + session.ProviderSessionId, + ).First(&stored).Error; err != nil { + return err + } + if stored.KycUuid != session.KycUuid || + stored.ApplicantType != session.ApplicantType || + stored.WorkflowId != session.WorkflowId { + return errors.New("existing provider session belongs to a different verification") + } + + updates := map[string]interface{}{ + "verification_provider": session.Provider, + "applicant_type": session.ApplicantType, + } + newlyCreatedReplacement := create.RowsAffected == 1 && + session.Provider == model.VerificationProviderDidit && + kyc.KycStatus == model.StatusRejected && + session.KycStatus == model.StatusInit + if kyc.KycStatus == model.StatusAccountCreated || newlyCreatedReplacement { + updates["kyc_status"] = session.KycStatus + updates["last_updated"] = now + } + return tx.Model(&model.Kyc{}). + Where("uuid = ?", session.KycUuid). + Updates(updates).Error + }) + if err != nil { + return nil, err + } + return &stored, nil +} + +func ClaimVerificationWebhookEvents( + now time.Time, + leaseDuration time.Duration, + batchSize int, + maxAttempts uint, +) ([]model.VerificationWebhookEvent, error) { + db, err := GetDB() + if err != nil { + return nil, err + } + if batchSize <= 0 || maxAttempts == 0 { + return nil, errors.New("verification event claim limits must be positive") + } + leaseExpiredAt := now.Add(-leaseDuration) + claimed := make([]model.VerificationWebhookEvent, 0, batchSize) + + err = db.Transaction(func(tx *gorm.DB) error { + var events []model.VerificationWebhookEvent + query := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}). + Where( + `provider IN (?, ?) AND + attempts < ? AND + (next_attempt_at IS NULL OR next_attempt_at <= ?) AND + ( + processing_status IN (?, ?) OR + (processing_status = ? AND claimed_at < ?) + )`, + model.VerificationProviderDidit, + model.VerificationProviderSumsub, + maxAttempts, + now, + model.VerificationEventReceived, + model.VerificationEventFailed, + model.VerificationEventProcessing, + leaseExpiredAt, + ). + Order("received_at ASC, uuid ASC"). + Limit(batchSize). + Find(&events) + if query.Error != nil { + return query.Error + } + + for index := range events { + event := &events[index] + update := tx.Model(&model.VerificationWebhookEvent{}). + Where("uuid = ?", event.Uuid). + Updates(map[string]interface{}{ + "processing_status": model.VerificationEventProcessing, + "attempts": gorm.Expr("attempts + 1"), + "claimed_at": now, + "updated_at": now, + }) + if update.Error != nil { + return update.Error + } + event.ProcessingStatus = model.VerificationEventProcessing + event.Attempts++ + event.ClaimedAt = &now + } + claimed = events + return nil + }) + return claimed, err +} + +func MarkVerificationWebhookEventFailed( + eventUuid uuid.UUID, + failure error, + nextAttemptAt time.Time, + maxAttempts uint, +) error { + db, err := GetDB() + if err != nil { + return err + } + if failure == nil { + return errors.New("verification event failure is required") + } + + var event model.VerificationWebhookEvent + if err := db.Where("uuid = ?", eventUuid).First(&event).Error; err != nil { + return err + } + status := model.VerificationEventFailed + var retryAt *time.Time + if event.Attempts >= maxAttempts { + status = model.VerificationEventDeadLetter + } else { + retryAt = &nextAttemptAt + } + now := time.Now().UTC() + return db.Model(&model.VerificationWebhookEvent{}). + Where("uuid = ?", eventUuid). + Updates(map[string]interface{}{ + "processing_status": status, + "next_attempt_at": retryAt, + "claimed_at": nil, + "last_error": truncateVerificationError(failure.Error()), + "updated_at": now, + }).Error +} + +func MarkVerificationWebhookEventProcessed(eventUuid uuid.UUID) error { + db, err := GetDB() + if err != nil { + return err + } + now := time.Now().UTC() + return db.Model(&model.VerificationWebhookEvent{}). + Where("uuid = ?", eventUuid). + Updates(map[string]interface{}{ + "processing_status": model.VerificationEventProcessed, + "processed_at": now, + "next_attempt_at": nil, + "claimed_at": nil, + "last_error": "", + "updated_at": now, + }).Error +} + +func MarkVerificationWebhookEventProcessedByIdentity( + provider, environment, eventId string, +) error { + db, err := GetDB() + if err != nil { + return err + } + now := time.Now().UTC() + update := db.Model(&model.VerificationWebhookEvent{}). + Where( + "provider = ? AND environment = ? AND event_id = ?", + provider, + environment, + eventId, + ). + Updates(map[string]interface{}{ + "processing_status": model.VerificationEventProcessed, + "processed_at": now, + "next_attempt_at": nil, + "claimed_at": nil, + "last_error": "", + "updated_at": now, + }) + if update.Error != nil { + return update.Error + } + if update.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func RetryDeadLetterVerificationWebhookEvent(eventUuid uuid.UUID) error { + db, err := GetDB() + if err != nil { + return err + } + now := time.Now().UTC() + update := db.Model(&model.VerificationWebhookEvent{}). + Where("uuid = ? AND processing_status = ?", eventUuid, model.VerificationEventDeadLetter). + Updates(map[string]interface{}{ + "processing_status": model.VerificationEventFailed, + "attempts": 0, + "next_attempt_at": now, + "claimed_at": nil, + "last_error": "", + "updated_at": now, + }) + if update.Error != nil { + return update.Error + } + if update.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func ClaimVerificationNotifications( + now time.Time, + leaseDuration time.Duration, + batchSize int, + maxAttempts uint, +) ([]model.VerificationNotification, error) { + db, err := GetDB() + if err != nil { + return nil, err + } + leaseExpiredAt := now.Add(-leaseDuration) + var claimed []model.VerificationNotification + err = db.Transaction(func(tx *gorm.DB) error { + var notifications []model.VerificationNotification + query := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}). + Where( + `attempts < ? AND + (next_attempt_at IS NULL OR next_attempt_at <= ?) AND + ( + processing_status IN (?, ?) OR + (processing_status = ? AND claimed_at < ?) + )`, + maxAttempts, + now, + model.VerificationNotificationPending, + model.VerificationNotificationFailed, + model.VerificationNotificationProcessing, + leaseExpiredAt, + ). + Order("created_at ASC, uuid ASC"). + Limit(batchSize). + Find(¬ifications) + if query.Error != nil { + return query.Error + } + for index := range notifications { + notification := ¬ifications[index] + if err := tx.Model(&model.VerificationNotification{}). + Where("uuid = ?", notification.Uuid). + Updates(map[string]interface{}{ + "processing_status": model.VerificationNotificationProcessing, + "attempts": gorm.Expr("attempts + 1"), + "claimed_at": now, + "updated_at": now, + }).Error; err != nil { + return err + } + notification.ProcessingStatus = model.VerificationNotificationProcessing + notification.Attempts++ + notification.ClaimedAt = &now + } + claimed = notifications + return nil + }) + return claimed, err +} + +func CompleteVerificationNotification( + notificationUuid uuid.UUID, + sendError error, + nextAttemptAt time.Time, + maxAttempts uint, +) error { + db, err := GetDB() + if err != nil { + return err + } + var notification model.VerificationNotification + if err := db.Where("uuid = ?", notificationUuid).First(¬ification).Error; err != nil { + return err + } + now := time.Now().UTC() + updates := map[string]interface{}{ + "claimed_at": nil, + "updated_at": now, + } + if sendError == nil { + updates["processing_status"] = model.VerificationNotificationSent + updates["sent_at"] = now + updates["next_attempt_at"] = nil + updates["last_error"] = "" + } else { + updates["processing_status"] = model.VerificationNotificationFailed + updates["next_attempt_at"] = nextAttemptAt + updates["last_error"] = truncateVerificationError(sendError.Error()) + if notification.Attempts >= maxAttempts { + updates["next_attempt_at"] = nil + } + } + return db.Model(&model.VerificationNotification{}). + Where("uuid = ?", notificationUuid). + Updates(updates).Error +} + +func ApplyVerificationProjection(update VerificationProjectionUpdate) (bool, error) { + db, err := GetDB() + if err != nil { + return false, err + } + if update.EventUuid == uuid.Nil || + update.SessionUuid == uuid.Nil || + update.ReconciledAt.IsZero() || + update.KycStatus == "" || + update.ProviderStatus == "" { + return false, errors.New("verification projection identity and status are required") + } + + appliedToKyc := false + err = db.Transaction(func(tx *gorm.DB) error { + var event model.VerificationWebhookEvent + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("uuid = ?", update.EventUuid). + First(&event).Error; err != nil { + return err + } + if event.ProcessingStatus == model.VerificationEventProcessed { + return nil + } + + var session model.VerificationSession + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("uuid = ?", update.SessionUuid). + First(&session).Error; err != nil { + return err + } + if session.LastReconciledAt != nil && + session.LastReconciledAt.After(update.ReconciledAt) { + return markVerificationEventProcessed(tx, event.Uuid, update.ReconciledAt) + } + + sessionUpdates := map[string]interface{}{ + "kyc_status": update.KycStatus, + "provider_status": update.ProviderStatus, + "status_reason": update.StatusReason, + "decision_at": update.DecisionAt, + "last_reconciled_at": update.ReconciledAt, + "updated_at": update.ReconciledAt, + } + if err := tx.Model(&model.VerificationSession{}). + Where("uuid = ?", session.Uuid). + Updates(sessionUpdates).Error; err != nil { + return err + } + + var latest model.VerificationSession + latestRead := tx.Where( + "kyc_uuid = ? AND provider = ? AND environment = ? AND applicant_type = ?", + session.KycUuid, + session.Provider, + session.Environment, + session.ApplicantType, + ).Order("created_at DESC, uuid DESC").First(&latest) + if latestRead.Error != nil { + return latestRead.Error + } + + var kyc model.Kyc + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("uuid = ?", session.KycUuid). + First(&kyc).Error; err != nil { + return err + } + if latest.Uuid == session.Uuid && kyc.VerificationProvider == session.Provider { + if update.KycStatus == model.StatusApproved && update.UserInfo == nil { + return errors.New("approved verification requires complete user info") + } + kycUpdates := map[string]interface{}{ + "kyc_status": update.KycStatus, + "last_updated": update.ReconciledAt, + } + if update.Country != "" { + kycUpdates["country"] = update.Country + kycUpdates["vies_registered"] = update.ViesRegistered + } + if err := tx.Model(&model.Kyc{}). + Where("uuid = ?", kyc.Uuid). + Updates(kycUpdates).Error; err != nil { + return err + } + if update.UserInfo != nil { + if err := createOrUpdateVerificationUserInfo(tx, update.UserInfo); err != nil { + return err + } + } + if update.NotificationType != "" && kyc.KycStatus != update.KycStatus { + if err := createVerificationNotification( + tx, + session.Uuid, + kyc.Email, + update.NotificationType, + update.NotificationTransitionKey, + update.ReconciledAt, + ); err != nil { + return err + } + } + appliedToKyc = true + } + + return markVerificationEventProcessed(tx, event.Uuid, update.ReconciledAt) + }) + return appliedToKyc, err +} + +func GetLatestVerificationSessionForKyc( + kycUuid uuid.UUID, + provider, environment string, +) (*model.VerificationSession, bool, error) { + db, err := GetDB() + if err != nil { + return nil, false, err + } + var session model.VerificationSession + query := db.Where( + "kyc_uuid = ? AND provider = ? AND environment = ?", + kycUuid, + provider, + environment, + ).Order("created_at DESC, uuid DESC").First(&session) + if errors.Is(query.Error, gorm.ErrRecordNotFound) { + return nil, false, nil + } + return &session, query.Error == nil, query.Error +} + +func ListStaleDiditVerificationSessions( + environment string, + reconciledBefore time.Time, + limit int, +) ([]model.VerificationSession, error) { + db, err := GetDB() + if err != nil { + return nil, err + } + if limit <= 0 { + return nil, errors.New("stale verification session limit must be positive") + } + var sessions []model.VerificationSession + err = db.Where( + `provider = ? AND environment = ? AND + provider_status NOT IN (?, ?, ?, ?, ?) AND + COALESCE(last_reconciled_at, updated_at, created_at) <= ?`, + model.VerificationProviderDidit, + environment, + string(model.DiditStatusApproved), + string(model.DiditStatusDeclined), + string(model.DiditStatusExpired), + string(model.DiditStatusKycExpired), + string(model.DiditStatusAbandoned), + reconciledBefore, + ).Order("COALESCE(last_reconciled_at, updated_at, created_at) ASC"). + Limit(limit). + Find(&sessions).Error + return sessions, err +} + +func createOrUpdateVerificationUserInfo(tx *gorm.DB, userInfo *model.UserInfo) error { + if strings.TrimSpace(userInfo.BlockchainAddress) == "" { + return errors.New("verification user info blockchain address is required") + } + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "blockchain_address"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "email", + "name", + "surname", + "company_name", + "identification_code", + "address", + "state", + "city", + "country", + "is_company", + }), + }).Create(userInfo).Error +} + +func createVerificationNotification( + tx *gorm.DB, + sessionUuid uuid.UUID, + email, notificationType, transitionKey string, + now time.Time, +) error { + if transitionKey == "" { + transitionKey = sessionUuid.String() + ":" + notificationType + } + notification := model.VerificationNotification{ + Uuid: uuid.New(), + VerificationSessionUuid: sessionUuid, + TransitionKey: transitionKey, + Email: email, + NotificationType: notificationType, + ProcessingStatus: model.VerificationNotificationPending, + CreatedAt: now, + UpdatedAt: now, + } + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "transition_key"}}, + DoNothing: true, + }).Create(¬ification).Error +} + +func markVerificationEventProcessed(tx *gorm.DB, eventUuid uuid.UUID, now time.Time) error { + return tx.Model(&model.VerificationWebhookEvent{}). + Where("uuid = ?", eventUuid). + Updates(map[string]interface{}{ + "processing_status": model.VerificationEventProcessed, + "processed_at": now, + "next_attempt_at": nil, + "claimed_at": nil, + "last_error": "", + "updated_at": now, + }).Error +} + +func validateVerificationSession(session *model.VerificationSession) error { + if session.KycUuid == uuid.Nil || + session.ProviderSessionId == "" || + session.KycStatus == "" || + session.ProviderStatus == "" { + return errors.New("verification session identity and status fields are required") + } + if err := validateVerificationProvider(session.Provider); err != nil { + return err + } + if err := validateVerificationEnvironment(session.Environment); err != nil { + return err + } + if err := validateApplicantType(session.ApplicantType); err != nil { + return err + } + if session.Provider == model.VerificationProviderDidit && session.WorkflowId == "" { + return errors.New("Didit verification sessions require a workflow id") + } + return nil +} + +func truncateVerificationError(message string) string { + const maxLength = 1024 + if len(message) <= maxLength { + return message + } + return message[:maxLength] +} diff --git a/storage/verificationWorkflowStorer_test.go b/storage/verificationWorkflowStorer_test.go new file mode 100644 index 0000000..8260ab8 --- /dev/null +++ b/storage/verificationWorkflowStorer_test.go @@ -0,0 +1,320 @@ +package storage + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestWithVerificationCreationLockSerializesSameVerification(t *testing.T) { + requireStorageTestDatabase(t) + + kycUuid := uuid.New() + var active atomic.Int32 + var maximum atomic.Int32 + start := make(chan struct{}) + errorsChannel := make(chan error, 2) + var waitGroup sync.WaitGroup + waitGroup.Add(2) + + for index := 0; index < 2; index++ { + go func() { + defer waitGroup.Done() + <-start + err := WithVerificationCreationLock( + context.Background(), + kycUuid, + model.VerificationProviderDidit, + model.VerificationEnvironmentSandbox, + func() error { + current := active.Add(1) + for { + observed := maximum.Load() + if current <= observed || maximum.CompareAndSwap(observed, current) { + break + } + } + time.Sleep(50 * time.Millisecond) + active.Add(-1) + return nil + }, + ) + errorsChannel <- err + }() + } + close(start) + waitGroup.Wait() + close(errorsChannel) + + for err := range errorsChannel { + require.NoError(t, err) + } + require.Equal(t, int32(1), maximum.Load()) +} + +func TestAssignVerificationSessionIsIdempotentAndClaimsProviderOwnership(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + kycUuid := uuid.New() + createVerificationWorkflowTestKyc(t, db, kycUuid, "") + t.Cleanup(func() { + cleanupVerificationWorkflowTest(t, db, kycUuid) + }) + + session := verificationSessionFixture( + kycUuid, + "session-"+uuid.NewString(), + model.VerificationEnvironmentSandbox, + ) + session.WorkflowVersion = "1" + first, err := AssignVerificationSession(session) + require.NoError(t, err) + second, err := AssignVerificationSession(session) + require.NoError(t, err) + require.Equal(t, first.Uuid, second.Uuid) + + var count int64 + require.NoError(t, db.Model(&model.VerificationSession{}). + Where("kyc_uuid = ?", kycUuid). + Count(&count).Error) + require.Equal(t, int64(1), count) + + var kyc model.Kyc + require.NoError(t, db.Where("uuid = ?", kycUuid).First(&kyc).Error) + require.Equal(t, model.VerificationProviderDidit, kyc.VerificationProvider) + require.Equal(t, model.IndividualCustomer, kyc.ApplicantType) +} + +func TestAssignVerificationSessionResetsOnlyNewDiditReplacementToInit(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + kycUuid := uuid.New() + createVerificationWorkflowTestKyc( + t, + db, + kycUuid, + model.VerificationProviderDidit, + ) + require.NoError(t, db.Model(&model.Kyc{}). + Where("uuid = ?", kycUuid). + Updates(map[string]interface{}{ + "applicant_type": model.IndividualCustomer, + "kyc_status": model.StatusRejected, + }).Error) + t.Cleanup(func() { + cleanupVerificationWorkflowTest(t, db, kycUuid) + }) + + session := verificationSessionFixture( + kycUuid, + "replacement-"+uuid.NewString(), + model.VerificationEnvironmentSandbox, + ) + session.WorkflowVersion = "1" + _, err = AssignVerificationSession(session) + require.NoError(t, err) + + var kyc model.Kyc + require.NoError(t, db.Where("uuid = ?", kycUuid).First(&kyc).Error) + require.Equal(t, model.StatusInit, kyc.KycStatus) + + require.NoError(t, db.Model(&model.Kyc{}). + Where("uuid = ?", kycUuid). + Update("kyc_status", model.StatusRejected).Error) + _, err = AssignVerificationSession(session) + require.NoError(t, err) + require.NoError(t, db.Where("uuid = ?", kycUuid).First(&kyc).Error) + require.Equal(t, model.StatusRejected, kyc.KycStatus) +} + +func TestClaimVerificationWebhookEventsClaimsDiditAndSumsubAndDeadLetters(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + eventPrefix := "claim-" + uuid.NewString() + t.Cleanup(func() { + require.NoError(t, db.Where("event_id LIKE ?", eventPrefix+"%"). + Delete(&model.VerificationWebhookEvent{}).Error) + }) + + didit := verificationWebhookEventFixture( + eventPrefix+"-didit", + model.VerificationEnvironmentSandbox, + "didit", + ) + sumsub := verificationWebhookEventFixture( + eventPrefix+"-sumsub", + model.VerificationEnvironmentSandbox, + "sumsub", + ) + didit.Uuid = uuid.New() + sumsub.Uuid = uuid.New() + sumsub.Provider = model.VerificationProviderSumsub + require.NoError(t, db.Create(didit).Error) + require.NoError(t, db.Create(sumsub).Error) + + now := time.Now().UTC() + claimed, err := ClaimVerificationWebhookEvents(now, time.Minute, 10, 1) + require.NoError(t, err) + require.Len(t, claimed, 2) + require.ElementsMatch(t, []string{didit.EventId, sumsub.EventId}, []string{ + claimed[0].EventId, + claimed[1].EventId, + }) + require.Equal(t, uint(1), claimed[0].Attempts) + require.Equal(t, uint(1), claimed[1].Attempts) + + require.NoError(t, MarkVerificationWebhookEventFailed( + claimed[0].Uuid, + context.DeadlineExceeded, + now.Add(time.Minute), + 1, + )) + var stored model.VerificationWebhookEvent + require.NoError(t, db.Where("uuid = ?", claimed[0].Uuid).First(&stored).Error) + require.Equal(t, model.VerificationEventDeadLetter, stored.ProcessingStatus) + + require.NoError(t, RetryDeadLetterVerificationWebhookEvent(stored.Uuid)) + require.NoError(t, db.Where("uuid = ?", stored.Uuid).First(&stored).Error) + require.Equal(t, model.VerificationEventFailed, stored.ProcessingStatus) + require.Zero(t, stored.Attempts) +} + +func TestApplyVerificationProjectionIsLatestProviderOwnedAndAtomic(t *testing.T) { + requireStorageTestDatabase(t) + + db, err := GetDB() + require.NoError(t, err) + kycUuid := uuid.New() + createVerificationWorkflowTestKyc( + t, + db, + kycUuid, + model.VerificationProviderDidit, + ) + t.Cleanup(func() { + cleanupVerificationWorkflowTest(t, db, kycUuid) + }) + + session := verificationSessionFixture( + kycUuid, + "session-"+uuid.NewString(), + model.VerificationEnvironmentSandbox, + ) + session.WorkflowVersion = "1" + session.CreatedAt = time.Now().UTC().Add(-time.Minute) + session.UpdatedAt = session.CreatedAt + require.NoError(t, CreateVerificationSession(session)) + event := verificationWebhookEventFixture( + "projection-"+uuid.NewString(), + model.VerificationEnvironmentSandbox, + "projection", + ) + event.ProviderSessionId = session.ProviderSessionId + event.VendorData = kycUuid.String() + event.ReceivedAt = time.Now().UTC() + created, err := CreateVerificationWebhookEvent(event) + require.NoError(t, err) + require.True(t, created) + + reconciledAt := time.Now().UTC() + applied, err := ApplyVerificationProjection(VerificationProjectionUpdate{ + EventUuid: event.Uuid, + SessionUuid: session.Uuid, + KycStatus: model.StatusApproved, + ProviderStatus: string(model.DiditStatusApproved), + ReconciledAt: reconciledAt, + }) + require.ErrorContains(t, err, "requires complete user info") + require.False(t, applied) + + var storedEvent model.VerificationWebhookEvent + require.NoError(t, db.Where("uuid = ?", event.Uuid).First(&storedEvent).Error) + require.Equal(t, model.VerificationEventReceived, storedEvent.ProcessingStatus) + + name := "Ada" + surname := "Lovelace" + applied, err = ApplyVerificationProjection(VerificationProjectionUpdate{ + EventUuid: event.Uuid, + SessionUuid: session.Uuid, + KycStatus: model.StatusApproved, + ProviderStatus: string(model.DiditStatusApproved), + ReconciledAt: reconciledAt, + Country: "GBR", + UserInfo: &model.UserInfo{ + BlockchainAddress: "0x0000000000000000000000000000000000000001", + Email: "workflow-" + kycUuid.String() + "@example.com", + Name: &name, + Surname: &surname, + IdentificationCode: "TAX-001", + Address: "1 Test Street", + State: "London", + City: "London", + Country: "GBR", + }, + NotificationType: model.VerificationNotificationApproved, + NotificationTransitionKey: session.Uuid.String() + ":approved", + }) + require.NoError(t, err) + require.True(t, applied) + + var kyc model.Kyc + require.NoError(t, db.Where("uuid = ?", kycUuid).First(&kyc).Error) + require.Equal(t, model.StatusApproved, kyc.KycStatus) + require.Equal(t, "GBR", kyc.Country) + require.NoError(t, db.Where("uuid = ?", event.Uuid).First(&storedEvent).Error) + require.Equal(t, model.VerificationEventProcessed, storedEvent.ProcessingStatus) + + var notificationCount int64 + require.NoError(t, db.Model(&model.VerificationNotification{}). + Where("verification_session_uuid = ?", session.Uuid). + Count(¬ificationCount).Error) + require.Equal(t, int64(1), notificationCount) +} + +func createVerificationWorkflowTestKyc( + t *testing.T, + db *gorm.DB, + kycUuid uuid.UUID, + provider string, +) { + t.Helper() + receiveUpdates := false + require.NoError(t, db.Create(&model.Kyc{ + Uuid: kycUuid, + Email: "workflow-" + kycUuid.String() + "@example.com", + KycStatus: model.StatusAccountCreated, + VerificationProvider: provider, + ReceiveUpdates: &receiveUpdates, + IsActive: true, + }).Error) +} + +func cleanupVerificationWorkflowTest(t *testing.T, db *gorm.DB, kycUuid uuid.UUID) { + t.Helper() + var sessions []model.VerificationSession + require.NoError(t, db.Where("kyc_uuid = ?", kycUuid).Find(&sessions).Error) + for _, session := range sessions { + require.NoError(t, db.Where("verification_session_uuid = ?", session.Uuid). + Delete(&model.VerificationNotification{}).Error) + } + require.NoError(t, db.Where("vendor_data = ?", kycUuid.String()). + Delete(&model.VerificationWebhookEvent{}).Error) + require.NoError(t, db.Where("kyc_uuid = ?", kycUuid). + Delete(&model.VerificationSession{}).Error) + require.NoError(t, db.Where("email = ?", "workflow-"+kycUuid.String()+"@example.com"). + Delete(&model.UserInfo{}).Error) + require.NoError(t, db.Where("uuid = ?", kycUuid).Delete(&model.Kyc{}).Error) +}