Skip to content

Commit 09a2ebe

Browse files
akfostersaito-svfrostbournesb
authored
Add KYC Service and enable getting user's persona accountId (#239)
* first pass * fix some bugs, clear unneeded code * create identity on user creation, and update on email verification * add kyc service * Update pkg/service/kyc.go Co-authored-by: saito-sv <[email protected]> * simplify KYC function names * unit tests passing * add route * update annotations * ensure the identity actually get's built * handle noRows in sql selects * use enum --------- Co-authored-by: saito-sv <[email protected]> Co-authored-by: frostbournesb <[email protected]>
1 parent 11f10c4 commit 09a2ebe

File tree

15 files changed

+481
-7
lines changed

15 files changed

+481
-7
lines changed

api/api.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ func heartbeat(c echo.Context) error {
3939

4040
// @host string-api.xyz
4141
// @BasePath /
42+
43+
// @SecurityDefinitions.api JWT
44+
// @Scheme bearer
45+
// @BearerFormat JWT
4246
func Start(config APIConfig) {
4347
e := echo.New()
4448
e.Validator = validator.New()

api/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func NewRepos(config APIConfig) repository.Repositories {
2323
TxLeg: repository.NewTxLeg(config.DB),
2424
Location: repository.NewLocation(config.DB),
2525
Platform: repository.NewPlatform(config.DB),
26+
Identity: repository.NewIdentity(config.DB),
2627
}
2728
}
2829

@@ -52,6 +53,8 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic
5253

5354
card := service.NewCard(repos)
5455

56+
kyc := service.NewKYC(repos)
57+
5558
return service.Services{
5659
Auth: auth,
5760
Cost: cost,
@@ -62,5 +65,6 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic
6265
Verification: verification,
6366
Device: device,
6467
Card: card,
68+
KYC: kyc,
6569
}
6670
}

api/handler/card.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func NewCard(route *echo.Echo, service service.Card) Card {
3030
// @Tags Cards
3131
// @Accept json
3232
// @Produce json
33-
// @Security ApiKeyAuth
33+
// @Security JWT
3434
// @Success 200 {object} []checkout.CardInstrument
3535
// @Failure 400 {object} error
3636
// @Failure 401 {object} error

api/handler/quotes.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func NewQuote(route *echo.Echo, service service.Transaction) Quotes {
3131
// @Tags Transactions
3232
// @Accept json
3333
// @Produce json
34-
// @Security ApiKeyAuth
34+
// @Security JWT
3535
// @Param body body model.TransactionRequest true "Transaction Request"
3636
// @Success 200 {object} model.Quote
3737
// @Failure 400 {object} error

api/handler/transact.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func NewTransaction(route *echo.Echo, service service.Transaction) Transaction {
3030
// @Tags Transactions
3131
// @Accept json
3232
// @Produce json
33-
// @Security ApiKeyAuth
33+
// @Security JWT
3434
// @Param saveCard query boolean false "do not save payment info"
3535
// @Param body body model.ExecutionRequest true "Execution Request"
3636
// @Success 200 {object} model.TransactionReceipt

api/handler/user.go

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type User interface {
2121
PreviewEmail(c echo.Context) error
2222
VerifyEmail(c echo.Context) error
2323
PreValidateEmail(c echo.Context) error
24+
GetPersonaAccountId(c echo.Context) error
2425
RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc)
2526
RegisterPrivateRoutes(g *echo.Group, ms ...echo.MiddlewareFunc)
2627
}
@@ -113,7 +114,7 @@ func (u user) Create(c echo.Context) error {
113114
// @Tags Users
114115
// @Accept json
115116
// @Produce json
116-
// @Security ApiKeyAuth
117+
// @Security JWT
117118
// @Param id path string true "User ID"
118119
// @Success 200 {object} model.UserOnboardingStatus
119120
// @Failure 401 {object} error
@@ -140,7 +141,7 @@ func (u user) Status(c echo.Context) error {
140141
// @Tags Users
141142
// @Accept json
142143
// @Produce json
143-
// @Security ApiKeyAuth
144+
// @Security JWT
144145
// @Param id path string true "User ID"
145146
// @Param body body model.UpdateUserName true "Update User Name"
146147
// @Success 200 {object} model.User
@@ -186,7 +187,7 @@ func (u user) Update(c echo.Context) error {
186187
// @Tags Users
187188
// @Accept json
188189
// @Produce json
189-
// @Security ApiKeyAuth
190+
// @Security JWT
190191
// @Param id path string true "User ID"
191192
// @Param email query string true "Email to verify"
192193
// @Success 200 {object} ResultMessage
@@ -367,6 +368,33 @@ func (u user) PreValidateEmail(c echo.Context) error {
367368
return c.JSON(http.StatusOK, ResultMessage{Status: "validated"})
368369
}
369370

371+
// @Summary Get user persona account id
372+
// @Description Get user persona account id
373+
// @Tags Users
374+
// @Accept json
375+
// @Produce json
376+
// @Security JWT
377+
// @Success 200 {object} string
378+
// @Failure 400 {object} error
379+
// @Failure 401 {object} error
380+
// @Failure 500 {object} error
381+
// @Router /users/persona-account-id [get]
382+
func (u user) GetPersonaAccountId(c echo.Context) error {
383+
ctx := c.Request().Context()
384+
385+
userId, ok := c.Get("userId").(string)
386+
if !ok {
387+
return httperror.Internal500(c, "missing or invalid userId")
388+
}
389+
390+
accountId, err := u.userService.GetPersonaAccountId(ctx, userId)
391+
if err != nil {
392+
libcommon.LogStringError(c, err, "user: get persona account id")
393+
return httperror.Internal500(c)
394+
}
395+
return c.JSON(http.StatusOK, accountId)
396+
}
397+
370398
// @Summary Get user email preview
371399
// @Description Get obscured user email
372400
// @Tags Users
@@ -428,12 +456,14 @@ func (u user) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) {
428456
g.POST("/preview-email", u.PreviewEmail, ms[0])
429457
g.POST("/verify-device", u.RequestDeviceVerification, ms[0])
430458
g.POST("/device-status", u.GetDeviceStatus, ms[0])
459+
431460
// the rest of the endpoints use the JWT auth and do not require an API Key
432461
// hence removing the first (API key) middleware
433462
ms = ms[1:]
434463

435464
g.GET("/:id/status", u.Status, ms...)
436465
g.GET("/:id/verify-email", u.VerifyEmail, ms...)
466+
g.GET("/persona-account-id", u.GetPersonaAccountId, ms...)
437467
g.PATCH("/:id", u.Update, ms...)
438468
}
439469

pkg/model/entity.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ type User struct {
2424
Email string `json:"email"`
2525
}
2626

27+
type Identity struct {
28+
Id string `json:"id,omitempty" db:"id"`
29+
Level int `json:"level" db:"level"`
30+
AccountId string `json:"accountId" db:"account_id"`
31+
UserId string `json:"userId" db:"user_id"`
32+
CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"`
33+
UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"`
34+
DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
35+
EmailVerified *time.Time `json:"emailVerified,omitempty" db:"email_verified"`
36+
PhoneVerified *time.Time `json:"phoneVerified,omitempty" db:"phone_verified"`
37+
SelfieVerified *time.Time `json:"selfieVerified,omitempty" db:"selfie_verified"`
38+
DocumentVerified *time.Time `json:"documentVerified,omitempty" db:"document_verified"`
39+
}
40+
2741
type UserWithContact struct {
2842
User
2943
Email string `db:"email"`

pkg/model/request.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ type UserUpdates struct {
7777
LastName *string `json:"lastName" db:"last_name"`
7878
}
7979

80+
type IdentityUpdates struct {
81+
Level *int `json:"level" db:"level"`
82+
AccountId *string `json:"accountId" db:"account_id"`
83+
UserId *string `json:"userId" db:"user_id"`
84+
EmailVerified *time.Time `json:"emailVerified,omitempty" db:"email_verified"`
85+
PhoneVerified *time.Time `json:"phoneVerified,omitempty" db:"phone_verified"`
86+
SelfieVerified *time.Time `json:"selfieVerified,omitempty" db:"selfie_verified"`
87+
DocumentVerified *time.Time `json:"documentVerified,omitempty" db:"document_verified"`
88+
}
89+
8090
type UserPKLogin struct {
8191
PublicAddress string `json:"publicAddress"`
8292
Signature string `json:"signature"`

pkg/repository/identity.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package repository
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"errors"
7+
"fmt"
8+
"strings"
9+
10+
libcommon "github.com/String-xyz/go-lib/v2/common"
11+
"github.com/String-xyz/go-lib/v2/database"
12+
baserepo "github.com/String-xyz/go-lib/v2/repository"
13+
"github.com/String-xyz/string-api/pkg/model"
14+
)
15+
16+
type Identity interface {
17+
Create(ctx context.Context, insert model.Identity) (identity model.Identity, err error)
18+
GetById(ctx context.Context, id string) (model.Identity, error)
19+
List(ctx context.Context, limit int, offset int) ([]model.Identity, error)
20+
Update(ctx context.Context, id string, updates any) (identity model.Identity, err error)
21+
GetByUserId(ctx context.Context, userId string) (identity model.Identity, err error)
22+
GetByAccountId(ctx context.Context, accountId string) (identity model.Identity, err error)
23+
}
24+
25+
type identity[T any] struct {
26+
baserepo.Base[T]
27+
}
28+
29+
func NewIdentity(db database.Queryable) Identity {
30+
return &identity[model.Identity]{baserepo.Base[model.Identity]{Store: db, Table: "identity"}}
31+
}
32+
33+
func (i identity[T]) Create(ctx context.Context, insert model.Identity) (identity model.Identity, err error) {
34+
query, args, err := i.Named(`
35+
INSERT INTO identity (user_id)
36+
VALUES(:user_id) RETURNING *`, insert)
37+
if err != nil {
38+
return identity, libcommon.StringError(err)
39+
}
40+
41+
err = i.Store.QueryRowxContext(ctx, query, args...).StructScan(&identity)
42+
if err != nil {
43+
return identity, libcommon.StringError(err)
44+
}
45+
46+
return identity, nil
47+
}
48+
49+
func (i identity[T]) Update(ctx context.Context, id string, updates any) (identity model.Identity, err error) {
50+
names, keyToUpdate := libcommon.KeysAndValues(updates)
51+
if len(names) == 0 {
52+
return identity, libcommon.StringError(errors.New("no updates provided"))
53+
}
54+
55+
keyToUpdate["id"] = id
56+
57+
query := fmt.Sprintf("UPDATE %s SET %s WHERE id=:id RETURNING *", i.Table, strings.Join(names, ","))
58+
namedQuery, args, err := i.Named(query, keyToUpdate)
59+
if err != nil {
60+
return identity, libcommon.StringError(err)
61+
}
62+
63+
err = i.Store.QueryRowxContext(ctx, namedQuery, args...).StructScan(&identity)
64+
if err != nil {
65+
return identity, libcommon.StringError(err)
66+
}
67+
return identity, nil
68+
}
69+
70+
func (i identity[T]) GetByUserId(ctx context.Context, userId string) (identity model.Identity, err error) {
71+
query := fmt.Sprintf("SELECT * FROM %s WHERE user_id=$1", i.Table)
72+
err = i.Store.QueryRowxContext(ctx, query, userId).StructScan(&identity)
73+
if err != nil && err == sql.ErrNoRows {
74+
return identity, libcommon.StringError(err)
75+
}
76+
return identity, nil
77+
}
78+
79+
func (i identity[T]) GetByAccountId(ctx context.Context, accountId string) (identity model.Identity, err error) {
80+
query := fmt.Sprintf("SELECT * FROM %s WHERE account_id=$1", i.Table)
81+
err = i.Store.QueryRowxContext(ctx, query, accountId).StructScan(&identity)
82+
if err != nil && err == sql.ErrNoRows {
83+
return identity, libcommon.StringError(err)
84+
}
85+
return identity, nil
86+
}

pkg/repository/repository.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ type Repositories struct {
1414
Transaction Transaction
1515
TxLeg TxLeg
1616
Location Location
17+
Identity Identity
1718
}

pkg/service/base.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ type Services struct {
1515
Unit21 Unit21
1616
Card Card
1717
Webhook Webhook
18+
KYC KYC
1819
}

pkg/service/kyc.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package service
2+
3+
import (
4+
"context"
5+
6+
"github.com/String-xyz/string-api/pkg/model"
7+
"github.com/String-xyz/string-api/pkg/repository"
8+
)
9+
10+
type KYC interface {
11+
MeetsRequirements(ctx context.Context, userId string, assetType string, cost float64) (met bool, err error)
12+
GetTransactionLevel(assetType string, cost float64) KYCLevel
13+
GetUserLevel(ctx context.Context, userId string) (level KYCLevel, err error)
14+
UpdateUserLevel(ctx context.Context, userId string) (level KYCLevel, err error)
15+
}
16+
17+
type KYCLevel int
18+
19+
const (
20+
Level0 KYCLevel = iota
21+
Level1
22+
Level2
23+
Level3
24+
)
25+
26+
type kyc struct {
27+
repos repository.Repositories
28+
}
29+
30+
func NewKYC(repos repository.Repositories) KYC {
31+
return &kyc{repos}
32+
}
33+
34+
func (k kyc) MeetsRequirements(ctx context.Context, userId string, assetType string, cost float64) (met bool, err error) {
35+
transactionLevel := k.GetTransactionLevel(assetType, cost)
36+
37+
userLevel, err := k.GetUserLevel(ctx, userId)
38+
if err != nil {
39+
return false, err
40+
}
41+
if userLevel >= transactionLevel {
42+
return true, nil
43+
} else {
44+
return false, nil
45+
}
46+
}
47+
48+
func (k kyc) GetTransactionLevel(assetType string, cost float64) KYCLevel {
49+
if assetType == "NFT" {
50+
if cost < 1000.00 {
51+
return Level1
52+
} else if cost < 5000.00 {
53+
return Level2
54+
} else {
55+
return Level3
56+
}
57+
} else {
58+
if cost < 5000.00 {
59+
return Level2
60+
} else {
61+
return Level3
62+
}
63+
}
64+
}
65+
66+
func (k kyc) GetUserLevel(ctx context.Context, userId string) (level KYCLevel, err error) {
67+
level, err = k.UpdateUserLevel(ctx, userId)
68+
if err != nil {
69+
return level, err
70+
}
71+
72+
return level, nil
73+
}
74+
75+
func (k kyc) UpdateUserLevel(ctx context.Context, userId string) (level KYCLevel, err error) {
76+
identity, err := k.repos.Identity.GetByUserId(ctx, userId)
77+
if err != nil {
78+
return level, err
79+
}
80+
81+
points := 0
82+
if identity.EmailVerified != nil {
83+
points++
84+
}
85+
if identity.PhoneVerified != nil {
86+
points++
87+
}
88+
if identity.DocumentVerified != nil {
89+
points++
90+
}
91+
if identity.SelfieVerified != nil {
92+
points++
93+
}
94+
95+
if points >= 4 {
96+
if identity.Level != int(Level2) {
97+
identity.Level = int(Level2)
98+
k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level})
99+
}
100+
} else if points >= 1 && identity.EmailVerified != nil {
101+
if identity.Level != int(Level1) {
102+
identity.Level = int(Level1)
103+
k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level})
104+
}
105+
} else if points <= 1 && identity.Level != int(Level0) {
106+
identity.Level = int(Level0)
107+
k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level})
108+
}
109+
110+
return KYCLevel(identity.Level), nil
111+
}

0 commit comments

Comments
 (0)