diff --git a/api/api.go b/api/api.go index 8fa1cd6d..b64c4b9b 100644 --- a/api/api.go +++ b/api/api.go @@ -38,6 +38,10 @@ func heartbeat(c echo.Context) error { // @host string-api.xyz // @BasePath / + +// @SecurityDefinitions.api JWT +// @Scheme bearer +// @BearerFormat JWT func Start(config APIConfig) { e := echo.New() e.Validator = validator.New() diff --git a/api/config.go b/api/config.go index 3b0f456a..f81368a0 100644 --- a/api/config.go +++ b/api/config.go @@ -23,6 +23,7 @@ func NewRepos(config APIConfig) repository.Repositories { TxLeg: repository.NewTxLeg(config.DB), Location: repository.NewLocation(config.DB), Platform: repository.NewPlatform(config.DB), + Identity: repository.NewIdentity(config.DB), } } @@ -52,6 +53,8 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic card := service.NewCard(repos) + kyc := service.NewKYC(repos) + return service.Services{ Auth: auth, Cost: cost, @@ -62,5 +65,6 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic Verification: verification, Device: device, Card: card, + KYC: kyc, } } diff --git a/api/handler/card.go b/api/handler/card.go index b3d29f46..58da89b4 100644 --- a/api/handler/card.go +++ b/api/handler/card.go @@ -30,7 +30,7 @@ func NewCard(route *echo.Echo, service service.Card) Card { // @Tags Cards // @Accept json // @Produce json -// @Security ApiKeyAuth +// @Security JWT // @Success 200 {object} []checkout.CardInstrument // @Failure 400 {object} error // @Failure 401 {object} error diff --git a/api/handler/quotes.go b/api/handler/quotes.go index 9e4d48d3..df2b4848 100644 --- a/api/handler/quotes.go +++ b/api/handler/quotes.go @@ -31,7 +31,7 @@ func NewQuote(route *echo.Echo, service service.Transaction) Quotes { // @Tags Transactions // @Accept json // @Produce json -// @Security ApiKeyAuth +// @Security JWT // @Param body body model.TransactionRequest true "Transaction Request" // @Success 200 {object} model.Quote // @Failure 400 {object} error diff --git a/api/handler/transact.go b/api/handler/transact.go index ee1364c2..d644277a 100644 --- a/api/handler/transact.go +++ b/api/handler/transact.go @@ -30,7 +30,7 @@ func NewTransaction(route *echo.Echo, service service.Transaction) Transaction { // @Tags Transactions // @Accept json // @Produce json -// @Security ApiKeyAuth +// @Security JWT // @Param saveCard query boolean false "do not save payment info" // @Param body body model.ExecutionRequest true "Execution Request" // @Success 200 {object} model.TransactionReceipt diff --git a/api/handler/user.go b/api/handler/user.go index 01c5d0ed..25e95b17 100644 --- a/api/handler/user.go +++ b/api/handler/user.go @@ -21,6 +21,7 @@ type User interface { PreviewEmail(c echo.Context) error VerifyEmail(c echo.Context) error PreValidateEmail(c echo.Context) error + GetPersonaAccountId(c echo.Context) error RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) RegisterPrivateRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) } @@ -113,7 +114,7 @@ func (u user) Create(c echo.Context) error { // @Tags Users // @Accept json // @Produce json -// @Security ApiKeyAuth +// @Security JWT // @Param id path string true "User ID" // @Success 200 {object} model.UserOnboardingStatus // @Failure 401 {object} error @@ -140,7 +141,7 @@ func (u user) Status(c echo.Context) error { // @Tags Users // @Accept json // @Produce json -// @Security ApiKeyAuth +// @Security JWT // @Param id path string true "User ID" // @Param body body model.UpdateUserName true "Update User Name" // @Success 200 {object} model.User @@ -186,7 +187,7 @@ func (u user) Update(c echo.Context) error { // @Tags Users // @Accept json // @Produce json -// @Security ApiKeyAuth +// @Security JWT // @Param id path string true "User ID" // @Param email query string true "Email to verify" // @Success 200 {object} ResultMessage @@ -367,6 +368,33 @@ func (u user) PreValidateEmail(c echo.Context) error { return c.JSON(http.StatusOK, ResultMessage{Status: "validated"}) } +// @Summary Get user persona account id +// @Description Get user persona account id +// @Tags Users +// @Accept json +// @Produce json +// @Security JWT +// @Success 200 {object} string +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 500 {object} error +// @Router /users/persona-account-id [get] +func (u user) GetPersonaAccountId(c echo.Context) error { + ctx := c.Request().Context() + + userId, ok := c.Get("userId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid userId") + } + + accountId, err := u.userService.GetPersonaAccountId(ctx, userId) + if err != nil { + libcommon.LogStringError(c, err, "user: get persona account id") + return httperror.Internal500(c) + } + return c.JSON(http.StatusOK, accountId) +} + // @Summary Get user email preview // @Description Get obscured user email // @Tags Users @@ -428,12 +456,14 @@ func (u user) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { g.POST("/preview-email", u.PreviewEmail, ms[0]) g.POST("/verify-device", u.RequestDeviceVerification, ms[0]) g.POST("/device-status", u.GetDeviceStatus, ms[0]) + // the rest of the endpoints use the JWT auth and do not require an API Key // hence removing the first (API key) middleware ms = ms[1:] g.GET("/:id/status", u.Status, ms...) g.GET("/:id/verify-email", u.VerifyEmail, ms...) + g.GET("/persona-account-id", u.GetPersonaAccountId, ms...) g.PATCH("/:id", u.Update, ms...) } diff --git a/pkg/model/entity.go b/pkg/model/entity.go index 41a9bb73..3eecb944 100644 --- a/pkg/model/entity.go +++ b/pkg/model/entity.go @@ -24,6 +24,20 @@ type User struct { Email string `json:"email"` } +type Identity struct { + Id string `json:"id,omitempty" db:"id"` + Level int `json:"level" db:"level"` + AccountId string `json:"accountId" db:"account_id"` + UserId string `json:"userId" db:"user_id"` + CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` + EmailVerified *time.Time `json:"emailVerified,omitempty" db:"email_verified"` + PhoneVerified *time.Time `json:"phoneVerified,omitempty" db:"phone_verified"` + SelfieVerified *time.Time `json:"selfieVerified,omitempty" db:"selfie_verified"` + DocumentVerified *time.Time `json:"documentVerified,omitempty" db:"document_verified"` +} + type UserWithContact struct { User Email string `db:"email"` diff --git a/pkg/model/request.go b/pkg/model/request.go index 0154963d..e7877a6d 100644 --- a/pkg/model/request.go +++ b/pkg/model/request.go @@ -77,6 +77,16 @@ type UserUpdates struct { LastName *string `json:"lastName" db:"last_name"` } +type IdentityUpdates struct { + Level *int `json:"level" db:"level"` + AccountId *string `json:"accountId" db:"account_id"` + UserId *string `json:"userId" db:"user_id"` + EmailVerified *time.Time `json:"emailVerified,omitempty" db:"email_verified"` + PhoneVerified *time.Time `json:"phoneVerified,omitempty" db:"phone_verified"` + SelfieVerified *time.Time `json:"selfieVerified,omitempty" db:"selfie_verified"` + DocumentVerified *time.Time `json:"documentVerified,omitempty" db:"document_verified"` +} + type UserPKLogin struct { PublicAddress string `json:"publicAddress"` Signature string `json:"signature"` diff --git a/pkg/repository/identity.go b/pkg/repository/identity.go new file mode 100644 index 00000000..ae731670 --- /dev/null +++ b/pkg/repository/identity.go @@ -0,0 +1,86 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + baserepo "github.com/String-xyz/go-lib/v2/repository" + "github.com/String-xyz/string-api/pkg/model" +) + +type Identity interface { + Create(ctx context.Context, insert model.Identity) (identity model.Identity, err error) + GetById(ctx context.Context, id string) (model.Identity, error) + List(ctx context.Context, limit int, offset int) ([]model.Identity, error) + Update(ctx context.Context, id string, updates any) (identity model.Identity, err error) + GetByUserId(ctx context.Context, userId string) (identity model.Identity, err error) + GetByAccountId(ctx context.Context, accountId string) (identity model.Identity, err error) +} + +type identity[T any] struct { + baserepo.Base[T] +} + +func NewIdentity(db database.Queryable) Identity { + return &identity[model.Identity]{baserepo.Base[model.Identity]{Store: db, Table: "identity"}} +} + +func (i identity[T]) Create(ctx context.Context, insert model.Identity) (identity model.Identity, err error) { + query, args, err := i.Named(` + INSERT INTO identity (user_id) + VALUES(:user_id) RETURNING *`, insert) + if err != nil { + return identity, libcommon.StringError(err) + } + + err = i.Store.QueryRowxContext(ctx, query, args...).StructScan(&identity) + if err != nil { + return identity, libcommon.StringError(err) + } + + return identity, nil +} + +func (i identity[T]) Update(ctx context.Context, id string, updates any) (identity model.Identity, err error) { + names, keyToUpdate := libcommon.KeysAndValues(updates) + if len(names) == 0 { + return identity, libcommon.StringError(errors.New("no updates provided")) + } + + keyToUpdate["id"] = id + + query := fmt.Sprintf("UPDATE %s SET %s WHERE id=:id RETURNING *", i.Table, strings.Join(names, ",")) + namedQuery, args, err := i.Named(query, keyToUpdate) + if err != nil { + return identity, libcommon.StringError(err) + } + + err = i.Store.QueryRowxContext(ctx, namedQuery, args...).StructScan(&identity) + if err != nil { + return identity, libcommon.StringError(err) + } + return identity, nil +} + +func (i identity[T]) GetByUserId(ctx context.Context, userId string) (identity model.Identity, err error) { + query := fmt.Sprintf("SELECT * FROM %s WHERE user_id=$1", i.Table) + err = i.Store.QueryRowxContext(ctx, query, userId).StructScan(&identity) + if err != nil && err == sql.ErrNoRows { + return identity, libcommon.StringError(err) + } + return identity, nil +} + +func (i identity[T]) GetByAccountId(ctx context.Context, accountId string) (identity model.Identity, err error) { + query := fmt.Sprintf("SELECT * FROM %s WHERE account_id=$1", i.Table) + err = i.Store.QueryRowxContext(ctx, query, accountId).StructScan(&identity) + if err != nil && err == sql.ErrNoRows { + return identity, libcommon.StringError(err) + } + return identity, nil +} diff --git a/pkg/repository/repository.go b/pkg/repository/repository.go index 7b138f98..ff6a350b 100644 --- a/pkg/repository/repository.go +++ b/pkg/repository/repository.go @@ -14,4 +14,5 @@ type Repositories struct { Transaction Transaction TxLeg TxLeg Location Location + Identity Identity } diff --git a/pkg/service/base.go b/pkg/service/base.go index b25d2bed..7550f301 100644 --- a/pkg/service/base.go +++ b/pkg/service/base.go @@ -15,4 +15,5 @@ type Services struct { Unit21 Unit21 Card Card Webhook Webhook + KYC KYC } diff --git a/pkg/service/kyc.go b/pkg/service/kyc.go new file mode 100644 index 00000000..f8d9115f --- /dev/null +++ b/pkg/service/kyc.go @@ -0,0 +1,111 @@ +package service + +import ( + "context" + + "github.com/String-xyz/string-api/pkg/model" + "github.com/String-xyz/string-api/pkg/repository" +) + +type KYC interface { + MeetsRequirements(ctx context.Context, userId string, assetType string, cost float64) (met bool, err error) + GetTransactionLevel(assetType string, cost float64) KYCLevel + GetUserLevel(ctx context.Context, userId string) (level KYCLevel, err error) + UpdateUserLevel(ctx context.Context, userId string) (level KYCLevel, err error) +} + +type KYCLevel int + +const ( + Level0 KYCLevel = iota + Level1 + Level2 + Level3 +) + +type kyc struct { + repos repository.Repositories +} + +func NewKYC(repos repository.Repositories) KYC { + return &kyc{repos} +} + +func (k kyc) MeetsRequirements(ctx context.Context, userId string, assetType string, cost float64) (met bool, err error) { + transactionLevel := k.GetTransactionLevel(assetType, cost) + + userLevel, err := k.GetUserLevel(ctx, userId) + if err != nil { + return false, err + } + if userLevel >= transactionLevel { + return true, nil + } else { + return false, nil + } +} + +func (k kyc) GetTransactionLevel(assetType string, cost float64) KYCLevel { + if assetType == "NFT" { + if cost < 1000.00 { + return Level1 + } else if cost < 5000.00 { + return Level2 + } else { + return Level3 + } + } else { + if cost < 5000.00 { + return Level2 + } else { + return Level3 + } + } +} + +func (k kyc) GetUserLevel(ctx context.Context, userId string) (level KYCLevel, err error) { + level, err = k.UpdateUserLevel(ctx, userId) + if err != nil { + return level, err + } + + return level, nil +} + +func (k kyc) UpdateUserLevel(ctx context.Context, userId string) (level KYCLevel, err error) { + identity, err := k.repos.Identity.GetByUserId(ctx, userId) + if err != nil { + return level, err + } + + points := 0 + if identity.EmailVerified != nil { + points++ + } + if identity.PhoneVerified != nil { + points++ + } + if identity.DocumentVerified != nil { + points++ + } + if identity.SelfieVerified != nil { + points++ + } + + if points >= 4 { + if identity.Level != int(Level2) { + identity.Level = int(Level2) + k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) + } + } else if points >= 1 && identity.EmailVerified != nil { + if identity.Level != int(Level1) { + identity.Level = int(Level1) + k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) + } + } else if points <= 1 && identity.Level != int(Level0) { + identity.Level = int(Level0) + k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) + } + + return KYCLevel(identity.Level), nil +} diff --git a/pkg/service/kyc_test.go b/pkg/service/kyc_test.go new file mode 100644 index 00000000..0c4f712c --- /dev/null +++ b/pkg/service/kyc_test.go @@ -0,0 +1,138 @@ +package service + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + env "github.com/String-xyz/go-lib/v2/config" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/String-xyz/string-api/config" + "github.com/String-xyz/string-api/pkg/model" + "github.com/String-xyz/string-api/pkg/repository" + "github.com/stretchr/testify/assert" +) + +// define a test case struct +type kycCase struct { + CaseName string + User model.User + Identity model.Identity + AssetType string + Cost float64 + Met bool +} + +func getTestCases() (cases []kycCase) { + assetTypes := []string{"NFT", "TOKEN", "NFT_AND_TOKEN"} + assetCosts := []float64{0, 999.99, 1000.00, 4999.99, 5000.00, 100000.00} + kycLevels := []int{0, 1, 2, 3} + now := time.Now() + verifications := [][]*time.Time{ + {nil, nil, nil, nil}, + {&now, nil, nil, nil}, + {&now, &now, nil, nil}, + {&now, &now, &now, nil}, + {&now, &now, &now, &now}, + } + baseUser := model.User{ + Id: uuid.NewString(), + CreatedAt: now, + UpdatedAt: now, + Type: "User", + Status: "Onboarded", + Tags: nil, + FirstName: "Test", + MiddleName: "A", + LastName: "User", + Email: "FakeUser123@nomail.com", + } + + for _, assetType := range assetTypes { + for _, assetCost := range assetCosts { + for _, kycLevel := range kycLevels { + for i, verification := range verifications { + trueLevel := 0 + if i == 4 { + trueLevel = 2 + } else if i >= 1 { + trueLevel = 1 + } + met := (assetType == "NFT" && assetCost < 1000.00 && trueLevel >= 1) || (assetCost < 5000.00 && trueLevel >= 2) + testCase := kycCase{ + CaseName: fmt.Sprintf("AssetType: %s, AssetCost: %f, KYCLevel: %d, TrueLevel: %v", assetType, assetCost, kycLevel, trueLevel), + User: baseUser, + Identity: model.Identity{ + Id: uuid.NewString(), + Level: kycLevel, + AccountId: "", + UserId: "", + CreatedAt: now, + UpdatedAt: now, + DeletedAt: nil, + EmailVerified: verification[0], + PhoneVerified: verification[1], + SelfieVerified: verification[2], + DocumentVerified: verification[3], + }, + AssetType: assetType, + Cost: assetCost, + Met: met, + } + cases = append(cases, testCase) + } + } + } + } + + return cases +} + +func setup(t *testing.T) (kyc KYC, ctx context.Context, mock sqlmock.Sqlmock, db *sql.DB) { + env.LoadEnv(&config.Var, "../../.env") + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + sqlxDB := sqlx.NewDb(db, "sqlmock") + if err != nil { + t.Fatalf("error %s was not expected when opening stub db", err) + } + repos := repository.Repositories{ + Auth: repository.NewAuth(nil, sqlxDB), + Apikey: repository.NewApikey(sqlxDB), + User: repository.NewUser(sqlxDB), + Contact: repository.NewContact(sqlxDB), + Contract: repository.NewContract(sqlxDB), + Instrument: repository.NewInstrument(sqlxDB), + Device: repository.NewDevice(sqlxDB), + Asset: repository.NewAsset(sqlxDB), + Network: repository.NewNetwork(sqlxDB), + Transaction: repository.NewTransaction(sqlxDB), + TxLeg: repository.NewTxLeg(sqlxDB), + Location: repository.NewLocation(sqlxDB), + Platform: repository.NewPlatform(sqlxDB), + Identity: repository.NewIdentity(sqlxDB), + } + kyc = NewKYC(repos) + ctx = context.Background() + return kyc, ctx, mock, db +} + +func TestMeetsRequirements(t *testing.T) { + kyc, ctx, mock, db := setup(t) + defer db.Close() + testCases := getTestCases() + for _, tc := range testCases { + mockedIdentityRow := sqlmock.NewRows([]string{"id", "level", "account_id", "user_id", "email_verified", "phone_verified", "selfie_verified", "document_verified"}). + AddRow(tc.Identity.Id, tc.Identity.Level, tc.Identity.AccountId, tc.Identity.UserId, tc.Identity.EmailVerified, tc.Identity.PhoneVerified, tc.Identity.SelfieVerified, tc.Identity.DocumentVerified) + mock.ExpectQuery("SELECT * FROM identity WHERE user_id=$1").WithArgs(tc.User.Id).WillReturnRows(mockedIdentityRow) + + fmt.Printf("Running test for: %v\n", tc.CaseName) + met, err := kyc.MeetsRequirements(ctx, tc.User.Id, tc.AssetType, tc.Cost) + assert.NoError(t, err) + assert.Equal(t, tc.Met, met) + } +} diff --git a/pkg/service/user.go b/pkg/service/user.go index 23aed82c..de4d8107 100644 --- a/pkg/service/user.go +++ b/pkg/service/user.go @@ -8,7 +8,9 @@ import ( libcommon "github.com/String-xyz/go-lib/v2/common" serror "github.com/String-xyz/go-lib/v2/stringerror" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/internal/common" + "github.com/String-xyz/string-api/pkg/internal/persona" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" @@ -42,6 +44,8 @@ type User interface { // GetDeviceStatus checks the status of the device verification GetDeviceStatus(ctx context.Context, request model.WalletSignaturePayloadSigned) (model.UserOnboardingStatus, error) + + GetPersonaAccountId(ctx context.Context, userId string) (accountId string, err error) } type user struct { @@ -51,10 +55,12 @@ type user struct { device Device unit21 Unit21 verification Verification + persona persona.PersonaClient } func NewUser(repos repository.Repositories, auth Auth, fprint Fingerprint, device Device, unit21 Unit21, verificationSrv Verification) User { - return &user{repos, auth, fprint, device, unit21, verificationSrv} + persona := persona.New(config.Var.PERSONA_API_KEY) + return &user{repos, auth, fprint, device, unit21, verificationSrv, *persona} } func (u user) GetStatus(ctx context.Context, userId string) (model.UserOnboardingStatus, error) { @@ -118,6 +124,9 @@ func (u user) Create(ctx context.Context, request model.WalletSignaturePayloadSi return resp, libcommon.StringError(err) } + // Create a user identity for KYC + go u.repos.Identity.Create(ctx, model.Identity{UserId: user.Id}) + if device.Fingerprint != "" { // validate that device on user creation now := time.Now() @@ -353,3 +362,43 @@ func (u user) PreviewEmail(ctx context.Context, request model.WalletSignaturePay return email, nil } + +func (u user) GetPersonaAccountId(ctx context.Context, userId string) (accountId string, err error) { + _, finish := Span(ctx, "service.user.GetPersonaAccountId") + defer finish() + + identity, err := u.repos.Identity.GetByUserId(ctx, userId) + if err != nil { + return accountId, libcommon.StringError(err) + } + if identity.AccountId != "" { + return identity.AccountId, nil + } + + user, err := u.repos.User.GetById(ctx, userId) + if err != nil { + return accountId, libcommon.StringError(err) + } + + request := persona.AccountCreateRequest{ + Data: persona.AccountCreate{ + Attributes: persona.CommonFields{ + EmailAddress: user.Email, + NameFirst: user.FirstName, + NameLast: user.LastName, + NameMiddle: user.MiddleName, + }, + }, + } + account, err := u.persona.CreateAccount(request) + if err != nil { + return accountId, libcommon.StringError(err) + } + + identity, err = u.repos.Identity.Update(ctx, identity.Id, model.IdentityUpdates{AccountId: &account.Data.Id}) + if err != nil { + return accountId, libcommon.StringError(err) + } + + return account.Data.Id, nil +} diff --git a/pkg/service/verification.go b/pkg/service/verification.go index 23ed2a9e..816c7aa5 100644 --- a/pkg/service/verification.go +++ b/pkg/service/verification.go @@ -135,6 +135,9 @@ func (v verification) VerifyEmail(ctx context.Context, userId string, email stri // 5. Create user in Checkout go v.createCheckoutCustomer(ctx2, userId, platformId) + // 6. Update user identity + go v.updateIdentityEmail(ctx2, userId, now) + return nil } @@ -193,3 +196,26 @@ func (v verification) createCheckoutCustomer(ctx context.Context, userId string, return customerId } + +func (v verification) updateIdentityEmail(ctx context.Context, userId string, now time.Time) error { + identity, err := v.repos.Identity.GetByUserId(ctx, userId) + if err != nil { + if serror.Is(err, serror.NOT_FOUND) { + identity, err = v.repos.Identity.Create(ctx, model.Identity{UserId: userId}) + if err != nil { + log.Err(err).Msg("Failed to create identity") + return libcommon.StringError(err) + } + } else { + log.Err(err).Msg("Failed to get identity by user id") + return libcommon.StringError(err) + } + } + identity, err = v.repos.Identity.Update(ctx, identity.Id, model.IdentityUpdates{EmailVerified: &now}) + if err != nil { + log.Err(err).Msg("Failed to update identity") + return libcommon.StringError(err) + } + + return nil +}