From 77a34a68e18e8a8e35bbe199eb9a11f1d2c8f32c Mon Sep 17 00:00:00 2001 From: Chris Rollins <3865054+chrisrollins65@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:37:34 +0200 Subject: [PATCH 1/5] Add option to return entire response from VIES or UK service when validating VAT number https://digitalcrew.teamwork.com/app/tasks/26053221 --- mock_lookup_service.go | 7 ++-- uk_vat_service.go | 48 +++++++++++++++++++++------ uk_vat_service_test.go | 42 +++++++++++++++++++++++- validate.go | 26 ++++++++++----- validate_test.go | 73 ++++++++++++++++++++++++++++++++++++++++-- vies_service.go | 46 ++++++++++++++------------ vies_service_test.go | 24 +++++++++++++- 7 files changed, 221 insertions(+), 45 deletions(-) diff --git a/mock_lookup_service.go b/mock_lookup_service.go index eba320a..a3ff59c 100644 --- a/mock_lookup_service.go +++ b/mock_lookup_service.go @@ -34,11 +34,12 @@ func (m *MockLookupServiceInterface) EXPECT() *MockLookupServiceInterfaceMockRec } // Validate mocks base method. -func (m *MockLookupServiceInterface) Validate(vatNumber string, opts ValidatorOpts) error { +func (m *MockLookupServiceInterface) Validate(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Validate", vatNumber, opts) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(*LookupResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 } // Validate indicates an expected call of Validate. diff --git a/uk_vat_service.go b/uk_vat_service.go index 364c651..ae0c01a 100644 --- a/uk_vat_service.go +++ b/uk_vat_service.go @@ -16,13 +16,14 @@ import ( 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 { +// When opts.IncludeResponse is true, the full UK VAT API response is returned in LookupResponse.UKVATResponse. +func (s *ukVATService) Validate(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 } @@ -31,7 +32,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( @@ -42,7 +43,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") @@ -54,26 +55,33 @@ 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 + if opts.IncludeResponse { + var ukResp UKVATResponse + if err := json.NewDecoder(response.Body).Decode(&ukResp); err != nil { + return nil, ErrServiceUnavailable{Err: err} + } + return &LookupResponse{UKVATResponse: &ukResp}, nil + } + return nil, nil } // UKAccessToken contains access token information used to authenticate with the UK VAT API. @@ -154,3 +162,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"` +} diff --git a/uk_vat_service_test.go b/uk_vat_service_test.go index 13a4ae5..b729938 100644 --- a/uk_vat_service_test.go +++ b/uk_vat_service_test.go @@ -58,9 +58,49 @@ func TestUKVATService(t *testing.T) { opts.UKAccessToken = token for _, test := range ukTests { - err := UKVATLookupService.Validate(test.vatNumber, opts) + _, 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) } } } + +func TestUKVATServiceIncludeResponse(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, + IncludeResponse: 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 := UKVATLookupService.Validate("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 when IncludeResponse is true") + } + 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") + } +} diff --git a/validate.go b/validate.go index a963549..7b35b28 100644 --- a/validate.go +++ b/validate.go @@ -7,13 +7,14 @@ import ( ) // Validate validates a VAT number by both format and existence. If no error then it is valid. +// When opts.IncludeResponse is true, the full service response is returned in LookupResponse. // 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 { +func Validate(vatNumber string, opts ...ValidatorOpts) (*LookupResponse, error) { err := ValidateFormat(vatNumber) if err != nil { - return err + return nil, err } return ValidateExists(vatNumber, opts...) } @@ -78,9 +79,10 @@ func ValidateFormat(vatNumber string) error { } // ValidateExists validates that the given VAT number exists in the external lookup service. -func ValidateExists(vatNumber string, optsSlice ...ValidatorOpts) error { +// When opts.IncludeResponse is true, the full service response is returned in LookupResponse. +func ValidateExists(vatNumber string, optsSlice ...ValidatorOpts) (*LookupResponse, error) { if len(vatNumber) < 3 { - return ErrInvalidVATNumberFormat + return nil, ErrInvalidVATNumberFormat } vatNumber = strings.ToUpper(vatNumber) @@ -100,8 +102,16 @@ func ValidateExists(vatNumber string, optsSlice ...ValidatorOpts) error { // ValidatorOpts are options for the VAT number validator. type ValidatorOpts struct { - UKClientID string - UKClientSecret string - UKAccessToken *UKAccessToken - IsUKTest bool + UKClientID string + UKClientSecret string + UKAccessToken *UKAccessToken + IsUKTest bool + IncludeResponse 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"` } diff --git a/validate_test.go b/validate_test.go index 59d811b..73ef902 100644 --- a/validate_test.go +++ b/validate_test.go @@ -201,16 +201,85 @@ func TestValidateExists(t *testing.T) { for _, test := range lookupTests { if len(test.vatNumber) >= 3 { - test.service.EXPECT().Validate(test.vatNumber, ValidatorOpts{}).Return(test.expectedError) + test.service.EXPECT().Validate(test.vatNumber, ValidatorOpts{}).Return(nil, test.expectedError) } - err := ValidateExists(test.vatNumber) + _, err := ValidateExists(test.vatNumber) if !errors.Is(err, test.expectedError) { t.Errorf("Expected <%v> for %v, got <%v>", test.expectedError, test.vatNumber, err) } } } +func TestValidateExistsIncludeResponse(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockViesService := NewMockLookupServiceInterface(ctrl) + mockUKVATService := NewMockLookupServiceInterface(ctrl) + ViesLookupService = mockViesService + UKVATLookupService = mockUKVATService + + defer restoreLookupServices() + + viesResp := &LookupResponse{ + VIESResponse: &VIESResponse{ + CountryCode: "NL", + VATNumber: "123456789B01", + RequestDate: "2026-04-08+02:00", + Valid: true, + Name: "Test Company", + Address: "123 Test St", + }, + } + ukResp := &LookupResponse{ + UKVATResponse: &UKVATResponse{ + ProcessingDate: "2026-04-08", + }, + } + ukResp.UKVATResponse.Target.Name = "UK Test Ltd" + ukResp.UKVATResponse.Target.VATNumber = "333289454" + + opts := ValidatorOpts{IncludeResponse: true} + + // VIES path: response is returned + mockViesService.EXPECT().Validate("NL123456789B01", opts).Return(viesResp, nil) + resp, err := ValidateExists("NL123456789B01", opts) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if resp == nil || resp.VIESResponse == nil { + t.Fatal("expected VIESResponse to be populated") + } + if resp.VIESResponse.Name != "Test Company" { + t.Errorf("expected Name 'Test Company', got %q", resp.VIESResponse.Name) + } + + // UK path: response is returned + mockUKVATService.EXPECT().Validate("GB333289454", opts).Return(ukResp, nil) + resp, err = ValidateExists("GB333289454", opts) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if resp == nil || resp.UKVATResponse == nil { + t.Fatal("expected UKVATResponse to be populated") + } + if resp.UKVATResponse.Target.Name != "UK Test Ltd" { + t.Errorf("expected Target.Name 'UK Test Ltd', got %q", resp.UKVATResponse.Target.Name) + } + + // VIES path: nil response when IncludeResponse is false + defaultOpts := ValidatorOpts{} + mockViesService.EXPECT().Validate("NL123456789B01", defaultOpts).Return(nil, nil) + resp, err = ValidateExists("NL123456789B01") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if resp != nil { + t.Errorf("expected nil response when IncludeResponse is false, got %+v", resp) + } +} + func restoreLookupServices() { ViesLookupService = &viesService{} UKVATLookupService = &ukVATService{} diff --git a/vies_service.go b/vies_service.go index 6639b0e..bf5d5c2 100644 --- a/vies_service.go +++ b/vies_service.go @@ -11,22 +11,22 @@ import ( // LookupServiceInterface is an interface for the service that calls external services to validate VATs. type LookupServiceInterface interface { - Validate(vatNumber string, opts ValidatorOpts) error + Validate(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) } // viesService validates EU VAT numbers with the VIES service type viesService struct{} // Validate returns whether the given VAT number is valid or not -// There currently are no VIES options, so the "opts" parameter is here for interface compliance but ignored -func (s *viesService) Validate(vatNumber string, _ ValidatorOpts) error { +// When opts.IncludeResponse is true, the full VIES response is returned in LookupResponse.VIESResponse. +func (s *viesService) Validate(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) { if len(vatNumber) < 3 { - return ErrInvalidVATNumberFormat + return nil, ErrInvalidVATNumberFormat } res, err := s.lookup(s.getEnvelope(vatNumber)) if err != nil { - return ErrServiceUnavailable{Err: err} + return nil, ErrServiceUnavailable{Err: err} } defer func() { _ = res.Body.Close() @@ -34,19 +34,19 @@ func (s *viesService) Validate(vatNumber string, _ ValidatorOpts) error { xmlRes, err := io.ReadAll(res.Body) if err != nil { - return ErrServiceUnavailable{Err: err} // assume if we can't read the body then VIES gave us a bad response + return nil, ErrServiceUnavailable{Err: err} // assume if we can't read the body then VIES gave us a bad response } // check if response contains "INVALID_INPUT" string if bytes.Contains(xmlRes, []byte("INVALID_INPUT")) { - return ErrInvalidVATNumberFormat + return nil, ErrInvalidVATNumberFormat } // check if response contains "MS_UNAVAILABLE" string if bytes.Contains(xmlRes, []byte("MS_UNAVAILABLE")) { - return ErrServiceUnavailable{Err: errors.New("vies reports service is unavailable")} + return nil, ErrServiceUnavailable{Err: errors.New("vies reports service is unavailable")} } else if bytes.Contains(xmlRes, []byte("MS_MAX_CONCURRENT_REQ")) { - return ErrServiceUnavailable{Err: errors.New("max concurrent requests limit hit")} + return nil, ErrServiceUnavailable{Err: errors.New("max concurrent requests limit hit")} } var rd struct { @@ -65,10 +65,10 @@ func (s *viesService) Validate(vatNumber string, _ ValidatorOpts) error { } } if err = xml.Unmarshal(xmlRes, &rd); err != nil { - return ErrServiceUnavailable{Err: err} // assume if response data doesn't match the struct, the service is down + return nil, ErrServiceUnavailable{Err: err} // assume if response data doesn't match the struct, the service is down } - r := &viesResponse{ + r := &VIESResponse{ CountryCode: rd.Soap.Soap.CountryCode, VATNumber: rd.Soap.Soap.VATNumber, RequestDate: rd.Soap.Soap.RequestDate, @@ -78,9 +78,13 @@ func (s *viesService) Validate(vatNumber string, _ ValidatorOpts) error { } if !r.Valid { - return ErrVATNumberNotFound + return nil, ErrVATNumberNotFound } - return nil + + if opts.IncludeResponse { + return &LookupResponse{VIESResponse: r}, nil + } + return nil, nil } // getEnvelope parses VIES lookup envelope template @@ -115,12 +119,12 @@ func (s *viesService) lookup(envelope string) (*http.Response, error) { const viesServiceURL = "https://ec.europa.eu/taxation_customs/vies/services/checkVatService" -// viesResponse holds the response data from the Vies call -type viesResponse struct { - CountryCode string - VATNumber string - RequestDate string - Valid bool - Name string - Address string +// VIESResponse holds the response data from the VIES service. +type VIESResponse struct { + CountryCode string `json:"countryCode"` + VATNumber string `json:"vatNumber"` + RequestDate string `json:"requestDate"` + Valid bool `json:"valid"` + Name string `json:"name"` + Address string `json:"address"` } diff --git a/vies_service_test.go b/vies_service_test.go index 2c343cd..4fee92a 100644 --- a/vies_service_test.go +++ b/vies_service_test.go @@ -23,10 +23,32 @@ var viesTests = []struct { // The external VIES calls are not always reliable so sometimes these tests may fail. Do not include them in CI/CD. func TestViesService(t *testing.T) { for _, test := range viesTests { - err := ViesLookupService.Validate(test.vatNumber, ValidatorOpts{}) + _, err := ViesLookupService.Validate(test.vatNumber, ValidatorOpts{}) if !errors.Is(err, test.expectedError) { t.Errorf("Expected <%v> for %v, got <%v>", test.expectedError, test.vatNumber, err) } time.Sleep(time.Second * 2) // delay to prevent rate limiting } } + +func TestViesServiceIncludeResponse(t *testing.T) { + resp, err := ViesLookupService.Validate("BE0472429986", ValidatorOpts{IncludeResponse: true}) + if err != nil { + t.Fatalf("expected no error for valid VAT, got %v", err) + } + if resp == nil || resp.VIESResponse == nil { + t.Fatal("expected VIESResponse to be populated when IncludeResponse is true") + } + if resp.VIESResponse.CountryCode != "BE" { + t.Errorf("expected CountryCode 'BE', got %q", resp.VIESResponse.CountryCode) + } + if resp.VIESResponse.VATNumber != "0472429986" { + t.Errorf("expected VATNumber '0472429986', got %q", resp.VIESResponse.VATNumber) + } + if !resp.VIESResponse.Valid { + t.Error("expected Valid to be true") + } + if resp.VIESResponse.Name == "" { + t.Error("expected Name to be populated") + } +} From 5a40c13bc581c27e822f6a0bece6451ebced7e23 Mon Sep 17 00:00:00 2001 From: Chris Rollins <3865054+chrisrollins65@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:39:08 +0200 Subject: [PATCH 2/5] Make changes backwards compatible --- mock_lookup_service.go | 7 +- uk_vat_service.go | 20 +++--- uk_vat_service_test.go | 15 ++--- validate.go | 54 ++++++++++++---- validate_test.go | 141 +++++++++++++++++++++++------------------ vies_service.go | 21 ++++-- vies_service_test.go | 8 +-- 7 files changed, 162 insertions(+), 104 deletions(-) diff --git a/mock_lookup_service.go b/mock_lookup_service.go index a3ff59c..eba320a 100644 --- a/mock_lookup_service.go +++ b/mock_lookup_service.go @@ -34,12 +34,11 @@ func (m *MockLookupServiceInterface) EXPECT() *MockLookupServiceInterfaceMockRec } // Validate mocks base method. -func (m *MockLookupServiceInterface) Validate(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) { +func (m *MockLookupServiceInterface) Validate(vatNumber string, opts ValidatorOpts) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Validate", vatNumber, opts) - ret0, _ := ret[0].(*LookupResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret0, _ := ret[0].(error) + return ret0 } // Validate indicates an expected call of Validate. diff --git a/uk_vat_service.go b/uk_vat_service.go index ae0c01a..c8e5c70 100644 --- a/uk_vat_service.go +++ b/uk_vat_service.go @@ -16,8 +16,13 @@ import ( type ukVATService struct{} // Validate checks if the given VAT number exists and is active. If no error is returned, then it is. -// When opts.IncludeResponse is true, the full UK VAT API response is returned in LookupResponse.UKVATResponse. -func (s *ukVATService) Validate(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) { +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) @@ -74,14 +79,11 @@ func (s *ukVATService) Validate(vatNumber string, opts ValidatorOpts) (*LookupRe } // If we receive a valid 200 response from this API, it means the VAT number exists and is valid - if opts.IncludeResponse { - var ukResp UKVATResponse - if err := json.NewDecoder(response.Body).Decode(&ukResp); err != nil { - return nil, ErrServiceUnavailable{Err: err} - } - return &LookupResponse{UKVATResponse: &ukResp}, nil + var ukResp UKVATResponse + if err := json.NewDecoder(response.Body).Decode(&ukResp); err != nil { + return nil, ErrServiceUnavailable{Err: err} } - return nil, nil + return &LookupResponse{UKVATResponse: &ukResp}, nil } // UKAccessToken contains access token information used to authenticate with the UK VAT API. diff --git a/uk_vat_service_test.go b/uk_vat_service_test.go index b729938..954ddd5 100644 --- a/uk_vat_service_test.go +++ b/uk_vat_service_test.go @@ -58,23 +58,22 @@ func TestUKVATService(t *testing.T) { opts.UKAccessToken = token for _, test := range ukTests { - _, err := UKVATLookupService.Validate(test.vatNumber, opts) + 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) } } } -func TestUKVATServiceIncludeResponse(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, - IncludeResponse: true, + UKClientID: os.Getenv("CLIENT_ID"), + UKClientSecret: os.Getenv("SECRET"), + IsUKTest: true, } if opts.UKClientID == "" || opts.UKClientSecret == "" { @@ -87,12 +86,12 @@ func TestUKVATServiceIncludeResponse(t *testing.T) { } opts.UKAccessToken = token - resp, err := UKVATLookupService.Validate("GB553557881", opts) + 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 when IncludeResponse is true") + 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) diff --git a/validate.go b/validate.go index 7b35b28..42f7275 100644 --- a/validate.go +++ b/validate.go @@ -7,18 +7,27 @@ import ( ) // Validate validates a VAT number by both format and existence. If no error then it is valid. -// When opts.IncludeResponse is true, the full service response is returned in LookupResponse. // 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) (*LookupResponse, error) { +func Validate(vatNumber string, opts ...ValidatorOpts) error { err := ValidateFormat(vatNumber) if err != nil { - return nil, err + return err } 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{ @@ -79,10 +88,9 @@ func ValidateFormat(vatNumber string) error { } // ValidateExists validates that the given VAT number exists in the external lookup service. -// When opts.IncludeResponse is true, the full service response is returned in LookupResponse. -func ValidateExists(vatNumber string, optsSlice ...ValidatorOpts) (*LookupResponse, error) { +func ValidateExists(vatNumber string, optsSlice ...ValidatorOpts) error { if len(vatNumber) < 3 { - return nil, ErrInvalidVATNumberFormat + return ErrInvalidVATNumberFormat } vatNumber = strings.ToUpper(vatNumber) @@ -100,13 +108,37 @@ func ValidateExists(vatNumber string, optsSlice ...ValidatorOpts) (*LookupRespon 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 - IncludeResponse bool + UKClientID string + UKClientSecret string + UKAccessToken *UKAccessToken + IsUKTest bool } // LookupResponse contains the response from the VAT lookup service. diff --git a/validate_test.go b/validate_test.go index 73ef902..e4a94e8 100644 --- a/validate_test.go +++ b/validate_test.go @@ -201,83 +201,102 @@ func TestValidateExists(t *testing.T) { for _, test := range lookupTests { if len(test.vatNumber) >= 3 { - test.service.EXPECT().Validate(test.vatNumber, ValidatorOpts{}).Return(nil, test.expectedError) + test.service.EXPECT().Validate(test.vatNumber, ValidatorOpts{}).Return(test.expectedError) } - _, err := ValidateExists(test.vatNumber) + err := ValidateExists(test.vatNumber) if !errors.Is(err, test.expectedError) { t.Errorf("Expected <%v> for %v, got <%v>", test.expectedError, test.vatNumber, err) } } } -func TestValidateExistsIncludeResponse(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() +// stubLookupService implements both LookupServiceInterface and lookupServiceWithResponse, +// returning canned values for testing. +type stubLookupService struct { + response *LookupResponse + err error +} - mockViesService := NewMockLookupServiceInterface(ctrl) - mockUKVATService := NewMockLookupServiceInterface(ctrl) - ViesLookupService = mockViesService - UKVATLookupService = mockUKVATService +func (s *stubLookupService) Validate(vatNumber string, opts ValidatorOpts) error { + return s.err +} +func (s *stubLookupService) validateWithResponse(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) { + return s.response, s.err +} + +func TestValidateExistsWithResponse(t *testing.T) { defer restoreLookupServices() - viesResp := &LookupResponse{ - VIESResponse: &VIESResponse{ - CountryCode: "NL", - VATNumber: "123456789B01", - RequestDate: "2026-04-08+02:00", - Valid: true, - Name: "Test Company", - Address: "123 Test St", - }, - } - ukResp := &LookupResponse{ - UKVATResponse: &UKVATResponse{ - ProcessingDate: "2026-04-08", - }, - } - ukResp.UKVATResponse.Target.Name = "UK Test Ltd" - ukResp.UKVATResponse.Target.VATNumber = "333289454" + 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} - opts := ValidatorOpts{IncludeResponse: true} + 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) + } + }) - // VIES path: response is returned - mockViesService.EXPECT().Validate("NL123456789B01", opts).Return(viesResp, nil) - resp, err := ValidateExists("NL123456789B01", opts) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if resp == nil || resp.VIESResponse == nil { - t.Fatal("expected VIESResponse to be populated") - } - if resp.VIESResponse.Name != "Test Company" { - t.Errorf("expected Name 'Test Company', got %q", resp.VIESResponse.Name) - } + 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} - // UK path: response is returned - mockUKVATService.EXPECT().Validate("GB333289454", opts).Return(ukResp, nil) - resp, err = ValidateExists("GB333289454", opts) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if resp == nil || resp.UKVATResponse == nil { - t.Fatal("expected UKVATResponse to be populated") - } - if resp.UKVATResponse.Target.Name != "UK Test Ltd" { - t.Errorf("expected Target.Name 'UK Test Ltd', got %q", resp.UKVATResponse.Target.Name) - } + 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) + } + }) - // VIES path: nil response when IncludeResponse is false - defaultOpts := ValidatorOpts{} - mockViesService.EXPECT().Validate("NL123456789B01", defaultOpts).Return(nil, nil) - resp, err = ValidateExists("NL123456789B01") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if resp != nil { - t.Errorf("expected nil response when IncludeResponse is false, got %+v", 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() { diff --git a/vies_service.go b/vies_service.go index bf5d5c2..dd25621 100644 --- a/vies_service.go +++ b/vies_service.go @@ -11,15 +11,25 @@ import ( // LookupServiceInterface is an interface for the service that calls external services to validate VATs. type LookupServiceInterface interface { - Validate(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) + Validate(vatNumber string, opts ValidatorOpts) error +} + +// lookupServiceWithResponse is implemented by services that can return the full response. +type lookupServiceWithResponse interface { + validateWithResponse(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) } // viesService validates EU VAT numbers with the VIES service type viesService struct{} // Validate returns whether the given VAT number is valid or not -// When opts.IncludeResponse is true, the full VIES response is returned in LookupResponse.VIESResponse. -func (s *viesService) Validate(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) { +func (s *viesService) Validate(vatNumber string, opts ValidatorOpts) error { + _, err := s.validateWithResponse(vatNumber, opts) + return err +} + +// validateWithResponse performs validation and returns the full VIES response. +func (s *viesService) validateWithResponse(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) { if len(vatNumber) < 3 { return nil, ErrInvalidVATNumberFormat } @@ -81,10 +91,7 @@ func (s *viesService) Validate(vatNumber string, opts ValidatorOpts) (*LookupRes return nil, ErrVATNumberNotFound } - if opts.IncludeResponse { - return &LookupResponse{VIESResponse: r}, nil - } - return nil, nil + return &LookupResponse{VIESResponse: r}, nil } // getEnvelope parses VIES lookup envelope template diff --git a/vies_service_test.go b/vies_service_test.go index 4fee92a..5fde04d 100644 --- a/vies_service_test.go +++ b/vies_service_test.go @@ -23,7 +23,7 @@ var viesTests = []struct { // The external VIES calls are not always reliable so sometimes these tests may fail. Do not include them in CI/CD. func TestViesService(t *testing.T) { for _, test := range viesTests { - _, err := ViesLookupService.Validate(test.vatNumber, ValidatorOpts{}) + err := ViesLookupService.Validate(test.vatNumber, ValidatorOpts{}) if !errors.Is(err, test.expectedError) { t.Errorf("Expected <%v> for %v, got <%v>", test.expectedError, test.vatNumber, err) } @@ -31,13 +31,13 @@ func TestViesService(t *testing.T) { } } -func TestViesServiceIncludeResponse(t *testing.T) { - resp, err := ViesLookupService.Validate("BE0472429986", ValidatorOpts{IncludeResponse: true}) +func TestViesServiceWithResponse(t *testing.T) { + resp, err := ValidateExistsWithResponse("BE0472429986") if err != nil { t.Fatalf("expected no error for valid VAT, got %v", err) } if resp == nil || resp.VIESResponse == nil { - t.Fatal("expected VIESResponse to be populated when IncludeResponse is true") + t.Fatal("expected VIESResponse to be populated") } if resp.VIESResponse.CountryCode != "BE" { t.Errorf("expected CountryCode 'BE', got %q", resp.VIESResponse.CountryCode) From a89e9def52dd0bcd8da6ab85c152a617e259194c Mon Sep 17 00:00:00 2001 From: Chris Rollins <3865054+chrisrollins65@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:10:59 +0200 Subject: [PATCH 3/5] Linter fixes --- validate_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/validate_test.go b/validate_test.go index e4a94e8..3029d4a 100644 --- a/validate_test.go +++ b/validate_test.go @@ -218,11 +218,11 @@ type stubLookupService struct { err error } -func (s *stubLookupService) Validate(vatNumber string, opts ValidatorOpts) error { +func (s *stubLookupService) Validate(_ string, _ ValidatorOpts) error { return s.err } -func (s *stubLookupService) validateWithResponse(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) { +func (s *stubLookupService) validateWithResponse(_ string, _ ValidatorOpts) (*LookupResponse, error) { return s.response, s.err } From 1416ba32554ddae44929ed57d1585ddb5ac45c92 Mon Sep 17 00:00:00 2001 From: Chris Rollins <3865054+chrisrollins65@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:48:14 +0200 Subject: [PATCH 4/5] Fix another lint issue and update linter --- .golangci.yml | 16 +++++++++------- bin/lint | 4 ++-- go.mod | 2 +- vies_service.go | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 0e67d4c..a9c9670 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,8 +1,10 @@ +version: "2" + run: tests: true linters: - disable-all: true + default: none enable: - govet - revive @@ -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 diff --git a/bin/lint b/bin/lint index eb91d5a..aaa72f3 100755 --- a/bin/lint +++ b/bin/lint @@ -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 "$@") diff --git a/go.mod b/go.mod index 5d204ad..08e3ade 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/vies_service.go b/vies_service.go index dd25621..7492ee4 100644 --- a/vies_service.go +++ b/vies_service.go @@ -29,7 +29,7 @@ func (s *viesService) Validate(vatNumber string, opts ValidatorOpts) error { } // validateWithResponse performs validation and returns the full VIES response. -func (s *viesService) validateWithResponse(vatNumber string, opts ValidatorOpts) (*LookupResponse, error) { +func (s *viesService) validateWithResponse(vatNumber string, _ ValidatorOpts) (*LookupResponse, error) { if len(vatNumber) < 3 { return nil, ErrInvalidVATNumberFormat } From a5331b7d9dadf0681c849bf3d2bea329822b4556 Mon Sep 17 00:00:00 2001 From: Chris Rollins <3865054+chrisrollins65@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:58:21 +0200 Subject: [PATCH 5/5] Updaget golangci-lint in github actions --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9f44147..be53724 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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