Repository: moov-io/customers (go)

Issue:
Customer delete endpoint
It's not presently possible to tombstone a customer via the API. This should be attainable with `DELETE /customers/:customerID` which would mark the customer as deleted and remove it from any GETs or queries. 

Pull request description:
http: add endpoint to delete a Customer
Add http endpoint to delete a customer by its ID

Fixes #174 

Patch:
diff --git a/api/client.yaml b/api/client.yaml
index 3bf0f6a..23074aa 100644
--- a/api/client.yaml
+++ b/api/client.yaml
@@ -167,6 +167,43 @@ paths:
                 $ref: 'https://raw.githubusercontent.com/moov-io/api/master/openapi-common.yaml#/components/schemas/Error'
         '404':
           description: No Customer with the specified customerID was found
+    delete:
+      description: Remove a given Customer
+      operationId: deleteCustomer
+      parameters:
+        - name: X-Request-ID
+          in: header
+          description: Optional requestID allows application developer to trace requests through the systems logs
+          example: rs4f9915
+          schema:
+            type: string
+        - name: X-Namespace
+          in: header
+          description: Value used to separate and identify models
+          example: org342
+          schema:
+            type: string
+        - description: customerID of the Customer to be deleted
+          explode: false
+          in: path
+          name: customerID
+          required: true
+          schema:
+            example: e210a9d6-d755-4455-9bd2-9577ea7e1081
+            type: string
+          style: simple
+      responses:
+        "200":
+          description: Customer removed from customers
+        '400':
+          description: Failed to delete customer, see error(s)
+          content:
+            application/json:
+              schema:
+                $ref: 'https://raw.githubusercontent.com/moov-io/api/master/openapi-common.yaml#/components/schemas/Error'
+      summary: Delete Customer by ID
+      tags:
+        - Customers
   /customers/{customerID}/address:
     post:
       tags: [Customers]
@@ -513,7 +550,7 @@ paths:
           content:
             application/json:
               schema:
-                    $ref: '#/components/schemas/AccountValidationResponse'
+                $ref: '#/components/schemas/AccountValidationResponse'
               example:
                 validationID: b74d7c51
                 strategy: instant
@@ -698,7 +735,7 @@ paths:
         content:
           application/json:
             schema:
-                $ref: '#/components/schemas/InitAccountValidationRequest'
+              $ref: '#/components/schemas/InitAccountValidationRequest'
             example:
               strategy: micro-deposits
               vendor: moov
@@ -708,7 +745,7 @@ paths:
           content:
             application/json:
               schema:
-                    $ref: '#/components/schemas/InitAccountValidationResponse'
+                $ref: '#/components/schemas/InitAccountValidationResponse'
               examples:
                 Micro-deposits:
                   value:
@@ -785,7 +822,7 @@ paths:
         content:
           application/json:
             schema:
-                $ref: '#/components/schemas/CompleteAccountValidationRequest'
+              $ref: '#/components/schemas/CompleteAccountValidationRequest'
             examples:
               Micro-deposits:
                 value:
@@ -814,7 +851,7 @@ paths:
           content:
             application/json:
               schema:
-                    $ref: '#/components/schemas/CompleteAccountValidationResponse'
+                $ref: '#/components/schemas/CompleteAccountValidationResponse'
               example:
                 validationID: b74d7c51
                 strategy: micro-deposits
@@ -1446,7 +1483,7 @@ components:
         accountNumber:
           type: string
           description: |
-              The base64 encoded encrypted account number for the bank account for transit across services. The symmetric key used is configured from TRANSIT_LOCAL_BASE64_KEY and callers of endpoints that respond with encrypted values need this same key to decrypt.
+            The base64 encoded encrypted account number for the bank account for transit across services. The symmetric key used is configured from TRANSIT_LOCAL_BASE64_KEY and callers of endpoints that respond with encrypted values need this same key to decrypt.
           example: MKtcJkzlxUlGcW7Nf5xm5cg==
     CreateAccount:
       properties:
diff --git a/cmd/server/customers.go b/cmd/server/customers.go
index 15dab83..35ddb1e 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/cmd/server/route"
 	"github.com/moov-io/customers/internal/usstates"
 	"github.com/moov-io/customers/pkg/client"
@@ -28,6 +29,7 @@ import (
 func addCustomerRoutes(logger log.Logger, r *mux.Router, repo customerRepository, customerSSNStorage *ssnStorage, ofac *ofacSearcher) {
 	r.Methods("GET").Path("/customers").HandlerFunc(searchCustomers(logger, repo))
 	r.Methods("GET").Path("/customers/{customerID}").HandlerFunc(getCustomer(logger, repo))
+	r.Methods("DELETE").Path("/customers/{customerID}").HandlerFunc(deleteCustomer(logger, repo))
 	r.Methods("POST").Path("/customers").HandlerFunc(createCustomer(logger, repo, customerSSNStorage, ofac))
 	r.Methods("PUT").Path("/customers/{customerID}/metadata").HandlerFunc(replaceCustomerMetadata(logger, repo))
 	r.Methods("POST").Path("/customers/{customerID}/address").HandlerFunc(addCustomerAddress(logger, repo))
@@ -63,6 +65,25 @@ func getCustomer(logger log.Logger, repo customerRepository) http.HandlerFunc {
 	}
 }
 
+func deleteCustomer(logger log.Logger, repo customerRepository) func(http.ResponseWriter, *http.Request) {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w = route.Responder(logger, w, r)
+
+		customerID := route.GetCustomerID(w, r)
+		if customerID == "" {
+			return
+		}
+
+		err := repo.deleteCustomer(customerID)
+		if err != nil {
+			moovhttp.Problem(w, fmt.Errorf("deleting customer: %v", err))
+			return
+		}
+
+		w.WriteHeader(http.StatusNoContent)
+	}
+}
+
 func respondWithCustomer(logger log.Logger, w http.ResponseWriter, customerID string, requestID string, repo customerRepository) {
 	cust, err := repo.getCustomer(customerID)
 	if err != nil {
@@ -319,6 +340,7 @@ type customerRepository interface {
 	getCustomer(customerID string) (*client.Customer, error)
 	createCustomer(c *client.Customer, namespace string) error
 	updateCustomerStatus(customerID string, status client.CustomerStatus, comment string) error
+	deleteCustomer(customerID string) error
 
 	searchCustomers(params searchParams) ([]*client.Customer, error)
 
@@ -341,6 +363,18 @@ func (r *sqlCustomerRepository) close() error {
 	return r.db.Close()
 }
 
+func (r *sqlCustomerRepository) deleteCustomer(customerID string) error {
+	query := `update customers set deleted_at = ? where customer_id = ? and deleted_at is null;`
+	stmt, err := r.db.Prepare(query)
+	if err != nil {
+		return err
+	}
+	defer stmt.Close()
+
+	_, err = stmt.Exec(time.Now(), customerID)
+	return err
+}
+
 func (r *sqlCustomerRepository) createCustomer(c *client.Customer, namespace string) error {
 	tx, err := r.db.Begin()
 	if err != nil {
diff --git a/pkg/client/README.md b/pkg/client/README.md
index b4aa317..a78b44b 100644
--- a/pkg/client/README.md
+++ b/pkg/client/README.md
@@ -44,6 +44,7 @@ Class | Method | HTTP request | Description
 *CustomersApi* | [**CreateCustomer**](docs/CustomersApi.md#createcustomer) | **Post** /customers | Create customer
 *CustomersApi* | [**CreateCustomerAccount**](docs/CustomersApi.md#createcustomeraccount) | **Post** /customers/{customerID}/accounts | Create Customer Account
 *CustomersApi* | [**DecryptAccountNumber**](docs/CustomersApi.md#decryptaccountnumber) | **Post** /customers/{customerID}/accounts/{accountID}/decrypt | Decrypt Account Number
+*CustomersApi* | [**DeleteCustomer**](docs/CustomersApi.md#deletecustomer) | **Delete** /customers/{customerID} | Delete Customer by ID
 *CustomersApi* | [**DeleteCustomerAccount**](docs/CustomersApi.md#deletecustomeraccount) | **Delete** /customers/{customerID}/accounts | Delete Customer Account
 *CustomersApi* | [**GetAccountValidation**](docs/CustomersApi.md#getaccountvalidation) | **Get** /customers/{customerID}/accounts/{accountID}/validations/{validationID} | Get Account Validation
 *CustomersApi* | [**GetCustomer**](docs/CustomersApi.md#getcustomer) | **Get** /customers/{customerID} | Retrieve customer
diff --git a/pkg/client/api/openapi.yaml b/pkg/client/api/openapi.yaml
index 01979ed..7b814ed 100644
--- a/pkg/client/api/openapi.yaml
+++ b/pkg/client/api/openapi.yaml
@@ -154,6 +154,50 @@ paths:
       tags:
       - Customers
   /customers/{customerID}:
+    delete:
+      description: Remove a given Customer
+      operationId: deleteCustomer
+      parameters:
+      - description: Optional requestID allows application developer to trace requests
+          through the systems logs
+        example: rs4f9915
+        explode: false
+        in: header
+        name: X-Request-ID
+        required: false
+        schema:
+          type: string
+        style: simple
+      - description: Value used to separate and identify models
+        example: org342
+        explode: false
+        in: header
+        name: X-Namespace
+        required: false
+        schema:
+          type: string
+        style: simple
+      - description: customerID of the Customer to be deleted
+        explode: false
+        in: path
+        name: customerID
+        required: true
+        schema:
+          example: e210a9d6-d755-4455-9bd2-9577ea7e1081
+          type: string
+        style: simple
+      responses:
+        "200":
+          description: Customer removed from customers
+        "400":
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+          description: Failed to delete customer, see error(s)
+      summary: Delete Customer by ID
+      tags:
+      - Customers
     get:
       description: Get the Customer object and metadata for the customerID.
       operationId: getCustomer
diff --git a/pkg/client/api_customers.go b/pkg/client/api_customers.go
index 9185109..7fe0011 100644
--- a/pkg/client/api_customers.go
+++ b/pkg/client/api_customers.go
@@ -657,6 +657,97 @@ func (a *CustomersApiService) DecryptAccountNumber(ctx _context.Context, custome
 	return localVarReturnValue, localVarHTTPResponse, nil
 }
 
+// DeleteCustomerOpts Optional parameters for the method 'DeleteCustomer'
+type DeleteCustomerOpts struct {
+	XRequestID optional.String
+	XNamespace optional.String
+}
+
+/*
+DeleteCustomer Delete Customer by ID
+Remove a given Customer
+ * @param ctx _context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
+ * @param customerID customerID of the Customer to be deleted
+ * @param optional nil or *DeleteCustomerOpts - Optional Parameters:
+ * @param "XRequestID" (optional.String) -  Optional requestID allows application developer to trace requests through the systems logs
+ * @param "XNamespace" (optional.String) -  Value used to separate and identify models
+*/
+func (a *CustomersApiService) DeleteCustomer(ctx _context.Context, customerID string, localVarOptionals *DeleteCustomerOpts) (*_nethttp.Response, error) {
+	var (
+		localVarHTTPMethod   = _nethttp.MethodDelete
+		localVarPostBody     interface{}
+		localVarFormFileName string
+		localVarFileName     string
+		localVarFileBytes    []byte
+	)
+
+	// create path and map variables
+	localVarPath := a.client.cfg.BasePath + "/customers/{customerID}"
+	localVarPath = strings.Replace(localVarPath, "{"+"customerID"+"}", _neturl.QueryEscape(parameterToString(customerID, "")), -1)
+
+	localVarHeaderParams := make(map[string]string)
+	localVarQueryParams := _neturl.Values{}
+	localVarFormParams := _neturl.Values{}
+
+	// to determine the Content-Type header
+	localVarHTTPContentTypes := []string{}
+
+	// set Content-Type header
+	localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
+	if localVarHTTPContentType != "" {
+		localVarHeaderParams["Content-Type"] = localVarHTTPContentType
+	}
+
+	// to determine the Accept header
+	localVarHTTPHeaderAccepts := []string{"application/json"}
+
+	// set Accept header
+	localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
+	if localVarHTTPHeaderAccept != "" {
+		localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
+	}
+	if localVarOptionals != nil && localVarOptionals.XRequestID.IsSet() {
+		localVarHeaderParams["X-Request-ID"] = parameterToString(localVarOptionals.XRequestID.Value(), "")
+	}
+	if localVarOptionals != nil && localVarOptionals.XNamespace.IsSet() {
+		localVarHeaderParams["X-Namespace"] = parameterToString(localVarOptionals.XNamespace.Value(), "")
+	}
+	r, err := a.client.prepareRequest(ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
+	if err != nil {
+		return nil, err
+	}
+
+	localVarHTTPResponse, err := a.client.callAPI(r)
+	if err != nil || localVarHTTPResponse == nil {
+		return localVarHTTPResponse, err
+	}
+
+	localVarBody, err := _ioutil.ReadAll(localVarHTTPResponse.Body)
+	localVarHTTPResponse.Body.Close()
+	if err != nil {
+		return localVarHTTPResponse, err
+	}
+
+	if localVarHTTPResponse.StatusCode >= 300 {
+		newErr := GenericOpenAPIError{
+			body:  localVarBody,
+			error: localVarHTTPResponse.Status,
+		}
+		if localVarHTTPResponse.StatusCode == 400 {
+			var v Error
+			err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+			if err != nil {
+				newErr.error = err.Error()
+				return localVarHTTPResponse, newErr
+			}
+			newErr.model = v
+		}
+		return localVarHTTPResponse, newErr
+	}
+
+	return localVarHTTPResponse, nil
+}
+
 // DeleteCustomerAccountOpts Optional parameters for the method 'DeleteCustomerAccount'
 type DeleteCustomerAccountOpts struct {
 	XRequestID optional.String
diff --git a/pkg/client/docs/CustomersApi.md b/pkg/client/docs/CustomersApi.md
index cf9e57c..bab13c8 100644
--- a/pkg/client/docs/CustomersApi.md
+++ b/pkg/client/docs/CustomersApi.md
@@ -10,6 +10,7 @@ Method | HTTP request | Description
 [**CreateCustomer**](CustomersApi.md#CreateCustomer) | **Post** /customers | Create customer
 [**CreateCustomerAccount**](CustomersApi.md#CreateCustomerAccount) | **Post** /customers/{customerID}/accounts | Create Customer Account
 [**DecryptAccountNumber**](CustomersApi.md#DecryptAccountNumber) | **Post** /customers/{customerID}/accounts/{accountID}/decrypt | Decrypt Account Number
+[**DeleteCustomer**](CustomersApi.md#DeleteCustomer) | **Delete** /customers/{customerID} | Delete Customer by ID
 [**DeleteCustomerAccount**](CustomersApi.md#DeleteCustomerAccount) | **Delete** /customers/{customerID}/accounts | Delete Customer Account
 [**GetAccountValidation**](CustomersApi.md#GetAccountValidation) | **Get** /customers/{customerID}/accounts/{accountID}/validations/{validationID} | Get Account Validation
 [**GetCustomer**](CustomersApi.md#GetCustomer) | **Get** /customers/{customerID} | Retrieve customer
@@ -319,6 +320,52 @@ No authorization required
 [[Back to README]](../README.md)
 
 
+## DeleteCustomer
+
+> DeleteCustomer(ctx, customerID, optional)
+
+Delete Customer by ID
+
+Remove a given Customer
+
+### Required Parameters
+
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+**ctx** | **context.Context** | context for authentication, logging, cancellation, deadlines, tracing, etc.
+**customerID** | **string**| customerID of the Customer to be deleted | 
+ **optional** | ***DeleteCustomerOpts** | optional parameters | nil if no parameters
+
+### Optional Parameters
+
+Optional parameters are passed through a pointer to a DeleteCustomerOpts struct
+
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+
+ **xRequestID** | **optional.String**| Optional requestID allows application developer to trace requests through the systems logs | 
+ **xNamespace** | **optional.String**| Value used to separate and identify models | 
+
+### Return type
+
+ (empty response body)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+- **Content-Type**: Not defined
+- **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
+[[Back to Model list]](../README.md#documentation-for-models)
+[[Back to README]](../README.md)
+
+
 ## DeleteCustomerAccount
 
 > DeleteCustomerAccount(ctx, customerID, optional)


Test patch:
diff --git a/cmd/server/customers_test.go b/cmd/server/customers_test.go
index 929e80e..5a1e755 100644
--- a/cmd/server/customers_test.go
+++ b/cmd/server/customers_test.go
@@ -15,9 +15,12 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/stretchr/testify/require"
+
 	"github.com/moov-io/customers/internal/database"
 
 	"github.com/moov-io/base"
+
 	"github.com/moov-io/customers/pkg/client"
 
 	"github.com/go-kit/kit/log"
@@ -46,6 +49,11 @@ func (r *testCustomerRepository) createCustomer(c *client.Customer, namespace st
 	return r.err
 }
 
+func (r *testCustomerRepository) deleteCustomer(customerID string) error {
+	r.customer = nil
+	return r.err
+}
+
 func (r *testCustomerRepository) updateCustomerStatus(customerID string, status client.CustomerStatus, comment string) error {
 	r.updatedStatus = status
 	return r.err
@@ -173,6 +181,39 @@ func TestCustomers__GetCustomerEmpty(t *testing.T) {
 	}
 }
 
+func TestCustomers__DeleteCustomer(t *testing.T) {
+	repo := createTestCustomerRepository(t)
+	defer repo.close()
+
+	cr := &customerRequest{
+		FirstName: "Jane",
+		LastName:  "Doe",
+		Email:     "jane@example.com",
+	}
+	customer, _, _ := cr.asCustomer(testCustomerSSNStorage(t))
+	require.NoError(t,
+		repo.createCustomer(customer, "namespace"),
+	)
+
+	got, err := repo.getCustomer(customer.CustomerID)
+	require.NoError(t, err)
+	require.NotNil(t, got)
+
+	router := mux.NewRouter()
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest("DELETE", fmt.Sprintf("/customers/%s", customer.CustomerID), nil)
+
+	addCustomerRoutes(log.NewNopLogger(), router, repo, testCustomerSSNStorage(t), createTestOFACSearcher(nil, nil))
+	router.ServeHTTP(w, req)
+	w.Flush()
+
+	require.Equal(t, http.StatusNoContent, w.Code)
+	require.Empty(t, w.Body)
+	got, err = repo.getCustomer(customer.CustomerID)
+	require.NoError(t, err)
+	require.Nil(t, got)
+}
+
 func TestCustomerRepository__createCustomer(t *testing.T) {
 	check := func(t *testing.T, repo *sqlCustomerRepository) {
 		cust, _, _ := (customerRequest{
@@ -407,6 +448,63 @@ func TestCustomers__repository(t *testing.T) {
 	}
 }
 
+func TestCustomerRepository__delete(t *testing.T) {
+	repo := createTestCustomerRepository(t)
+	defer repo.close()
+
+	type customer struct {
+		*client.Customer
+		deleted bool
+	}
+	customers := make([]*customer, 10)
+
+	for i := 0; i < len(customers); i++ {
+		cr := &customerRequest{
+			FirstName: "Jane",
+			LastName:  "Doe",
+			Email:     "jane@example.com",
+		}
+		cust, _, _ := cr.asCustomer(testCustomerSSNStorage(t))
+		require.NoError(t,
+			repo.createCustomer(cust, "namespace"),
+		)
+
+		customers[i] = &customer{
+			Customer: cust,
+		}
+	}
+
+	// mark customers to be deleted
+	indexesToDelete := []int{1, 2, 5, 8}
+	for _, idx := range indexesToDelete {
+		require.Less(t, idx, len(customers))
+		customers[idx].deleted = true
+		require.NoError(t,
+			repo.deleteCustomer(customers[idx].CustomerID),
+		)
+	}
+
+	deletedCustomerIds := make(map[string]bool)
+	// query all customers that have been marked as deleted
+	query := `select customer_id from customers where deleted_at is not null;`
+	stmt, err := repo.db.Prepare(query)
+	require.NoError(t, err)
+
+	rows, err := stmt.Query()
+	require.NoError(t, err)
+
+	for rows.Next() {
+		var customerID *string
+		require.NoError(t, rows.Scan(&customerID))
+		deletedCustomerIds[*customerID] = true
+	}
+
+	for _, cust := range customers {
+		_, ok := deletedCustomerIds[cust.CustomerID]
+		require.Equal(t, cust.deleted, ok)
+	}
+}
+
 func TestCustomerRepository__updateCustomerStatus(t *testing.T) {
 	repo := createTestCustomerRepository(t)
 	defer repo.close()