Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
run: go build .

- name: Lint
uses: golangci/golangci-lint-action@v3.7.0
uses: golangci/golangci-lint-action@v7

- name: Test
run: ./bin/test
Expand Down
16 changes: 9 additions & 7 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
version: "2"

run:
tests: true

linters:
disable-all: true
default: none
enable:
- govet
- revive
Expand All @@ -11,14 +13,14 @@ linters:
- staticcheck
- ineffassign
- unconvert
- goimports
- misspell
- lll
- nakedret
- gocritic
settings:
lll:
line-length: 120

linters-settings:
lll:
line-length: 120
issues:
exclude-use-default: false
formatters:
enable:
- goimports
4 changes: 2 additions & 2 deletions bin/lint
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ ignore_metalinter() {
for d in $lint_ignore; do printf " --skip-dirs %s" "$d"; done
}

curl -sSfL --silent -o /dev/null https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.11.4

# There's an issue dealing with symbolic link folders (cd -P .) solves that
(cd -P .; golangci-lint run $(ignore_metalinter) "$@")
(cd -P .; golangci-lint run "$@")
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ go 1.21

require github.com/golang/mock v1.6.0

require github.com/joho/godotenv v1.5.1 // indirect
require github.com/joho/godotenv v1.5.1
48 changes: 40 additions & 8 deletions uk_vat_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,18 @@ 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, opts ValidatorOpts) error {
_, err := s.validateWithResponse(vatNumber, opts)
return err
}

// validateWithResponse performs validation and returns the full UK VAT API response.
func (s *ukVATService) validateWithResponse(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) {
if opts.UKAccessToken == nil || opts.UKAccessToken.IsExpired() {
// if no access token is provided or if it's expired, 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
return nil, ErrMissingUKAccessToken
}
opts.UKAccessToken = accessToken
}
Expand All @@ -31,7 +37,7 @@ func (s *ukVATService) Validate(vatNumber string, opts ValidatorOpts) error {

// 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
return nil, ErrInvalidCountryCode
}

apiURL := fmt.Sprintf(
Expand All @@ -42,7 +48,7 @@ func (s *ukVATService) Validate(vatNumber string, opts ValidatorOpts) error {

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

req.Header.Set("Accept", "application/vnd.hmrc.2.0+json")
Expand All @@ -54,26 +60,30 @@ func (s *ukVATService) Validate(vatNumber string, opts ValidatorOpts) error {

response, err := client.Do(req)
if err != nil {
return ErrServiceUnavailable{Err: err}
return nil, ErrServiceUnavailable{Err: err}
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(response.Body)

if response.StatusCode == http.StatusBadRequest {
return ErrInvalidVATNumberFormat
return nil, ErrInvalidVATNumberFormat
}
if response.StatusCode == http.StatusNotFound {
return ErrVATNumberNotFound
return nil, ErrVATNumberNotFound
}
if response.StatusCode != http.StatusOK {
return ErrServiceUnavailable{
return nil, ErrServiceUnavailable{
Err: fmt.Errorf("unexpected status code from UK VAT API: %d", response.StatusCode),
}
}

// If we receive a valid 200 response from this API, it means the VAT number exists and is valid
return nil
var ukResp UKVATResponse
if err := json.NewDecoder(response.Body).Decode(&ukResp); err != nil {
return nil, ErrServiceUnavailable{Err: err}
}
return &LookupResponse{UKVATResponse: &ukResp}, nil
}

// UKAccessToken contains access token information used to authenticate with the UK VAT API.
Expand Down Expand Up @@ -154,3 +164,25 @@ func ukVatServiceURL(isTest bool) string {
// API Documentation:
// 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"

// UKVATResponse holds the response data from the UK VAT API.
type UKVATResponse struct {
Target struct {
Name string `json:"name"`
VATNumber string `json:"vatNumber"`
Address UKVATAddress `json:"address"`
} `json:"target"`
Requester string `json:"requester,omitempty"`
ConsultationNumber string `json:"consultationNumber,omitempty"`
ProcessingDate string `json:"processingDate"`
}

// UKVATAddress holds the address data from the UK VAT API response.
type UKVATAddress struct {
Line1 string `json:"line1"`
Line2 string `json:"line2,omitempty"`
Line3 string `json:"line3,omitempty"`
Line4 string `json:"line4,omitempty"`
Postcode string `json:"postcode"`
CountryCode string `json:"countryCode"`
}
39 changes: 39 additions & 0 deletions uk_vat_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,42 @@ func TestUKVATService(t *testing.T) {
}
}
}

func TestUKVATServiceWithResponse(t *testing.T) {
if err := godotenv.Load(); err != nil {
t.Fatalf("Error loading .env file: %v", err)
}

opts := ValidatorOpts{
UKClientID: os.Getenv("CLIENT_ID"),
UKClientSecret: os.Getenv("SECRET"),
IsUKTest: true,
}

if opts.UKClientID == "" || opts.UKClientSecret == "" {
t.Fatal("CLIENT_ID and SECRET must be set in .env file")
}

token, err := GenerateUKAccessToken(opts)
if err != nil {
t.Fatal(err)
}
opts.UKAccessToken = token

resp, err := ValidateExistsWithResponse("GB553557881", opts)
if err != nil {
t.Fatalf("expected no error for valid VAT, got %v", err)
}
if resp == nil || resp.UKVATResponse == nil {
t.Fatal("expected UKVATResponse to be populated")
}
if resp.UKVATResponse.Target.VATNumber != "553557881" {
t.Errorf("expected Target.VATNumber '553557881', got %q", resp.UKVATResponse.Target.VATNumber)
}
if resp.UKVATResponse.Target.Name == "" {
t.Error("expected Target.Name to be populated")
}
if resp.UKVATResponse.ProcessingDate == "" {
t.Error("expected ProcessingDate to be populated")
}
}
42 changes: 42 additions & 0 deletions validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ func Validate(vatNumber string, opts ...ValidatorOpts) error {
return ValidateExists(vatNumber, opts...)
}

// ValidateWithResponse validates a VAT number by both format and existence, returning the full
// service response on success. If no error then the VAT number is valid.
func ValidateWithResponse(vatNumber string, opts ...ValidatorOpts) (*LookupResponse, error) {
err := ValidateFormat(vatNumber)
if err != nil {
return nil, err
}
return ValidateExistsWithResponse(vatNumber, opts...)
}

// ValidateFormat validates a VAT number by its format. If no error is returned then it is valid.
func ValidateFormat(vatNumber string) error {
patterns := map[string]string{
Expand Down Expand Up @@ -98,10 +108,42 @@ func ValidateExists(vatNumber string, optsSlice ...ValidatorOpts) error {
return lookupService.Validate(vatNumber, opts)
}

// ValidateExistsWithResponse validates that the given VAT number exists in the external lookup
// service, returning the full service response on success. If no error then the VAT number is valid.
func ValidateExistsWithResponse(vatNumber string, optsSlice ...ValidatorOpts) (*LookupResponse, error) {
if len(vatNumber) < 3 {
return nil, ErrInvalidVATNumberFormat
}

vatNumber = strings.ToUpper(vatNumber)

lookupService := ViesLookupService
if strings.HasPrefix(vatNumber, "GB") {
lookupService = UKVATLookupService
}

opts := ValidatorOpts{}
if len(optsSlice) > 0 {
opts = optsSlice[0]
}

if svc, ok := lookupService.(lookupServiceWithResponse); ok {
return svc.validateWithResponse(vatNumber, opts)
}
return nil, lookupService.Validate(vatNumber, opts)
}

// ValidatorOpts are options for the VAT number validator.
type ValidatorOpts struct {
UKClientID string
UKClientSecret string
UKAccessToken *UKAccessToken
IsUKTest bool
}

// LookupResponse contains the response from the VAT lookup service.
// Exactly one of VIESResponse or UKVATResponse will be populated, depending on the service used.
type LookupResponse struct {
VIESResponse *VIESResponse `json:"viesResponse,omitempty"`
UKVATResponse *UKVATResponse `json:"ukVATResponse,omitempty"`
}
88 changes: 88 additions & 0 deletions validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,94 @@ func TestValidateExists(t *testing.T) {
}
}

// stubLookupService implements both LookupServiceInterface and lookupServiceWithResponse,
// returning canned values for testing.
type stubLookupService struct {
response *LookupResponse
err error
}

func (s *stubLookupService) Validate(_ string, _ ValidatorOpts) error {
return s.err
}

func (s *stubLookupService) validateWithResponse(_ string, _ ValidatorOpts) (*LookupResponse, error) {
return s.response, s.err
}

func TestValidateExistsWithResponse(t *testing.T) {
defer restoreLookupServices()

t.Run("returns VIES response on success", func(t *testing.T) {
expected := &LookupResponse{
VIESResponse: &VIESResponse{
CountryCode: "NL",
VATNumber: "123456789B01",
Valid: true,
Name: "Test Company B.V.",
Address: "123 Test St",
},
}
ViesLookupService = &stubLookupService{response: expected}

resp, err := ValidateExistsWithResponse("NL123456789B01")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if resp != expected {
t.Errorf("expected response %+v, got %+v", expected, resp)
}
})

t.Run("returns UK response on success", func(t *testing.T) {
expected := &LookupResponse{
UKVATResponse: &UKVATResponse{
ProcessingDate: "2026-04-08",
},
}
expected.UKVATResponse.Target.Name = "UK Test Ltd"
expected.UKVATResponse.Target.VATNumber = "333289454"
UKVATLookupService = &stubLookupService{response: expected}

resp, err := ValidateExistsWithResponse("GB333289454")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if resp != expected {
t.Errorf("expected response %+v, got %+v", expected, resp)
}
})

t.Run("returns error and nil response on failure", func(t *testing.T) {
ViesLookupService = &stubLookupService{err: ErrVATNumberNotFound}

resp, err := ValidateExistsWithResponse("NL123456789B01")
if err != ErrVATNumberNotFound {
t.Errorf("expected ErrVATNumberNotFound, got %v", err)
}
if resp != nil {
t.Errorf("expected nil response, got %+v", resp)
}
})

t.Run("falls back to error-only when service lacks response support", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockService := NewMockLookupServiceInterface(ctrl)
ViesLookupService = mockService
mockService.EXPECT().Validate("NL123456789B01", ValidatorOpts{}).Return(nil)

resp, err := ValidateExistsWithResponse("NL123456789B01")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if resp != nil {
t.Errorf("expected nil response from fallback, got %+v", resp)
}
})
}

func restoreLookupServices() {
ViesLookupService = &viesService{}
UKVATLookupService = &ukVATService{}
Expand Down
Loading
Loading