diff --git a/mpesa.go b/mpesa.go index cc1086a..2e65d93 100644 --- a/mpesa.go +++ b/mpesa.go @@ -47,11 +47,25 @@ var accessTokenTTL = 55 * time.Minute // requiredURLScheme present the required scheme for the callbacks const requiredURLScheme = "https" +const ( + sandboxBaseURL = "https://sandbox.safaricom.co.ke" + productionBaseURL = "https://api.safaricom.co.ke" +) + // IsProduction returns true if the current env is set to production. func (e Environment) IsProduction() bool { return e == EnvironmentProduction } +// BaseURL returns the base url for the current Environment +func (e Environment) BaseURL() string { + if !e.IsProduction() { + return sandboxBaseURL + } + + return productionBaseURL +} + type HttpClient interface { Do(req *http.Request) (*http.Response, error) } @@ -68,15 +82,6 @@ type Mpesa struct { consumerKey string consumerSecret string - - authURL string - accountBalanceURL string - b2cURL string - c2bURL string - dynamicQRURL string - stkPushQueryURL string - stkPushURL string - txnStatusURL string } var ( @@ -87,11 +92,6 @@ var ( ErrInvalidInitiatorPassword = errors.New("mpesa: initiator password cannot be empty") ) -const ( - sandboxBaseURL = "https://sandbox.safaricom.co.ke" - productionBaseURL = "https://api.safaricom.co.ke" -) - // validateURL checks if the provided URL is valid and is being server via https func validateURL(rawURL string) error { u, err := url.ParseRequestURI(rawURL) @@ -114,11 +114,6 @@ func NewApp(c HttpClient, consumerKey, consumerSecret string, env Environment) * } } - baseUrl := sandboxBaseURL - if env == EnvironmentProduction { - baseUrl = productionBaseURL - } - return &Mpesa{ client: c, environment: env, @@ -126,18 +121,49 @@ func NewApp(c HttpClient, consumerKey, consumerSecret string, env Environment) * consumerKey: consumerKey, consumerSecret: consumerSecret, - - authURL: baseUrl + `/oauth/v1/generate?grant_type=client_credentials`, - accountBalanceURL: baseUrl + `/mpesa/accountbalance/v1/query`, - b2cURL: baseUrl + `/mpesa/b2c/v1/paymentrequest`, - c2bURL: baseUrl + `/mpesa/c2b/v1/registerurl`, - dynamicQRURL: baseUrl + `/mpesa/qrcode/v1/generate`, - stkPushQueryURL: baseUrl + `/mpesa/stkpushquery/v1/query`, - stkPushURL: baseUrl + `/mpesa/stkpush/v1/processrequest`, - txnStatusURL: baseUrl + `/mpesa/transactionstatus/v1/query`, } } +// endpointAuth returns the auth endpoint prefixed with the current Environment base URL +func (m *Mpesa) endpointAuth() string { + return m.Environment().BaseURL() + `/oauth/v1/generate?grant_type=client_credentials` +} + +// endpointB2C returns the account balance endpoint prefixed with the current Environment base URL +func (m *Mpesa) endpointAccountBalance() string { + return m.Environment().BaseURL() + `/mpesa/accountbalance/v1/query` +} + +// endpointB2C returns the B2C endpoint prefixed with the current Environment base URL +func (m *Mpesa) endpointB2C() string { + return m.Environment().BaseURL() + `/mpesa/b2c/v1/paymentrequest` +} + +// endpointB2C returns the endpoint to register C2B callbacks prefixed with the current Environment base URL +func (m *Mpesa) endpointC2BRegister() string { + return m.Environment().BaseURL() + `/mpesa/c2b/v1/registerurl` +} + +// endpointB2C returns the endpoint to generate dunamic QR code prefixed with the current Environment base URL +func (m *Mpesa) endpointDynamicQR() string { + return m.Environment().BaseURL() + `/mpesa/qrcode/v1/generate` +} + +// endpointSTK returns the endpoint to generate an STK push prefixed with the current Environment base URL +func (m *Mpesa) endpointSTK() string { + return m.Environment().BaseURL() + `/mpesa/stkpush/v1/processrequest` +} + +// endpointSTK returns the endpoint to query the status of an STK request prefixed with the current Environment base URL +func (m *Mpesa) endpointSTKQuery() string { + return m.Environment().BaseURL() + `/mpesa/stkpushquery/v1/query` +} + +// endpointSTK returns the endpoint to query the status of a transaction prefixed with the current Environment base URL +func (m *Mpesa) endpointTransactionStatus() string { + return m.Environment().BaseURL() + `/mpesa/transactionstatus/v1/query` +} + // generateTimestampAndPassword returns the current timestamp in the format YYYYMMDDHHmmss and a base64 encoded // password in the format shortcode+passkey+timestamp func generateTimestampAndPassword(shortcode uint, passkey string) (string, string) { @@ -194,7 +220,7 @@ func (m *Mpesa) GenerateAccessToken(ctx context.Context) (string, error) { } } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.authURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.endpointAuth(), nil) if err != nil { return "", fmt.Errorf("mpesa: create auth request: %v", err) } @@ -231,7 +257,7 @@ func (m *Mpesa) STKPush(ctx context.Context, passkey string, req STKPushRequest) req.Timestamp, req.Password = generateTimestampAndPassword(req.BusinessShortCode, passkey) - res, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.stkPushURL, req) + res, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.endpointSTK(), req) if err != nil { return nil, err } @@ -305,7 +331,7 @@ func (m *Mpesa) B2C(ctx context.Context, initiatorPwd string, req B2CRequest) (* req.SecurityCredential = securityCredential - res, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.b2cURL, req) + res, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.endpointB2C(), req) if err != nil { return nil, err } @@ -345,7 +371,7 @@ func (m *Mpesa) STKQuery(ctx context.Context, passkey string, req STKQueryReques req.Timestamp, req.Password = generateTimestampAndPassword(req.BusinessShortCode, passkey) - res, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.stkPushQueryURL, req) + res, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.endpointSTKQuery(), req) if err != nil { return nil, err } @@ -376,7 +402,7 @@ func (m *Mpesa) STKQuery(ctx context.Context, passkey string, req STKQueryReques func (m *Mpesa) RegisterC2BURL(ctx context.Context, req RegisterC2BURLRequest) (*Response, error) { switch req.ResponseType { case ResponseTypeComplete, ResponseTypeCanceled: - response, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.c2bURL, req) + response, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.endpointC2BRegister(), req) if err != nil { return nil, err } @@ -405,7 +431,7 @@ func (m *Mpesa) DynamicQR( ) (*DynamicQRResponse, error) { req.TransactionType = transactionType - res, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.dynamicQRURL, req) + res, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.endpointDynamicQR(), req) if err != nil { return nil, err } @@ -490,7 +516,7 @@ func (m *Mpesa) GetTransactionStatus( req.CommandID = TransactionStatusQuery req.IdentifierType = Shortcode - res, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.txnStatusURL, req) + res, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.endpointTransactionStatus(), req) if err != nil { return nil, err } @@ -538,7 +564,7 @@ func (m *Mpesa) GetAccountBalance( req.CommandID = AccountBalance req.IdentifierType = Shortcode - res, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.accountBalanceURL, req) + res, err := m.makeHttpRequestWithToken(ctx, http.MethodPost, m.endpointAccountBalance(), req) if err != nil { return nil, err } diff --git a/mpesa_test.go b/mpesa_test.go index e675435..0267f3a 100644 --- a/mpesa_test.go +++ b/mpesa_test.go @@ -33,7 +33,7 @@ func TestMpesa_GenerateAccessToken(t *testing.T) { { name: "it generates and caches an access token successfully", mock: func(t *testing.T, app *Mpesa, c *mockHttpClient) { - c.MockRequest(app.authURL, func() (status int, body string) { + c.MockRequest(app.endpointAuth(), func() (status int, body string) { return http.StatusOK, ` { "access_token": "0A0v8OgxqqoocblflR58m9chMdnU", @@ -55,7 +55,7 @@ func TestMpesa_GenerateAccessToken(t *testing.T) { { name: "it fails to generate an access token", mock: func(t *testing.T, app *Mpesa, c *mockHttpClient) { - c.MockRequest(app.authURL, func() (status int, body string) { + c.MockRequest(app.endpointAuth(), func() (status int, body string) { return http.StatusBadRequest, `` }) @@ -69,7 +69,7 @@ func TestMpesa_GenerateAccessToken(t *testing.T) { mock: func(t *testing.T, app *Mpesa, c *mockHttpClient) { oldToken := "0A0v8OgxqqoocblflR58m9chMdnU" - c.MockRequest(app.authURL, func() (status int, body string) { + c.MockRequest(app.endpointAuth(), func() (status int, body string) { return http.StatusOK, ` { "access_token": "` + oldToken + `", @@ -88,7 +88,7 @@ func TestMpesa_GenerateAccessToken(t *testing.T) { gotCachedData.setAt = time.Now().Add(-1 * time.Hour) app.cache[testConsumerKey] = gotCachedData - c.MockRequest(app.authURL, func() (status int, body string) { + c.MockRequest(app.endpointAuth(), func() (status int, body string) { return http.StatusOK, ` { "access_token": "R58m9chMdnU0A0v8Ogxqqoocblfl", @@ -106,7 +106,7 @@ func TestMpesa_GenerateAccessToken(t *testing.T) { { name: "it fails with 404 if invalid url is passed", mock: func(t *testing.T, app *Mpesa, c *mockHttpClient) { - c.MockRequest(app.stkPushURL, func() (status int, body string) { + c.MockRequest(app.endpointSTK(), func() (status int, body string) { return http.StatusNotFound, `` }) @@ -157,7 +157,7 @@ func TestMpesa_STKPush(t *testing.T) { mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, stkReq STKPushRequest) { passkey := "passkey" - c.MockRequest(app.stkPushURL, func() (status int, body string) { + c.MockRequest(app.endpointSTK(), func() (status int, body string) { req := c.requests[1] require.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -208,7 +208,7 @@ func TestMpesa_STKPush(t *testing.T) { mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, stkReq STKPushRequest) { passkey := "passkey" - c.MockRequest(app.stkPushURL, func() (status int, body string) { + c.MockRequest(app.endpointSTK(), func() (status int, body string) { return http.StatusBadRequest, ` { "requestId": "4788-81090592-4", @@ -234,7 +234,7 @@ func TestMpesa_STKPush(t *testing.T) { app = NewApp(cl, testConsumerKey, testConsumerSecret, EnvironmentSandbox) ) - cl.MockRequest(app.authURL, func() (status int, body string) { + cl.MockRequest(app.endpointAuth(), func() (status int, body string) { return http.StatusOK, ` { "access_token": "0A0v8OgxqqoocblflR58m9chMdnU", @@ -353,7 +353,7 @@ func TestMpesa_B2C(t *testing.T) { }, env: EnvironmentSandbox, mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, b2cReq B2CRequest) { - c.MockRequest(app.b2cURL, func() (status int, body string) { + c.MockRequest(app.endpointB2C(), func() (status int, body string) { req := c.requests[1] require.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -396,7 +396,7 @@ func TestMpesa_B2C(t *testing.T) { }, env: EnvironmentProduction, mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, b2cReq B2CRequest) { - c.MockRequest(app.b2cURL, func() (status int, body string) { + c.MockRequest(app.endpointB2C(), func() (status int, body string) { req := c.requests[1] var reqParams B2CRequest @@ -434,7 +434,7 @@ func TestMpesa_B2C(t *testing.T) { }, env: EnvironmentProduction, mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, b2cReq B2CRequest) { - c.MockRequest(app.b2cURL, func() (status int, body string) { + c.MockRequest(app.endpointB2C(), func() (status int, body string) { return http.StatusBadRequest, ` { "requestId": "11728-2929992-1", @@ -461,7 +461,7 @@ func TestMpesa_B2C(t *testing.T) { app = NewApp(cl, testConsumerKey, testConsumerSecret, tc.env) ) - cl.MockRequest(app.authURL, func() (status int, body string) { + cl.MockRequest(app.endpointAuth(), func() (status int, body string) { return http.StatusOK, ` { "access_token": "0A0v8OgxqqoocblflR58m9chMdnU", @@ -637,7 +637,7 @@ func TestMpesa_STKPushQuery(t *testing.T) { mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, stkReq STKQueryRequest) { passkey := "passkey" - c.MockRequest(app.stkPushQueryURL, func() (status int, body string) { + c.MockRequest(app.endpointSTKQuery(), func() (status int, body string) { req := c.requests[1] require.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -678,7 +678,7 @@ func TestMpesa_STKPushQuery(t *testing.T) { mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, stkReq STKQueryRequest) { passkey := "passkey" - c.MockRequest(app.stkPushQueryURL, func() (status int, body string) { + c.MockRequest(app.endpointSTKQuery(), func() (status int, body string) { return http.StatusInternalServerError, ` { "RequestID": "ws_CO_03082022131319635708374149", @@ -703,7 +703,7 @@ func TestMpesa_STKPushQuery(t *testing.T) { cl := newMockHttpClient() app := NewApp(cl, testConsumerKey, testConsumerSecret, EnvironmentSandbox) - cl.MockRequest(app.authURL, func() (status int, body string) { + cl.MockRequest(app.endpointAuth(), func() (status int, body string) { return http.StatusOK, ` { "access_token": "0A0v8OgxqqoocblflR58m9chMdnU", @@ -741,7 +741,7 @@ func Test_RegisterC2BURL(t *testing.T) { ConfirmationURL: "http://example.com/confirm", }, mock: func(t *testing.T, ctx context.Context, app *Mpesa, c *mockHttpClient, c2bRequest RegisterC2BURLRequest) { - c.MockRequest(app.c2bURL, func() (status int, body string) { + c.MockRequest(app.endpointC2BRegister(), func() (status int, body string) { req := c.requests[1] require.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -776,7 +776,7 @@ func Test_RegisterC2BURL(t *testing.T) { ConfirmationURL: "http://example.com/confirm", }, mock: func(t *testing.T, ctx context.Context, app *Mpesa, c *mockHttpClient, c2bRequest RegisterC2BURLRequest) { - c.MockRequest(app.c2bURL, func() (status int, body string) { + c.MockRequest(app.endpointC2BRegister(), func() (status int, body string) { req := c.requests[1] require.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -824,7 +824,7 @@ func Test_RegisterC2BURL(t *testing.T) { app = NewApp(client, testConsumerKey, testConsumerSecret, tc.env) ) - client.MockRequest(app.authURL, func() (status int, body string) { + client.MockRequest(app.endpointAuth(), func() (status int, body string) { return http.StatusOK, ` { "access_token": "0A0v8OgxqqoocblflR58m9chMdnU", @@ -850,7 +850,7 @@ func TestMpesa_DynamicQR(t *testing.T) { { name: "it makes a request and generates a qr code", mock: func(app *Mpesa, c *mockHttpClient, qrReq DynamicQRRequest) { - c.MockRequest(app.dynamicQRURL, func() (status int, body string) { + c.MockRequest(app.endpointDynamicQR(), func() (status int, body string) { req := c.requests[1] require.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -874,7 +874,7 @@ func TestMpesa_DynamicQR(t *testing.T) { { name: "it makes a request and generates a qr code with the decode image", mock: func(app *Mpesa, c *mockHttpClient, qrReq DynamicQRRequest) { - c.MockRequest(app.dynamicQRURL, func() (status int, body string) { + c.MockRequest(app.endpointDynamicQR(), func() (status int, body string) { req := c.requests[1] require.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -919,7 +919,7 @@ func TestMpesa_DynamicQR(t *testing.T) { { name: "request fails if an invalid trasaction type is passed", mock: func(app *Mpesa, c *mockHttpClient, qrReq DynamicQRRequest) { - c.MockRequest(app.dynamicQRURL, func() (status int, body string) { + c.MockRequest(app.endpointDynamicQR(), func() (status int, body string) { req := c.requests[1] require.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -951,7 +951,7 @@ func TestMpesa_DynamicQR(t *testing.T) { app = NewApp(cl, testConsumerKey, testConsumerSecret, EnvironmentSandbox) ) - cl.MockRequest(app.authURL, func() (status int, body string) { + cl.MockRequest(app.endpointAuth(), func() (status int, body string) { return http.StatusOK, ` { "access_token": "0A0v8OgxqqoocblflR58m9chMdnU", @@ -999,7 +999,7 @@ func TestMpesa_GetTransactionStatus(t *testing.T) { TransactionID: "SAM62HFIRW", }, mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, txnStatusReq TransactionStatusRequest) { - c.MockRequest(app.txnStatusURL, func() (status int, body string) { + c.MockRequest(app.endpointTransactionStatus(), func() (status int, body string) { req := c.requests[1] require.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -1040,7 +1040,7 @@ func TestMpesa_GetTransactionStatus(t *testing.T) { TransactionID: "SAM62HFIRW", }, mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, txnStatusReq TransactionStatusRequest) { - c.MockRequest(app.txnStatusURL, func() (status int, body string) { + c.MockRequest(app.endpointTransactionStatus(), func() (status int, body string) { req := c.requests[1] require.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -1115,7 +1115,7 @@ func TestMpesa_GetTransactionStatus(t *testing.T) { TransactionID: "SAM62HFIRW", }, mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, txnStatusReq TransactionStatusRequest) { - c.MockRequest(app.txnStatusURL, func() (status int, body string) { + c.MockRequest(app.endpointTransactionStatus(), func() (status int, body string) { return http.StatusBadRequest, ` { "requestId": "11728-2929992-1", @@ -1143,7 +1143,7 @@ func TestMpesa_GetTransactionStatus(t *testing.T) { app = NewApp(cl, testConsumerKey, testConsumerSecret, tc.env) ) - cl.MockRequest(app.authURL, func() (status int, body string) { + cl.MockRequest(app.endpointAuth(), func() (status int, body string) { return http.StatusOK, ` { "access_token": "0A0v8OgxqqoocblflR58m9chMdnU", @@ -1183,7 +1183,7 @@ func TestMpesa_GetAccountBalance(t *testing.T) { ResultURL: "https://example.com", }, mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, accountBalanceReq AccountBalanceRequest) { - c.MockRequest(app.accountBalanceURL, func() (status int, body string) { + c.MockRequest(app.endpointAccountBalance(), func() (status int, body string) { req := c.requests[1] require.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -1222,7 +1222,7 @@ func TestMpesa_GetAccountBalance(t *testing.T) { ResultURL: "https://example.com", }, mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, accountBalanceReq AccountBalanceRequest) { - c.MockRequest(app.accountBalanceURL, func() (status int, body string) { + c.MockRequest(app.endpointAccountBalance(), func() (status int, body string) { req := c.requests[1] require.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -1295,7 +1295,7 @@ func TestMpesa_GetAccountBalance(t *testing.T) { ResultURL: "https://example.com", }, mock: func(t *testing.T, app *Mpesa, c *mockHttpClient, accountBalanceReq AccountBalanceRequest) { - c.MockRequest(app.accountBalanceURL, func() (status int, body string) { + c.MockRequest(app.endpointAccountBalance(), func() (status int, body string) { return http.StatusBadRequest, ` { "requestId": "11728-2929992-1", @@ -1323,7 +1323,7 @@ func TestMpesa_GetAccountBalance(t *testing.T) { app = NewApp(cl, testConsumerKey, testConsumerSecret, tc.env) ) - cl.MockRequest(app.authURL, func() (status int, body string) { + cl.MockRequest(app.endpointAuth(), func() (status int, body string) { return http.StatusOK, ` { "access_token": "0A0v8OgxqqoocblflR58m9chMdnU",