Repository: moov-io/customers
Pull request #195 (2020-09-24 22:05:30)

approval: return 'blocked' boolean from OFAC search

codecov-commenter: # [Codecov](https://codecov.io/gh/moov-io/customers/pull/195?src=pr&el=h1) Report
> Merging [#195](https://codecov.io/gh/moov-io/customers/pull/195?src=pr&el=desc) into [master](https://codecov.io/gh/moov-io/customers/commit/024f2155181b5fc5699f1b09d021c35bff46a8a5?el=desc) will **increase** coverage by `0.10%`.
> The diff coverage is `75.00%`.


```diff
@@            Coverage Diff             @@
##           master     #195      +/-   ##
==========================================
+ Coverage   61.54%   61.64%   +0.10%     
==========================================
  Files          49       49              
  Lines        2582     2584       +2     
==========================================
+ Hits         1589     1593       +4     
+ Misses        686      685       -1     
+ Partials      307      306       -1     
```


Patch:
diff --git a/api/client.yaml b/api/client.yaml
index 1220ac1..24fbd2c 100644
--- a/api/client.yaml
+++ b/api/client.yaml
@@ -2048,6 +2048,10 @@ components:
           type: string
           description: SDN EntityID of the Entity
           example: "2141"
+        blocked:
+          type: boolean
+          description: If the search resulted in a positive match against a sanctions list and should be blocked from making transfers or other operations.
+          example: false
         sdnName:
           type: string
           description: Name of the SDN entity
@@ -2066,6 +2070,7 @@ components:
           example: '2016-08-29T09:12:33.001Z'
       required:
         - entityID
+        - blocked
         - sdnName
         - sdnType
         - match
diff --git a/docs/watchman.md b/docs/watchman.md
index e82415b..59fa6aa 100644
--- a/docs/watchman.md
+++ b/docs/watchman.md
@@ -10,4 +10,4 @@ As required by United States law and NACHA guidelines all transfers are checked
 
 ### OFAC Searches
 
-Customers performs searches against the OFAC list of entities which US businesses are blocked from doing business with. This list changes frequently with world politics and policies. Customer objects are required to be in `OFAC` or greater status in order for `Transfers` to be created.
+Customers performs searches against the OFAC list of entities which US businesses are blocked from doing business with. This list changes frequently with world politics and policies. Customer objects are required to be in either `ReceiveOnly` or `Validated` status in order for `Transfers` to be created.
diff --git a/internal/database/mysql.go b/internal/database/mysql.go
index 004214e..1651564 100644
--- a/internal/database/mysql.go
+++ b/internal/database/mysql.go
@@ -148,6 +148,10 @@ var (
 			"rename_organization_configuration__namespace_to_organization",
 			`alter table organization_configuration rename column namespace to organization;`,
 		),
+		execsql(
+			"add_blocked__to__customer_ofac_searches",
+			`alter table customer_ofac_searches add column blocked boolean;`,
+		),
 	)
 )
 
diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go
index 85d74ff..1871215 100644
--- a/internal/database/sqlite.go
+++ b/internal/database/sqlite.go
@@ -41,7 +41,7 @@ var (
 		),
 		execsql(
 			"create_customers_addresses",
-			`create table if not exists customers_addresses(address_id primary key, customer_id, type, address1, address2, city, state, postal_code, country, validated, 
+			`create table if not exists customers_addresses(address_id primary key, customer_id, type, address1, address2, city, state, postal_code, country, validated,
 			deleted_at datetime, unique (customer_id, address1) on conflict abort);`,
 		),
 		execsql(
@@ -151,6 +151,10 @@ PRAGMA foreign_keys=on;`,
 			"rename_customers_namespace_to_organization",
 			`alter table customers rename column namespace to organization;`,
 		),
+		execsql(
+			"add_blocked__to__customer_ofac_searches",
+			`alter table customer_ofac_searches add column blocked boolean;`,
+		),
 	)
 )
 
diff --git a/pkg/client/api/openapi.yaml b/pkg/client/api/openapi.yaml
index e4c7651..0011dee 100644
--- a/pkg/client/api/openapi.yaml
+++ b/pkg/client/api/openapi.yaml
@@ -2526,6 +2526,7 @@ components:
       example:
         sdnType: Individual
         createdAt: 2016-08-29T09:12:33.001Z
+        blocked: false
         match: 0.91
         entityID: "2141"
         sdnName: John Smith
@@ -2534,6 +2535,11 @@ components:
           description: SDN EntityID of the Entity
           example: "2141"
           type: string
+        blocked:
+          description: If the search resulted in a positive match against a sanctions
+            list and should be blocked from making transfers or other operations.
+          example: false
+          type: boolean
         sdnName:
           description: Name of the SDN entity
           example: John Smith
@@ -2552,6 +2558,7 @@ components:
           format: date-time
           type: string
       required:
+      - blocked
       - createdAt
       - entityID
       - match
diff --git a/pkg/client/docs/OfacSearch.md b/pkg/client/docs/OfacSearch.md
index 1e2dd20..a06b401 100644
--- a/pkg/client/docs/OfacSearch.md
+++ b/pkg/client/docs/OfacSearch.md
@@ -5,6 +5,7 @@
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 **EntityID** | **string** | SDN EntityID of the Entity | 
+**Blocked** | **bool** | If the search resulted in a positive match against a sanctions list and should be blocked from making transfers or other operations. | 
 **SdnName** | **string** | Name of the SDN entity | 
 **SdnType** | **string** | SDN entity type | 
 **Match** | **float32** | Percentage of similarity between the Customer name and this OFAC entity | 
diff --git a/pkg/client/model_ofac_search.go b/pkg/client/model_ofac_search.go
index a358227..6c07417 100644
--- a/pkg/client/model_ofac_search.go
+++ b/pkg/client/model_ofac_search.go
@@ -17,6 +17,8 @@ import (
 type OfacSearch struct {
 	// SDN EntityID of the Entity
 	EntityID string `json:"entityID"`
+	// If the search resulted in a positive match against a sanctions list and should be blocked from making transfers or other operations.
+	Blocked bool `json:"blocked"`
 	// Name of the SDN entity
 	SdnName string `json:"sdnName"`
 	// SDN entity type
diff --git a/pkg/customers/approval_ofac.go b/pkg/customers/approval_ofac.go
index ba62d34..baa2bbb 100644
--- a/pkg/customers/approval_ofac.go
+++ b/pkg/customers/approval_ofac.go
@@ -37,14 +37,6 @@ var (
 	}()
 )
 
-type ofacSearchResult struct {
-	EntityID  string    `json:"entityID"`
-	SDNName   string    `json:"sdnName"`
-	SDNType   string    `json:"sdnType"`
-	Match     float32   `json:"match"`
-	CreatedAt time.Time `json:"createdAt"`
-}
-
 type OFACSearcher struct {
 	repo           CustomerRepository
 	watchmanClient watchman.Client
@@ -81,18 +73,20 @@ func (s *OFACSearcher) storeCustomerOFACSearch(cust *client.Customer, requestID
 	// Save the higher matching SDN (from name search or nick name)
 	switch {
 	case nickSDN != nil && nickSDN.Match > sdn.Match:
-		err = s.repo.saveCustomerOFACSearch(cust.CustomerID, ofacSearchResult{
+		err = s.repo.saveCustomerOFACSearch(cust.CustomerID, client.OfacSearch{
 			EntityID:  nickSDN.EntityID,
-			SDNName:   nickSDN.SdnName,
-			SDNType:   nickSDN.SdnType,
+			Blocked:   nickSDN.Match > ofacMatchThreshold,
+			SdnName:   nickSDN.SdnName,
+			SdnType:   nickSDN.SdnType,
 			Match:     nickSDN.Match,
 			CreatedAt: time.Now(),
 		})
 	case sdn != nil:
-		err = s.repo.saveCustomerOFACSearch(cust.CustomerID, ofacSearchResult{
+		err = s.repo.saveCustomerOFACSearch(cust.CustomerID, client.OfacSearch{
 			EntityID:  sdn.EntityID,
-			SDNName:   sdn.SdnName,
-			SDNType:   sdn.SdnType,
+			Blocked:   sdn.Match > ofacMatchThreshold,
+			SdnName:   sdn.SdnName,
+			SdnType:   sdn.SdnType,
 			Match:     sdn.Match,
 			CreatedAt: time.Now(),
 		})
@@ -153,13 +147,15 @@ func refreshOFACSearch(logger log.Logger, repo CustomerRepository, ofac *OFACSea
 			moovhttp.Problem(w, err)
 			return
 		}
+
 		result, err := repo.getLatestCustomerOFACSearch(customerID)
 		if err != nil {
 			logger.Log("ofac", fmt.Sprintf("error getting latest ofac search: %v", err))
 			moovhttp.Problem(w, err)
 			return
 		}
-		if result.Match > ofacMatchThreshold {
+
+		if result.Blocked {
 			err = fmt.Errorf("customer=%s matched against OFAC entity=%s with a score of %.2f - rejecting customer", cust.CustomerID, result.EntityID, result.Match)
 			logger.Log("ofac", err.Error(), "requestID", requestID, "userID", userID)
 
@@ -169,6 +165,7 @@ func refreshOFACSearch(logger log.Logger, repo CustomerRepository, ofac *OFACSea
 				return
 			}
 		}
+
 		w.WriteHeader(http.StatusOK)
 		json.NewEncoder(w).Encode(result)
 	}
diff --git a/pkg/customers/customers.go b/pkg/customers/customers.go
index 7f3b36c..2a89b41 100644
--- a/pkg/customers/customers.go
+++ b/pkg/customers/customers.go
@@ -405,8 +405,8 @@ type CustomerRepository interface {
 	updateCustomerAddress(customerID, addressID string, req updateCustomerAddressRequest) error
 	deleteCustomerAddress(customerID string, addressID string) error
 
-	getLatestCustomerOFACSearch(customerID string) (*ofacSearchResult, error)
-	saveCustomerOFACSearch(customerID string, result ofacSearchResult) error
+	getLatestCustomerOFACSearch(customerID string) (*client.OfacSearch, error)
+	saveCustomerOFACSearch(customerID string, result client.OfacSearch) error
 }
 
 func NewCustomerRepo(logger log.Logger, db *sql.DB) CustomerRepository {
@@ -444,8 +444,7 @@ func (r *sqlCustomerRepository) createCustomer(c *client.Customer, organization
 	}
 
 	// Insert customer record
-	query := `insert into customers (customer_id, first_name, middle_name, last_name, nick_name, suffix, type, birth_date, status, email, created_at, last_modified,
-	organization)
+	query := `insert into customers (customer_id, first_name, middle_name, last_name, nick_name, suffix, type, birth_date, status, email, created_at, last_modified, organization)
 values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
 	stmt, err := tx.Prepare(query)
 	if err != nil {
@@ -803,8 +802,8 @@ func (r *sqlCustomerRepository) deleteCustomerAddress(customerID string, address
 	return err
 }
 
-func (r *sqlCustomerRepository) getLatestCustomerOFACSearch(customerID string) (*ofacSearchResult, error) {
-	query := `select entity_id, sdn_name, sdn_type, percentage_match, created_at from customer_ofac_searches where customer_id = ? order by created_at desc limit 1;`
+func (r *sqlCustomerRepository) getLatestCustomerOFACSearch(customerID string) (*client.OfacSearch, error) {
+	query := `select entity_id, blocked, sdn_name, sdn_type, percentage_match, created_at from customer_ofac_searches where customer_id = ? order by created_at desc limit 1;`
 	stmt, err := r.db.Prepare(query)
 	if err != nil {
 		return nil, fmt.Errorf("getLatestCustomerOFACSearch: prepare: %v", err)
@@ -812,8 +811,8 @@ func (r *sqlCustomerRepository) getLatestCustomerOFACSearch(customerID string) (
 	defer stmt.Close()
 
 	row := stmt.QueryRow(customerID)
-	var res ofacSearchResult
-	if err := row.Scan(&res.EntityID, &res.SDNName, &res.SDNType, &res.Match, &res.CreatedAt); err != nil {
+	var res client.OfacSearch
+	if err := row.Scan(&res.EntityID, &res.Blocked, &res.SdnName, &res.SdnType, &res.Match, &res.CreatedAt); err != nil {
 		if err == sql.ErrNoRows {
 			return nil, nil // nothing found
 		}
@@ -822,8 +821,8 @@ func (r *sqlCustomerRepository) getLatestCustomerOFACSearch(customerID string) (
 	return &res, nil
 }
 
-func (r *sqlCustomerRepository) saveCustomerOFACSearch(customerID string, result ofacSearchResult) error {
-	query := `insert into customer_ofac_searches (customer_id, entity_id, sdn_name, sdn_type, percentage_match, created_at) values (?, ?, ?, ?, ?, ?);`
+func (r *sqlCustomerRepository) saveCustomerOFACSearch(customerID string, result client.OfacSearch) error {
+	query := `insert into customer_ofac_searches (customer_id, blocked, entity_id, sdn_name, sdn_type, percentage_match, created_at) values (?, ?, ?, ?, ?, ?, ?);`
 	stmt, err := r.db.Prepare(query)
 	if err != nil {
 		return fmt.Errorf("saveCustomerOFACSearch: prepare: %v", err)
@@ -834,7 +833,7 @@ func (r *sqlCustomerRepository) saveCustomerOFACSearch(customerID string, result
 		result.CreatedAt = time.Now()
 	}
 
-	if _, err := stmt.Exec(customerID, result.EntityID, result.SDNName, result.SDNType, result.Match, result.CreatedAt); err != nil {
+	if _, err := stmt.Exec(customerID, result.Blocked, result.EntityID, result.SdnName, result.SdnType, result.Match, result.CreatedAt); err != nil {
 		return fmt.Errorf("saveCustomerOFACSearch: exec: %v", err)
 	}
 	return nil


Test patch:
diff --git a/pkg/customers/approval_ofac_test.go b/pkg/customers/approval_ofac_test.go
index 1b57902..3fd8ae8 100644
--- a/pkg/customers/approval_ofac_test.go
+++ b/pkg/customers/approval_ofac_test.go
@@ -62,7 +62,7 @@ func TestOFACSearcher__storeCustomerOFACSearch(t *testing.T) {
 		t.Fatal(err)
 	}
 	if res.EntityID != "1241421" {
-		t.Errorf("ofacSearchResult: %#v", res)
+		t.Errorf("ofac search: %#v", res)
 	}
 	if res.CreatedAt.IsZero() {
 		t.Errorf("res.CreatedAt=%v", res.CreatedAt)
@@ -85,7 +85,7 @@ func TestOFACApproval__getLatest(t *testing.T) {
 		customer: &client.Customer{
 			CustomerID: customerID,
 		},
-		savedOFACSearchResult: &ofacSearchResult{
+		savedSearchResult: &client.OfacSearch{
 			EntityID: "142",
 			Match:    1.0,
 		},
@@ -124,7 +124,7 @@ func TestOFACApproval__refresh(t *testing.T) {
 		customer: &client.Customer{
 			CustomerID: customerID,
 		},
-		savedOFACSearchResult: &ofacSearchResult{
+		savedSearchResult: &client.OfacSearch{
 			EntityID: "142",
 			Match:    1.0,
 		},
@@ -145,7 +145,7 @@ func TestOFACApproval__refresh(t *testing.T) {
 	if w.Code != http.StatusOK {
 		t.Errorf("bogus HTTP status: %d", w.Code)
 	}
-	var result ofacSearchResult
+	var result client.OfacSearch
 	if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
 		t.Fatal(err)
 	}
@@ -153,7 +153,7 @@ func TestOFACApproval__refresh(t *testing.T) {
 		t.Errorf("result=%#v", result)
 	}
 
-	repo.savedOFACSearchResult.Match = 0.90 // match isn't high enough to block
+	repo.savedSearchResult.Match = 0.90 // match isn't high enough to block
 
 	w = httptest.NewRecorder()
 	router.ServeHTTP(w, req)
@@ -174,7 +174,7 @@ func TestOFACApproval__refreshErr(t *testing.T) {
 		customer: &client.Customer{
 			CustomerID: customerID,
 		},
-		savedOFACSearchResult: &ofacSearchResult{
+		savedSearchResult: &client.OfacSearch{
 			EntityID: "142",
 			Match:    0.88,
 		},
diff --git a/pkg/customers/customers_test.go b/pkg/customers/customers_test.go
index e9ebdfa..5e62ab5 100644
--- a/pkg/customers/customers_test.go
+++ b/pkg/customers/customers_test.go
@@ -30,13 +30,13 @@ import (
 var _ CustomerRepository = (*testCustomerRepository)(nil)
 
 type testCustomerRepository struct {
-	err              error
-	customer         *client.Customer
-	ofacSearchResult *ofacSearchResult
+	err          error
+	customer     *client.Customer
+	searchResult *client.OfacSearch
 
-	createdCustomer       *client.Customer
-	updatedStatus         client.CustomerStatus
-	savedOFACSearchResult *ofacSearchResult
+	createdCustomer   *client.Customer
+	updatedStatus     client.CustomerStatus
+	savedSearchResult *client.OfacSearch
 }
 
 func (r *testCustomerRepository) getCustomer(customerID string) (*client.Customer, error) {
@@ -97,18 +97,18 @@ func (r *testCustomerRepository) deleteCustomerAddress(customerID string, addres
 	return r.err
 }
 
-func (r *testCustomerRepository) getLatestCustomerOFACSearch(customerID string) (*ofacSearchResult, error) {
+func (r *testCustomerRepository) getLatestCustomerOFACSearch(customerID string) (*client.OfacSearch, error) {
 	if r.err != nil {
 		return nil, r.err
 	}
-	if r.savedOFACSearchResult != nil {
-		return r.savedOFACSearchResult, nil
+	if r.savedSearchResult != nil {
+		return r.savedSearchResult, nil
 	}
-	return r.ofacSearchResult, nil
+	return r.searchResult, nil
 }
 
-func (r *testCustomerRepository) saveCustomerOFACSearch(customerID string, result ofacSearchResult) error {
-	r.savedOFACSearchResult = &result
+func (r *testCustomerRepository) saveCustomerOFACSearch(customerID string, result client.OfacSearch) error {
+	r.savedSearchResult = &result
 	return r.err
 }
 
@@ -911,11 +911,11 @@ func TestCustomerRepository__OFAC(t *testing.T) {
 		t.Fatal(err)
 	}
 	if res != nil {
-		t.Errorf("unexpected ofacSearchResult: %#v", res)
+		t.Errorf("unexpected ofac search: %#v", res)
 	}
 
 	// save a record and read it back
-	if err := repo.saveCustomerOFACSearch(customerID, ofacSearchResult{EntityID: "14141"}); err != nil {
+	if err := repo.saveCustomerOFACSearch(customerID, client.OfacSearch{EntityID: "14141"}); err != nil {
 		t.Fatal(err)
 	}
 	res, err = repo.getLatestCustomerOFACSearch(customerID)
@@ -923,11 +923,11 @@ func TestCustomerRepository__OFAC(t *testing.T) {
 		t.Fatal(err)
 	}
 	if res == nil || res.EntityID != "14141" {
-		t.Errorf("ofacSearchResult: %#v", res)
+		t.Errorf("ofac search: %#v", res)
 	}
 
 	// save another and get it back
-	if err := repo.saveCustomerOFACSearch(customerID, ofacSearchResult{EntityID: "777121"}); err != nil {
+	if err := repo.saveCustomerOFACSearch(customerID, client.OfacSearch{EntityID: "777121"}); err != nil {
 		t.Fatal(err)
 	}
 	res, err = repo.getLatestCustomerOFACSearch(customerID)
@@ -935,7 +935,7 @@ func TestCustomerRepository__OFAC(t *testing.T) {
 		t.Fatal(err)
 	}
 	if res == nil || res.EntityID != "777121" {
-		t.Errorf("ofacSearchResult: %#v", res)
+		t.Errorf("ofac search: %#v", res)
 	}
 }