diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b28ea7c9..c4c7c49a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +### Fixed + +- `bill`: removing taxes included in prices from a document using the `currency` rounding rule now switches it to `precise`, so that the resulting tax bases and amounts match those of the original document. Before, the tax-exclusive line totals were rounded to the currency's precision, which drifted from the original tax amounts by an amount that grew with the number of lines and left the difference in the document's `rounding` total. + ## [v0.504.0] ### Added diff --git a/bill/calculator.go b/bill/calculator.go index 1c5e2a0d5..99ae0e6bd 100644 --- a/bill/calculator.go +++ b/bill/calculator.go @@ -290,6 +290,17 @@ func removeIncludedTaxes(doc billable) error { tx := doc.getTax() tx.PricesInclude = "" + // Tax-exclusive prices need more decimal places than the currency to + // represent the original amounts, so the currency rounding rule can no + // longer be applied without losing the tax totals. + rr := tx.Rounding + if rr == "" { + rr = doc.RegimeDef().GetRoundingRule() + } + if rr == tax.RoundingRuleCurrency { + tx.Rounding = tax.RoundingRulePrecise + } + if err := calculate(doc); err != nil { return err } diff --git a/bill/calculator_test.go b/bill/calculator_test.go index 1f23aa151..85e27bbac 100644 --- a/bill/calculator_test.go +++ b/bill/calculator_test.go @@ -281,6 +281,44 @@ func TestRemoveIncludedTaxes(t *testing.T) { assert.Equal(t, "1000.00", inv.Totals.Payable.String()) }) + t.Run("with currency rounding rule", func(t *testing.T) { + lines := make([]*bill.Line, 12) + for i := range lines { + lines[i] = &bill.Line{ + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Name: "Room rate", + Price: num.NewAmount(12500, 2), + }, + Taxes: tax.Set{ + { + Category: tax.CategoryVAT, + Percent: num.NewPercentage(6, 2), + }, + }, + } + } + inv := baseInvoice(t, lines...) + inv.Tax.Rounding = tax.RoundingRuleCurrency + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.RemoveIncludedTaxes()) + + // The document can no longer use the currency's precision for the + // line prices, so it switches to the precise rounding rule in order + // to maintain the original tax amounts. + assert.Equal(t, tax.RoundingRulePrecise, inv.Tax.Rounding) + assert.Equal(t, "117.9245", inv.Lines[0].Item.Price.String()) + assert.Equal(t, "1415.09", inv.Totals.Sum.String()) + assert.Equal(t, "1415.09", inv.Totals.Total.String()) + assert.Equal(t, "84.91", inv.Totals.Tax.String()) + assert.Equal(t, "1500.00", inv.Totals.TotalWithTax.String()) + assert.Equal(t, "1500.00", inv.Totals.Payable.String()) + assert.Nil(t, inv.Totals.Rounding, "no rounding adjustment needed") + rt := inv.Totals.Taxes.Categories[0].Rates[0] + assert.Equal(t, "1415.09", rt.Base.String()) + assert.Equal(t, "84.91", rt.Amount.String()) + }) + t.Run("from discounts", func(t *testing.T) { inv := baseInvoiceWithLines(t) inv.Discounts = []*bill.Discount{ diff --git a/bill/invoice.go b/bill/invoice.go index 0f74dff4b..68097084c 100644 --- a/bill/invoice.go +++ b/bill/invoice.go @@ -300,6 +300,10 @@ func (inv *Invoice) supportedTags() []cbc.Key { // If after removing taxes the totals don't match, a rounding error will be added to the // invoice totals. In most scenarios this shouldn't be more than a cent or two. // +// Documents using the `currency` rounding rule are switched to `precise`, as the +// tax-exclusive prices need more decimal places than the currency to reproduce the +// original tax amounts. +// // This method will replace the invoice contents in place, or return an error. func (inv *Invoice) RemoveIncludedTaxes() error { return removeIncludedTaxes(inv)