diff --git a/.github/workflows/golang.yml b/.github/workflows/golang.yml new file mode 100644 index 0000000..a8c6d4d --- /dev/null +++ b/.github/workflows/golang.yml @@ -0,0 +1,30 @@ +name: Go + +on: [push, pull_request] + +jobs: + + build: + name: Build + runs-on: ubuntu-latest + steps: + + - name: Set up Go 1.x + uses: actions/setup-go@v2 + with: + go-version: ^1.13 + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Debug + run: | + printenv | sort + + - name: Get dependencies + run: | + go get -t ./... + + - name: Test + run: go test -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5048da6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.vscode +.env \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0709673 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Uchencho + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c886f4c --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +OUTPUT = main + +.PHONY: test +test: + go test -failfast -cover ./... + +.PHONY: clean +clean: + rm -f $(OUTPUT) + +build-local: + go build -o $(OUTPUT) ./client/main.go + +run: build-local + @echo ">> Running application ..." + ./$(OUTPUT) diff --git a/README.md b/README.md new file mode 100644 index 0000000..aede5a7 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Pawapay + +[Pawapay](https://pawapay.io/) client application written in Go + +## Usage + +### Install Package + +```bash +go get github.com/Uchencho/pawapay +``` + +### Documentation + +Please see [the docs](https://pawapay.io/) for the most up-to-date documentation of the Pawapay API. + +#### Pawapay + +- Sample Usage + +```go +package main + +import ( + "log" + "time" + + "github.com/Uchencho/pawapay" +) + +func main() { + cfg := pawapay.GetConfigFromEnvVars() + cfg.AllowRequestLogging() // only for debugging, would not advise this for production. Also not necessary, read on for why + + /* + You can also explicitly declare the config + cfg := pawapay.Config{ + APIKey: "key", + BaseURL: "url", + LogRequest: os.Getenv("env") == "production", + LogResponse: strings.EqualFold(os.Getenv("env"), "production"), + } + */ + + service := pawapay.NewService(cfg) + + amt := pawapay.Amount{Currency: "GHS", Value: "500"} + description := "sending money to all my children" // this will be truncated to the first 22 characters + pn := pawapay.PhoneNumber{CountryCode: "233", Number: "704584739348"} + + allCorrespondentMappings, err := pawapay.GetAllCorrespondents() + if err != nil { + log.Fatal(err) + } + + // each mapping, think country, have a number of correspondents, pick the one you are trying to send money to + // ideally you will take this as an input and map to the correspondent of your choice + correspondent := allCorrespondentMappings[0].Correspondents[0] + req := pawapay.PayoutRequest{ + Amount: amt, + PhoneNumber: pn, + Description: description, + PayoutId: "uniqueId", + Correspondent: correspondent.Correspondent, + } + + resp, err := service.CreatePayout(time.Now, req) + if err != nil { + log.Printf("something went wrong, we will confirm through their webhook") + + // even in error, depending on the error, you might have access to the annotation + + log.Printf("request failed with status code %v, response payload %s, error=%s", + resp.Annotation.ResponseCode, resp.Annotation.ResponsePayload, err) + } + + log.Printf("response: %+v", resp) +} + + +``` + +> **NOTE** +> You also have access to deposit and refund functionalities +> Check the `client` directory to see a sample implementation and pawapay_test.go file to see sample tests diff --git a/client/main.go b/client/main.go new file mode 100644 index 0000000..2a151da --- /dev/null +++ b/client/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "log" + "time" + + "github.com/Uchencho/pawapay" +) + +func main() { + cfg := pawapay.GetConfigFromEnvVars() + cfg.AllowRequestLogging() // only for debugging, would not advise this for production. Also not necessary, read on for why + + /* + You can also explicitly declare the config + cfg := pawapay.Config{ + APIKey: "key", + BaseURL: "url", + LogRequest: os.Getenv("env") == "production", + LogResponse: strings.EqualFold(os.Getenv("env"), "production"), + } + */ + + service := pawapay.NewService(cfg) + + amt := pawapay.Amount{Currency: "GHS", Value: "500"} + description := "sending money to all my children" // this will be truncated to the first 22 characters + pn := pawapay.PhoneNumber{CountryCode: "233", Number: "704584739348"} + + allCorrespondentMappings, err := pawapay.GetAllCorrespondents() + if err != nil { + log.Fatal(err) + } + + // each mapping, think country, have a number of correspondents, pick the one you are trying to send money to + // ideally you will take this as an input and map to the correspondent of your choice + correspondent := allCorrespondentMappings[0].Correspondents[0] + req := pawapay.PayoutRequest{ + Amount: amt, + PhoneNumber: pn, + Description: description, + PayoutId: "uniqueId", + Correspondent: correspondent.Correspondent, + } + + resp, err := service.CreatePayout(time.Now, req) + if err != nil { + log.Printf("something went wrong, we will confirm through their webhook") + + // even in error, depending on the error, you might have access to the annotation + + log.Printf("request failed with status code %v, response payload %s, error=%s", + resp.Annotation.ResponseCode, resp.Annotation.ResponsePayload, err) + } + + log.Printf("response: %+v", resp) +} diff --git a/default.go b/default.go new file mode 100644 index 0000000..09e9e9c --- /dev/null +++ b/default.go @@ -0,0 +1,418 @@ +package pawapay + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/pariz/gountries" + "github.com/pkg/errors" +) + +const ( + recipientType = "MSISDN" +) + +type OperationType struct { + OperationType string `json:"operationType"` + Status string `json:"status"` +} + +type Correspondent struct { + Correspondent string `json:"correspondent"` + OperationTypes []OperationType `json:"operationTypes"` +} + +type MomoMapping struct { + Country string `json:"country"` + Extension string `json:"extension"` + Correspondents []Correspondent `json:"correspondents"` +} + +type Amount struct { + Value string `json:"value"` + Currency string `json:"currency"` +} + +type PayoutRequest struct { + PayoutId string + Amount Amount + Description string + PhoneNumber PhoneNumber + Correspondent string +} + +type DepositRequest struct { + DepositId string + Amount Amount + Description string + PhoneNumber PhoneNumber + Correspondent string + PreAuthCode string +} + +// PhoneNumber holds country code and number, eg countryCode:234, number: 7017238745 +type PhoneNumber struct { + CountryCode string `json:"countryCode"` + Number string `json:"number"` +} + +type Address struct { + Value string `json:"value"` +} + +type Recipient struct { + Type string `json:"type"` + Address Address `json:"address"` +} + +// APIAnnotation is a representation of provider api request and response +type APIAnnotation struct { + URL string `json:"url"` + RequestPayload string `json:"requestPayload"` + ResponsePayload string `json:"responsePayload"` + ResponseCode int `json:"responseCode"` +} + +type CreatePayoutRequest struct { + PayoutId string `json:"payoutId"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Country string `json:"country"` + Correspondent string `json:"correspondent"` + Recipient Recipient `json:"recipient"` + CustomerTimestamp string `json:"customerTimestamp"` + StatementDescription string `json:"statementDescription"` +} + +type Payer struct { + Type string `json:"type"` + Address Address `json:"address"` +} + +type CreatePayoutResponse struct { + PayoutID string `json:"payoutId"` + Status string `json:"status"` + Created string `json:"created"` + Annotation APIAnnotation +} + +type CreateBulkPayoutResponse struct { + Result []CreatePayoutResponse + Annotation APIAnnotation +} + +type FailureReason struct { + FailureCode string `json:"failureCode"` + FailureMessage string `json:"failureMessage"` +} + +type Payout struct { + Amount string `json:"amount"` + Correspondent string `json:"correspondent"` + CorrespondentIds map[string]interface{} `json:"correspondentIds"` + Country string `json:"country"` + Created string `json:"created"` + Currency string `json:"currency"` + CustomerTimestamp string `json:"customerTimestamp"` + PayoutID string `json:"payoutId"` + Recipient Recipient `json:"recipient"` + StatementDescription string `json:"statementDescription"` + Status string `json:"status"` + FailureReason FailureReason `json:"failureReason"` + Annotation APIAnnotation +} + +// IsSuccessful reports if a payout is successful +func (t Payout) IsSuccessful() bool { return strings.EqualFold(t.Status, "completed") } + +// IsFailed reports if a payout failed +func (t Payout) IsFailed() bool { return strings.EqualFold(t.Status, "failed") } + +// IsPending reports if a payout is still pending +func (t Payout) IsPending() bool { return !t.IsSuccessful() && !t.IsFailed() && t.Status != "" } + +// IsNotFound checks a payout response to see if the transaction is not found +func (t Payout) IsNotFound() bool { + return t.Annotation.ResponseCode == 200 && t.Annotation.ResponsePayload == "[]" +} + +type ResendCallbackRequest struct { + PayoutId string `json:"payoutId,omitempty"` + DepositId string `json:"depositId,omitempty"` + RefundId string `json:"refundId,omitempty"` +} + +type PayoutStatusResponse struct { + PayoutId string `json:"payoutId"` + Status string `json:"status"` + Annotation APIAnnotation +} + +type CreateDepositRequest struct { + DepositId string `json:"depositId"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Country string `json:"country"` + Correspondent string `json:"correspondent"` + Payer Payer `json:"payer"` + CustomerTimestamp string `json:"customerTimestamp"` + StatementDescription string `json:"statementDescription"` + PreAuthorizationCode string `json:"preAuthorisationCode"` +} + +type CreateDepositResponse struct { + DepositId string `json:"depositId"` + Status string `json:"status"` + Created string `json:"created"` + Annotation APIAnnotation +} + +type InitiateRefundResponse struct { + RefundId string `json:"depositId"` + Status string `json:"status"` + Created string `json:"created"` + Annotation APIAnnotation +} + +type Deposit struct { + DepositId string `json:"depositId"` + Status string `json:"status"` + RequestedAmount string `json:"requestedAmount"` + DepositedAmount string `json:"depositedAmount"` + Currency string `json:"currency"` + Country string `json:"country"` + Payer Payer `json:"payer"` + Correspondent string `json:"correspondent"` + StatementDescription string `json:"statementDescription"` + CustomerTimestamp string `json:"customerTimestamp"` + Created string `json:"created"` + RespondedByPayer string `json:"respondedByPayer"` + CorrespondentIds map[string]interface{} `json:"correspondentIds"` + SuspiciousActivity map[string]interface{} `json:"suspiciousActivityReport"` + FailureReason FailureReason `json:"failureReason"` + Annotation APIAnnotation +} + +type DepositStatusResponse struct { + DepositId string `json:"depositId"` + Status string `json:"status"` + Annotation APIAnnotation +} + +type RefundRequest struct { + RefundId string `json:"refundId"` + DepositId string `json:"depositId"` + Amount string `json:"amount"` +} + +type CreateBulkDepositResponse struct { + Result []CreateDepositResponse + Annotation APIAnnotation +} + +type Refund struct { + RefundId string `json:"refundId"` + Status string `json:"status"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Country string `json:"country"` + Recipient Recipient `json:"recipient"` + Correspondent string `json:"correspondent"` + StatementDescription string `json:"statementDescription"` + CustomerTimestamp string `json:"customerTimestamp"` + Created string `json:"created"` + ReceivedByRecipient string `json:"receivedByRecipient"` + CorrespondentIds map[string]interface{} `json:"correspondentIds"` + FailureReason FailureReason `json:"failureReason"` + Annotation APIAnnotation +} + +type RefundStatusResponse struct { + RefundId string `json:"refundId"` + Status string `json:"status"` + Annotation APIAnnotation +} + +// TimeProviderFunc represents a provider of time +type TimeProviderFunc func() time.Time + +func (s *Service) makeRequest(method, resource string, reqBody interface{}, resp interface{}) (APIAnnotation, error) { + + URL := fmt.Sprintf("%s/%s", s.config.BaseURL, resource) + var ( + body io.Reader + requestBody, rb []byte + ) + if reqBody != nil { + + requestBody, err := json.Marshal(reqBody) + if err != nil { + return APIAnnotation{}, errors.Wrap(err, "client - unable to marshal request struct") + } + + // only log request when explicitly asked to do so + if s.config.LogRequest { + rb, _ = json.Marshal(reqBody) + log.Printf("pawapay: making request to route %s with payload %s", URL, rb) + } + + body = bytes.NewReader(requestBody) + } + + if reqBody == nil && s.config.LogRequest { + log.Printf("pawapay: making request to route %s", URL) + } + + req, err := http.NewRequest(method, URL, body) + if err != nil { + return APIAnnotation{}, errors.Wrap(err, "client - unable to create request body") + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.APIKey)) + + res, err := s.client.Do(req) + if err != nil { + return APIAnnotation{}, errors.Wrap(err, "client - failed to execute request") + } + + b, _ := io.ReadAll(res.Body) + if s.config.LogResponse { + log.Printf("pawapay: got response %s code %d", string(b), res.StatusCode) + } + + var apiAnnotation APIAnnotation + apiAnnotation.RequestPayload = string(rb) + apiAnnotation.ResponseCode = res.StatusCode + apiAnnotation.ResponsePayload = string(b) + if !strings.EqualFold(os.Getenv("env"), "testing") { + apiAnnotation.URL = URL + } + + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent && res.StatusCode != http.StatusCreated { + if s.config.LogResponse { + log.Printf("pawapay: error response body: %s for request payload %s", b, requestBody) + } + return apiAnnotation, fmt.Errorf("invalid status code received, expected 200/204/201, got %v with body %s", res.StatusCode, b) + } + + if resp != nil || res.StatusCode != http.StatusNoContent { + if err := json.NewDecoder(bytes.NewReader(b)).Decode(&resp); err != nil { + return apiAnnotation, errors.Wrap(err, "unable to unmarshal response body") + } + } + return apiAnnotation, nil +} + +func (s *Service) newCreatePayoutRequest(timeProvider TimeProviderFunc, payoutId string, amt Amount, countryCode, code, description string, + pn PhoneNumber) CreatePayoutRequest { + layout := "2006-01-02T15:04:05Z" + if len(description) > 22 { + description = description[:22] + } + + return CreatePayoutRequest{ + PayoutId: payoutId, + Amount: amt.Value, + Currency: amt.Currency, + Country: countryCode, + Correspondent: code, + CustomerTimestamp: timeProvider().Format(layout), + StatementDescription: description, + Recipient: Recipient{Type: recipientType, Address: Address{Value: fmt.Sprintf("%s%s", pn.CountryCode, pn.Number)}}, + } +} + +func (s *Service) newCreateBulkPayoutRequest(timeProvider TimeProviderFunc, req []PayoutRequest) ([]CreatePayoutRequest, error) { + requests := []CreatePayoutRequest{} + for _, payload := range req { + + query := gountries.New() + se, err := query.FindCountryByCallingCode(payload.PhoneNumber.CountryCode) + if err != nil { + return []CreatePayoutRequest{}, err + } + + countryCode := se.Alpha3 + + requests = append(requests, s.newCreatePayoutRequest(timeProvider, payload.PayoutId, payload.Amount, + countryCode, payload.Correspondent, payload.Description, payload.PhoneNumber)) + } + + return requests, nil +} + +func (s *Service) newDepositRequest(timeProvider TimeProviderFunc, depositId string, amt Amount, countryCode, code, description string, + pn PhoneNumber, authCode string) CreateDepositRequest { + layout := "2006-01-02T15:04:05Z" + if len(description) > 22 { + description = description[:22] + } + + return CreateDepositRequest{ + DepositId: depositId, + Amount: amt.Value, + Currency: amt.Currency, + Country: countryCode, + Correspondent: code, + CustomerTimestamp: timeProvider().Format(layout), + StatementDescription: description, + PreAuthorizationCode: authCode, + Payer: Payer{Type: recipientType, Address: Address{Value: fmt.Sprintf("%s%s", pn.CountryCode, pn.Number)}}, + } +} + +func (s *Service) newCreateBulkDepositRequest(timeProvider TimeProviderFunc, req []DepositRequest) ([]CreateDepositRequest, error) { + requests := []CreateDepositRequest{} + for _, payload := range req { + + query := gountries.New() + se, err := query.FindCountryByCallingCode(payload.PhoneNumber.CountryCode) + if err != nil { + return []CreateDepositRequest{}, err + } + + countryCode := se.Alpha3 + + requests = append(requests, s.newDepositRequest(timeProvider, payload.DepositId, payload.Amount, + countryCode, payload.Correspondent, payload.Description, payload.PhoneNumber, payload.PreAuthCode)) + } + + return requests, nil +} + +func (s Service) newRefundRequest(refundId, depositId string, amount Amount) RefundRequest { + return RefundRequest{ + RefundId: refundId, + DepositId: depositId, + Amount: amount.Value, + } +} + +func GetAllCorrespondents() ([]MomoMapping, error) { + var path string + + if os.Getenv("BANK_FILE_PATH") != "" { + path = os.Getenv("BANK_FILE_PATH") // USED WHEN TESTING + } + fileName := filepath.Join(path, "momo.json") + bb, err := os.ReadFile(fileName) + if err != nil { + return []MomoMapping{}, err + } + + var momoProviderMappings []MomoMapping + if err := json.Unmarshal(bb, &momoProviderMappings); err != nil { + return []MomoMapping{}, err + } + + return momoProviderMappings, nil +} diff --git a/deposit.go b/deposit.go new file mode 100644 index 0000000..de355e7 --- /dev/null +++ b/deposit.go @@ -0,0 +1,90 @@ +package pawapay + +import ( + "fmt" + "net/http" + + "github.com/pariz/gountries" +) + +// InitiateDeposit provides the functionality of initiating a deposit for the sender to confirm +// See docs https://docs.pawapay.co.uk/#operation/createDesposit for more details +func (s *Service) InitiateDeposit(timeProvider TimeProviderFunc, depositReq DepositRequest) (CreateDepositResponse, error) { + + query := gountries.New() + se, err := query.FindCountryByCallingCode(depositReq.PhoneNumber.CountryCode) + if err != nil { + return CreateDepositResponse{}, err + } + countryCode := se.Alpha3 + + resource := "deposits" + payload := s.newDepositRequest(timeProvider, depositReq.DepositId, depositReq.Amount, countryCode, + depositReq.Correspondent, depositReq.Description, depositReq.PhoneNumber, depositReq.PreAuthCode) + + var response CreateDepositResponse + annotation, err := s.makeRequest(http.MethodPost, resource, payload, &response) + if err != nil { + return CreateDepositResponse{}, err + } + response.Annotation = annotation + + return response, nil +} + +// CreateBulkDeposit provides the functionality of creating a bulk deposit +// See docs https://docs.pawapay.co.uk/#operation/createDeposits for more details +func (s *Service) InitiateBulkDeposit(timeProvider TimeProviderFunc, data []DepositRequest) (CreateBulkDepositResponse, error) { + + resource := "deposits/bulk" + payload, err := s.newCreateBulkDepositRequest(timeProvider, data) + if err != nil { + return CreateBulkDepositResponse{}, err + } + + var response []CreateDepositResponse + annotation, err := s.makeRequest(http.MethodPost, resource, payload, &response) + if err != nil { + return CreateBulkDepositResponse{}, err + } + + return CreateBulkDepositResponse{Result: response, Annotation: annotation}, nil +} + +// GetDeposit provides the functionality of retrieving a deposit +// See docs https://docs.pawapay.co.uk/#operation/getDeposit for more details +func (s *Service) GetDeposit(depositId string) (Deposit, error) { + + resource := fmt.Sprintf("deposits/%s", depositId) + var ( + response []Deposit + result Deposit + ) + + annotation, err := s.makeRequest(http.MethodGet, resource, nil, &response) + if err != nil { + return Deposit{}, err + } + if len(response) > 0 { + result = response[0] + } + result.Annotation = annotation + return result, nil +} + +// ResendDepositCallback provides the functionality of resending a callback (webhook) for a deposit +// See docs https://docs.pawapay.co.uk/#operation/depositsResendCallback for more details +func (s *Service) ResendDepositCallback(depositId string) (DepositStatusResponse, error) { + + resource := "deposits/resend-callback" + payload := ResendCallbackRequest{DepositId: depositId} + + var response DepositStatusResponse + annotation, err := s.makeRequest(http.MethodPost, resource, payload, &response) + if err != nil { + return DepositStatusResponse{}, err + } + response.Annotation = annotation + + return response, nil +} diff --git a/go.mod b/go.mod index a02427a..bdeeeb9 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,16 @@ module github.com/Uchencho/pawapay go 1.20 + +require ( + github.com/pariz/gountries v0.1.6 + github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..63c502b --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pariz/gountries v0.1.6 h1:Cu8sBSvD6HvAtzinKJ7Yw8q4wAF2dD7oXjA5yDJQt1I= +github.com/pariz/gountries v0.1.6/go.mod h1:Et5QWMc75++5nUKSYKNtz/uc+2LHl4LKhNd6zwdTu+0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/momo.json b/momo.json new file mode 100644 index 0000000..99be24e --- /dev/null +++ b/momo.json @@ -0,0 +1,451 @@ +[ + { + "country": "GHA", + "correspondents": [ + { + "correspondent": "VODAFONE_GHA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "MTN_MOMO_GHA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "AIRTELTIGO_GHA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "CMR", + "correspondents": [ + { + "correspondent": "MTN_MOMO_CMR", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "ORANGE_CMR", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "CLOSED" } + ] + } + ] + }, + { + "country": "NGA", + "correspondents": [ + { + "correspondent": "AIRTEL_NGA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "MTN_MOMO_NGA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "FLUTTERWAVE_NGA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "PALMPAY_NGA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "MTN_AIRTIME_NGA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "PAYSTACK_NGA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "OPAY_NGA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "BEN", + "correspondents": [ + { + "correspondent": "SHARPVISION_MTN_MOMO_BEN", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "SHARPVISION_MOOV_BEN", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "MOOV_BEN", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "MTN_MOMO_BEN", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "SHARPVISION_CELTIIS_BEN", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "MLI", + "correspondents": [ + { + "correspondent": "ORANGE_MLI", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "UGA", + "correspondents": [ + { + "correspondent": "MTN_MOMO_OAPI_UGA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "DELAYED" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "MTN_MOMO_UGA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "AIRTEL_OAPI_UGA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "AIRTEL_UGA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "CLOSED" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "ZMB", + "correspondents": [ + { + "correspondent": "ZAMTEL_ZMB", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "MTN_MOMO_ZMB", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "NFS_ZMB", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "AIRTEL_OAPI_ZMB", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "AIRTEL_ZMB", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "CIV", + "correspondents": [ + { + "correspondent": "MOOV_CIV", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "ORANGE_CIV", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "MTN_MOMO_CIV", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "WAVE_CIV", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "KEN", + "correspondents": [ + { + "correspondent": "MPESA_KEN", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "COD", + "correspondents": [ + { + "correspondent": "VODACOM_MPESA_COD", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "AIRTEL_COD", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "ORANGE_COD", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "MOZ", + "correspondents": [ + { + "correspondent": "MOVITEL_MOZ", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "VODACOM_MOZ", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "COG", + "correspondents": [ + { + "correspondent": "MTN_MOMO_COG", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "TZA", + "correspondents": [ + { + "correspondent": "HALOTEL_TZA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "AIRTEL_TZA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "VODACOM_TZA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "TIGO_TZA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "BFA", + "correspondents": [ + { + "correspondent": "ORANGE_BFA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "SEN", + "correspondents": [ + { + "correspondent": "EXPRESSO_SEN", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "FREE_SEN", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "WAVE_SEN", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "ORANGE_SEN", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "RWA", + "correspondents": [ + { + "correspondent": "AIRTEL_RWA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "MTN_MOMO_RWA", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + }, + { + "country": "MWI", + "correspondents": [ + { + "correspondent": "TNM_MWI", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "AIRTEL_MWI", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + }, + { + "correspondent": "TNM_MPAMBA_MWI", + "operationTypes": [ + { "operationType": "PAYOUT", "status": "OPERATIONAL" }, + { "operationType": "DEPOSIT", "status": "OPERATIONAL" } + ] + } + ] + } +] diff --git a/pawapay_test.go b/pawapay_test.go new file mode 100644 index 0000000..85a5478 --- /dev/null +++ b/pawapay_test.go @@ -0,0 +1,728 @@ +package pawapay_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Uchencho/pawapay" + "github.com/stretchr/testify/assert" +) + +const ( + testPayoutId = "d334c312-6c18-4d7e-a0f1-097d398543d3" + testDepositId = "d334c312-6c18-4d7e-a0f1-097d398543d3" +) + +func timeProvider() pawapay.TimeProviderFunc { + return func() time.Time { + t, _ := time.Parse("2006-01-02", "2021-01-01") + return t + } +} + +func fileToStruct(filepath string, s interface{}) io.Reader { + bb, _ := os.ReadFile(filepath) + json.Unmarshal(bb, s) + return bytes.NewReader(bb) +} + +type row struct { + Name string + Input interface{} + CustomServerURL func(t *testing.T) string +} + +func TestCreatePayout(t *testing.T) { + table := []row{ + { + Name: "Creating payout succeeds", + Input: pawapay.PayoutRequest{ + PayoutId: testPayoutId, + Amount: pawapay.Amount{Currency: "GHS", Value: "1000"}, + Description: "test", + PhoneNumber: pawapay.PhoneNumber{CountryCode: "233", Number: "247492147"}, + Correspondent: "MTN_MOMO_GHA", + }, + CustomServerURL: func(t *testing.T) string { + pawapayService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + var actualBody, expectedBody pawapay.CreatePayoutRequest + + if err := json.NewDecoder(req.Body).Decode(&actualBody); err != nil { + log.Printf("error in unmarshalling %+v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + t.Run("URL and request method is as expected", func(t *testing.T) { + expectedURL := "/payouts" + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, expectedURL, req.RequestURI) + }) + + t.Run("Request is as expected", func(t *testing.T) { + fileToStruct(filepath.Join("testdata", "create-payout-request.json"), &expectedBody) + assert.Equal(t, expectedBody, actualBody) + }) + + var resp pawapay.CreatePayoutResponse + fileToStruct(filepath.Join("testdata", "create-payout-response.json"), &resp) + + w.WriteHeader(http.StatusOK) + bb, _ := json.Marshal(resp) + w.Write(bb) + + })) + return pawapayService.URL + }, + }, + } + + for _, row := range table { + + c := pawapay.NewService(pawapay.Config{ + BaseURL: row.CustomServerURL(t), + }) + + req := row.Input.(pawapay.PayoutRequest) + + log.Printf("======== Running row: %s ==========", row.Name) + + _, err := c.CreatePayout(timeProvider(), req) + t.Run("No error is returned", func(t *testing.T) { + assert.NoError(t, err) + }) + + } +} + +func TestCreateBulkPayout(t *testing.T) { + table := []row{ + { + Name: "Creating bulk payout succeeds", + Input: []pawapay.PayoutRequest{ + { + PayoutId: testPayoutId, + Amount: pawapay.Amount{Currency: "GHS", Value: "1000"}, + Description: "test", + PhoneNumber: pawapay.PhoneNumber{CountryCode: "233", Number: "247492147"}, + Correspondent: "MTN_MOMO_GHA", + }, + }, + CustomServerURL: func(t *testing.T) string { + pawapayService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + var actualBody, expectedBody []pawapay.CreatePayoutRequest + + if err := json.NewDecoder(req.Body).Decode(&actualBody); err != nil { + log.Printf("error in unmarshalling %+v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + t.Run("URL and request method is as expected", func(t *testing.T) { + expectedURL := "/payouts/bulk" + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, expectedURL, req.RequestURI) + }) + + t.Run("Request payload is as expected", func(t *testing.T) { + fileToStruct(filepath.Join("testdata", "create-bulk-payout-request.json"), &expectedBody) + assert.Equal(t, expectedBody, actualBody) + }) + + var resp []pawapay.CreatePayoutResponse + fileToStruct(filepath.Join("testdata", "create-bulk-payout-response.json"), &resp) + + w.WriteHeader(http.StatusOK) + bb, _ := json.Marshal(resp) + w.Write(bb) + + })) + return pawapayService.URL + }, + }, + } + + for _, row := range table { + + c := pawapay.NewService(pawapay.Config{ + BaseURL: row.CustomServerURL(t), + }) + + req := row.Input.([]pawapay.PayoutRequest) + + log.Printf("======== Running row: %s ==========", row.Name) + + _, err := c.CreateBulkPayout(timeProvider(), req) + t.Run("No error is returned", func(t *testing.T) { + assert.NoError(t, err) + }) + + } +} + +func TestGetPayout(t *testing.T) { + table := []row{ + { + Name: "Retrieving payout succeeds", + Input: testPayoutId, + CustomServerURL: func(t *testing.T) string { + pawapayService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + t.Run("URL and request method is as expected", func(t *testing.T) { + expectedURL := fmt.Sprintf("/payouts/%s", testPayoutId) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, expectedURL, req.RequestURI) + }) + + var resp []pawapay.Payout + fileToStruct(filepath.Join("testdata", "get-payout-response.json"), &resp) + + w.WriteHeader(http.StatusOK) + bb, _ := json.Marshal(resp) + w.Write(bb) + + })) + return pawapayService.URL + }, + }, + } + + for _, row := range table { + + c := pawapay.NewService(pawapay.Config{ + BaseURL: row.CustomServerURL(t), + }) + + req := row.Input.(string) + + log.Printf("======== Running row: %s ==========", row.Name) + + _, err := c.GetPayout(req) + t.Run("No error is returned", func(t *testing.T) { + assert.NoError(t, err) + }) + + } +} + +func TestResendPayoutCallBack(t *testing.T) { + table := []row{ + { + Name: "Resend payout callback succeeds", + Input: testPayoutId, + CustomServerURL: func(t *testing.T) string { + pawapayService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + var actualBody, expectedBody pawapay.ResendCallbackRequest + expectedBody.PayoutId = testPayoutId + + if err := json.NewDecoder(req.Body).Decode(&actualBody); err != nil { + log.Printf("error in unmarshalling %+v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + t.Run("URL and request method is as expected", func(t *testing.T) { + expectedURL := "/payouts/resend-callback" + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, expectedURL, req.RequestURI) + }) + + t.Run("Request payload is as expected", func(t *testing.T) { + assert.Equal(t, expectedBody, actualBody) + }) + + var resp pawapay.PayoutStatusResponse + fileToStruct(filepath.Join("testdata", "payout-status-response.json"), &resp) + + w.WriteHeader(http.StatusOK) + bb, _ := json.Marshal(resp) + w.Write(bb) + + })) + return pawapayService.URL + }, + }, + } + + for _, row := range table { + + c := pawapay.NewService(pawapay.Config{ + BaseURL: row.CustomServerURL(t), + }) + + req := row.Input.(string) + + log.Printf("======== Running row: %s ==========", row.Name) + + _, err := c.ResendPayoutCallback(req) + t.Run("No error is returned", func(t *testing.T) { + assert.NoError(t, err) + }) + + } +} + +func TestFailEnqueuedPayout(t *testing.T) { + table := []row{ + { + Name: "Fail enqueued payout", + Input: testPayoutId, + CustomServerURL: func(t *testing.T) string { + pawapayService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + t.Run("URL and request method is as expected", func(t *testing.T) { + expectedURL := fmt.Sprintf("/payouts/fail-enqueued/%s", testPayoutId) + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, expectedURL, req.RequestURI) + }) + + var resp pawapay.PayoutStatusResponse + fileToStruct(filepath.Join("testdata", "payout-status-response.json"), &resp) + + w.WriteHeader(http.StatusOK) + bb, _ := json.Marshal(resp) + w.Write(bb) + + })) + return pawapayService.URL + }, + }, + } + + for _, row := range table { + + c := pawapay.NewService(pawapay.Config{ + BaseURL: row.CustomServerURL(t), + }) + + req := row.Input.(string) + + log.Printf("======== Running row: %s ==========", row.Name) + + _, err := c.FailEnqueued(req) + t.Run("No error is returned", func(t *testing.T) { + assert.NoError(t, err) + }) + } +} + +func TestCreateDeposit(t *testing.T) { + table := []row{ + { + Name: "deposit is initialized successfully", + Input: pawapay.DepositRequest{ + DepositId: testDepositId, + Amount: pawapay.Amount{Currency: "GHS", Value: "1000"}, + Description: "test", + PhoneNumber: pawapay.PhoneNumber{CountryCode: "233", Number: "247492147"}, + Correspondent: "MTN_MOMO_GHA", + }, + CustomServerURL: func(t *testing.T) string { + pawapayService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + var actualBody, expectedBody pawapay.CreateDepositRequest + + if err := json.NewDecoder(req.Body).Decode(&actualBody); err != nil { + log.Printf("error in unmarshalling %+v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + t.Run("URL and request method is as expected", func(t *testing.T) { + expectedURL := "/deposits" + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, expectedURL, req.RequestURI) + }) + + t.Run("Request is as expected", func(t *testing.T) { + fileToStruct(filepath.Join("testdata", "create-deposit-request.json"), &expectedBody) + assert.Equal(t, expectedBody, actualBody) + }) + + var resp pawapay.CreateDepositResponse + fileToStruct(filepath.Join("testdata", "create-deposit-response.json"), &resp) + + w.WriteHeader(http.StatusOK) + bb, _ := json.Marshal(resp) + w.Write(bb) + + })) + return pawapayService.URL + }, + }, + } + + for _, row := range table { + + c := pawapay.NewService(pawapay.Config{ + BaseURL: row.CustomServerURL(t), + }) + + req := row.Input.(pawapay.DepositRequest) + + log.Printf("======== Running row: %s ==========", row.Name) + + _, err := c.InitiateDeposit(timeProvider(), req) + t.Run("No error is returned", func(t *testing.T) { + assert.NoError(t, err) + }) + + } +} + +func TestCreateBulkDeposit(t *testing.T) { + table := []row{ + { + Name: "bulk deposit is initialized successfully", + Input: []pawapay.DepositRequest{ + { + DepositId: testDepositId, + Amount: pawapay.Amount{Currency: "GHS", Value: "1000"}, + Description: "test", + PhoneNumber: pawapay.PhoneNumber{CountryCode: "233", Number: "247492147"}, + Correspondent: "MTN_MOMO_GHA", + PreAuthCode: "QJS3RSK", + }, + }, + CustomServerURL: func(t *testing.T) string { + pawapayService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + var actualBody, expectedBody []pawapay.CreateDepositRequest + + if err := json.NewDecoder(req.Body).Decode(&actualBody); err != nil { + log.Printf("error in unmarshalling %+v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + t.Run("URL and request method is as expected", func(t *testing.T) { + expectedURL := "/deposits/bulk" + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, expectedURL, req.RequestURI) + }) + + t.Run("Request is as expected", func(t *testing.T) { + fileToStruct(filepath.Join("testdata", "create-bulk-deposit-request.json"), &expectedBody) + assert.Equal(t, expectedBody, actualBody) + }) + + var resp []pawapay.CreateDepositResponse + fileToStruct(filepath.Join("testdata", "create-bulk-deposit-response.json"), &resp) + + w.WriteHeader(http.StatusOK) + bb, _ := json.Marshal(resp) + w.Write(bb) + + })) + return pawapayService.URL + }, + }, + } + + for _, row := range table { + + c := pawapay.NewService(pawapay.Config{ + BaseURL: row.CustomServerURL(t), + }) + + req := row.Input.([]pawapay.DepositRequest) + + log.Printf("======== Running row: %s ==========", row.Name) + + _, err := c.InitiateBulkDeposit(timeProvider(), req) + t.Run("No error is returned", func(t *testing.T) { + assert.NoError(t, err) + }) + } +} + +func TestGetDeposit(t *testing.T) { + table := []row{ + { + Name: "Retrieving deposit succeeds", + Input: testDepositId, + CustomServerURL: func(t *testing.T) string { + pawapayService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + t.Run("URL and request method is as expected", func(t *testing.T) { + expectedURL := fmt.Sprintf("/deposits/%s", testDepositId) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, expectedURL, req.RequestURI) + }) + + var resp []pawapay.Deposit + fileToStruct(filepath.Join("testdata", "get-deposit-response.json"), &resp) + + w.WriteHeader(http.StatusOK) + bb, _ := json.Marshal(resp) + w.Write(bb) + + })) + return pawapayService.URL + }, + }, + } + + for _, row := range table { + + c := pawapay.NewService(pawapay.Config{ + BaseURL: row.CustomServerURL(t), + }) + + req := row.Input.(string) + + log.Printf("======== Running row: %s ==========", row.Name) + + _, err := c.GetDeposit(req) + t.Run("No error is returned", func(t *testing.T) { + assert.NoError(t, err) + }) + } +} + +func TestResendDepositCallBack(t *testing.T) { + table := []row{ + { + Name: "Resend payout callback succeeds", + Input: testDepositId, + CustomServerURL: func(t *testing.T) string { + pawapayService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + var actualBody, expectedBody pawapay.ResendCallbackRequest + expectedBody.DepositId = testDepositId + + if err := json.NewDecoder(req.Body).Decode(&actualBody); err != nil { + log.Printf("error in unmarshalling %+v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + t.Run("URL and request method is as expected", func(t *testing.T) { + expectedURL := "/deposits/resend-callback" + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, expectedURL, req.RequestURI) + }) + + t.Run("Request payload is as expected", func(t *testing.T) { + assert.Equal(t, expectedBody, actualBody) + }) + + var resp pawapay.DepositStatusResponse + fileToStruct(filepath.Join("testdata", "deposit-status-response.json"), &resp) + + w.WriteHeader(http.StatusOK) + bb, _ := json.Marshal(resp) + w.Write(bb) + + })) + return pawapayService.URL + }, + }, + } + + for _, row := range table { + + c := pawapay.NewService(pawapay.Config{ + BaseURL: row.CustomServerURL(t), + }) + + req := row.Input.(string) + + log.Printf("======== Running row: %s ==========", row.Name) + + _, err := c.ResendDepositCallback(req) + t.Run("No error is returned", func(t *testing.T) { + assert.NoError(t, err) + }) + + } +} + +func TestRequestRefund(t *testing.T) { + + type refundPayload struct { + DepositId string + Amount pawapay.Amount + RefundId string + } + + table := []row{ + { + Name: "refund is requested successfully", + Input: refundPayload{ + DepositId: testDepositId, + Amount: pawapay.Amount{Currency: "GHS", Value: "1000"}, + RefundId: testDepositId, + }, + CustomServerURL: func(t *testing.T) string { + pawapayService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + var actualBody, expectedBody pawapay.RefundRequest + + if err := json.NewDecoder(req.Body).Decode(&actualBody); err != nil { + log.Printf("error in unmarshalling %+v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + t.Run("URL and request method is as expected", func(t *testing.T) { + expectedURL := "/refunds" + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, expectedURL, req.RequestURI) + }) + + t.Run("Request is as expected", func(t *testing.T) { + fileToStruct(filepath.Join("testdata", "refund-request.json"), &expectedBody) + assert.Equal(t, expectedBody, actualBody) + }) + + var resp pawapay.InitiateRefundResponse + fileToStruct(filepath.Join("testdata", "refund-response.json"), &resp) + + w.WriteHeader(http.StatusOK) + bb, _ := json.Marshal(resp) + w.Write(bb) + + })) + return pawapayService.URL + }, + }, + } + + for _, row := range table { + + c := pawapay.NewService(pawapay.Config{ + BaseURL: row.CustomServerURL(t), + }) + + req := row.Input.(refundPayload) + + log.Printf("======== Running row: %s ==========", row.Name) + + _, err := c.RequestRefund(req.RefundId, req.DepositId, req.Amount) + t.Run("No error is returned", func(t *testing.T) { + assert.NoError(t, err) + }) + + } +} + +func TestGetRefund(t *testing.T) { + table := []row{ + { + Name: "Retrieving refund succeeds", + Input: testDepositId, + CustomServerURL: func(t *testing.T) string { + pawapayService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + t.Run("URL and request method is as expected", func(t *testing.T) { + expectedURL := fmt.Sprintf("/refunds/%s", testDepositId) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, expectedURL, req.RequestURI) + }) + + var resp []pawapay.Refund + fileToStruct(filepath.Join("testdata", "get-refund-response.json"), &resp) + + w.WriteHeader(http.StatusOK) + bb, _ := json.Marshal(resp) + w.Write(bb) + + })) + return pawapayService.URL + }, + }, + } + + for _, row := range table { + + c := pawapay.NewService(pawapay.Config{ + BaseURL: row.CustomServerURL(t), + }) + + req := row.Input.(string) + + log.Printf("======== Running row: %s ==========", row.Name) + + result, err := c.GetRefund(req) + t.Run("No error is returned", func(t *testing.T) { + assert.NoError(t, err) + }) + + t.Run("Annotation response code is as expected", func(t *testing.T) { + assert.Equal(t, http.StatusOK, result.Annotation.ResponseCode) + }) + } +} + +func TestResendRefundCallBack(t *testing.T) { + table := []row{ + { + Name: "Resend refund callback succeeds", + Input: testDepositId, + CustomServerURL: func(t *testing.T) string { + pawapayService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + var actualBody, expectedBody pawapay.ResendCallbackRequest + expectedBody.RefundId = testDepositId + + if err := json.NewDecoder(req.Body).Decode(&actualBody); err != nil { + log.Printf("error in unmarshalling %+v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + t.Run("URL and request method is as expected", func(t *testing.T) { + expectedURL := "/refunds/resend-callback" + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, expectedURL, req.RequestURI) + }) + + t.Run("Request payload is as expected", func(t *testing.T) { + assert.Equal(t, expectedBody, actualBody) + }) + + var resp pawapay.RefundStatusResponse + fileToStruct(filepath.Join("testdata", "refund-status-response.json"), &resp) + + w.WriteHeader(http.StatusOK) + bb, _ := json.Marshal(resp) + w.Write(bb) + + })) + return pawapayService.URL + }, + }, + } + + for _, row := range table { + + c := pawapay.NewService(pawapay.Config{ + BaseURL: row.CustomServerURL(t), + }) + + req := row.Input.(string) + + log.Printf("======== Running row: %s ==========", row.Name) + + result, err := c.ResendRefundCallback(req) + t.Run("No error is returned", func(t *testing.T) { + assert.NoError(t, err) + }) + t.Run("Annotation response code is as expected", func(t *testing.T) { + assert.Equal(t, http.StatusOK, result.Annotation.ResponseCode) + }) + } +} diff --git a/payout.go b/payout.go new file mode 100644 index 0000000..ee1d3b2 --- /dev/null +++ b/payout.go @@ -0,0 +1,155 @@ +package pawapay + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/pariz/gountries" +) + +// Config represents the pawapay config +type Config struct { + BaseURL string + APIKey string + LogRequest bool + LogResponse bool +} + +// Service is a representation of a pawapay service +type Service struct { + config Config + client *http.Client +} + +// ConfigProvider pawapay config provider +type ConfigProvider func() Config + +// GetConfigFromEnvVars returns config configurations from environment variables +func GetConfigFromEnvVars() Config { + return Config{ + BaseURL: os.Getenv("PAWAPAY_API_URL"), + APIKey: os.Getenv("PAWAPAY_API_KEY"), + } +} + +// functionality to allow you log request payload. This is only necessary for debugging as all response +// objects return the api annotation giving all the required details of the request +func (c *Config) AllowRequestLogging() { c.LogRequest = true } + +// functionality to allow you log response payload. This is only necessary for debugging as all response +// objects return the api annotation giving all the required details of the request +func (c *Config) AllowResponseLogging() { c.LogResponse = true } + +// functionality to allow the package log request and responses +func (c *Config) AllowLogging() { + c.AllowRequestLogging() + c.AllowResponseLogging() +} + +// NewService returns a new pawapay service +func NewService(c Config) Service { + return Service{ + config: c, + client: &http.Client{Timeout: 60 * time.Second}, + } +} + +// CreatePayout provides the functionality of creating a payout +// See docs https://docs.pawapay.co.uk/#operation/createPayout for more details +func (s *Service) CreatePayout(timeProvider TimeProviderFunc, payoutReq PayoutRequest) (CreatePayoutResponse, error) { + + query := gountries.New() + se, err := query.FindCountryByCallingCode(payoutReq.PhoneNumber.CountryCode) + if err != nil { + return CreatePayoutResponse{}, err + } + countryCode := se.Alpha3 + + resource := "payouts" + payload := s.newCreatePayoutRequest(timeProvider, payoutReq.PayoutId, payoutReq.Amount, countryCode, + payoutReq.Correspondent, payoutReq.Description, payoutReq.PhoneNumber) + + var response CreatePayoutResponse + annotation, err := s.makeRequest(http.MethodPost, resource, payload, &response) + if err != nil { + return CreatePayoutResponse{}, err + } + response.Annotation = annotation + + return response, nil +} + +// CreateBulkPayout provides the functionality of creating a bulk payout +// See docs https://docs.pawapay.co.uk/#operation/createPayout for more details +func (s *Service) CreateBulkPayout(timeProvider TimeProviderFunc, data []PayoutRequest) (CreateBulkPayoutResponse, error) { + + resource := "payouts/bulk" + payload, err := s.newCreateBulkPayoutRequest(timeProvider, data) + if err != nil { + return CreateBulkPayoutResponse{}, err + } + + var response []CreatePayoutResponse + annotation, err := s.makeRequest(http.MethodPost, resource, payload, &response) + if err != nil { + return CreateBulkPayoutResponse{}, err + } + + return CreateBulkPayoutResponse{Result: response, Annotation: annotation}, nil +} + +// GetPayout provides the functionality of retrieving a payout +// See docs https://docs.pawapay.co.uk/#operation/getPayout for more details +func (s *Service) GetPayout(payoutId string) (Payout, error) { + + resource := fmt.Sprintf("payouts/%s", payoutId) + var ( + response []Payout + result Payout + ) + + annotation, err := s.makeRequest(http.MethodGet, resource, nil, &response) + if err != nil { + return Payout{}, err + } + if len(response) > 0 { + result = response[0] + } + result.Annotation = annotation + return result, nil +} + +// ResendPayoutCallback provides the functionality of resending a callback (webhook) +// See docs https://docs.pawapay.co.uk/#operation/payoutsResendCallback for more details +func (s *Service) ResendPayoutCallback(payoutId string) (PayoutStatusResponse, error) { + + resource := "payouts/resend-callback" + payload := ResendCallbackRequest{PayoutId: payoutId} + + var response PayoutStatusResponse + annotation, err := s.makeRequest(http.MethodPost, resource, payload, &response) + if err != nil { + return PayoutStatusResponse{}, err + } + response.Annotation = annotation + + return response, nil +} + +// FailEnqueued provides the functionality of failing an already created payout +// See docs https://docs.pawapay.co.uk/#operation/payoutsFailEnqueued for more details +func (s *Service) FailEnqueued(payoutId string) (PayoutStatusResponse, error) { + + resource := fmt.Sprintf("payouts/fail-enqueued/%s", payoutId) + + var response PayoutStatusResponse + annotation, err := s.makeRequest(http.MethodPost, resource, nil, &response) + if err != nil { + return PayoutStatusResponse{}, err + } + response.Annotation = annotation + + return response, nil +} diff --git a/refund.go b/refund.go new file mode 100644 index 0000000..c005a06 --- /dev/null +++ b/refund.go @@ -0,0 +1,61 @@ +package pawapay + +import ( + "fmt" + "net/http" +) + +// RequestRefund provides the functionality of requesting a refund for an initiated deposit +// See docs https://docs.pawapay.co.uk/#operation/depositWebhook for more details +func (s *Service) RequestRefund(refundId, depositId string, amount Amount) (InitiateRefundResponse, error) { + + resource := "refunds" + payload := s.newRefundRequest(refundId, depositId, amount) + + var response InitiateRefundResponse + annotation, err := s.makeRequest(http.MethodPost, resource, payload, &response) + if err != nil { + return InitiateRefundResponse{}, err + } + response.Annotation = annotation + + return response, nil +} + +// GetRefund provides the functionality of retrieving an initiated refund +// See docs https://docs.pawapay.co.uk/#operation/getRefund for more details +func (s *Service) GetRefund(refundId string) (Refund, error) { + + resource := fmt.Sprintf("refunds/%s", refundId) + var ( + response []Refund + result Refund + ) + + annotation, err := s.makeRequest(http.MethodGet, resource, nil, &response) + if err != nil { + return Refund{}, err + } + if len(response) > 0 { + result = response[0] + } + result.Annotation = annotation + return result, nil +} + +// ResendRefundCallback provides the functionality of resending a callback (webhook) +// See docs https://docs.pawapay.co.uk/#operation/refundsResendCallback for more details +func (s *Service) ResendRefundCallback(refundId string) (RefundStatusResponse, error) { + + resource := "refunds/resend-callback" + payload := ResendCallbackRequest{RefundId: refundId} + + var response RefundStatusResponse + annotation, err := s.makeRequest(http.MethodPost, resource, payload, &response) + if err != nil { + return RefundStatusResponse{}, err + } + response.Annotation = annotation + + return response, nil +} diff --git a/testdata/create-bulk-deposit-request.json b/testdata/create-bulk-deposit-request.json new file mode 100644 index 0000000..e0703e8 --- /dev/null +++ b/testdata/create-bulk-deposit-request.json @@ -0,0 +1,16 @@ +[ + { + "depositId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "amount": "1000", + "currency": "GHS", + "country": "GHA", + "correspondent": "MTN_MOMO_GHA", + "payer": { + "type": "MSISDN", + "address": { "value": "233247492147" } + }, + "customerTimestamp": "2021-01-01T00:00:00Z", + "statementDescription": "test", + "preAuthorisationCode": "QJS3RSK" + } +] diff --git a/testdata/create-bulk-deposit-response.json b/testdata/create-bulk-deposit-response.json new file mode 100644 index 0000000..2016ba1 --- /dev/null +++ b/testdata/create-bulk-deposit-response.json @@ -0,0 +1,7 @@ +[ + { + "depositId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "status": "ACCEPTED", + "created": "2020-10-19T11:17:01Z" + } +] diff --git a/testdata/create-bulk-payout-request.json b/testdata/create-bulk-payout-request.json new file mode 100644 index 0000000..301e8c5 --- /dev/null +++ b/testdata/create-bulk-payout-request.json @@ -0,0 +1,15 @@ +[ + { + "payoutId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "amount": "1000", + "currency": "GHS", + "country": "GHA", + "correspondent": "MTN_MOMO_GHA", + "recipient": { + "type": "MSISDN", + "address": { "value": "233247492147" } + }, + "customerTimestamp": "2021-01-01T00:00:00Z", + "statementDescription": "test" + } +] diff --git a/testdata/create-bulk-payout-response.json b/testdata/create-bulk-payout-response.json new file mode 100644 index 0000000..8c7b643 --- /dev/null +++ b/testdata/create-bulk-payout-response.json @@ -0,0 +1,7 @@ +[ + { + "payoutId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "status": "ACCEPTED", + "created": "2020-10-19T11:17:01Z" + } +] diff --git a/testdata/create-deposit-request.json b/testdata/create-deposit-request.json new file mode 100644 index 0000000..a61bc7b --- /dev/null +++ b/testdata/create-deposit-request.json @@ -0,0 +1,13 @@ +{ + "depositId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "amount": "1000", + "currency": "GHS", + "country": "GHA", + "correspondent": "MTN_MOMO_GHA", + "payer": { + "type": "MSISDN", + "address": { "value": "233247492147" } + }, + "customerTimestamp": "2021-01-01T00:00:00Z", + "statementDescription": "test" +} diff --git a/testdata/create-deposit-response.json b/testdata/create-deposit-response.json new file mode 100644 index 0000000..6b90813 --- /dev/null +++ b/testdata/create-deposit-response.json @@ -0,0 +1,5 @@ +{ + "depositId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "status": "ACCEPTED", + "created": "2020-10-19T11:17:01Z" +} diff --git a/testdata/create-payout-request.json b/testdata/create-payout-request.json new file mode 100644 index 0000000..feb14ce --- /dev/null +++ b/testdata/create-payout-request.json @@ -0,0 +1,13 @@ +{ + "payoutId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "amount": "1000", + "currency": "GHS", + "country": "GHA", + "correspondent": "MTN_MOMO_GHA", + "recipient": { + "type": "MSISDN", + "address": { "value": "233247492147" } + }, + "customerTimestamp": "2021-01-01T00:00:00Z", + "statementDescription": "test" +} diff --git a/testdata/create-payout-response.json b/testdata/create-payout-response.json new file mode 100644 index 0000000..72368fe --- /dev/null +++ b/testdata/create-payout-response.json @@ -0,0 +1,5 @@ +{ + "payoutId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "status": "ACCEPTED", + "created": "2020-10-19T11:17:01Z" +} diff --git a/testdata/deposit-status-response.json b/testdata/deposit-status-response.json new file mode 100644 index 0000000..dbb0c60 --- /dev/null +++ b/testdata/deposit-status-response.json @@ -0,0 +1,4 @@ +{ + "depositId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "status": "ACCEPTED" +} diff --git a/testdata/get-deposit-response.json b/testdata/get-deposit-response.json new file mode 100644 index 0000000..d6cf238 --- /dev/null +++ b/testdata/get-deposit-response.json @@ -0,0 +1,30 @@ +[ + { + "depositId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "status": "COMPLETED", + "requestedAmount": "200.00", + "depositedAmount": "1.00", + "currency": "ZMW", + "country": "ZMB", + "payer": { + "type": "MSISDN", + "address": { + "value": "260961234567" + } + }, + "correspondent": "MTN_MOMO_ZMB", + "statementDescription": "To ACME company", + "customerTimestamp": "2020-10-19T08:17:00Z", + "created": "2020-10-19T08:17:01Z", + "respondedByPayer": "2020-10-19T08:17:02Z", + "correspondentIds": { + "SOME_CORRESPONDENT_ID": "12356789" + }, + "suspiciousActivityReport": [ + { + "activityType": "AMOUNT_DISCREPANCY", + "comment": "There is a discrepancy between requested and actual deposit amount has been detected." + } + ] + } +] diff --git a/testdata/get-payout-response.json b/testdata/get-payout-response.json new file mode 100644 index 0000000..11eb2b9 --- /dev/null +++ b/testdata/get-payout-response.json @@ -0,0 +1,23 @@ +[ + { + "payoutId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "status": "COMPLETED", + "amount": "123.45", + "currency": "ZMW", + "country": "ZMB", + "correspondent": "MTN_MOMO_ZMB", + "recipient": { + "type": "MSISDN", + "address": { + "value": "256780334452" + } + }, + "customerTimestamp": "2020-10-19T08:17:00Z", + "statementDescription": "From ACME company", + "created": "2020-10-19T08:17:01Z", + "receivedByRecipient": "2020-10-19T08:17:02Z", + "correspondentIds": { + "SOME_CORRESPONDENT_ID": "12356789" + } + } +] diff --git a/testdata/get-refund-response.json b/testdata/get-refund-response.json new file mode 100644 index 0000000..a1044ef --- /dev/null +++ b/testdata/get-refund-response.json @@ -0,0 +1,23 @@ +[ + { + "refundId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "status": "COMPLETED", + "amount": "123.45", + "currency": "ZMW", + "country": "ZMB", + "correspondent": "MTN_MOMO_ZMB", + "recipient": { + "type": "MSISDN", + "address": { + "value": "260961234567" + } + }, + "customerTimestamp": "2020-10-19T08:17:00Z", + "statementDescription": "From ACME company", + "created": "2020-10-19T08:17:01Z", + "receivedByRecipient": "2020-10-19T08:17:02Z", + "correspondentIds": { + "SOME_CORRESPONDENT_ID": "12356789" + } + } +] diff --git a/testdata/payout-status-response.json b/testdata/payout-status-response.json new file mode 100644 index 0000000..70b7246 --- /dev/null +++ b/testdata/payout-status-response.json @@ -0,0 +1,4 @@ +{ + "payoutId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "status": "ACCEPTED" +} diff --git a/testdata/refund-request.json b/testdata/refund-request.json new file mode 100644 index 0000000..df9c371 --- /dev/null +++ b/testdata/refund-request.json @@ -0,0 +1,5 @@ +{ + "refundId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "depositId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "amount": "1000" +} diff --git a/testdata/refund-response.json b/testdata/refund-response.json new file mode 100644 index 0000000..629d992 --- /dev/null +++ b/testdata/refund-response.json @@ -0,0 +1,5 @@ +{ + "refundId": "d334c312-6c18-4d7e-a0f1-097d398543d3", + "status": "ACCEPTED", + "created": "2020-10-19T11:17:01Z" +} diff --git a/testdata/refund-status-response.json b/testdata/refund-status-response.json new file mode 100644 index 0000000..fa05f2d --- /dev/null +++ b/testdata/refund-status-response.json @@ -0,0 +1,4 @@ +{ + "refundId": "f4401bd2-1568-4140-bf2d-eb77d2b2b639", + "status": "ACCEPTED" +}