304 lines
8.3 KiB
Go
304 lines
8.3 KiB
Go
// Copyright 2020 Google LLC
|
||
//
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
// you may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// https://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
|
||
// Package qrbill implements the Swiss QR-bill standard.
|
||
//
|
||
// More specifically, the most recent standard version at the time of writing
|
||
// 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)
|
||
//
|
||
// Note
|
||
//
|
||
// QRR and SCOR references are not yet implemented.
|
||
package qrbill
|
||
|
||
import (
|
||
"bytes"
|
||
"image"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"github.com/aaronarduino/goqrsvg"
|
||
svg "github.com/ajstarks/svgo"
|
||
"github.com/boombuler/barcode/qr"
|
||
|
||
// We currently read the swiss cross PNG version.
|
||
_ "image/png"
|
||
)
|
||
|
||
// As per section 4.1: In general:
|
||
// Oriented upon the Swiss Implementation Guidelines for Credit Transfers for
|
||
// the ISO 20022 Customer Credit Transfer Initiation message (pain.001).
|
||
|
||
// see also:
|
||
// https://github.com/codebude/QRCoder/wiki/Advanced-usage---Payload-generators#317-swissqrcode-iso-20022
|
||
|
||
const (
|
||
// QRType is an unambiguous indicator for the Swiss QR Code. Fixed value.
|
||
QRType = "SPC" // Swiss Payments Code
|
||
|
||
// Version contains the version of the specifications (Implementation
|
||
// Guidelines) in use on the date on which the Swiss QR Code was
|
||
// created. The first two positions indicate the main version, the following
|
||
// two positions the sub-version. Fixed value.
|
||
Version = "0200" // Version 2.0
|
||
|
||
// CodingType is the character set code. Fixed value.
|
||
CodingType = "1" // UTF-8 restricted to the Latin character set
|
||
|
||
// Trailer is an unambiguous indicator for the end of payment data. Fixed
|
||
// value.
|
||
Trailer = "EPD" // End Payment Data
|
||
)
|
||
|
||
// AddressType corresponds to AdrTp in ISO20022.
|
||
type AddressType string
|
||
|
||
const (
|
||
AddressTypeStructured AddressType = "S"
|
||
AddressTypeCombined AddressType = "K"
|
||
)
|
||
|
||
type Address struct {
|
||
AdrTp AddressType
|
||
Name string // Name, max 70. chars, first name + last name, or company name
|
||
StrtNmOrAdrLine1 string // Street or address line 1
|
||
BldgNbOrAdrLine2 string // Building number or address line 2
|
||
PstCd string // Postal code, max 16 chars, must be provided without a country code prefix
|
||
TwnNm string // Town, max. 35 chars
|
||
Ctry string // Country, two-digit country code according to ISO 3166-1
|
||
}
|
||
|
||
func (a Address) Validate() Address {
|
||
c := a
|
||
|
||
if v := c.Name; len(v) > 70 {
|
||
c.Name = v[:70]
|
||
}
|
||
|
||
if v := c.StrtNmOrAdrLine1; len(v) > 70 {
|
||
c.StrtNmOrAdrLine1 = v[:70]
|
||
}
|
||
|
||
if v := c.BldgNbOrAdrLine2; len(v) > 16 {
|
||
c.BldgNbOrAdrLine2 = v[:16]
|
||
}
|
||
|
||
if v := c.PstCd; len(v) > 16 {
|
||
c.PstCd = v[:16]
|
||
}
|
||
|
||
if v := c.TwnNm; len(v) > 35 {
|
||
c.TwnNm = v[:35]
|
||
}
|
||
|
||
return c
|
||
}
|
||
|
||
type QRCHHeader struct {
|
||
QRType string
|
||
Version string
|
||
Coding string
|
||
}
|
||
|
||
type QRCHCdtrInf struct {
|
||
IBAN string
|
||
Cdtr Address // Creditor
|
||
}
|
||
|
||
type QRCHCcyAmt struct {
|
||
Amt string // Amount
|
||
Ccy string // Currency
|
||
}
|
||
|
||
type QRCHRmtInfAddInf struct {
|
||
Ustrd string // Unstructured message
|
||
Trailer string // Trailer
|
||
}
|
||
|
||
type QRCHRmtInf struct {
|
||
Tp string // Reference type
|
||
Ref string // Reference
|
||
AddInf QRCHRmtInfAddInf // Additional information
|
||
}
|
||
|
||
type QRCH struct {
|
||
Header QRCHHeader // Header
|
||
CdtrInf QRCHCdtrInf // Creditor information (Account / Payable to)
|
||
UltmtCdtr Address // (must not be filled in, for Future Use)
|
||
CcyAmt QRCHCcyAmt // Paymount amount information
|
||
UltmtDbtr Address // Ultimate Debtor
|
||
RmtInf QRCHRmtInf // Payment reference
|
||
}
|
||
|
||
var (
|
||
nonNumericRe = regexp.MustCompile(`[^0-9]`)
|
||
nonAlphanumericRe = regexp.MustCompile(`[^A-Za-z0-9]`)
|
||
nonDecimalRe = regexp.MustCompile(`[^0-9.]`)
|
||
|
||
// The SIX Swiss Implementation Guidelines Reference Standard
|
||
// Documentation declares the following regular expression pattern in
|
||
// https://validation.iso-payments.ch/gp/projectdata/qrrechnung/deliverables/installed/publishingproject/qr__ch.scm/html/en/0247.htm
|
||
ustrdRe = regexp.MustCompile(`([a-zA-Z0-9\.,;:'\+\-/\(\)?\*\[\]\{\}\\` + "`" + `´~ ]|[!"#%&<>÷=@_$£]|[àáâäçèéêëìíîïñòóôöùúûüýßÀÁÂÄÇÈÉÊËÌÍÎÏÒÓÔÖÙÚÛÜÑ])`)
|
||
)
|
||
|
||
func (q *QRCH) Validate() *QRCH {
|
||
clone := &QRCH{}
|
||
*clone = *q
|
||
|
||
// Fill in all fixed values:
|
||
clone.Header.QRType = QRType
|
||
clone.Header.Version = Version
|
||
clone.Header.Coding = CodingType
|
||
clone.RmtInf.AddInf.Trailer = Trailer
|
||
|
||
// TODO(spec): strictly speaking, we need to restrict ourselves only to
|
||
// permitted characters (see below). But, even the example from SIX does not
|
||
// do that (Monatspr_ä_mie):
|
||
// https://www.moneytoday.ch/lexikon/qr-rechnung/
|
||
|
||
// 4.3.2 Permitted characters
|
||
// general: only the latin character set is permitted. UTF-8 should be used for encoding
|
||
// numeric: 0-9
|
||
// alphanumeric: A-Z a-z 0-9
|
||
// decimal: 0-9 plus decimal separator .
|
||
|
||
// Character Set as per PAIN.001.001.03:
|
||
// https://businessbanking.bankofireland.com/app/uploads/2018/11/OMI015017-Credit-Transfer-PAIN.001.001.03-DIGITALFINAL-VERSION.pdf
|
||
|
||
// a b c d e f g h i j k l m n o p q r s t u v w x y z
|
||
// A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
|
||
// / - ? : ( ) . , ' +
|
||
// <space>
|
||
|
||
// Enforce all field constraints:
|
||
|
||
clone.CdtrInf.IBAN = nonAlphanumericRe.ReplaceAllString(clone.CdtrInf.IBAN, "")
|
||
|
||
clone.CdtrInf.Cdtr = clone.CdtrInf.Cdtr.Validate()
|
||
|
||
clone.UltmtCdtr = clone.UltmtCdtr.Validate()
|
||
|
||
clone.RmtInf.Tp = nonAlphanumericRe.ReplaceAllString(clone.RmtInf.Tp, "")
|
||
if v := clone.RmtInf.Tp; len(v) > 4 {
|
||
clone.RmtInf.Tp = v[:4]
|
||
}
|
||
|
||
clone.RmtInf.Ref = nonAlphanumericRe.ReplaceAllString(clone.RmtInf.Ref, "")
|
||
if v := clone.RmtInf.Ref; len(v) > 27 {
|
||
clone.RmtInf.Ref = v[:27]
|
||
}
|
||
|
||
ustrd := clone.RmtInf.AddInf.Ustrd
|
||
matches := ustrdRe.FindAllString(ustrd, -1)
|
||
ustrd = strings.Join(matches, "")
|
||
|
||
if len(ustrd) > 140 {
|
||
ustrd = ustrd[:140]
|
||
}
|
||
clone.RmtInf.AddInf.Ustrd = ustrd
|
||
|
||
return clone
|
||
}
|
||
|
||
func (q *QRCH) Encode() (*Bill, error) {
|
||
f := q.Validate()
|
||
return &Bill{
|
||
qrcontents: strings.Join([]string{
|
||
f.Header.QRType,
|
||
f.Header.Version,
|
||
f.Header.Coding,
|
||
|
||
f.CdtrInf.IBAN,
|
||
|
||
string(f.CdtrInf.Cdtr.AdrTp),
|
||
f.CdtrInf.Cdtr.Name,
|
||
f.CdtrInf.Cdtr.StrtNmOrAdrLine1,
|
||
f.CdtrInf.Cdtr.BldgNbOrAdrLine2,
|
||
f.CdtrInf.Cdtr.PstCd,
|
||
f.CdtrInf.Cdtr.TwnNm,
|
||
f.CdtrInf.Cdtr.Ctry,
|
||
|
||
string(f.UltmtCdtr.AdrTp),
|
||
f.UltmtCdtr.Name,
|
||
f.UltmtCdtr.StrtNmOrAdrLine1,
|
||
f.UltmtCdtr.BldgNbOrAdrLine2,
|
||
f.UltmtCdtr.PstCd,
|
||
f.UltmtCdtr.TwnNm,
|
||
f.UltmtCdtr.Ctry,
|
||
|
||
f.CcyAmt.Amt,
|
||
f.CcyAmt.Ccy,
|
||
|
||
string(f.UltmtDbtr.AdrTp),
|
||
f.UltmtDbtr.Name,
|
||
f.UltmtDbtr.StrtNmOrAdrLine1,
|
||
f.UltmtDbtr.BldgNbOrAdrLine2,
|
||
f.UltmtDbtr.PstCd,
|
||
f.UltmtDbtr.TwnNm,
|
||
f.UltmtDbtr.Ctry,
|
||
|
||
f.RmtInf.Tp,
|
||
f.RmtInf.Ref,
|
||
f.RmtInf.AddInf.Ustrd,
|
||
f.RmtInf.AddInf.Trailer,
|
||
}, "\n"),
|
||
}, nil
|
||
}
|
||
|
||
type Bill struct {
|
||
qrcontents string
|
||
}
|
||
|
||
func (b *Bill) EncodeToSVG() ([]byte, error) {
|
||
// as per https://www.paymentstandards.ch/dam/downloads/ig-qr-bill-en.pdf, section 5.1:
|
||
// Error correction level M (redundancy of around 15%)
|
||
|
||
// Section 4.2.1: Character set:
|
||
// UTF-8 should be used for encoding
|
||
|
||
code, err := qr.Encode(b.qrcontents, qr.M, qr.Unicode)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var buf bytes.Buffer
|
||
s := svg.New(&buf)
|
||
qrsvg := goqrsvg.NewQrSVG(code, 5)
|
||
qrsvg.StartQrSVG(s)
|
||
if err := qrsvg.WriteQrSVG(s); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Overlay the swiss cross (not entirely scaled correctly, but should be
|
||
// good enough):
|
||
const px = 5
|
||
x := (30 - 4) * px
|
||
y := (30 - 4) * px
|
||
|
||
s.Rect(x+0*px, y+0*px, 8*px, 8*px, "fill:#FFFFFF")
|
||
s.Rect(x+0*px+2, y+0*px+2, 8*px-4, 8*px-4, "fill:#000000")
|
||
s.Rect(x+3*px, y+1*px, 2*px, 6*px, "fill:#FFFFFF")
|
||
s.Rect(x+1*px, y+3*px, 6*px, 2*px, "fill:#FFFFFF")
|
||
|
||
s.End()
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
func (b *Bill) EncodeToImage() (image.Image, error) {
|
||
return generateSwissQrCode(b.qrcontents)
|
||
}
|