Skip to content
Merged
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
57 changes: 51 additions & 6 deletions lines.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,25 @@ func FromInvoiceTaxAmountToTaxCombo(taxAmount *stripe.InvoiceTotalTaxAmount, reg
// Instead of the percentage we can also look at the taxability_reason field.
// There are different types defined and we could map them to the tax categories in GOBL.

// A reverse charge is issued from the supplier's side: the supplier charges no
// tax and the customer self-accounts. We therefore express it with the supplier
// regime's own category and country, not the customer's. Stripe reports the
// customer's tax category (e.g. Australian GST for an AU customer), which an EU
// supplier's regime (e.g. Poland) doesn't define, so in that case we fall back to
// the regime's VAT category. An actual customer-country rate is not a reverse
// charge and is handled as a foreign tax below.
if taxAmount.TaxabilityReason == stripe.InvoiceTotalTaxAmountTaxabilityReasonReverseCharge {
tc.Country = regimeDef.Country
tc.Key = tax.KeyReverseCharge
return tc
cat := reverseChargeCategory(tc.Category, regimeDef)
if cat != "" {
tc.Category = cat
tc.Country = regimeDef.Country
Comment thread
pmenendz marked this conversation as resolved.
tc.Key = tax.KeyReverseCharge
return tc
}
// The regime defines no reverse-charge-capable category (no VAT), so a valid
// reverse charge can't be expressed here: GOBL only accepts the reverse-charge
// key on VAT. Record the Stripe tax as-is below, without the key — forcing the
// key would make GOBL reject the line.
}

taxDate := newDateFromTS(taxAmount.TaxRate.Created, regimeDef.TimeLocation())
Expand Down Expand Up @@ -361,10 +376,22 @@ func FromCreditNoteTaxAmountToTaxCombo(taxAmount *stripe.CreditNoteTaxAmount, re
// Instead of the percentage we can also look at the taxability_reason field.
// There are different types defined and we could map them to the tax categories in GOBL.

// A reverse charge is issued from the supplier's side (see the invoice version of
// this function for the full rationale): express it with the supplier regime's own
// category and country, falling back to VAT when Stripe reports a foreign category
// the regime doesn't define.
if taxAmount.TaxabilityReason == stripe.CreditNoteTaxAmountTaxabilityReasonReverseCharge {
tc.Country = regimeDef.Country
tc.Key = tax.KeyReverseCharge
return tc
cat := reverseChargeCategory(tc.Category, regimeDef)
if cat != "" {
tc.Category = cat
tc.Country = regimeDef.Country
Comment thread
pmenendz marked this conversation as resolved.
tc.Key = tax.KeyReverseCharge
return tc
}
// The regime defines no reverse-charge-capable category (no VAT), so a valid
// reverse charge can't be expressed here: GOBL only accepts the reverse-charge
// key on VAT. Record the Stripe tax as-is below, without the key — forcing the
// key would make GOBL reject the line.
}

taxDate := newDateFromTS(taxAmount.TaxRate.Created, regimeDef.TimeLocation())
Expand Down Expand Up @@ -395,6 +422,24 @@ func FromCreditNoteTaxAmountToTaxCombo(taxAmount *stripe.CreditNoteTaxAmount, re

//Useful functions

// reverseChargeCategory picks the category to use for a supplier-side reverse
// charge. Stripe reports the category from the customer's perspective (e.g.
// Australian GST for an AU customer), but a reverse charge is the supplier's
// statement that it charges no tax, so it must sit in the supplier's regime. If
// the regime already defines the reported category (e.g. VAT for an EU supplier)
// we keep it; otherwise we fall back to the regime's VAT category. It returns an
// empty code when the regime defines neither, signalling the caller to record the
// tax as-is instead.
func reverseChargeCategory(cat cbc.Code, regimeDef *tax.RegimeDef) cbc.Code {
if regimeDef.CategoryDef(cat) != nil {
return cat
}
if regimeDef.CategoryDef(tax.CategoryVAT) != nil {
return tax.CategoryVAT
}
return ""
}

// lookupRateValue looks up a tax rate and value from a regime definition.
func lookupRateValue(sRate float64, country l10n.Code, cat cbc.Code, date *cal.Date) (rate *tax.RateDef, val *tax.RateValueDef) {
regimeDef := tax.RegimeDefFor(country)
Expand Down
75 changes: 75 additions & 0 deletions lines_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,32 @@ func TestFromInvoiceTaxAmountsReverseCharge(t *testing.T) {
},
},
},
{
// Stripe reports the customer's category (AU GST), which the supplier's
// regime (PL) doesn't define. A reverse charge is issued from the supplier's
// side, so it must fall back to the supplier regime's VAT category and country.
name: "reverse charge with foreign category not in supplier regime",
input: []*stripe.InvoiceTotalTaxAmount{
{
TaxRate: &stripe.TaxRate{
Country: "AU",
TaxType: stripe.TaxRateTaxTypeGST,
EffectivePercentage: 0.0,
Percentage: 10.0,
Created: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).Unix(),
},
TaxabilityReason: stripe.InvoiceTotalTaxAmountTaxabilityReasonReverseCharge,
},
},
regime: l10n.PL, // Polish supplier billing an Australian customer
expected: tax.Set{
{
Category: tax.CategoryVAT,
Country: l10n.PL.Tax(),
Key: tax.KeyReverseCharge,
},
},
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -794,6 +820,31 @@ func TestFromCreditNoteTaxAmountsReverseCharge(t *testing.T) {
},
},
},
{
// Foreign category (AU GST) not defined in the supplier regime (PL):
// falls back to the supplier regime's VAT category and country.
name: "credit note reverse charge with foreign category not in supplier regime",
input: []*stripe.CreditNoteTaxAmount{
{
TaxRate: &stripe.TaxRate{
Country: "AU",
TaxType: stripe.TaxRateTaxTypeGST,
EffectivePercentage: 0.0,
Percentage: 10.0,
Created: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).Unix(),
},
TaxabilityReason: stripe.CreditNoteTaxAmountTaxabilityReasonReverseCharge,
},
},
regime: l10n.PL, // Polish supplier billing an Australian customer
expected: tax.Set{
{
Category: tax.CategoryVAT,
Country: l10n.PL.Tax(),
Key: tax.KeyReverseCharge,
},
},
},
}

for _, tt := range tests {
Expand All @@ -804,6 +855,30 @@ func TestFromCreditNoteTaxAmountsReverseCharge(t *testing.T) {
}
}

// TestReverseChargeFallbackWithoutVATRegime documents the fall-through: when the
// supplier regime defines no reverse-charge-capable category (no VAT), the Stripe
// tax is recorded as-is and the reverse-charge key is NOT forced onto the combo.
// Forcing it would be invalid, as GOBL only accepts the reverse-charge key on VAT.
func TestReverseChargeFallbackWithoutVATRegime(t *testing.T) {
input := []*stripe.InvoiceTotalTaxAmount{
{
TaxRate: &stripe.TaxRate{
Country: "AU",
TaxType: stripe.TaxRateTaxTypeGST,
Percentage: 10.0,
Created: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).Unix(),
},
TaxabilityReason: stripe.InvoiceTotalTaxAmountTaxabilityReasonReverseCharge,
},
}

// US has no VAT category, so the reverse charge cannot be placed in the regime.
result := goblstripe.FromInvoiceTaxAmountsToTaxSet(input, tax.RegimeDefFor(l10n.US))
assert.Len(t, result, 1)
assert.Equal(t, tax.CategoryGST, result[0].Category)
assert.Empty(t, result[0].Key, "reverse-charge key must not be forced onto a combo the regime can't validate")
}

// Credit Notes

func validCreditNoteLine() *stripe.CreditNoteLineItem {
Expand Down
Loading