Skip to content

Commit

Permalink
Refining rounding for EN16931 in line discounts and charges
Browse files Browse the repository at this point in the history
  • Loading branch information
samlown committed Mar 6, 2025
1 parent f7380df commit 5e7b060
Show file tree
Hide file tree
Showing 52 changed files with 245 additions and 114 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

## [Unreleased]

### Added

- `bill`: Line discount and charge `base` property, to use instead of the line sum in order to comply with EN16931.

### Changed

- `bill`: line totals will be rounded to currency precision for presentation only
- `bill`: Discount and Charge amounts always rounded to currency's precision
- `bill`: document Discount and Charge base and amounts always rounded to currency's precision
- `bill`: line Discount and Charge base and amounts always rounded to currency's precision
- `bill`: `round-then-sum` rounding rule _now_ implies that line totals will not be calculated with additional precision, this brings closer aliance with EN16931 requirements.

## [v0.211.0] - 2025-02-28
Expand Down
4 changes: 2 additions & 2 deletions bill/invoice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ func TestRemoveIncludedTax(t *testing.T) {
assert.Equal(t, "826.4463", l0.Item.Price.String())
assert.Equal(t, "826.4463", l0.Sum.String())
assert.Equal(t, "826.4463", l0.Sum.String())
assert.Equal(t, "82.6446", l0.Discounts[0].Amount.String())
assert.Equal(t, "82.64", l0.Discounts[0].Amount.String())
assert.Equal(t, "743.80", l0.Total.String())

assert.Equal(t, "743.80", i2.Totals.Sum.String())
Expand Down Expand Up @@ -589,7 +589,7 @@ func TestRemoveIncludedTaxQuantity(t *testing.T) {
l0 := i2.Lines[0]
assert.Equal(t, "8.2645", l0.Item.Price.String())
assert.Equal(t, "826.4500", l0.Sum.String())
assert.Equal(t, "82.6450", l0.Discounts[0].Amount.String())
assert.Equal(t, "82.65", l0.Discounts[0].Amount.String())
assert.Equal(t, "743.81", l0.Total.String())
assert.Equal(t, "10.00", i.Lines[0].Item.Price.String())

Expand Down
42 changes: 29 additions & 13 deletions bill/line_calculate.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ func calculateLine(l *Line, cur currency.Code, rates []*currency.ExchangeRate, r

// Calculate the line sum and total
sum := price.Multiply(l.Quantity)
total := sum
total = calculateLineDiscounts(l.Discounts, *l.Item.Price, sum, total, cur, rr)
total = calculateLineCharges(l.Charges, *l.Item.Price, sum, total, cur, rr)
total := tax.ApplyRoundingRule(rr, cur, sum)
total = calculateLineDiscounts(l.Discounts, sum, total, cur)
total = calculateLineCharges(l.Charges, sum, total, cur)

// Rescale the final sum to match item's price
sum = sum.Rescale(l.Item.Price.Exp())
Expand All @@ -114,7 +114,9 @@ func calculateLine(l *Line, cur currency.Code, rates []*currency.ExchangeRate, r
return nil
}

// calculate figures out the totals according to quantity and discounts.
// calculateSubline figures out the totals according to quantity and discounts.
// We don't apply rounding rules here, as the objective is to have
// maximum precision to determine the final line item price.
func calculateSubLine(sl *SubLine, cur currency.Code, rates []*currency.ExchangeRate) error {
if sl.Item == nil {
return nil
Expand All @@ -139,8 +141,8 @@ func calculateSubLine(sl *SubLine, cur currency.Code, rates []*currency.Exchange
// Calculate the line sum and total
sum := price.Multiply(sl.Quantity)
total := sum
total = calculateLineDiscounts(sl.Discounts, *sl.Item.Price, sum, total, cur, tax.RoundingRuleSumThenRound)
total = calculateLineCharges(sl.Charges, *sl.Item.Price, sum, total, cur, tax.RoundingRuleSumThenRound)
total = calculateLineDiscounts(sl.Discounts, sum, total, cur)
total = calculateLineCharges(sl.Charges, sum, total, cur)

// Rescale the final sum and total
sl.total = total
Expand All @@ -152,26 +154,40 @@ func calculateSubLine(sl *SubLine, cur currency.Code, rates []*currency.Exchange
return nil
}

func calculateLineDiscounts(discounts []*LineDiscount, price, sum, total num.Amount, cur currency.Code, rr cbc.Key) num.Amount {
func calculateLineDiscounts(discounts []*LineDiscount, sum, total num.Amount, cur currency.Code) num.Amount {
cd := cur.Def()
for _, d := range discounts {
if d.Percent != nil && !d.Percent.IsZero() {
d.Amount = d.Percent.Of(sum) // always override
base := sum
if d.Base != nil {
base = cd.Rescale(*d.Base)
d.Base = &base
}
d.Amount = d.Percent.Of(base) // always override
}
d.Amount = d.Amount.MatchPrecision(price)
d.Amount = tax.ApplyRoundingRule(rr, cur, d.Amount)
total = total.Subtract(d.Amount)
// As per EN16931 specs, discount amounts have same number of
// decimal places as the currency.
d.Amount = cd.Rescale(d.Amount)
}
return total
}

func calculateLineCharges(charges []*LineCharge, price, sum, total num.Amount, cur currency.Code, rr cbc.Key) num.Amount {
func calculateLineCharges(charges []*LineCharge, sum, total num.Amount, cur currency.Code) num.Amount {
cd := cur.Def()
for _, c := range charges {
if c.Percent != nil && !c.Percent.IsZero() {
base := sum
if c.Base != nil {
base = cd.Rescale(*c.Base)
c.Base = &base
}
c.Amount = c.Percent.Of(sum) // always override
}
c.Amount = c.Amount.MatchPrecision(price)
c.Amount = tax.ApplyRoundingRule(rr, cur, c.Amount)
total = total.Add(c.Amount)
// As per EN16931 specs, charge amounts have same number of
// decimal places as the currency.
c.Amount = cd.Rescale(c.Amount)
}
return total
}
Expand Down
53 changes: 52 additions & 1 deletion bill/line_calculate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ func TestLineCalculate(t *testing.T) {
sum := calculateLineSum(lines, currency.EUR)
assert.Equal(t, "35.1356", sum.String())
assert.Equal(t, "37.7802", lines[0].Sum.String())
assert.Equal(t, "2.6446", lines[0].Discounts[0].Amount.String())
assert.Equal(t, "2.64", lines[0].Discounts[0].Amount.String())
assert.Equal(t, "35.1356", lines[0].total.String())
assert.Equal(t, "35.14", lines[0].Total.String())
})
Expand All @@ -312,4 +312,55 @@ func TestLineCalculate(t *testing.T) {
assert.Equal(t, "35.13", lines[0].Total.String())
assert.Equal(t, "35.1261", lines[0].total.String())
})

t.Run("lines with discount base", func(t *testing.T) {
lines := []*Line{
{
Quantity: num.MakeAmount(3, 0),
Item: &org.Item{
Name: "Test Item",
Price: num.NewAmount(1259, 2),
},
Discounts: []*LineDiscount{
{
Base: num.NewAmount(51256, 3),
Percent: num.NewPercentage(7, 2),
},
},
},
}
err := calculateLines(lines, currency.EUR, nil, tax.RoundingRuleSumThenRound)
assert.NoError(t, err)
sum := calculateLineSum(lines, currency.EUR)
assert.Equal(t, "34.1800", sum.String())
assert.Equal(t, "51.26", lines[0].Discounts[0].Base.String())
assert.Equal(t, "3.59", lines[0].Discounts[0].Amount.String())
assert.Equal(t, "34.18", lines[0].Total.String())
assert.Equal(t, "34.1800", lines[0].total.String())
})

t.Run("lines with charge base", func(t *testing.T) {
lines := []*Line{
{
Quantity: num.MakeAmount(3, 0),
Item: &org.Item{
Name: "Test Item",
Price: num.NewAmount(1259, 2),
},
Charges: []*LineCharge{
{
Base: num.NewAmount(512, 2),
Percent: num.NewPercentage(7, 2),
},
},
},
}
err := calculateLines(lines, currency.EUR, nil, tax.RoundingRuleSumThenRound)
assert.NoError(t, err)
sum := calculateLineSum(lines, currency.EUR)
assert.Equal(t, "40.4139", sum.String())
assert.Equal(t, "2.64", lines[0].Charges[0].Amount.String())
assert.Equal(t, "40.41", lines[0].Total.String())
assert.Equal(t, "40.4139", lines[0].total.String())
})
}
12 changes: 10 additions & 2 deletions bill/line_charge.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ type LineCharge struct {
Code cbc.Code `json:"code,omitempty" jsonschema:"title=Code"`
// Text description as to why the charge was applied
Reason string `json:"reason,omitempty" jsonschema:"title=Reason"`
// Percentage if fixed amount not applied
// Base for percent calculations instead of the line's sum
Base *num.Amount `json:"base,omitempty" jsonschema:"title=Base"`
// Percentage of base or parent line's sum
Percent *num.Percentage `json:"percent,omitempty" jsonschema:"title=Percent"`
// Fixed or resulting charge amount to apply (calculated if percent present).
Amount num.Amount `json:"amount" jsonschema:"title=Amount" jsonschema_extras:"calculated=true"`
Expand All @@ -40,7 +42,13 @@ func (lc *LineCharge) Validate() error {
return validation.ValidateStruct(lc,
validation.Field(&lc.Key),
validation.Field(&lc.Code),
validation.Field(&lc.Percent),
validation.Field(&lc.Base),
validation.Field(&lc.Percent,
validation.When(
lc.Base != nil,
validation.Required,
),
),
validation.Field(&lc.Amount, validation.Required, num.NotZero),
validation.Field(&lc.Ext),
)
Expand Down
12 changes: 10 additions & 2 deletions bill/line_discount.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ type LineDiscount struct {
Code cbc.Code `json:"code,omitempty" jsonschema:"title=Code"`
// Text description as to why the discount was applied
Reason string `json:"reason,omitempty" jsonschema:"title=Reason"`
// Percentage to apply to the line total to calcaulte the discount amount
// Base for percent calculations instead of the line's sum.
Base *num.Amount `json:"base,omitempty" jsonschema:"title=Base"`
// Percentage to apply to the base or line sum to calculate the discount amount
Percent *num.Percentage `json:"percent,omitempty" jsonschema:"title=Percent"`
// Fixed discount amount to apply (calculated if percent present)
Amount num.Amount `json:"amount" jsonschema:"title=Amount" jsonschema_extras:"calculated=true"`
Expand All @@ -38,7 +40,13 @@ func (ld *LineDiscount) Validate() error {
return validation.ValidateStruct(ld,
validation.Field(&ld.Key),
validation.Field(&ld.Code),
validation.Field(&ld.Percent),
validation.Field(&ld.Base),
validation.Field(&ld.Percent,
validation.When(
ld.Base != nil,
validation.Required,
),
),
validation.Field(&ld.Amount, validation.Required, num.NotZero),
validation.Field(&ld.Ext),
)
Expand Down
4 changes: 2 additions & 2 deletions cmd/gobl/testdata/Test_build_explicit_stdout
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "4905f174-0384-11ed-9fa6-b24117999d50",
"dig": {
"alg": "sha256",
"val": "7a28e3ac52dc87a13f13ad9af168f9d37379c6795b082a700d9c462a176c8e93"
"val": "87d4a01626fd62e7d19ecefc072fbd966ae1837e9d81e84eb68d0d452f58bb68"
}
},
"doc": {
Expand Down Expand Up @@ -67,7 +67,7 @@
{
"reason": "Special discount",
"percent": "10%",
"amount": "180.0000"
"amount": "180.00"
}
],
"taxes": [
Expand Down
4 changes: 2 additions & 2 deletions cmd/gobl/testdata/Test_build_input_file
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "4905f174-0384-11ed-9fa6-b24117999d50",
"dig": {
"alg": "sha256",
"val": "7a28e3ac52dc87a13f13ad9af168f9d37379c6795b082a700d9c462a176c8e93"
"val": "87d4a01626fd62e7d19ecefc072fbd966ae1837e9d81e84eb68d0d452f58bb68"
}
},
"doc": {
Expand Down Expand Up @@ -67,7 +67,7 @@
{
"reason": "Special discount",
"percent": "10%",
"amount": "180.0000"
"amount": "180.00"
}
],
"taxes": [
Expand Down
4 changes: 2 additions & 2 deletions cmd/gobl/testdata/Test_build_merge_values
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "4905f174-0384-11ed-9fa6-b24117999d50",
"dig": {
"alg": "sha256",
"val": "aa4ce3a998bd7a6523730ae5e08e1c791f504121263499e51598499e5a0f6ff6"
"val": "1f2afbcbdda51a5fc5b81300a406d27cf31f48f6a6ecd269b2cb8810f42cf1a2"
}
},
"doc": {
Expand Down Expand Up @@ -67,7 +67,7 @@
{
"reason": "Special discount",
"percent": "10%",
"amount": "180.0000"
"amount": "180.00"
}
],
"taxes": [
Expand Down
4 changes: 2 additions & 2 deletions cmd/gobl/testdata/Test_build_output_file_outfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "4905f174-0384-11ed-9fa6-b24117999d50",
"dig": {
"alg": "sha256",
"val": "7a28e3ac52dc87a13f13ad9af168f9d37379c6795b082a700d9c462a176c8e93"
"val": "87d4a01626fd62e7d19ecefc072fbd966ae1837e9d81e84eb68d0d452f58bb68"
}
},
"doc": {
Expand Down Expand Up @@ -67,7 +67,7 @@
{
"reason": "Special discount",
"percent": "10%",
"amount": "180.0000"
"amount": "180.00"
}
],
"taxes": [
Expand Down
4 changes: 2 additions & 2 deletions cmd/gobl/testdata/Test_build_overwrite_output_file_outfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "4905f174-0384-11ed-9fa6-b24117999d50",
"dig": {
"alg": "sha256",
"val": "7a28e3ac52dc87a13f13ad9af168f9d37379c6795b082a700d9c462a176c8e93"
"val": "87d4a01626fd62e7d19ecefc072fbd966ae1837e9d81e84eb68d0d452f58bb68"
}
},
"doc": {
Expand Down Expand Up @@ -67,7 +67,7 @@
{
"reason": "Special discount",
"percent": "10%",
"amount": "180.0000"
"amount": "180.00"
}
],
"taxes": [
Expand Down
4 changes: 2 additions & 2 deletions cmd/gobl/testdata/Test_sign_explicit_stdout
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "4905f174-0384-11ed-9fa6-b24117999d50",
"dig": {
"alg": "sha256",
"val": "7a28e3ac52dc87a13f13ad9af168f9d37379c6795b082a700d9c462a176c8e93"
"val": "87d4a01626fd62e7d19ecefc072fbd966ae1837e9d81e84eb68d0d452f58bb68"
}
},
"doc": {
Expand Down Expand Up @@ -67,7 +67,7 @@
{
"reason": "Special discount",
"percent": "10%",
"amount": "180.0000"
"amount": "180.00"
}
],
"taxes": [
Expand Down
4 changes: 2 additions & 2 deletions cmd/gobl/testdata/Test_sign_input_file
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "4905f174-0384-11ed-9fa6-b24117999d50",
"dig": {
"alg": "sha256",
"val": "7a28e3ac52dc87a13f13ad9af168f9d37379c6795b082a700d9c462a176c8e93"
"val": "87d4a01626fd62e7d19ecefc072fbd966ae1837e9d81e84eb68d0d452f58bb68"
}
},
"doc": {
Expand Down Expand Up @@ -67,7 +67,7 @@
{
"reason": "Special discount",
"percent": "10%",
"amount": "180.0000"
"amount": "180.00"
}
],
"taxes": [
Expand Down
4 changes: 2 additions & 2 deletions cmd/gobl/testdata/Test_sign_merge_values
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "4905f174-0384-11ed-9fa6-b24117999d50",
"dig": {
"alg": "sha256",
"val": "63a34456e8508328c8706544554b79a1ec37fb5bbcc86797f58f232796565784"
"val": "2dff8279b81655599c7a82b8991c68e93cf327f152720cb74ae57b46a4927b40"
}
},
"doc": {
Expand Down Expand Up @@ -67,7 +67,7 @@
{
"reason": "Special discount",
"percent": "10%",
"amount": "180.0000"
"amount": "180.00"
}
],
"taxes": [
Expand Down
4 changes: 2 additions & 2 deletions cmd/gobl/testdata/Test_sign_output_file_outfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "4905f174-0384-11ed-9fa6-b24117999d50",
"dig": {
"alg": "sha256",
"val": "7a28e3ac52dc87a13f13ad9af168f9d37379c6795b082a700d9c462a176c8e93"
"val": "87d4a01626fd62e7d19ecefc072fbd966ae1837e9d81e84eb68d0d452f58bb68"
}
},
"doc": {
Expand Down Expand Up @@ -67,7 +67,7 @@
{
"reason": "Special discount",
"percent": "10%",
"amount": "180.0000"
"amount": "180.00"
}
],
"taxes": [
Expand Down
Loading

0 comments on commit 5e7b060

Please sign in to comment.