From 4e37cc00a149e473c10fd6c6d44d207d5254e9fa Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Thu, 13 Aug 2026 12:02:50 +0000 Subject: [PATCH 1/3] feat: display UNTDID unit codes --- components/bill/line_support.go | 3 +- components/bill/line_support_test.go | 22 ++++++++++++++ components/bill/lines.templ | 8 ++--- components/bill/lines_templ.go | 12 ++++---- components/t/units.go | 28 +++++++++++++++++ components/t/units_test.go | 45 ++++++++++++++++++++++++++++ 6 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 components/bill/line_support_test.go diff --git a/components/bill/line_support.go b/components/bill/line_support.go index 9b6a3b7e..aabcbb89 100644 --- a/components/bill/line_support.go +++ b/components/bill/line_support.go @@ -1,6 +1,7 @@ package bill import ( + "github.com/invopop/gobl.html/components/t" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/org" @@ -31,7 +32,7 @@ func prepareLineSupport(reg *tax.RegimeDef, lines []*bill.Line, dss []*bill.Disc if len(l.Charges) > 0 { ls.charges = true } - if l.Item.Unit != "" { + if t.ItemHasUnit(l.Item) { ls.units = true } if l.Item.Price != nil { diff --git a/components/bill/line_support_test.go b/components/bill/line_support_test.go new file mode 100644 index 00000000..1e9c367d --- /dev/null +++ b/components/bill/line_support_test.go @@ -0,0 +1,22 @@ +package bill + +import ( + "testing" + + gbill "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestPrepareLineSupportUNTDIDUnit(t *testing.T) { + lines := []*gbill.Line{ + { + Item: &org.Item{ + Ext: tax.MakeExtensions().Set("untdid-unit", "E48"), + }, + }, + } + + assert.True(t, prepareLineSupport(new(tax.RegimeDef), lines, nil, nil).units) +} diff --git a/components/bill/lines.templ b/components/bill/lines.templ index c679fa2c..36ebada3 100644 --- a/components/bill/lines.templ +++ b/components/bill/lines.templ @@ -124,8 +124,8 @@ templ line(l *bill.Line, ls *lineSupport, st *subtotal) { if ls.units { - if l.Item.Unit != "" { - { t.UnitName(ctx, l.Item.Unit) } + if t.ItemHasUnit(l.Item) { + { t.ItemUnitName(ctx, l.Item) } } else { } @@ -223,8 +223,8 @@ templ breakdownLine(sl *bill.SubLine, ls *lineSupport, st *subtotal) { if ls.units { - if sl.Item.Unit != "" { - { t.UnitName(ctx, sl.Item.Unit) } + if t.ItemHasUnit(sl.Item) { + { t.ItemUnitName(ctx, sl.Item) } } else { } diff --git a/components/bill/lines_templ.go b/components/bill/lines_templ.go index c45c76f0..f7ac3c64 100644 --- a/components/bill/lines_templ.go +++ b/components/bill/lines_templ.go @@ -440,11 +440,11 @@ func line(l *bill.Line, ls *lineSupport, st *subtotal) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if l.Item.Unit != "" { + if t.ItemHasUnit(l.Item) { var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(t.UnitName(ctx, l.Item.Unit)) + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(t.ItemUnitName(ctx, l.Item)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/bill/lines.templ`, Line: 128, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/bill/lines.templ`, Line: 128, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -743,11 +743,11 @@ func breakdownLine(sl *bill.SubLine, ls *lineSupport, st *subtotal) templ.Compon if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if sl.Item.Unit != "" { + if t.ItemHasUnit(sl.Item) { var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(t.UnitName(ctx, sl.Item.Unit)) + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(t.ItemUnitName(ctx, sl.Item)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/bill/lines.templ`, Line: 227, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/bill/lines.templ`, Line: 227, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { diff --git a/components/t/units.go b/components/t/units.go index 270b960b..6c0c5ed8 100644 --- a/components/t/units.go +++ b/components/t/units.go @@ -2,11 +2,15 @@ package t import ( "context" + "fmt" "github.com/invopop/ctxi18n/i18n" + "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/org" ) +const untdidUnitExtension cbc.Key = "untdid-unit" + var unitDefs = func() map[org.Unit]*org.DefUnit { m := make(map[org.Unit]*org.DefUnit, len(org.UnitDefinitions)) for i := range org.UnitDefinitions { @@ -30,3 +34,27 @@ func UnitName(ctx context.Context, u org.Unit) string { } return i18n.T(ctx, "units."+string(u), i18n.Default(def.Name)) } + +// ItemUnitName provides the display name for an item's unit. When the item +// contains a UNTDID unit extension, the code is appended to the GOBL unit +// name, or used on its own if the item does not have a GOBL unit. +func ItemUnitName(ctx context.Context, item *org.Item) string { + if item == nil { + return "" + } + name := UnitName(ctx, item.Unit) + code := item.Ext.Get(untdidUnitExtension).String() + if name == "" { + return code + } + if code == "" { + return name + } + return fmt.Sprintf("%s (%s)", name, code) +} + +// ItemHasUnit reports whether an item has either a GOBL unit or a UNTDID unit +// extension that can be displayed. +func ItemHasUnit(item *org.Item) bool { + return item != nil && (item.Unit != "" || item.Ext.Has(untdidUnitExtension)) +} diff --git a/components/t/units_test.go b/components/t/units_test.go index 0a263d45..f275d2a1 100644 --- a/components/t/units_test.go +++ b/components/t/units_test.go @@ -8,6 +8,7 @@ import ( ct "github.com/invopop/gobl.html/components/t" srclocales "github.com/invopop/gobl.html/locales" "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -70,3 +71,47 @@ func TestUnitName(t *testing.T) { } }) } + +func TestItemUnitName(t *testing.T) { + locales := new(i18n.Locales) + require.NoError(t, locales.LoadWithDefault(srclocales.Content, "en")) + ctx := locales.Get("en").WithContext(context.Background()) + + tests := []struct { + name string + item *org.Item + want string + }{ + {name: "nil item", want: ""}, + {name: "empty item", item: &org.Item{}, want: ""}, + {name: "GOBL unit", item: &org.Item{Unit: org.UnitKilogram}, want: "kg"}, + { + name: "GOBL and UNTDID units", + item: &org.Item{ + Unit: org.UnitKilogram, + Ext: tax.MakeExtensions().Set("untdid-unit", "KGM"), + }, + want: "kg (KGM)", + }, + { + name: "translated GOBL and UNTDID units", + item: &org.Item{ + Unit: org.UnitHour, + Ext: tax.MakeExtensions().Set("untdid-unit", "HUR"), + }, + want: "hours (HUR)", + }, + { + name: "UNTDID unit only", + item: &org.Item{Ext: tax.MakeExtensions().Set("untdid-unit", "E48")}, + want: "E48", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, ct.ItemUnitName(ctx, test.item)) + assert.Equal(t, test.want != "", ct.ItemHasUnit(test.item)) + }) + } +} From e40f10d8a21dd955e68d0d157c3c2e5f827e0815 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Thu, 13 Aug 2026 12:22:36 +0000 Subject: [PATCH 2/3] docs: demonstrate UNTDID unit rendering --- examples/fr-invoice-units.fr.json | 11 ++++++++--- examples/out/fr-invoice-units.fr.html | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/examples/fr-invoice-units.fr.json b/examples/fr-invoice-units.fr.json index 93584425..078f999e 100644 --- a/examples/fr-invoice-units.fr.json +++ b/examples/fr-invoice-units.fr.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "e588d036427eab5deef69c8855be9be469cb7a95f63be5f90a6ccb87d01926a5" + "val": "9c9d0b43c26dc5f4a0d36089b7bcd78657b1eab4172119da39b6f167578cd39d" } }, "doc": { @@ -155,7 +155,10 @@ "item": { "name": "Café en grains", "price": "9.80", - "unit": "kg" + "unit": "kg", + "ext": { + "untdid-unit": "KGM" + } }, "sum": "245.00", "taxes": [ @@ -174,7 +177,9 @@ "item": { "name": "Eau minérale", "price": "1.20", - "unit": "bottle" + "ext": { + "untdid-unit": "XBO" + } }, "sum": "14.40", "taxes": [ diff --git a/examples/out/fr-invoice-units.fr.html b/examples/out/fr-invoice-units.fr.html index 3e18e47b..40aee76d 100644 --- a/examples/out/fr-invoice-units.fr.html +++ b/examples/out/fr-invoice-units.fr.html @@ -299,7 +299,7 @@

25 - kg + kg (KGM) €9,80 @@ -326,7 +326,7 @@

12 - bouteilles + XBO €1,20 From 77dbf8e958faaf0cca548400fe76b152fc21e5ac Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Thu, 13 Aug 2026 12:24:51 +0000 Subject: [PATCH 3/3] docs: preserve translated bottle unit example --- examples/fr-invoice-units.fr.json | 45 +++++++++++++++++++-------- examples/out/fr-invoice-units.fr.html | 39 +++++++++++++++++++---- 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/examples/fr-invoice-units.fr.json b/examples/fr-invoice-units.fr.json index 078f999e..549e6ce0 100644 --- a/examples/fr-invoice-units.fr.json +++ b/examples/fr-invoice-units.fr.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "9c9d0b43c26dc5f4a0d36089b7bcd78657b1eab4172119da39b6f167578cd39d" + "val": "5169c45e6bb7dc8ba4e49f960ac5a2b0718beb4b0a6b258e90916aee1dabb63b" } }, "doc": { @@ -177,9 +177,7 @@ "item": { "name": "Eau minérale", "price": "1.20", - "ext": { - "untdid-unit": "XBO" - } + "unit": "bottle" }, "sum": "14.40", "taxes": [ @@ -191,11 +189,32 @@ } ], "total": "14.40" + }, + { + "i": 8, + "quantity": "1", + "item": { + "name": "Prestation avec unité UNTDID", + "price": "10.00", + "ext": { + "untdid-unit": "E48" + } + }, + "sum": "10.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "general", + "percent": "20%" + } + ], + "total": "10.00" } ], "totals": { - "sum": "4350.40", - "total": "4350.40", + "sum": "4360.40", + "total": "4360.40", "taxes": { "categories": [ { @@ -203,19 +222,19 @@ "rates": [ { "key": "standard", - "base": "4350.40", + "base": "4360.40", "percent": "20%", - "amount": "870.08" + "amount": "872.08" } ], - "amount": "870.08" + "amount": "872.08" } ], - "sum": "870.08" + "sum": "872.08" }, - "tax": "870.08", - "total_with_tax": "5220.48", - "payable": "5220.48" + "tax": "872.08", + "total_with_tax": "5232.48", + "payable": "5232.48" } } } \ No newline at end of file diff --git a/examples/out/fr-invoice-units.fr.html b/examples/out/fr-invoice-units.fr.html index 40aee76d..7a08a589 100644 --- a/examples/out/fr-invoice-units.fr.html +++ b/examples/out/fr-invoice-units.fr.html @@ -326,7 +326,7 @@

12 - XBO + bouteilles €1,20 @@ -338,6 +338,33 @@

€14,40 + + + 8 + + +
+ + Prestation avec unité UNTDID + +
+ + + 1 + + + E48 + + + €10,00 + + + 20% + + + €10,00 + + @@ -354,7 +381,7 @@

Somme - €4.350,40 + €4.360,40 @@ -362,7 +389,7 @@

Taxe - €870,08 + €872,08 @@ -370,7 +397,7 @@

Total à payer - €5.220,48 + €5.232,48 @@ -403,13 +430,13 @@

TVA - €4.350,40 + €4.360,40 20% - €870,08 + €872,08