Skip to content

Add KYC Service and enable getting user's persona accountId #239

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 28, 2023
4 changes: 4 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -62,5 +65,6 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic
Verification: verification,
Device: device,
Card: card,
KYC: kyc,
}
}
2 changes: 1 addition & 1 deletion api/handler/card.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/handler/quotes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/handler/transact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 33 additions & 3 deletions api/handler/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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...)
}

Expand Down
14 changes: 14 additions & 0 deletions pkg/model/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
10 changes: 10 additions & 0 deletions pkg/model/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
86 changes: 86 additions & 0 deletions pkg/repository/identity.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions pkg/repository/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ type Repositories struct {
Transaction Transaction
TxLeg TxLeg
Location Location
Identity Identity
}
1 change: 1 addition & 0 deletions pkg/service/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ type Services struct {
Unit21 Unit21
Card Card
Webhook Webhook
KYC KYC
}
111 changes: 111 additions & 0 deletions pkg/service/kyc.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading