diff --git a/lines.go b/lines.go index a2fc9a3..491877a 100644 --- a/lines.go +++ b/lines.go @@ -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 + 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()) @@ -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 + 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()) @@ -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) diff --git a/lines_test.go b/lines_test.go index de10fa6..fa18feb 100644 --- a/lines_test.go +++ b/lines_test.go @@ -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 { @@ -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 { @@ -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 {