Skip to content
Draft
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
### Added

- `bill`: `PaymentDetails.Payer` party — the party responsible for making payment of the invoice if not the customer, the counterpart of the existing `Payee`.
- `org`: `Item.BaseQuantity` — the number of units the item's price refers to (e.g. a price per 100 units, EN 16931 BT-149). Line sums are calculated as `price × quantity ÷ base_quantity`.
- `bill`: `Line.Unit` and `SubLine.Unit` — the unit of measure for the invoiced quantity (EN 16931 BT-130), distinct from the item's price base quantity unit (`Item.Unit`, BT-150). A line-only unit seeds `Item.Unit`, but an item-only unit leaves the line unit empty, so existing documents are unchanged; core EN 16931 permits them to differ (only the PEPPOL layer requires them equal).

## [v0.501.0] - 2026-06-16

Expand Down
29 changes: 29 additions & 0 deletions bill/line.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ type Line struct {
Index int `json:"i" jsonschema:"title=Index" jsonschema_extras:"calculated=true"`
// Number of items
Quantity num.Amount `json:"quantity" jsonschema:"title=Quantity"`
// Unit of measure for the invoiced quantity (EN 16931 BT-130). When left
// empty it is inferred from the item's unit; set it explicitly when the
// invoiced unit differs from the item's price base quantity unit (BT-150).
Unit org.Unit `json:"unit,omitempty" jsonschema:"title=Unit"`
// Single identifier provided by the supplier for an object on which the
// line item is based and is not considered a universal identity. Examples
// include a subscription number, telephone number, meter point, etc.
Expand Down Expand Up @@ -71,6 +75,10 @@ type SubLine struct {
Index int `json:"i" jsonschema:"title=Index" jsonschema_extras:"calculated=true"`
// Number of items
Quantity num.Amount `json:"quantity" jsonschema:"title=Quantity"`
// Unit of measure for the invoiced quantity (EN 16931 BT-130). When left
// empty it is inferred from the item's unit; set it explicitly when the
// invoiced unit differs from the item's price base quantity unit (BT-150).
Unit org.Unit `json:"unit,omitempty" jsonschema:"title=Unit"`
// Single identifier provided by the supplier for an object on which the
// line item is based and is not considered a universal identity. Examples
// include a subscription number, telephone number, meter point, etc.
Expand Down Expand Up @@ -181,6 +189,7 @@ func (sl *SubLine) IsEmpty() bool {
return sl == nil ||
(sl.UUID.IsZero() &&
sl.Quantity.IsZero() &&
sl.Unit == org.UnitEmpty &&
sl.Identifier == nil &&
sl.Period == nil &&
sl.Order.IsEmpty() &&
Expand All @@ -207,6 +216,7 @@ func CleanSubLines(sls []*SubLine) []*SubLine {

func normalizeLine(l *Line) {
normalizeLineItemPrice(l)
l.Unit = normalizeLineUnit(l.Unit, l.Item)
l.Taxes = tax.CleanSet(l.Taxes)
l.Discounts = CleanLineDiscounts(l.Discounts)
l.Charges = CleanLineCharges(l.Charges)
Expand All @@ -215,10 +225,29 @@ func normalizeLine(l *Line) {

func normalizeSubLine(sl *SubLine) {
normalizeSubLineItemPrice(sl)
sl.Unit = normalizeLineUnit(sl.Unit, sl.Item)
sl.Discounts = CleanLineDiscounts(sl.Discounts)
sl.Charges = CleanLineCharges(sl.Charges)
}

// normalizeLineUnit seeds the item's price base quantity unit (BT-150,
// org.Item.Unit) from the line's invoiced quantity unit (BT-130) when the item
// has none, so a line that only sets the new unit still carries a base quantity
// unit. The reverse is intentionally NOT done: an item-only unit leaves the
// line unit empty, keeping existing documents (and their digests) unchanged.
// Converters read BT-130 from Line.Unit, falling back to Item.Unit when empty.
// Core EN 16931 permits BT-130 and BT-150 to differ (only the PEPPOL layer
// requires them equal), so explicitly divergent units are preserved.
func normalizeLineUnit(unit org.Unit, item *org.Item) org.Unit {
if item == nil {
return unit
}
if unit != org.UnitEmpty && item.Unit == org.UnitEmpty {
item.Unit = unit
}
return unit
}

func normalizeLineItemPrice(l *Line) {
if l == nil || l.Item == nil || l.Item.Price == nil {
return
Expand Down
16 changes: 16 additions & 0 deletions bill/line_calculate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bill

import (
"fmt"
"strconv"

"github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/currency"
Expand Down Expand Up @@ -101,6 +102,7 @@ func calculateLine(l *Line, cur currency.Code, rates []*currency.ExchangeRate, r

// Calculate the line sum and total
sum := price.Multiply(l.Quantity)
sum = applyItemBaseQuantity(sum, l.Item)
sum = tax.ApplyRoundingRule(rr, cur, sum)
total := sum
total = calculateLineDiscounts(l.Discounts, sum, total, cur, rr)
Expand Down Expand Up @@ -142,6 +144,7 @@ func calculateSubLine(sl *SubLine, cur currency.Code, rates []*currency.Exchange

// Calculate the line sum and total
sum := price.Multiply(sl.Quantity)
sum = applyItemBaseQuantity(sum, sl.Item)
sum = tax.ApplyRoundingRule(rr, cur, sum)
total := sum
total = calculateLineDiscounts(sl.Discounts, sum, total, cur, rr)
Expand Down Expand Up @@ -200,6 +203,19 @@ func calculateLineCharges(charges []*LineCharge, quantity, sum, total num.Amount
return total
}

// applyItemBaseQuantity divides the sum by the item's base quantity, used
// when the item's price refers to a number of units other than one. The sum
// is upscaled to cover the decimal places the division may introduce so
// that precision is maintained until rounding is applied.
func applyItemBaseQuantity(sum num.Amount, item *org.Item) num.Amount {
bq := item.BaseQuantity
if bq == nil || bq.IsZero() {
return sum
}
extra := uint32(len(strconv.FormatInt(bq.Rescale(0).Value(), 10)))
return sum.Upscale(extra).Divide(*bq)
}

// calculateItemPrice will attempt to perform any currency conversion process on
// the line item's data so that the currency always matches that of the
// document.
Expand Down
94 changes: 94 additions & 0 deletions bill/line_calculate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -629,3 +629,97 @@ func TestLineCalculate(t *testing.T) {
assert.Equal(t, "37.77", lines[0].Total.String())
})
}

func TestLineCalculateBaseQuantity(t *testing.T) {
t.Run("exact division", func(t *testing.T) {
line := &Line{
Quantity: num.MakeAmount(200, 0),
Item: &org.Item{
Name: "Screws",
Price: num.NewAmount(123, 2), // 1.23 per 100 units
BaseQuantity: num.NewAmount(100, 0),
},
}
err := calculateLine(line, currency.EUR, nil, tax.RoundingRuleCurrency)
require.NoError(t, err)
assert.Equal(t, "2.46", line.Sum.String())
assert.Equal(t, "2.46", line.Total.String())
})
t.Run("fractional base quantity", func(t *testing.T) {
line := &Line{
Quantity: num.MakeAmount(5, 0),
Item: &org.Item{
Name: "Cable",
Price: num.NewAmount(500, 2), // 5.00 per 2.5 units
BaseQuantity: num.NewAmount(25, 1),
},
}
err := calculateLine(line, currency.EUR, nil, tax.RoundingRuleCurrency)
require.NoError(t, err)
assert.Equal(t, "10.00", line.Sum.String())
})
t.Run("zero base quantity ignored", func(t *testing.T) {
line := &Line{
Quantity: num.MakeAmount(2, 0),
Item: &org.Item{
Name: "Widget",
Price: num.NewAmount(1000, 2),
BaseQuantity: num.NewAmount(0, 0),
},
}
err := calculateLine(line, currency.EUR, nil, tax.RoundingRuleCurrency)
require.NoError(t, err)
assert.Equal(t, "20.00", line.Sum.String())
})
t.Run("sub-line base quantity", func(t *testing.T) {
sl := &SubLine{
Quantity: num.MakeAmount(200, 0),
Item: &org.Item{
Name: "Screws",
Price: num.NewAmount(123, 2),
BaseQuantity: num.NewAmount(100, 0),
},
}
err := calculateSubLine(sl, currency.EUR, nil, tax.RoundingRuleCurrency)
require.NoError(t, err)
assert.Equal(t, "2.46", sl.Sum.String())
})
t.Run("precise rounding keeps division precision", func(t *testing.T) {
line := &Line{
Quantity: num.MakeAmount(1, 0),
Item: &org.Item{
Name: "Bulk item",
Price: num.NewAmount(10000, 2), // 100.00 per 3 units
BaseQuantity: num.NewAmount(3, 0),
},
}
err := calculateLine(line, currency.EUR, nil, tax.RoundingRulePrecise)
require.NoError(t, err)
assert.Equal(t, "33.33333", line.Sum.String())
})
t.Run("base quantity larger than quantity", func(t *testing.T) {
line := &Line{
Quantity: num.MakeAmount(50, 0),
Item: &org.Item{
Name: "Per-thousand price",
Price: num.NewAmount(2500, 2), // 25.00 per 1000 units
BaseQuantity: num.NewAmount(1000, 0),
},
}
err := calculateLine(line, currency.EUR, nil, tax.RoundingRuleCurrency)
require.NoError(t, err)
assert.Equal(t, "1.25", line.Sum.String())
})
t.Run("nil base quantity behaves as one", func(t *testing.T) {
line := &Line{
Quantity: num.MakeAmount(3, 0),
Item: &org.Item{
Name: "Plain item",
Price: num.NewAmount(1000, 2),
},
}
err := calculateLine(line, currency.EUR, nil, tax.RoundingRuleCurrency)
require.NoError(t, err)
assert.Equal(t, "30.00", line.Sum.String())
})
}
35 changes: 35 additions & 0 deletions bill/line_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,3 +497,38 @@ func TestLineGetTotal(t *testing.T) {
assert.Equal(t, "0", line.GetTotal().String())
})
}

func TestLineUnitNormalization(t *testing.T) {
t.Run("item-only unit leaves the line unit empty (no digest churn)", func(t *testing.T) {
// Existing documents set the unit on the item; the line unit stays
// empty so their serialized output is unchanged. Converters fall back
// to Item.Unit for BT-130.
line := &Line{
Quantity: num.MakeAmount(1, 0),
Item: &org.Item{Name: "Item", Unit: org.UnitKilogram},
}
norm.Normalize(line)
assert.Equal(t, org.UnitEmpty, line.Unit)
assert.Equal(t, org.UnitKilogram, line.Item.Unit)
})
t.Run("a line-only unit seeds the item unit (BT-150 from BT-130)", func(t *testing.T) {
line := &Line{
Quantity: num.MakeAmount(1, 0),
Unit: org.UnitKilogram,
Item: &org.Item{Name: "Item"},
}
norm.Normalize(line)
assert.Equal(t, org.UnitKilogram, line.Unit)
assert.Equal(t, org.UnitKilogram, line.Item.Unit)
})
t.Run("explicit divergent units are preserved (BT-130 != BT-150)", func(t *testing.T) {
line := &Line{
Quantity: num.MakeAmount(1, 0),
Unit: org.UnitMetricTon,
Item: &org.Item{Name: "Item", Unit: org.UnitKilogram},
}
norm.Normalize(line)
assert.Equal(t, org.UnitMetricTon, line.Unit)
assert.Equal(t, org.UnitKilogram, line.Item.Unit)
})
}
15 changes: 15 additions & 0 deletions data/rules/org.json
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,21 @@
]
}
]
},
{
"field": "base_quantity",
"subsets": [
{
"guard": "present",
"assert": [
{
"id": "GOBL-ORG-ITEM-03",
"desc": "item base quantity must be positive",
"tests": "min 0"
}
]
}
]
}
]
},
Expand Down
10 changes: 10 additions & 0 deletions data/schemas/bill/line.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
"title": "Quantity",
"description": "Number of items"
},
"unit": {
"$ref": "https://gobl.org/draft-0/org/unit",
"title": "Unit",
"description": "Unit of measure for the invoiced quantity (EN 16931 BT-130). When left\nempty it is inferred from the item's unit; set it explicitly when the\ninvoiced unit differs from the item's price base quantity unit (BT-150)."
},
"identifier": {
"$ref": "https://gobl.org/draft-0/org/identity",
"title": "Identifier",
Expand Down Expand Up @@ -359,6 +364,11 @@
"title": "Quantity",
"description": "Number of items"
},
"unit": {
"$ref": "https://gobl.org/draft-0/org/unit",
"title": "Unit",
"description": "Unit of measure for the invoiced quantity (EN 16931 BT-130). When left\nempty it is inferred from the item's unit; set it explicitly when the\ninvoiced unit differs from the item's price base quantity unit (BT-150)."
},
"identifier": {
"$ref": "https://gobl.org/draft-0/org/identity",
"title": "Identifier",
Expand Down
7 changes: 6 additions & 1 deletion data/schemas/org/item.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,15 @@
"title": "Alternative Prices",
"description": "AltPrices defines a list of prices with their currencies that may be used\nas an alternative to the item's base price."
},
"base_quantity": {
"$ref": "https://gobl.org/draft-0/num/amount",
"title": "Base Quantity",
"description": "Number of units the price refers to, e.g. a price per 100 units\n(EN 16931 BT-149). Assumed to be 1 when left empty. The base quantity is\nexpressed in the item's unit of measure (BT-150)."
},
"unit": {
"$ref": "https://gobl.org/draft-0/org/unit",
"title": "Unit",
"description": "Unit of measure."
"description": "Unit of measure the item's price refers to (EN 16931 BT-150). The line's\ninvoiced quantity may use a different unit (BT-130)."
},
"origin": {
"$ref": "https://gobl.org/draft-0/l10n/iso-country-code",
Expand Down
43 changes: 43 additions & 0 deletions examples/gb/invoice-base-quantity.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
$schema: "https://gobl.org/draft-0/bill/invoice"
uuid: "019035bd-4524-73ab-bf44-6037841ce5d9"
issue_date: "2024-07-31"
series: "SAMPLE"
code: "003"

supplier:
tax_id:
country: "GB"
code: "000472631"
name: "Test Company Ltd."
addresses:
- num: "12"
street: "Main Street"
locality: "Hull"
code: "HU17 7PQ"
country: "GB"

customer:
tax_id:
country: "GB"
code: "350983637"
name: "Random Company Ltd."
addresses:
- num: "45"
street: "Some Street"
locality: "London"
code: "SW1A 1AA"
country: "GB"

lines:
# 250 kg invoiced (BT-129/BT-130) at a price quoted per 100 kg
# (BT-149 base quantity, BT-150 unit). Sum = 120.00 × 250 ÷ 100 = 300.00.
- quantity: 250
unit: "kg"
item:
name: "Bulk coffee beans"
price: "120.00"
base_quantity: 100
unit: "kg"
taxes:
- cat: VAT
rate: "standard"
Loading
Loading