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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

### Changed

- `pkg/luhn`: Replaced duplicate `computeLuhnCheckDigit` implementations in the `fr` and `it` regimes with a shared `CheckDigit` function.
- `org`, `bill`, `regimes`, `addons/it/sdi`: Replaced duplicate `hasTaxIDCode` helpers with a shared `Party.HasTaxIDCode()` method.
- `bill`: `Invoice.Invert()` returns an error if the invoice has the `bypass` tag.
- `num`: `AmountFromString` now limits precision to 18 significant digits.
- `tax`: Added `$defs` and `$refs` to the `tax.RegimeCode` JSON schema
Expand Down
6 changes: 1 addition & 5 deletions addons/it/sdi/bill.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ func validateCustomer(value interface{}) error {
),
validation.Field(&customer.Identities,
validation.When(
isItalianParty(customer) && !hasTaxIDCode(customer),
isItalianParty(customer) && !customer.HasTaxIDCode(),
org.RequireIdentityKey(it.IdentityKeyFiscalCode),
),
validation.Skip,
Expand Down Expand Up @@ -287,10 +287,6 @@ func validateItalianTelephone(value any) error {
)
}

func hasTaxIDCode(party *org.Party) bool {
return party != nil && party.TaxID != nil && party.TaxID.Code != ""
}

func hasFiscalCode(party *org.Party) bool {
if party == nil {
return false
Expand Down
6 changes: 1 addition & 5 deletions bill/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,17 +214,13 @@ func validateInvoiceCustomer(value any) error {
return validation.ValidateStruct(p,
validation.Field(&p.Name,
validation.When(
partyHasTaxIDCode(p),
p.HasTaxIDCode(),
validation.Required,
),
),
)
}

func partyHasTaxIDCode(party *org.Party) bool {
return party != nil && party.TaxID != nil && party.TaxID.Code != ""
}

// Invert effectively reverses the invoice by inverting the sign of all quantity
// or amount values. Caution should be taken when using this method as
// advances will also be inverted, while payment terms will remain the same,
Expand Down
5 changes: 5 additions & 0 deletions org/party.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ type Party struct {
Meta cbc.Meta `json:"meta,omitempty" jsonschema:"title=Meta"`
}

// HasTaxIDCode returns true if the party has a tax identity with a non-empty code.
func (p *Party) HasTaxIDCode() bool {
return p != nil && p.TaxID != nil && p.TaxID.Code != ""
}

// Calculate will perform basic normalization of the party's data without
// using any tax regime or addon.
func (p *Party) Calculate() error {
Expand Down
14 changes: 14 additions & 0 deletions org/party_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,20 @@ func TestPartyValidation(t *testing.T) {
assert.NoError(t, party.Validate())
assert.Equal(t, "DE", party.GetRegime().String())
})
t.Run("has tax id code", func(t *testing.T) {
var nilParty *org.Party
assert.False(t, nilParty.HasTaxIDCode())

assert.False(t, (&org.Party{Name: "Test"}).HasTaxIDCode())

assert.False(t, (&org.Party{
TaxID: &tax.Identity{Country: "ES"},
}).HasTaxIDCode())

assert.True(t, (&org.Party{
TaxID: &tax.Identity{Country: "ES", Code: "B85905495"},
}).HasTaxIDCode())
})
t.Run("with regime and bad code", func(t *testing.T) {
party := org.Party{
Regime: tax.WithRegime("DE"),
Expand Down
21 changes: 21 additions & 0 deletions pkg/luhn/luhn.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package luhn

import (
"regexp"
"strconv"

"github.com/invopop/gobl/cbc"
)
Expand Down Expand Up @@ -40,3 +41,23 @@ func Check(code cbc.Code) bool {

return checksum%10 == 0
}

// CheckDigit computes the Luhn check digit for the given numeric string.
// The caller is responsible for ensuring the input contains only ASCII digit
// characters; no validation is performed on the input.
func CheckDigit(number string) string {
sum := 0
pos := 0
for i := len(number) - 1; i >= 0; i-- {
digit := int(number[i] - '0')
if pos%2 == 0 {
digit *= 2
if digit > 9 {
digit -= 9
}
}
sum += digit
pos++
}
return strconv.Itoa((10 - sum%10) % 10)
}
26 changes: 26 additions & 0 deletions pkg/luhn/luhn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,29 @@ func TestCheck(t *testing.T) {
})
}
}

func TestCheckDigit(t *testing.T) {
t.Parallel()
tests := []struct {
name string
number string
want string
}{
{name: "single digit", number: "0", want: "0"},
{name: "credit card base", number: "411111111111111", want: "1"},
{name: "luhn example", number: "7992739871", want: "3"},
{name: "italian VAT", number: "0271580010", want: "4"},
{name: "french SIREN", number: "73282932", want: "0"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := luhn.CheckDigit(tt.number)
assert.Equal(t, tt.want, got)
// Verify consistency: number + check digit should pass Check.
full := cbc.Code(tt.number + got)
assert.True(t, luhn.Check(full), "number+check digit should pass Check: %s", full)
})
}
}
6 changes: 1 addition & 5 deletions regimes/be/invoices.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,14 @@ func validateInvoiceSupplier(value any) error {
),
validation.Field(&p.Identities,
validation.When(
!hasTaxIDCode(p),
!p.HasTaxIDCode(),
org.RequireIdentityType(IdentityTypeBCE),
),
validation.Skip,
),
)
}

func hasTaxIDCode(party *org.Party) bool {
return party != nil && party.TaxID != nil && party.TaxID.Code != ""
}

func hasIdentityBCE(party *org.Party) bool {
if party == nil || len(party.Identities) == 0 {
return false
Expand Down
6 changes: 1 addition & 5 deletions regimes/de/invoices.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func validateInvoiceSupplier(value any) error {
),
validation.Field(&p.Identities,
validation.When(
!hasTaxIDCode(p),
!p.HasTaxIDCode(),
org.RequireIdentityKey(IdentityKeyTaxNumber),
),
validation.Skip,
Expand All @@ -49,10 +49,6 @@ func isSimplified(inv *bill.Invoice) bool {
return inv.HasTags(tax.TagSimplified)
}

func hasTaxIDCode(party *org.Party) bool {
return party != nil && party.TaxID != nil && party.TaxID.Code != ""
}

func hasIdentityTaxNumber(party *org.Party) bool {
if party == nil || len(party.Identities) == 0 {
return false
Expand Down
6 changes: 1 addition & 5 deletions regimes/dk/invoices.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,14 @@ func validateInvoiceSupplier(value any) error {
),
validation.Field(&p.Identities,
validation.When(
!hasTaxIDCode(p),
!p.HasTaxIDCode(),
org.RequireIdentityType(IdentityTypeCVR),
),
validation.Skip,
),
)
}

func hasTaxIDCode(party *org.Party) bool {
return party != nil && party.TaxID != nil && party.TaxID.Code != ""
}

func hasIdentityCVR(party *org.Party) bool {
if party == nil || len(party.Identities) == 0 {
return false
Expand Down
6 changes: 1 addition & 5 deletions regimes/fr/invoices.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,14 @@ func validateInvoiceSupplier(value any) error {
),
validation.Field(&p.Identities,
validation.When(
!hasTaxIDCode(p),
!p.HasTaxIDCode(),
org.RequireIdentityType(IdentityTypeSIREN, IdentityTypeSIRET),
),
validation.Skip,
),
)
}

func hasTaxIDCode(party *org.Party) bool {
return party != nil && party.TaxID != nil && party.TaxID.Code != ""
}

func hasSupplierIdentity(party *org.Party) bool {
if party == nil || len(party.Identities) == 0 {
return false
Expand Down
26 changes: 2 additions & 24 deletions regimes/fr/tax_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strconv"

"github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/pkg/luhn"
"github.com/invopop/gobl/tax"
"github.com/invopop/validation"
)
Expand Down Expand Up @@ -98,32 +99,9 @@ func validateSIRENTaxCode(value any) error {

base := str[:8]
chk := str[8:]
v := computeLuhnCheckDigit(base)
if chk != v {
if luhn.CheckDigit(base) != chk {
return errors.New("checksum mismatch")
}

return nil
}

// TODO: refactor this into a shareable method.
func computeLuhnCheckDigit(number string) string {
sum := 0
pos := 0

for i := len(number) - 1; i >= 0; i-- {
digit := int(number[i] - '0')

if pos%2 == 0 {
digit *= 2
if digit > 9 {
digit -= 9
}
}

sum += digit
pos++
}

return strconv.FormatInt(int64((10-(sum%10))%10), 10)
}
27 changes: 2 additions & 25 deletions regimes/it/tax_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ package it

import (
"errors"
"strconv"

"github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/pkg/luhn"
"github.com/invopop/gobl/tax"
"github.com/invopop/validation"
)
Expand Down Expand Up @@ -49,32 +49,9 @@ func validateTaxCode(value interface{}) error {
return errors.New("invalid length")
}

chk := computeLuhnCheckDigit(str[:10])
if chk != str[10:] {
if luhn.CheckDigit(str[:10]) != str[10:] {
return errors.New("invalid check digit")
}

return nil
}

// TODO: refactor this into a shareable method.
func computeLuhnCheckDigit(number string) string {
sum := 0
pos := 0

for i := len(number) - 1; i >= 0; i-- {
digit := int(number[i] - '0')

if pos%2 == 0 {
digit *= 2
if digit > 9 {
digit -= 9
}
}

sum += digit
pos++
}

return strconv.FormatInt(int64((10-(sum%10))%10), 10)
}
6 changes: 1 addition & 5 deletions regimes/nl/invoices.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,14 @@ func validateInvoiceSupplier(value interface{}) error {
),
validation.Field(&p.Identities,
validation.When(
!hasTaxIDCode(p),
!p.HasTaxIDCode(),
org.RequireIdentityType(IdentityTypeKVK, IdentityTypeOIN),
),
validation.Skip,
),
)
}

func hasTaxIDCode(party *org.Party) bool {
return party != nil && party.TaxID != nil && party.TaxID.Code != ""
}

func hasSupplierIdentity(party *org.Party) bool {
if party == nil || len(party.Identities) == 0 {
return false
Expand Down
Loading