Compare commits

...

10 Commits

Author SHA1 Message Date
Pim van Pelt
132abcfd2d Add IPng logo and address 2026-03-11 19:11:16 +01:00
Michael Stapelberg
cafa0182eb go.mod: update language version
Some checks failed
Push / CI (push) Has been cancelled
2025-10-25 10:47:12 +02:00
Michael Stapelberg
4b488515eb switch to structured address throughout (for QR-bill 2.3) 2025-10-25 10:46:35 +02:00
Michael Stapelberg
c9cd171d6f add qrbill.service systemd service file 2024-05-20 10:56:00 +02:00
Michael Stapelberg
0913336aed bump language version, go mod tidy 2024-01-31 18:00:19 +01:00
Michael Stapelberg
57db542958 update to latest gozxing 2024-01-31 17:59:31 +01:00
Michael Stapelberg
376f1c2508 test: add cases for rounding 2023-12-15 17:55:04 +01:00
Michael Stapelberg
23c2fd6596 Ccy.Amt validation: %.2f to conform to spec
fixes https://github.com/stapelberg/qrbill/issues/8
2023-12-09 17:56:15 +01:00
Michael Stapelberg
0e933663d9 update spec link 2023-12-09 17:33:37 +01:00
Michael Stapelberg
77a4ad47f8 Validate CcyAmt: add .00 to integer numbers
fixes https://github.com/stapelberg/qrbill/issues/8
2023-11-25 10:16:29 +01:00
9 changed files with 337 additions and 40 deletions

9
GENERATED_ipng.go Normal file

File diff suppressed because one or more lines are too long

View File

@@ -46,11 +46,11 @@ func qrchFromRequest(r *http.Request) *qrbill.QRCH {
IBAN: ifEmpty(r.Form, "criban", "CH0209000000870913543"),
Cdtr: qrbill.Address{
AdrTp: qrbill.AddressType(ifEmpty(r.Form, "craddrtype", string(qrbill.AddressTypeStructured))),
Name: ifEmpty(r.Form, "crname", "Legalize it"),
StrtNmOrAdrLine1: ifEmpty(r.Form, "craddr1", "Quellenstrasse"),
BldgNbOrAdrLine2: ifEmpty(r.Form, "craddr2", "25"),
PstCd: ifEmpty(r.Form, "crpost", "8005"),
TwnNm: ifEmpty(r.Form, "crcity", "Zürich"),
Name: ifEmpty(r.Form, "crname", "IPng Networks GmbH"),
StrtNmOrAdrLine1: ifEmpty(r.Form, "craddr1", "Im Bungert"),
BldgNbOrAdrLine2: ifEmpty(r.Form, "craddr2", "14"),
PstCd: ifEmpty(r.Form, "crpost", "8306"),
TwnNm: ifEmpty(r.Form, "crcity", "Brüttisellen"),
Ctry: ifEmpty(r.Form, "crcountry", "CH"),
},
},
@@ -59,19 +59,19 @@ func qrchFromRequest(r *http.Request) *qrbill.QRCH {
Ccy: "CHF",
},
UltmtDbtr: qrbill.Address{
AdrTp: qrbill.AddressType(ifEmpty(r.Form, "udaddrtype", string(qrbill.AddressTypeCombined))),
Name: ifEmpty(r.Form, "udname", "Michael Stapelberg"),
StrtNmOrAdrLine1: ifEmpty(r.Form, "udaddr1", "Stauffacherstr 42"),
BldgNbOrAdrLine2: ifEmpty(r.Form, "udaddr2", "8004 Zürich"),
PstCd: ifEmpty(r.Form, "udpost", ""),
TwnNm: ifEmpty(r.Form, "udcity", ""),
AdrTp: qrbill.AddressType(ifEmpty(r.Form, "udaddrtype", string(qrbill.AddressTypeStructured))),
Name: ifEmpty(r.Form, "udname", "IPng Networks GmbH"),
StrtNmOrAdrLine1: ifEmpty(r.Form, "udaddr1", "Im Bungert"),
BldgNbOrAdrLine2: ifEmpty(r.Form, "udaddr2", "14"),
PstCd: ifEmpty(r.Form, "udpost", "8306"),
TwnNm: ifEmpty(r.Form, "udcity", "Brüttisellen"),
Ctry: ifEmpty(r.Form, "udcountry", "CH"),
},
RmtInf: qrbill.QRCHRmtInf{
Tp: "NON", // Reference type
Ref: "", // Reference
AddInf: qrbill.QRCHRmtInfAddInf{
Ustrd: ifEmpty(r.Form, "message", "Spende 420"),
Ustrd: ifEmpty(r.Form, "message", "IPng Networks GmbH invoice"),
},
},
}

17
go.mod
View File

@@ -1,13 +1,16 @@
module github.com/stapelberg/qrbill
go 1.14
go 1.25
require (
github.com/ajstarks/svgo v0.0.0-20200725142600-7a3c8b57fecb
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b
github.com/davecgh/go-spew v1.1.1
github.com/makiuchi-d/gozxing v0.0.0-20200903113411-25f730ed83da
github.com/mattn/go-isatty v0.0.12
golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c // indirect
golang.org/x/text v0.3.4 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
github.com/makiuchi-d/gozxing v0.1.1
github.com/mattn/go-isatty v0.0.20
)
require (
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
)

52
go.sum
View File

@@ -1,18 +1,42 @@
github.com/ajstarks/svgo v0.0.0-20200725142600-7a3c8b57fecb h1:EVl3FJLQCzSbgBezKo/1A4ADnJ4mtJZ0RvnNzDJ44nY=
github.com/ajstarks/svgo v0.0.0-20200725142600-7a3c8b57fecb/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/makiuchi-d/gozxing v0.0.0-20200903113411-25f730ed83da h1:OgNu1PPD9EvZckyKDAc8DA4KymNXuc6vaCLsdOGyjOE=
github.com/makiuchi-d/gozxing v0.0.0-20200903113411-25f730ed83da/go.mod h1:WoI7z45M7ZNA5BJxiJHaB+x7+k8S/3phW5Y13IR4yWY=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c h1:UIcGWL6/wpCfyGuJnRFJRurA+yj8RrW7Q6x2YMCXt6c=
golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I=
github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=

57
logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -18,8 +18,8 @@
// was the Swiss Payment Standards 2019 Swiss Implementation Guidelines QR-bill
// Version 2.1, to be found at:
//
// https://www.paymentstandards.ch/dam/downloads/ig-qr-bill-en.pdf (English)
// https://www.paymentstandards.ch/dam/downloads/ig-qr-bill-de.pdf (German)
// https://www.six-group.com/dam/download/banking-services/standardization/qr-bill/ig-qr-bill-v2.1-en.pdf (English)
// https://www.six-group.com/dam/download/banking-services/standardization/qr-bill/ig-qr-bill-v2.1-de.pdf (German)
//
// # Note
//
@@ -28,8 +28,11 @@ package qrbill
import (
"bytes"
"fmt"
"image"
"log"
"regexp"
"strconv"
"strings"
"github.com/makiuchi-d/gozxing/qrcode/decoder"
@@ -124,6 +127,37 @@ type QRCHCcyAmt struct {
Ccy string // Currency
}
func (a QRCHCcyAmt) Validate() QRCHCcyAmt {
c := a
if c.Amt != "" {
parsed, err := strconv.ParseFloat(c.Amt, 64)
if err != nil {
log.Printf("ParseFloat(%q): %v", c.Amt, err)
}
// The Swiss Payment Standards 2019 Swiss Implementation Guidelines
// QR-bill Version 2.3 explains:
//
// The amount element is to be entered without leading
// zeroes, including decimal separators and two decimal
// places.
// Decimal, maximum 12-digits permitted, including decimal
// separators. Only decimal points (".") are permitted as
// decimal separators. The amount must be between CHF/
// EUR 0.01 and 999,999,999.99
//
// (Notably, the validator is less strict and also permits values
// without decimal separators or with only one decimal place.)
//
// Some banking apps are picky regarding integer numbers (e.g. 50) and
// require a separator plus two digits (e.g. 50.00).
c.Amt = fmt.Sprintf("%.2f", parsed)
}
return c
}
type QRCHRmtInfAddInf struct {
Ustrd string // Unstructured message
Trailer string // Trailer
@@ -194,6 +228,8 @@ func (q *QRCH) Validate() *QRCH {
clone.UltmtDbtr = clone.UltmtDbtr.Validate()
clone.CcyAmt = clone.CcyAmt.Validate()
clone.RmtInf.Tp = nonAlphanumericRe.ReplaceAllString(clone.RmtInf.Tp, "")
if v := clone.RmtInf.Tp; len(v) > 4 {
clone.RmtInf.Tp = v[:4]
@@ -282,8 +318,8 @@ func (b *Bill) EncodeToSVG() ([]byte, error) {
return nil, err
}
// overlay the swiss cross
cross := swisscross["swisscross.svg"]
// overlay the IPng Logo
cross := ipng["logo.svg"]
// Remove XML document header, we embed the <svg> element:
cross = bytes.ReplaceAll(cross, []byte(`<?xml version="1.0" encoding="utf-8"?>`), nil)
// Overwrite position and size of the embedded <svg> element:

124
qrbill_test.go Normal file
View File

@@ -0,0 +1,124 @@
package qrbill_test
import (
"testing"
"github.com/stapelberg/qrbill"
)
func TestAmountValidation(t *testing.T) {
for _, tt := range []struct {
amount string
wantAmount string
}{
{
// ensure empty amount values are not modified
amount: "",
wantAmount: "",
},
{
amount: "50",
wantAmount: "50.00",
},
{
amount: "50.3",
wantAmount: "50.30",
},
{
amount: "50.32",
wantAmount: "50.32",
},
{
amount: "50.32",
wantAmount: "50.32",
},
{
amount: "50.000",
wantAmount: "50.00",
},
{
amount: "50.339",
wantAmount: "50.34",
},
{
amount: "50.331",
wantAmount: "50.33",
},
{
amount: "50.-",
wantAmount: "0.00", // result of invalid input
},
{
amount: ".30",
wantAmount: "0.30",
},
{
amount: ".3",
wantAmount: "0.30",
},
{
// minimum amount mentioned in the Implementation Guidelines
amount: "0.01",
wantAmount: "0.01",
},
{
// maximum amount mentioned in the Implementation Guidelines
amount: "999999999.99",
wantAmount: "999999999.99",
},
} {
t.Run(tt.amount, func(t *testing.T) {
qrch := &qrbill.QRCH{
CdtrInf: qrbill.QRCHCdtrInf{
IBAN: "CH0209000000870913543",
Cdtr: qrbill.Address{
AdrTp: qrbill.AddressTypeStructured,
Name: "Legalize it",
StrtNmOrAdrLine1: "Quellenstrasse",
BldgNbOrAdrLine2: "25",
PstCd: "8005",
TwnNm: "Zürich",
Ctry: "CH",
},
},
CcyAmt: qrbill.QRCHCcyAmt{
Amt: tt.amount,
Ccy: "CHF",
},
UltmtDbtr: qrbill.Address{
AdrTp: qrbill.AddressTypeStructured,
Name: "Michael Stapelberg",
StrtNmOrAdrLine1: "Stauffacherstr",
BldgNbOrAdrLine2: "42",
PstCd: "8004",
TwnNm: "Zürich",
Ctry: "CH",
},
RmtInf: qrbill.QRCHRmtInf{
Tp: "NON", // Reference type
Ref: "", // Reference
AddInf: qrbill.QRCHRmtInfAddInf{
Ustrd: "test",
},
},
}
validated := qrch.Validate()
if got, want := validated.CcyAmt.Amt, tt.wantAmount; got != want {
t.Errorf("CcyAmt.Amt = %q, want %q", got, want)
}
})
}
}

View File

@@ -20,7 +20,6 @@ import (
"image/draw"
"github.com/makiuchi-d/gozxing"
"github.com/makiuchi-d/gozxing/common"
"github.com/makiuchi-d/gozxing/qrcode"
"github.com/makiuchi-d/gozxing/qrcode/decoder"
)
@@ -61,7 +60,7 @@ func qrEncodeHints() map[gozxing.EncodeHintType]interface{} {
// Section 4.2.1: Character set:
// UTF-8 should be used for encoding
gozxing.EncodeHintType_CHARACTER_SET: common.CharacterSetECI_UTF8,
gozxing.EncodeHintType_CHARACTER_SET: "UTF-8",
}
}
@@ -79,7 +78,7 @@ func generateQrCodeImage(payload string) (image.Image, error) {
}
func overlayWithSwissCross(qrCodeImage image.Image) (image.Image, error) {
b := swisscross["third_party/swiss-cross/CH-Kreuz_7mm/CH-Kreuz_7mm.png"]
b := ipng["logo-inverted.png"]
swissCrossImage, _, err := image.Decode(bytes.NewReader(b))
if err != nil {
return nil, err

45
systemd/qrbill.service Normal file
View File

@@ -0,0 +1,45 @@
[Unit]
Description=qrbill
[Service]
ExecStart=/usr/local/bin/qrbill-api
# See also http://0pointer.net/blog/dynamic-users-with-systemd.html
DynamicUser=yes
# Remove all capabilities(7), this is a stateless web server:
CapabilityBoundingSet=
# Ensure the service can never gain new privileges:
NoNewPrivileges=yes
# Prohibit access to any kind of namespacing:
RestrictNamespaces=yes
# Make home directories inaccessible:
ProtectHome=true
# Make device nodes except for /dev/null, /dev/zero, /dev/full,
# /dev/random and /dev/urandom inaccessible:
PrivateDevices=yes
# Make users other than root and the user for this daemon inaccessible:
PrivateUsers=yes
# Make cgroup file system hierarchy inaccessible:
ProtectControlGroups=yes
# Deny kernel module loading:
ProtectKernelModules=yes
# Make kernel variables (e.g. /proc/sys) read-only:
ProtectKernelTunables=yes
# Filter dangerous system calls. The following is listed as safe basic choice
# in systemd.exec(5):
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target