Skip to content

Commit 8a779d8

Browse files
committed
Add ASCII QR output and dynamic Pix improvements
- Add GenQRCodeASCII and qrcode.NewASCII (uses github.com/mdp/qrterminal) plus CLI flags: --ascii-scale, --ascii-quiet, --ascii-black/--ascii-white; also add --qr-size. - Improve payload generation: sanitize/normalize characters, MAI/url handling per BACEN Pix manual v2.9.0, and enforce TXID rules (static up to 25 alnum or "***"; dynamic requires txid and payload carries "***"). - Harden validations: stricter amount/txid patterns, increased additional info limit, and return errors from New/GenPayload when constraints fail. - Update QR code PNG sizing, replace CRC handling, adjust options getters/setters, and add tests, README updates, go.mod/go.sum changes and docs/.gitkeep.
1 parent 68ebf13 commit 8a779d8

File tree

15 files changed

+654
-120
lines changed

15 files changed

+654
-120
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
.gocache
22

33
bin/*
4-
!bin/.gitkeep
4+
!bin/.gitkeep
5+
6+
docs/*
7+
!docs/.gitkeep

README.md

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
<p align="center"><img alt="pix-utils" src="https://raw.githubusercontent.com/thiagozs/go-pixgen/main/assets/logo-pix.png" width="128px" /></p>
44

5+
***release: Ultima versão do manual v2.9.0***
6+
7+
---
8+
59
Gere e valide pagamentos do Sistema de Pagamentos Instantâneo (Pix) do Banco Central de forma simples. Além da biblioteca, este repositório inclui um CLI e um serviço REST para gerar payloads Pix estáticos ou dinâmicos, bem como seus códigos QR.
610

711
## Funcionalidades
@@ -24,7 +28,7 @@ Requer Go 1.17 ou superior.
2428

2529
## Guia rápido
2630

27-
### CLI gerar payload no terminal
31+
### CLI - gerar payload no terminal
2832

2933
```bash
3034
make cli
@@ -36,10 +40,13 @@ bin/pixgen generate \
3640
--merchant-city ARARANGUA \
3741
--amount 10.00 \
3842
--description "Pedido 123" \
39-
--txid PEDIDO-123
43+
--txid PEDIDO-123 \
44+
--ascii-scale 1
4045
```
4146

42-
A saída inclui o código copia-e-cola, campos relevantes e o QR Code em base64.
47+
A saída inclui o código copia-e-cola, campos relevantes, o QR Code em base64 e a versão em ASCII. Use `--ascii-scale` (>=1), `--ascii-quiet=true` para recolocar a borda de silêncio e `--ascii-black/--ascii-white` para personalizar o render em terminal.
48+
49+
Para QR Codes dinâmicos, lembre-se de informar `--url https://...` e um `--txid` alfanumérico (até 25 caracteres); o payload emitido trará a URL (tag `25`) e `***` no campo TxID conforme o manual.
4350

4451
### Serviço REST
4552

@@ -106,7 +113,11 @@ if err != nil {
106113
return
107114
}
108115

109-
cpy := p.GenPayload()
116+
cpy, err := p.GenPayload()
117+
if err != nil {
118+
fmt.Println(err.Error())
119+
return
120+
}
110121
fmt.Printf("Copy and Paste: %s\n", cpy)
111122

112123
qrPNG, err := p.GenQRCode()
@@ -117,6 +128,13 @@ if err != nil {
117128

118129
fmt.Printf("QRCode bytes: %d\n", len(qrPNG))
119130

131+
asciiQR, err := p.GenQRCodeASCII()
132+
if err != nil {
133+
fmt.Println(err.Error())
134+
return
135+
}
136+
fmt.Println(asciiQR)
137+
120138
parsed, err := pix.ParsePayload(cpy)
121139
if err != nil {
122140
fmt.Println(err.Error())
@@ -156,23 +174,25 @@ fmt.Printf("Remote Pix expires at: %v\n", payload.ExpiresAt)
156174

157175
## Destaques da API
158176

159-
- `pix.New(opts...) (*pix.Pix, error)` – cria um gerador Pix configurável.
160-
- `(*Pix).GenPayload()` – retorna o payload EMV e o mantém em cache para `GenQRCode()`.
161-
- `pix.ParsePayload(string) (*ParsedPayload, error)` – faz o parsing do payload e valida o CRC.
162-
- `(*Pix).FetchDynamicPayload(ctx, client)` – baixa, valida e parseia payloads dinâmicos remotos.
163-
- `(*Pix).Validates()` – valida os parâmetros (chaves, tamanho de campos, etc.).
177+
- `pix.New(opts...) (*pix.Pix, error)` - cria um gerador Pix configurável.
178+
- `(*Pix).GenPayload() (string, error)` - retorna o payload EMV e o mantém em cache para `GenQRCode()`.
179+
- `pix.ParsePayload(string) (*ParsedPayload, error)` - faz o parsing do payload e valida o CRC.
180+
- `(*Pix).FetchDynamicPayload(ctx, client)` - baixa, valida e parseia payloads dinâmicos remotos.
181+
- `(*Pix).Validates()` - valida os parâmetros (chaves, tamanho de campos, etc.).
182+
- `(*Pix).GenQRCodeASCII() (string, error)` - renderiza o QR Code em ASCII para uso direto no terminal.
183+
- `pix.OptQRCodeScale`, `pix.OptASCIIQuietZone`, `pix.OptASCIICharset` - controlam escala, borda e caracteres usados no QR ASCII.
164184

165185
### Formatos aceitos de chave Pix
166186

167-
- EVP (UUID) normalizado para minúsculo.
168-
- Telefone (`+55DDDNÚMERO`) aceita apenas dígitos com ou sem `+55`.
169-
- CPF/CNPJ somente dígitos, com validação dos dígitos verificadores.
170-
- E-mail validado via `net/mail`.
187+
- EVP (UUID) - normalizado para minúsculo.
188+
- Telefone (`+55DDDNÚMERO`) - aceita apenas dígitos com ou sem `+55`.
189+
- CPF/CNPJ - somente dígitos, com validação dos dígitos verificadores.
190+
- E-mail - validado via `net/mail`.
171191

172192
### Valor e TxID
173193

174194
- Valor suporta até 13 dígitos antes da vírgula e 2 casas decimais (`9999999999999.99` limite).
175-
- TxID permite letras, números, `.` e `-` até 35 caracteres e é armazenado em maiúsculas.
195+
- TxID (estático ou dinâmico) deve ser alfanumérico (A-Z, 0-9) e ter no máximo 25 caracteres. No caso estático, deixe em branco para que o payload utilize `***`; no dinâmico, o QR Code continua transportando `***` enquanto a URL carrega os dados da cobrança.
176196

177197
### Busca de payload dinâmico
178198

cmd/pixgen/main.go

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"log"
99
"net/http"
1010
"os"
11+
"strconv"
1112
"strings"
1213
"time"
1314

@@ -48,7 +49,7 @@ func newGenerateCmd() *cobra.Command {
4849
return err
4950
}
5051

51-
payload, qr, parsed, err := buildPix(params)
52+
payload, qr, asciiQR, parsed, err := buildPix(params)
5253
if err != nil {
5354
return err
5455
}
@@ -60,6 +61,10 @@ func newGenerateCmd() *cobra.Command {
6061
fmt.Printf("TxID: %s\n", parsed.AdditionalDataField.TxID)
6162
}
6263
fmt.Printf("QR Code (base64): %s\n", base64.StdEncoding.EncodeToString(qr))
64+
if asciiQR != "" {
65+
fmt.Println("QR Code (ASCII):")
66+
fmt.Println(asciiQR)
67+
}
6368

6469
return nil
6570
},
@@ -75,6 +80,11 @@ func newGenerateCmd() *cobra.Command {
7580
flags.String("description", "", "Transaction description (optional)")
7681
flags.String("additional-info", "", "Additional info (static only)")
7782
flags.String("txid", "", "Transaction identifier (optional)")
83+
flags.Int("qr-size", 0, "PNG QR code size in pixels (default 256 when omitted)")
84+
flags.Int("ascii-scale", 1, "Scale factor (>=1) for ASCII QR output")
85+
flags.Bool("ascii-quiet", false, "Include quiet zone border in ASCII QR output")
86+
flags.String("ascii-black", "", "Character(s) used for dark modules in ASCII QR output")
87+
flags.String("ascii-white", "", "Character(s) used for light modules in ASCII QR output")
7888

7989
return cmd
8090
}
@@ -151,7 +161,7 @@ func pixHandler(w http.ResponseWriter, r *http.Request) {
151161
return
152162
}
153163

154-
payload, qr, parsed, err := buildPix(params)
164+
payload, qr, _, parsed, err := buildPix(params)
155165
if err != nil {
156166
http.Error(w, err.Error(), http.StatusBadRequest)
157167
return
@@ -182,11 +192,21 @@ type pixParams struct {
182192
Description string
183193
AdditionalInfo string
184194
TxID string
195+
QRCodeSize int
196+
ASCII asciiParams
197+
}
198+
199+
type asciiParams struct {
200+
Scale int
201+
Quiet bool
202+
QuietSet bool
203+
BlackChar string
204+
WhiteChar string
185205
}
186206

187207
func collectPixParams(cmd *cobra.Command) (pixParams, error) {
188208
flags := cmd.Flags()
189-
return parseParams(
209+
params, err := parseParams(
190210
flags.Lookup("kind").Value.String(),
191211
flags.Lookup("key").Value.String(),
192212
flags.Lookup("url").Value.String(),
@@ -197,6 +217,50 @@ func collectPixParams(cmd *cobra.Command) (pixParams, error) {
197217
flags.Lookup("additional-info").Value.String(),
198218
flags.Lookup("txid").Value.String(),
199219
)
220+
if err != nil {
221+
return pixParams{}, err
222+
}
223+
224+
if fl := flags.Lookup("qr-size"); fl != nil && fl.Value.String() != fl.DefValue {
225+
size, err := strconv.Atoi(fl.Value.String())
226+
if err != nil {
227+
return pixParams{}, fmt.Errorf("invalid qr-size: %w", err)
228+
}
229+
if size <= 0 {
230+
return pixParams{}, fmt.Errorf("qr-size must be greater than zero")
231+
}
232+
params.QRCodeSize = size
233+
}
234+
235+
if fl := flags.Lookup("ascii-scale"); fl != nil && fl.Value.String() != fl.DefValue {
236+
scale, err := strconv.Atoi(fl.Value.String())
237+
if err != nil {
238+
return pixParams{}, fmt.Errorf("invalid ascii-scale: %w", err)
239+
}
240+
if scale < 1 {
241+
return pixParams{}, fmt.Errorf("ascii-scale must be at least 1")
242+
}
243+
params.ASCII.Scale = scale
244+
}
245+
246+
if fl := flags.Lookup("ascii-quiet"); fl != nil && fl.Value.String() != fl.DefValue {
247+
quiet, err := strconv.ParseBool(fl.Value.String())
248+
if err != nil {
249+
return pixParams{}, fmt.Errorf("invalid ascii-quiet: %w", err)
250+
}
251+
params.ASCII.Quiet = quiet
252+
params.ASCII.QuietSet = true
253+
}
254+
255+
if fl := flags.Lookup("ascii-black"); fl != nil && fl.Value.String() != fl.DefValue {
256+
params.ASCII.BlackChar = fl.Value.String()
257+
}
258+
259+
if fl := flags.Lookup("ascii-white"); fl != nil && fl.Value.String() != fl.DefValue {
260+
params.ASCII.WhiteChar = fl.Value.String()
261+
}
262+
263+
return params, nil
200264
}
201265

202266
func requestToParams(req pixRequest) (pixParams, error) {
@@ -259,7 +323,7 @@ func parseKind(kind string) (pix.PixKind, error) {
259323
}
260324
}
261325

262-
func buildPix(params pixParams) (string, []byte, *pix.ParsedPayload, error) {
326+
func buildPix(params pixParams) (string, []byte, string, *pix.ParsedPayload, error) {
263327
opts := []pix.Options{
264328
pix.OptKind(params.Kind),
265329
pix.OptMerchantName(params.MerchantName),
@@ -284,22 +348,42 @@ func buildPix(params pixParams) (string, []byte, *pix.ParsedPayload, error) {
284348
if params.URL != "" {
285349
opts = append(opts, pix.OptUrl(params.URL))
286350
}
351+
if params.QRCodeSize > 0 {
352+
opts = append(opts, pix.OptQRCodeSize(params.QRCodeSize))
353+
}
354+
if params.ASCII.Scale > 0 {
355+
opts = append(opts, pix.OptQRCodeScale(params.ASCII.Scale))
356+
}
357+
if params.ASCII.BlackChar != "" || params.ASCII.WhiteChar != "" {
358+
opts = append(opts, pix.OptASCIICharset(params.ASCII.BlackChar, params.ASCII.WhiteChar))
359+
}
360+
if params.ASCII.QuietSet {
361+
opts = append(opts, pix.OptASCIIQuietZone(params.ASCII.Quiet))
362+
}
287363

288364
p, err := pix.New(opts...)
289365
if err != nil {
290-
return "", nil, nil, err
366+
return "", nil, "", nil, err
291367
}
292368

293-
payload := p.GenPayload()
369+
payload, err := p.GenPayload()
370+
if err != nil {
371+
return "", nil, "", nil, err
372+
}
294373
qr, err := p.GenQRCode()
295374
if err != nil {
296-
return "", nil, nil, err
375+
return "", nil, "", nil, err
376+
}
377+
378+
asciiQR, err := p.GenQRCodeASCII()
379+
if err != nil {
380+
return "", nil, "", nil, err
297381
}
298382

299383
parsed, err := pix.ParsePayload(payload)
300384
if err != nil {
301-
return "", nil, nil, err
385+
return "", nil, "", nil, err
302386
}
303387

304-
return payload, qr, parsed, nil
388+
return payload, qr, asciiQR, parsed, nil
305389
}

docs/.gitkeep

Whitespace-only changes.

examples/main.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ func runStaticExample() error {
4242
return fmt.Errorf("create pix: %w", err)
4343
}
4444

45-
payload := p.GenPayload()
45+
payload, err := p.GenPayload()
46+
if err != nil {
47+
return fmt.Errorf("generate payload: %w", err)
48+
}
4649
fmt.Printf("Copy & Paste payload: %s\n", payload)
4750

4851
qrBytes, err := p.GenQRCode()
@@ -73,6 +76,7 @@ func runDynamicExample() error {
7376
pix.OptUrl(server.URL),
7477
pix.OptMerchantName("Fulano de Tal"),
7578
pix.OptMerchantCity("CURITIBA"),
79+
pix.OptTxId("DYNAMICPIXTXIDEXAMPLE01"),
7680
}
7781

7882
p, err := pix.New(opts...)
@@ -108,7 +112,7 @@ func newMockDynamicServer() *httptest.Server {
108112
pix.OptMerchantName("Fulano de Tal"),
109113
pix.OptMerchantCity("CURITIBA"),
110114
pix.OptAmount("50.00"),
111-
pix.OptTxId("ABC123"),
115+
pix.OptTxId("SERVERPAYLOADTXIDEX1"),
112116
}
113117

114118
payloadPix, err := pix.New(opts...)
@@ -117,7 +121,11 @@ func newMockDynamicServer() *httptest.Server {
117121
return
118122
}
119123

120-
payload := payloadPix.GenPayload()
124+
payload, err := payloadPix.GenPayload()
125+
if err != nil {
126+
http.Error(w, fmt.Sprintf("generate payload: %v", err), http.StatusInternalServerError)
127+
return
128+
}
121129
resp := map[string]string{
122130
"pixCopyPaste": payload,
123131
"expiresAt": time.Now().Add(5 * time.Minute).Format(time.RFC3339),

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,9 @@ require (
88
github.com/spf13/cobra v1.7.0
99
)
1010

11+
require (
12+
github.com/mdp/qrterminal v1.0.1 // indirect
13+
rsc.io/qr v0.2.0 // indirect
14+
)
15+
1116
replace github.com/spf13/cobra => ./internal/cobra

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
github.com/mdp/qrterminal v1.0.1 h1:07+fzVDlPuBlXS8tB0ktTAyf+Lp1j2+2zK3fBOL5b7c=
2+
github.com/mdp/qrterminal v1.0.1/go.mod h1:Z33WhxQe9B6CdW37HaVqcRKzP+kByF3q/qLxOGe12xQ=
13
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
24
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
35
github.com/snksoft/crc v1.1.0 h1:HkLdI4taFlgGGG1KvsWMpz78PkOC9TkPVpTV/cuWn48=
46
github.com/snksoft/crc v1.1.0/go.mod h1:5/gUOsgAm7OmIhb6WJzw7w5g2zfJi4FrHYgGPdshE+A=
7+
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
8+
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

0 commit comments

Comments
 (0)