DEV Community

Cover image for I built a Pix library for Elixir (and one cent proved it works)
Igor Giamoniano
Igor Giamoniano

Posted on Originally published at coisadedevacademy.com.br AI-assisted

I built a Pix library for Elixir (and one cent proved it works)

Over the last few months I became a bit of a compulsive open source contributor. Translating Linux kernel documentation into Portuguese, a merged PR in uutils/coreutils, a few fixes in Elixir libraries I had used before. Always the same routine: find a problem in someone else's project, understand it, fix it, wait for review.

Then a different itch showed up: instead of fixing other people's libraries, build my own.

This post is about that. How I built pix_brcode, a Pix library for Elixir, over a weekend, tested it with real money (R$ 0.01, don't judge me) and published it on Hex. And about the bug that only showed up once a real bank got involved.


Github print

What is Pix, and why a library for it

If you're not from Brazil, you've probably never heard of Pix. It's the instant payment system created by Brazil's Central Bank in 2020, and it took over the country: it's free for individuals, settles in seconds, works 24/7, and today it's how Brazilians pay for everything from coffee to rent.

Most Pix payments start with a QR code, or with its text version, the "copia e cola" (copy and paste) code: a long string starting with 000201 that you paste into your bank app. Everyone uses it every day, and almost nobody knows what's inside it.

In the JavaScript world this is a solved problem: pix-utils on npm generates and parses Pix codes and gets over 60k downloads a month. I went looking for the equivalent on Hex, Elixir's package manager, and couldn't find a maintained one.

So I put the two things together: a big, mature library to use as a reference, and an empty spot in the ecosystem I wanted to fill. I decided to build a "pix-utils" for Hex, or, as I preferred to call it, pix_brcode.

What's inside a Pix code

That string is a BR Code: an international payment QR code standard (EMVCo's QR Code Specification, Merchant-Presented Mode) with Pix-specific fields.

The structure is simpler than it looks. Everything is TLV: a 2-digit ID, a 2-digit length, then the value.

00 02 01                       → payload format: "01"
26 58 0014br.gov.bcb.pix0136…  → Pix account (with subfields inside!)
52 04 0000                     → merchant category
53 03 986                      → currency: Brazilian real
54 04 0.01                     → amount
58 02 BR                       → country
59 04 Igor                     → receiver name
60 09 Sao Paulo                → city
62 07 0503***                  → txid (transaction id)
63 04 F6CE                     → CRC16
Enter fullscreen mode Exit fullscreen mode

Look at field 26: its value is another TLV, holding the Pix identifier (br.gov.bcb.pix) and the key. TLV inside TLV.

And the last field, 63, is a CRC16: a checksum over everything before it. If someone copies the code missing one character, the CRC doesn't match and the bank app rejects it.

(Important side note: a CRC is not security. It catches accidental errors, not fraud. Anyone can recompute it.)

The decisions that made the weekend worth it

Zero dependencies

Installing pix_brcode pulls in nothing else. That had a cost: Erlang only ships CRC32, so the CRC16 was written by hand in about 20 lines, using Elixir's binary pattern matching:

defp crc(<<byte, rest::binary>>, acc) do
  acc = Enum.reduce(1..8, bxor(acc, byte <<< 8), fn _, acc -> shift(acc <<< 1) end)
  crc(rest, acc)
end

defp crc(<<>>, acc), do: acc
Enter fullscreen mode Exit fullscreen mode

Adding a dependency for 20 lines would be trading a small problem for a permanent one.

Money is never a float

pix-utils takes the amount as a JavaScript number, which is a float. And floats with money are how you end up seeing 0.1 + 0.2 = 0.30000000000000004 on someone's bill.

In pix_brcode, the amount is either an integer in cents (1050 becomes "10.50") or an exact string ("10.50"). The conversion is done on text, with no division at all.

Strict in what you send, liberal in what you accept

When generating, the library is strict: a name longer than 25 characters is an error instead of being silently truncated (imagine the payer's bank app showing the wrong name). When parsing, it accepts whitespace and line breaks around the code, because real copy-and-paste comes dirty from WhatsApp.

That principle has a name, Postel's law. And it was about to be tested sooner than I expected.

The moment of truth: real money

Tests passing, Credo clean, docs generated. Only one test left that really matters: a bank app accepting the code.

The first try failed in all three banks I tested. Itaú, Nubank and Mercado Pago: "Invalid QR Code".

For a minute I thought the weekend was wasted. It wasn't: I had used a made-up Pix key. When you paste a static Pix code, the app looks the key up in the Central Bank's directory. Unknown key, rejected code. The test was wrong, not the library.

I generated it again with my real Itaú random key and an amount of one cent:

PixBrcode.encode(%{key: "my-real-key", amount: 1,
                   merchant_name: "Igor", merchant_city: "Sao Paulo"})
Enter fullscreen mode Exit fullscreen mode

Pasted it into Nubank. My name showed up, R$ 0.01. Paid. It landed in my Itaú account instantly.

The best-invested cent of my life.

The bug no test caught

The reverse path was still missing: generate a code in the bank app and parse it with the library. Pix > Receive on Nubank, copy, paste into the terminal:

iex> PixBrcode.decode("00020126580014BR.GOV.BCB.PIX0136…")
{:error, :invalid_gui}
Enter fullscreen mode Exit fullscreen mode

BR.GOV.BCB.PIX. In uppercase.

The Central Bank's manual writes br.gov.bcb.pix in lowercase, and the library compared the exact text. Nubank sends it uppercase. So does Itaú. All my tests passed because they all followed the manual to the letter, and the banks don't.

The fix was one line: compare case-insensitively when parsing, and keep generating lowercase, as the manual says. Postel again: strict in what you send, liberal in what you accept.

The real codes showed other differences too: Nubank includes the postal code (field 61) and puts part of the account holder's tax ID in the receiver name; Itaú doesn't. The library's tests now use payloads with the same layout as the real ones, but with fake data. (Your partial tax ID in a public repo is not a great idea, and I learned that before making the mistake.)

The lesson: the spec is the minimum. Real systems have variations that only show up with real data.

A second opinion before publishing

Before pushing to Hex, I had an AI agent do an independent code review: a fresh session, with no context from the development, reading everything from scratch. Nothing blocked the release, but it came back with four good findings:

  • the amount field didn't enforce the spec's 13-character limit;
  • decode(nil) raised an exception, while the README promised always {:ok, _} or {:error, _};
  • text with invalid bytes also crashed the library;
  • emoji in the name got through, and since each emoji takes several bytes, the length declared in the TLV was wrong for other parsers.

All four were fixed before v0.1.0. Name, city and description must now be ASCII after removing accents (São João becomes Sao Joao, and ☕ is an error).

What's next

v0.1.0 generates and parses static and dynamic Pix codes, validates keys (tax IDs, phone, e-mail, random key) and runs CI on three Elixir versions. Next on the list: typespecs, more tolerant key parsing and a CHANGELOG. And there's another idea in the queue: Brazilian boleto bank slips, which also have no package on Hex.

If you work with Elixir and Pix, try the library and tell me what breaks. Issues and PRs are very welcome:

Fixing other people's libraries taught me to read code. Building my own taught me that the manual is only the start of the conversation.

This post was originally published in Portuguese on Coisa de Dev.

Top comments (2)

Collapse
 
kevinpruett023_kevinpruet profile image
Lee •

Good Post!
I wanna have meaningful conversation about collaboration with you.
I believe we can achieve something big by collaboration.
How about discussing about collaboration via a meeting?

Some comments may only be visible to logged-in visitors. Sign in to view all comments.