Skip to content

Commit

Permalink
Use V2 of the UK VAT API and allow adding credentials (#10)
Browse files Browse the repository at this point in the history
* Use V2 of the UK VAT API and allow adding credentials

* prevent "opts" from being nil

* Fix language in a comment

* Clarify a comment
  • Loading branch information
chrisrollins65 authored Feb 26, 2025
1 parent 3343a36 commit cfd1afb
Show file tree
Hide file tree
Showing 11 changed files with 214 additions and 37 deletions.
3 changes: 0 additions & 3 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
run:
skip-dirs:
- testdata
deadline: 240s
tests: true

linters:
Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ EU VAT numbers are looked up using the [VIES VAT validation API](http://ec.europ

UK VAT numbers are looked up
using [UK GOV VAT validation API](https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/vat-registered-companies-api/1.0)
.
(requires [signing up for the UK API](#accessing-the-uk-vat-api)).

```go
package main
Expand Down Expand Up @@ -83,6 +83,38 @@ func main() {
}
```

# Accessing the UK VAT API

For validating VAT numbers that begin with "GB" you will need
to [sign up to gain access to the UK government's VAT API](https://developer.service.hmrc.gov.uk/api-documentation/docs/using-the-hub).

Once you have signed up and acquire a client ID and client secret, here's how to generate and use an access token:

```go
package main

import "github.com/teamwork/vat"

func main() {
var ukAccessToken *vat.UKAccessToken
ukAccessToken, err := vat.GenerateUKAccessToken(vat.ValidatorOpts{
UKClientID: "yourClientID",
UKClientSecret: "yourClientSecret",
IsUKTest: true, // set this if you are testing this in their sandbox test API
})
if err != nil {
panic(err)
}
// Recommended to cache the access token until it expires

err := vat.Validate("GB123456789", vat.ValidatorOpts{
UKAccessToken: ukAccessToken.Token,
IsUKTest: true, // if token created in test mode, run validation in test mode
})
}

```

## License

MIT licensed. See the LICENSE file for details.
15 changes: 15 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,18 @@ type ErrServiceUnavailable struct {
func (e ErrServiceUnavailable) Error() string {
return fmt.Sprintf("vat: service is unreachable: %v", e.Err)
}

// ErrUnableToGenerateUKAccessToken will be returned if the UK API Access token could not be generated
type ErrUnableToGenerateUKAccessToken struct {
Err error
}

// Error returns the error message
func (e ErrUnableToGenerateUKAccessToken) Error() string {
return fmt.Sprintf("vat: Error generating UK API Access token: %v", e.Err)
}

// ErrMissingUKAccessToken will be returned if the UK API Access token is missing
var ErrMissingUKAccessToken = errors.New(
"vat: missing UK API Access token. Run `vat.GenerateUKAccessToken` to generate one",
)
2 changes: 1 addition & 1 deletion gen.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
//go:generate mockgen -destination=mocks/mock_lookup_service.go -package=mocks github.com/teamwork/vat/v3 LookupServiceInterface
//go:generate mockgen -destination=mock_lookup_service.go --package=vat --source=vies_service.go

package vat
14 changes: 7 additions & 7 deletions mocks/mock_lookup_service.go → mock_lookup_service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

106 changes: 101 additions & 5 deletions uk_vat_service.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,57 @@
package vat

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)

// ukVATService is service that calls a UK VAT API to validate UK VAT numbers.
type ukVATService struct{}

// Validate checks if the given VAT number exists and is active. If no error is returned, then it is.
func (s *ukVATService) Validate(vatNumber string) error {
func (s *ukVATService) Validate(vatNumber string, opts ValidatorOpts) error {
if opts.UKAccessToken == "" {
// if no access token is provided, try to generate one
// (it is recommended to generate one separately and cache it and pass it in as an option here)
accessToken, err := GenerateUKAccessToken(opts)
if err != nil {
return ErrMissingUKAccessToken
}
opts.UKAccessToken = accessToken.Token
}

vatNumber = strings.ToUpper(vatNumber)

// Only VAT numbers starting with "GB" are supported by this service. All others should go through the VIES service.
if !strings.HasPrefix(vatNumber, "GB") {
return ErrInvalidCountryCode
}

client := http.Client{
apiURL := fmt.Sprintf(
"%s/organisations/vat/check-vat-number/lookup/%s",
ukVatServiceURL(opts.IsUKTest),
vatNumber[2:],
)

req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return ErrServiceUnavailable{Err: err}
}

req.Header.Set("Accept", "application/vnd.hmrc.2.0+json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", opts.UKAccessToken))

client := &http.Client{
Timeout: serviceTimeout,
}
response, err := client.Get(fmt.Sprintf(ukVATServiceURL, vatNumber[2:]))

response, err := client.Do(req)
if err != nil {
return ErrServiceUnavailable{Err: err}
}
Expand All @@ -46,6 +75,73 @@ func (s *ukVATService) Validate(vatNumber string) error {
return nil
}

// UKAccessToken contains access token information used to authenticate with the UK VAT API.
type UKAccessToken struct {
Token string `json:"access_token"`
SecondsUntilExpires int `json:"expires_in"`
IsTest bool `json:"-"`
}

// GenerateUKAccessToken generates an access token from given client credentials for use with the UK VAT API.
func GenerateUKAccessToken(opts ValidatorOpts) (*UKAccessToken, error) {
if opts.UKClientID == "" || opts.UKClientSecret == "" {
return nil, ErrUnableToGenerateUKAccessToken{Err: errors.New("missing client ID or secret")}
}

data := url.Values{}
data.Set("client_secret", opts.UKClientSecret)
data.Set("client_id", opts.UKClientID)
data.Set("grant_type", "client_credentials")
data.Set("scope", "read:vat")

req, err := http.NewRequest(
"POST",
fmt.Sprintf("%s/oauth/token", ukVatServiceURL(opts.IsUKTest)),
bytes.NewBufferString(data.Encode()),
)
if err != nil {
return nil, ErrUnableToGenerateUKAccessToken{Err: err}
}

req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, ErrUnableToGenerateUKAccessToken{Err: err}
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)

if resp.StatusCode != http.StatusOK {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, ErrUnableToGenerateUKAccessToken{Err: fmt.Errorf("unexpected status code: %d", resp.StatusCode)}
}
return nil, ErrUnableToGenerateUKAccessToken{
Err: fmt.Errorf("unexpected status code: %d: %s", resp.StatusCode, respBody),
}
}

var token UKAccessToken
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
return nil, ErrUnableToGenerateUKAccessToken{Err: err}
}
if opts.IsUKTest {
token.IsTest = true
}

return &token, nil
}

func ukVatServiceURL(isTest bool) string {
if isTest {
return fmt.Sprintf("https://test-%s", ukVATServiceDomain)
}
return fmt.Sprintf("https://%s", ukVATServiceDomain)
}

// API Documentation:
// https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/vat-registered-companies-api/1.0
const ukVATServiceURL = "https://api.service.hmrc.gov.uk/organisations/vat/check-vat-number/lookup/%s"
// https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/vat-registered-companies-api/2.0/oas/page
const ukVATServiceDomain = "api.service.hmrc.gov.uk"
25 changes: 21 additions & 4 deletions uk_vat_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@

package vat

import "testing"
import (
"errors"
"testing"
)

// test numbers to use with the UK VAT service API in their sandbox environment:
// https://github.com/hmrc/vat-registered-companies-api/blob/main/public/api/conf/2.0/test-data/vrn.csv

var ukTests = []struct {
vatNumber string
expectedError error
}{
{"GB333289454", nil}, // valid number (as of 2024-04-26)
{"GB553557881", nil}, // valid VAT number in the sandbox environment
{"GB0472429986", ErrInvalidVATNumberFormat},
{"Hi", ErrInvalidCountryCode},
{"GB333289453", ErrVATNumberNotFound},
Expand All @@ -18,9 +24,20 @@ var ukTests = []struct {
// TestUKVATService tests the UK VAT service. Just meant to be a quick way to check that this service is working.
// Makes external calls that sometimes might fail. Do not include them in CI/CD.
func TestUKVATService(t *testing.T) {
opts := ValidatorOpts{
UKClientID: "yourClientID", // insert your own client ID and secret here. Do not commit real values.
UKClientSecret: "yourClientSecret",
IsUKTest: true,
}
token, err := GenerateUKAccessToken(opts)
if err != nil {
t.Fatal(err)
}
opts.UKAccessToken = token.Token

for _, test := range ukTests {
err := UKVATLookupService.Validate(test.vatNumber)
if err != test.expectedError {
err := UKVATLookupService.Validate(test.vatNumber, opts)
if !errors.Is(err, test.expectedError) {
t.Errorf("Expected <%v> for %v, got <%v>", test.expectedError, test.vatNumber, err)
}
}
Expand Down
24 changes: 20 additions & 4 deletions validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import (
)

// Validate validates a VAT number by both format and existence. If no error then it is valid.
func Validate(vatNumber string) error {
// Note: for backwards compatibility this is a variadic function that effectively makes it optional to pass in options.
// If no opts are passed in, VIES numbers will still be validated as always, but GB numbers will not.
// If multiple opts arguments passed in, only the first one is used.
func Validate(vatNumber string, opts ...ValidatorOpts) error {
err := ValidateFormat(vatNumber)
if err != nil {
return err
}
return ValidateExists(vatNumber)
return ValidateExists(vatNumber, opts...)
}

// ValidateFormat validates a VAT number by its format. If no error is returned then it is valid.
Expand Down Expand Up @@ -75,7 +78,7 @@ func ValidateFormat(vatNumber string) error {
}

// ValidateExists validates that the given VAT number exists in the external lookup service.
func ValidateExists(vatNumber string) error {
func ValidateExists(vatNumber string, optsSlice ...ValidatorOpts) error {
if len(vatNumber) < 3 {
return ErrInvalidVATNumberFormat
}
Expand All @@ -87,5 +90,18 @@ func ValidateExists(vatNumber string) error {
lookupService = UKVATLookupService
}

return lookupService.Validate(vatNumber)
opts := ValidatorOpts{}
if len(optsSlice) > 0 {
opts = optsSlice[0]
}

return lookupService.Validate(vatNumber, opts)
}

// ValidatorOpts are options for the VAT number validator.
type ValidatorOpts struct {
UKClientID string
UKClientSecret string
UKAccessToken string
IsUKTest bool
}
14 changes: 7 additions & 7 deletions validate_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package vat

import (
"errors"
"strconv"
"testing"

"github.com/golang/mock/gomock"
"github.com/teamwork/vat/v3/mocks"
)

var tests = []struct {
Expand Down Expand Up @@ -164,7 +164,7 @@ func BenchmarkValidateFormat(b *testing.B) {
func TestValidateFormat(t *testing.T) {
for _, test := range tests {
err := ValidateFormat(test.number)
if err != test.expectedError {
if !errors.Is(err, test.expectedError) {
t.Errorf("Expected <%v> for %v, got <%v>", test.expectedError, test.number, err)
}
}
Expand All @@ -174,16 +174,16 @@ func TestValidateExists(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockViesService := mocks.NewMockLookupServiceInterface(ctrl)
mockUKVATService := mocks.NewMockLookupServiceInterface(ctrl)
mockViesService := NewMockLookupServiceInterface(ctrl)
mockUKVATService := NewMockLookupServiceInterface(ctrl)
ViesLookupService = mockViesService
UKVATLookupService = mockUKVATService

defer restoreLookupServices()

var lookupTests = []struct {
vatNumber string
service *mocks.MockLookupServiceInterface
service *MockLookupServiceInterface
expectedError error
}{
{"BE0472429986", mockViesService, ErrVATNumberNotFound},
Expand All @@ -198,11 +198,11 @@ func TestValidateExists(t *testing.T) {

for _, test := range lookupTests {
if len(test.vatNumber) >= 3 {
test.service.EXPECT().Validate(test.vatNumber).Return(test.expectedError)
test.service.EXPECT().Validate(test.vatNumber, ValidatorOpts{}).Return(test.expectedError)
}

err := ValidateExists(test.vatNumber)
if err != test.expectedError {
if !errors.Is(err, test.expectedError) {
t.Errorf("Expected <%v> for %v, got <%v>", test.expectedError, test.vatNumber, err)
}
}
Expand Down
Loading

0 comments on commit cfd1afb

Please sign in to comment.