info@altius-group.ch
Froideville, Vaud
FR

A QR code that grants nothing

/ 13 min de lecture / mis à jour 04.09.2026

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:

"""
Sans ce lien, la caissière cherche le candidat dans une liste de plusieurs
centaines de noms homonymes, ou recopie une référence à la main sous les yeux
d'une file d'attente. On se trompe de dossier, on encaisse deux fois, ou l'on
valide un dossier qui n'a pas payé.
"""

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

"""
Le code n'ouvre aucun droit par lui-même. Il ne porte qu'une référence — celle
qui figure déjà sur le dossier et sur tous ses courriels — et l'écran qu'il
ouvre exige la permission d'encaisser. Scanner le code d'autrui ne donne donc
rien de plus que lire son numéro de dossier par-dessus son épaule.
"""

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:

def code_qr_svg(adresse):
    """
    Rend le code QR d'une adresse, en SVG.

    En SVG et non en PNG : le code reste net sur un écran de téléphone comme
    sur un récépissé imprimé, il pèse quelques kilo-octets, et il s'insère dans
    la page sans passer par le stockage ni par une seconde requête.
    """

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:

#: Taille du module du code téléchargeable, en points. Plus généreuse que
#: celle de l'écran : ce code-là est relu depuis une capture d'écran envoyée
#: par messagerie, parfois recompressée deux fois, souvent sur un écran fêlé.
TAILLE_MODULE_FICHIER = 12


def code_qr_png(adresse):
    """
    Rend le code QR d'une adresse en PNG, prêt à être téléchargé.

    En PNG et non en SVG, contrairement à ce qui s'affiche dans la page : ce
    fichier-là n'est pas fait pour un navigateur mais pour une messagerie. Le
    candidat qui habite loin d'un guichet envoie son code à un parent ou à un
    ami qui, lui, s'en approche [...]
    """

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:

#: Taille du module du code, en points SVG. Sept donne un code lisible par un
#: téléphone d'entrée de gamme à trente centimètres, sans occuper la moitié de
#: l'écran du candidat.
TAILLE_MODULE = 7

#: Marge silencieuse, en modules. La norme en demande quatre ; deux suffisent à
#: l'écran, où le fond est déjà blanc et calme.
MARGE = 2

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

"""
**Le tarif n'est pas dans le code.** Ce module ne connaît que des *natures* de
tarif — concours, inscription — et va chercher en base le montant applicable à
l'objet et à son année. L'école crée « Frais d'inscription 2026-2027 » à
25 000 F, et c'est ce montant qui est facturé le lendemain sans livraison. Un
tarif daté prime sur le tarif permanent : c'est ainsi qu'on augmente les frais
sans toucher aux transactions déjà ouvertes.
"""

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:

transaction = Transaction.objects.create(
    payeur=payeur,
    tarif=tarif,
    moyen=moyen,
    montant=tarif.montant,
    frais=moyen.frais_pour(tarif.montant),
    ...
)

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

"""
**Ce module n'importe ni les admissions ni la scolarité.** Il reconnaît les
objets par leur étiquette (`admissions.dossier`, `scolarite.inscription`), ce
qui lui permet de facturer n'importe quoi sans dépendre de personne — et à
n'importe quelle application d'être facturée sans le savoir.
"""
#: Nature du tarif applicable à chaque objet facturable, par son étiquette de
#: modèle. Ajouter une famille de frais se fait ici, en une ligne.
NATURE_PAR_OBJET = {
    "admissions.dossier": CodeNatureTarif.CONCOURS,
    "scolarite.inscription": CodeNatureTarif.INSCRIPTION,
}

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

def ouvrir(objet, payeur, moyen):
    """
    Ouvre une transaction pour un objet, et rend la référence à payer.

    L'opération est idempotente : une référence déjà ouverte pour le même objet
    est rendue telle quelle plutôt que doublée. Deux références vivantes pour
    une même dette, c'est un guichet qui encaisse deux fois.
    """
    existante = _transaction_de(objet)
    if existante is not None and existante.etat in (
        Transaction.Etat.EN_ATTENTE,
        Transaction.Etat.INITIEE,
        Transaction.Etat.REGLE,
    ):
        return existante

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:

#: Durée de validité retenue quand le moyen de paiement n'en déclare aucune.
VALIDITE_DEFAUT_HEURES = 48
heures = moyen.validite_heures or VALIDITE_DEFAUT_HEURES

The guard that four screens did not have

This is the part of the module with a story:

"""
Les conditions du guichet sont vérifiées **ici** et non dans les écrans.
Quatre boutons menaient à cette fonction — la liste des paiements, l'écran
d'instruction, le comptoir, les inscriptions — et chacun appliquait sa
propre idée de ce qui était permis, c'est-à-dire aucune. Un règlement
soldé délivre un reçu, l'envoie au payeur, et fait entrer le dossier dans
la file des dossiers payés : c'est le geste le moins rattrapable de
l'application, et le seul qui n'avait pas de garde.
"""

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:

def exiger_guichet(agent, objet):
    """
    Vérifie qu'un encaissement est légitime. Lève `EncaissementRefuse` sinon.

    Deux conditions, et elles tiennent ensemble. Un **agent identifié portant
    la permission de caisse** : un encaissement sans nom n'est pas opposable,
    et le journal doit pouvoir dire qui a reçu l'argent. Un **objet en état
    d'être payé** : voir `obstacle_a_encaisser`.
    """
    if agent is None or not getattr(agent, "is_authenticated", False):
        raise EncaissementRefuse(
            _("Un encaissement se fait au guichet, par un agent identifié.")
        )
    if not agent.has_perm(PERMISSION_CAISSE):
        raise EncaissementRefuse(
            _("Votre rôle ne tient pas la caisse : seul le guichet encaisse.")
        )
    obstacle = obstacle_a_encaisser(objet)
    if obstacle:
        raise EncaissementRefuse(
            _("Encaissement refusé : %(raison)s.") % {"raison": obstacle}
        )

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

@db_transaction.atomic
def confirmer(transaction, agent=None, reference_externe="", requete=None):
    ...
    if transaction.etat == Transaction.Etat.REGLE:
        return getattr(transaction, "recu", None)

    exiger_guichet(agent, transaction.objet)

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:

"""
C'est le geste de la caisse pour les canaux manuels ; ce sera celui du
rappel de l'opérateur pour les canaux automatiques, qui appelleront cette
même fonction. D'où le paramètre `reference_externe` : l'identifiant que
l'opérateur donne au règlement, sans lequel aucun rapprochement bancaire
n'est possible.
"""
if reference_externe:
    transaction.reference_externe = reference_externe[:80]

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:

"""
Le reçu part par courriel dès qu'il est délivré, et c'est ici qu'on
l'envoie plutôt que dans l'écran de caisse : les canaux automatiques
appelleront la même fonction, et un reçu envoyé « depuis le guichet
seulement » serait un reçu qu'on oublie le jour où l'on branche Orange
Money. Un envoi qui échoue ne défait rien — l'argent est encaissé, le reçu
existe, et il reste imprimable.
"""

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:

def _consigner_sur_le_dossier(transaction, agent=None):
    """
    Porte l'encaissement dans la chronologie du dossier, et le dit au candidat.

    L'événement `paiement` existait dans le circuit depuis l'origine, marqué
    « visible par le candidat », et rien ne l'écrivait : le règlement ne laissait
    sa trace que du côté de la transaction. Le candidat qui s'était déplacé au
    guichet avec dix mille francs revenait sur son espace et n'y voyait rien
    changer — c'est le moment précis où l'on doute d'avoir payé.
    """

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:

dossier = transaction.objet
if not isinstance(dossier, Dossier):
    return None

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.
Prêt à démarrer ?

Parlons de votre projet

Dites-nous vos besoins en IoT, SIG ou développement sur mesure — nous vous répondons sous 24 h.