From 2424718851f0d4b833877ae1e02e189c520c26ad Mon Sep 17 00:00:00 2001 From: 0x6f736f646f Date: Thu, 11 Aug 2022 13:21:58 +0300 Subject: [PATCH] Manual test Signed-off-by: 0x6f736f646f --- .github/workflows/ci.yaml | 14 +++--- pkg/auth.go | 34 ++++++++------- pkg/errors.go | 20 +++++++++ pkg/kopokopo.go | 28 ++++++++---- pkg/mpesa.go | 27 +++++++----- pkg/pay.go | 92 +++++++++++++++++++++++++++++---------- pkg/requests.go | 90 +++++++++++++++++++++++++------------- pkg/responses.go | 35 +++++++-------- 8 files changed, 229 insertions(+), 111 deletions(-) create mode 100644 pkg/errors.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d968b9f..b250e85 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,11 +33,15 @@ jobs: - name: Build run: go build -v ./... - - name: Install staticcheck - run: go install honnef.co/go/tools/cmd/staticcheck@latest - - - name: Run staticcheck - run: staticcheck ./... + - name: Lint with golangci-lint + run: | + export GOBIN=$HOME/go/bin + echo $(go env GOBIN) + export PATH=$PATH:$(go env GOBIN) + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOBIN) v1.46.2 + echo "Running lint..." + golangci-lint run --no-config --disable-all --enable gosimple --enable govet --enable unused --enable deadcode --timeout 3m + - name: Install golint run: go install golang.org/x/lint/golint@latest diff --git a/pkg/auth.go b/pkg/auth.go index aa725e7..17384ff 100644 --- a/pkg/auth.go +++ b/pkg/auth.go @@ -2,7 +2,6 @@ package kopokopo import ( "encoding/json" - "errors" "fmt" "net/http" "net/url" @@ -10,41 +9,42 @@ import ( var tokenEndpoint = "oauth" -// https://sandbox.kopokopo.com/oauth/token?grant_type=client_credentials&client_id={{CLIENT-ID}}&client_secret={{CLIENT-SECRET}} -func (sdk kSDK) GetToken() (string, error) { +// GetToken Request application authorization +func (sdk kSDK) GetToken() (tokenResp, error) { q := url.Values{} q.Add("client_id", sdk.credentials.AppID) q.Add("client_secret", sdk.credentials.Secret) q.Add("grant_type", "client_credentials") - endpoint := fmt.Sprintf("%s/%s/token?%s", sdk.baseURL, tokenEndpoint, q.Encode()) + url := fmt.Sprintf("%s/%s/token?%s", sdk.baseURL, tokenEndpoint, q.Encode()) - req, err := http.NewRequest(http.MethodPost, endpoint, nil) + req, err := http.NewRequest(http.MethodPost, url, nil) if err != nil { - return "", err + return tokenResp{}, err } resp, err := sdk.getBodyParams(req, "") if err != nil { - return "", err + return tokenResp{}, err } var tr tokenResp if err := json.Unmarshal(resp, &tr); err != nil { - return "", err + return tokenResp{}, err } - return tr.AccessToken, nil + return tr, nil } +// RevokeToken Revoke application's access token func (sdk kSDK) RevokeToken(token string) error { if token == "" { - return errors.New("empty token") + return ErrEmptyToken } q := url.Values{} q.Add("client_id", sdk.credentials.AppID) q.Add("client_secret", sdk.credentials.Secret) q.Add("token", token) - endpoint := fmt.Sprintf("%s/%s/revoke?%s", sdk.baseURL, tokenEndpoint, q.Encode()) + url := fmt.Sprintf("%s/%s/revoke?%s", sdk.baseURL, tokenEndpoint, q.Encode()) - req, err := http.NewRequest(http.MethodPost, endpoint, nil) + req, err := http.NewRequest(http.MethodPost, url, nil) if err != nil { return err } @@ -55,17 +55,18 @@ func (sdk kSDK) RevokeToken(token string) error { return nil } +// TokenIntrospection Request token introspection func (sdk kSDK) TokenIntrospection(token string) (tokenIntrospectionResp, error) { if token == "" { - return tokenIntrospectionResp{}, errors.New("empty token") + return tokenIntrospectionResp{}, ErrEmptyToken } q := url.Values{} q.Add("client_id", sdk.credentials.AppID) q.Add("client_secret", sdk.credentials.Secret) q.Add("token", token) - endpoint := fmt.Sprintf("%s/%s/token/introspect?%s", sdk.baseURL, tokenEndpoint, q.Encode()) + url := fmt.Sprintf("%s/%s/introspect?%s", sdk.baseURL, tokenEndpoint, q.Encode()) - req, err := http.NewRequest(http.MethodPost, endpoint, nil) + req, err := http.NewRequest(http.MethodPost, url, nil) if err != nil { return tokenIntrospectionResp{}, err } @@ -80,9 +81,10 @@ func (sdk kSDK) TokenIntrospection(token string) (tokenIntrospectionResp, error) return tir, nil } +// TokenInformation Request token information func (sdk kSDK) TokenInformation(token string) (tokenInfo, error) { if token == "" { - return tokenInfo{}, errors.New("empty token") + return tokenInfo{}, ErrEmptyToken } endpoint := fmt.Sprintf("%s/%s/token/info", sdk.baseURL, tokenEndpoint) diff --git a/pkg/errors.go b/pkg/errors.go new file mode 100644 index 0000000..86b3d14 --- /dev/null +++ b/pkg/errors.go @@ -0,0 +1,20 @@ +package kopokopo + +import "errors" + +var ( + // ErrEmptyToken indicates missing or invalid bearer token. + ErrEmptyToken = errors.New("empty token") + + // ErrInvalidPaymentChannel indicates missing or invalid payment channel. + ErrInvalidPaymentChannel = errors.New("invalid payment channel") + + // ErrMaxMetadataSize indicates maximum metadata length + ErrMaxMetadataSize = errors.New("maximum metadata size is 5") + + // ErrEmptyID indicates an empty reference or ID + ErrEmptyID = errors.New("empty id") + + // ErrInvalidSettlementMethod indicates invalid settlement method + ErrInvalidSettlementMethod = errors.New("invalid settlement method") +) diff --git a/pkg/kopokopo.go b/pkg/kopokopo.go index 3c493a8..a4b9fe8 100644 --- a/pkg/kopokopo.go +++ b/pkg/kopokopo.go @@ -17,10 +17,8 @@ type SDK interface { // The client credentials flow is the simplest OAuth 2 grant, // with a server-to-server exchange of your application’s client_id, client_secret - // for an OAuth application access token. In order to execute this flow, - // you will need to make an HTTP request from your application server, - // to the Kopo Kopo authorization server. - GetToken() (string, error) + // for an OAuth application access token. + GetToken() (tokenResp, error) // The request is used to revoke a particular token at a time. RevokeToken(token string) error @@ -63,9 +61,21 @@ type SDK interface { // Receive payments from M-PESA users via STK Push. ReceiveMpesaPayment(token string, receiveMpesaReq ReceiveMpesaReq) (string, error) + // ProcessIncommingMpesaPayment After a Incoming Payment is initiated, + // a Incoming Payment Result will be posted asynchronously to the call back URL specified in the Incoming Payment. + ProcessIncommingMpesaPayment(grantType string) (string, error) + // With an Incoming Payment location url, you can query what the status of the Incoming Payment is. // If a corresponding Incoming Payment Result exists, it will be bundled in the payload of the result. QueryIncommingMpesaPayment(token, id string) (IncomingPaymentEvent, error) + + // AddPayRecipients Add external entities that will be the destination of your payments. + AddPayRecipients(token string, recipient AddPAYRecipient) (string, error) + + // CreatePayment Create an outgoing payment to a third party. + // The final result of the Payment will be posted asynchronously to your systems + // via the call back URL provided in the request. + CreatePayment(token string, payment CreatePaymentReq) (string, error) } // Credentials contains the credentials @@ -103,20 +113,20 @@ func NewSDK(conf Config) SDK { } } -func (sdk kSDK) makeRequest(req *http.Request, token string) (*http.Response, error) { +func (sdk kSDK) makeRequest(req *http.Request, token string) (*http.Response, int, error) { if token != "" { req.Header.Add("Authorization", "Bearer "+token) } req.Header.Add("Content-Type", "application/json") resp, err := sdk.client.Do(req) if err != nil { - return nil, err + return nil, resp.StatusCode, err } - return resp, nil + return resp, resp.StatusCode, nil } func (sdk kSDK) getBodyParams(req *http.Request, token string) ([]byte, error) { - resp, err := sdk.makeRequest(req, token) + resp, _, err := sdk.makeRequest(req, token) if err != nil { return nil, err } @@ -129,7 +139,7 @@ func (sdk kSDK) getBodyParams(req *http.Request, token string) ([]byte, error) { } func (sdk kSDK) getHeaderParams(req *http.Request, token string) (string, error) { - resp, err := sdk.makeRequest(req, token) + resp, _, err := sdk.makeRequest(req, token) if err != nil { return "", err } diff --git a/pkg/mpesa.go b/pkg/mpesa.go index 5a461f9..6ea7134 100644 --- a/pkg/mpesa.go +++ b/pkg/mpesa.go @@ -3,12 +3,14 @@ package kopokopo import ( "bytes" "encoding/json" - "errors" "fmt" "net/http" "strings" ) +var mpesaEndpoint = "api/v1/incoming_payments" + +// ReceiveMpesaPayment Receive payments from M-PESA users via STK Push. func (sdk kSDK) ReceiveMpesaPayment(token string, receiveMpesaReq ReceiveMpesaReq) (string, error) { if err := receiveMpesaReq.Validate(); err != nil { return "", err @@ -17,31 +19,32 @@ func (sdk kSDK) ReceiveMpesaPayment(token string, receiveMpesaReq ReceiveMpesaRe if err != nil { return "", err } - endpoint := fmt.Sprintf("%s/api/v1/incoming_payments", sdk.baseURL) - req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(data)) + url := fmt.Sprintf("%s/%s", sdk.baseURL, mpesaEndpoint) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) if err != nil { return "", err } - url, err := sdk.getHeaderParams(req, token) + locationURL, err := sdk.getHeaderParams(req, token) if err != nil { return "", err } - id := strings.TrimPrefix(url, fmt.Sprintf("%s/api/v1/incoming_payments/", sdk.baseURL)) + id := strings.TrimPrefix(locationURL, fmt.Sprintf("%s/%s/", sdk.baseURL, mpesaEndpoint)) return id, nil } -// func (sdk kSDK) ProcessIncommingMpesaPayment(grantType string) (string, error) { -// panic("Not implemented") -// } +// ProcessIncommingMpesaPayment Process Incoming Payment Result +func (sdk kSDK) ProcessIncommingMpesaPayment(grantType string) (string, error) { + panic("Not implemented") +} +// QueryIncommingMpesaPayment Query Incoming Payment Status func (sdk kSDK) QueryIncommingMpesaPayment(token, id string) (IncomingPaymentEvent, error) { if id == "" { - return IncomingPaymentEvent{}, errors.New("empty id") + return IncomingPaymentEvent{}, ErrEmptyID } - endpoint := fmt.Sprintf("%s/api/v1/incoming_payments/", sdk.baseURL) - - req, err := http.NewRequest(http.MethodGet, endpoint, nil) + url := fmt.Sprintf("%s/%s/", sdk.baseURL, mpesaEndpoint) + req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return IncomingPaymentEvent{}, err } diff --git a/pkg/pay.go b/pkg/pay.go index 8521c02..1ac5fef 100644 --- a/pkg/pay.go +++ b/pkg/pay.go @@ -1,33 +1,81 @@ package kopokopo -// func (sdk kSDK) AddPayRecipients(grantType string) (string, error) { -// panic("Not implemented") -// } +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" +) -// func (sdk kSDK) PayMobileRecipient(grantType string) (string, error) { -// panic("Not implemented") -// } +var payEndpoint = "api/v1" -// func (sdk kSDK) PayBankRecipient(grantType string) (string, error) { -// panic("Not implemented") -// } +// AddPayRecipients Adding PAY recipients +func (sdk kSDK) AddPayRecipients(token string, recipient AddPAYRecipient) (string, error) { + if err := recipient.Validate(); err != nil { + return "", err + } + data, err := json.Marshal(recipient) + if err != nil { + return "", err + } + url := fmt.Sprintf("%s/%s/pay_recipients", sdk.baseURL, payEndpoint) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return "", err + } + locationURL, err := sdk.getHeaderParams(req, token) + if err != nil { + return "", err + } + id := strings.TrimPrefix(locationURL, fmt.Sprintf("%s/%s/pay_recipients/", sdk.baseURL, payEndpoint)) + return id, nil +} -// func (sdk kSDK) PayTillRecipient(grantType string) (string, error) { -// panic("Not implemented") -// } - -// func (sdk kSDK) PayPaybillRecipient(grantType string) (string, error) { -// panic("Not implemented") -// } - -// func (sdk kSDK) CreatePayment(grantType string) (string, error) { -// panic("Not implemented") -// } +// CreatePayment Create a Payment +func (sdk kSDK) CreatePayment(token string, payment CreatePaymentReq) (string, error) { + if err := payment.Validate(); err != nil { + return "", err + } + data, err := json.Marshal(payment) + if err != nil { + return "", err + } + url := fmt.Sprintf("%s/%s/payments", sdk.baseURL, payEndpoint) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return "", err + } + locationURL, err := sdk.getHeaderParams(req, token) + if err != nil { + return "", err + } + id := strings.TrimPrefix(locationURL, fmt.Sprintf("%s/%s/payments/", sdk.baseURL, payEndpoint)) + return id, nil +} // func (sdk kSDK) ProcessPayment(grantType string) (string, error) { // panic("Not implemented") // } -// func (sdk kSDK) QueryPayment(grantType string) (string, error) { -// panic("Not implemented") +// QueryPayment Query Payment status +// func (sdk kSDK) QueryPayment(token, id string) (string, error) { +// if id == "" { +// return IncomingPaymentEvent{}, EmptyID +// } + +// url := fmt.Sprintf("%s/%s/", sdk.baseURL, mpesaEndpoint) +// req, err := http.NewRequest(http.MethodGet, url, nil) +// if err != nil { +// return IncomingPaymentEvent{}, err +// } +// resp, err := sdk.getBodyParams(req, token) +// if err != nil { +// return IncomingPaymentEvent{}, err +// } +// var ipe IncomingPaymentEvent +// if err := json.Unmarshal(resp, &ipe); err != nil { +// return ipe, err +// } +// return ipe, nil // } diff --git a/pkg/requests.go b/pkg/requests.go index 48f8ffa..341221a 100644 --- a/pkg/requests.go +++ b/pkg/requests.go @@ -165,6 +165,12 @@ type ReceiveMpesaReq struct { // Validate returns nil if the struct is valid func (rmr ReceiveMpesaReq) Validate() error { + if rmr.PaymentChannel != "M-PESA" { + return ErrInvalidPaymentChannel + } + if len(rmr.Metadata) > 5 { + return ErrMaxMetadataSize + } return nil } @@ -202,38 +208,62 @@ type ProcessIncommingPaymentReq struct { Links Links `json:"_links,omitempty"` // A JSON object containing links to the Webhook Event and the corresponding Buygoods Transaction resource } -// type PaymentRecipient struct { -// LastName string `json:"last_name,omitempty"` // Last name of the recipient -// FirstName string `json:"first_name,omitempty"` // First name of the recipient -// MiddleName string `json:"middle_name,omitempty"` // Middle name of the recipient -// PhoneNumber string `json:"phone_number,omitempty"` // The phone number of the recipient from which the payment will be made -// Email string `json:"email,omitempty"` // E-mail address of the recipient - optional -// AccountName string `json:"account_name,omitempty"` //The name as indicated on the bank account name -// BankBranchReference string `json:"bank_branch_ref,omitempty"` // An identifier identifying the destination bank branch. -// AccountNumber string `json:"account_number,omitempty"` // The bank account number -// SettlementMethod string `json:"settlement_method,omitempty"` // RTS -// TillName string `json:"till_name,omitempty"` // The name as indicated on the till -// TillNumber string `json:"till_number,omitempty"` // The till number -// PayBillName string `json:"paybill_name,omitempty"` // The name referring to the paybill -// PayBillNumber string `json:"paybill_number,omitempty"` // The paybill business number -// PayBillAccountNumber string `json:"paybill_account_number,omitempty"` // The paybill account number -// } +// PaymentRecipient struct comprises of Mobile Wallet, Bank Account, External Till and Paybill recipients +type PaymentRecipient struct { + // Mobile Wallet + LastName string `json:"last_name,omitempty"` // Last name of the recipient + FirstName string `json:"first_name,omitempty"` // First name of the recipient + MiddleName string `json:"middle_name,omitempty"` // Middle name of the recipient + PhoneNumber string `json:"phone_number,omitempty"` // The phone number of the recipient from which the payment will be made + Email string `json:"email,omitempty"` // E-mail address of the recipient - optional + Network string `json:"network,omitempty"` // The mobile network to which the phone number belongs + // Bank Account + AccountName string `json:"account_name,omitempty"` //The name as indicated on the bank account name + BankBranchReference string `json:"bank_branch_ref,omitempty"` // An identifier identifying the destination bank branch. + AccountNumber string `json:"account_number,omitempty"` // The bank account number + SettlementMethod string `json:"settlement_method,omitempty"` // RTS + // External Till + TillName string `json:"till_name,omitempty"` // The name as indicated on the till + TillNumber string `json:"till_number,omitempty"` // The till number + // Paybill + PayBillName string `json:"paybill_name,omitempty"` // The name referring to the paybill + PayBillNumber string `json:"paybill_number,omitempty"` // The paybill business number + PayBillAccountNumber string `json:"paybill_account_number,omitempty"` // The paybill account number +} -// type AddPAYRecipient struct { -// Type string `json:"type,omitempty"` // The type of the recipient eg. mobile wallet or bank account -// PaymentRecipient PaymentRecipient `json:"payment_recipient,omitempty"` // A JSON object containing details of the recipeint -// } +// AddPAYRecipient struct +type AddPAYRecipient struct { + Type string `json:"type,omitempty"` // The type of the recipient eg. mobile wallet or bank account + PaymentRecipient PaymentRecipient `json:"payment_recipient,omitempty"` // A JSON object containing details of the recipeint +} -// type CreatePaymentReq struct { -// DestinationType string `json:"destination_type,omitempty"` // Pay recipient type (bank_account, mobile_wallet, till or paybill -// DestinationReference string `json:"destination_reference,omitempty"` // Reference for the destination. -// Amount Amount `json:"amount,omitempty"` // A JSON object containing the currency and the amount to be transferred -// Description string `json:"description,omitempty"` // A reason for the payment -// Category string `json:"category,omitempty"` // Categorize the transaction -// Tags string `json:"tags,omitempty"` // Define your own tag to label the transaction with -// Metadata map[string]interface{} `json:"metadata,omitempty"` // A JSON containing upto a maximum of 5 key-value pairs for your own use -// Links Links `json:"_links,omitempty"` // A JSON containing a call back URL where the results of the Payment will be posted. MUST be a secure HTTPS (TLS) endpoint -// } +// Validate returns nil if the struct is valid +func (apr AddPAYRecipient) Validate() error { + if apr.PaymentRecipient.SettlementMethod != "" && apr.PaymentRecipient.SettlementMethod != "RTS" { + return ErrInvalidSettlementMethod + } + return nil +} + +// CreatePaymentReq struct +type CreatePaymentReq struct { + DestinationType string `json:"destination_type,omitempty"` // Pay recipient type (bank_account, mobile_wallet, till or paybill + DestinationReference string `json:"destination_reference,omitempty"` // Reference for the destination. + Amount Amount `json:"amount,omitempty"` // A JSON object containing the currency and the amount to be transferred + Description string `json:"description,omitempty"` // A reason for the payment + Category string `json:"category,omitempty"` // Categorize the transaction + Tags string `json:"tags,omitempty"` // Define your own tag to label the transaction with + Metadata map[string]interface{} `json:"metadata,omitempty"` // A JSON containing upto a maximum of 5 key-value pairs for your own use + Links Links `json:"_links,omitempty"` // A JSON containing a call back URL where the results of the Payment will be posted. MUST be a secure HTTPS (TLS) endpoint +} + +// Validate returns nil if the struct is valid +func (cpr CreatePaymentReq) Validate() error { + if len(cpr.Metadata) > 5 { + return ErrMaxMetadataSize + } + return nil +} // type MerchantBankAccountReq struct { // } diff --git a/pkg/responses.go b/pkg/responses.go index 157ae19..e876759 100644 --- a/pkg/responses.go +++ b/pkg/responses.go @@ -1,32 +1,33 @@ package kopokopo // type ErrorResp struct { -// Code int `json:"error_code"` -// Message string `json:"error_message"` +// Type string `json:"error,omitempty"` +// Message string `json:"error_description,omitempty"` +// State string `json:"state,omitempty"` // } type tokenResp struct { - AccessToken string `json:"access_token,omitempty"` - Type string `json:"token_type,omitempty"` - Expiry uint64 `json:"expires_in,omitempty"` - Creation uint64 `json:"created_at,omitempty"` + AccessToken string `json:"access_token,omitempty"` // Access Token + Type string `json:"token_type,omitempty"` // Type of token + Expiry uint64 `json:"expires_in,omitempty"` // Expiry duration of token + Creation uint64 `json:"created_at,omitempty"` // Creation timestamp of token } type tokenIntrospectionResp struct { - Active bool `json:"active,omitempty"` - Scope string `json:"scope,omitempty"` - ClientID string `json:"client_id,omitempty"` - Type string `json:"token_type,omitempty"` - Expiry uint64 `json:"exp,omitempty"` - Creation uint64 `json:"iat,omitempty"` + Active bool `json:"active,omitempty"` // If the token is active or not + Scope string `json:"scope,omitempty"` // The application scope of the token + ClientID string `json:"client_id,omitempty"` // The application id associated with the token + Type string `json:"token_type,omitempty"` // Type of the token + Expiry uint64 `json:"exp,omitempty"` // Expiry timestamp of the token + Creation uint64 `json:"iat,omitempty"` // Creation timestamp of the token } type application struct { - UID string `json:"uid,omitempty"` + UID string `json:"uid,omitempty"` // The application id associated with the token } type tokenInfo struct { - OwnerID string `json:"resource_owner_id,omitempty"` - Scope []string `json:"scope,omitempty"` - Expiry uint64 `json:"expires_in,omitempty"` + OwnerID string `json:"resource_owner_id,omitempty"` // The owner id associated with the token + Scope []string `json:"scope,omitempty"` // The application scope of the token + Expiry uint64 `json:"expires_in,omitempty"` // Expiry duration of token Application application `json:"application,omitempty"` - Creation uint64 `json:"created_at,omitempty"` + Creation uint64 `json:"created_at,omitempty"` // Creation timestamp of the token }