The candidates in this admissions campaign do not pay online. They travel, they queue, and they pay at the school’s counter in cash.
Which leaves a join to make: the application holds the file, the counter holds the money, and nothing connects them. Without that connection, here is the cashier’s actual job:
Searching for the candidate in a list of several hundred homonymous names, or copying a reference by hand under the eyes of a queue. You take the wrong file, you charge twice, or you validate a file that has not paid.
The join is a QR code, in ENI-ABT. The interesting part is not the code — it is what the code deliberately does not do.
The code carries no authority
Scanning somebody else’s code therefore gives nothing more than reading their file number over their shoulder.
That sentence is the whole security design, and it is worth dwelling on because the tempting alternative is so natural. A QR code is a convenient place to put a signed token: scan it, and the till screen opens already authenticated, no login, fast queue. Every argument for it is an operational argument, and they are all good arguments.
They are also how you end up with bearer credentials printed on paper, carried in candidates’ pockets, photographed, forwarded over messaging apps, and screenshotted into group chats. A capability in a QR code is a capability anybody who sees the paper holds.
So the code contains a URL, the URL contains a reference the candidate already has on every email the school sends them, and the screen behind it is protected the ordinary way. The scan saves the cashier from typing; it grants nothing. The authorisation is done where authorisation is always done, by the logged-in agent’s permissions.
The practical test for this design is simple: if I photograph this code and post it publicly, what does the reader gain? Here, the answer is “a file number, which was already on the candidate’s receipt”. That is the answer you want.
Two renderings, for two different pieces of hardware
The same code is produced twice, in two formats, and the reasoning is entirely about the physical world:
Inline SVG: sharp at any size, a few kilobytes, no media storage, no second HTTP request. For a code that is displayed in a page and printed on a receipt, that is right on every axis.
And then the other one:
This one is not made for a browser but for a messaging app. A candidate who lives far from a counter sends their code to a relative or a friend who is closer. No messaging app in the country renders SVG.
Twelve-point modules instead of seven, because the file will be screenshotted, recompressed twice by the app, and read off a cracked screen. That is not defensive over-engineering; it is a measurement of the actual delivery path.
The two on-screen constants are calibrated the same way:
Deviating from the spec’s four-module quiet zone is a real decision, and the comment says what makes it safe: the surrounding page is already white and uncluttered. On a printed page competing with other ink, four would be the right answer. Writing down why two is enough here is what lets the next person put it back to four when the context changes.
The price is not in the code
The last clause is the one that carries. A dated tariff beating a permanent one is what makes a price change forward-looking by construction: the school creates the 2027-2028 fee, and every transaction already opened at the old price keeps it, because those transactions hold their own amount:
montant is copied onto the transaction at opening. A transaction that read its amount through its tariff foreign key would silently reprice every open debt on the day the fee changed, and the candidate holding a printed reference for 10 000 F would arrive at the counter owing 12 000.
Billing something without knowing what it is
String labels rather than imports. The payments app depends on nothing, and any app can become billable without knowing the payments app exists. The inversion is worth the loss of static checking here, because the alternative — payments importing admissions importing payments — is the circular dependency every Django project of this size eventually grows.
The local imports inside the functions confirm the intent: from admissions.models import ... appears inside _consigner_sur_le_dossier, not at module top. That is a deliberate one-way door, not an oversight.
Opening a debt exactly once
Two live references for one debt is a counter that charges twice. And it would happen the ordinary way: the candidate opens the payment page, loses signal, opens it again, and walks in with two printouts.
Note which states count as live. REGLE — settled — is in the list, so a paid debt never reopens. The states not listed are the expired and cancelled ones, which is what allows a candidate whose 48-hour reference lapsed to get a fresh one:
The guard that four screens did not have
This is the part of the module with a story:
Four buttons led to this function, and each applied its own idea of what was allowed — that is, none. The least recoverable action in the application, and the only one with no guard.
Four entry points is the number at which per-screen checks stop working. Not because developers are careless, but because the fourth screen is written by somebody reading the third, and the third had already dropped one of the two conditions. The fix is not more diligence; it is moving the check to the single place all four go through:
A payment taken without a name is not enforceable, and the log must be able to say who received the money. That is the reason for the identity check, and it is an accounting reason rather than a technical one — which is exactly why it belongs in a docstring where an auditor’s question can be answered from the source.
EncaissementRefuse subclasses ValidationError, so all four screens render it as a form error without any of them knowing what the rules are.
Settling, once
An already-settled transaction returns its receipt instead of raising. That is the right shape for a counter: the cashier who clicks twice, or whose page reloads, gets the receipt they were going to get — not an error suggesting something went wrong with money that was correctly taken.
Then the piece that exists for a channel that does not exist yet:
Reconciliation is the thing people forget when building a payment flow, because it is invisible until the first month-end. Without the operator’s own identifier stored against the transaction, matching the school’s ledger to the mobile-money statement is a manual join on amount and timestamp — which fails precisely when two candidates pay the same fee in the same minute, which is every morning of the campaign.
The receipt is sent from here, and the docstring defends the placement:
Both halves matter. Sending from the screen would work today and quietly stop working the day a webhook calls confirmer instead. And a failed send must not roll back: the money is taken, the receipt exists, and it remains printable. Putting the email inside the atomic block such that an SMTP timeout reverses a cash payment is a real bug people write, and the sentence above is what stops someone “tidying” the code into it.
The moment you doubt you paid
The last function is the smallest and the one a candidate would name first:
The event existed in the workflow from the beginning, marked “visible to the candidate”, and nothing ever wrote it. Someone who travelled to the counter with ten thousand francs came back to their account and saw nothing change — which is the precise moment you doubt you paid.
This is the same failure shape as the empty convocation stack elsewhere in this codebase, and it is worth naming as a class: a declared capability that nothing exercises. The event type is seeded, it has a translated label, it is flagged candidate-visible, it renders correctly in the timeline template. Every part exists except the line that creates a row. Nothing fails, no test is red, and the gap is only visible from the candidate’s side of the screen.
The type guard on the way in keeps the payments module honest about its independence:
An enrolment being paid is not an admission file, and this function simply does not apply. Returning None rather than raising is right: confirmer bills generic objects, and the timeline is a courtesy specific to one of them.
What carries over
- Put a reference in the QR code, never a capability. Ask what a photograph of it is worth to a stranger; if the answer is more than “a number they could read anyway”, redesign it.
- Render the same code twice for two delivery paths. Inline SVG for the screen, a generous PNG for the messaging app that will recompress it.
- Copy the price onto the debt at opening. A live foreign key to a tariff reprices every open transaction the day the fee changes.
- Make opening a debt idempotent. Two live references are a counter that charges twice, and lost signal creates them for free.
- Count the entry points before deciding where the guard goes. At four, the check belongs in the shared function — screens will disagree with each other and none of them will be wrong on purpose.
- Send the receipt from the settlement, not from the till. The webhook will call the same function, and it has no screen.
- Look for declared capabilities nothing exercises. A seeded event type that nothing writes, a button that prints an empty stack: they pass every test, because there is nothing to fail.