Aller au contenu

Exercice : comptabilité personnelle — version allégée

💶 Présentation

Version simplifiée de l'exercice de comptabilité personnelle : un seul compte, une seule table, mais avec le pointage des transactions — la notion la plus utile à comprendre pour un usage réel avec sa banque.

Objectifs pédagogiques

  1. CRUD complet sur une seule table (Créer / Lire / Modifier / Supprimer)
  2. Comprendre la différence entre deux soldes :
    • le solde théorique : tout ce qui est enregistré
    • le solde pointé : uniquement ce que la banque a confirmé sur le relevé
  3. Retrouver le motif POST → redirection → GET déjà vu dans le carnet de contacts

Pourquoi cette version allégée ?

La version complète ajoute les cartes bancaires, la validation mensuelle et les transactions récurrentes — donc plusieurs tables reliées entre elles et une navigation par onglets en session PHP. Cette version-ci se concentre sur un seul concept nouveau à la fois : le pointage. Le multi-comptes et les cartes pourront venir dans un second temps, une fois celle-ci bien assimilée.

🗂️ Fichiers à créer

Fichier Rôle
config.php Connexion PDO à la base de données
index.php Application complète (formulaire, tableau, pointage)
schema_compta.sql Script SQL de création de la base et des données de démonstration

⚙️ Prérequis

Environnement nécessaire

  • PHP 7.4+ avec l'extension pdo_mysql activée
  • MariaDB 10.3+ (ou MySQL 5.7+)
  • Apache, ou simplement le serveur PHP intégré (php -S localhost:8080)

🚀 Installation pas à pas

1. Créer la base de données

mysql -u root -p < schema_compta.sql

2. Configurer la connexion et le solde de départ

Dans config.php, ajuster les identifiants et le solde initial du compte :

define('DB_USER', 'root');
define('DB_PASS', '');
define('SOLDE_INITIAL', 1000.00); // à adapter à votre situation

3. Lancer l'application

php -S localhost:8080

Puis ouvrir http://localhost:8080/index.php.


🧩 Fonctionnalités

Fonctionnalité Méthode Détail
Ajouter une transaction POST Libellé, montant, type (revenu/dépense), date
Lister les transactions GET Triées par date décroissante
Pointer / dépointer GET Simple lien qui bascule l'état (NOT pointe), la ligne passe en vert
Supprimer une transaction GET Avec confirmation JavaScript (confirm())
Calcul des deux soldes Recalculés à chaque affichage à partir des données en base

💡 Le point clé : pourquoi deux soldes ?

Comme avec un relevé papier

Quand on tient ses comptes « à la main », on note une dépense dès qu'on la fait — mais elle ne débite le compte en banque que quelques jours plus tard. Le solde théorique correspond à son propre carnet ; le solde pointé correspond à ce que la banque a réellement traité, vérifié ligne à ligne sur le relevé. Les deux soldes ne sont identiques que lorsque tout a été pointé.

Dans le code, cela se traduit simplement par une colonne pointe (0 ou 1) et deux requêtes SQL quasi identiques : la seconde ajoute juste WHERE pointe = 1. C'est une bonne occasion de montrer aux élèves comment une même logique de calcul peut être filtrée différemment selon le besoin.


🔐 Sécurité

  • Anti-injection SQL : toutes les requêtes utilisent des requêtes préparées PDO
  • Anti-XSS : toutes les sorties HTML passent par htmlspecialchars()
  • Motif PRG : les formulaires POST redirigent vers GET pour éviter la re-soumission
  • Cast entier : les identifiants provenant de l'URL sont castés en (int)
  • Validation côté serveur : is_numeric() sur le montant, in_array() sur le type (liste blanche)

🗄️ Structure de la table transactions

CREATE TABLE transactions (
    id               INT AUTO_INCREMENT PRIMARY KEY,
    libelle          VARCHAR(150)   NOT NULL,
    montant          DECIMAL(10,2)  NOT NULL,
    type             ENUM('revenu', 'depense') NOT NULL,
    date_transaction DATE           NOT NULL,
    pointe           TINYINT(1)     NOT NULL DEFAULT 0,
    created_at       TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

🔧 Pistes d'extension (vers la version complète)

  • Passer le solde initial d'une constante à une table comptes — première étape vers le multi-comptes
  • Ajouter une catégorie (courses, loyer, loisirs…) à chaque transaction
  • Filtrer l'affichage par mois
  • Ajouter un export CSV des transactions pointées
  • (Plus avancé) Passer à plusieurs comptes, puis aux cartes bancaires — c'est alors la version complète de cet exercice

💻 Code source complet

Copiez chacun des blocs ci-dessous dans un fichier du même nom.

<?php
/**
 * ============================================================
 *  config.php — Connexion à la base de données
 * ============================================================
 *
 *  Version « allégée » : un seul compte, pas de table dédiée
 *  pour le représenter. Le solde de départ est simplement une
 *  constante ci-dessous, à adapter au montant réel de départ.
 * ============================================================
 */

// --- Paramètres de connexion -----------------------------------
define('DB_HOST', 'localhost');
define('DB_NAME', 'comptabilite_perso_lite');
define('DB_USER', 'root');        // ⚠️ à remplacer par un utilisateur dédié
define('DB_PASS', '');            // ⚠️ mot de passe MariaDB

// --- Solde de départ du compte (avant la première transaction) --
define('SOLDE_INITIAL', 1000.00); // à adapter à votre situation

function getConnection(): PDO
{
    $dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4";

    $options = [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ];

    return new PDO($dsn, DB_USER, DB_PASS, $options);
}
<?php
/**
 * ============================================================
 *  index.php — Comptabilité personnelle (version allégée)
 * ============================================================
 *
 *  Objectifs pédagogiques :
 *   1. Un seul compte, une seule table : comprendre le CRUD
 *      (Créer / Lire / Modifier / Supprimer) sans la complexité
 *      des relations entre tables.
 *   2. Comprendre POURQUOI on distingue deux soldes :
 *        - le solde THÉORIQUE (tout ce qui est enregistré)
 *        - le solde POINTÉ (uniquement ce que la banque a
 *          confirmé sur le relevé)
 *      C'est exactement ce qu'on fait avec un relevé papier et
 *      un stylo, ou une étoile dans une case Excel.
 * ============================================================
 */

require 'config.php';

$message = '';
$erreur  = false;

try {
    $pdo = getConnection();

    $action = $_GET['action'] ?? 'liste';

    // ── ACTION : AJOUTER UNE TRANSACTION ────────────────────
    if ($action === 'ajouter' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        $libelle = trim($_POST['libelle'] ?? '');
        $montant = trim($_POST['montant'] ?? '');
        $type    = $_POST['type'] ?? '';
        $date    = $_POST['date_transaction'] ?? '';

        // is_numeric() vérifie que le montant saisi est bien un nombre
        if ($libelle && is_numeric($montant) && in_array($type, ['revenu', 'depense'], true) && $date) {
            $stmt = $pdo->prepare(
                'INSERT INTO transactions (libelle, montant, type, date_transaction)
                 VALUES (:libelle, :montant, :type, :date)'
            );
            $stmt->execute([
                ':libelle' => $libelle,
                ':montant' => (float) $montant,
                ':type'    => $type,
                ':date'    => $date,
            ]);
            header('Location: ' . $_SERVER['PHP_SELF'] . '?message=ajout_ok');
            exit;
        } else {
            $message = 'Merci de remplir tous les champs (montant valide, type, date).';
            $erreur  = true;
        }

    // ── ACTION : POINTER / DÉPOINTER (bascule) ──────────────
    // Un simple lien GET suffit : pas besoin de JavaScript pour
    // comprendre le principe. On pourra ajouter l'AJAX plus tard.
    } elseif ($action === 'pointer' && isset($_GET['id'])) {
        $id = (int) $_GET['id'];
        $stmt = $pdo->prepare('UPDATE transactions SET pointe = NOT pointe WHERE id = :id');
        $stmt->execute([':id' => $id]);
        header('Location: ' . $_SERVER['PHP_SELF']);
        exit;

    // ── ACTION : SUPPRIMER ───────────────────────────────────
    } elseif ($action === 'supprimer' && isset($_GET['id'])) {
        $id = (int) $_GET['id'];
        $stmt = $pdo->prepare('DELETE FROM transactions WHERE id = :id');
        $stmt->execute([':id' => $id]);
        header('Location: ' . $_SERVER['PHP_SELF'] . '?message=suppr_ok');
        exit;
    }

    if (isset($_GET['message'])) {
        $messages = [
            'ajout_ok' => '✅ Transaction ajoutée.',
            'suppr_ok' => '🗑️ Transaction supprimée.',
        ];
        $message = $messages[$_GET['message']] ?? '';
    }

    // ── CALCUL DES DEUX SOLDES ───────────────────────────────
    // Solde théorique : toutes les transactions enregistrées.
    $stmt = $pdo->query(
        "SELECT
            SUM(CASE WHEN type = 'revenu'  THEN montant ELSE 0 END) AS total_revenus,
            SUM(CASE WHEN type = 'depense' THEN montant ELSE 0 END) AS total_depenses
         FROM transactions"
    );
    $tousMouvements = $stmt->fetch();
    $soldeTheorique = SOLDE_INITIAL
        + (float) ($tousMouvements['total_revenus']  ?? 0)
        - (float) ($tousMouvements['total_depenses'] ?? 0);

    // Solde pointé : uniquement les transactions confirmées par la banque.
    $stmt = $pdo->query(
        "SELECT
            SUM(CASE WHEN type = 'revenu'  THEN montant ELSE 0 END) AS total_revenus,
            SUM(CASE WHEN type = 'depense' THEN montant ELSE 0 END) AS total_depenses
         FROM transactions
         WHERE pointe = 1"
    );
    $mouvementsPointes = $stmt->fetch();
    $soldePointe = SOLDE_INITIAL
        + (float) ($mouvementsPointes['total_revenus']  ?? 0)
        - (float) ($mouvementsPointes['total_depenses'] ?? 0);

    // Liste de toutes les transactions, les plus récentes en premier
    $transactions = $pdo->query(
        'SELECT * FROM transactions ORDER BY date_transaction DESC, id DESC'
    )->fetchAll();

} catch (PDOException $e) {
    $message = 'Erreur de base de données : ' . $e->getMessage();
    $erreur  = true;
    $transactions = [];
    $soldeTheorique = $soldePointe = 0;
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Comptabilité personnelle — version allégée</title>
    <style>
        :root {
            --vert:   #2c6e49;
            --rouge:  #c0392b;
            --bg-vert: #eaf5ef;
            --bg-gris: #f4f4f4;
            --bordure: #ddd;
        }
        * { box-sizing: border-box; }
        body {
            font-family: "Segoe UI", Arial, sans-serif;
            max-width: 850px;
            margin: 2rem auto;
            padding: 0 1rem;
            color: #222;
        }
        h1 { font-size: 1.5rem; margin-bottom: .3rem; }
        .sous-titre { color: #666; margin-bottom: 1.5rem; font-size: .9rem; }

        .alert {
            padding: .7rem 1rem;
            border-radius: 6px;
            margin-bottom: 1.2rem;
            font-size: .9rem;
            border-left: 4px solid;
        }
        .alert-ok     { background: var(--bg-vert); border-color: var(--vert);  color: var(--vert); }
        .alert-erreur { background: #fbe9e7; border-color: var(--rouge); color: var(--rouge); }

        /* ── Soldes ── */
        .soldes {
            display: flex;
            gap: 1rem;
            margin-bottom: 1.5rem;
            flex-wrap: wrap;
        }
        .solde-carte {
            flex: 1;
            min-width: 220px;
            background: white;
            border: 1px solid var(--bordure);
            border-radius: 8px;
            padding: 1rem 1.2rem;
        }
        .solde-carte h2 {
            font-size: .8rem;
            text-transform: uppercase;
            letter-spacing: .5px;
            color: #888;
            margin-bottom: .3rem;
        }
        .solde-carte .montant {
            font-size: 1.6rem;
            font-weight: 700;
        }
        .solde-theorique .montant { color: #444; }
        .solde-pointe .montant    { color: var(--vert); }
        .solde-carte p.aide { font-size: .78rem; color: #999; margin-top: .3rem; }

        /* ── Formulaire ── */
        .formulaire {
            display: flex;
            flex-wrap: wrap;
            gap: .7rem 1rem;
            align-items: end;
            background: white;
            border: 1px solid var(--bordure);
            border-radius: 8px;
            padding: 1rem;
            margin-bottom: 1.5rem;
        }
        .champ { display: flex; flex-direction: column; gap: .3rem; }
        .champ label { font-size: .8rem; font-weight: 600; }
        .champ input, .champ select {
            padding: .4rem .5rem;
            border: 1px solid #ccc;
            border-radius: 4px;
            font-size: .9rem;
        }
        .formulaire button {
            background: var(--vert);
            color: white;
            border: none;
            padding: .5rem 1.1rem;
            border-radius: 4px;
            cursor: pointer;
            font-size: .9rem;
        }
        .formulaire button:hover { background: #1f4f34; }

        /* ── Tableau ── */
        table {
            width: 100%;
            border-collapse: collapse;
            background: white;
            box-shadow: 0 1px 3px rgba(0,0,0,.08);
        }
        th, td {
            border: 1px solid var(--bordure);
            padding: .5rem .7rem;
            text-align: left;
            font-size: .9rem;
        }
        thead th { background: var(--bg-gris); }
        tr.pointee { background: var(--bg-vert); }
        .montant-revenu  { color: var(--vert); font-weight: 600; }
        .montant-depense { color: var(--rouge); font-weight: 600; }
        .col-centre { text-align: center; }
        .lien-action {
            font-size: .85rem;
            text-decoration: none;
            color: #666;
        }
        .lien-action:hover { text-decoration: underline; }
        .lien-supprimer { color: var(--rouge); }
    </style>
</head>
<body>

<h1>💶 Comptabilité personnelle</h1>
<p class="sous-titre">Version allégée — un seul compte, avec pointage des transactions</p>

<?php if ($message): ?>
    <div class="alert <?= $erreur ? 'alert-erreur' : 'alert-ok' ?>">
        <?= htmlspecialchars($message) ?>
    </div>
<?php endif; ?>

<!-- ============================================================
     LES DEUX SOLDES : c'est le cœur pédagogique de l'exercice
     ============================================================ -->
<div class="soldes">
    <div class="solde-carte solde-theorique">
        <h2>Solde théorique</h2>
        <div class="montant"><?= number_format($soldeTheorique, 2, ',', ' ') ?> €</div>
        <p class="aide">Toutes les transactions enregistrées, pointées ou non.</p>
    </div>
    <div class="solde-carte solde-pointe">
        <h2>Solde pointé</h2>
        <div class="montant"><?= number_format($soldePointe, 2, ',', ' ') ?> €</div>
        <p class="aide">Uniquement ce que la banque a confirmé sur le relevé.</p>
    </div>
</div>

<!-- ============================================================
     FORMULAIRE D'AJOUT
     ============================================================ -->
<form method="post" action="?action=ajouter" class="formulaire">
    <div class="champ">
        <label for="libelle">Libellé</label>
        <input type="text" id="libelle" name="libelle" placeholder="ex : Courses" required>
    </div>
    <div class="champ">
        <label for="montant">Montant (€)</label>
        <input type="number" id="montant" name="montant" step="0.01" min="0" required>
    </div>
    <div class="champ">
        <label for="type">Type</label>
        <select id="type" name="type" required>
            <option value="depense">Dépense</option>
            <option value="revenu">Revenu</option>
        </select>
    </div>
    <div class="champ">
        <label for="date_transaction">Date</label>
        <input type="date" id="date_transaction" name="date_transaction"
               value="<?= date('Y-m-d') ?>" required>
    </div>
    <button type="submit">Ajouter</button>
</form>

<!-- ============================================================
     TABLEAU DES TRANSACTIONS
     ============================================================ -->
<table>
    <thead>
        <tr>
            <th>Date</th>
            <th>Libellé</th>
            <th>Montant</th>
            <th class="col-centre">Pointé</th>
            <th class="col-centre">Action</th>
        </tr>
    </thead>
    <tbody>
        <?php if (empty($transactions)): ?>
            <tr>
                <td colspan="5" class="col-centre">Aucune transaction pour le moment.</td>
            </tr>
        <?php else: ?>
            <?php foreach ($transactions as $t): ?>
                <tr class="<?= $t['pointe'] ? 'pointee' : '' ?>">
                    <td><?= htmlspecialchars($t['date_transaction']) ?></td>
                    <td><?= htmlspecialchars($t['libelle']) ?></td>
                    <td class="<?= $t['type'] === 'revenu' ? 'montant-revenu' : 'montant-depense' ?>">
                        <?= $t['type'] === 'revenu' ? '+' : '−' ?><?= number_format($t['montant'], 2, ',', ' ') ?>
                    </td>
                    <td class="col-centre">
                        <a class="lien-action" href="?action=pointer&id=<?= (int) $t['id'] ?>">
                            <?= $t['pointe'] ? '☑️ pointée' : '☐ pointer' ?>
                        </a>
                    </td>
                    <td class="col-centre">
                        <a class="lien-action lien-supprimer"
                           href="?action=supprimer&id=<?= (int) $t['id'] ?>"
                           onclick="return confirm('Supprimer cette transaction ?');">
                            supprimer
                        </a>
                    </td>
                </tr>
            <?php endforeach; ?>
        <?php endif; ?>
    </tbody>
</table>

</body>
</html>
-- ============================================================
--  schema.sql — Comptabilité personnelle (version allégée)
--  Un seul compte, une seule table.
--
--  À exécuter une seule fois :
--    mysql -u root -p < schema_compta.sql
-- ============================================================

CREATE DATABASE IF NOT EXISTS comptabilite_perso_lite
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;

USE comptabilite_perso_lite;

-- Une seule table suffit : c'est volontaire. Le multi-comptes,
-- les cartes et la validation mensuelle viendront plus tard,
-- une fois ce modèle simple bien compris.
CREATE TABLE IF NOT EXISTS transactions (
    id               INT AUTO_INCREMENT PRIMARY KEY,
    libelle          VARCHAR(150)   NOT NULL,
    montant          DECIMAL(10,2)  NOT NULL,
    type             ENUM('revenu', 'depense') NOT NULL,
    date_transaction DATE           NOT NULL,

    -- pointe = la transaction a été vérifiée sur le relevé bancaire.
    -- 0 = pas encore pointée, 1 = pointée.
    pointe           TINYINT(1)     NOT NULL DEFAULT 0,

    created_at       TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

-- Quelques mouvements de démonstration
INSERT INTO transactions (libelle, montant, type, date_transaction, pointe) VALUES
    ('Salaire',            1800.00, 'revenu',  '2026-09-01', 1),
    ('Loyer',               650.00, 'depense', '2026-09-02', 1),
    ('Courses Carrefour',    84.30, 'depense', '2026-09-05', 1),
    ('Abonnement internet',  39.90, 'depense', '2026-09-05', 0),
    ('Remboursement CAF',   120.00, 'revenu',  '2026-09-10', 0),
    ('Restaurant',           45.60, 'depense', '2026-09-12', 0);