diff --git a/cmd/server/customers.go b/cmd/server/customers.go index 36f5efeda..9a0f05568 100644 --- a/cmd/server/customers.go +++ b/cmd/server/customers.go @@ -250,6 +250,39 @@ type updateCustomerStatusRequest struct { Status CustomerStatus `json:"status"` } +// validCustomerStatusTransition determines if a future CustomerStatus is valid for a given +// Customer. There are several rules which apply to a CustomerStatus, such as: +// - Deceased, Rejected statuses can never be changed +// - 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, futureStatus CustomerStatus) 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) + } + switch futureStatus { + case CustomerStatusKYC: + if existing.FirstName == "" || existing.LastName == "" { + return fmt.Errorf("customer=%s is missing fist/last name", existing.Id) + } + if existing.BirthDate.IsZero() { + return fmt.Errorf("customer=%s is missing date of birth", existing.Id) + } + if len(existing.Addresses) == 0 { // TODO(adam): we should probably check existing.Addresses.exists(_.Validated) + return fmt.Errorf("customer=%s is missing an Address", existing.Id) + } + case CustomerStatusOFAC: // TODO(adam): need to impl lookup + return fmt.Errorf("customers=%s %s to OFAC transition needs to lookup OFAC search results", existing.Id, existing.Status) + case CustomerStatusCIP: // TODO(adam): need to impl lookup + return fmt.Errorf("customers=%s %s to CIP transition needs to lookup encrypted SSN", existing.Id, existing.Status) + } + return nil +} + func updateCustomerStatus(logger log.Logger, repo customerRepository) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w = wrapResponseWriter(logger, w, r) @@ -265,6 +298,16 @@ func updateCustomerStatus(logger log.Logger, repo customerRepository) http.Handl return } + cust, err := repo.getCustomer(customerId) + if err != nil { + moovhttp.Problem(w, err) + return + } + if err := validCustomerStatusTransition(cust, req.Status); err != nil { + moovhttp.Problem(w, err) + return + } + // Update Customer's status in the database if err := repo.updateCustomerStatus(customerId, req.Status, req.Comment); err != nil { moovhttp.Problem(w, err) diff --git a/cmd/server/customers_test.go b/cmd/server/customers_test.go index 4d7b3ebe4..02f57c0ed 100644 --- a/cmd/server/customers_test.go +++ b/cmd/server/customers_test.go @@ -13,6 +13,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/moov-io/base" client "github.com/moov-io/customers/client" @@ -309,7 +310,7 @@ func TestCustomers__updateCustomerStatus(t *testing.T) { }, } - body := strings.NewReader(`{"status": "OFAC", "comment": "test comment"}`) + body := strings.NewReader(`{"status": "ReviewRequired", "comment": "test comment"}`) w := httptest.NewRecorder() req := httptest.NewRequest("PUT", "/customers/foo/status", body) @@ -322,7 +323,7 @@ func TestCustomers__updateCustomerStatus(t *testing.T) { w.Flush() if w.Code != http.StatusOK { - t.Errorf("bogus HTTP status: %d", w.Code) + t.Errorf("bogus HTTP status: %d: %v", w.Code, w.Body.String()) } var customer client.Customer @@ -332,7 +333,7 @@ func TestCustomers__updateCustomerStatus(t *testing.T) { if customer.Id == "" { t.Errorf("missing customer JSON: %#v", customer) } - if repo.updatedStatus != CustomerStatusOFAC { + if repo.updatedStatus != CustomerStatusReviewRequired { t.Errorf("unexpected status: %s", repo.updatedStatus) } } @@ -540,3 +541,49 @@ func TestCustomers__validateMetadata(t *testing.T) { t.Error("expected error") } } + +func TestCustomers__validCustomerStatusTransition(t *testing.T) { + cust := &client.Customer{ + Id: base.ID(), + Status: CustomerStatusNone, + } + + if err := validCustomerStatusTransition(cust, CustomerStatusDeceased); err != nil { + t.Errorf("expected no error: %v", err) + } + + // block Deceased and Rejected customers + cust.Status = CustomerStatusDeceased + if err := validCustomerStatusTransition(cust, CustomerStatusKYC); err == nil { + t.Error("expected error") + } + cust.Status = CustomerStatusRejected + if err := validCustomerStatusTransition(cust, CustomerStatusKYC); err == nil { + t.Error("expected error") + } + + // normal KYC approval (rejected due to missing info) + cust.FirstName, cust.LastName = "Jane", "Doe" + cust.Status = CustomerStatusReviewRequired + if err := validCustomerStatusTransition(cust, CustomerStatusKYC); err == nil { + t.Error("expected error") + } + cust.BirthDate = time.Now() + if err := validCustomerStatusTransition(cust, CustomerStatusKYC); err == nil { + t.Error("expected error") + } + cust.Addresses = append(cust.Addresses, client.Address{ + Type: "primary", + Address1: "123 1st st", + }) + + // OFAC and CIP transistions are WIP // TODO(adam): impl both transistions + cust.Status = CustomerStatusReviewRequired + if err := validCustomerStatusTransition(cust, CustomerStatusOFAC); err == nil { + t.Error("OFAC transition is WIP") + } + cust.Status = CustomerStatusReviewRequired + if err := validCustomerStatusTransition(cust, CustomerStatusCIP); err == nil { + t.Error("CIP transition is WIP") + } +}