From e0c4a8001bd3c3e28f2201f6907bc0c3153e3008 Mon Sep 17 00:00:00 2001 From: Ocasta Date: Wed, 26 Jul 2023 15:41:29 -0500 Subject: [PATCH 01/12] first pass --- api/config.go | 3 ++ api/handler/user.go | 26 +++++++++++ pkg/model/entity.go | 14 ++++++ pkg/model/request.go | 10 +++++ pkg/repository/identity.go | 85 ++++++++++++++++++++++++++++++++++++ pkg/repository/repository.go | 1 + pkg/service/user.go | 48 +++++++++++++++++++- 7 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 pkg/repository/identity.go diff --git a/api/config.go b/api/config.go index 3b0f456a..c7d3d6c0 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), } } @@ -49,6 +50,7 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic transaction := service.NewTransaction(repos, config.Redis, unit21) user := service.NewUser(repos, auth, fingerprint, device, unit21, verification) + identity := service.NewIdentity(repos) card := service.NewCard(repos) @@ -59,6 +61,7 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic Geofencing: geofencing, Transaction: transaction, User: user, + Identity: identity, Verification: verification, Device: device, Card: card, diff --git a/api/handler/user.go b/api/handler/user.go index 01c5d0ed..f5590586 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) } @@ -367,6 +368,30 @@ 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 ApiKeyAuth +// @Param id path string true "User ID" +// @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 := c.Get("userId") + + identity, err := u.userService.GetPersonaAccountId(ctx, userId) + if err != nil { + return httperror.Internal500(c) + } + return c.JSON(http.StatusOK, identity.Data.Id) +} + // @Summary Get user email preview // @Description Get obscured user email // @Tags Users @@ -428,6 +453,7 @@ 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:] diff --git a/pkg/model/entity.go b/pkg/model/entity.go index 41a9bb73..c1a66ff1 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 string `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..761dc1e0 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 *string `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..0e1b2502 --- /dev/null +++ b/pkg/repository/identity.go @@ -0,0 +1,85 @@ +package repository + +import ( + "context" + "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 (userId) + VALUES(:userId) RETURNING *`, insert) + if err != nil { + return identity, libcommon.StringError(err) + } + + err = i.Store.QueryRowxContext(ctx, query, args...).StructScan(&m) + 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 userId=$1", i.Table) + err = i.Store.QueryRowxContext(ctx, query, userId).StructScan(&identity) + if err != nil { + 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 accountId=$1", i.Table) + err = i.Store.QueryRowxContext(ctx, query, accountId).StructScan(&identity) + if err != nil { + 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/user.go b/pkg/service/user.go index 23aed82c..cb04037a 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) { @@ -353,3 +359,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 +} From 653be586b57441b52cb1db30c10c9faa51127629 Mon Sep 17 00:00:00 2001 From: Ocasta Date: Wed, 26 Jul 2023 15:57:07 -0500 Subject: [PATCH 02/12] fix some bugs, clear unneeded code --- api/config.go | 2 -- api/handler/user.go | 9 ++++++--- pkg/repository/identity.go | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/api/config.go b/api/config.go index c7d3d6c0..cd701b07 100644 --- a/api/config.go +++ b/api/config.go @@ -50,7 +50,6 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic transaction := service.NewTransaction(repos, config.Redis, unit21) user := service.NewUser(repos, auth, fingerprint, device, unit21, verification) - identity := service.NewIdentity(repos) card := service.NewCard(repos) @@ -61,7 +60,6 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic Geofencing: geofencing, Transaction: transaction, User: user, - Identity: identity, Verification: verification, Device: device, Card: card, diff --git a/api/handler/user.go b/api/handler/user.go index f5590586..d1aecb90 100644 --- a/api/handler/user.go +++ b/api/handler/user.go @@ -383,13 +383,16 @@ func (u user) PreValidateEmail(c echo.Context) error { func (u user) GetPersonaAccountId(c echo.Context) error { ctx := c.Request().Context() - userId := c.Get("userId") + userId, ok := c.Get("userId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid userId") + } - identity, err := u.userService.GetPersonaAccountId(ctx, userId) + accountId, err := u.userService.GetPersonaAccountId(ctx, userId) if err != nil { return httperror.Internal500(c) } - return c.JSON(http.StatusOK, identity.Data.Id) + return c.JSON(http.StatusOK, accountId) } // @Summary Get user email preview diff --git a/pkg/repository/identity.go b/pkg/repository/identity.go index 0e1b2502..32d676d0 100644 --- a/pkg/repository/identity.go +++ b/pkg/repository/identity.go @@ -37,7 +37,7 @@ func (i identity[T]) Create(ctx context.Context, insert model.Identity) (identit return identity, libcommon.StringError(err) } - err = i.Store.QueryRowxContext(ctx, query, args...).StructScan(&m) + err = i.Store.QueryRowxContext(ctx, query, args...).StructScan(&identity) if err != nil { return identity, libcommon.StringError(err) } From 7198e9ec0686e239f21ba860a4966ff939ed6bf2 Mon Sep 17 00:00:00 2001 From: Ocasta Date: Wed, 26 Jul 2023 16:39:27 -0500 Subject: [PATCH 03/12] create identity on user creation, and update on email verification --- pkg/service/user.go | 3 +++ pkg/service/verification.go | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/pkg/service/user.go b/pkg/service/user.go index cb04037a..de4d8107 100644 --- a/pkg/service/user.go +++ b/pkg/service/user.go @@ -124,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() 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 +} From 2507bdb325581565dda70faf33593861d2dd0ce5 Mon Sep 17 00:00:00 2001 From: Ocasta Date: Wed, 26 Jul 2023 18:20:36 -0500 Subject: [PATCH 04/12] add kyc service --- api/config.go | 3 ++ pkg/model/entity.go | 2 +- pkg/model/request.go | 2 +- pkg/service/base.go | 1 + pkg/service/kyc.go | 105 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 pkg/service/kyc.go diff --git a/api/config.go b/api/config.go index cd701b07..f81368a0 100644 --- a/api/config.go +++ b/api/config.go @@ -53,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, @@ -63,5 +65,6 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic Verification: verification, Device: device, Card: card, + KYC: kyc, } } diff --git a/pkg/model/entity.go b/pkg/model/entity.go index c1a66ff1..3eecb944 100644 --- a/pkg/model/entity.go +++ b/pkg/model/entity.go @@ -26,7 +26,7 @@ type User struct { type Identity struct { Id string `json:"id,omitempty" db:"id"` - Level string `json:"level" db:"level"` + 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"` diff --git a/pkg/model/request.go b/pkg/model/request.go index 761dc1e0..e7877a6d 100644 --- a/pkg/model/request.go +++ b/pkg/model/request.go @@ -78,7 +78,7 @@ type UserUpdates struct { } type IdentityUpdates struct { - Level *string `json:"level" db:"level"` + 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"` 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..5e444f8f --- /dev/null +++ b/pkg/service/kyc.go @@ -0,0 +1,105 @@ +package service + +import ( + "context" + + "github.com/String-xyz/string-api/pkg/model" + "github.com/String-xyz/string-api/pkg/repository" +) + +type KYC interface { + GetTransactionKYCLevel(assetType string, cost float64) int + GetUserKYCLevel(ctx context.Context, userId string) (level int, err error) + UpdateUserKYCLevel(ctx context.Context, userId string) (level int, err error) +} + +type kyc struct { + repos repository.Repositories +} + +func NewKYC(repos repository.Repositories) KYC { + return &kyc{repos} +} + +func (k kyc) UserMeetsKYCRequirements(ctx context.Context, userId string, assetType string, cost float64) (met bool, err error) { + transactionLevel := k.GetTransactionKYCLevel(assetType, cost) + + userLevel, err := k.GetUserKYCLevel(ctx, userId) + if err != nil { + return false, err + } + if userLevel >= transactionLevel { + return true, nil + } else { + return false, nil + } +} + +func (k kyc) GetTransactionKYCLevel(assetType string, cost float64) int { + if assetType == "NFT" { + if cost < 1000.00 { + return 1 + } else if cost < 5000.00 { + return 2 + } else { + return 3 + } + } else { + if cost < 5000.00 { + return 2 + } else { + return 3 + } + } +} + +func (k kyc) GetUserKYCLevel(ctx context.Context, userId string) (level int, err error) { + level, err = k.UpdateUserKYCLevel(ctx, userId) + if err != nil { + return level, err + } + + if err != nil { + return level, err + } + + return level, nil +} + +func (k kyc) UpdateUserKYCLevel(ctx context.Context, userId string) (level int, err error) { + identity, err := k.repos.Identity.GetByUserId(ctx, userId) + if err != nil { + return level, err + } + + 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 < 1 && level >= 1 { + identity.Level = 0 + k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) + } else if points >= 4 && level < 2 { + identity.Level = 2 + k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) + } else if points >= 1 && level < 1 && identity.EmailVerified != nil { + identity.Level = 1 + k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) + } + + return identity.Level, nil +} From 5825d48b1f5639744926c50bf5753b6463eed7ef Mon Sep 17 00:00:00 2001 From: akfoster Date: Wed, 26 Jul 2023 18:46:19 -0600 Subject: [PATCH 05/12] Update pkg/service/kyc.go Co-authored-by: saito-sv <7920256+saito-sv@users.noreply.github.com> --- pkg/service/kyc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/service/kyc.go b/pkg/service/kyc.go index 5e444f8f..682612d8 100644 --- a/pkg/service/kyc.go +++ b/pkg/service/kyc.go @@ -21,7 +21,7 @@ func NewKYC(repos repository.Repositories) KYC { return &kyc{repos} } -func (k kyc) UserMeetsKYCRequirements(ctx context.Context, userId string, assetType string, cost float64) (met bool, err error) { +func (k kyc) MeetsRequirements(ctx context.Context, userId string, assetType string, cost float64) (met bool, err error) { transactionLevel := k.GetTransactionKYCLevel(assetType, cost) userLevel, err := k.GetUserKYCLevel(ctx, userId) From 83ef00badaff92dd013ccc5bc4476d06c43e1d9a Mon Sep 17 00:00:00 2001 From: Ocasta Date: Wed, 26 Jul 2023 19:47:57 -0500 Subject: [PATCH 06/12] simplify KYC function names --- pkg/service/kyc.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/pkg/service/kyc.go b/pkg/service/kyc.go index 682612d8..c11363a5 100644 --- a/pkg/service/kyc.go +++ b/pkg/service/kyc.go @@ -8,9 +8,10 @@ import ( ) type KYC interface { - GetTransactionKYCLevel(assetType string, cost float64) int - GetUserKYCLevel(ctx context.Context, userId string) (level int, err error) - UpdateUserKYCLevel(ctx context.Context, userId string) (level int, err error) + MeetsRequirements(ctx context.Context, userId string, assetType string, cost float64) (met bool, err error) + GetTransactionLevel(assetType string, cost float64) int + GetUserLevel(ctx context.Context, userId string) (level int, err error) + UpdateUserLevel(ctx context.Context, userId string) (level int, err error) } type kyc struct { @@ -22,9 +23,9 @@ func NewKYC(repos repository.Repositories) KYC { } func (k kyc) MeetsRequirements(ctx context.Context, userId string, assetType string, cost float64) (met bool, err error) { - transactionLevel := k.GetTransactionKYCLevel(assetType, cost) + transactionLevel := k.GetTransactionLevel(assetType, cost) - userLevel, err := k.GetUserKYCLevel(ctx, userId) + userLevel, err := k.GetUserLevel(ctx, userId) if err != nil { return false, err } @@ -35,7 +36,7 @@ func (k kyc) MeetsRequirements(ctx context.Context, userId string, assetType str } } -func (k kyc) GetTransactionKYCLevel(assetType string, cost float64) int { +func (k kyc) GetTransactionLevel(assetType string, cost float64) int { if assetType == "NFT" { if cost < 1000.00 { return 1 @@ -53,8 +54,8 @@ func (k kyc) GetTransactionKYCLevel(assetType string, cost float64) int { } } -func (k kyc) GetUserKYCLevel(ctx context.Context, userId string) (level int, err error) { - level, err = k.UpdateUserKYCLevel(ctx, userId) +func (k kyc) GetUserLevel(ctx context.Context, userId string) (level int, err error) { + level, err = k.UpdateUserLevel(ctx, userId) if err != nil { return level, err } @@ -66,7 +67,7 @@ func (k kyc) GetUserKYCLevel(ctx context.Context, userId string) (level int, err return level, nil } -func (k kyc) UpdateUserKYCLevel(ctx context.Context, userId string) (level int, err error) { +func (k kyc) UpdateUserLevel(ctx context.Context, userId string) (level int, err error) { identity, err := k.repos.Identity.GetByUserId(ctx, userId) if err != nil { return level, err From 27df6f7625099db6e849c653dbcdc3c0af3adce1 Mon Sep 17 00:00:00 2001 From: Ocasta Date: Thu, 27 Jul 2023 00:24:00 -0500 Subject: [PATCH 07/12] unit tests passing --- pkg/repository/identity.go | 4 +- pkg/service/kyc.go | 26 +++---- pkg/service/kyc_test.go | 138 +++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 pkg/service/kyc_test.go diff --git a/pkg/repository/identity.go b/pkg/repository/identity.go index 32d676d0..78ba76b7 100644 --- a/pkg/repository/identity.go +++ b/pkg/repository/identity.go @@ -67,7 +67,7 @@ func (i identity[T]) Update(ctx context.Context, id string, updates any) (identi } func (i identity[T]) GetByUserId(ctx context.Context, userId string) (identity model.Identity, err error) { - query := fmt.Sprintf("SELECT * FROM %s WHERE userId=$1", i.Table) + query := fmt.Sprintf("SELECT * FROM %s WHERE user_id=$1", i.Table) err = i.Store.QueryRowxContext(ctx, query, userId).StructScan(&identity) if err != nil { return identity, libcommon.StringError(err) @@ -76,7 +76,7 @@ func (i identity[T]) GetByUserId(ctx context.Context, userId string) (identity m } func (i identity[T]) GetByAccountId(ctx context.Context, accountId string) (identity model.Identity, err error) { - query := fmt.Sprintf("SELECT * FROM %s WHERE accountId=$1", i.Table) + query := fmt.Sprintf("SELECT * FROM %s WHERE account_id=$1", i.Table) err = i.Store.QueryRowxContext(ctx, query, accountId).StructScan(&identity) if err != nil { return identity, libcommon.StringError(err) diff --git a/pkg/service/kyc.go b/pkg/service/kyc.go index c11363a5..d767d830 100644 --- a/pkg/service/kyc.go +++ b/pkg/service/kyc.go @@ -60,10 +60,6 @@ func (k kyc) GetUserLevel(ctx context.Context, userId string) (level int, err er return level, err } - if err != nil { - return level, err - } - return level, nil } @@ -73,10 +69,6 @@ func (k kyc) UpdateUserLevel(ctx context.Context, userId string) (level int, err return level, err } - if err != nil { - return level, err - } - points := 0 if identity.EmailVerified != nil { points++ @@ -91,15 +83,19 @@ func (k kyc) UpdateUserLevel(ctx context.Context, userId string) (level int, err points++ } - if points < 1 && level >= 1 { + if points >= 4 { + if identity.Level != 2 { + identity.Level = 2 + k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) + } + } else if points >= 1 && identity.EmailVerified != nil { + if identity.Level != 1 { + identity.Level = 1 + k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) + } + } else if points <= 1 && identity.Level != 0 { identity.Level = 0 k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) - } else if points >= 4 && level < 2 { - identity.Level = 2 - k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) - } else if points >= 1 && level < 1 && identity.EmailVerified != nil { - identity.Level = 1 - k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) } return 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) + } +} From 22df8aa1135b914ffeb10f0541cf673f72f7c334 Mon Sep 17 00:00:00 2001 From: frostbournesb <85953565+frostbournesb@users.noreply.github.com> Date: Thu, 27 Jul 2023 16:26:57 -0400 Subject: [PATCH 08/12] add route --- api/handler/user.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/handler/user.go b/api/handler/user.go index d1aecb90..6b292920 100644 --- a/api/handler/user.go +++ b/api/handler/user.go @@ -390,6 +390,7 @@ func (u user) GetPersonaAccountId(c echo.Context) error { 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) @@ -463,6 +464,7 @@ func (u user) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { 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...) } From 4fd4485b20e300961818eefae9874074b5d6c895 Mon Sep 17 00:00:00 2001 From: Ocasta Date: Thu, 27 Jul 2023 16:20:29 -0500 Subject: [PATCH 09/12] update annotations --- api/api.go | 4 ++++ api/handler/card.go | 2 +- api/handler/quotes.go | 2 +- api/handler/transact.go | 2 +- api/handler/user.go | 9 ++++----- 5 files changed, 11 insertions(+), 8 deletions(-) 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/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 6b292920..25e95b17 100644 --- a/api/handler/user.go +++ b/api/handler/user.go @@ -114,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 @@ -141,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 @@ -187,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 @@ -373,8 +373,7 @@ func (u user) PreValidateEmail(c echo.Context) error { // @Tags Users // @Accept json // @Produce json -// @Security ApiKeyAuth -// @Param id path string true "User ID" +// @Security JWT // @Success 200 {object} string // @Failure 400 {object} error // @Failure 401 {object} error From 452a27e638102bade8bbea93d60fded647bfcdb8 Mon Sep 17 00:00:00 2001 From: Ocasta Date: Thu, 27 Jul 2023 17:10:39 -0500 Subject: [PATCH 10/12] ensure the identity actually get's built --- pkg/repository/identity.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/repository/identity.go b/pkg/repository/identity.go index 78ba76b7..113c3186 100644 --- a/pkg/repository/identity.go +++ b/pkg/repository/identity.go @@ -31,8 +31,8 @@ func NewIdentity(db database.Queryable) 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 (userId) - VALUES(:userId) RETURNING *`, insert) + INSERT INTO identity (user_id) + VALUES(:user_id) RETURNING *`, insert) if err != nil { return identity, libcommon.StringError(err) } From 7bd8b6b83ff3b5d70c1a0ea6c424a94aadad2467 Mon Sep 17 00:00:00 2001 From: Ocasta Date: Thu, 27 Jul 2023 17:34:26 -0500 Subject: [PATCH 11/12] handle noRows in sql selects --- pkg/repository/identity.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/repository/identity.go b/pkg/repository/identity.go index 113c3186..ae731670 100644 --- a/pkg/repository/identity.go +++ b/pkg/repository/identity.go @@ -2,6 +2,7 @@ package repository import ( "context" + "database/sql" "errors" "fmt" "strings" @@ -69,7 +70,7 @@ func (i identity[T]) Update(ctx context.Context, id string, updates any) (identi 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 { + if err != nil && err == sql.ErrNoRows { return identity, libcommon.StringError(err) } return identity, nil @@ -78,7 +79,7 @@ func (i identity[T]) GetByUserId(ctx context.Context, userId string) (identity m 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 { + if err != nil && err == sql.ErrNoRows { return identity, libcommon.StringError(err) } return identity, nil From 839065b907551e7e1edb3f81e5f67a3b60d7a39e Mon Sep 17 00:00:00 2001 From: Ocasta Date: Fri, 28 Jul 2023 15:05:40 -0500 Subject: [PATCH 12/12] use enum --- pkg/service/kyc.go | 45 +++++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/pkg/service/kyc.go b/pkg/service/kyc.go index d767d830..f8d9115f 100644 --- a/pkg/service/kyc.go +++ b/pkg/service/kyc.go @@ -9,11 +9,20 @@ import ( type KYC interface { MeetsRequirements(ctx context.Context, userId string, assetType string, cost float64) (met bool, err error) - GetTransactionLevel(assetType string, cost float64) int - GetUserLevel(ctx context.Context, userId string) (level int, err error) - UpdateUserLevel(ctx context.Context, userId string) (level int, 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 } @@ -36,25 +45,25 @@ func (k kyc) MeetsRequirements(ctx context.Context, userId string, assetType str } } -func (k kyc) GetTransactionLevel(assetType string, cost float64) int { +func (k kyc) GetTransactionLevel(assetType string, cost float64) KYCLevel { if assetType == "NFT" { if cost < 1000.00 { - return 1 + return Level1 } else if cost < 5000.00 { - return 2 + return Level2 } else { - return 3 + return Level3 } } else { if cost < 5000.00 { - return 2 + return Level2 } else { - return 3 + return Level3 } } } -func (k kyc) GetUserLevel(ctx context.Context, userId string) (level int, err error) { +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 @@ -63,7 +72,7 @@ func (k kyc) GetUserLevel(ctx context.Context, userId string) (level int, err er return level, nil } -func (k kyc) UpdateUserLevel(ctx context.Context, userId string) (level int, err error) { +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 @@ -84,19 +93,19 @@ func (k kyc) UpdateUserLevel(ctx context.Context, userId string) (level int, err } if points >= 4 { - if identity.Level != 2 { - identity.Level = 2 + 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 != 1 { - identity.Level = 1 + 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 != 0 { - identity.Level = 0 + } else if points <= 1 && identity.Level != int(Level0) { + identity.Level = int(Level0) k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) } - return identity.Level, nil + return KYCLevel(identity.Level), nil }