---
titre: Modèle de données
statut: Draft v4 — partiellement implémenté
date: 2026-05-08
---

# 02 — Modèle de données

> **État d'implémentation au 2026-05-21** :
> - ✅ En base : `organization`, `user`, `membership`, `membership_bank_account`, `bank_account`, `transaction`, `category`, `tag`, `transaction_tag`, `import_job`
> - ✅ En base (parcours bien / plateformes) : `property`, `rental_platform`, `organization_rental_platform`, **`property_platform_listing`**, colonne org **`listing_payout_extract_regex`**
> - ⏳ À implémenter ou à finaliser selon périmètre : `statement`, `audit_log` (voir [`docs/existant/etat-courant.md`](../existant/etat-courant.md)).

> **Changelog v4 (2026-05-21)** — listings bien × plateforme (spec [06-listings-plateforme-par-bien.md](06-listings-plateforme-par-bien.md)) :
> - Entité **`property_platform_listing`** : plusieurs lignes par couple **(bien, plateforme)** possibles (ex. anciennes annonces Booking + une active), chacune avec URL HTTPS et **identifiant de flux distinct** ; unicités SQL **`(organization_id, payout_identifier)`** lorsque l’identifiant est renseigné (plusieurs `NULL` tolérés), **sans** unicité `(property_id, rental_platform_id)` depuis 2026-05-20.
> - Colonne **`organization.listing_payout_extract_regex`** : motif PCRE pour extraire l’identifiant depuis le libellé importé (défaut applicatif équivalent motif `ID.` + chiffres si vide).

> **Changelog v6 (2026-05-20)** — plusieurs annonces par bien × plateforme :
> - Suppression contrainte **`UNIQUE(property_id, rental_platform_id)`** : ajout possible de plusieurs **`property_platform_listing`** pour la même plateforme sur un même bien (IDs de flux bancaires différents selon l’historique des annonces, ex. Booking).
> - Matching import par ID reste **`UNIQUE(organization_id, payout_identifier)`** pour les valeurs non nulles.

> **Changelog v5 (2026-05-25)** — rattachement des dépenses fournisseurs au bien à l'import :
> - Colonnes **`property.billing_account_electricity`**, **`billing_account_water`**, **`billing_account_internet`** : références optionnelles ; correspondance par sous-chaîne (sans tenir compte de la casse) dans le libellé et la référence bancaire côté import CSV ; revue PDF (dépenses sortantes uniquement) ; références d'au moins 5 caractères pour réduire les ambiguïtés entre biens.

> **Changelog v3 (2026-05-13)** — métier locatif courte durée :
> - Nouvelle entité **`property`** : un bien immobilier appartenant à une organisation (tenant-scoped).
> - Nouvelle entité **`rental_platform`** : référentiel **global** (cross-tenant) des plateformes de location (Booking, Airbnb, Abritel, etc.), géré par le SUPERADMIN.
> - Nouvelle entité de liaison **`organization_rental_platform`** : chaque organisation active/désactive les plateformes qu'elle utilise.
> - Ajout sur `transaction` de 2 FK optionnelles : `property_id` (le bien concerné), `rental_platform_id` (la plateforme à l'origine du flux).
>
> **Changelog v2 (2026-05-10)** — intégration des retours utilisateur :
> - Montants stockés en **BIGINT centimes** (au lieu de DECIMAL).
> - Entité `organization` étendue (entreprise, SIREN/SIRET, adresse).
> - Entité `user` étendue (date et lieu de naissance).
> - Entité `membership` étendue (`access_mode` pour la visibilité des comptes).
> - Nouvelles tables `tag`, `transaction_tag`, `membership_bank_account`.

## Conventions

- Toutes les tables ont : `id` (BIGINT auto-increment, PK), `created_at`, `updated_at` (DATETIME, UTC).
- Les tables tenant-scoped portent une colonne `organization_id` (FK, NOT NULL, indexée).
- Suppressions logiques via `deleted_at` sur les entités sensibles (Transaction, BankAccount, User).
- **Montants stockés en `BIGINT` (entiers signés, unité = la plus petite unité de la devise)** — jamais de `float`, jamais de `DECIMAL`.
  - `amount = 12345` avec `currency = EUR` → 123,45 €.
  - Une colonne sœur `currency_minor_units` (TINYINT) sur les comptes pour gérer JPY (0 décimale), BHD (3 décimales), etc. — défaut `2`.
  - Conversion en chaîne d'affichage exclusivement via le formatter Twig `{{ amount|money(currency, currency_minor_units) }}` (EUR : symbole après le montant, ex. « 123,45 € », jamais en code métier).
- IBAN : `iban_masked` (`FR76 XXXX XXXX XXXX XXXX XXXX 123`) + optionnellement `iban_encrypted` (BLOB) pour les exports.
- SIREN/SIRET : validation par algorithme de **Luhn** à la saisie (côté serveur). Pas d'appel API INSEE Sirene en MVP (envisageable en v2).
- Tous les libellés en `VARCHAR(255)` sauf description (`TEXT`).

## Diagramme conceptuel

```
┌──────────────┐         ┌──────────────────┐         ┌──────────────┐
│ Organization │◄────────┤   Membership     ├────────►│     User     │
│              │  1:N    │ (role, access_md)│  N:1    │              │
└──────┬───────┘         └────────┬─────────┘         └──────────────┘
       │ 1:N                      │ 1:N (si restricted)
       │                          ▼
       │                ┌──────────────────────┐
       │                │ MembershipBankAccount│
       │                └──────────┬───────────┘
       │                           │ N:1
       ▼                           │
┌──────────────┐  1:N    ┌─────────▼─────────┐  1:N    ┌──────────────┐
│ BankAccount  ├────────►│   Transaction     │────────►│   Category   │
│              │         │                   │  N:1    │              │
└──────┬───────┘         └──┬────────┬───────┘         └──────────────┘
       │ 1:N                │ N:M    │ N:1
       │                    │        │
       ▼                    ▼        ▼
┌──────────────┐    ┌─────────────┐   ┌──────────────┐
│  Statement   │    │ TransactionTag│ │   ImportJob  │
│  (relevé)    │    └──────┬──────┘   └──────────────┘
└──────────────┘           │ N:1
                           ▼
                     ┌───────────┐
                     │    Tag    │
                     └───────────┘
```

## Entités

### `organization`

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| name | VARCHAR(255) | Nom affiché |
| slug | VARCHAR(64) UNIQUE | URL-friendly |
| billing_email | VARCHAR(255) | |
| status | ENUM('active','suspended','archived') | |
| is_company | BOOLEAN | true = entreprise, false = particulier/famille (défaut false) |
| legal_form | VARCHAR(50) NULL | SAS, SARL, SA, SCI, EURL, SASU, EI, micro-entreprise, association, autre |
| siren | CHAR(9) NULL | **Obligatoire si `is_company=true`**. Validation Luhn |
| siret | CHAR(14) NULL | Optionnel. Validation Luhn (et cohérence : commence par le SIREN) |
| incorporation_date | DATE NULL | Date de création de l'entreprise |
| website | VARCHAR(255) NULL | URL avec protocole |
| address_line_1 | VARCHAR(255) NULL | |
| address_line_2 | VARCHAR(255) NULL | |
| postal_code | VARCHAR(20) NULL | |
| city | VARCHAR(100) NULL | |
| country | CHAR(2) NULL | ISO 3166-1 alpha-2, défaut FR |
| listing_payout_extract_regex | VARCHAR(500) NULL | Motif PCRE pour extraire l’identifiant de payout depuis les libellés CSV (voir `property_platform_listing` — spec [06](06-listings-plateforme-par-bien.md)) |
| created_at, updated_at | DATETIME | |

**Contraintes** :
- `is_company = true ⇒ siren NOT NULL`.
- Si `siret` renseigné, ses 9 premiers caractères doivent être égaux au `siren`.

### `user`

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| email | VARCHAR(255) UNIQUE | |
| password | VARCHAR(255) | hash bcrypt/argon2 |
| first_name | VARCHAR(100) | |
| last_name | VARCHAR(100) | |
| birth_date | DATE NULL | Optionnel — finalité : profil + préparation KYC futur |
| birth_place | VARCHAR(255) NULL | Ville + pays libre |
| is_superadmin | BOOLEAN | true uniquement pour SUPERADMIN |
| last_login_at | DATETIME NULL | |
| current_organization_id | BIGINT FK NULL | Org active dans la session |
| created_at, updated_at, deleted_at | DATETIME | |

**Note RGPD** : `birth_date` et `birth_place` sont des données personnelles. Finalité documentée : enrichissement profil + préparation conformité KYC pour les futurs connecteurs DSP2. Données collectées sur la base du **consentement** (champs optionnels). Pas de chiffrement au repos en v1, à reconsidérer si collecte massive ou KYC effectif.

### `membership`

Lien User ⇄ Organization avec rôle et mode d'accès. Un user peut appartenir à N organisations avec un rôle distinct.

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| user_id | BIGINT FK | |
| organization_id | BIGINT FK | |
| role | ENUM('ADMIN','MANAGER','USER','PARTENAIRE') | SUPERADMIN n'est pas ici |
| access_mode | ENUM('all_accounts','restricted') | Défaut `all_accounts`. **Forcé à `all_accounts` si role = ADMIN** |
| invited_by_user_id | BIGINT FK NULL | |
| joined_at | DATETIME | |
| status | ENUM('invited','active','revoked') | |
| UNIQUE(user_id, organization_id) | | |

**Contrainte applicative** : `role = ADMIN ⇒ access_mode = all_accounts` (un ADMIN voit toujours tous les comptes de son organisation).

### `membership_bank_account`

Table de jointure utilisée **uniquement** quand `membership.access_mode = restricted`. Liste les comptes auxquels le membre a accès.

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| membership_id | BIGINT FK | |
| bank_account_id | BIGINT FK | |
| created_at | DATETIME | |
| UNIQUE(membership_id, bank_account_id) | | |

> Si `access_mode = all_accounts`, cette table est **ignorée** pour le membership concerné. Si `restricted`, l'absence de ligne signifie « aucun compte accessible » (cas dégradé à éviter — UI doit forcer ≥ 1 compte sélectionné en mode restreint).

### `bank_account`

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| organization_id | BIGINT FK | |
| label | VARCHAR(255) | Ex. « Compte courant Camille » |
| bank_name | VARCHAR(100) | Ex. « BNP Paribas » |
| bank_code | VARCHAR(20) NULL | Code interne (BNP, SG, CA…) |
| iban_masked | VARCHAR(34) | |
| iban_encrypted | BLOB NULL | |
| currency | CHAR(3) | ISO 4217 (EUR par défaut) |
| currency_minor_units | TINYINT | Nb de décimales de la devise. Défaut 2 (EUR/USD), 0 pour JPY, 3 pour BHD |
| initial_balance | BIGINT | En centimes (ou unité mineure de la devise) |
| current_balance | BIGINT | Recalculé à chaque import. En centimes |
| account_type | ENUM('checking','savings','credit_card','other') | |
| created_at, updated_at, deleted_at | DATETIME | |

### `property`

Bien immobilier identifié, appartenant à une organisation (tenant-scoped).
Référence stable pour suivre la rentabilité d'un appartement / d'un studio /
d'un local sur la durée. Utilisé principalement pour les organisations de
gestion locative (Factory Patrimoine et similaires) mais optionnel pour les
autres usages (un particulier sans bien locatif n'a pas besoin de créer de
Property).

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| organization_id | BIGINT FK | NOT NULL, CASCADE delete |
| code | VARCHAR(50) | Court, ex. « A001 », « A101 ». UNIQUE par organisation |
| label | VARCHAR(255) | Long, ex. « Studio Bastille 25m² ». Optionnel |
| address_line_1 | VARCHAR(255) NULL | |
| address_line_2 | VARCHAR(255) NULL | |
| postal_code | VARCHAR(20) NULL | |
| city | VARCHAR(100) NULL | |
| country | CHAR(2) NULL | ISO 3166-1 alpha-2 |
| surface_m2 | SMALLINT NULL | Surface en m² |
| bedrooms | SMALLINT NULL | Nombre de chambres |
| status | ENUM('active','archived') | Défaut `active` |
| billing_account_electricity | VARCHAR(120) NULL | Sous-chaîne retrouvable dans les écritures (import) pour rattacher automatiquement le bien (**dépenses sortantes**) |
| billing_account_water | VARCHAR(120) NULL | Idem eau |
| billing_account_internet | VARCHAR(120) NULL | Idem internet / box |
| notes | TEXT NULL | Annotations libres |
| created_at, updated_at | DATETIME | |
| UNIQUE(organization_id, code) | | Code unique par organisation |

### `rental_platform`

**Entité globale, NON tenant-scoped.** Référentiel des plateformes de location
(Booking, Airbnb, Abritel, VRBO, Direct, etc.) géré exclusivement par le
SUPERADMIN. Chaque organisation peut **activer** une partie de ce référentiel
via `organization_rental_platform`.

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| name | VARCHAR(100) | Affichage public, ex. « Booking.com » |
| slug | VARCHAR(50) UNIQUE | URL-friendly, ex. `booking` |
| color | CHAR(7) NULL | Hex, pour badges (#003580 pour Booking) |
| icon | VARCHAR(50) NULL | Classe Tabler (`ti-brand-booking`) ou nom logo |
| website | VARCHAR(255) NULL | URL principale de la plateforme |
| status | ENUM('active','archived') | Défaut `active`. Les plateformes archivées ne sont plus proposées en activation aux nouvelles orgs |
| created_at, updated_at | DATETIME | |

**Seed initial** (à livrer au déploiement) :
`Booking.com`, `Airbnb`, `Abritel/VRBO`, `Direct` (location en direct, hors plateforme), `Stripe` (encaissement direct via paiement Stripe). Liste extensible par le SUPERADMIN.

### `organization_rental_platform`

Lien organisation ⇄ plateforme. Une organisation active les plateformes qu'elle
utilise effectivement. La désactivation masque la plateforme dans les listes
de l'org **sans** détruire l'historique : les transactions déjà associées
restent reliées à la `rental_platform` correspondante.

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| organization_id | BIGINT FK | NOT NULL, CASCADE delete |
| rental_platform_id | BIGINT FK | NOT NULL, CASCADE delete |
| enabled | BOOLEAN | Défaut true. Permet la désactivation sans supprimer le lien |
| notes | TEXT NULL | Notes internes à l'org (taux de commission, identifiant interne, etc.) |
| created_at, updated_at | DATETIME | |
| UNIQUE(organization_id, rental_platform_id) | | |

### `property_platform_listing`

**Tenant-scoped** via `organization_id` (redondant mais cohérent avec `property.organization_id`).
Plusieurs lignes peuvent être rattachées au **même couple (bien, plateforme)** (ex. plusieurs annonces Booking dans le temps, anciennes fermées vs annonce active) : chaque ligne porte une **URL d’annonce** et, pour le matching import, un **`payout_identifier`** distinct lorsqu’il est renseigné (spec [06-listings-plateforme-par-bien.md](06-listings-plateforme-par-bien.md)).

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| organization_id | BIGINT FK | NOT NULL, CASCADE delete ; aligné sur le bien parent |
| property_id | BIGINT FK | NOT NULL, CASCADE delete |
| rental_platform_id | BIGINT FK | NOT NULL vers `rental_platform` |
| listing_url | VARCHAR(2048) | URL annonce (HTTPS obligatoire côté validation applicative) |
| payout_identifier | VARCHAR(128) NULL | Valeur canonique après normalisation si saisie ; **plusieurs lignes peuvent avoir `NULL`** ; valeur non nulle unique **par organisation** |
| listing_label | VARCHAR(255) NULL | Libellé d’affichage humain facultatif (`label` métier spec 06) |
| notes | TEXT NULL | Mémo interne |
| created_at, updated_at | DATETIME IMMUTABLE | |
| UNIQUE(organization_id, payout_identifier) | | Deux lignes différentes ne partagent pas le même identifiant non nul dans l’organisation (SQL : valeurs NULL autorisées en plusieurs exemplaires dans l’index unique MySQL). |

### `transaction`

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| organization_id | BIGINT FK | |
| bank_account_id | BIGINT FK | |
| category_id | BIGINT FK NULL | Catégorie unique (structure principale) |
| property_id | BIGINT FK NULL | Bien concerné (loyer, charges, encaissement…). SET NULL si Property supprimé |
| rental_platform_id | BIGINT FK NULL | Plateforme à l'origine du flux (Booking, Airbnb…). SET NULL |
| statement_id | BIGINT FK NULL | Relevé d'origine |
| import_job_id | BIGINT FK NULL | Traçabilité import |
| booking_date | DATE | Date d'opération |
| value_date | DATE NULL | Date de valeur |
| amount | BIGINT | En centimes. Signé (négatif = débit) |
| currency | CHAR(3) | |
| label | VARCHAR(500) | Libellé brut |
| label_normalized | VARCHAR(500) | Libellé nettoyé pour catégorisation |
| reference | VARCHAR(100) NULL | Référence bancaire si dispo |
| transaction_type | ENUM('card','transfer','direct_debit','check','fee','interest','other') | |
| notes | TEXT NULL | Annotations utilisateur |
| hash | CHAR(64) | SHA-256 (account+date+amount+label) pour dédoublonnage |
| created_at, updated_at, deleted_at | DATETIME | |
| UNIQUE(bank_account_id, hash) | | Anti-doublon |

### `category`

Catégorie unique par transaction, structurée pour les statistiques.

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| organization_id | BIGINT FK | |
| parent_id | BIGINT FK NULL | Hiérarchie 1 niveau (réservé pour évolution future, MVP = plat) |
| name | VARCHAR(100) | |
| color | CHAR(7) | Hex |
| icon | VARCHAR(50) NULL | |
| kind | ENUM('income','expense','transfer') | |

### `tag`

Tags multiples, libres, transverses (complémentaires des catégories).

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| organization_id | BIGINT FK | |
| name | VARCHAR(50) | Sans `#`, casse libre, normalisé en minuscules pour l'unicité |
| slug | VARCHAR(50) | Normalisé (kebab-case) pour matching |
| color | CHAR(7) NULL | Hex, optionnel |
| is_default | BOOLEAN | true pour les tags livrés au seed |
| created_by_user_id | BIGINT FK NULL | NULL pour les tags par défaut |
| created_at, updated_at | DATETIME | |
| UNIQUE(organization_id, slug) | | |

**Tags par défaut au seed** : `Remboursable`, `Pro`, `Perso`, `Récurrent`, `Exceptionnel`, `À vérifier`, `Cadeau`, `Voyage`. L'utilisateur peut les utiliser ou non, et créer ses propres tags.

### `transaction_tag`

Table de jointure many-to-many entre transactions et tags.

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| transaction_id | BIGINT FK | |
| tag_id | BIGINT FK | |
| created_at | DATETIME | |
| created_by_user_id | BIGINT FK NULL | qui a posé le tag |
| UNIQUE(transaction_id, tag_id) | | |

### `import_job`

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| organization_id | BIGINT FK | |
| user_id | BIGINT FK | qui a déclenché |
| bank_account_id | BIGINT FK NULL | cible (peut être déduite) |
| source_type | ENUM('csv','pdf','manual') | |
| source_filename | VARCHAR(255) | |
| source_path | VARCHAR(500) | chemin fichier stocké |
| mapping | JSON NULL | Mapping CSV (user-defined) |
| status | ENUM('pending','running','succeeded','failed','partial') | |
| transactions_total | INT | |
| transactions_imported | INT | |
| transactions_skipped | INT | doublons |
| error_message | TEXT NULL | |
| started_at, finished_at | DATETIME NULL | |

### `statement`

Représente un relevé bancaire (PDF/CSV) importé en tant que document.

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| organization_id | BIGINT FK | |
| bank_account_id | BIGINT FK | |
| import_job_id | BIGINT FK | |
| period_start | DATE | |
| period_end | DATE | |
| opening_balance | BIGINT NULL | En centimes |
| closing_balance | BIGINT NULL | En centimes |
| file_path | VARCHAR(500) | PDF/CSV original conservé |

### `audit_log`

| Colonne | Type | Notes |
|---|---|---|
| id | BIGINT PK | |
| organization_id | BIGINT FK NULL | NULL pour actions SUPERADMIN globales |
| user_id | BIGINT FK NULL | |
| action | VARCHAR(100) | Ex. `transaction.deleted` |
| target_type | VARCHAR(50) | Ex. `Transaction` |
| target_id | BIGINT NULL | |
| payload | JSON NULL | snapshot avant/après |
| ip_address | VARCHAR(45) | |
| created_at | DATETIME | |

## Index recommandés

- `transaction (organization_id, bank_account_id, booking_date)` — requête principale du dashboard.
- `transaction (organization_id, category_id, booking_date)` — analyses par catégorie.
- `transaction (organization_id, property_id, booking_date)` — analyses par bien.
- `transaction (organization_id, rental_platform_id, booking_date)` — analyses par plateforme.
- `transaction (label_normalized)` FULLTEXT — recherche libellé.
- `transaction_tag (tag_id, transaction_id)` — listing par tag.
- `tag (organization_id, slug)` UNIQUE — anti-doublon de tags.
- `property (organization_id, code)` UNIQUE — anti-doublon de code bien par org.
- `rental_platform (slug)` UNIQUE — anti-doublon de plateforme globale.
- `organization_rental_platform (organization_id, rental_platform_id)` UNIQUE — anti-doublon d'activation.
- `membership (user_id, organization_id)` UNIQUE — lookup session.
- `membership_bank_account (membership_id)` — lookup des comptes accessibles d'un membre.
- `organization (siren)` — recherche par SIREN (admin).
- `audit_log (organization_id, created_at)`.

## Stratégie de migrations

- Toutes les modifications de schéma passent par Doctrine Migrations.
- Migrations versionnées dans `migrations/`.
- Aucune modification manuelle en prod ; reproductibilité garantie via `doctrine:migrations:migrate`.

## Données de référence (fixtures)

Au seed initial :
- 1 organisation `viizia-demo` (`is_company = false`)
- 1 SUPERADMIN (`admin@viizia.local`)
- 1 ADMIN demo (`access_mode = all_accounts`)
- Catégories par défaut : Alimentation, Logement, Transport, Salaire, Charges, Loisirs, Santé, Virement interne…
- Tags par défaut : `Remboursable`, `Pro`, `Perso`, `Récurrent`, `Exceptionnel`, `À vérifier`, `Cadeau`, `Voyage`.
- Plateformes de location par défaut (globales) : `Booking.com`, `Airbnb`, `Abritel/VRBO`, `Direct`, `Stripe`. Étendables par le SUPERADMIN sans migration.

## Changelog

- **v3 — 2026-05-13** : métier locatif. Ajout des entités `property` (bien immobilier tenant-scoped), `rental_platform` (référentiel global cross-tenant, géré SUPERADMIN), `organization_rental_platform` (activation par org). Ajout des FK optionnelles `property_id` et `rental_platform_id` sur `transaction`. Mise à jour des indexes et des fixtures par défaut.
- **v2 — 2026-05-10** : montants en BIGINT centimes ; ajout SIREN/SIRET/adresse sur organization ; ajout naissance sur user ; ajout `access_mode` sur membership + table `membership_bank_account` ; ajout système de tags (`tag` + `transaction_tag`).
- **v1 — 2026-05-08** : création initiale.
