Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add bill line charge rate and quantity #487

Merged
merged 7 commits into from
Mar 10, 2025
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
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

### Added

- `bill`: Line discount and charge `base` property, to use instead of the line sum in order to comply with EN16931.
- `bill`: line discount and charge `base` property, to use instead of the line sum in order to comply with EN16931.
- `bill`: line Charge support for Quantity and Rate special cases for charges like tariffs that result in a fixed amount base on a rate, like, 1 cent for every 100g of sugar.

### Changed

- `bill`: line totals will be rounded to currency precision for presentation only
- `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.
- `tax`: renamed rounding rules `sum-then-round` to `precise`, and `round-then-sum` to `currency`, to more accurately reflect their objectives.
- `bill`: `currency` rounding rule implies that line totals will be calculated with the currency's precisions, bringing closer alliance with EN16931 requirements.

## [v0.211.0] - 2025-02-28

Expand Down
1 change: 1 addition & 0 deletions bill/bill.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ func init() {
Order{},
Payment{},
CorrectionOptions{},
Line{},
)
}

Expand Down
3 changes: 3 additions & 0 deletions bill/calculator.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ func calculate(doc billable) error {
pd.Terms.CalculateDues(zero, t.Payable)
}

roundLines(doc.getLines(), cur)
roundDiscounts(doc.getDiscounts(), cur)
roundCharges(doc.getCharges(), cur)
t.round(zero)
doc.setTotals(t)

Expand Down
4 changes: 2 additions & 2 deletions bill/calculator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ func TestCalculate(t *testing.T) {
},
})
inv.Tax.PricesInclude = ""
inv.Tax.Rounding = tax.RoundingRuleRoundThenSum
inv.Tax.Rounding = tax.RoundingRuleCurrency
require.NoError(t, inv.Calculate())
assert.Equal(t, "3.48", inv.Totals.Tax.String())

inv.Tax.Rounding = tax.RoundingRuleSumThenRound
inv.Tax.Rounding = tax.RoundingRulePrecise
require.NoError(t, inv.Calculate())
assert.Equal(t, "3.49", inv.Totals.Tax.String())
})
Expand Down
28 changes: 17 additions & 11 deletions bill/charges.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,6 @@ type Charge struct {
Ext tax.Extensions `json:"ext,omitempty" jsonschema:"title=Extensions"`
// Additional semi-structured information.
Meta cbc.Meta `json:"meta,omitempty" jsonschema:"title=Meta"`

// internal amount for calculations
amount num.Amount
}

// Normalize performs normalization on the line and embedded objects using the
Expand Down Expand Up @@ -175,14 +172,12 @@ func calculateCharges(lines []*Charge, cur currency.Code, sum num.Amount, rr cbc
if l.Percent != nil && !l.Percent.IsZero() {
base := sum
if l.Base != nil {
base = l.Base.RescaleUp(zero.Exp())
base = l.Base.RescaleUp(zero.Exp() + linePrecisionExtra)
base = tax.ApplyRoundingRule(rr, cur, base)
}
l.amount = l.Percent.Of(base)
l.amount = tax.ApplyRoundingRule(rr, cur, l.amount)
} else {
l.amount = l.Amount.Rescale(zero.Exp())
l.Amount = l.Percent.Of(base)
}
l.Amount = l.amount.Rescale(zero.Exp())
l.Amount = tax.ApplyRoundingRule(rr, cur, l.Amount)
}
}

Expand All @@ -192,12 +187,23 @@ func calculateChargeSum(charges []*Charge, cur currency.Code) *num.Amount {
}
total := cur.Def().Zero()
for _, l := range charges {
total = total.MatchPrecision(l.amount)
total = total.Add(l.amount)
total = total.MatchPrecision(l.Amount)
total = total.Add(l.Amount)
}
return &total
}

func (m *Charge) round(cur currency.Code) {
cd := cur.Def()
m.Amount = cd.Rescale(m.Amount)
}

func roundCharges(lines []*Charge, cur currency.Code) {
for _, l := range lines {
l.round(cur)
}
}

func extendJSONSchemaWithChargeKey(schema *jsonschema.Schema) {
prop, ok := schema.Properties.Get("key")
if !ok {
Expand Down
48 changes: 29 additions & 19 deletions bill/charges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,24 @@ func TestChargeTotals(t *testing.T) {
},
}
base := num.MakeAmount(30000, 2)
calculateCharges(ls, currency.EUR, base, tax.RoundingRuleSumThenRound)
calculateCharges(ls, currency.EUR, base, tax.RoundingRulePrecise)
sum := calculateChargeSum(ls, currency.EUR)
require.NotNil(t, sum)
assert.Equal(t, 1, ls[0].Index)
assert.Nil(t, ls[0].Base)
assert.Equal(t, 2, ls[1].Index)
assert.Equal(t, "200.00", sum.String())
assert.Equal(t, "200.0000", sum.String())
assert.Equal(t, "100.00", ls[0].Amount.String())
assert.Nil(t, ls[1].Base)
assert.Equal(t, "20%", ls[1].Percent.String())
assert.Equal(t, "60.00", ls[1].Amount.String())
assert.Equal(t, "200.00", ls[2].Base.String())
assert.Equal(t, "40.0000", ls[2].Amount.String())
roundCharges(ls, currency.EUR)
assert.Equal(t, "40.00", ls[2].Amount.String())

ls = []*Charge{}
calculateCharges(ls, currency.EUR, base, tax.RoundingRuleSumThenRound)
calculateCharges(ls, currency.EUR, base, tax.RoundingRulePrecise)
sum = calculateChargeSum(ls, currency.EUR)
assert.Nil(t, sum)
})
Expand All @@ -80,13 +82,14 @@ func TestChargeTotals(t *testing.T) {
},
}
base := num.MakeAmount(30844212, 6)
calculateCharges(ls, currency.EUR, base, tax.RoundingRuleSumThenRound)
calculateCharges(ls, currency.EUR, base, tax.RoundingRulePrecise)
sum := calculateChargeSum(ls, currency.EUR)
require.NotNil(t, sum)
assert.Equal(t, "50.00", ls[0].Amount.String())
assert.Equal(t, "6.17", ls[1].Amount.String())
assert.Equal(t, "6.168842", ls[1].amount.String())
assert.Equal(t, "6.168842", ls[1].Amount.String())
assert.Equal(t, "56.168842", sum.String())
roundCharges(ls, currency.EUR)
assert.Equal(t, "6.17", ls[1].Amount.String())
})

t.Run("with precision, round-then-sum", func(t *testing.T) {
Expand All @@ -101,14 +104,14 @@ func TestChargeTotals(t *testing.T) {
},
}
base := num.MakeAmount(30844212, 6)
calculateCharges(ls, currency.EUR, base, tax.RoundingRuleRoundThenSum)
calculateCharges(ls, currency.EUR, base, tax.RoundingRuleCurrency)
sum := calculateChargeSum(ls, currency.EUR)
require.NotNil(t, sum)
assert.Equal(t, "50.00", ls[0].amount.String())
assert.Equal(t, "50.00", ls[0].Amount.String())
assert.Equal(t, "6.17", ls[1].Amount.String())
assert.Equal(t, "6.17", ls[1].amount.String())
assert.Equal(t, "56.17", sum.String())
roundCharges(ls, currency.EUR)
assert.Equal(t, "6.17", ls[1].Amount.String()) // no change
})

t.Run("with fixed base", func(t *testing.T) {
Expand All @@ -120,11 +123,13 @@ func TestChargeTotals(t *testing.T) {
},
}
base := num.MakeAmount(30844212, 6)
calculateCharges(ls, currency.EUR, base, tax.RoundingRuleSumThenRound)
calculateCharges(ls, currency.EUR, base, tax.RoundingRulePrecise)
sum := calculateChargeSum(ls, currency.EUR)
require.NotNil(t, sum)
assert.Equal(t, "10.0240", ls[0].Amount.String())
assert.Equal(t, "10.0240", sum.String())
roundCharges(ls, currency.EUR)
assert.Equal(t, "10.02", ls[0].Amount.String())
assert.Equal(t, "10.02", sum.String())
})

t.Run("with fixed amount", func(t *testing.T) {
Expand All @@ -135,12 +140,13 @@ func TestChargeTotals(t *testing.T) {
},
}
base := num.MakeAmount(30844212, 6)
calculateCharges(ls, currency.EUR, base, tax.RoundingRuleSumThenRound)
calculateCharges(ls, currency.EUR, base, tax.RoundingRulePrecise)
sum := calculateChargeSum(ls, currency.EUR)
require.NotNil(t, sum)
assert.Equal(t, "50.18", ls[0].amount.String())
assert.Equal(t, "50.1762", ls[0].Amount.String())
assert.Equal(t, "50.1762", sum.String())
roundCharges(ls, currency.EUR)
assert.Equal(t, "50.18", ls[0].Amount.String())
assert.Equal(t, "50.18", sum.String())
})

t.Run("with fixed base high precision", func(t *testing.T) {
Expand All @@ -152,12 +158,14 @@ func TestChargeTotals(t *testing.T) {
},
}
base := num.MakeAmount(30844212, 6)
calculateCharges(ls, currency.EUR, base, tax.RoundingRuleSumThenRound)
calculateCharges(ls, currency.EUR, base, tax.RoundingRulePrecise)
sum := calculateChargeSum(ls, currency.EUR)
require.NotNil(t, sum)
assert.Equal(t, "10.0247", ls[0].amount.String())
assert.Equal(t, "10.02", ls[0].Amount.String())
assert.Equal(t, "50.1234", ls[0].Base.String())
assert.Equal(t, "10.0247", ls[0].Amount.String())
assert.Equal(t, "10.0247", sum.String())
roundCharges(ls, currency.EUR)
assert.Equal(t, "10.02", ls[0].Amount.String())
})

t.Run("with fixed base high precision, round-then-sum", func(t *testing.T) {
Expand All @@ -169,12 +177,14 @@ func TestChargeTotals(t *testing.T) {
},
}
base := num.MakeAmount(30844212, 6)
calculateCharges(ls, currency.EUR, base, tax.RoundingRuleRoundThenSum)
calculateCharges(ls, currency.EUR, base, tax.RoundingRuleCurrency)
sum := calculateChargeSum(ls, currency.EUR)
require.NotNil(t, sum)
assert.Equal(t, "50.1234", ls[0].Base.String())
assert.Equal(t, "10.02", ls[0].amount.String())
assert.Equal(t, "10.02", ls[0].Amount.String())
assert.Equal(t, "10.02", sum.String())
roundCharges(ls, currency.EUR)
assert.Equal(t, "50.1234", ls[0].Base.String(), "should maintain original precision")
assert.Equal(t, "10.02", ls[0].Amount.String())
})
}
28 changes: 17 additions & 11 deletions bill/discounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,6 @@ type Discount struct {
Ext tax.Extensions `json:"ext,omitempty" jsonschema:"title=Extensions"`
// Additional semi-structured information.
Meta cbc.Meta `json:"meta,omitempty" jsonschema:"title=Meta"`

// internal amount for calculations
amount num.Amount
}

// Normalize performs normalization on the line and embedded objects using the
Expand Down Expand Up @@ -190,14 +187,12 @@ func calculateDiscounts(lines []*Discount, cur currency.Code, sum num.Amount, rr
if l.Percent != nil && !l.Percent.IsZero() {
base := sum
if l.Base != nil {
base = l.Base.RescaleUp(zero.Exp())
base = l.Base.RescaleUp(zero.Exp() + linePrecisionExtra)
base = tax.ApplyRoundingRule(rr, cur, base)
}
l.amount = l.Percent.Of(base)
l.amount = tax.ApplyRoundingRule(rr, cur, l.amount)
} else {
l.amount = l.Amount.Rescale(zero.Exp())
l.Amount = l.Percent.Of(base)
}
l.Amount = l.amount.Rescale(zero.Exp())
l.Amount = tax.ApplyRoundingRule(rr, cur, l.Amount)
}
}

Expand All @@ -207,12 +202,23 @@ func calculateDiscountSum(discounts []*Discount, cur currency.Code) *num.Amount
}
total := cur.Def().Zero()
for _, l := range discounts {
total = total.MatchPrecision(l.amount)
total = total.Add(l.amount)
total = total.MatchPrecision(l.Amount)
total = total.Add(l.Amount)
}
return &total
}

func (m *Discount) round(cur currency.Code) {
cd := cur.Def()
m.Amount = cd.Rescale(m.Amount)
}

func roundDiscounts(lines []*Discount, cur currency.Code) {
for _, l := range lines {
l.round(cur)
}
}

func extendJSONSchemaWithDiscountKey(schema *jsonschema.Schema) {
prop, ok := schema.Properties.Get("key")
if !ok {
Expand Down
Loading