From 3a5d3265159263aba47e0457d8e298097fd6af15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Lucas?= Date: Thu, 22 Oct 2026 14:17:01 -0300 Subject: [PATCH 1/2] PHPAY-109: feat(support): compose the static Pix QR Code locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By the BACEN standard a static QR Code is assembled from the Pix key, locally: there is nothing to request. Asaas and Woovi offer an endpoint for it as a convenience, Efí does not offer one, and until now the library could only pass that gap on to whoever needed it. StaticPixCode is support code, like Money — not a Resource, not a capability, no case in the Capability enum, because no HTTP happens. It returns the copy-and-paste payload only: rendering the image needs a QR Code encoder, and a new dependency for a picture is not a trade this library makes. The CRC is the part implementations get wrong: it covers the `6304` literal that announces it, not only what comes before. Stopping earlier produces a code that simply does not read, and the error shows up in front of the payer rather than in a log. So the suite carries the test vector from the Manual de Padrões para Iniciação do Pix and demands the payload byte for byte, 1D3D included — the wrong calculation gives 4AAD for the same vector. Two decisions worth naming. Lengths in the TLV are counted in bytes, not characters, because a reader walks bytes and a name with an accent would misalign every field after it. And the key format is not validated: EVP, e-mail, phone and CPF/CNPJ look nothing alike, so a hasty check would refuse a good key — only an empty key and one over 77 characters are refused, 77 being what fits the field. --- src/Support/StaticPixCode.php | 267 +++++++++++++++++++++++ tests/Unit/Support/StaticPixCodeTest.php | 147 +++++++++++++ 2 files changed, 414 insertions(+) create mode 100644 src/Support/StaticPixCode.php create mode 100644 tests/Unit/Support/StaticPixCodeTest.php diff --git a/src/Support/StaticPixCode.php b/src/Support/StaticPixCode.php new file mode 100644 index 0000000..669d013 --- /dev/null +++ b/src/Support/StaticPixCode.php @@ -0,0 +1,267 @@ +withAmount(Money::reais('100,50')) + * ->withTxid('PEDIDO-1234') + * ->payload(); + * + * Only the copy-and-paste string is returned. Rendering the image needs a QR + * Code encoder, and the runtime of this library is ext-curl, ext-json, + * ext-openssl and Guzzle — a new dependency for a picture is not a trade this + * library makes. + * + * @see https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf + */ +final class StaticPixCode +{ + /** + * globally unique identifier of the Pix arrangement, in field 26. + */ + private const GUI = 'br.gov.bcb.pix'; + + /** + * BRL, in field 53. + */ + private const CURRENCY = '986'; + + /** + * field 58. + */ + private const COUNTRY = 'BR'; + + /** + * merchant category code, field 52: zeros when not informed. + */ + private const MCC = '0000'; + + /** + * txid that means "none", in field 62. Not an empty value: the standard + * asks for these three asterisks. + */ + private const TXID_FREE = '***'; + + /** + * a Pix key is at most 77 characters, which is also what fits field 26: + * its length is two digits, so the whole value has to stay under 100. + */ + private const KEY_MAX = 77; + + /** + * field 59. + */ + private const NAME_MAX = 25; + + /** + * field 60. + */ + private const CITY_MAX = 15; + + /** + * field 62, sub 05. + */ + private const TXID_MAX = 25; + + /** + * field 54. + */ + private const AMOUNT_MAX = 13; + + /** + * construct + * + * @param string $key Pix key of the receiver + * @param string $merchantName name in field 59 + * @param string $merchantCity city in field 60 + * @param string|null $amount already formatted as reais with a dot + * @param string $txid + */ + private function __construct( + private readonly string $key, + private readonly string $merchantName, + private readonly string $merchantCity, + private readonly ?string $amount = null, + private readonly string $txid = self::TXID_FREE + ) { + } + + /** + * build a code for a key. + * + * $merchantName is NOT what the payer sees. The app shows the name the + * key resolves to at the DICT, and the standard says this field is + * ignored by the payer's app — it is required by the payload and + * cosmetic in practice, so do not build anything on top of it. + * + * The key format is not validated: EVP, e-mail, phone and CPF/CNPJ look + * nothing alike, and a wrong guess here would refuse a good key. + * + * @param string $key + * @param string $merchantName + * @param string $merchantCity + * @return self + * @throws ValidationException + */ + public static function make(string $key, string $merchantName, string $merchantCity): self + { + $key = trim($key); + $merchantName = trim($merchantName); + $merchantCity = trim($merchantCity); + $messages = self::messages(); + + if ($key === '') { + throw ValidationException::make('PHPay', $messages->key); + } + + if (strlen($key) > self::KEY_MAX) { + throw ValidationException::make('PHPay', sprintf($messages->keyLength, self::KEY_MAX)); + } + + if ($merchantName === '' || strlen($merchantName) > self::NAME_MAX) { + throw ValidationException::make('PHPay', sprintf($messages->name, self::NAME_MAX)); + } + + if ($merchantCity === '' || strlen($merchantCity) > self::CITY_MAX) { + throw ValidationException::make('PHPay', sprintf($messages->city, self::CITY_MAX)); + } + + return new self($key, $merchantName, $merchantCity); + } + + /** + * fix the amount, in reais. + * + * Without one the payer types the amount, which is what a static code is + * usually for. A raw number is read as reais — field 54 is reais with a + * dot — so withAmount(100) is a hundred reais, not a hundred cents. + * + * @param Money|int|float $amount + * @return self + * @throws ValidationException + */ + public function withAmount(Money|int|float $amount): self + { + $money = $amount instanceof Money ? $amount : Money::reais($amount); + $formatted = $money->toDecimal(); + + if (strlen($formatted) > self::AMOUNT_MAX) { + throw ValidationException::make('PHPay', sprintf(self::messages()->amount, self::AMOUNT_MAX)); + } + + return new self($this->key, $this->merchantName, $this->merchantCity, $formatted, $this->txid); + } + + /** + * identify the transaction in your own system. + * + * Letters and digits only, up to 25 — the standard is strict here, and a + * reader that refuses the code says nothing about why. Absent, the code + * carries `***`, which means the receiver did not set one. + * + * @param string $txid + * @return self + * @throws ValidationException + */ + public function withTxid(string $txid): self + { + $txid = trim($txid); + + if ($txid !== self::TXID_FREE && preg_match('/^[A-Za-z0-9]{1,' . self::TXID_MAX . '}$/', $txid) !== 1) { + throw ValidationException::make('PHPay', sprintf(self::messages()->txid, self::TXID_MAX)); + } + + return new self($this->key, $this->merchantName, $this->merchantCity, $this->amount, $txid); + } + + /** + * the copy-and-paste string, CRC included. + * + * @return string + */ + public function payload(): string + { + $account = $this->field('00', self::GUI) . $this->field('01', $this->key); + + $payload = $this->field('00', '01') + . $this->field('26', $account) + . $this->field('52', self::MCC) + . $this->field('53', self::CURRENCY) + . ($this->amount === null ? '' : $this->field('54', $this->amount)) + . $this->field('58', self::COUNTRY) + . $this->field('59', $this->merchantName) + . $this->field('60', $this->merchantCity) + . $this->field('62', $this->field('05', $this->txid)); + + /* + * the CRC covers the `6304` that announces it, not only what comes + * before: stopping earlier is the mistake that produces a code which + * simply does not read, and the standard says as much in the footnote + * of field 63. + */ + return $payload . '6304' . self::crc16($payload . '6304'); + } + + /** + * one field, as the standard wants it: id, two digits of length, value. + * + * @param string $id + * @param string $value + * @return string + */ + private function field(string $id, string $value): string + { + return $id . str_pad((string) strlen($value), 2, '0', STR_PAD_LEFT) . $value; + } + + /** + * CRC-16/CCITT-FALSE, polynomial 0x1021 and initial value 0xFFFF, as the + * footnote of field 63 spells out. Four uppercase hex digits. + * + * @param string $payload + * @return string + */ + private static function crc16(string $payload): string + { + $crc = 0xFFFF; + + for ($i = 0; $i < strlen($payload); $i++) { + $crc ^= ord($payload[$i]) << 8; + + for ($bit = 0; $bit < 8; $bit++) { + $crc = ($crc & 0x8000) !== 0 ? ($crc << 1) ^ 0x1021 : $crc << 1; + $crc &= 0xFFFF; + } + } + + return strtoupper(str_pad(dechex($crc), 4, '0', STR_PAD_LEFT)); + } + + /** + * messages of this value object. + * + * @return object{key: string, keyLength: string, name: string, city: string, txid: string, amount: string} + */ + public static function messages(): object + { + return (object) [ + 'key' => 'Informe a chave Pix do recebedor.', + 'keyLength' => 'A chave Pix não pode passar de %d caracteres.', + 'name' => 'O nome do recebedor é obrigatório e não pode passar de %d caracteres. Ele é exigido pelo padrão, mas o app do pagador mostra o nome que vem do DICT, não este.', + 'city' => 'A cidade do recebedor é obrigatória e não pode passar de %d caracteres.', + 'txid' => 'O txid aceita apenas letras e números, até %d caracteres. Sem txid, o código leva *** no lugar.', + 'amount' => 'O valor não cabe no campo do padrão, que aceita %d caracteres em reais.', + ]; + } +} diff --git a/tests/Unit/Support/StaticPixCodeTest.php b/tests/Unit/Support/StaticPixCodeTest.php new file mode 100644 index 0000000..50c3d4d --- /dev/null +++ b/tests/Unit/Support/StaticPixCodeTest.php @@ -0,0 +1,147 @@ + + */ +function camposDe(string $payload): array +{ + $campos = []; + $i = 0; + + while ($i < strlen($payload)) { + $id = substr($payload, $i, 2); + $tamanho = (int) substr($payload, $i + 2, 2); + $campos[$id] = substr($payload, $i + 4, $tamanho); + $i += 4 + $tamanho; + } + + return $campos; +} + +it('reproduz o vetor de teste do manual do BACEN, byte a byte', function () { + $payload = StaticPixCode::make('123e4567-e12b-12d1-a456-426655440000', 'Fulano de Tal', 'BRASILIA') + ->payload(); + + expect($payload)->toBe(VETOR_BACEN); +})->group('support'); + +it('calcula o CRC sobre o literal 6304 também', function () { + /* + * o manual traz 1D3D para esse vetor. 4AAD é o que sai quando o cálculo + * para antes do `6304` — o erro que gera um código que não lê. + */ + expect(VETOR_BACEN)->toEndWith('63041D3D') + ->and(VETOR_BACEN)->not->toEndWith('63044AAD'); +})->group('support'); + +it('fecha cada campo com o tamanho que declara', function () { + $campos = camposDe(VETOR_BACEN); + + expect($campos['00'])->toBe('01') + ->and($campos['52'])->toBe('0000') + ->and($campos['53'])->toBe('986') + ->and($campos['58'])->toBe('BR') + ->and($campos['59'])->toBe('Fulano de Tal') + ->and($campos['60'])->toBe('BRASILIA') + ->and($campos['63'])->toBe('1D3D') + ->and(camposDe($campos['26']))->toBe([ + '00' => 'br.gov.bcb.pix', + '01' => '123e4567-e12b-12d1-a456-426655440000', + ]) + ->and(camposDe($campos['62']))->toBe(['05' => '***']); +})->group('support'); + +it('não leva campo de valor quando o valor não é informado', function () { + $campos = camposDe(StaticPixCode::make('fale@phpay.io', 'Sixtec LTDA', 'BRASILIA')->payload()); + + /* sem o 54, o pagador digita o valor — é para isso que serve o estático */ + expect($campos)->not->toHaveKey('54'); +})->group('support'); + +it('escreve o valor em reais com ponto', function (Money|int|float $valor, string $esperado) { + $campos = camposDe( + StaticPixCode::make('fale@phpay.io', 'Sixtec LTDA', 'BRASILIA')->withAmount($valor)->payload() + ); + + expect($campos['54'])->toBe($esperado); +})->with([ + 'Money em reais' => [Money::reais('100,50'), '100.50'], + 'Money em centavos' => [Money::centavos(10050), '100.50'], + 'número cru é reais' => [100, '100.00'], + 'float' => [49.9, '49.90'], +])->group('support'); + +it('leva *** quando não há txid, e o txid quando há', function () { + $semTxid = StaticPixCode::make('fale@phpay.io', 'Sixtec LTDA', 'BRASILIA'); + + expect(camposDe(camposDe($semTxid->payload())['62']))->toBe(['05' => '***']) + ->and(camposDe(camposDe($semTxid->withTxid('PEDIDO1234')->payload())['62'])) + ->toBe(['05' => 'PEDIDO1234']); +})->group('support'); + +it('é imutável: with() devolve outra instância', function () { + $base = StaticPixCode::make('fale@phpay.io', 'Sixtec LTDA', 'BRASILIA'); + $com = $base->withAmount(Money::reais('10,00')); + + expect($com)->not->toBe($base) + ->and(camposDe($base->payload()))->not->toHaveKey('54') + ->and(camposDe($com->payload())['54'])->toBe('10.00'); +})->group('support'); + +it('conta o tamanho em bytes, não em caracteres', function () { + /* + * "Comércio" tem 8 caracteres e 9 bytes em UTF-8. Quem lê o payload anda + * em bytes, então o tamanho declarado é em bytes — senão todos os campos + * seguintes desalinham. + */ + $campos = camposDe(StaticPixCode::make('fale@phpay.io', 'Comércio', 'BRASILIA')->payload()); + + expect($campos['59'])->toBe('Comércio') + ->and(strlen($campos['59']))->toBe(9) + ->and($campos['60'])->toBe('BRASILIA'); +})->group('support'); + +it('recusa o que o padrão não aceita', function (string $chave, string $nome, string $cidade, string $trecho) { + expect(fn () => StaticPixCode::make($chave, $nome, $cidade)) + ->toThrow(ValidationException::class, $trecho); +})->with([ + 'chave vazia' => ['', 'Sixtec LTDA', 'BRASILIA', 'Informe a chave Pix'], + 'chave só espaço' => [' ', 'Sixtec LTDA', 'BRASILIA', 'Informe a chave Pix'], + 'chave longa' => [str_repeat('a', 78), 'Sixtec LTDA', 'BRASILIA', '77 caracteres'], + 'nome vazio' => ['fale@phpay.io', '', 'BRASILIA', 'nome do recebedor'], + 'nome longo' => ['fale@phpay.io', str_repeat('a', 26), 'BRASILIA', '25 caracteres'], + 'cidade vazia' => ['fale@phpay.io', 'Sixtec LTDA', '', 'cidade do recebedor'], + 'cidade longa' => ['fale@phpay.io', 'Sixtec LTDA', str_repeat('a', 16), '15 caracteres'], +])->group('support'); + +it('recusa txid fora do que o padrão aceita', function (string $txid) { + $code = StaticPixCode::make('fale@phpay.io', 'Sixtec LTDA', 'BRASILIA'); + + expect(fn () => $code->withTxid($txid))->toThrow(ValidationException::class, 'txid'); +})->with([ + 'com hífen' => ['PEDIDO-1234'], + 'com espaço' => ['PEDIDO 1234'], + 'vazio' => [''], + 'longo' => [str_repeat('A', 26)], +])->group('support'); + +it('aceita o valor que o Money aceita, e recusa o que ele recusa', function () { + $code = StaticPixCode::make('fale@phpay.io', 'Sixtec LTDA', 'BRASILIA'); + + expect(fn () => $code->withAmount(10.555))->toThrow(ValidationException::class, 'duas casas decimais') + ->and(fn () => $code->withAmount(-1))->toThrow(ValidationException::class, 'negativo'); +})->group('support'); From 45b73ca2644ff6dc84fab39989f6410a4381e124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Lucas?= Date: Thu, 22 Oct 2026 14:17:01 -0300 Subject: [PATCH 2/2] PHPAY-109: doc: the static QR Code, and why its CRC is delicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page in docs/ with what to know before using it: the receiver name is required by the standard and ignored by the payer app, which shows the name from the DICT; only the payload is returned; the txid takes letters and digits and PEDIDO-1234 does not pass; a raw amount is reais. The matrix bullet and the Efí note now point there, since that is where the asymmetry was documented. --- CHANGELOG.md | 7 +++++ CLAUDE.md | 22 +++++++++++++ README.md | 4 ++- docs/README.md | 1 + docs/gateways/efi.md | 3 ++ docs/qr-code-estatico.md | 68 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 docs/qr-code-estatico.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7334c06..2d3da66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ O de-para completo da v1 para a v2, quebra por quebra, está no ([#111], [#112]). - Cobertura de testes medida no CI, num job só, com o número no log e no resumo do job. Sem mínimo que reprove o build ([#111], [#112]). +- `PHPay\Support\StaticPixCode`, que compõe o QR Code estático de Pix (o BR + Code) a partir da chave, localmente e sem rede — como o padrão do BACEN manda. + Fecha a assimetria dos gateways que não expõem endpoint para isso, a Efí entre + eles, sem stub e sem sugerir que a API deles oferece o que não oferece. A suíte + exige o vetor de teste do manual do BACEN byte a byte, porque CRC errado gera um + código que não lê ([#109]). - Verificador de links da documentação no gate, em `composer test:links`. Ele confere que todo link relativo aponta para arquivo que existe e que toda âncora corresponde a um título de verdade — título dentro de bloco de código @@ -462,6 +468,7 @@ Primeira versão publicada no Packagist, sob licença MIT. [#100]: https://github.com/phpay-io/phpay/pull/100 [#101]: https://github.com/phpay-io/phpay/issues/101 [#102]: https://github.com/phpay-io/phpay/pull/102 +[#109]: https://github.com/phpay-io/phpay/issues/109 [#111]: https://github.com/phpay-io/phpay/issues/111 [#112]: https://github.com/phpay-io/phpay/pull/112 [#113]: https://github.com/phpay-io/phpay/issues/113 diff --git a/CLAUDE.md b/CLAUDE.md index e840a64..806829e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,6 +220,28 @@ mandar chave em toda requisição esbarraria no `409 idempotency_key_in_use`. Ga novo só ganha setter se a documentação dele descrever o mecanismo — e a geração da chave vem de `PHPay\Http\Idempotency::key()`, não de `random_bytes` espalhado. +## QR Code estático + +Use **`PHPay\Support\StaticPixCode`**. Pelo padrão do BACEN um QR Code estático é +composto localmente a partir da chave, então não é Resource, não é capacidade e não +entra na enum `Capability` — é suporte, como o `Money`. O Asaas e a Woovi expõem +endpoint por conveniência; a Efí não expõe, e isso não é falta dela. + +- **O CRC cobre o literal `6304`**, não para antes dele. Polinômio `0x1021`, C.I. + `0xFFFF`. É o erro mais comum de quem implementa o padrão, e gera um código que + não lê — o vetor oficial do manual do BACEN está na suíte exigindo o payload byte + a byte, e **não é teste opcional**: para o mesmo vetor, o cálculo errado devolve + `4AAD` em vez de `1D3D`. +- O tamanho no TLV é em **bytes**, não em caracteres: quem lê o payload anda em + bytes, e um nome com acento desalinharia todos os campos seguintes. +- O objeto é imutável, como o `Money` e o `Customer`: `with...()` devolve outro. +- **Só o payload**, nunca a imagem: renderizar exige codificador de QR Code, e isso + seria dependência nova numa biblioteca de pagamentos. +- O `merchantName` é exigido pelo padrão e **ignorado pelo app do pagador**, que + mostra o nome vindo do DICT. Está no PHPDoc para ninguém construir em cima dele. +- Não se valida o formato da chave — EVP, e-mail, telefone e CPF/CNPJ não se + parecem, e errar reprovaria chave boa. + ## Assinatura de webhook Use **`webhookSignature()`**. Cadastrar o endpoint é `SupportsWebhooks`; provar que diff --git a/README.md b/README.md index be0e824..2272b88 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,8 @@ gateway não aceita Pix, não manda webhook ou não assina nada: forma de pagamento de uma cobrança — e todos aceitam. As duas metades não andam juntas em todo PSP: o QR Code estático está no Asaas e na Woovi, que expõem endpoint para ele, e **não** na [Efí](docs/gateways/efi.md), cuja API Pix não tem - essa rota. + essa rota. Onde o gateway não expõe, a biblioteca compõe: ver + [QR Code estático](docs/qr-code-estatico.md). - **`SupportsWebhooks`** significa *cadastrar endpoints pela API*. Nos outros, o cadastro é no painel; a notificação vai por cobrança, no campo `notification_url`. O Pagar.me ainda deixa **consultar e reenviar entregas**, @@ -173,6 +174,7 @@ específico dele. | [Cliente](docs/cliente.md) | o VO `Customer`, porque o mesmo campo tem seis grafias entre os gateways | | [Valores monetários](docs/valores-monetarios.md) | `Money`, porque reais e centavos não se misturam | | [Verificando o webhook que chega](docs/webhooks.md) | provar que a notificação veio do gateway | +| [QR Code estático](docs/qr-code-estatico.md) | compor o BR Code de uma chave Pix localmente, que é como o padrão manda | **Gateways** diff --git a/docs/README.md b/docs/README.md index 683bb7a..0b04c7c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ específico dele. | [Cliente](cliente.md) | o VO `Customer`, porque o mesmo campo tem seis grafias entre os gateways | | [Valores monetários](valores-monetarios.md) | `Money`, porque reais e centavos não se misturam | | [Verificando o webhook que chega](webhooks.md) | provar que a notificação veio do gateway | +| [QR Code estático](qr-code-estatico.md) | compor o BR Code de uma chave Pix localmente, que é como o padrão manda | ## Gateways diff --git a/docs/gateways/efi.md b/docs/gateways/efi.md index 10d3553..7fd6253 100644 --- a/docs/gateways/efi.md +++ b/docs/gateways/efi.md @@ -105,6 +105,9 @@ PHPay::gateway($gateway)->pix()->destroy($chave); > valor opcional e de um identificador livre — quem oferece endpoint faz por > conveniência, não por necessidade. O `qrCode()` do `pixCharge()`, acima, é o > QR Code **de uma cobrança**, que é outra coisa. +> +> Para o estático, use [`StaticPixCode`](../qr-code-estatico.md): ele compõe o +> payload a partir da chave, sem rede e sem gateway. **Webhooks** — um por chave Pix, endereçado pela própria chave: diff --git a/docs/qr-code-estatico.md b/docs/qr-code-estatico.md new file mode 100644 index 0000000..66dd58b --- /dev/null +++ b/docs/qr-code-estatico.md @@ -0,0 +1,68 @@ +# QR Code estático + +Um QR Code estático de Pix não se pede a ninguém: pelo padrão do BACEN ele é +**composto localmente**, a partir da chave. Quem oferece endpoint para isso — o +Asaas e a Woovi — faz por conveniência, e a Efí não ter um não a torna menos +PSP. + +`PHPay\Support\StaticPixCode` compõe o payload, sem tocar a rede: + +```php +use PHPay\Support\{Money, StaticPixCode}; + +$code = StaticPixCode::make( + key: 'fale@phpay.io', + merchantName: 'Sixtec LTDA', + merchantCity: 'BRASILIA', +); + +echo $code->payload(); +// 00020126350014br.gov.bcb.pix0113fale@phpay.io520400005303986... +``` + +Sem valor, o pagador digita quanto pagar — é o uso mais comum do estático. Com +valor e com identificador: + +```php +$code = StaticPixCode::make('fale@phpay.io', 'Sixtec LTDA', 'BRASILIA') + ->withAmount(Money::reais('100,50')) + ->withTxid('PEDIDO1234'); +``` + +O objeto é imutável: cada `with...()` devolve outro, como no `Money`. + +## O que saber antes de usar + +**O nome do recebedor não é o que o pagador vê.** O app mostra o nome que a +chave resolve no DICT; o campo 59 do payload é exigido pelo padrão e ignorado +pelo app do pagador. Ele é obrigatório e cosmético — não construa nada em cima +dele. + +**Só o copia-e-cola.** Renderizar a imagem exige um codificador de QR Code, e o +runtime desta biblioteca é `ext-curl`, `ext-json`, `ext-openssl` e o Guzzle. +Passe o payload para o gerador que você já usa. + +**O txid aceita letras e números, até 25.** Sem txid, o código leva `***`, que +é como o padrão diz "o recebedor não definiu um". `PEDIDO-1234` **não** passa: +hífen não é aceito, e um leitor que recusa o código não explica por quê. + +**O valor vai em reais com ponto.** `withAmount(100)` é cem reais, porque reais +é a unidade do campo. Para centavos, `Money::centavos(10050)` — ver +[Valores monetários](valores-monetarios.md). + +**A chave não é validada no formato.** EVP, e-mail, telefone e CPF/CNPJ têm +formas diferentes, e uma validação apressada reprovaria chave boa. São +recusadas a chave vazia e a que passa de 77 caracteres, que é o que cabe no +campo. + +## Por que o CRC importa + +O último campo do payload é um CRC-16 — polinômio `0x1021`, valor inicial +`0xFFFF` — e ele **cobre também o literal `6304`** que o anuncia. Parar o +cálculo antes desses quatro caracteres é o erro mais comum de quem implementa o +padrão, e o resultado é um código que simplesmente não lê: o erro aparece na +frente do pagador, não no seu log. + +A suíte carrega o vetor de teste do próprio Manual de Padrões para Iniciação do +Pix e exige o payload **byte a byte**, incluindo o `1D3D` final. Para o mesmo +vetor, o cálculo que para antes do `6304` devolve `4AAD`.