From e86d1d51cfe925a9a427b4c872cdff5f58ec519b Mon Sep 17 00:00:00 2001 From: Adam Shannon Date: Mon, 28 Oct 2019 12:28:38 -0500 Subject: [PATCH 1/4] cmd/server: add methods for easier checking of Customer status levels --- cmd/server/approval.go | 9 ++-- cmd/server/approval_test.go | 17 ++++--- cmd/server/customers.go | 43 ++-------------- cmd/server/customers_test.go | 30 +---------- cmd/server/status.go | 97 ++++++++++++++++++++++++++++++++++++ cmd/server/status_test.go | 89 +++++++++++++++++++++++++++++++++ 6 files changed, 202 insertions(+), 83 deletions(-) create mode 100644 cmd/server/status.go create mode 100644 cmd/server/status_test.go diff --git a/cmd/server/approval.go b/cmd/server/approval.go index 5e95da9d5..d23fb2c89 100644 --- a/cmd/server/approval.go +++ b/cmd/server/approval.go @@ -55,12 +55,9 @@ type updateCustomerStatusRequest struct { // - OFAC can only be after an OFAC search has been performed (and search info recorded) // - CIP can only be if the SSN has been set func validCustomerStatusTransition(existing *client.Customer, ssn *SSN, futureStatus CustomerStatus, repo customerRepository, ofac *ofacSearcher, requestID string) error { - eql := func(s string, status CustomerStatus) bool { - return strings.EqualFold(s, string(status)) - } - // Check Deceased and Rejected - if eql(existing.Status, CustomerStatusDeceased) || eql(existing.Status, CustomerStatusRejected) { - return fmt.Errorf("customer status '%s' cannot be changed", existing.Status) + // Reject certain Deceased and Rejected statuses + if cs, err := LiftStatus(existing.Status); err != nil || cs == nil || *cs <= CustomerStatusRejected { + return fmt.Errorf("customer status '%s' cannot be changed: %v", existing.Status, err) } switch futureStatus { case CustomerStatusKYC: diff --git a/cmd/server/approval_test.go b/cmd/server/approval_test.go index 8decb0c22..c2e5a4724 100644 --- a/cmd/server/approval_test.go +++ b/cmd/server/approval_test.go @@ -27,7 +27,8 @@ import ( func TestCustomers__updateCustomerStatus(t *testing.T) { repo := &testCustomerRepository{ customer: &client.Customer{ - ID: base.ID(), + ID: base.ID(), + Status: "none", }, } searcher := createTestOFACSearcher(repo, nil) @@ -141,7 +142,7 @@ func TestCustomers__containsValidPrimaryAddress(t *testing.T) { func TestCustomers__validCustomerStatusTransition(t *testing.T) { cust := &client.Customer{ ID: base.ID(), - Status: CustomerStatusNone, + Status: CustomerStatusNone.String(), } repo := &testCustomerRepository{} searcher := createTestOFACSearcher(repo, nil) @@ -153,18 +154,18 @@ func TestCustomers__validCustomerStatusTransition(t *testing.T) { } // block Deceased and Rejected customers - cust.Status = CustomerStatusDeceased + cust.Status = CustomerStatusDeceased.String() if err := validCustomerStatusTransition(cust, ssn, CustomerStatusKYC, repo, searcher, "requestID"); err == nil { t.Error("expected error") } - cust.Status = CustomerStatusRejected + cust.Status = CustomerStatusRejected.String() if err := validCustomerStatusTransition(cust, ssn, CustomerStatusKYC, repo, searcher, "requestID"); err == nil { t.Error("expected error") } // normal KYC approval (rejected due to missing info) cust.FirstName, cust.LastName = "Jane", "Doe" - cust.Status = CustomerStatusReviewRequired + cust.Status = CustomerStatusReviewRequired.String() if err := validCustomerStatusTransition(cust, ssn, CustomerStatusKYC, repo, searcher, "requestID"); err == nil { t.Error("expected error") } @@ -178,7 +179,7 @@ func TestCustomers__validCustomerStatusTransition(t *testing.T) { }) // CIP transistions are WIP // TODO(adam): - cust.Status = CustomerStatusReviewRequired + cust.Status = CustomerStatusReviewRequired.String() if err := validCustomerStatusTransition(cust, nil, CustomerStatusCIP, repo, searcher, "requestID"); err != nil { if !strings.Contains(err.Error(), "is missing SSN") { t.Errorf("CIP: unexpected error: %v", err) @@ -194,7 +195,7 @@ func TestCustomers__validCustomerStatusTransition(t *testing.T) { func TestCustomers__validCustomerStatusTransitionError(t *testing.T) { cust := &client.Customer{ ID: base.ID(), - Status: CustomerStatusReviewRequired, + Status: CustomerStatusReviewRequired.String(), } repo := &testCustomerRepository{} ofacClient := &testOFACClient{} @@ -217,7 +218,7 @@ func TestCustomers__validCustomerStatusTransitionError(t *testing.T) { func TestCustomers__validCustomerStatusTransitionOFAC(t *testing.T) { cust := &client.Customer{ ID: base.ID(), - Status: CustomerStatusReviewRequired, + Status: CustomerStatusReviewRequired.String(), } repo := &testCustomerRepository{} searcher := createTestOFACSearcher(repo, nil) diff --git a/cmd/server/customers.go b/cmd/server/customers.go index 8192f4d58..bf1007240 100644 --- a/cmd/server/customers.go +++ b/cmd/server/customers.go @@ -26,43 +26,6 @@ var ( errNoCustomerID = errors.New("no Customer ID found") ) -type CustomerStatus string - -const ( - CustomerStatusDeceased = "deceased" - CustomerStatusRejected = "rejected" - CustomerStatusNone = "none" - CustomerStatusReviewRequired = "reviewrequired" - CustomerStatusKYC = "kyc" - CustomerStatusOFAC = "ofac" - CustomerStatusCIP = "cip" -) - -func (cs CustomerStatus) validate() error { - switch cs { - case CustomerStatusDeceased, CustomerStatusRejected: - return nil - case CustomerStatusReviewRequired, CustomerStatusNone: - return nil - case CustomerStatusKYC, CustomerStatusOFAC, CustomerStatusCIP: - return nil - default: - return fmt.Errorf("CustomerStatus(%s) is invalid", cs) - } -} - -func (cs *CustomerStatus) UnmarshalJSON(b []byte) error { - var s string - if err := json.Unmarshal(b, &s); err != nil { - return err - } - *cs = CustomerStatus(strings.TrimSpace(strings.ToLower(s))) - if err := cs.validate(); err != nil { - return err - } - return nil -} - func addCustomerRoutes(logger log.Logger, r *mux.Router, repo customerRepository, customerSSNStorage *ssnStorage, ofac *ofacSearcher) { r.Methods("GET").Path("/customers/{customerID}").HandlerFunc(getCustomer(logger, repo)) r.Methods("POST").Path("/customers").HandlerFunc(createCustomer(logger, repo, customerSSNStorage, ofac)) @@ -186,7 +149,7 @@ func (req customerRequest) asCustomer(storage *ssnStorage) (*client.Customer, *S Suffix: req.Suffix, BirthDate: req.BirthDate, Email: req.Email, - Status: CustomerStatusNone, + Status: CustomerStatusNone.String(), Metadata: req.Metadata, } for i := range req.Phones { @@ -508,7 +471,7 @@ func (r *sqlCustomerRepository) updateCustomerStatus(customerID string, status C if err != nil { return fmt.Errorf("updateCustomerStatus: update customers prepare: %v", err) } - if _, err := stmt.Exec(status, customerID); err != nil { + if _, err := stmt.Exec(status.String(), customerID); err != nil { stmt.Close() return fmt.Errorf("updateCustomerStatus: update customers exec: %v", err) } @@ -521,7 +484,7 @@ func (r *sqlCustomerRepository) updateCustomerStatus(customerID string, status C return fmt.Errorf("updateCustomerStatus: insert status prepare: %v", err) } defer stmt.Close() - if _, err := stmt.Exec(customerID, status, comment, time.Now()); err != nil { + if _, err := stmt.Exec(customerID, status.String(), comment, time.Now()); err != nil { return fmt.Errorf("updateCustomerStatus: insert status exec: %v", err) } return tx.Commit() diff --git a/cmd/server/customers_test.go b/cmd/server/customers_test.go index 567ff076c..d1c9f0ab6 100644 --- a/cmd/server/customers_test.go +++ b/cmd/server/customers_test.go @@ -92,34 +92,6 @@ func TestCustomers__getCustomerID(t *testing.T) { } } -func TestCustomerStatus__json(t *testing.T) { - cs := CustomerStatus("invalid") - valid := map[string]CustomerStatus{ - "deCEAsed": CustomerStatusDeceased, - "Rejected": CustomerStatusRejected, - "ReviewRequired": CustomerStatusReviewRequired, - "NONE": CustomerStatusNone, - "KYC": CustomerStatusKYC, - "ofaC": CustomerStatusOFAC, - "cip": CustomerStatusCIP, - } - for k, v := range valid { - in := []byte(fmt.Sprintf(`"%v"`, k)) - if err := json.Unmarshal(in, &cs); err != nil { - t.Error(err.Error()) - } - if cs != v { - t.Errorf("got cs=%#v, v=%#v", cs, v) - } - } - - // make sure other values fail - in := []byte(fmt.Sprintf(`"%v"`, base.ID())) - if err := json.Unmarshal(in, &cs); err == nil { - t.Error("expected error") - } -} - func TestCustomers__formatCustomerName(t *testing.T) { if out := formatCustomerName(nil); out != "" { t.Errorf("got %q", out) @@ -402,7 +374,7 @@ func TestCustomerRepository__updateCustomerStatus(t *testing.T) { if err != nil { t.Fatal(err) } - if customer.Status != CustomerStatusKYC { + if customer.Status != CustomerStatusKYC.String() { t.Errorf("unexpected status: %s", customer.Status) } } diff --git a/cmd/server/status.go b/cmd/server/status.go new file mode 100644 index 000000000..1ae7560da --- /dev/null +++ b/cmd/server/status.go @@ -0,0 +1,97 @@ +// Copyright 2019 The Moov Authors +// Use of this source code is governed by an Apache License +// license that can be found in the LICENSE file. + +package main + +import ( + "encoding/json" + "fmt" + "strings" +) + +type CustomerStatus int + +const ( + CustomerStatusDeceased CustomerStatus = iota + CustomerStatusRejected + CustomerStatusNone + CustomerStatusReviewRequired + CustomerStatusKYC + CustomerStatusOFAC + CustomerStatusCIP +) + +var ( + customerStatusStrings = []string{"deceased", "rejected", "none", "reviewrequired", "kyc", "ofac", "cip"} +) + +func (cs CustomerStatus) validate() error { + switch cs { + case CustomerStatusDeceased, CustomerStatusRejected: + return nil + case CustomerStatusReviewRequired, CustomerStatusNone: + return nil + case CustomerStatusKYC, CustomerStatusOFAC, CustomerStatusCIP: + return nil + default: + return fmt.Errorf("CustomerStatus(%v) is invalid", cs) + } +} + +func (cs CustomerStatus) String() string { + if cs < CustomerStatusDeceased || cs > CustomerStatusCIP { + return "unknown" + } + return customerStatusStrings[int(cs)] +} + +func (cs *CustomerStatus) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + cs.fromString(s) + if err := cs.validate(); err != nil { + return err + } + return nil +} + +func (cs *CustomerStatus) fromString(s string) { + for i := range customerStatusStrings { + if strings.EqualFold(s, customerStatusStrings[i]) { + *cs = CustomerStatus(i) + return + } + } + *cs = CustomerStatus(-1) +} + +// LiftStatus will attempt to return an enum value of CustomerStatus after reading +// the string value. +func LiftStatus(str string) (*CustomerStatus, error) { + var cs CustomerStatus + cs.fromString(str) + if err := cs.validate(); err != nil { + return nil, err + } + return &cs, nil +} + +// ApprovedAt returns true only if the customerStatus is higher than ReviewRequired +// and is at least the minimum status. It's used to ensure a specific customer is at least +// KYC, OFAC, or CIP in applications. +func (cs CustomerStatus) ApprovedAt(minimum CustomerStatus) bool { + return ApprovedAt(cs, minimum) +} + +// ApprovedAt returns true only if the customerStatus is higher than ReviewRequired +// and is at least the minimum status. It's used to ensure a specific customer is at least +// KYC, OFAC, or CIP in applications. +func ApprovedAt(customerStatus CustomerStatus, minimum CustomerStatus) bool { + if customerStatus <= CustomerStatusReviewRequired { + return false // any status below ReveiewRequired is never approved + } + return customerStatus >= minimum +} diff --git a/cmd/server/status_test.go b/cmd/server/status_test.go new file mode 100644 index 000000000..b2bc07350 --- /dev/null +++ b/cmd/server/status_test.go @@ -0,0 +1,89 @@ +// Copyright 2019 The Moov Authors +// Use of this source code is governed by an Apache License +// license that can be found in the LICENSE file. + +package main + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/moov-io/base" +) + +func TestCustomerStatus__json(t *testing.T) { + cs := CustomerStatus(10) + valid := map[string]CustomerStatus{ + "deCEAsed": CustomerStatusDeceased, + "Rejected": CustomerStatusRejected, + "ReviewRequired": CustomerStatusReviewRequired, + "NONE": CustomerStatusNone, + "KYC": CustomerStatusKYC, + "ofaC": CustomerStatusOFAC, + "cip": CustomerStatusCIP, + } + for k, v := range valid { + in := []byte(fmt.Sprintf(`"%s"`, k)) + if err := json.Unmarshal(in, &cs); err != nil { + t.Error(err.Error()) + } + if cs != v { + t.Errorf("got cs=%#v, v=%#v", cs, v) + } + } + + // make sure other values fail + in := []byte(fmt.Sprintf(`"%v"`, base.ID())) + if err := json.Unmarshal(in, &cs); err == nil { + t.Error("expected error") + } +} + +func TestCustomerStatus__string(t *testing.T) { + if v := CustomerStatusOFAC.String(); v != "ofac" { + t.Errorf("got %s", v) + } + if v := CustomerStatusDeceased.String(); v != "deceased" { + t.Errorf("got %s", v) + } +} + +func TestCustomerStatus__liftStatus(t *testing.T) { + if cs, err := LiftStatus("kyc"); *cs != CustomerStatusKYC || err != nil { + t.Errorf("got %s error=%v", cs, err) + } + if cs, err := LiftStatus("none"); *cs != CustomerStatusNone || err != nil { + t.Errorf("got %s error=%v", cs, err) + } + if cs, err := LiftStatus("cip"); *cs != CustomerStatusCIP || err != nil { + t.Errorf("got %s error=%v", cs, err) + } +} + +func TestCustomerStatus__approvedAt(t *testing.T) { + // authorized + if !ApprovedAt(CustomerStatusOFAC, CustomerStatusOFAC) { + t.Errorf("expected ApprovedAt") + } + if !ApprovedAt(CustomerStatusOFAC, CustomerStatusKYC) { + t.Errorf("expected ApprovedAt") + } + if !ApprovedAt(CustomerStatusCIP, CustomerStatusKYC) { + t.Errorf("expected ApprovedAt") + } + + // not authorized + if ApprovedAt(CustomerStatusReviewRequired, CustomerStatusReviewRequired) { + t.Errorf("expected not ApprovedAt") + } + if ApprovedAt(CustomerStatusNone, CustomerStatusOFAC) { + t.Errorf("expected not ApprovedAt") + } + if ApprovedAt(CustomerStatusOFAC, CustomerStatusCIP) { + t.Errorf("expected not ApprovedAt") + } + if ApprovedAt(CustomerStatusRejected, CustomerStatusOFAC) { + t.Errorf("expected not ApprovedAt") + } +} From 47cf044cb2e0f67f54044175804832f2e3285997 Mon Sep 17 00:00:00 2001 From: Adam Shannon Date: Mon, 28 Oct 2019 12:29:47 -0500 Subject: [PATCH 2/4] cmd/server: drop email requirement in transistion to KYC --- cmd/server/approval.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/cmd/server/approval.go b/cmd/server/approval.go index d23fb2c89..6904f598b 100644 --- a/cmd/server/approval.go +++ b/cmd/server/approval.go @@ -67,9 +67,6 @@ func validCustomerStatusTransition(existing *client.Customer, ssn *SSN, futureSt if existing.BirthDate.IsZero() { return fmt.Errorf("customer=%s is missing date of birth", existing.ID) } - if existing.Email == "" { - return fmt.Errorf("customer=%s is missing an email address", existing.ID) - } if !containsValidPrimaryAddress(existing.Addresses) { return fmt.Errorf("customer=%s is missing a valid primary Address", existing.ID) } From 73329957cca891270828fe2c3a2727e41ee809fe Mon Sep 17 00:00:00 2001 From: Adam Shannon Date: Mon, 28 Oct 2019 12:29:56 -0500 Subject: [PATCH 3/4] cmd/server: let nil *SSN into status transistion checks --- cmd/server/approval.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/server/approval.go b/cmd/server/approval.go index 6904f598b..f4be4035a 100644 --- a/cmd/server/approval.go +++ b/cmd/server/approval.go @@ -5,6 +5,7 @@ package main import ( + "database/sql" "encoding/json" "errors" "fmt" @@ -135,7 +136,7 @@ func updateCustomerStatus(logger log.Logger, repo customerRepository, customerSS } ssn, err := customerSSNRepo.getCustomerSSN(customerID) - if err != nil { + if err != nil && err != sql.ErrNoRows { moovhttp.Problem(w, err) return } From 045cce0cdccbded6caf9533c662d123b59cdc2ec Mon Sep 17 00:00:00 2001 From: Adam Shannon Date: Mon, 28 Oct 2019 13:00:00 -0500 Subject: [PATCH 4/4] cmd/server: factor out 'CustomerStatus' by using root package customers.CustomerStatusKYC is verbose, so let's offer customers.KYC instead. --- cmd/server/approval.go | 15 +++--- cmd/server/approval_test.go | 41 +++++++------- cmd/server/customers.go | 7 +-- cmd/server/customers_test.go | 9 ++-- cmd/server/status_test.go | 89 ------------------------------- cmd/server/status.go => status.go | 50 ++++++++--------- status_test.go | 89 +++++++++++++++++++++++++++++++ 7 files changed, 152 insertions(+), 148 deletions(-) delete mode 100644 cmd/server/status_test.go rename cmd/server/status.go => status.go (59%) create mode 100644 status_test.go diff --git a/cmd/server/approval.go b/cmd/server/approval.go index f4be4035a..77db3594c 100644 --- a/cmd/server/approval.go +++ b/cmd/server/approval.go @@ -16,6 +16,7 @@ import ( "github.com/moov-io/base/admin" moovhttp "github.com/moov-io/base/http" + "github.com/moov-io/customers" client "github.com/moov-io/customers/client" "github.com/go-kit/kit/log" @@ -45,8 +46,8 @@ func addApprovalRoutes(logger log.Logger, svc *admin.Server, repo customerReposi } type updateCustomerStatusRequest struct { - Comment string `json:"comment,omitempty"` - Status CustomerStatus `json:"status"` + Comment string `json:"comment,omitempty"` + Status customers.Status `json:"status"` } // validCustomerStatusTransition determines if a future CustomerStatus is valid for a given @@ -55,13 +56,13 @@ type updateCustomerStatusRequest struct { // - KYC is only valid if the Customer has first, last, address, and date of birth // - OFAC can only be after an OFAC search has been performed (and search info recorded) // - CIP can only be if the SSN has been set -func validCustomerStatusTransition(existing *client.Customer, ssn *SSN, futureStatus CustomerStatus, repo customerRepository, ofac *ofacSearcher, requestID string) error { +func validCustomerStatusTransition(existing *client.Customer, ssn *SSN, futureStatus customers.Status, repo customerRepository, ofac *ofacSearcher, requestID string) error { // Reject certain Deceased and Rejected statuses - if cs, err := LiftStatus(existing.Status); err != nil || cs == nil || *cs <= CustomerStatusRejected { + if cs, err := customers.LiftStatus(existing.Status); err != nil || cs == nil || *cs <= customers.Rejected { return fmt.Errorf("customer status '%s' cannot be changed: %v", existing.Status, err) } switch futureStatus { - case CustomerStatusKYC: + case customers.KYC: if existing.FirstName == "" || existing.LastName == "" { return fmt.Errorf("customer=%s is missing fist/last name", existing.ID) } @@ -71,7 +72,7 @@ func validCustomerStatusTransition(existing *client.Customer, ssn *SSN, futureSt if !containsValidPrimaryAddress(existing.Addresses) { return fmt.Errorf("customer=%s is missing a valid primary Address", existing.ID) } - case CustomerStatusOFAC: + case customers.OFAC: searchResult, err := repo.getLatestCustomerOFACSearch(existing.ID) if err != nil { return fmt.Errorf("validCustomerStatusTransition: error getting OFAC search: %v", err) @@ -89,7 +90,7 @@ func validCustomerStatusTransition(existing *client.Customer, ssn *SSN, futureSt return fmt.Errorf("validCustomerStatusTransition: customer=%s has positive OFAC match (%.2f) with SDN=%s", existing.ID, searchResult.match, searchResult.entityId) } return nil - case CustomerStatusCIP: // TODO(adam): need to impl lookup + case customers.CIP: // TODO(adam): need to impl lookup // What can we do to validate an SSN? // https://www.ssa.gov/employer/randomization.html (not much) if ssn == nil || len(ssn.encrypted) == 0 { diff --git a/cmd/server/approval_test.go b/cmd/server/approval_test.go index c2e5a4724..2f9e060d1 100644 --- a/cmd/server/approval_test.go +++ b/cmd/server/approval_test.go @@ -17,6 +17,7 @@ import ( "github.com/moov-io/base" "github.com/moov-io/base/admin" + "github.com/moov-io/customers" client "github.com/moov-io/customers/client" ofac "github.com/moov-io/ofac/client" @@ -64,7 +65,7 @@ func TestCustomers__updateCustomerStatus(t *testing.T) { if customer.ID == "" { t.Errorf("missing customer JSON: %#v", customer) } - if repo.updatedStatus != CustomerStatusReviewRequired { + if repo.updatedStatus != customers.ReviewRequired { t.Errorf("unexpected status: %s", repo.updatedStatus) } } @@ -142,35 +143,35 @@ func TestCustomers__containsValidPrimaryAddress(t *testing.T) { func TestCustomers__validCustomerStatusTransition(t *testing.T) { cust := &client.Customer{ ID: base.ID(), - Status: CustomerStatusNone.String(), + Status: customers.None.String(), } repo := &testCustomerRepository{} searcher := createTestOFACSearcher(repo, nil) ssn := &SSN{customerID: cust.ID, encrypted: []byte("secret")} - if err := validCustomerStatusTransition(cust, ssn, CustomerStatusDeceased, repo, searcher, "requestID"); err != nil { + if err := validCustomerStatusTransition(cust, ssn, customers.Deceased, repo, searcher, "requestID"); err != nil { t.Errorf("expected no error: %v", err) } // block Deceased and Rejected customers - cust.Status = CustomerStatusDeceased.String() - if err := validCustomerStatusTransition(cust, ssn, CustomerStatusKYC, repo, searcher, "requestID"); err == nil { + cust.Status = customers.Deceased.String() + if err := validCustomerStatusTransition(cust, ssn, customers.KYC, repo, searcher, "requestID"); err == nil { t.Error("expected error") } - cust.Status = CustomerStatusRejected.String() - if err := validCustomerStatusTransition(cust, ssn, CustomerStatusKYC, repo, searcher, "requestID"); err == nil { + cust.Status = customers.Rejected.String() + if err := validCustomerStatusTransition(cust, ssn, customers.KYC, repo, searcher, "requestID"); err == nil { t.Error("expected error") } // normal KYC approval (rejected due to missing info) cust.FirstName, cust.LastName = "Jane", "Doe" - cust.Status = CustomerStatusReviewRequired.String() - if err := validCustomerStatusTransition(cust, ssn, CustomerStatusKYC, repo, searcher, "requestID"); err == nil { + cust.Status = customers.ReviewRequired.String() + if err := validCustomerStatusTransition(cust, ssn, customers.KYC, repo, searcher, "requestID"); err == nil { t.Error("expected error") } cust.BirthDate = time.Now() - if err := validCustomerStatusTransition(cust, ssn, CustomerStatusKYC, repo, searcher, "requestID"); err == nil { + if err := validCustomerStatusTransition(cust, ssn, customers.KYC, repo, searcher, "requestID"); err == nil { t.Error("expected error") } cust.Addresses = append(cust.Addresses, client.Address{ @@ -179,15 +180,15 @@ func TestCustomers__validCustomerStatusTransition(t *testing.T) { }) // CIP transistions are WIP // TODO(adam): - cust.Status = CustomerStatusReviewRequired.String() - if err := validCustomerStatusTransition(cust, nil, CustomerStatusCIP, repo, searcher, "requestID"); err != nil { + cust.Status = customers.ReviewRequired.String() + if err := validCustomerStatusTransition(cust, nil, customers.CIP, repo, searcher, "requestID"); err != nil { if !strings.Contains(err.Error(), "is missing SSN") { t.Errorf("CIP: unexpected error: %v", err) } } else { t.Error("CIP transition is WIP") } - if err := validCustomerStatusTransition(cust, ssn, CustomerStatusCIP, repo, searcher, "requestID"); err == nil { + if err := validCustomerStatusTransition(cust, ssn, customers.CIP, repo, searcher, "requestID"); err == nil { t.Error("CIP transition is WIP") } } @@ -195,7 +196,7 @@ func TestCustomers__validCustomerStatusTransition(t *testing.T) { func TestCustomers__validCustomerStatusTransitionError(t *testing.T) { cust := &client.Customer{ ID: base.ID(), - Status: CustomerStatusReviewRequired.String(), + Status: customers.ReviewRequired.String(), } repo := &testCustomerRepository{} ofacClient := &testOFACClient{} @@ -204,13 +205,13 @@ func TestCustomers__validCustomerStatusTransitionError(t *testing.T) { ssn := &SSN{customerID: cust.ID, encrypted: []byte("secret")} repo.err = errors.New("bad error") - if err := validCustomerStatusTransition(cust, ssn, CustomerStatusOFAC, repo, searcher, ""); err == nil { + if err := validCustomerStatusTransition(cust, ssn, customers.OFAC, repo, searcher, ""); err == nil { t.Error("expected error, but got none") } repo.err = nil ofacClient.err = errors.New("bad error") - if err := validCustomerStatusTransition(cust, ssn, CustomerStatusOFAC, repo, searcher, ""); err == nil { + if err := validCustomerStatusTransition(cust, ssn, customers.OFAC, repo, searcher, ""); err == nil { t.Error("expected error, but got none") } } @@ -218,7 +219,7 @@ func TestCustomers__validCustomerStatusTransitionError(t *testing.T) { func TestCustomers__validCustomerStatusTransitionOFAC(t *testing.T) { cust := &client.Customer{ ID: base.ID(), - Status: CustomerStatusReviewRequired.String(), + Status: customers.ReviewRequired.String(), } repo := &testCustomerRepository{} searcher := createTestOFACSearcher(repo, nil) @@ -229,13 +230,13 @@ func TestCustomers__validCustomerStatusTransitionOFAC(t *testing.T) { sdnName: "Jane Doe", match: 0.10, } - if err := validCustomerStatusTransition(cust, ssn, CustomerStatusOFAC, repo, searcher, "requestID"); err != nil { + if err := validCustomerStatusTransition(cust, ssn, customers.OFAC, repo, searcher, "requestID"); err != nil { t.Errorf("unexpected error in OFAC transition: %v", err) } // OFAC transition with positive match repo.ofacSearchResult.match = 0.99 - if err := validCustomerStatusTransition(cust, ssn, CustomerStatusOFAC, repo, searcher, "requestID"); err != nil { + if err := validCustomerStatusTransition(cust, ssn, customers.OFAC, repo, searcher, "requestID"); err != nil { if !strings.Contains(err.Error(), "positive OFAC match") { t.Errorf("unexpected error in OFAC transition: %v", err) } @@ -248,7 +249,7 @@ func TestCustomers__validCustomerStatusTransitionOFAC(t *testing.T) { EntityID: "12124", } } - if err := validCustomerStatusTransition(cust, ssn, CustomerStatusOFAC, repo, searcher, "requestID"); err != nil { + if err := validCustomerStatusTransition(cust, ssn, customers.OFAC, repo, searcher, "requestID"); err != nil { t.Errorf("unexpected error: %v", err) } if repo.savedOFACSearchResult.entityId != "12124" { diff --git a/cmd/server/customers.go b/cmd/server/customers.go index bf1007240..a134497ca 100644 --- a/cmd/server/customers.go +++ b/cmd/server/customers.go @@ -16,6 +16,7 @@ import ( "github.com/moov-io/base" moovhttp "github.com/moov-io/base/http" + "github.com/moov-io/customers" client "github.com/moov-io/customers/client" "github.com/go-kit/kit/log" @@ -149,7 +150,7 @@ func (req customerRequest) asCustomer(storage *ssnStorage) (*client.Customer, *S Suffix: req.Suffix, BirthDate: req.BirthDate, Email: req.Email, - Status: CustomerStatusNone.String(), + Status: customers.None.String(), Metadata: req.Metadata, } for i := range req.Phones { @@ -291,7 +292,7 @@ func addCustomerAddress(logger log.Logger, repo customerRepository) http.Handler type customerRepository interface { getCustomer(customerID string) (*client.Customer, error) createCustomer(c *client.Customer) error - updateCustomerStatus(customerID string, status CustomerStatus, comment string) error + updateCustomerStatus(customerID string, status customers.Status, comment string) error getCustomerMetadata(customerID string) (map[string]string, error) replaceCustomerMetadata(customerID string, metadata map[string]string) error @@ -459,7 +460,7 @@ func (r *sqlCustomerRepository) readAddresses(customerID string) ([]client.Addre return adds, rows.Err() } -func (r *sqlCustomerRepository) updateCustomerStatus(customerID string, status CustomerStatus, comment string) error { +func (r *sqlCustomerRepository) updateCustomerStatus(customerID string, status customers.Status, comment string) error { tx, err := r.db.Begin() if err != nil { return fmt.Errorf("updateCustomerStatus: tx begin: %v", err) diff --git a/cmd/server/customers_test.go b/cmd/server/customers_test.go index d1c9f0ab6..81957d589 100644 --- a/cmd/server/customers_test.go +++ b/cmd/server/customers_test.go @@ -18,6 +18,7 @@ import ( "github.com/moov-io/customers/internal/database" "github.com/moov-io/base" + "github.com/moov-io/customers" client "github.com/moov-io/customers/client" "github.com/go-kit/kit/log" @@ -30,7 +31,7 @@ type testCustomerRepository struct { ofacSearchResult *ofacSearchResult createdCustomer *client.Customer - updatedStatus CustomerStatus + updatedStatus customers.Status savedOFACSearchResult *ofacSearchResult } @@ -46,7 +47,7 @@ func (r *testCustomerRepository) createCustomer(c *client.Customer) error { return r.err } -func (r *testCustomerRepository) updateCustomerStatus(customerID string, status CustomerStatus, comment string) error { +func (r *testCustomerRepository) updateCustomerStatus(customerID string, status customers.Status, comment string) error { r.updatedStatus = status return r.err } @@ -365,7 +366,7 @@ func TestCustomerRepository__updateCustomerStatus(t *testing.T) { } // update status - if err := repo.updateCustomerStatus(cust.ID, CustomerStatusKYC, "test comment"); err != nil { + if err := repo.updateCustomerStatus(cust.ID, customers.KYC, "test comment"); err != nil { t.Fatal(err) } @@ -374,7 +375,7 @@ func TestCustomerRepository__updateCustomerStatus(t *testing.T) { if err != nil { t.Fatal(err) } - if customer.Status != CustomerStatusKYC.String() { + if customer.Status != customers.KYC.String() { t.Errorf("unexpected status: %s", customer.Status) } } diff --git a/cmd/server/status_test.go b/cmd/server/status_test.go deleted file mode 100644 index b2bc07350..000000000 --- a/cmd/server/status_test.go +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright 2019 The Moov Authors -// Use of this source code is governed by an Apache License -// license that can be found in the LICENSE file. - -package main - -import ( - "encoding/json" - "fmt" - "testing" - - "github.com/moov-io/base" -) - -func TestCustomerStatus__json(t *testing.T) { - cs := CustomerStatus(10) - valid := map[string]CustomerStatus{ - "deCEAsed": CustomerStatusDeceased, - "Rejected": CustomerStatusRejected, - "ReviewRequired": CustomerStatusReviewRequired, - "NONE": CustomerStatusNone, - "KYC": CustomerStatusKYC, - "ofaC": CustomerStatusOFAC, - "cip": CustomerStatusCIP, - } - for k, v := range valid { - in := []byte(fmt.Sprintf(`"%s"`, k)) - if err := json.Unmarshal(in, &cs); err != nil { - t.Error(err.Error()) - } - if cs != v { - t.Errorf("got cs=%#v, v=%#v", cs, v) - } - } - - // make sure other values fail - in := []byte(fmt.Sprintf(`"%v"`, base.ID())) - if err := json.Unmarshal(in, &cs); err == nil { - t.Error("expected error") - } -} - -func TestCustomerStatus__string(t *testing.T) { - if v := CustomerStatusOFAC.String(); v != "ofac" { - t.Errorf("got %s", v) - } - if v := CustomerStatusDeceased.String(); v != "deceased" { - t.Errorf("got %s", v) - } -} - -func TestCustomerStatus__liftStatus(t *testing.T) { - if cs, err := LiftStatus("kyc"); *cs != CustomerStatusKYC || err != nil { - t.Errorf("got %s error=%v", cs, err) - } - if cs, err := LiftStatus("none"); *cs != CustomerStatusNone || err != nil { - t.Errorf("got %s error=%v", cs, err) - } - if cs, err := LiftStatus("cip"); *cs != CustomerStatusCIP || err != nil { - t.Errorf("got %s error=%v", cs, err) - } -} - -func TestCustomerStatus__approvedAt(t *testing.T) { - // authorized - if !ApprovedAt(CustomerStatusOFAC, CustomerStatusOFAC) { - t.Errorf("expected ApprovedAt") - } - if !ApprovedAt(CustomerStatusOFAC, CustomerStatusKYC) { - t.Errorf("expected ApprovedAt") - } - if !ApprovedAt(CustomerStatusCIP, CustomerStatusKYC) { - t.Errorf("expected ApprovedAt") - } - - // not authorized - if ApprovedAt(CustomerStatusReviewRequired, CustomerStatusReviewRequired) { - t.Errorf("expected not ApprovedAt") - } - if ApprovedAt(CustomerStatusNone, CustomerStatusOFAC) { - t.Errorf("expected not ApprovedAt") - } - if ApprovedAt(CustomerStatusOFAC, CustomerStatusCIP) { - t.Errorf("expected not ApprovedAt") - } - if ApprovedAt(CustomerStatusRejected, CustomerStatusOFAC) { - t.Errorf("expected not ApprovedAt") - } -} diff --git a/cmd/server/status.go b/status.go similarity index 59% rename from cmd/server/status.go rename to status.go index 1ae7560da..3e51ad5e0 100644 --- a/cmd/server/status.go +++ b/status.go @@ -2,7 +2,7 @@ // Use of this source code is governed by an Apache License // license that can be found in the LICENSE file. -package main +package customers import ( "encoding/json" @@ -10,43 +10,43 @@ import ( "strings" ) -type CustomerStatus int +type Status int const ( - CustomerStatusDeceased CustomerStatus = iota - CustomerStatusRejected - CustomerStatusNone - CustomerStatusReviewRequired - CustomerStatusKYC - CustomerStatusOFAC - CustomerStatusCIP + Deceased Status = iota + Rejected + None + ReviewRequired + KYC + OFAC + CIP ) var ( customerStatusStrings = []string{"deceased", "rejected", "none", "reviewrequired", "kyc", "ofac", "cip"} ) -func (cs CustomerStatus) validate() error { +func (cs Status) validate() error { switch cs { - case CustomerStatusDeceased, CustomerStatusRejected: + case Deceased, Rejected: return nil - case CustomerStatusReviewRequired, CustomerStatusNone: + case ReviewRequired, None: return nil - case CustomerStatusKYC, CustomerStatusOFAC, CustomerStatusCIP: + case KYC, OFAC, CIP: return nil default: - return fmt.Errorf("CustomerStatus(%v) is invalid", cs) + return fmt.Errorf("status '%v' is invalid", cs) } } -func (cs CustomerStatus) String() string { - if cs < CustomerStatusDeceased || cs > CustomerStatusCIP { +func (cs Status) String() string { + if cs < Deceased || cs > CIP { return "unknown" } return customerStatusStrings[int(cs)] } -func (cs *CustomerStatus) UnmarshalJSON(b []byte) error { +func (cs *Status) UnmarshalJSON(b []byte) error { var s string if err := json.Unmarshal(b, &s); err != nil { return err @@ -58,20 +58,20 @@ func (cs *CustomerStatus) UnmarshalJSON(b []byte) error { return nil } -func (cs *CustomerStatus) fromString(s string) { +func (cs *Status) fromString(s string) { for i := range customerStatusStrings { if strings.EqualFold(s, customerStatusStrings[i]) { - *cs = CustomerStatus(i) + *cs = Status(i) return } } - *cs = CustomerStatus(-1) + *cs = Status(-1) } // LiftStatus will attempt to return an enum value of CustomerStatus after reading // the string value. -func LiftStatus(str string) (*CustomerStatus, error) { - var cs CustomerStatus +func LiftStatus(str string) (*Status, error) { + var cs Status cs.fromString(str) if err := cs.validate(); err != nil { return nil, err @@ -82,15 +82,15 @@ func LiftStatus(str string) (*CustomerStatus, error) { // ApprovedAt returns true only if the customerStatus is higher than ReviewRequired // and is at least the minimum status. It's used to ensure a specific customer is at least // KYC, OFAC, or CIP in applications. -func (cs CustomerStatus) ApprovedAt(minimum CustomerStatus) bool { +func (cs Status) ApprovedAt(minimum Status) bool { return ApprovedAt(cs, minimum) } // ApprovedAt returns true only if the customerStatus is higher than ReviewRequired // and is at least the minimum status. It's used to ensure a specific customer is at least // KYC, OFAC, or CIP in applications. -func ApprovedAt(customerStatus CustomerStatus, minimum CustomerStatus) bool { - if customerStatus <= CustomerStatusReviewRequired { +func ApprovedAt(customerStatus Status, minimum Status) bool { + if customerStatus <= ReviewRequired { return false // any status below ReveiewRequired is never approved } return customerStatus >= minimum diff --git a/status_test.go b/status_test.go new file mode 100644 index 000000000..1c12257a1 --- /dev/null +++ b/status_test.go @@ -0,0 +1,89 @@ +// Copyright 2019 The Moov Authors +// Use of this source code is governed by an Apache License +// license that can be found in the LICENSE file. + +package customers + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/moov-io/base" +) + +func TestStatus__json(t *testing.T) { + cs := Status(10) + valid := map[string]Status{ + "deCEAsed": Deceased, + "Rejected": Rejected, + "ReviewRequired": ReviewRequired, + "NONE": None, + "KYC": KYC, + "ofaC": OFAC, + "cip": CIP, + } + for k, v := range valid { + in := []byte(fmt.Sprintf(`"%s"`, k)) + if err := json.Unmarshal(in, &cs); err != nil { + t.Error(err.Error()) + } + if cs != v { + t.Errorf("got cs=%#v, v=%#v", cs, v) + } + } + + // make sure other values fail + in := []byte(fmt.Sprintf(`"%v"`, base.ID())) + if err := json.Unmarshal(in, &cs); err == nil { + t.Error("expected error") + } +} + +func TestStatus__string(t *testing.T) { + if v := OFAC.String(); v != "ofac" { + t.Errorf("got %s", v) + } + if v := Deceased.String(); v != "deceased" { + t.Errorf("got %s", v) + } +} + +func TestStatus__liftStatus(t *testing.T) { + if cs, err := LiftStatus("kyc"); *cs != KYC || err != nil { + t.Errorf("got %s error=%v", cs, err) + } + if cs, err := LiftStatus("none"); *cs != None || err != nil { + t.Errorf("got %s error=%v", cs, err) + } + if cs, err := LiftStatus("cip"); *cs != CIP || err != nil { + t.Errorf("got %s error=%v", cs, err) + } +} + +func TestStatus__approvedAt(t *testing.T) { + // authorized + if !ApprovedAt(OFAC, OFAC) { + t.Errorf("expected ApprovedAt") + } + if !ApprovedAt(OFAC, KYC) { + t.Errorf("expected ApprovedAt") + } + if !ApprovedAt(CIP, KYC) { + t.Errorf("expected ApprovedAt") + } + + // not authorized + if ApprovedAt(ReviewRequired, ReviewRequired) { + t.Errorf("expected not ApprovedAt") + } + if ApprovedAt(None, OFAC) { + t.Errorf("expected not ApprovedAt") + } + if ApprovedAt(OFAC, CIP) { + t.Errorf("expected not ApprovedAt") + } + if ApprovedAt(Rejected, OFAC) { + t.Errorf("expected not ApprovedAt") + } +}