info@altius-group.ch
Froideville, Vaud
IT

Class 1 is assets, except where it is liabilities

/ 9 min di lettura / aggiornato 04.09.2026

Open any tutorial on building an accounting module and you will find some version of this:

def account_nature(number: str) -> str:
    return {
        "1": "ASSET",
        "2": "LIABILITY",
        "3": "REVENUE",
        "4": "EXPENSE",
        ...
    }[number[0]]

It is correct. It is correct in Switzerland, in Germany, in most of the places the tutorial’s author has worked, and it will be correct for the entire life of most applications.

It is exactly backwards in Abidjan.

Under the SYSCOHADA standard used across the seventeen OHADA member states of West and Central Africa, class 1 is Comptes de ressources durables — long-term resources, which is to say liabilities and equity. Class 2 is Comptes d’actif immobilisé — fixed assets. The first two classes are inverted relative to the Swiss plan, and it is not a translation difference. It is a different theory of how a balance sheet is ordered.

AltiusOne is a fiduciary platform whose clients keep books under four standards. This is what that costs, and what it forces you to stop treating as a constant.

Four standards, and their classes are data

{
    'PME': {
        'nom': 'Plan Comptable PME Suisse',
        'pays': 'Suisse',
        'norme_comptable': 'CO (Code des obligations)',
        'version': '2023',
        'classes': [
            {'numero': 1, 'libelle': 'Actifs',                       'type_compte': 'ACTIF'},
            {'numero': 2, 'libelle': 'Passifs',                      'type_compte': 'PASSIF'},
            {'numero': 3, 'libelle': "Produits d'exploitation",      'type_compte': 'PRODUIT'},
            {'numero': 4, 'libelle': 'Charges de matériel',          'type_compte': 'CHARGE'},
            {'numero': 5, 'libelle': 'Charges de personnel',         'type_compte': 'CHARGE'},
            ...
        ]
    },
    'OHADA': {
        'nom': 'Plan Comptable OHADA',
        'pays': 'Zone OHADA',
        'norme_comptable': 'SYSCOHADA révisé',
        'version': '2017',
        'classes': [
            {'numero': 1, 'libelle': 'Comptes de ressources durables',   'type_compte': 'PASSIF'},
            {'numero': 2, 'libelle': "Comptes d'actif immobilisé",       'type_compte': 'ACTIF'},
            {'numero': 3, 'libelle': 'Comptes de stocks',                'type_compte': 'ACTIF'},
            {'numero': 4, 'libelle': 'Comptes de tiers',                 'type_compte': 'ACTIF'},
            {'numero': 5, 'libelle': 'Comptes de trésorerie',            'type_compte': 'ACTIF'},
            ...
        ]
    },
    'SWISSGAAP': { ... },
    'PCG':       { ... 'norme_comptable': 'Plan Comptable Général France — ANC 2014-03' },
}

Read classes 1 and 2 twice. Then read classes 3, 4 and 5.

Under the Swiss plan, class 3 is operating revenue, class 4 and 5 are expenses. Under SYSCOHADA, class 3 is inventory, class 4 is receivables and payables, class 5 is cash — all balance-sheet accounts, with revenue not appearing until class 7 and expenses in class 6.

There is no partial mapping between them. The two systems agree on almost nothing except that there are nine classes and they are numbered.

The consequence for the schema is that type_compte cannot be derived. It has to be stored, per class, per standard:

class ClasseComptable(BaseModel):
    """
    Chaque type de plan a sa propre définition des classes:
    - PME Suisse: Classe 1 = Actifs, Classe 2 = Passifs, etc.
    - OHADA: Classe 1 = Capitaux propres, Classe 2 = Actifs immobilisés, etc.
    """

    TYPE_COMPTE_CHOICES = [
        ('ACTIF',    _('Actif')),
        ('PASSIF',   _('Passif')),
        ('CHARGE',   _('Charge')),
        ('PRODUIT',  _('Produit')),
        ('RESULTAT', _('Résultat')),
    ]

    type_plan = models.ForeignKey(TypePlanComptable, on_delete=models.CASCADE,
                                  related_name='classes')
    numero = models.IntegerField()
    libelle = models.CharField(max_length=200)
    type_compte = models.CharField(max_length=10, choices=TYPE_COMPTE_CHOICES)
    numero_debut = models.CharField(max_length=10, blank=True)
    numero_fin = models.CharField(max_length=10, blank=True)

    class Meta:
        unique_together = [['type_plan', 'numero']]
        ordering = ['type_plan', 'numero']

unique_together = [['type_plan', 'numero']] is the whole design in one line. Class 1 exists once per standard, and it means something different in each. Not unique=True on numero — that would assert there is one class 1 in the world, which is the assumption we are removing.

And the docstring spells the difference out, in the model, where somebody about to write a shortcut will read it. That is the correct place for it: not in a wiki, not in a comment on the loader. In the class whose existence is caused by the fact.

What this makes impossible, which is the point

Once type_compte is a column, several things you might have written stop being possible, and each of them is a bug you will not have:

No startswith on the account number. Anything asking “is this an asset?” has to reach the class through the plan. Under a mixed portfolio the shortcut is not merely inelegant; it inverts the balance sheet for a whole set of clients.

No shared enum across plans. AccountClass.ASSETS = 1 cannot exist as a constant, because the constant is per standard.

No report that assumes nine classes with fixed meanings. The trial balance, the balance sheet, the income statement — each of them groups by classe.type_compte, which is read from the database, for this client’s plan.

This is the shape of the general lesson, and it applies far outside accounting:

A mapping that is stable within one jurisdiction, and different in the next, is data. It is not a constant, and it is not a translation.

Translation is the trap that catches people here. A team that internationalises carefully will translate "Assets" into four languages and feel finished — the labels are localised, the interface is bilingual, everything reads correctly. And class 1 still returns ASSET for a client in Dakar, in perfect French. Localisation moved the words. It did not move the structure, and the structure was the thing that differed.

Naming the standard, not just the plan

class TypePlanComptable(BaseModel):
    code = models.CharField(max_length=20, unique=True, db_index=True)
    nom = models.CharField(max_length=200)
    pays = models.CharField(max_length=100, blank=True)
    region = models.CharField(max_length=100, blank=True)
    norme_comptable = models.CharField(max_length=100, blank=True)
    version = models.CharField(max_length=20, blank=True)
    date_publication = models.DateField(null=True, blank=True)

norme_comptable and version are the fields that look like metadata and are not.

SYSCOHADA révisé, version 2017. That revision is a real event: the 2017 revision changed the chart, and books kept under the previous version are not wrong, they are under the previous version. CO (Code des obligations), version 2023 — Swiss company law’s accounting provisions, which likewise change.

An accounting system is a legal artefact. Which edition of which standard a set of books follows is a fact an auditor will ask about, and a fact that determines whether a given account number is legitimate. Recording it as a string on the plan type is the minimum; recording it nowhere means the answer lives in whoever set the system up.

pays and regionZone OHADA, Afrique — do the practical work of letting the interface offer the right default. A new client incorporated in Côte d’Ivoire should not be shown the Swiss SME plan first.

Four languages, on the labels that matter

defaults = {
    "nom_fr": "Plan Comptable OHADA",
    "nom_de": "OHADA-Kontenrahmen",
    "nom_it": "Piano Contabile OHADA",
    "nom_en": "OHADA Chart of Accounts",
    "description_fr": "Plan comptable SYSCOHADA révisé 2017",
    "description_de": "SYSCOHADA revidierter Kontenrahmen 2017",
    ...
}

Four languages, in the database rather than in .po files, and the distinction is the one people get wrong when they set up modeltranslation and then wonder which mechanism to use for what.

gettext is for strings the developer wrote. They ship with the code, they change when the code changes, and every deployment has all of them.

Translated columns are for strings the data owns. An account label is not part of the application; it belongs to a chart of accounts that a customer can extend with their own accounts, which no .po file will ever contain.

The chart is seeded by the application, so its labels could plausibly have gone either way — and the moment a Swiss fiduciary adds account 6512 with their own wording, the seeded labels and the customer’s labels have to live in the same place and be reachable by the same query. Splitting them across two mechanisms means every report joins a table to a translation catalogue.

The languages are not decorative either. A Swiss fiduciary serves clients in French, German and Italian, and produces statements an auditor reads in the canton’s language. English is there for the international mandates.

Ranges as strings

numero_debut = models.CharField(max_length=10, blank=True)   # '1000'
numero_fin   = models.CharField(max_length=10, blank=True)   # '1999'

An account number looks like an integer and is not one. It is an identifier with internal structure: the first digit is the class, the next digits narrow it, and the depth of the hierarchy varies by plan. 1000 and 1 are different accounts; leading zeros are meaningful in plans that use them; and nobody ever adds two account numbers together.

Storing them as strings is right. The caveat worth writing down next to them is that a range check over strings is lexicographic, so '1000' <= n <= '1999' is only equivalent to the numeric comparison while every number has the same number of digits. A six-digit sub-account under a four-digit range boundary compares in a way nobody intends. Either pad, or compare on a derived numeric column, or keep the invariant that a plan’s numbers are fixed-width — and say which one you chose.

One standard, two loaders, two definitions

An honest note about our own code, because it illustrates a failure mode that seeded reference data attracts.

There are two management commands. load_ohada_chart_of_accounts loads SYSCOHADA. load_swiss_chart_of_accounts takes a --type argument:

parser.add_argument("--type", default="PME",
                    choices=["PME", "GENERAL", "OHADA", "SWISSGAAP", "PCG"])

Both can create the TypePlanComptable with code="OHADA". And they do not agree: the dedicated loader defines nine classes, including class 9, Comptes des engagements hors bilan — off-balance-sheet commitments. The generic loader’s OHADA entry defines eight, stopping at class 8.

Whichever command is run first wins, and nothing detects the disagreement. A deployment seeded one way has off-balance-sheet accounts; a deployment seeded the other way does not, and an accountant looking for class 9 concludes the software does not support it.

The cause is ordinary: a general loader grew a special case, then the special case grew a loader of its own, and the first copy was never deleted. Reference data is unusually prone to it because seeding code is written once, runs on installation, and is never read again.

The fix is not to pick a winner — it is to make there be one definition:

  • The standards live in one data file, keyed by code, and the commands read it. A standard defined twice cannot then disagree, because it is defined once.
  • The seeder is idempotent and asserts, so a second run against an existing plan with a different class count fails loudly instead of silently doing nothing.

The general rule for seed data: one definition, many entry points — never the reverse. If two commands can create the same reference row, they are two sources of truth and they will diverge, on a table nobody looks at, that every report depends on.

(Also worth mentioning because it is the same class of problem: a file named load_swiss_chart_of_accounts copy.py sits next to the real one. Reference-data directories collect these, and a stray copy is a command someone can run.)

What to take away

  1. The class-number-to-nature mapping is not universal. Under SYSCOHADA, class 1 is liabilities and class 2 is assets — inverted from the Swiss plan, and the revenue and expense classes are elsewhere entirely.
  2. Store the nature on the class, keyed by standard, and make the uniqueness constraint (standard, number) rather than number.
  3. Localisation is not the same as multi-standard. Translating “Assets” into four languages leaves the structural assumption untouched.
  4. Record the standard and its versionSYSCOHADA révisé, 2017. Which edition a set of books follows is a question an auditor asks.
  5. gettext for strings the code owns; translated columns for strings the data owns. Customer-extensible reference data is always the second.
  6. Account numbers are identifiers, not quantities — and lexicographic range comparisons need fixed-width numbers.
  7. Define reference data once and give it many entry points. Two loaders that can create the same standard will eventually define it differently.
Pronto a cominciare?

Parliamo del suo progetto

Ci racconti le sue esigenze in IoT, GIS o sviluppo su misura — le rispondiamo entro 24 ore.