diff --git a/README.md b/README.md index 4be3955..1bde679 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ Package vat === ![Build](https://github.com/Teamwork/vat/actions/workflows/build.yml/badge.svg) -[![Go Report Card](https://goreportcard.com/badge/github.com/teamwork/vat/v2)](https://goreportcard.com/report/github.com/teamwork/vat/v2) -[![GoDoc](https://godoc.org/github.com/teamwork/vat/v2?status.svg)](https://godoc.org/github.com/teamwork/vat/v2) +[![Go Report Card](https://goreportcard.com/badge/github.com/teamwork/vat/v3)](https://goreportcard.com/report/github.com/teamwork/vat/v3) +[![GoDoc](https://godoc.org/github.com/teamwork/vat/v3?status.svg)](https://godoc.org/github.com/teamwork/vat/v3) [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/teamwork/vat/master/LICENSE) -Package for validating VAT numbers & retrieving VAT rates (from [ibericode/vat-rates](https://github.com/ibericode/vat-rates)) in Go. +Package for validating VAT numbers & retrieving VAT rates ( +from [ibericode/vat-rates](https://github.com/ibericode/vat-rates)) in Go. Based on https://github.com/dannyvankooten/vat @@ -28,7 +29,13 @@ import "github.com/teamwork/vat" ### Validating VAT numbers -VAT numbers can be validated by format, existence or both. VAT numbers are looked up using the [VIES VAT validation API](http://ec.europa.eu/taxation_customs/vies/). +VAT numbers can be validated by format, existence or both. + +EU VAT numbers are looked up using the [VIES VAT validation API](http://ec.europa.eu/taxation_customs/vies/). + +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) +. ```go package main @@ -36,20 +43,24 @@ package main import "github.com/teamwork/vat" func main() { - // Validate number by format + existence - validity, err := vat.ValidateNumber("NL123456789B01") + // These validation functions return an error if the VAT number is invalid. If no error, then it is valid. + + // Validate number by format + existence + err := vat.Validate("NL123456789B01") - // Validate number format - validity, err := vat.ValidateNumberFormat("NL123456789B01") + // Validate number format + err := vat.ValidateFormat("NL123456789B01") - // Validate number existence - validity, err := vat.ValidateNumberExistence("NL123456789B01") + // Validate number existence + err := vat.ValidateExists("NL123456789B01") } ``` ### Retrieving VAT rates -> This package relies on a [community maintained repository of vat rates](https://github.com/ibericode/vat-rates). We invite you to toggle notifications for that repository and contribute changes to VAT rates in your country once they are announced. +> This package relies on a [community maintained repository of vat rates](https://github.com/ibericode/vat-rates). We +> invite you to toggle notifications for that repository and contribute changes to VAT rates in your country once they +> are announced. To get VAT rate periods for a country, first get a CountryRates struct using the country's ISO-3166-1-alpha2 code. @@ -59,16 +70,16 @@ You can get the rate that is currently in effect using the `GetRate` function. package main import ( - "fmt" - "github.com/teamwork/vat" + "fmt" + "github.com/teamwork/vat" ) func main() { - c, err := vat.GetCountryRates("IE") - r, err := c.GetRate("standard") + c, err := vat.GetCountryRates("IE") + r, err := c.GetRate("standard") - fmt.Printf("Standard VAT rate for IE is %.2f", r) - // Output: Standard VAT rate for IE is 23.00 + fmt.Printf("Standard VAT rate for IE is %.2f", r) + // Output: Standard VAT rate for IE is 23.00 } ``` diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..0be741d --- /dev/null +++ b/errors.go @@ -0,0 +1,28 @@ +package vat + +import ( + "errors" + "fmt" +) + +// ErrInvalidVATNumberFormat will be returned if a VAT with an invalid format is given +var ErrInvalidVATNumberFormat = errors.New("vat: VAT number format is invalid") + +// ErrVATNumberNotFound will be returned if the given VAT number is not found in the external lookup service +var ErrVATNumberNotFound = errors.New("vat: number not found as an existing active VAT number") + +// ErrInvalidCountryCode indicates that this package could not find a country matching the VAT number prefix +var ErrInvalidCountryCode = errors.New("vat: unknown country code") + +// ErrInvalidRateLevel will be returned when getting wrong rate level +var ErrInvalidRateLevel = errors.New("vat: unknown rate level") + +// ErrServiceUnavailable will be returned when the service is unreachable +type ErrServiceUnavailable struct { + Err error +} + +// Error returns the error message +func (e ErrServiceUnavailable) Error() string { + return fmt.Sprintf("vat: service is unreachable: %v", e.Err) +} diff --git a/gen.go b/gen.go new file mode 100644 index 0000000..cb928cb --- /dev/null +++ b/gen.go @@ -0,0 +1,3 @@ +//go:generate mockgen -destination=mocks/mock_lookup_service.go -package=mocks github.com/teamwork/vat/v3 LookupServiceInterface + +package vat diff --git a/go.mod b/go.mod index 125b6bb..be2ce61 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/teamwork/vat/v2 +module github.com/teamwork/vat/v3 go 1.21 diff --git a/lookup_service.go b/lookup_service.go deleted file mode 100644 index c05f81a..0000000 --- a/lookup_service.go +++ /dev/null @@ -1,29 +0,0 @@ -package vat - -import ( - "bytes" - "net/http" - "time" -) - -// ViesServiceInterface is an interface for the service that calls VIES. -type ViesServiceInterface interface { - Lookup(envelope string) (*http.Response, error) -} - -// ViesService implements ViesServiceInterface using the HTTP client. -type ViesService struct{} - -// Lookup calls the VIES service to get info about the VAT number -func (s *ViesService) Lookup(envelope string) (*http.Response, error) { - envelopeBuffer := bytes.NewBufferString(envelope) - client := http.Client{ - Timeout: time.Duration(ServiceTimeout) * time.Second, - } - return client.Post(serviceURL, "text/xml;charset=UTF-8", envelopeBuffer) -} - -const serviceURL = "http://ec.europa.eu/taxation_customs/vies/services/checkVatService" - -// ServiceTimeout indicates the number of seconds before a service request times out. -var ServiceTimeout = 10 diff --git a/mocks/mock_lookup_service.go b/mocks/mock_lookup_service.go new file mode 100644 index 0000000..0f6ef65 --- /dev/null +++ b/mocks/mock_lookup_service.go @@ -0,0 +1,48 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/teamwork/vat/v3 (interfaces: LookupServiceInterface) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockLookupServiceInterface is a mock of LookupServiceInterface interface. +type MockLookupServiceInterface struct { + ctrl *gomock.Controller + recorder *MockLookupServiceInterfaceMockRecorder +} + +// MockLookupServiceInterfaceMockRecorder is the mock recorder for MockLookupServiceInterface. +type MockLookupServiceInterfaceMockRecorder struct { + mock *MockLookupServiceInterface +} + +// NewMockLookupServiceInterface creates a new mock instance. +func NewMockLookupServiceInterface(ctrl *gomock.Controller) *MockLookupServiceInterface { + mock := &MockLookupServiceInterface{ctrl: ctrl} + mock.recorder = &MockLookupServiceInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLookupServiceInterface) EXPECT() *MockLookupServiceInterfaceMockRecorder { + return m.recorder +} + +// Validate mocks base method. +func (m *MockLookupServiceInterface) Validate(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Validate", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Validate indicates an expected call of Validate. +func (mr *MockLookupServiceInterfaceMockRecorder) Validate(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Validate", reflect.TypeOf((*MockLookupServiceInterface)(nil).Validate), arg0) +} diff --git a/mocks/mock_vies_service.go b/mocks/mock_vies_service.go deleted file mode 100644 index 0ab4fc7..0000000 --- a/mocks/mock_vies_service.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/teamwork/vat/v2 (interfaces: ViesServiceInterface) - -// Package mocks is a generated GoMock package. -package mocks - -import ( - http "net/http" - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockViesServiceInterface is a mock of ViesServiceInterface interface. -type MockViesServiceInterface struct { - ctrl *gomock.Controller - recorder *MockViesServiceInterfaceMockRecorder -} - -// MockViesServiceInterfaceMockRecorder is the mock recorder for MockViesServiceInterface. -type MockViesServiceInterfaceMockRecorder struct { - mock *MockViesServiceInterface -} - -// NewMockViesServiceInterface creates a new mock instance. -func NewMockViesServiceInterface(ctrl *gomock.Controller) *MockViesServiceInterface { - mock := &MockViesServiceInterface{ctrl: ctrl} - mock.recorder = &MockViesServiceInterfaceMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockViesServiceInterface) EXPECT() *MockViesServiceInterfaceMockRecorder { - return m.recorder -} - -// Lookup mocks base method. -func (m *MockViesServiceInterface) Lookup(arg0 string) (*http.Response, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Lookup", arg0) - ret0, _ := ret[0].(*http.Response) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Lookup indicates an expected call of Lookup. -func (mr *MockViesServiceInterfaceMockRecorder) Lookup(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Lookup", reflect.TypeOf((*MockViesServiceInterface)(nil).Lookup), arg0) -} diff --git a/numbers.go b/numbers.go deleted file mode 100644 index 5c1d8d2..0000000 --- a/numbers.go +++ /dev/null @@ -1,181 +0,0 @@ -package vat - -import ( - "bytes" - "encoding/xml" - "errors" - "io" - "regexp" - "strings" -) - -// ViesResponse holds the response data from the Vies call -type ViesResponse struct { - CountryCode string - VATNumber string - RequestDate string - Valid bool - Name string - Address string -} - -// ErrInvalidVATNumber will be returned when an invalid VAT number is passed to a function that validates existence. -var ErrInvalidVATNumber = errors.New("vat: vat number is invalid") - -// ErrCountryNotFound indicates that this package could not find a country matching the VAT number prefix. -var ErrCountryNotFound = errors.New("vat: country not found") - -// ErrServiceUnavailable will be returned when VIES VAT validation API or jsonvat.com is unreachable. -var ErrServiceUnavailable = errors.New("vat: service is unreachable") - -// ValidateNumber validates a VAT number by both format and existence. -// The existence check uses the VIES VAT validation SOAP API and will only run when format validation passes. -func ValidateNumber(n string) (bool, error) { - isValidFormat, err := ValidateNumberFormat(n) - existence := false - - if isValidFormat { - existence, err = ValidateNumberExistence(n) - } - - return isValidFormat && existence, err -} - -// ValidateNumberFormat validates a VAT number by its format. -func ValidateNumberFormat(n string) (bool, error) { - patterns := map[string]string{ - "AT": `U[A-Z0-9]{8}`, - "BE": `(0[0-9]{9}|[0-9]{10})`, - "BG": `[0-9]{9,10}`, - "CH": `(?:E(?:-| )[0-9]{3}(?:\.| )[0-9]{3}(?:\.| )[0-9]{3}( MWST)?|E[0-9]{9}(?:MWST)?)`, - "CY": `[0-9]{8}[A-Z]`, - "CZ": `[0-9]{8,10}`, - "DE": `[0-9]{9}`, - "DK": `[0-9]{8}`, - "EE": `[0-9]{9}`, - "EL": `[0-9]{9}`, - "ES": `[A-Z][0-9]{7}[A-Z]|[0-9]{8}[A-Z]|[A-Z][0-9]{8}`, - "FI": `[0-9]{8}`, - "FR": `([A-Z]{2}|[0-9]{2})[0-9]{9}`, - "GB": `[0-9]{9}|[0-9]{12}|(GD|HA)[0-9]{3}`, - "HR": `[0-9]{11}`, - "HU": `[0-9]{8}`, - "IE": `[A-Z0-9]{7}[A-Z]|[A-Z0-9]{7}[A-W][A-I]`, - "IT": `[0-9]{11}`, - "LT": `([0-9]{9}|[0-9]{12})`, - "LU": `[0-9]{8}`, - "LV": `[0-9]{11}`, - "MT": `[0-9]{8}`, - "NL": `[0-9]{9}B[0-9]{2}`, - "PL": `[0-9]{10}`, - "PT": `[0-9]{9}`, - "RO": `[0-9]{2,10}`, - "SE": `[0-9]{12}`, - "SI": `[0-9]{8}`, - "SK": `[0-9]{10}`, - } - - if len(n) < 3 { - return false, nil - } - - n = strings.ToUpper(n) - pattern, ok := patterns[n[0:2]] - if !ok { - return false, ErrCountryNotFound - } - - matched, err := regexp.MatchString(pattern, n[2:]) - return matched, err -} - -// ValidateNumberExistence validates a VAT number by its existence using the VIES VAT API (using SOAP) -func ValidateNumberExistence(vatNumber string) (bool, error) { - r, err := Lookup(vatNumber, &ViesService{}) - if err != nil { - return false, err - } - return r.Valid, nil -} - -// Lookup returns *ViesResponse for a VAT number -func Lookup(vatNumber string, service ViesServiceInterface) (*ViesResponse, error) { - if len(vatNumber) < 3 { - return nil, ErrInvalidVATNumber - } - - res, err := service.Lookup(getEnvelope(vatNumber)) - if err != nil { - return nil, ErrServiceUnavailable - } - defer func() { - _ = res.Body.Close() - }() - - xmlRes, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - // check if response contains "INVALID_INPUT" string - if bytes.Contains(xmlRes, []byte("INVALID_INPUT")) { - return nil, ErrInvalidVATNumber - } - - // check if response contains "MS_UNAVAILABLE" string - if bytes.Contains(xmlRes, []byte("MS_UNAVAILABLE")) || - bytes.Contains(xmlRes, []byte("MS_MAX_CONCURRENT_REQ")) { - return nil, ErrServiceUnavailable - } - - var rd struct { - XMLName xml.Name `xml:"Envelope"` - Soap struct { - XMLName xml.Name `xml:"Body"` - Soap struct { - XMLName xml.Name `xml:"checkVatResponse"` - CountryCode string `xml:"countryCode"` - VATNumber string `xml:"vatNumber"` - RequestDate string `xml:"requestDate"` // 2015-03-06+01:00 - Valid bool `xml:"valid"` - Name string `xml:"name"` - Address string `xml:"address"` - } - } - } - if err = xml.Unmarshal(xmlRes, &rd); err != nil { - return nil, err - } - - r := &ViesResponse{ - CountryCode: rd.Soap.Soap.CountryCode, - VATNumber: rd.Soap.Soap.VATNumber, - RequestDate: rd.Soap.Soap.RequestDate, - Valid: rd.Soap.Soap.Valid, - Name: rd.Soap.Soap.Name, - Address: rd.Soap.Soap.Address, - } - - return r, nil -} - -// getEnvelope parses envelope template -func getEnvelope(n string) string { - n = strings.ToUpper(n) - countryCode := n[0:2] - vatNumber := n[2:] - const envelopeTemplate = ` - - - - {{.countryCode}} - {{.vatNumber}} - - -` - - e := envelopeTemplate - e = strings.Replace(e, "{{.countryCode}}", countryCode, 1) - e = strings.Replace(e, "{{.vatNumber}}", vatNumber, 1) - return e -} diff --git a/numbers_test.go b/numbers_test.go deleted file mode 100644 index 01d9ec4..0000000 --- a/numbers_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package vat - -import ( - "bytes" - "fmt" - "io" - "net/http" - "strconv" - "testing" - - "github.com/golang/mock/gomock" - "github.com/teamwork/vat/v2/mocks" -) - -var tests = []struct { - number string - valid bool -}{ - {"", false}, - {"A", false}, - {"AB123A01", false}, - {"ATU12345678", true}, - {"ATU15673009", true}, - {"ATU1234567", false}, - {"BE0123456789", true}, - {"BE1234567891", true}, - {"BE0999999999", true}, - {"BE9999999999", true}, - {"BE012345678", false}, - {"BE123456789", false}, - {"BG123456789", true}, - {"BG1234567890", true}, - {"BG1234567", false}, - {"CHE-156.730.098 MWST", true}, - {"CHE-156.730.098", true}, - {"CHE156730098MWST", true}, - {"CHE156730098", true}, - {"CY12345678X", true}, - {"CY15673009L", true}, - {"CY1234567X", false}, - {"CZ12345678", true}, - {"CZ1234567", false}, - {"DE123456789", true}, - {"DE12345678", false}, - {"DK12345678", true}, - {"DK1234567", false}, - {"EE123456789", true}, - {"EE12345678", false}, - {"EL123456789", true}, - {"EL12345678", false}, - {"ESX12345678", true}, - {"ESX1234567", false}, - {"FI1234567", false}, - {"FI12345678", true}, - {"FR12345678901", true}, - {"FR1234567890", false}, - {"GB999999973", true}, - {"GB156730098481", true}, - {"GBGD549", true}, - {"GBHA549", true}, - {"GB99999997", false}, - {"HU12345678", true}, - {"HU1234567", false}, - {"HR12345678901", true}, - {"HR1234567890", false}, - {"IE1234567X", true}, - {"IE123456X", false}, - {"IT12345678901", true}, - {"IT1234567890", false}, - {"LT123456789", true}, - {"LT12345678", false}, - {"LU26375245", true}, - {"LU12345678", true}, - {"LU1234567", false}, - {"LV12345678901", true}, - {"LV1234567890", false}, - {"MT12345678", true}, - {"MT1234567", false}, - {"NL123456789B01", true}, - {"NL123456789B12", true}, - {"NL12345678B12", false}, - {"PL1234567890", true}, - {"PL123456789", false}, - {"PT123456789", true}, - {"PT12345678", false}, - {"RO123456789", true}, - {"RO1", false}, // Romania has a really weird VAT format... - {"SE123456789012", true}, - {"SE12345678901", false}, - {"SI12345678", true}, - {"SI1234567", false}, - {"SK1234567890", true}, - {"SK123456789", false}, - {"KL123456789B12", false}, - {"NL12343678B12", false}, - {"PL1224567890", true}, - {"PL123456989", false}, - {"PT125456789", true}, - {"PT16345678", false}, - {"RO123456789", true}, - {"KT123456789", false}, - {"LT12335678", false}, - {"LU26275245", true}, - {"LU14345678", true}, - {"LU1234567", false}, - {"LQ12345678901", false}, - {"QE123456789", false}, - {"DE12375670", false}, - {"DK123365678", true}, - {"DO1231567", false}, - {"EE123456789", true}, - {"EL123434678", true}, - {"EL122456789", true}, - {"EL12245678", false}, - - {"CY1134567X", false}, - {"CZ17345678", true}, - {"CZ1239567", false}, - {"DE123456789", true}, - {"DE12325678", false}, - {"DK12385678", true}, - {"DK1234767", false}, - {"EE123456689", true}, - {"EE12343678", false}, - {"EL123426789", true}, - {"EL12342678", false}, - {"ESX12315678", true}, - {"ESX1634567", false}, - {"FI1274567", false}, - {"FI12385678", true}, - {"FR12349678901", true}, - {"FR1234597890", false}, - {"GB999979973", true}, - {"GB15673098481", true}, - {"GBGD529", true}, - {"GBHA519", true}, - {"GB99997997", false}, - {"HU12342678", true}, - {"HU1234167", false}, - {"HR12341678901", true}, - {"HR1234267890", false}, - {"IE1234167X", true}, - {"IE123356X", false}, - {"IT12245678901", true}, - {"IT1231567890", false}, - {"LT123156789", true}, - {"LT12343678", false}, - {"LU26371245", true}, - {"LU12325678", true}, - {"LU1214567", false}, - {"LV12345578901", true}, - {"LV1234367890", false}, - {"MT12325678", true}, - {"MT1234167", false}, - {"NL123156789B01", true}, - {"NL113456789B12", true}, - {"NL12315678B12", false}, - {"PO1234567890", false}, -} - -func BenchmarkValidateFormat(b *testing.B) { - for i := 0; i < b.N; i++ { - _, _ = ValidateNumberFormat("NL" + strconv.Itoa(i)) - } -} - -func TestValidateNumberFormat(t *testing.T) { - for _, test := range tests { - valid, err := ValidateNumberFormat(test.number) - if err != nil { - if err.Error() != ErrCountryNotFound.Error() { - panic(err) - } - } - - if test.valid != valid { - t.Errorf("Expected %v for %v, got %v", test.valid, test.number, valid) - } - - } -} - -var lookupTests = []struct { - fullVatNumber string - countryCode string - vatNumber string - isValid bool - expectedResponse string - expectedError *error -}{ - {"BE0472429986", "BE", "0472429986", true, "", nil}, - {"NL123456789B01", "NL", "123456789B01", false, "", nil}, - {"Hi", "HI", "", false, "", &ErrInvalidVATNumber}, - {"INVALID INPUT", "IN", "VALID INPUT", false, "INVALID_INPUT", &ErrInvalidVATNumber}, - {"BE0472429986", "BE", "0472429986", true, "MS_UNAVAILABLE", &ErrServiceUnavailable}, - {"BE0472429986", "BE", "0472429986", true, "MS_MAX_CONCURRENT_REQ", &ErrServiceUnavailable}, -} - -func TestLookupValidateNumberExistence(t *testing.T) { - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockViesService := mocks.NewMockViesServiceInterface(ctrl) - - for _, test := range lookupTests { - if len(test.fullVatNumber) >= 3 { - expectedViesResponseBody := test.expectedResponse - if expectedViesResponseBody == "" { - expectedViesResponseBody = expectedViesResponse(test.vatNumber, test.countryCode, test.isValid) - } - expectedViesResponse := &http.Response{ - Body: io.NopCloser(bytes.NewBufferString(expectedViesResponseBody)), - StatusCode: http.StatusOK, - } - - mockViesService.EXPECT(). - Lookup(expectedViesRequestEnvelope(test.vatNumber, test.countryCode)). - Return(expectedViesResponse, nil) - } - - viesResponse, err := Lookup(test.fullVatNumber, mockViesService) - if err != nil { - if test.expectedError == nil { - t.Error(fmt.Errorf("unexpected error: %w", err)) - } else if err != *test.expectedError { - t.Error(fmt.Errorf("Expected error: %w\nGot: %s", *test.expectedError, err.Error())) - } - } else { - if viesResponse == nil { - t.Error("expected a ViesResponse, none received") - } - if test.isValid && !viesResponse.Valid { - t.Errorf("expected %s to be a valid VAT number.", test.fullVatNumber) - } - if !test.isValid && viesResponse.Valid { - t.Errorf("expected %s to not be a valid VAT number.", test.fullVatNumber) - } - } - } -} - -func expectedViesRequestEnvelope(vatNumber string, countryCode string) string { - return fmt.Sprintf( - ` - - - - %s - %s - - -`, - countryCode, - vatNumber, - ) -} - -func expectedViesResponse(vatNumber string, countryCode string, isValid bool) string { - return fmt.Sprintf( - ` - - - - %s - %s - 2024-01-24+01:00 - %s - VAT HOLDER'S NAME' - VAT HOLDER'S ADDRESS - - - `, - countryCode, - vatNumber, - strconv.FormatBool(isValid), - ) -} diff --git a/rates.go b/rates.go index b4a47c7..a80689f 100644 --- a/rates.go +++ b/rates.go @@ -2,7 +2,6 @@ package vat import ( "encoding/json" - "errors" "net/http" "strings" "sync" @@ -24,12 +23,6 @@ type CountryRates struct { var mutex = &sync.Mutex{} // protect countriesRates var countriesRates []CountryRates -// ErrInvalidCountryCode will be returned when calling GetCountryRates with an invalid country code -var ErrInvalidCountryCode = errors.New("vat: unknown country code") - -// ErrInvalidRateLevel will be returned when getting wrong rate level -var ErrInvalidRateLevel = errors.New("vat: unknown rate level") - // GetRateOn returns the effective VAT rate on a given date func (cr *CountryRates) GetRateOn(t time.Time, level string) (float32, error) { var activePeriod RatePeriod @@ -90,7 +83,7 @@ func GetRates() ([]CountryRates, error) { // FetchRates fetches the latest VAT rates from ibericode/vat-rates and updates the in-memory rates func FetchRates() ([]CountryRates, error) { client := http.Client{ - Timeout: (time.Duration(ServiceTimeout) * time.Second), + Timeout: time.Duration(ViesServiceTimeout) * time.Second, } r, err := client.Get("https://raw.githubusercontent.com/ibericode/vat-rates/master/vat-rates.json") if err != nil { @@ -111,7 +104,7 @@ func FetchRates() ([]CountryRates, error) { return nil, err } - rates := []CountryRates{} + var rates []CountryRates for code, periods := range apiResponse.Items { rate := CountryRates{CountryCode: code} for _, period := range periods { diff --git a/uk_vat_service.go b/uk_vat_service.go new file mode 100644 index 0000000..bd1eabf --- /dev/null +++ b/uk_vat_service.go @@ -0,0 +1,48 @@ +package vat + +import ( + "fmt" + "io" + "net/http" + "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 { + 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 + } + + response, err := http.Get(fmt.Sprintf(ukVATServiceURL, vatNumber[2:])) + if err != nil { + return ErrServiceUnavailable{Err: err} + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(response.Body) + + if response.StatusCode == http.StatusBadRequest { + return ErrInvalidVATNumberFormat + } + if response.StatusCode == http.StatusNotFound { + return ErrVATNumberNotFound + } + if response.StatusCode != http.StatusOK { + return 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 +} + +// 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" diff --git a/uk_vat_service_test.go b/uk_vat_service_test.go new file mode 100644 index 0000000..3dd1bb2 --- /dev/null +++ b/uk_vat_service_test.go @@ -0,0 +1,27 @@ +//go:build integration +// +build integration + +package vat + +import "testing" + +var ukTests = []struct { + vatNumber string + expectedError error +}{ + {"GB333289454", nil}, // valid number (as of 2024-04-26) + {"GB0472429986", ErrInvalidVATNumberFormat}, + {"Hi", ErrInvalidCountryCode}, + {"GB333289453", ErrVATNumberNotFound}, +} + +// 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) { + for _, test := range ukTests { + err := UKVATLookupService.Validate(test.vatNumber) + if err != test.expectedError { + t.Errorf("Expected <%v> for %v, got <%v>", test.expectedError, test.vatNumber, err) + } + } +} diff --git a/validate.go b/validate.go new file mode 100644 index 0000000..7f49a85 --- /dev/null +++ b/validate.go @@ -0,0 +1,91 @@ +package vat + +import ( + "fmt" + "regexp" + "strings" +) + +// Validate validates a VAT number by both format and existence. If no error then it is valid. +func Validate(vatNumber string) error { + err := ValidateFormat(vatNumber) + if err != nil { + return err + } + return ValidateExists(vatNumber) +} + +// 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{ + "AT": `U[A-Z0-9]{8}`, + "BE": `(0[0-9]{9}|[0-9]{10})`, + "BG": `[0-9]{9,10}`, + "CH": `(?:E(?:-| )[0-9]{3}(?:\.| )[0-9]{3}(?:\.| )[0-9]{3}( MWST)?|E[0-9]{9}(?:MWST)?)`, + "CY": `[0-9]{8}[A-Z]`, + "CZ": `[0-9]{8,10}`, + "DE": `[0-9]{9}`, + "DK": `[0-9]{8}`, + "EE": `[0-9]{9}`, + "EL": `[0-9]{9}`, + "ES": `[A-Z][0-9]{7}[A-Z]|[0-9]{8}[A-Z]|[A-Z][0-9]{8}`, + "FI": `[0-9]{8}`, + "FR": `([A-Z]{2}|[0-9]{2})[0-9]{9}`, + // Supposedly the regex for GB numbers is `[0-9]{9}|[0-9]{12}|(GD|HA)[0-9]{3}`, + // but our validator service only accepts numbers with 9 or 12 digits following the country code. + // Seems like the official site only accepts 9 digits... https://www.gov.uk/check-uk-vat-number + "GB": `([0-9]{9}|[0-9]{12})`, + "HR": `[0-9]{11}`, + "HU": `[0-9]{8}`, + "IE": `[A-Z0-9]{7}[A-Z]|[A-Z0-9]{7}[A-W][A-I]`, + "IT": `[0-9]{11}`, + "LT": `([0-9]{9}|[0-9]{12})`, + "LU": `[0-9]{8}`, + "LV": `[0-9]{11}`, + "MT": `[0-9]{8}`, + "NL": `[0-9]{9}B[0-9]{2}`, + "PL": `[0-9]{10}`, + "PT": `[0-9]{9}`, + "RO": `[0-9]{2,10}`, + "SE": `[0-9]{12}`, + "SI": `[0-9]{8}`, + "SK": `[0-9]{10}`, + "XI": `([0-9]{9}|[0-9]{12})`, // Northern Ireland, same format as GB + } + + if len(vatNumber) < 3 { + return ErrInvalidVATNumberFormat + } + + vatNumber = strings.ToUpper(vatNumber) + + pattern, ok := patterns[vatNumber[0:2]] + if !ok { + return ErrInvalidCountryCode + } + + matched, err := regexp.MatchString(fmt.Sprintf("^%s$", pattern), vatNumber[2:]) + if err != nil { + return err + } + if !matched { + return ErrInvalidVATNumberFormat + } + return nil +} + +// ValidateExists validates that the given VAT number exists in the external lookup service. +func ValidateExists(vatNumber string) error { + if len(vatNumber) < 3 { + return ErrInvalidVATNumberFormat + } + + vatNumber = strings.ToUpper(vatNumber) + + lookupService := ViesLookupService + if strings.HasPrefix(vatNumber, "GB") { + lookupService = UKVATLookupService + } + + return lookupService.Validate(vatNumber) +} diff --git a/validate_test.go b/validate_test.go new file mode 100644 index 0000000..1dbba86 --- /dev/null +++ b/validate_test.go @@ -0,0 +1,214 @@ +package vat + +import ( + "strconv" + "testing" + + "github.com/golang/mock/gomock" + "github.com/teamwork/vat/v3/mocks" +) + +var tests = []struct { + number string + expectedError error +}{ + {"", ErrInvalidVATNumberFormat}, + {"A", ErrInvalidVATNumberFormat}, + {"AB123A01", ErrInvalidCountryCode}, + {"ATU12345678", nil}, + {"ATU15673009", nil}, + {"ATU1234567", ErrInvalidVATNumberFormat}, + {"BE0123456789", nil}, + {"BE1234567891", nil}, + {"BE0999999999", nil}, + {"BE9999999999", nil}, + {"BE012345678", ErrInvalidVATNumberFormat}, + {"BE123456789", ErrInvalidVATNumberFormat}, + {"BG123456789", nil}, + {"BG1234567890", nil}, + {"BG1234567", ErrInvalidVATNumberFormat}, + {"CHE-156.730.098 MWST", nil}, + {"CHE-156.730.098", nil}, + {"CHE156730098MWST", nil}, + {"CHE156730098", nil}, + {"CY12345678X", nil}, + {"CY15673009L", nil}, + {"CY1234567X", ErrInvalidVATNumberFormat}, + {"CZ12345678", nil}, + {"CZ1234567", ErrInvalidVATNumberFormat}, + {"DE123456789", nil}, + {"DE12345678", ErrInvalidVATNumberFormat}, + {"DK12345678", nil}, + {"DK1234567", ErrInvalidVATNumberFormat}, + {"EE123456789", nil}, + {"EE12345678", ErrInvalidVATNumberFormat}, + {"EL123456789", nil}, + {"EL12345678", ErrInvalidVATNumberFormat}, + {"ESX12345678", nil}, + {"ESX1234567", ErrInvalidVATNumberFormat}, + {"FI1234567", ErrInvalidVATNumberFormat}, + {"FI12345678", nil}, + {"FR12345678901", nil}, + {"FR1234567890", ErrInvalidVATNumberFormat}, + {"GB999999973", nil}, + {"GB156730098481", nil}, + {"GBGD549", ErrInvalidVATNumberFormat}, + {"GBHA549", ErrInvalidVATNumberFormat}, + {"GB99999997", ErrInvalidVATNumberFormat}, + {"HU12345678", nil}, + {"HU1234567", ErrInvalidVATNumberFormat}, + {"HR12345678901", nil}, + {"HR1234567890", ErrInvalidVATNumberFormat}, + {"IE1234567X", nil}, + {"IE123456X", ErrInvalidVATNumberFormat}, + {"IT12345678901", nil}, + {"IT1234567890", ErrInvalidVATNumberFormat}, + {"LT123456789", nil}, + {"LT12345678", ErrInvalidVATNumberFormat}, + {"LU26375245", nil}, + {"LU12345678", nil}, + {"LU1234567", ErrInvalidVATNumberFormat}, + {"LV12345678901", nil}, + {"LV1234567890", ErrInvalidVATNumberFormat}, + {"MT12345678", nil}, + {"MT1234567", ErrInvalidVATNumberFormat}, + {"NL123456789B01", nil}, + {"NL123456789B12", nil}, + {"NL12345678B12", ErrInvalidVATNumberFormat}, + {"PL1234567890", nil}, + {"PL123456789", ErrInvalidVATNumberFormat}, + {"PT123456789", nil}, + {"PT12345678", ErrInvalidVATNumberFormat}, + {"RO123456789", nil}, + {"RO1", ErrInvalidVATNumberFormat}, // Romania has a really weird VAT format... + {"SE123456789012", nil}, + {"SE12345678901", ErrInvalidVATNumberFormat}, + {"SI12345678", nil}, + {"SI1234567", ErrInvalidVATNumberFormat}, + {"SK1234567890", nil}, + {"SK123456789", ErrInvalidVATNumberFormat}, + {"KL123456789B12", ErrInvalidCountryCode}, + {"NL12343678B12", ErrInvalidVATNumberFormat}, + {"PL1224567890", nil}, + {"PL123456989", ErrInvalidVATNumberFormat}, + {"PT125456789", nil}, + {"PT16345678", ErrInvalidVATNumberFormat}, + {"RO123456789", nil}, + {"KT123456789", ErrInvalidCountryCode}, + {"LT12335678", ErrInvalidVATNumberFormat}, + {"LU26275245", nil}, + {"LU14345678", nil}, + {"LU1234567", ErrInvalidVATNumberFormat}, + {"LQ12345678901", ErrInvalidCountryCode}, + {"QE123456789", ErrInvalidCountryCode}, + {"DE12375670", ErrInvalidVATNumberFormat}, + {"DK123365678", ErrInvalidVATNumberFormat}, + {"DO1231567", ErrInvalidCountryCode}, + {"EE123456789", nil}, + {"EL123434678", nil}, + {"EL122456789", nil}, + {"EL12245678", ErrInvalidVATNumberFormat}, + {"CY1134567X", ErrInvalidVATNumberFormat}, + {"CZ17345678", nil}, + {"CZ1239567", ErrInvalidVATNumberFormat}, + {"DE123456789", nil}, + {"DE12325678", ErrInvalidVATNumberFormat}, + {"DK12385678", nil}, + {"DK1234767", ErrInvalidVATNumberFormat}, + {"EE123456689", nil}, + {"EE12343678", ErrInvalidVATNumberFormat}, + {"EL123426789", nil}, + {"EL12342678", ErrInvalidVATNumberFormat}, + {"ESX12315678", nil}, + {"ESX1634567", ErrInvalidVATNumberFormat}, + {"FI1274567", ErrInvalidVATNumberFormat}, + {"FI12385678", nil}, + {"FR12349678901", nil}, + {"FR1234597890", ErrInvalidVATNumberFormat}, + {"GB999979973", nil}, + {"GB156730984812", nil}, + {"GBGD529", ErrInvalidVATNumberFormat}, + {"GBHA519", ErrInvalidVATNumberFormat}, + {"GB99997997", ErrInvalidVATNumberFormat}, + {"GB15673098481", ErrInvalidVATNumberFormat}, + {"HU12342678", nil}, + {"HU1234167", ErrInvalidVATNumberFormat}, + {"HR12341678901", nil}, + {"HR1234267890", ErrInvalidVATNumberFormat}, + {"IE1234167X", nil}, + {"IE123356X", ErrInvalidVATNumberFormat}, + {"IT12245678901", nil}, + {"IT122456789013333", ErrInvalidVATNumberFormat}, + {"IT1231567890", ErrInvalidVATNumberFormat}, + {"LT123156789", nil}, + {"LT12343678", ErrInvalidVATNumberFormat}, + {"LU26371245", nil}, + {"LU12325678", nil}, + {"LU1214567", ErrInvalidVATNumberFormat}, + {"LV12345578901", nil}, + {"LV1234367890", ErrInvalidVATNumberFormat}, + {"MT12325678", nil}, + {"MT1234167", ErrInvalidVATNumberFormat}, + {"NL123156789B01", nil}, + {"NL113456789B12", nil}, + {"NL12315678B12", ErrInvalidVATNumberFormat}, + {"PO1234567890", ErrInvalidCountryCode}, +} + +func BenchmarkValidateFormat(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = ValidateFormat("NL" + strconv.Itoa(i)) + } +} + +func TestValidateFormat(t *testing.T) { + for _, test := range tests { + err := ValidateFormat(test.number) + if err != test.expectedError { + t.Errorf("Expected <%v> for %v, got <%v>", test.expectedError, test.number, err) + } + } +} + +func TestValidateExists(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockViesService := mocks.NewMockLookupServiceInterface(ctrl) + mockUKVATService := mocks.NewMockLookupServiceInterface(ctrl) + ViesLookupService = mockViesService + UKVATLookupService = mockUKVATService + + defer restoreLookupServices() + + var lookupTests = []struct { + vatNumber string + service *mocks.MockLookupServiceInterface + expectedError error + }{ + {"BE0472429986", mockViesService, ErrVATNumberNotFound}, + {"NL123456789B01", mockViesService, nil}, + {"Hi", mockViesService, ErrInvalidVATNumberFormat}, + {"INVALID INPUT", mockViesService, ErrInvalidVATNumberFormat}, + {"BE0472429986", mockViesService, ErrServiceUnavailable{Err: nil}}, + {"GB333289454", mockUKVATService, nil}, + // XI is Northern Ireland, which while part of the UK is actually still validated by VIES + {"XI0472429986", mockViesService, nil}, + } + + for _, test := range lookupTests { + if len(test.vatNumber) >= 3 { + test.service.EXPECT().Validate(test.vatNumber).Return(test.expectedError) + } + + err := ValidateExists(test.vatNumber) + if err != test.expectedError { + t.Errorf("Expected <%v> for %v, got <%v>", test.expectedError, test.vatNumber, err) + } + } +} + +func restoreLookupServices() { + ViesLookupService = &viesService{} + UKVATLookupService = &ukVATService{} +} diff --git a/vat.go b/vat.go index 19613ee..730d603 100644 --- a/vat.go +++ b/vat.go @@ -2,10 +2,11 @@ Package vat helps you deal with European VAT in Go. It offers VAT number validation using the VIES VAT validation API & VAT rates retrieval using jsonvat.com +It also offers UK VAT number validation using a UK VAT API. Validate a VAT number - validity := vat.ValidateNumber("NL123456789B01") + err := vat.Validate("NL123456789B01") Get VAT rate that is currently in effect for a given country @@ -13,3 +14,9 @@ Get VAT rate that is currently in effect for a given country r, _ := c.GetRate("standard") */ package vat + +// ViesLookupService is the interface for the VIES VAT number validation service +var ViesLookupService LookupServiceInterface = &viesService{} + +// UKVATLookupService is the interface for the UK VAT number validation service +var UKVATLookupService LookupServiceInterface = &ukVATService{} diff --git a/vies_service.go b/vies_service.go new file mode 100644 index 0000000..0c4eb0f --- /dev/null +++ b/vies_service.go @@ -0,0 +1,127 @@ +package vat + +import ( + "bytes" + "encoding/xml" + "io" + "net/http" + "strings" + "time" +) + +// LookupServiceInterface is an interface for the service that calls external services to validate VATs. +type LookupServiceInterface interface { + Validate(vatNumber string) error +} + +// viesService validates EU VAT numbers with the VIES service +type viesService struct{} + +// Validate returns whether the given VAT number is valid or not +func (s *viesService) Validate(vatNumber string) error { + if len(vatNumber) < 3 { + return ErrInvalidVATNumberFormat + } + + res, err := s.lookup(s.getEnvelope(vatNumber)) + if err != nil { + return ErrServiceUnavailable{Err: err} + } + defer func() { + _ = res.Body.Close() + }() + + 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 + } + + // check if response contains "INVALID_INPUT" string + if bytes.Contains(xmlRes, []byte("INVALID_INPUT")) { + return ErrInvalidVATNumberFormat + } + + // check if response contains "MS_UNAVAILABLE" string + if bytes.Contains(xmlRes, []byte("MS_UNAVAILABLE")) || + bytes.Contains(xmlRes, []byte("MS_MAX_CONCURRENT_REQ")) { + return ErrServiceUnavailable{Err: nil} + } + + var rd struct { + XMLName xml.Name `xml:"Envelope"` + Soap struct { + XMLName xml.Name `xml:"Body"` + Soap struct { + XMLName xml.Name `xml:"checkVatResponse"` + CountryCode string `xml:"countryCode"` + VATNumber string `xml:"vatNumber"` + RequestDate string `xml:"requestDate"` // 2015-03-06+01:00 + Valid bool `xml:"valid"` + Name string `xml:"name"` + Address string `xml:"address"` + } + } + } + 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 + } + + r := &viesResponse{ + CountryCode: rd.Soap.Soap.CountryCode, + VATNumber: rd.Soap.Soap.VATNumber, + RequestDate: rd.Soap.Soap.RequestDate, + Valid: rd.Soap.Soap.Valid, + Name: rd.Soap.Soap.Name, + Address: rd.Soap.Soap.Address, + } + + if !r.Valid { + return ErrVATNumberNotFound + } + return nil +} + +// getEnvelope parses VIES lookup envelope template +func (s *viesService) getEnvelope(n string) string { + n = strings.ToUpper(n) + countryCode := n[0:2] + vatNumber := n[2:] + const envelopeTemplate = ` + + + + {{.countryCode}} + {{.vatNumber}} + + +` + + e := envelopeTemplate + e = strings.Replace(e, "{{.countryCode}}", countryCode, 1) + e = strings.Replace(e, "{{.vatNumber}}", vatNumber, 1) + return e +} + +// lookup calls the VIES service to get info about the VAT number +func (s *viesService) lookup(envelope string) (*http.Response, error) { + envelopeBuffer := bytes.NewBufferString(envelope) + client := http.Client{ + Timeout: time.Duration(ViesServiceTimeout) * time.Second, + } + return client.Post(viesServiceURL, "text/xml;charset=UTF-8", envelopeBuffer) +} + +const viesServiceURL = "https://ec.europa.eu/taxation_customs/vies/services/checkVatService" + +// ViesServiceTimeout is the timeout for the VIES service +const ViesServiceTimeout = 10 + +// viesResponse holds the response data from the Vies call +type viesResponse struct { + CountryCode string + VATNumber string + RequestDate string + Valid bool + Name string + Address string +} diff --git a/vies_service_test.go b/vies_service_test.go new file mode 100644 index 0000000..99b42de --- /dev/null +++ b/vies_service_test.go @@ -0,0 +1,27 @@ +//go:build integration +// +build integration + +package vat + +import "testing" + +var viesTests = []struct { + vatNumber string + expectedError error +}{ + {"BE0472429986", nil}, // valid number + {"NL123456789B01", ErrVATNumberNotFound}, + {"Hi", ErrInvalidVATNumberFormat}, + {"INVALID INPUT", ErrInvalidVATNumberFormat}, +} + +// TestViesService tests the VIES service. Just meant to be a quick way to check that this service is working. +// 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) + if err != test.expectedError { + t.Errorf("Expected <%v> for %v, got <%v>", test.expectedError, test.vatNumber, err) + } + } +}