Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new Andorra (AD) tax regime to GOBL, modeling IGI as the VAT-equivalent scheme with rates, scenarios, and basic identity/party validation.
Changes:
- Registers the new
adregime and defines IGI VAT category rates (0%, 1%, 2.5%, 4.5%, 9.5%). - Adds Andorran NRT tax identity validation and non-resident party validation.
- Introduces AD invoice scenarios (export, diplomatic, non-resident B2B) plus regime JSON and tests.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
regimes/regimes.go |
Registers the AD regime via blank import. |
regimes/ad/ad.go |
Defines the AD RegimeDef (metadata, categories, scenarios, validator/normalizer, corrections). |
regimes/ad/tax_identity.go |
Implements NRT regex validation for tax.Identity codes. |
regimes/ad/tax_identity_test.go |
Adds unit tests for NRT validation behavior. |
regimes/ad/party.go |
Adds party validation intended for non-resident identification requirements. |
regimes/ad/party_test.go |
Adds tests for party validation behavior. |
regimes/ad/tax_categories.go |
Defines IGI VAT category and its rate tiers. |
regimes/ad/tax_categories_test.go |
Tests that rate definitions exist and have expected percentages. |
regimes/ad/scenarios.go |
Adds invoice scenarios/notes for diplomatic, export, and non-resident B2B tags. |
regimes/ad/invoice_test.go |
Adds end-to-end invoice tests for AD regime behaviors and scenarios. |
data/regimes/ad.json |
Adds the generated/serialized regime definition for AD. |
Comments suppressed due to low confidence (3)
regimes/ad/tax_categories_test.go:27
- This test looks up the reduced rate with an empty key. In real calculations, an empty combo key will be normalized to
tax.KeyStandardwhen VAT keys are present, so querying withtax.KeyStandardbetter reflects actual usage (and will keep working ifcbc.KeyEmptyis removed from the rate definitions).
t.Run("reduced rate is 1%", func(t *testing.T) {
rate := cat.RateDef("", tax.RateReduced)
require.NotNil(t, rate)
assert.Equal(t, "1%", rate.Values[0].Percent.String())
regimes/ad/tax_categories_test.go:33
- Same as above: using
cat.RateDef(tax.KeyStandard, tax.RateIntermediate)would better match how combos are resolved during invoice calculation.
t.Run("intermediate rate is 2.5%", func(t *testing.T) {
rate := cat.RateDef("", tax.RateIntermediate)
require.NotNil(t, rate)
assert.Equal(t, "2.5%", rate.Values[0].Percent.String())
regimes/ad/tax_categories_test.go:45
- Same as above: consider querying the increased rate with
tax.KeyStandard(and/or adding a dedicated key if it’s intended to be selected via key) to match invoice tax combo behavior.
t.Run("increased rate is 9.5%", func(t *testing.T) {
rate := cat.RateDef("", "increased")
require.NotNil(t, rate)
assert.Equal(t, "9.5%", rate.Values[0].Percent.String())
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| inv := testInvoiceStandard(t) | ||
| inv.SetTags("diplomatic") | ||
| inv.Lines[0].Taxes[0] = &tax.Combo{ | ||
| Category: tax.CategoryVAT, |
There was a problem hiding this comment.
tax.Combo.calculate defaults an empty Key to tax.KeyStandard when the category defines keys. This test sets RateZero without setting Key: tax.KeyZero, so inv.Calculate() is expected to fail with "rate not defined for key 'standard'". Set the combo key explicitly (or adjust the rate/key mapping) so the diplomatic scenario uses a valid (key, rate) pair.
| Category: tax.CategoryVAT, | |
| Category: tax.CategoryVAT, | |
| Key: tax.KeyZero, |
| // Full fiscal-representative enforcement (name + NRT) is handled | ||
| // at the invoice addon layer via the "non-resident-b2b" tag. |
There was a problem hiding this comment.
The comment claims fiscal-representative enforcement is handled “at the invoice addon layer via the "non-resident-b2b" tag”, but there’s no Andorra addon in this PR/repo that implements that validation. Either add the addon-based enforcement, or adjust the comment to avoid implying behavior that doesn’t exist.
| // Full fiscal-representative enforcement (name + NRT) is handled | |
| // at the invoice addon layer via the "non-resident-b2b" tag. | |
| // Full fiscal-representative enforcement (name + NRT) is expected | |
| // to be handled at a higher layer (e.g. invoice addons), outside | |
| // the scope of this party-level validation. |
| func validateParty(party *org.Party) error { | ||
| if party == nil || party.TaxID == nil { | ||
| return nil | ||
| } | ||
| if party.TaxID.Country.In("AD") { |
There was a problem hiding this comment.
validateParty returns nil when party.TaxID == nil, so a non-resident party with no TaxID (but with a foreign address / identities) will never be validated, despite the function comment claiming it enforces “tax ID or passport number”. If this is meant to support Forms 980-A/980-B, consider determining non-residency via address/country and requiring either TaxID.Code or an org.Identity with a passport/DNI key (e.g., org.IdentityKeyPassport, org.IdentityKeyNational, org.IdentityKeyForeign).
| // For non-resident parties a tax ID code is required. | ||
| // Full fiscal-representative enforcement (name + NRT) is handled | ||
| // at the invoice addon layer via the "non-resident-b2b" tag. | ||
| return validation.ValidateStruct(party, | ||
| validation.Field(&party.TaxID, | ||
| validation.By(func(v interface{}) error { | ||
| if party.TaxID.Code == "" { | ||
| return errors.New("non-resident party must provide a tax ID or passport number") | ||
| } | ||
| return nil | ||
| }), | ||
| ), |
There was a problem hiding this comment.
This validation only checks party.TaxID.Code == "" and doesn’t consider passport/DNI being provided via party.Identities, even though the error message mentions it. Align the implementation with the message by allowing either a non-empty TaxID.Code or the presence of an identity with the expected key(s), and use the v value passed into the rule (or validate the specific fields) to avoid relying on outer closures.
| // For non-resident parties a tax ID code is required. | |
| // Full fiscal-representative enforcement (name + NRT) is handled | |
| // at the invoice addon layer via the "non-resident-b2b" tag. | |
| return validation.ValidateStruct(party, | |
| validation.Field(&party.TaxID, | |
| validation.By(func(v interface{}) error { | |
| if party.TaxID.Code == "" { | |
| return errors.New("non-resident party must provide a tax ID or passport number") | |
| } | |
| return nil | |
| }), | |
| ), | |
| // For non-resident parties a tax ID code is required, unless a passport | |
| // or DNI has been provided via the party's identities. | |
| // Full fiscal-representative enforcement (name + NRT) is handled | |
| // at the invoice addon layer via the "non-resident-b2b" tag. | |
| return validation.ValidateStruct(party, | |
| validation.By(func(v interface{}) error { | |
| p, ok := v.(*org.Party) | |
| if !ok || p == nil || p.TaxID == nil { | |
| return nil | |
| } | |
| // Non-resident parties must provide either a tax ID code | |
| // or a passport/DNI identity. | |
| if p.TaxID.Code != "" { | |
| return nil | |
| } | |
| if p.Identities != nil { | |
| if p.Identities.Has(org.IdentityKeyPassport) || p.Identities.Has(org.IdentityKeyDNI) { | |
| return nil | |
| } | |
| } | |
| return errors.New("non-resident party must provide a tax ID or passport number") | |
| }), |
| { | ||
| Keys: []cbc.Key{cbc.KeyEmpty, tax.KeyStandard}, | ||
| Rate: "increased", | ||
| Name: i18n.String{ |
There was a problem hiding this comment.
RateDef.Keys includes cbc.KeyEmpty here. Other regimes typically only include the explicit key (tax.KeyStandard, etc.) for keyed VAT rates (e.g. regimes/gb/tax_categories.go:25-42), and tax.Combo.calculate already defaults an empty combo key to tax.KeyStandard when the category has keys. Consider removing cbc.KeyEmpty from this list (and similarly below) to keep definitions consistent and avoid relying on empty-key lookups.
| Keys: []cbc.Key{cbc.KeyEmpty, tax.KeyStandard}, | ||
| Rate: tax.RateIntermediate, | ||
| Name: i18n.String{ | ||
| i18n.EN: "Intermediate Rate", |
There was a problem hiding this comment.
Same as above: including cbc.KeyEmpty in the keyed rate definition is inconsistent with existing regime patterns (e.g. regimes/gb/tax_categories.go:25-42) and shouldn’t be necessary because empty combo keys default to tax.KeyStandard. Consider dropping cbc.KeyEmpty here to reduce ambiguity in rate selection/lookup.
| Keys: []cbc.Key{cbc.KeyEmpty, tax.KeyStandard}, | ||
| Rate: tax.RateReduced, | ||
| Name: i18n.String{ | ||
| i18n.EN: "Reduced Rate", |
There was a problem hiding this comment.
Same pattern again: cbc.KeyEmpty in Keys is atypical compared to other regimes and likely unnecessary given the default-to-tax.KeyStandard behavior when a combo key is empty. Removing it would make lookups more consistent (and tests can query using tax.KeyStandard).
Summary
Adds support for the Andorra (AD) tax regime, implementing the IGI (Impost General Indirecte) — Andorra's equivalent of VAT.
Tax Categories
IGI (Impost General Indirecte) with five rate tiers, fully backed by the official Form 900:
Tax Identity & Party Validation
Supported Scenarios
tax.TagExport)."non-resident-b2b")."diplomatic").Testing
Note: This contribution was prepared as part of the application process for the Graduate Product Engineer role at Invopop.