Back to work

myaade

An open-source client for Bratnet's myDATA provider API — the integration layer and the interface on top of it.

Role
Solo developer
Timeline
One semester · industry capstone with Bratnet
Stack
Express 5better-sqlite3AxiosReact 19TypeScriptViteZustandTailwind
myaade interface

The problem

Every business in Greece that issues an invoice has to file it with myDATA, the e-invoicing platform of the Independent Authority for Public Revenue (AADE). Almost nobody talks to AADE directly — they go through a licensed provider that owns that conversation. Bratnet is one of them.

Bratnet sells access to its API. The application you actually click buttons in is a separate product. So a customer who only needs the API is left building their own client against a domain they have never worked in: invoice type codes, VAT categories, series and sequence numbers, correlation rules for credit notes.

The capstone brief was to close that gap — a working, documented, open-source client that a Bratnet customer can clone and adapt. Not a demo of the happy path: the parts a real cashier hits on a Tuesday.

The hard parts

Error 603, or: the counter that drifts

Every document carries a series and a sequence number (AA). The provider API returns error 603 — "Invoice already has been sent" — if that number was already used. The obvious reading is that you sent a duplicate. The real cause is usually stranger, and it lives entirely on my side of the boundary.

A request that times out on my client can still have been accepted upstream. The document goes through; my local database never learns about it and still believes the number is free. The next issue reuses it and comes back rejected. And because that can happen more than once, the drift is not always one number deep.

So retrying once and incrementing is not a fix, it's a coin flip. The backend retries up to five times, incrementing and persisting the sequence number on every 603, which repairs the drift rather than papering over it. The cap is the part I'd defend hardest: past five, the cause is no longer transient drift but a genuine mismatch that needs a person. It fails loudly, with the SQL to correct the series, instead of retrying against a paid API in a loop.

Money is not a float

Totals, VAT and partial credits are computed on integer cents, not floating-point euros. This sounds like pedantry until you write the validation for a partial credit note.

A credit note cannot exceed the uncredited balance of the original invoice. Express that check as `remaining > 0` over float arithmetic and `0.1 + 0.2 !== 0.3` eventually issues a credit note for a cent that doesn't exist. Best case it comes back rejected and a user sees an error they cannot act on. Worst case it goes through, and the books are wrong in a way nobody notices for months.

A credit note is a state machine, not a form

Issuing a credit note (type 5.1) means referencing the original document's MARK, staying within the remaining balance, and accounting for the fact that a single invoice may accumulate several partial credits over its life.

The naive build is a form that posts and shows whatever comes back. I moved the constraint earlier: the UI derives the available balance per invoice, shows it on each option, and disables fully-credited documents at selection time. An invalid credit can't be composed in the first place, so the round trip is never spent finding out.

Credit notes also net out of period totals automatically — they reduce debt rather than collect revenue, and a summary card that adds them up is quietly lying to the person reading it.

Decisions & trade-offs

SQLite over PostgreSQL

Why
This is a reference implementation. `git clone && npm install` has to produce a running app with no database to provision.
Cost
Single-writer, single-instance. Anyone taking it to production has to migrate — so the data layer is isolated in one module to keep that a contained change.

No authentication layer

Why
Every integrator already has an identity system. Shipping one here would mean the first thing they do is rip it out.
Cost
The app is unusable as-is on a public host. Documented as a deliberate boundary, not an omission.

Zustand over React Query

Why
Most of the state is small, long-lived reference data — customers, series, issuer details — that changes on user action, not on a server cadence. Cache invalidation was not the problem worth solving.
Cost
Refetch-on-focus and background revalidation are hand-rolled where needed instead of free.

Retry in the backend, not the UI

Why
Sequence drift is a property of the data, not of the click. Putting recovery next to the database means it works the same regardless of which client calls it.
Cost
A single issue request can take several seconds. The UI needed explicit pending states to stay honest about that.

What I'd do differently

Written after shipping, not before.

Idempotency keys, not retries

The retry loop treats a symptom. The disease is that an issue request has no identity of its own, so a timeout leaves my client genuinely unable to tell "never arrived" from "arrived and succeeded". A key generated before the first attempt would let a retry ask "did this specific document go through?" instead of guessing at the counter. I'd build that first now, and most of the retry logic would disappear.

Tests around the money, from day one

There is nothing worth testing about the provider's API — that's their contract and they test it. But the balance derivation for partial credits, the totals and VAT arithmetic, and the sequence-counter update are my logic, they are pure functions, and they are the part with real consequences. I verified them by hand and then wrote documentation about their edge cases, which is the wrong order.

Structured logging on every provider call

Debugging a rejected document meant reading console output and guessing. Every request and response should be persisted with a correlation ID — not for observability points, but because that trail is the only thing that answers "what exactly did we send in March" six months later.