Skip to content

feat: add Andorra (AD) IGI tax regime#752

Open
Vegely wants to merge 1 commit intoinvopop:mainfrom
Vegely:feat/regime-ad-andorra
Open

feat: add Andorra (AD) IGI tax regime#752
Vegely wants to merge 1 commit intoinvopop:mainfrom
Vegely:feat/regime-ad-andorra

Conversation

@Vegely
Copy link

@Vegely Vegely commented Mar 3, 2026

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:

  • Increased (Tipus Incrementat) — 9.5%
  • General (Tipus General) — 4.5%
  • Intermediate (Tipus Intermedi) — 2.5%
  • Reduced (Tipus Reduit) — 1%
  • Zero (Tipus Zero) — 0%

Tax Identity & Party Validation

  • Validates the Andorran NRT (Número de Registre Tributari) format using regex structural requirements.
  • Validates non-resident parties to ensure they provide at least a foreign tax ID or passport/DNI, fulfilling the requirements of Forms 980-A and 980-B.

Supported Scenarios

  • Form 980-A: Traveler / Export Refund (tax.TagExport).
  • Form 980-B: Non-Resident B2B ("non-resident-b2b").
  • Form 980-C: Diplomatic Exemption ("diplomatic").

Testing

  • 29 tests passing.
  • Comprehensive coverage across NRT format validation, party validation, end-to-end invoice scenarios, and direct inspection of tax rate definitions.

Note: This contribution was prepared as part of the application process for the Graduate Product Engineer role at Invopop.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ad regime 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.KeyStandard when VAT keys are present, so querying with tax.KeyStandard better reflects actual usage (and will keep working if cbc.KeyEmpty is 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,
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
Category: tax.CategoryVAT,
Category: tax.CategoryVAT,
Key: tax.KeyZero,

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +21
// Full fiscal-representative enforcement (name + NRT) is handled
// at the invoice addon layer via the "non-resident-b2b" tag.
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +16
func validateParty(party *org.Party) error {
if party == nil || party.TaxID == nil {
return nil
}
if party.TaxID.Country.In("AD") {
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +30
// 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
}),
),
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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")
}),

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +27
{
Keys: []cbc.Key{cbc.KeyEmpty, tax.KeyStandard},
Rate: "increased",
Name: i18n.String{
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +50
Keys: []cbc.Key{cbc.KeyEmpty, tax.KeyStandard},
Rate: tax.RateIntermediate,
Name: i18n.String{
i18n.EN: "Intermediate Rate",
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +61
Keys: []cbc.Key{cbc.KeyEmpty, tax.KeyStandard},
Rate: tax.RateReduced,
Name: i18n.String{
i18n.EN: "Reduced Rate",
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants