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 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/uk_vat_service.go b/uk_vat_service.go index 364c651..c8e5c70 100644 --- a/uk_vat_service.go +++ b/uk_vat_service.go @@ -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 } @@ -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( @@ -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") @@ -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. @@ -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"` +} diff --git a/uk_vat_service_test.go b/uk_vat_service_test.go index 13a4ae5..954ddd5 100644 --- a/uk_vat_service_test.go +++ b/uk_vat_service_test.go @@ -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") + } +} diff --git a/validate.go b/validate.go index a963549..42f7275 100644 --- a/validate.go +++ b/validate.go @@ -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{ @@ -98,6 +108,31 @@ 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 @@ -105,3 +140,10 @@ type ValidatorOpts struct { 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"` +} diff --git a/validate_test.go b/validate_test.go index 59d811b..3029d4a 100644 --- a/validate_test.go +++ b/validate_test.go @@ -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{} diff --git a/vies_service.go b/vies_service.go index 6639b0e..7492ee4 100644 --- a/vies_service.go +++ b/vies_service.go @@ -14,19 +14,29 @@ type LookupServiceInterface interface { 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 -// 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 { +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, _ 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 +44,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 +75,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 +88,10 @@ func (s *viesService) Validate(vatNumber string, _ ValidatorOpts) error { } if !r.Valid { - return ErrVATNumberNotFound + return nil, ErrVATNumberNotFound } - return nil + + return &LookupResponse{VIESResponse: r}, nil } // getEnvelope parses VIES lookup envelope template @@ -115,12 +126,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..5fde04d 100644 --- a/vies_service_test.go +++ b/vies_service_test.go @@ -30,3 +30,25 @@ func TestViesService(t *testing.T) { time.Sleep(time.Second * 2) // delay to prevent rate limiting } } + +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") + } + 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") + } +}