From b75ec9ca7c644da26145ab3b788481b756e37fe4 Mon Sep 17 00:00:00 2001 From: Alassane Ndoye Date: Mon, 7 Sep 2026 14:08:14 +0000 Subject: [PATCH] Add multi-account SIM support and v2 migration This change introduces account-based multi-SIM/mobile-wallet tracking with unique names per provider, opening allocation validation, and archive/reactivate flows. It migrates the SQLite schema to v2 with audited, backup-verified migration steps, preserves historical snapshots, and updates the dashboard, API, and exports for per-account balances, journal entries, debts, and reports. The frontend also adds tools to manage accounts and SIMs, and to record inventory/debt activity against specific active accounts. --- README.md | 28 +- src-tauri/src/account_tests.rs | 439 +++++++++++++++++++++++ src-tauri/src/accounts.rs | 318 ++++++++++++++++ src-tauri/src/backup.rs | 210 ++++++++++- src-tauri/src/commands.rs | 58 ++- src-tauri/src/db.rs | 327 ++++++++++++----- src-tauri/src/domain.rs | 4 +- src-tauri/src/export.rs | 345 +++++++++++++++++- src-tauri/src/lib.rs | 7 + src-tauri/src/migration_v2.sql | 121 +++++++ src-tauri/src/models.rs | 115 +++++- src-tauri/src/security.rs | 5 + src-tauri/src/state.rs | 30 +- src-tauri/src/test_support.rs | 151 ++++++++ src/api.ts | 20 +- src/components/AccountSelect.tsx | 38 ++ src/components/OpeningAccountsEditor.tsx | 31 ++ src/screens/AccountsSettings.tsx | 63 ++++ src/screens/AuthScreens.tsx | 59 +-- src/screens/DashboardPage.tsx | 13 +- src/screens/DebtsPage.tsx | 19 +- src/screens/InventoryPages.tsx | 53 ++- src/screens/JournalPage.tsx | 7 +- src/screens/SettingsPage.tsx | 11 +- src/styles.css | 23 ++ src/test/accounts.test.tsx | 141 ++++++++ src/types.ts | 48 ++- 27 files changed, 2468 insertions(+), 216 deletions(-) create mode 100644 src-tauri/src/account_tests.rs create mode 100644 src-tauri/src/accounts.rs create mode 100644 src-tauri/src/migration_v2.sql create mode 100644 src-tauri/src/test_support.rs create mode 100644 src/components/AccountSelect.tsx create mode 100644 src/components/OpeningAccountsEditor.tsx create mode 100644 src/screens/AccountsSettings.tsx create mode 100644 src/test/accounts.test.tsx diff --git a/README.md b/README.md index 0a5088d..2662e3f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Application desktop locale de gestion d’un multiservices au Sénégal : invent - inventaires périodiques, comparaison par compte et justification des écarts ; - capital réel incluant les créances non soldées ; - recettes, commissions, apports, achats, dépenses et retraits de capital ; -- dettes Orange Money/Wave avec échéances et remboursements partiels ; +- plusieurs comptes/SIM Orange Money, Wave et Djamo, avec une caisse espèces unique ; +- dettes Orange Money/Wave/Djamo avec échéances et remboursements partiels sur le compte choisi ; - contre-écritures et journal d’audit immuable ; - rapports PDF, Excel et CSV ; - base SQLite chiffrée par SQLCipher ; @@ -20,12 +21,35 @@ Application desktop locale de gestion d’un multiservices au Sénégal : invent - `src/` : interface React 19 + TypeScript + Vite ; - `src-tauri/src/domain.rs` : validations et règles comptables ; - `src-tauri/src/db.rs` : schéma SQLite et transactions métier ; +- `src-tauri/src/accounts.rs` : comptes, validations, soldes et variations ; +- `src-tauri/src/migration_v2.sql` : migration transactionnelle multi-comptes ; - `src-tauri/src/security.rs` : enveloppes de clés et chiffrement ; - `src-tauri/src/backup.rs` : sauvegarde, rétention, contrôle et restauration ; - `src-tauri/src/export.rs` : exports PDF, XLSX et CSV. Toutes les écritures transitent par des commandes Tauri typées et sont validées en Rust. Le frontend n’accède jamais directement à la base. +## Comptes et SIM + +À l’ouverture, un compte par service est proposé. Ajoutez les SIM nécessaires et répartissez le capital entre elles et la caisse. La somme doit être exactement égale au capital initial ; les montants sont des FCFA entiers positifs ou nuls. + +Ensuite, **Paramètres → Comptes et SIM** permet de créer, renommer, archiver et réactiver les comptes. Chaque compte a un nom obligatoire et un identifiant facultatif. Le nom est unique dans son service, sans distinction de casse, y compris parmi les comptes archivés. Le service et l’ID permanent ne changent jamais. + +- Un compte ajouté ne modifie aucun capital. Il affiche « Pas encore relevé » jusqu’au prochain inventaire, où son solde devient obligatoire. +- Un transfert entre deux SIM change seulement la répartition. Ne l’enregistrez pas comme une recette. Un apport réel de capital doit en revanche être inscrit au journal. +- Les inventaires demandent un solde pour chaque compte actif et affichent les sous-totaux par service. La variation du premier relevé d’un compte est indisponible, et non supposée nulle. +- Pour archiver un compte utilisé, son dernier solde doit être nul et aucune opération ne doit avoir été enregistrée sur ce compte depuis. Un compte jamais utilisé peut être archivé directement. La caisse ne peut pas être archivée ni dupliquée. +- Les anciennes dettes d’un compte archivé peuvent être remboursées sur une SIM active ou en espèces. Les contre-écritures gardent le compte et le libellé de l’écriture originale, même après archivage ou renommage. +- L’historique et les exports conservent les noms et identifiants au moment des opérations. Dans les exports CSV, les lignes `solde_compte` détaillent les lignes `inventaire` : ne les additionnez pas ensemble. Dans Excel, la synthèse et les soldes détaillés sont dans des feuilles distinctes. + +## Mise à niveau des données et sauvegardes + +Le schéma SQLite passe de 1 à 2 au prochain déverrouillage. Avant tout changement, l’application crée et vérifie une sauvegarde chiffrée `avant-migration-v1-….msbackup` dans son dossier de sauvegardes. Si cette étape échoue, la migration s’arrête et les données restent en version 1. + +La migration crée les comptes Orange Money — Principal, Wave — Principal, Djamo — Principal et Espèces. Les montants, dates, références, corrections et écarts restent inchangés. Les anciens soldes sont explicitement marqués « historique regroupé par service » : aucune répartition ancienne entre SIM n’est inventée. La migration est atomique, auditée et ne s’exécute qu’une fois. + +Les sauvegardes version 2 contiennent tous les comptes, y compris archivés, leurs relevés et leurs libellés historiques. Une sauvegarde version 1 est déchiffrée, contrôlée puis migrée dans une copie temporaire avant de remplacer les données actives. Un mot de passe incorrect ou une migration invalide interrompt la restauration. Ne rouvrez pas une base version 2 avec l’ancienne application ; conservez une copie externe de la sauvegarde préalable. + ## Développement Prérequis : Node.js 20+, Rust stable et les [prérequis Tauri 2](https://v2.tauri.app/start/prerequisites/). @@ -92,7 +116,7 @@ Au premier démarrage, le gérant choisit : Le mot de passe de récupération doit être conservé hors du PC. Il est indispensable pour restaurer une sauvegarde sur un autre ordinateur. Kër Finance ne possède aucun serveur capable de le récupérer. -## Limites de la V1 +## Limites actuelles - un seul PC, une seule boutique et un seul profil gérant ; - pas de synchronisation cloud ; diff --git a/src-tauri/src/account_tests.rs b/src-tauri/src/account_tests.rs new file mode 100644 index 0000000..641da51 --- /dev/null +++ b/src-tauri/src/account_tests.rs @@ -0,0 +1,439 @@ +use super::{test_support::*, *}; + +#[test] +fn tauri_payloads_use_stable_account_ids_and_reject_fractional_or_null_balances() { + let db = multi_database(); + let dashboard = serde_json::to_value(get_dashboard(&db).unwrap()).unwrap(); + let account = &dashboard["accounts"][0]; + assert!(account["accountId"].is_string()); + assert!(account["lastBalance"].is_i64()); + assert!(account.get("snapshot").is_none()); + let detail = &dashboard["lastInventory"]["accountBalances"][0]; + assert!(detail["accountId"].is_string()); + assert!(detail["previousAmount"].is_null()); + assert!(detail["delta"].is_null()); + assert_eq!( + dashboard["lastInventory"]["balances"]["orangeMoney"], + 1_500_000 + ); + let value = serde_json::json!({"balances":[{"accountId":"sim-id","amount":1234}]}); + let parsed: InventoryPreviewInput = serde_json::from_value(value).unwrap(); + assert_eq!(parsed.balances[0].account_id, "sim-id"); + for amount in [ + serde_json::json!(1.5), + serde_json::Value::Null, + serde_json::json!("10"), + ] { + assert!(serde_json::from_value::( + serde_json::json!({"balances":[{"accountId":"sim-id","amount":amount}]}) + ) + .is_err()); + } +} + +#[test] +fn multiple_sims_opening_and_reallocation_preserve_capital() { + let mut db = multi_database(); + let opening = last_inventory(&db).unwrap(); + assert_eq!(opening.account_balances.len(), 6); + assert_eq!(opening.balances.orange_money, 1_500_000); + assert_eq!(opening.balances.wave, 1_200_000); + assert_eq!(opening.actual_total, 5_000_000); + assert!(opening.account_balances.iter().all(|b| b.delta.is_none())); + let om1 = id(&db, "Orange 1"); + let om2 = id(&db, "Orange 2"); + let mut inputs = balances(&db); + inputs + .iter_mut() + .find(|b| b.account_id == om1) + .unwrap() + .amount -= 200_000; + inputs + .iter_mut() + .find(|b| b.account_id == om2) + .unwrap() + .amount += 200_000; + let next = close(&mut db, inputs); + assert_eq!(next.actual_total, 5_000_000); + assert_eq!(next.variance, 0); + assert_eq!(next.balances.orange_money, 1_500_000); + assert_eq!( + next.account_balances + .iter() + .find(|b| b.account.account_id == om1) + .unwrap() + .delta, + Some(-200_000) + ); +} + +#[test] +fn opening_rejects_invalid_capital_names_and_extra_cash() { + let mut input = multi_setup(); + input.accounts[0].amount -= 1; + assert!(accounts::validate_opening(&input).is_err()); + input.accounts[0].amount = -1; + assert!(accounts::validate_opening(&input).is_err()); + let mut input = multi_setup(); + input.accounts[1].name = " orange 1 ".into(); + assert!(accounts::validate_opening(&input).is_err()); + let mut input = multi_setup(); + input.accounts[1].provider = "cash".into(); + assert!(accounts::validate_opening(&input).is_err()); + let mut input = multi_setup(); + input.accounts[0].name = " ".into(); + assert!(accounts::validate_opening(&input).is_err()); +} + +#[test] +fn inventory_rejects_missing_duplicate_negative_unknown_and_archived_accounts() { + let mut db = multi_database(); + let valid = balances(&db); + let mut missing = valid.clone(); + missing.pop(); + let mut duplicate = valid.clone(); + duplicate[1] = duplicate[0].clone(); + let mut negative = valid.clone(); + negative[0].amount = -1; + let mut unknown = valid.clone(); + unknown[0].account_id = "missing".into(); + let mut too_large = valid.clone(); + too_large[0].amount = 9_007_199_254_740_992; + for inputs in [missing, duplicate, negative, unknown, too_large] { + assert!(preview_inventory( + &db, + InventoryPreviewInput { + balances: inputs.clone() + } + ) + .is_err()); + assert!(close_inventory( + &mut db, + CloseInventoryInput { + balances: inputs, + variance_category: None, + variance_note: None + } + ) + .is_err()); + } + assert_eq!(list_inventories(&db, None).unwrap().len(), 1); + let extra = accounts::create( + &mut db, + CreateAccountInput { + provider: "wave".into(), + name: "Réserve".into(), + identifier: None, + }, + ) + .unwrap(); + assert_eq!(extra.last_balance, None); + let pending = balances(&db); + let preview = preview_inventory( + &db, + InventoryPreviewInput { + balances: pending.clone(), + }, + ) + .unwrap(); + assert!(preview + .account_balances + .iter() + .find(|b| b.account.account_id == extra.snapshot.account_id) + .unwrap() + .delta + .is_none()); + accounts::set_active(&mut db, &extra.snapshot.account_id, false).unwrap(); + // Valid at preview time, stale at closure: active account set must be rechecked. + assert!(close_inventory( + &mut db, + CloseInventoryInput { + balances: pending, + variance_category: None, + variance_note: None + } + ) + .is_err()); + let mut archived = valid; + archived[0].account_id = extra.snapshot.account_id; + assert!(accounts::preview(&db, &archived).is_err()); +} + +#[test] +fn accounts_require_unique_names_and_keep_historical_snapshots() { + let mut db = multi_database(); + let account_id = id(&db, "Orange 1"); + let original = journal(&mut db, &account_id); + let capital = get_dashboard(&db).unwrap().expected_capital; + let renamed = accounts::update( + &mut db, + UpdateAccountInput { + account_id: account_id.clone(), + name: "Boutique façade".into(), + identifier: Some("771112233".into()), + }, + ) + .unwrap(); + assert_eq!(renamed.snapshot.account_id, account_id); + assert_eq!(get_dashboard(&db).unwrap().expected_capital, capital); + let original_again = list_journal_entries(&db, None).unwrap().remove(0); + assert_eq!( + original_again.account_snapshot.name, + original.account_snapshot.name + ); + assert_eq!( + original_again.account_snapshot.identifier, + original.account_snapshot.identifier + ); + assert!(last_inventory(&db) + .unwrap() + .account_balances + .iter() + .any(|b| b.account.name == "Orange 1")); + assert!(accounts::create( + &mut db, + CreateAccountInput { + provider: "orange_money".into(), + name: " boutique FAÇADE ".into(), + identifier: None + } + ) + .is_err()); + let new = accounts::create( + &mut db, + CreateAccountInput { + provider: "wave".into(), + name: "Boutique façade".into(), + identifier: None, + }, + ) + .unwrap(); + assert!(new.last_balance.is_none()); + assert_eq!(get_dashboard(&db).unwrap().expected_capital, capital); + assert!(db + .execute( + "UPDATE accounts SET provider='wave' WHERE id=?1", + [&account_id] + ) + .is_err()); + assert!(db + .execute("DELETE FROM accounts WHERE id=?1", [&account_id]) + .is_err()); + assert!(accounts::create( + &mut db, + CreateAccountInput { + provider: "cash".into(), + name: "Caisse 2".into(), + identifier: None + } + ) + .is_err()); +} + +#[test] +fn archive_requires_zero_and_no_later_operations_reversals_keep_original_account() { + let mut db = multi_database(); + let om = id(&db, "Orange 1"); + let cash = id(&db, "Espèces"); + assert!(accounts::set_active(&mut db, &cash, false).is_err()); + assert!(accounts::set_active(&mut db, &om, false).is_err()); + let mut inputs = balances(&db); + inputs + .iter_mut() + .find(|b| b.account_id == om) + .unwrap() + .amount = 0; + close(&mut db, inputs); + let entry = journal(&mut db, &om); + assert!(accounts::set_active(&mut db, &om, false).is_err()); + let inputs = balances(&db); + close(&mut db, inputs); + accounts::set_active(&mut db, &om, false).unwrap(); + assert!(accounts::get(&db, &om, true).is_err()); + assert!(create_journal_entry( + &mut db, + CreateJournalEntryInput { + entry_type: "sale".into(), + amount: 1, + account_id: om.clone(), + occurred_at: "2026-09-01".into(), + reference: None, + note: None + } + ) + .is_err()); + accounts::update( + &mut db, + UpdateAccountInput { + account_id: om.clone(), + name: "Ancienne SIM".into(), + identifier: None, + }, + ) + .unwrap(); + let reversal = reverse_journal_entry( + &mut db, + ReverseEntryInput { + entry_id: entry.id.clone(), + reason: "Annulation de la recette".into(), + }, + ) + .unwrap(); + assert_eq!(reversal.account_snapshot.account_id, om); + assert_eq!(reversal.account_snapshot.name, "Orange 1"); + assert_eq!(reversal.signed_amount, -entry.signed_amount); + assert!(reverse_journal_entry( + &mut db, + ReverseEntryInput { + entry_id: entry.id, + reason: "Deuxième tentative".into() + } + ) + .is_err()); + assert!(accounts::set_active(&mut db, &om, true).unwrap().active); + assert!(list_audit_events(&db, 500) + .unwrap() + .iter() + .any(|e| e.action == "account_archived")); +} + +#[test] +fn djamo_debt_can_be_repaid_on_other_sim_and_cash_after_source_archive() { + let mut db = multi_database(); + let djamo = id(&db, "Djamo 1"); + let wave = id(&db, "Wave 2"); + let cash = id(&db, "Espèces"); + let loan = debt(&mut db, &djamo); + assert_eq!(loan.provider, "djamo"); + let mut inputs = balances(&db); + inputs + .iter_mut() + .find(|b| b.account_id == djamo) + .unwrap() + .amount -= 50_000; + let inventory = close(&mut db, inputs); + assert_eq!(inventory.liquidity, 4_950_000); + assert_eq!(inventory.receivables, 50_000); + assert_eq!(inventory.actual_total, 5_000_000); + let partial = payment(&mut db, &loan.id, &wave, 20_000); + assert_eq!(partial.remaining, 30_000); + let mut inputs = balances(&db); + inputs + .iter_mut() + .find(|b| b.account_id == wave) + .unwrap() + .amount += 20_000; + let inventory = close(&mut db, inputs); + assert_eq!(inventory.actual_total, 5_000_000); + assert_eq!(inventory.variance, 0); + // Move remaining Djamo liquidity into cash and archive the empty source. + let mut inputs = balances(&db); + inputs + .iter_mut() + .find(|b| b.account_id == djamo) + .unwrap() + .amount = 0; + inputs + .iter_mut() + .find(|b| b.account_id == cash) + .unwrap() + .amount += 750_000; + close(&mut db, inputs); + accounts::set_active(&mut db, &djamo, false).unwrap(); + let paid = payment(&mut db, &loan.id, &cash, 30_000); + assert_eq!(paid.status, "paid"); + assert_eq!(paid.payments.len(), 2); + accounts::update( + &mut db, + UpdateAccountInput { + account_id: wave.clone(), + name: "Wave renommée".into(), + identifier: None, + }, + ) + .unwrap(); + let paid = list_debts(&db, None).unwrap().remove(0); + assert!(paid + .payments + .iter() + .any(|p| p.account_snapshot.name == "Wave 2")); + assert!(!accounts::get(&db, &djamo, false).unwrap().active); +} + +#[test] +fn v1_migration_is_atomic_preserves_all_original_columns_and_runs_once() { + let db = Connection::open_in_memory().unwrap(); + legacy_database(&db); + let before = historical_data(&db); + migrate(&db).unwrap(); + assert_eq!(schema_version(&db).unwrap(), 2); + assert_eq!(historical_data(&db), before); + assert_eq!(accounts::list(&db).unwrap().len(), 4); + let inventories = list_inventories(&db, None).unwrap(); + assert!(inventories + .iter() + .all(|i| i.account_balances.len() == 4 && i.account_balances.iter().all(|b| b.legacy))); + assert_eq!( + list_debts(&db, None).unwrap()[0] + .account_snapshot + .account_id, + "legacy-wave" + ); + assert_eq!( + list_debts(&db, None).unwrap()[0].payments[0] + .account_snapshot + .account_id, + "legacy-cash" + ); + migrate(&db).unwrap(); + assert_eq!(accounts::list(&db).unwrap().len(), 4); + assert_eq!(historical_data(&db), before); + assert_eq!( + list_audit_events(&db, 500) + .unwrap() + .iter() + .filter(|e| e.action == "schema_migrated") + .count(), + 1 + ); + assert!(!db + .prepare("PRAGMA foreign_key_check") + .unwrap() + .exists([]) + .unwrap()); + for sql in [ + "UPDATE inventories SET cash=0", + "DELETE FROM inventories", + "UPDATE inventory_account_balances SET amount=0", + "DELETE FROM inventory_account_balances", + "UPDATE journal_entries SET amount=1", + "DELETE FROM audit_events", + ] { + assert!(db.execute(sql, []).is_err(), "{sql}"); + } +} + +#[test] +fn broken_v1_migration_rolls_back_and_restores_foreign_keys() { + let db = Connection::open_in_memory().unwrap(); + legacy_database(&db); + db.execute( + "UPDATE journal_entries SET payment_account='unknown' WHERE id='sale'", + [], + ) + .unwrap(); + let before = historical_data(&db); + assert!(migrate(&db).is_err()); + assert_eq!(schema_version(&db).unwrap(), 1); + assert_eq!(historical_data(&db), before); + assert!(!db + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='accounts')", + [], + |r| r.get::<_, bool>(0) + ) + .unwrap()); + assert!(db + .query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0)) + .unwrap()); +} diff --git a/src-tauri/src/accounts.rs b/src-tauri/src/accounts.rs new file mode 100644 index 0000000..01c75d4 --- /dev/null +++ b/src-tauri/src/accounts.rs @@ -0,0 +1,318 @@ +use crate::{ + db, + domain::{ + clean_optional, clean_required, validate_balances, validate_initial_allocation, + validate_payment_account, + }, + error::{AppError, AppResult}, + models::*, +}; +use chrono::Utc; +use rusqlite::{params, Connection, Transaction}; +use serde_json::json; +use std::collections::HashSet; +use uuid::Uuid; + +pub fn list(connection: &Connection) -> AppResult> { + let mut statement = connection.prepare( + "SELECT a.id, a.provider, a.name, a.identifier, a.active, + (SELECT b.amount FROM inventory_account_balances b JOIN inventories i ON i.id=b.inventory_id + WHERE b.account_id=a.id ORDER BY i.closed_at DESC, i.rowid DESC LIMIT 1), + (SELECT i.closed_at FROM inventory_account_balances b JOIN inventories i ON i.id=b.inventory_id + WHERE b.account_id=a.id ORDER BY i.closed_at DESC, i.rowid DESC LIMIT 1) + FROM accounts a ORDER BY CASE a.provider WHEN 'orange_money' THEN 0 WHEN 'wave' THEN 1 WHEN 'djamo' THEN 2 ELSE 3 END, a.name_key" + )?; + let rows = statement + .query_map([], |r| { + Ok(Account { + snapshot: AccountSnapshot { + account_id: r.get(0)?, + provider: r.get(1)?, + name: r.get(2)?, + identifier: r.get(3)?, + }, + active: r.get(4)?, + last_balance: r.get(5)?, + last_measured_at: r.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(rows) +} + +pub fn get(connection: &Connection, id: &str, require_active: bool) -> AppResult { + let account = list(connection)? + .into_iter() + .find(|a| a.snapshot.account_id == id) + .ok_or(AppError::NotFound)?; + if require_active && !account.active { + return Err(AppError::Validation("Ce compte est archivé.".into())); + } + Ok(account) +} + +fn validate_name( + connection: &Connection, + provider: &str, + name: &str, + except: &str, +) -> AppResult { + let name = clean_required(name, "Le nom du compte", 1)?; + let exists: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM accounts WHERE provider=?1 AND name_key=?2 AND id!=?3)", + params![provider, name.to_lowercase(), except], + |r| r.get(0), + )?; + if exists { + return Err(AppError::Validation( + "Ce nom existe déjà pour ce service (y compris les comptes archivés).".into(), + )); + } + Ok(name) +} + +pub fn insert( + tx: &Transaction<'_>, + input: CreateAccountInput, + allow_cash: bool, +) -> AppResult { + validate_payment_account(&input.provider)?; + if input.provider == "cash" && !allow_cash { + return Err(AppError::Validation( + "La boutique possède une seule caisse espèces.".into(), + )); + } + let name = validate_name(tx, &input.provider, &input.name, "")?; + let account = AccountSnapshot { + account_id: Uuid::new_v4().to_string(), + provider: input.provider, + name, + identifier: clean_optional(input.identifier), + }; + tx.execute("INSERT INTO accounts(id,provider,name,name_key,identifier,created_at) VALUES (?1,?2,?3,?4,?5,?6)", + params![account.account_id,account.provider,account.name,account.name.to_lowercase(),account.identifier,Utc::now().to_rfc3339()])?; + db::audit_tx( + tx, + "account_created", + "account", + Some(&account.account_id), + json!(account), + )?; + Ok(account) +} + +pub fn create(connection: &mut Connection, input: CreateAccountInput) -> AppResult { + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let account = insert(&tx, input, false)?; + tx.commit()?; + get(connection, &account.account_id, false) +} + +pub fn update(connection: &mut Connection, input: UpdateAccountInput) -> AppResult { + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let old = get(&tx, &input.account_id, false)?; + let name = validate_name(&tx, &old.snapshot.provider, &input.name, &input.account_id)?; + let identifier = clean_optional(input.identifier); + tx.execute( + "UPDATE accounts SET name=?1,name_key=?2,identifier=?3 WHERE id=?4", + params![name, name.to_lowercase(), identifier, input.account_id], + )?; + db::audit_tx( + &tx, + "account_updated", + "account", + Some(&input.account_id), + json!({"before":old.snapshot,"name":name,"identifier":identifier}), + )?; + tx.commit()?; + get(connection, &input.account_id, false) +} + +pub fn set_active(connection: &mut Connection, id: &str, active: bool) -> AppResult { + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let account = get(&tx, id, false)?; + if !active && account.snapshot.provider == "cash" { + return Err(AppError::Validation( + "La caisse espèces ne peut pas être archivée.".into(), + )); + } + if !active && account.active { + if account.last_balance.is_some_and(|v| v != 0) { + return Err(AppError::Validation( + "Clôturez d’abord un inventaire avec un solde nul sur ce compte.".into(), + )); + } + let since = account.last_measured_at.as_deref().unwrap_or(""); + let pending: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM journal_entries WHERE account_id=?1 AND posted_at>=?2 + UNION ALL SELECT 1 FROM debts WHERE account_id=?1 AND created_at>=?2 + UNION ALL SELECT 1 FROM debt_payments WHERE account_id=?1 AND created_at>=?2)", + params![id, since], + |r| r.get(0), + )?; + if pending { + return Err(AppError::Validation("Une opération a été enregistrée depuis le dernier relevé : faites un inventaire avant l’archivage.".into())); + } + } + if active != account.active { + tx.execute( + "UPDATE accounts SET active=?1 WHERE id=?2", + params![active, id], + )?; + db::audit_tx( + &tx, + if active { + "account_reactivated" + } else { + "account_archived" + }, + "account", + Some(id), + json!(account.snapshot), + )?; + } + tx.commit()?; + get(connection, id, false) +} + +pub fn totals<'a>( + values: impl IntoIterator, +) -> AppResult { + let mut result = AccountBalances { + orange_money: 0, + wave: 0, + djamo: 0, + cash: 0, + }; + for (provider, amount) in values { + if !(0..=9_007_199_254_740_991).contains(&amount) { + return Err(AppError::Validation( + "Solde entier positif ou nul requis, dans la limite autorisée.".into(), + )); + } + let slot = match provider { + "orange_money" => &mut result.orange_money, + "wave" => &mut result.wave, + "djamo" => &mut result.djamo, + "cash" => &mut result.cash, + _ => return Err(AppError::Validation("Service inconnu.".into())), + }; + *slot = slot + .checked_add(amount) + .ok_or_else(|| AppError::Validation("Total trop élevé.".into()))?; + } + if validate_balances(&result)? > 9_007_199_254_740_991 { + return Err(AppError::Validation("Total trop élevé.".into())); + } + Ok(result) +} + +pub fn opening_totals(inputs: &[OpeningAccount]) -> AppResult { + let mut names = HashSet::new(); + for a in inputs { + let name = clean_required(&a.name, "Le nom du compte", 1)?; + if !names.insert((a.provider.as_str(), name.to_lowercase())) { + return Err(AppError::Validation( + "Deux comptes du même service portent le même nom.".into(), + )); + } + } + if inputs.iter().filter(|a| a.provider == "cash").count() != 1 { + return Err(AppError::Validation( + "La répartition doit contenir exactement une caisse espèces.".into(), + )); + } + totals(inputs.iter().map(|a| (a.provider.as_str(), a.amount))) +} + +pub fn validate_opening(input: &SetupInput) -> AppResult { + let totals = opening_totals(&input.accounts)?; + validate_initial_allocation(input.initial_capital, &totals)?; + Ok(totals) +} + +pub fn preview( + connection: &Connection, + inputs: &[AccountBalanceInput], +) -> AppResult<(AccountBalances, Vec)> { + let active: Vec<_> = list(connection)?.into_iter().filter(|a| a.active).collect(); + if inputs.len() != active.len() { + return Err(AppError::Validation( + "Saisissez exactement un solde pour chaque compte actif.".into(), + )); + } + let mut seen = HashSet::new(); + let mut details = Vec::new(); + for input in inputs { + if !seen.insert(&input.account_id) { + return Err(AppError::Validation( + "Un compte apparaît plusieurs fois.".into(), + )); + } + let account = active + .iter() + .find(|a| a.snapshot.account_id == input.account_id) + .ok_or_else(|| { + AppError::Validation("Compte inconnu ou archivé dans l’inventaire.".into()) + })?; + details.push(AccountBalanceSnapshot { + account: account.snapshot.clone(), + amount: input.amount, + previous_amount: account.last_balance, + delta: account + .last_balance + .and_then(|v| input.amount.checked_sub(v)), + legacy: false, + }); + } + let totals = totals( + details + .iter() + .map(|a| (a.account.provider.as_str(), a.amount)), + )?; + Ok((totals, details)) +} + +pub fn save_balances( + tx: &Transaction<'_>, + inventory_id: &str, + details: &[AccountBalanceSnapshot], +) -> AppResult<()> { + for d in details { + tx.execute("INSERT INTO inventory_account_balances(inventory_id,account_id,provider,account_name,account_identifier,amount,legacy) VALUES (?1,?2,?3,?4,?5,?6,?7)", + params![inventory_id,d.account.account_id,d.account.provider,d.account.name,d.account.identifier,d.amount,d.legacy])?; + } + Ok(()) +} + +pub fn inventory_balances( + connection: &Connection, + inventory_id: &str, +) -> AppResult> { + let mut statement=connection.prepare( + "SELECT b.account_id,b.provider,b.account_name,b.account_identifier,b.amount,b.legacy, + (SELECT p.amount FROM inventory_account_balances p JOIN inventories pi ON pi.id=p.inventory_id + WHERE p.account_id=b.account_id AND (pi.closed_at = r.get(6)?; + Ok(AccountBalanceSnapshot { + account: AccountSnapshot { + account_id: r.get(0)?, + provider: r.get(1)?, + name: r.get(2)?, + identifier: r.get(3)?, + }, + amount, + previous_amount, + delta: previous_amount.map(|v| amount - v), + legacy: r.get(5)?, + }) + })? + .collect::, _>>()?; + Ok(values) +} diff --git a/src-tauri/src/backup.rs b/src-tauri/src/backup.rs index 411d78e..2989a2b 100644 --- a/src-tauri/src/backup.rs +++ b/src-tauri/src/backup.rs @@ -16,7 +16,7 @@ use crate::{ error::{AppError, AppResult}, models::{BackupInfo, BackupManifest, RestoreInput}, security::{self, KeyEnvelope}, - state::AppState, + state::{AppPaths, AppState}, }; const DATABASE_ENTRY: &str = "database.db"; @@ -85,6 +85,32 @@ fn archive_backup( Ok(()) } +pub fn before_migration( + connection: &Connection, + key: &[u8], + paths: &AppPaths, +) -> AppResult { + let temp = TempDir::new_in(&paths.data_dir)?; + let snapshot = temp.path().join(DATABASE_ENTRY); + database_snapshot(connection, &snapshot, key)?; + let manifest = BackupManifest { + app_version: env!("CARGO_PKG_VERSION").into(), + schema_version: db::schema_version(connection)?, + created_at: Utc::now().to_rfc3339(), + database_sha256: sha256_file(&snapshot)?, + business_name: db::get_settings(connection)?.business_name, + }; + let path = paths.backups.join(format!( + "avant-migration-v{}-{}.msbackup", + manifest.schema_version, + uuid::Uuid::new_v4() + )); + archive_backup(&path, &snapshot, &paths.security, &manifest)?; + // Verify the completed archive before touching the source database. + extract_and_validate(&path)?; + Ok(path) +} + pub fn create_backup(state: &AppState, destination: Option) -> AppResult { let key = state.database_key()?; let timestamp = Utc::now(); @@ -105,7 +131,7 @@ pub fn create_backup(state: &AppState, destination: Option) -> AppResul let settings = state.with_connection(|connection| db::get_settings(connection))?; let manifest = BackupManifest { app_version: env!("CARGO_PKG_VERSION").into(), - schema_version: db::SCHEMA_VERSION, + schema_version: state.with_connection(|connection| db::schema_version(connection))?, created_at: timestamp.to_rfc3339(), database_sha256: sha256_file(&snapshot)?, business_name: settings.business_name, @@ -209,6 +235,15 @@ fn extract_and_validate(path: &Path) -> AppResult { } pub fn restore_backup(state: &AppState, input: RestoreInput) -> AppResult { + restore_backup_with(state, input, security::rewrap_recovered_key) +} + +fn restore_backup_with( + state: &AppState, + input: RestoreInput, + rewrap: impl FnOnce(&[u8], &str, &str) -> AppResult, +) -> AppResult { + crate::domain::validate_pin(&input.new_pin)?; let backup_path = PathBuf::from(&input.backup_path); if !backup_path.is_file() { return Err(AppError::Validation( @@ -221,6 +256,13 @@ pub fn restore_backup(state: &AppState, input: RestoreInput) -> AppResult AppResult AppResult serde_json::Value { + serde_json::json!({ + "accounts": accounts::list(connection).unwrap(), + "inventories": db::list_inventories(connection,None).unwrap(), + "journal": db::list_journal_entries(connection,None).unwrap(), + "debts": db::list_debts(connection,None).unwrap(), + "audit": db::list_audit_events(connection,500).unwrap(), + }) + } + + #[test] + fn migration_requires_a_verified_encrypted_backup_before_any_change() { + let temp = TempDir::new().unwrap(); + let state = AppState::new(temp.path().to_path_buf()).unwrap(); + let key = vec![17u8; 32]; + let connection = db::open_database(&state.paths.database, &key).unwrap(); + legacy_database(&connection); + let before = historical_data(&connection); + // Missing envelope makes the safety backup fail: database must remain v1. + assert!(state.set_recovered_session(key.clone().into()).is_err()); + assert_eq!(db::schema_version(&connection).unwrap(), 1); + assert_eq!(historical_data(&connection), before); + assert!(!state.setup_status().unlocked); + let envelope = + security::test_envelope(&key, "123456", "une phrase de récupération solide").unwrap(); + security::write_envelope(&state.paths.security, &envelope).unwrap(); + state.set_recovered_session(key.clone().into()).unwrap(); + assert_eq!(db::schema_version(&connection).unwrap(), 2); + let backups = list_backups(&state).unwrap(); + assert_eq!(backups.len(), 1); + let extracted = extract_and_validate(Path::new(&backups[0].path)).unwrap(); + assert_eq!(extracted.manifest.schema_version, 1); + assert!(!fs::read(&extracted.database) + .unwrap() + .starts_with(b"SQLite format 3")); + let original = db::open_database(&extracted.database, &key).unwrap(); + assert_eq!(db::schema_version(&original).unwrap(), 1); + assert_eq!(historical_data(&original), before); + state.with_connection(|_| Ok(())).unwrap(); + assert_eq!(list_backups(&state).unwrap().len(), 1); + } + + #[test] + fn restores_v1_and_v2_archives_in_staging_and_preserves_multi_account_history() { + const PASSWORD: &str = "une phrase de récupération solide"; + let source_dir = TempDir::new().unwrap(); + let source = AppState::new(source_dir.path().to_path_buf()).unwrap(); + let key = vec![31u8; 32]; + let connection = db::open_database(&source.paths.database, &key).unwrap(); + legacy_database(&connection); + let legacy = historical_data(&connection); + drop(connection); + security::write_envelope( + &source.paths.security, + &security::test_envelope(&key, "123456", PASSWORD).unwrap(), + ) + .unwrap(); + source.set_recovered_session(key.clone().into()).unwrap(); + let v1 = list_backups(&source).unwrap().remove(0); + source + .with_connection(|db| { + let extra = accounts::create( + db, + CreateAccountInput { + provider: "wave".into(), + name: "Wave 2".into(), + identifier: Some("771234567".into()), + }, + )?; + let loan = debt(db, "legacy-djamo"); + payment(db, &loan.id, &extra.snapshot.account_id, 10_000); + accounts::update( + db, + UpdateAccountInput { + account_id: "legacy-djamo".into(), + name: "Djamo renommé".into(), + identifier: Some("DJ2".into()), + }, + )?; + let unused = accounts::create( + db, + CreateAccountInput { + provider: "orange_money".into(), + name: "Réserve".into(), + identifier: None, + }, + )?; + accounts::set_active(db, &unused.snapshot.account_id, false)?; + let values = balances(db); + close(db, values); + Ok(()) + }) + .unwrap(); + let v2 = create_backup(&source, None).unwrap(); + let expected = source.with_connection(|db| Ok(snapshot(db))).unwrap(); + for (version, backup) in [(1, v1), (2, v2)] { + let target_dir = TempDir::new().unwrap(); + let target = AppState::new(target_dir.path().to_path_buf()).unwrap(); + let input = RestoreInput { + backup_path: backup.path, + recovery_password: PASSWORD.into(), + new_pin: "654321".into(), + }; + if version == 2 { + // Exercise replacement of an existing unlocked installation as well. + let mut existing = db::open_database(&target.paths.database, &[22u8; 32]).unwrap(); + db::migrate(&existing).unwrap(); + db::initialize_business(&mut existing, &multi_setup()).unwrap(); + security::write_envelope( + &target.paths.security, + &security::test_envelope(&[22u8; 32], "123456", PASSWORD).unwrap(), + ) + .unwrap(); + target.set_recovered_session(vec![22u8; 32].into()).unwrap(); + } + let before_attempt = if target.setup_status().unlocked { + Some(target.with_connection(|db| Ok(snapshot(db))).unwrap()) + } else { + None + }; + let wrong = RestoreInput { + recovery_password: "un mot de passe erroné".into(), + ..input.clone() + }; + assert!(restore_backup_with(&target, wrong, security::test_envelope).is_err()); + if let Some(before) = before_attempt { + assert_eq!( + target.with_connection(|db| Ok(snapshot(db))).unwrap(), + before + ); + } else { + assert!(!target.paths.database.exists()); + } + restore_backup_with(&target, input, security::test_envelope).unwrap(); + assert!(target.setup_status().unlocked); + target + .with_connection(|db| { + assert_eq!(db::schema_version(db)?, 2); + if version == 1 { + assert_eq!(historical_data(db), legacy); + assert_eq!(accounts::list(db)?.len(), 4); + assert_eq!( + db::list_audit_events(db, 500)? + .iter() + .filter(|a| a.action == "schema_migrated") + .count(), + 1 + ); + } else { + assert_eq!(snapshot(db), expected); + } + db::integrity_check(db) + }) + .unwrap(); + } + } #[test] fn backup_archive_roundtrip_checks_hash_and_manifest() { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 57c58af..39c9754 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use tauri::State; -use crate::{backup, db, export, models::*, state::AppState}; +use crate::{accounts, backup, db, export, models::*, state::AppState}; type CommandResult = Result; @@ -221,3 +221,59 @@ pub fn restore_backup( ) -> CommandResult { backup::restore_backup(&state, input).map_err(command_error) } + +#[tauri::command] +pub fn list_accounts(state: State<'_, AppState>) -> CommandResult> { + state + .with_connection(|c| accounts::list(c)) + .map_err(command_error) +} +#[tauri::command] +pub fn create_account( + input: CreateAccountInput, + state: State<'_, AppState>, +) -> CommandResult { + state + .with_connection(|c| accounts::create(c, input)) + .map_err(command_error) +} +#[tauri::command] +pub fn update_account( + input: UpdateAccountInput, + state: State<'_, AppState>, +) -> CommandResult { + state + .with_connection(|c| accounts::update(c, input)) + .map_err(command_error) +} +#[tauri::command] +pub fn archive_account(account_id: String, state: State<'_, AppState>) -> CommandResult { + state + .with_connection(|c| accounts::set_active(c, &account_id, false)) + .map_err(command_error) +} +#[tauri::command] +pub fn reactivate_account( + account_id: String, + state: State<'_, AppState>, +) -> CommandResult { + state + .with_connection(|c| accounts::set_active(c, &account_id, true)) + .map_err(command_error) +} +#[tauri::command] +pub fn preview_opening( + accounts: Vec, + initial_capital: Money, +) -> CommandResult { + let balances = accounts::opening_totals(&accounts).map_err(command_error)?; + let liquidity = balances.liquidity().ok_or("Total trop élevé.")?; + let difference = initial_capital + .checked_sub(liquidity) + .ok_or("Capital invalide.")?; + Ok(OpeningPreview { + balances, + liquidity, + difference, + }) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 1cd0015..2c694a2 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -6,15 +6,16 @@ use serde_json::json; use uuid::Uuid; use crate::{ + accounts, domain::{ clean_optional, clean_required, signed_journal_amount, validate_balances, - validate_debt_provider, validate_payment_account, validate_variance_explanation, + validate_debt_provider, validate_variance_explanation, }, error::{AppError, AppResult}, models::*, }; -pub const SCHEMA_VERSION: i64 = 1; +pub const SCHEMA_VERSION: i64 = 2; pub fn open_database(path: &Path, database_key: &[u8]) -> AppResult { let connection = Connection::open(path)?; @@ -30,7 +31,64 @@ pub fn open_database(path: &Path, database_key: &[u8]) -> AppResult Ok(connection) } +pub fn schema_version(connection: &Connection) -> AppResult { + Ok(connection.query_row("PRAGMA user_version", [], |row| row.get(0))?) +} + pub fn migrate(connection: &Connection) -> AppResult<()> { + let version = schema_version(connection)?; + if version > SCHEMA_VERSION { + return Err(AppError::Validation( + "La base nécessite une version plus récente de l’application.".into(), + )); + } + if version == SCHEMA_VERSION { + return Ok(()); + } + // SQLite requires foreign keys off outside the transaction to rebuild debts. + connection.pragma_update(None, "foreign_keys", false)?; + let result = (|| { + let tx = connection.unchecked_transaction()?; + if version == 0 { + migrate_v1(&tx)?; + } + let debt_count: i64 = tx.query_row("SELECT count(*) FROM debts", [], |r| r.get(0))?; + let inventory_count: i64 = + tx.query_row("SELECT count(*) FROM inventories", [], |r| r.get(0))?; + tx.execute_batch(include_str!("migration_v2.sql"))?; + let migrated_debts: i64 = tx.query_row("SELECT count(*) FROM debts", [], |r| r.get(0))?; + let migrated_balances: i64 = + tx.query_row("SELECT count(*) FROM inventory_account_balances", [], |r| { + r.get(0) + })?; + if debt_count != migrated_debts || migrated_balances != inventory_count * 4 { + return Err(AppError::Validation( + "Migration interrompue : données historiques incomplètes.".into(), + )); + } + let broken = tx.prepare("PRAGMA foreign_key_check")?.exists([])?; + if broken { + return Err(AppError::Validation( + "Migration interrompue : références invalides.".into(), + )); + } + if version == 1 { + audit_tx( + &tx, + "schema_migrated", + "business", + Some("1"), + json!({"from":1,"to":2}), + )?; + } + tx.commit()?; + Ok(()) + })(); + connection.pragma_update(None, "foreign_keys", true)?; + result +} + +fn migrate_v1(connection: &Connection) -> AppResult<()> { connection.execute_batch( "CREATE TABLE IF NOT EXISTS business_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -129,7 +187,7 @@ fn validate_date(value: &str, label: &str) -> AppResult { Ok(value.to_string()) } -fn audit_tx( +pub(crate) fn audit_tx( tx: &Transaction<'_>, action: &str, entity_type: &str, @@ -151,13 +209,10 @@ fn audit_tx( Ok(()) } -pub fn initialize_business( - connection: &mut Connection, - input: &SetupInput, - balances: &AccountBalances, -) -> AppResult<()> { +pub fn initialize_business(connection: &mut Connection, input: &SetupInput) -> AppResult<()> { let business_name = clean_required(&input.business_name, "Le nom de la boutique", 2)?; - let liquidity = validate_balances(balances)?; + let balances = accounts::validate_opening(input)?; + let liquidity = validate_balances(&balances)?; let timestamp = now(); let inventory_id = Uuid::new_v4().to_string(); let tx = connection.transaction()?; @@ -182,6 +237,26 @@ pub fn initialize_business( liquidity ], )?; + let mut details = Vec::new(); + for draft in &input.accounts { + let account = accounts::insert( + &tx, + CreateAccountInput { + provider: draft.provider.clone(), + name: draft.name.clone(), + identifier: draft.identifier.clone(), + }, + true, + )?; + details.push(AccountBalanceSnapshot { + account, + amount: draft.amount, + previous_amount: None, + delta: None, + legacy: false, + }); + } + accounts::save_balances(&tx, &inventory_id, &details)?; audit_tx( &tx, "business_initialized", @@ -300,7 +375,7 @@ fn load_inventory_rows(connection: &Connection) -> AppResult> let mut statement = connection.prepare( "SELECT id, kind, closed_at, orange_money, wave, djamo, cash, receivables, liquidity, expected_total, actual_total, variance, variance_category, variance_note - FROM inventories ORDER BY closed_at ASC", + FROM inventories ORDER BY closed_at ASC, rowid ASC", )?; let rows = statement .query_map([], map_inventory_row)? @@ -313,6 +388,7 @@ fn to_inventory(row: &InventoryRow, previous: Option<&InventoryRow>) -> Inventor .map(|v| v.balances.clone()) .unwrap_or_else(|| row.balances.clone()); Inventory { + account_balances: Vec::new(), id: row.id.clone(), kind: row.kind.clone(), closed_at: row.closed_at.clone(), @@ -350,6 +426,9 @@ pub fn list_inventories( .map(|(index, row)| to_inventory(row, index.checked_sub(1).map(|i| &rows[i]))) .filter(|item| filters.is_none_or(|f| date_in_filter(&item.closed_at, f))) .collect(); + for item in &mut items { + item.account_balances = accounts::inventory_balances(connection, &item.id)?; + } items.reverse(); Ok(items) } @@ -359,7 +438,7 @@ fn last_inventory_row(connection: &Connection) -> AppResult { .query_row( "SELECT id, kind, closed_at, orange_money, wave, djamo, cash, receivables, liquidity, expected_total, actual_total, variance, variance_category, variance_note - FROM inventories ORDER BY closed_at DESC LIMIT 1", + FROM inventories ORDER BY closed_at DESC, rowid DESC LIMIT 1", [], map_inventory_row, ) @@ -370,7 +449,9 @@ pub fn last_inventory(connection: &Connection) -> AppResult { let all = load_inventory_rows(connection)?; let last = all.last().ok_or(AppError::NotFound)?; let previous = all.len().checked_sub(2).map(|index| &all[index]); - Ok(to_inventory(last, previous)) + let mut item = to_inventory(last, previous); + item.account_balances = accounts::inventory_balances(connection, &item.id)?; + Ok(item) } pub fn open_receivables(connection: &Connection) -> AppResult { @@ -397,12 +478,7 @@ pub fn preview_inventory( connection: &Connection, input: InventoryPreviewInput, ) -> AppResult { - let balances = AccountBalances { - orange_money: input.orange_money, - wave: input.wave, - djamo: input.djamo, - cash: input.cash, - }; + let (balances, account_balances) = accounts::preview(connection, &input.balances)?; let liquidity = validate_balances(&balances)?; let previous = last_inventory_row(connection)?; let receivables = open_receivables(connection)?; @@ -416,6 +492,7 @@ pub fn preview_inventory( AppError::Validation("Le capital réel dépasse la limite autorisée.".into()) })?; Ok(InventoryPreview { + account_balances, balances: balances.clone(), previous_balances: previous.balances.clone(), delta: AccountBalances { @@ -436,13 +513,11 @@ pub fn close_inventory( connection: &mut Connection, input: CloseInventoryInput, ) -> AppResult { + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let preview = preview_inventory( - connection, + &tx, InventoryPreviewInput { - orange_money: input.orange_money, - wave: input.wave, - djamo: input.djamo, - cash: input.cash, + balances: input.balances, }, )?; validate_variance_explanation( @@ -462,7 +537,6 @@ pub fn close_inventory( }; let id = Uuid::new_v4().to_string(); let timestamp = now(); - let tx = connection.transaction()?; tx.execute( "INSERT INTO inventories (id, kind, closed_at, orange_money, wave, djamo, cash, receivables, liquidity, @@ -484,6 +558,7 @@ pub fn close_inventory( note ], )?; + accounts::save_balances(&tx, &id, &preview.account_balances)?; audit_tx( &tx, "inventory_closed", @@ -505,14 +580,15 @@ pub fn create_inventory_correction( connection: &mut Connection, input: InventoryCorrectionInput, ) -> AppResult { + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; if input.amount <= 0 { return Err(AppError::Validation( "Le montant de correction doit être supérieur à zéro.".into(), )); } - validate_payment_account(&input.payment_account)?; + let account = accounts::get(&tx, &input.account_id, true)?.snapshot; let reason = clean_required(&input.reason, "Le motif de correction", 3)?; - let exists: bool = connection.query_row( + let exists: bool = tx.query_row( "SELECT EXISTS(SELECT 1 FROM inventories WHERE id = ?1)", [&input.inventory_id], |row| row.get(0), @@ -536,20 +612,20 @@ pub fn create_inventory_correction( let timestamp = now(); let occurred_at = Utc::now().date_naive().format("%Y-%m-%d").to_string(); let reference = format!("inventory:{}", input.inventory_id); - let tx = connection.transaction()?; tx.execute( "INSERT INTO journal_entries - (id, entry_type, amount, signed_amount, payment_account, occurred_at, posted_at, reference, note, reverses_id) - VALUES (?1, 'inventory_correction', ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL)", + (id, entry_type, amount, signed_amount, payment_account, occurred_at, posted_at, reference, note, reverses_id, account_id, account_name, account_identifier) + VALUES (?1, 'inventory_correction', ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11)", params![ id, input.amount, signed_amount, - input.payment_account, + account.provider, occurred_at, timestamp, reference, - reason + reason, + account.account_id, account.name, account.identifier ], )?; audit_tx( @@ -568,7 +644,7 @@ pub fn create_inventory_correction( connection .query_row( "SELECT j.id, j.entry_type, j.amount, j.signed_amount, j.payment_account, - j.occurred_at, j.posted_at, j.reference, j.note, j.reverses_id, 0 + j.occurred_at, j.posted_at, j.reference, j.note, j.reverses_id, 0, j.account_id, j.account_name, j.account_identifier FROM journal_entries j WHERE j.id = ?1", [&id], map_journal_row, @@ -578,6 +654,12 @@ pub fn create_inventory_correction( fn map_journal_row(row: &Row<'_>) -> rusqlite::Result { Ok(JournalEntry { + account_snapshot: AccountSnapshot { + account_id: row.get(11)?, + provider: row.get(4)?, + name: row.get(12)?, + identifier: row.get(13)?, + }, id: row.get(0)?, entry_type: row.get(1)?, amount: row.get(2)?, @@ -599,7 +681,7 @@ pub fn list_journal_entries( let mut statement = connection.prepare( "SELECT j.id, j.entry_type, j.amount, j.signed_amount, j.payment_account, j.occurred_at, j.posted_at, j.reference, j.note, j.reverses_id, - EXISTS(SELECT 1 FROM journal_entries r WHERE r.reverses_id = j.id) AS reversed + EXISTS(SELECT 1 FROM journal_entries r WHERE r.reverses_id = j.id) AS reversed, j.account_id, j.account_name, j.account_identifier FROM journal_entries j ORDER BY j.occurred_at DESC, j.posted_at DESC", )?; let entries = statement @@ -615,28 +697,29 @@ pub fn create_journal_entry( connection: &mut Connection, input: CreateJournalEntryInput, ) -> AppResult { + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let signed_amount = signed_journal_amount(&input.entry_type, input.amount)?; - validate_payment_account(&input.payment_account)?; + let account = accounts::get(&tx, &input.account_id, true)?.snapshot; let occurred_at = validate_date(&input.occurred_at, "La date")?; let reference = clean_optional(input.reference); let note = clean_optional(input.note); let id = Uuid::new_v4().to_string(); let posted_at = now(); - let tx = connection.transaction()?; tx.execute( "INSERT INTO journal_entries - (id, entry_type, amount, signed_amount, payment_account, occurred_at, posted_at, reference, note, reverses_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL)", + (id, entry_type, amount, signed_amount, payment_account, occurred_at, posted_at, reference, note, reverses_id, account_id, account_name, account_identifier) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10, ?11, ?12)", params![ id, input.entry_type, input.amount, signed_amount, - input.payment_account, + account.provider, occurred_at, posted_at, reference, - note + note, + account.account_id, account.name, account.identifier ], )?; audit_tx( @@ -648,14 +731,14 @@ pub fn create_journal_entry( "entryType": input.entry_type, "amount": input.amount, "signedAmount": signed_amount, - "paymentAccount": input.payment_account + "account": account }), )?; tx.commit()?; connection .query_row( "SELECT j.id, j.entry_type, j.amount, j.signed_amount, j.payment_account, - j.occurred_at, j.posted_at, j.reference, j.note, j.reverses_id, 0 + j.occurred_at, j.posted_at, j.reference, j.note, j.reverses_id, 0, j.account_id, j.account_name, j.account_identifier FROM journal_entries j WHERE j.id = ?1", [&id], map_journal_row, @@ -667,12 +750,13 @@ pub fn reverse_journal_entry( connection: &mut Connection, input: ReverseEntryInput, ) -> AppResult { + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let reason = clean_required(&input.reason, "Le motif", 3)?; - let original: JournalEntry = connection + let original: JournalEntry = tx .query_row( "SELECT j.id, j.entry_type, j.amount, j.signed_amount, j.payment_account, j.occurred_at, j.posted_at, j.reference, j.note, j.reverses_id, - EXISTS(SELECT 1 FROM journal_entries r WHERE r.reverses_id = j.id) + EXISTS(SELECT 1 FROM journal_entries r WHERE r.reverses_id = j.id), j.account_id, j.account_name, j.account_identifier FROM journal_entries j WHERE j.id = ?1", [&input.entry_id], map_journal_row, @@ -690,11 +774,10 @@ pub fn reverse_journal_entry( .ok_or_else(|| AppError::Validation("Montant de correction invalide.".into()))?; let id = Uuid::new_v4().to_string(); let timestamp = now(); - let tx = connection.transaction()?; tx.execute( "INSERT INTO journal_entries - (id, entry_type, amount, signed_amount, payment_account, occurred_at, posted_at, reference, note, reverses_id) - VALUES (?1, 'reversal', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + (id, entry_type, amount, signed_amount, payment_account, occurred_at, posted_at, reference, note, reverses_id, account_id, account_name, account_identifier) + VALUES (?1, 'reversal', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ id, original.amount, @@ -704,7 +787,8 @@ pub fn reverse_journal_entry( timestamp, format!("Correction {}", original.id), reason, - original.id + original.id, + original.account_snapshot.account_id, original.account_snapshot.name, original.account_snapshot.identifier ], )?; audit_tx( @@ -718,7 +802,7 @@ pub fn reverse_journal_entry( connection .query_row( "SELECT j.id, j.entry_type, j.amount, j.signed_amount, j.payment_account, - j.occurred_at, j.posted_at, j.reference, j.note, j.reverses_id, 0 + j.occurred_at, j.posted_at, j.reference, j.note, j.reverses_id, 0, j.account_id, j.account_name, j.account_identifier FROM journal_entries j WHERE j.id = ?1", [&id], map_journal_row, @@ -728,12 +812,18 @@ pub fn reverse_journal_entry( fn payment_for_debt(connection: &Connection, debt_id: &str) -> AppResult> { let mut statement = connection.prepare( - "SELECT id, debt_id, amount, account, paid_at, note, created_at + "SELECT id, debt_id, amount, account, paid_at, note, created_at, account_id, account_name, account_identifier FROM debt_payments WHERE debt_id = ?1 ORDER BY paid_at DESC, created_at DESC", )?; let values = statement .query_map([debt_id], |row| { Ok(DebtPayment { + account_snapshot: AccountSnapshot { + account_id: row.get(7)?, + provider: row.get(3)?, + name: row.get(8)?, + identifier: row.get(9)?, + }, id: row.get(0)?, debt_id: row.get(1)?, amount: row.get(2)?, @@ -775,7 +865,7 @@ pub fn list_debts( ) -> AppResult> { let mut statement = connection.prepare( "SELECT id, customer_name, phone, provider, principal, remaining, issued_at, - due_date, note, status, created_at + due_date, note, status, created_at, account_id, account_name, account_identifier FROM debts ORDER BY CASE status WHEN 'open' THEN 0 WHEN 'partial' THEN 1 WHEN 'paid' THEN 2 ELSE 3 END, COALESCE(due_date, '9999-12-31'), issued_at DESC", @@ -794,6 +884,12 @@ pub fn list_debts( row.get::<_, Option>(8)?, row.get::<_, String>(9)?, row.get::<_, String>(10)?, + AccountSnapshot { + account_id: row.get(11)?, + provider: row.get(3)?, + name: row.get(12)?, + identifier: row.get(13)?, + }, )) })? .collect::, _>>()?; @@ -811,12 +907,14 @@ pub fn list_debts( note, base_status, created_at, + account_snapshot, ) in raw { if filters.is_some_and(|f| !date_in_filter(&issued_at, f)) { continue; } debts.push(Debt { + account_snapshot, payments: payment_for_debt(connection, &id)?, status: display_debt_status(&base_status, remaining, due_date.as_deref(), today), id, @@ -835,9 +933,11 @@ pub fn list_debts( } pub fn create_debt(connection: &mut Connection, input: CreateDebtInput) -> AppResult { + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let customer_name = clean_required(&input.customer_name, "Le nom du client", 2)?; let phone = clean_required(&input.phone, "Le téléphone", 6)?; - validate_debt_provider(&input.provider)?; + let account = accounts::get(&tx, &input.account_id, true)?.snapshot; + validate_debt_provider(&account.provider)?; if input.amount <= 0 { return Err(AppError::Validation( "Le montant de la dette doit être supérieur à zéro.".into(), @@ -859,21 +959,21 @@ pub fn create_debt(connection: &mut Connection, input: CreateDebtInput) -> AppRe let note = clean_optional(input.note); let id = Uuid::new_v4().to_string(); let created_at = now(); - let tx = connection.transaction()?; tx.execute( "INSERT INTO debts - (id, customer_name, phone, provider, principal, remaining, issued_at, due_date, note, status, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6, ?7, ?8, 'open', ?9)", + (id, customer_name, phone, provider, principal, remaining, issued_at, due_date, note, status, created_at, account_id, account_name, account_identifier) + VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6, ?7, ?8, 'open', ?9, ?10, ?11, ?12)", params![ id, customer_name, phone, - input.provider, + account.provider, input.amount, issued_at, due_date, note, - created_at + created_at, + account.account_id, account.name, account.identifier ], )?; audit_tx( @@ -884,7 +984,7 @@ pub fn create_debt(connection: &mut Connection, input: CreateDebtInput) -> AppRe json!({ "customerName": customer_name, "phone": phone, - "provider": input.provider, + "account": account, "amount": input.amount, "dueDate": due_date }), @@ -900,15 +1000,16 @@ pub fn record_debt_payment( connection: &mut Connection, input: RecordPaymentInput, ) -> AppResult { + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; if input.amount <= 0 { return Err(AppError::Validation( "Le remboursement doit être supérieur à zéro.".into(), )); } - validate_payment_account(&input.account)?; + let account = accounts::get(&tx, &input.account_id, true)?.snapshot; let paid_at = validate_date(&input.paid_at, "La date du remboursement")?; let note = clean_optional(input.note); - let current: (Money, String) = connection + let current: (Money, String) = tx .query_row( "SELECT remaining, status FROM debts WHERE id = ?1", [&input.debt_id], @@ -931,18 +1032,18 @@ pub fn record_debt_payment( let status = if remaining == 0 { "paid" } else { "partial" }; let payment_id = Uuid::new_v4().to_string(); let created_at = now(); - let tx = connection.transaction()?; tx.execute( - "INSERT INTO debt_payments (id, debt_id, amount, account, paid_at, note, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT INTO debt_payments (id, debt_id, amount, account, paid_at, note, created_at, account_id, account_name, account_identifier) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ payment_id, input.debt_id, input.amount, - input.account, + account.provider, paid_at, note, - created_at + created_at, + account.account_id, account.name, account.identifier ], )?; tx.execute( @@ -958,7 +1059,7 @@ pub fn record_debt_payment( "paymentId": payment_id, "amount": input.amount, "remaining": remaining, - "account": input.account + "account": account }), )?; tx.commit()?; @@ -969,8 +1070,9 @@ pub fn record_debt_payment( } pub fn cancel_debt(connection: &mut Connection, input: CancelDebtInput) -> AppResult { + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let reason = clean_required(&input.reason, "Le motif d’annulation", 3)?; - let status: String = connection + let status: String = tx .query_row( "SELECT status FROM debts WHERE id = ?1", [&input.debt_id], @@ -981,7 +1083,6 @@ pub fn cancel_debt(connection: &mut Connection, input: CancelDebtInput) -> AppRe if status == "cancelled" { return Err(AppError::Validation("Cette dette est déjà annulée.".into())); } - let tx = connection.transaction()?; tx.execute( "UPDATE debts SET remaining = 0, status = 'cancelled', cancellation_reason = ?1 WHERE id = ?2", params![reason, input.debt_id], @@ -1022,6 +1123,7 @@ pub fn get_dashboard(connection: &Connection) -> AppResult { .with_timezone(&Utc); let next = closed_at + Duration::minutes(settings.inventory_interval_minutes); Ok(Dashboard { + accounts: accounts::list(connection)?, settings, expected_capital, last_actual_capital: last_inventory.actual_total, @@ -1097,13 +1199,22 @@ pub fn get_report(connection: &Connection, filters: ReportFilters) -> AppResult< pub fn integrity_check(connection: &Connection) -> AppResult<()> { let result: String = connection.query_row("PRAGMA integrity_check", [], |row| row.get(0))?; - if result == "ok" { + let invalid_references = connection.prepare("PRAGMA foreign_key_check")?.exists([])?; + if result == "ok" && !invalid_references { Ok(()) } else { Err(AppError::Database(rusqlite::Error::InvalidQuery)) } } +#[cfg(test)] +#[path = "test_support.rs"] +pub(crate) mod test_support; + +#[cfg(test)] +#[path = "account_tests.rs"] +mod account_tests; + #[cfg(test)] mod tests { use super::*; @@ -1117,36 +1228,64 @@ mod tests { connection } + fn opening_accounts() -> Vec { + [ + ("orange_money", 1_500_000), + ("wave", 1_200_000), + ("djamo", 800_000), + ("cash", 1_500_000), + ] + .into_iter() + .map(|(provider, amount)| OpeningAccount { + provider: provider.into(), + name: provider.into(), + identifier: None, + amount, + }) + .collect() + } + + fn balances_for(accounts: &[Account], amounts: [Money; 4]) -> Vec { + accounts + .iter() + .filter(|a| a.active) + .map(|a| { + let index = match a.snapshot.provider.as_str() { + "orange_money" => 0, + "wave" => 1, + "djamo" => 2, + _ => 3, + }; + AccountBalanceInput { + account_id: a.snapshot.account_id.clone(), + amount: amounts[index], + } + }) + .collect() + } + fn setup(connection: &mut Connection) { let input = SetupInput { business_name: "Boutique test".into(), pin: "123456".into(), recovery_password: "mot de passe de récupération".into(), initial_capital: 5_000_000, - orange_money: 1_500_000, - wave: 1_200_000, - djamo: 800_000, - cash: 1_500_000, - }; - let balances = AccountBalances { - orange_money: input.orange_money, - wave: input.wave, - djamo: input.djamo, - cash: input.cash, + accounts: opening_accounts(), }; - initialize_business(connection, &input, &balances).unwrap(); + initialize_business(connection, &input).unwrap(); } #[test] fn journal_updates_expected_capital() { let mut connection = test_db(); setup(&mut connection); + let cash = test_support::id(&connection, "cash"); create_journal_entry( &mut connection, CreateJournalEntryInput { entry_type: "sale".into(), amount: 100_000, - payment_account: "cash".into(), + account_id: cash.clone(), occurred_at: "2026-08-26".into(), reference: None, note: None, @@ -1158,7 +1297,7 @@ mod tests { CreateJournalEntryInput { entry_type: "expense".into(), amount: 30_000, - payment_account: "cash".into(), + account_id: cash.clone(), occurred_at: "2026-08-26".into(), reference: None, note: None, @@ -1175,12 +1314,14 @@ mod tests { fn debt_and_partial_payment_preserve_receivable_logic() { let mut connection = test_db(); setup(&mut connection); + let cash = test_support::id(&connection, "cash"); + let wave = test_support::id(&connection, "wave"); let debt = create_debt( &mut connection, CreateDebtInput { customer_name: "Awa Ndiaye".into(), phone: "771234567".into(), - provider: "wave".into(), + account_id: wave.clone(), amount: 50_000, issued_at: "2026-08-26".into(), // Les échéances sont testées séparément avec une date contrôlée. @@ -1196,7 +1337,7 @@ mod tests { RecordPaymentInput { debt_id: debt.id, amount: 20_000, - account: "cash".into(), + account_id: cash.clone(), paid_at: "2026-08-27".into(), note: None, }, @@ -1251,12 +1392,14 @@ mod tests { fn overpayment_is_rejected() { let mut connection = test_db(); setup(&mut connection); + let cash = test_support::id(&connection, "cash"); + let orange = test_support::id(&connection, "orange_money"); let debt = create_debt( &mut connection, CreateDebtInput { customer_name: "Moussa Fall".into(), phone: "781234567".into(), - provider: "orange_money".into(), + account_id: orange.clone(), amount: 10_000, issued_at: "2026-08-26".into(), due_date: None, @@ -1269,7 +1412,7 @@ mod tests { RecordPaymentInput { debt_id: debt.id, amount: 10_001, - account: "cash".into(), + account_id: cash.clone(), paid_at: "2026-08-27".into(), note: None, } @@ -1281,13 +1424,11 @@ mod tests { fn non_zero_inventory_variance_requires_explanation_and_becomes_baseline() { let mut connection = test_db(); setup(&mut connection); + let account_list = accounts::list(&connection).unwrap(); let invalid = close_inventory( &mut connection, CloseInventoryInput { - orange_money: 1_510_000, - wave: 1_200_000, - djamo: 800_000, - cash: 1_500_000, + balances: balances_for(&account_list, [1_510_000, 1_200_000, 800_000, 1_500_000]), variance_category: None, variance_note: None, }, @@ -1296,10 +1437,7 @@ mod tests { let closed = close_inventory( &mut connection, CloseInventoryInput { - orange_money: 1_510_000, - wave: 1_200_000, - djamo: 800_000, - cash: 1_500_000, + balances: balances_for(&account_list, [1_510_000, 1_200_000, 800_000, 1_500_000]), variance_category: Some("commission_mobile".into()), variance_note: Some("Commissions de la période".into()), }, @@ -1316,6 +1454,7 @@ mod tests { fn inventory_correction_is_linked_and_updates_expected_capital() { let mut connection = test_db(); setup(&mut connection); + let cash = test_support::id(&connection, "cash"); let inventory = last_inventory(&connection).unwrap(); let entry = create_inventory_correction( &mut connection, @@ -1323,7 +1462,7 @@ mod tests { inventory_id: inventory.id.clone(), amount: 25_000, direction: "decrease".into(), - payment_account: "cash".into(), + account_id: cash.clone(), reason: "Correction d’un comptage erroné".into(), }, ) diff --git a/src-tauri/src/domain.rs b/src-tauri/src/domain.rs index bf22dab..3c0c460 100644 --- a/src-tauri/src/domain.rs +++ b/src-tauri/src/domain.rs @@ -6,7 +6,7 @@ use crate::{ pub const POSITIVE_ENTRY_TYPES: &[&str] = &["sale", "commission", "capital_contribution"]; pub const NEGATIVE_ENTRY_TYPES: &[&str] = &["purchase", "expense", "capital_withdrawal"]; pub const PAYMENT_ACCOUNTS: &[&str] = &["cash", "orange_money", "wave", "djamo"]; -pub const DEBT_PROVIDERS: &[&str] = &["orange_money", "wave"]; +pub const DEBT_PROVIDERS: &[&str] = &["orange_money", "wave", "djamo"]; pub const VARIANCE_CATEGORIES: &[&str] = &[ "commission_mobile", "surplus_caisse", @@ -77,7 +77,7 @@ pub fn validate_debt_provider(value: &str) -> AppResult<()> { Ok(()) } else { Err(AppError::Validation( - "Une dette doit provenir d’Orange Money ou de Wave.".into(), + "Une dette doit provenir d’Orange Money, Wave ou Djamo.".into(), )) } } diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index fd8e2cc..e5f8f2c 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -54,6 +54,9 @@ fn export_csv(destination: &Path, report: &ReportData) -> AppResult<()> { "date", "libelle", "compte_service", + "compte_id", + "compte_nom", + "compte_identifiant", "montant_fcfa", "solde_ou_ecart_fcfa", "statut_note", @@ -68,17 +71,43 @@ fn export_csv(destination: &Path, report: &ReportData) -> AppResult<()> { "Inventaire" }, "tous", + "", + "", + "", &inventory.actual_total.to_string(), &inventory.variance.to_string(), inventory.variance_note.as_deref().unwrap_or(""), ])?; } + for inventory in &report.inventories { + for detail in &inventory.account_balances { + writer.write_record([ + "solde_compte", + &inventory.closed_at, + "Détail inventaire (hors synthèse)", + &detail.account.provider, + &detail.account.account_id, + &detail.account.name, + detail.account.identifier.as_deref().unwrap_or(""), + &detail.amount.to_string(), + &detail.delta.map(|v| v.to_string()).unwrap_or_default(), + if detail.legacy { + "Solde historique regroupé" + } else { + "" + }, + ])?; + } + } for entry in &report.journal { writer.write_record([ "journal", &entry.occurred_at, &entry.entry_type, &entry.payment_account, + &entry.account_snapshot.account_id, + &entry.account_snapshot.name, + entry.account_snapshot.identifier.as_deref().unwrap_or(""), &entry.signed_amount.to_string(), "", entry.note.as_deref().unwrap_or(""), @@ -90,6 +119,9 @@ fn export_csv(destination: &Path, report: &ReportData) -> AppResult<()> { &debt.issued_at, &debt.customer_name, &debt.provider, + &debt.account_snapshot.account_id, + &debt.account_snapshot.name, + debt.account_snapshot.identifier.as_deref().unwrap_or(""), &debt.principal.to_string(), &debt.remaining.to_string(), &debt.status, @@ -100,6 +132,9 @@ fn export_csv(destination: &Path, report: &ReportData) -> AppResult<()> { &payment.paid_at, &debt.customer_name, &payment.account, + &payment.account_snapshot.account_id, + &payment.account_snapshot.name, + payment.account_snapshot.identifier.as_deref().unwrap_or(""), &payment.amount.to_string(), "", payment.note.as_deref().unwrap_or(""), @@ -182,6 +217,7 @@ fn export_xlsx(destination: &Path, report: &ReportData) -> AppResult<()> { "Référence", "Note", "Correction", + "Compte ID", ]; for (column, title) in titles.iter().enumerate() { sheet @@ -197,7 +233,7 @@ fn export_xlsx(destination: &Path, report: &ReportData) -> AppResult<()> { .write_string(row, 1, &item.entry_type) .map_err(xlsx_err)?; sheet - .write_string(row, 2, &item.payment_account) + .write_string(row, 2, item.account_snapshot.display_name()) .map_err(xlsx_err)?; sheet .write_number_with_format(row, 3, item.signed_amount as f64, &money) @@ -211,8 +247,14 @@ fn export_xlsx(destination: &Path, report: &ReportData) -> AppResult<()> { sheet .write_string(row, 6, item.reverses_id.as_deref().unwrap_or("")) .map_err(xlsx_err)?; + sheet + .write_string(row, 7, &item.account_snapshot.account_id) + .map_err(xlsx_err)?; } sheet.set_column_width(0, 14).map_err(xlsx_err)?; + sheet.set_column_width(2, 48).map_err(xlsx_err)?; + sheet.set_column_width(3, 20).map_err(xlsx_err)?; + sheet.set_column_width(7, 38).map_err(xlsx_err)?; sheet.set_column_width(4, 24).map_err(xlsx_err)?; sheet.set_column_width(5, 38).map_err(xlsx_err)?; } @@ -224,11 +266,12 @@ fn export_xlsx(destination: &Path, report: &ReportData) -> AppResult<()> { "Date", "Client", "Téléphone", - "Service", + "Compte", "Principal", "Reste", "Échéance", "Statut", + "Compte ID", ]; for (column, title) in titles.iter().enumerate() { sheet @@ -245,7 +288,7 @@ fn export_xlsx(destination: &Path, report: &ReportData) -> AppResult<()> { .map_err(xlsx_err)?; sheet.write_string(row, 2, &debt.phone).map_err(xlsx_err)?; sheet - .write_string(row, 3, &debt.provider) + .write_string(row, 3, debt.account_snapshot.display_name()) .map_err(xlsx_err)?; sheet .write_number_with_format(row, 4, debt.principal as f64, &money) @@ -257,14 +300,139 @@ fn export_xlsx(destination: &Path, report: &ReportData) -> AppResult<()> { .write_string(row, 6, debt.due_date.as_deref().unwrap_or("")) .map_err(xlsx_err)?; sheet.write_string(row, 7, &debt.status).map_err(xlsx_err)?; + sheet + .write_string(row, 8, &debt.account_snapshot.account_id) + .map_err(xlsx_err)?; } + sheet.set_column_width(8, 38).map_err(xlsx_err)?; for column in 0..=7 { sheet - .set_column_width(column, if column == 1 { 28 } else { 18 }) + .set_column_width( + column, + if column == 3 { + 48 + } else if column == 1 { + 28 + } else { + 18 + }, + ) .map_err(xlsx_err)?; } } + { + let sheet = workbook.add_worksheet(); + sheet.set_name("Soldes par compte").map_err(xlsx_err)?; + for (column, title) in [ + "Date", + "Compte ID", + "Service", + "Compte", + "Identifiant", + "Solde FCFA", + "Variation FCFA", + "Origine", + ] + .iter() + .enumerate() + { + sheet + .write_string_with_format(0, column as u16, *title, &header) + .map_err(xlsx_err)?; + sheet + .set_column_width(column as u16, 24) + .map_err(xlsx_err)?; + } + let mut row = 1; + for inventory in &report.inventories { + for d in &inventory.account_balances { + for (column, value) in [ + &inventory.closed_at, + &d.account.account_id, + &d.account.provider, + &d.account.name, + d.account.identifier.as_deref().unwrap_or(""), + ] + .iter() + .enumerate() + { + sheet + .write_string(row, column as u16, *value) + .map_err(xlsx_err)?; + } + sheet + .write_number_with_format(row, 5, d.amount as f64, &money) + .map_err(xlsx_err)?; + if let Some(delta) = d.delta { + sheet + .write_number_with_format(row, 6, delta as f64, &money) + .map_err(xlsx_err)?; + } + sheet + .write_string( + row, + 7, + if d.legacy { + "Historique regroupé" + } else { + "Relevé par compte" + }, + ) + .map_err(xlsx_err)?; + row += 1; + } + } + } + { + let sheet = workbook.add_worksheet(); + sheet.set_name("Remboursements").map_err(xlsx_err)?; + for (column, title) in [ + "Date", + "Dette ID", + "Client", + "Compte ID", + "Compte", + "Montant FCFA", + "Note", + ] + .iter() + .enumerate() + { + sheet + .write_string_with_format(0, column as u16, *title, &header) + .map_err(xlsx_err)?; + sheet + .set_column_width(column as u16, 26) + .map_err(xlsx_err)?; + } + let mut row = 1; + for debt in &report.debts { + for payment in &debt.payments { + for (column, value) in [ + &payment.paid_at, + &debt.id, + &debt.customer_name, + &payment.account_snapshot.account_id, + &payment.account_snapshot.display_name(), + ] + .iter() + .enumerate() + { + sheet + .write_string(row, column as u16, *value) + .map_err(xlsx_err)?; + } + sheet + .write_number_with_format(row, 5, payment.amount as f64, &money) + .map_err(xlsx_err)?; + sheet + .write_string(row, 6, payment.note.as_deref().unwrap_or("")) + .map_err(xlsx_err)?; + row += 1; + } + } + } workbook .save(destination) .map_err(|e| AppError::Export(e.to_string()))?; @@ -309,6 +477,19 @@ fn export_pdf(destination: &Path, report: &ReportData) -> AppResult<()> { format_money(item.expected_total), format_money(item.variance) )); + for d in &item.account_balances { + lines.push(format!( + " {} | Solde {} | Variation {}{}", + d.account.display_name(), + format_money(d.amount), + d.delta.map(format_money).unwrap_or_else(|| "—".into()), + if d.legacy { + " | Historique regroupé" + } else { + "" + } + )); + } if let Some(note) = &item.variance_note { lines.push(format!(" Motif: {note}")); } @@ -320,7 +501,7 @@ fn export_pdf(destination: &Path, report: &ReportData) -> AppResult<()> { "{} | {} | {} | {}", item.occurred_at, item.entry_type, - item.payment_account, + item.account_snapshot.display_name(), format_money(item.signed_amount) )); } @@ -332,12 +513,34 @@ fn export_pdf(destination: &Path, report: &ReportData) -> AppResult<()> { debt.issued_at, debt.customer_name, debt.phone, - debt.provider, + debt.account_snapshot.display_name(), format_money(debt.remaining), debt.status )); + for payment in &debt.payments { + lines.push(format!( + " Reçu le {} | {} | {}", + payment.paid_at, + payment.account_snapshot.display_name(), + format_money(payment.amount) + )); + } } + let lines: Vec = lines + .into_iter() + .flat_map(|line| { + let chars: Vec = line.chars().collect(); + if chars.is_empty() { + vec![String::new()] + } else { + chars + .chunks(82) + .map(|chunk| chunk.iter().collect()) + .collect() + } + }) + .collect(); let mut document = PdfDocument::new("Rapport Kër Finance"); let mut pages = Vec::new(); for chunk in lines.chunks(40) { @@ -373,6 +576,136 @@ fn export_pdf(destination: &Path, report: &ReportData) -> AppResult<()> { mod tests { use super::*; use crate::models::ReportFilters; + use crate::{ + accounts, + db::{self, test_support::*}, + models::UpdateAccountInput, + }; + + #[test] + fn exports_match_rust_totals_and_historical_account_details() { + use std::io::Read; + let mut db = multi_database(); + let djamo = id(&db, "Djamo 1"); + let wave = id(&db, "Wave 2"); + journal(&mut db, &wave); + let loan = debt(&mut db, &djamo); + payment(&mut db, &loan.id, &wave, 20_000); + accounts::update( + &mut db, + UpdateAccountInput { + account_id: wave.clone(), + name: "Nouveau nom".into(), + identifier: None, + }, + ) + .unwrap(); + let report = db::get_report( + &db, + ReportFilters { + from: None, + to: None, + }, + ) + .unwrap(); + let temp = tempfile::tempdir().unwrap(); + let csv_path = temp.path().join("report.csv"); + export_csv(&csv_path, &report).unwrap(); + let mut reader = csv::ReaderBuilder::new() + .delimiter(b';') + .from_path(csv_path) + .unwrap(); + let records = reader.records().collect::, _>>().unwrap(); + let summaries: Vec<_> = records.iter().filter(|r| &r[0] == "inventaire").collect(); + let details: Vec<_> = records.iter().filter(|r| &r[0] == "solde_compte").collect(); + assert_eq!(summaries.len(), 1); + assert_eq!(details.len(), 6); + assert_eq!( + &summaries[0][7], + &report.inventories[0].actual_total.to_string() + ); + assert_eq!( + details + .iter() + .map(|r| r[7].parse::().unwrap()) + .sum::(), + report.inventories[0].liquidity + ); + for detail in &report.inventories[0].account_balances { + let row = details + .iter() + .find(|r| r[4] == detail.account.account_id) + .unwrap(); + assert_eq!(&row[5], detail.account.name); + assert_eq!(&row[6], detail.account.identifier.as_deref().unwrap()); + assert_eq!(&row[7], detail.amount.to_string()); + assert_eq!(&row[8], ""); // No invented variation on the first reading. + } + for kind in ["journal", "remboursement"] { + let row = records.iter().find(|r| &r[0] == kind).unwrap(); + assert_eq!(&row[4], wave); + assert_eq!(&row[5], "Wave 2"); + } + let row = records.iter().find(|r| &r[0] == "dette").unwrap(); + assert_eq!(&row[4], djamo); + assert_eq!(&row[7], "50000"); + assert_eq!(&row[8], "30000"); + + let xlsx_path = temp.path().join("report.xlsx"); + export_xlsx(&xlsx_path, &report).unwrap(); + let mut zip = zip::ZipArchive::new(fs::File::open(xlsx_path).unwrap()).unwrap(); + fn xml(zip: &mut zip::ZipArchive, name: &str) -> String { + let mut result = String::new(); + zip.by_name(name) + .unwrap() + .read_to_string(&mut result) + .unwrap(); + result + } + let strings = xml(&mut zip, "xl/sharedStrings.xml"); + for detail in &report.inventories[0].account_balances { + assert!(strings.contains(&detail.account.account_id)); + assert!(strings.contains(&detail.account.name)); + assert!(strings.contains(detail.account.identifier.as_deref().unwrap())); + } + assert!(!strings.contains("Nouveau nom")); + let summary = xml(&mut zip, "xl/worksheets/sheet1.xml"); + assert_eq!(summary.matches("5000000")); + let account_sheet = xml(&mut zip, "xl/worksheets/sheet4.xml"); + assert_eq!(account_sheet.matches("1000000")); + let payments = xml(&mut zip, "xl/worksheets/sheet5.xml"); + assert_eq!(payments.matches("20000")); + + let pdf_path = temp.path().join("report.pdf"); + export_pdf(&pdf_path, &report).unwrap(); + let pdf = PdfDocument::parse( + &fs::read(pdf_path).unwrap(), + &printpdf::PdfParseOptions::default(), + &mut Vec::new(), + ) + .unwrap(); + let text = pdf + .extract_text() + .into_iter() + .flatten() + .collect::>() + .join(" "); + for name in ["Orange 1", "Orange 2", "Wave 1", "Wave 2", "Djamo 1"] { + assert!(text.contains(name), "PDF omitted {name}: {text}"); + } + for amount in [ + "5 000 000 FCFA", + "1 000 000 FCFA", + "20 000 FCFA", + "30 000 FCFA", + ] { + assert!(text.contains(amount), "PDF omitted {amount}: {text}"); + } + assert!(!text.contains("Nouveau nom")); + } fn empty_report() -> ReportData { ReportData { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 54a1717..1ad3db1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod accounts; mod backup; mod commands; mod db; @@ -25,6 +26,12 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ commands::check_setup, + commands::list_accounts, + commands::create_account, + commands::update_account, + commands::archive_account, + commands::reactivate_account, + commands::preview_opening, commands::setup_business, commands::login, commands::lock_app, diff --git a/src-tauri/src/migration_v2.sql b/src-tauri/src/migration_v2.sql new file mode 100644 index 0000000..d361d7e --- /dev/null +++ b/src-tauri/src/migration_v2.sql @@ -0,0 +1,121 @@ +CREATE TABLE accounts ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL CHECK(provider IN ('orange_money','wave','djamo','cash')), + name TEXT NOT NULL CHECK(length(trim(name)) > 0), + name_key TEXT NOT NULL, + identifier TEXT, + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + UNIQUE(provider, name_key), + CHECK(provider != 'cash' OR active = 1) +); +CREATE UNIQUE INDEX one_cash_account ON accounts(provider) WHERE provider = 'cash'; +INSERT INTO accounts(id, provider, name, name_key, created_at) +SELECT 'legacy-' || p.provider, p.provider, p.name, lower(p.name), b.created_at +FROM business_settings b CROSS JOIN ( + SELECT 'orange_money' AS provider, 'Orange Money — Principal' AS name + UNION ALL SELECT 'wave', 'Wave — Principal' + UNION ALL SELECT 'djamo', 'Djamo — Principal' + UNION ALL SELECT 'cash', 'Espèces' +) p; +CREATE TABLE inventory_account_balances ( + inventory_id TEXT NOT NULL REFERENCES inventories(id), + account_id TEXT NOT NULL REFERENCES accounts(id), + provider TEXT NOT NULL, + account_name TEXT NOT NULL, + account_identifier TEXT, + amount INTEGER NOT NULL CHECK(amount >= 0), + legacy INTEGER NOT NULL DEFAULT 0 CHECK(legacy IN (0,1)), + PRIMARY KEY(inventory_id, account_id) +); +INSERT INTO inventory_account_balances +SELECT i.id, a.id, a.provider, a.name, a.identifier, + CASE a.provider WHEN 'orange_money' THEN i.orange_money WHEN 'wave' THEN i.wave + WHEN 'djamo' THEN i.djamo ELSE i.cash END, 1 +FROM inventories i CROSS JOIN accounts a; +ALTER TABLE journal_entries ADD COLUMN account_id TEXT REFERENCES accounts(id); +ALTER TABLE journal_entries ADD COLUMN account_name TEXT; +ALTER TABLE journal_entries ADD COLUMN account_identifier TEXT; +UPDATE journal_entries SET account_id = 'legacy-' || payment_account, + account_name = (SELECT name FROM accounts WHERE id = 'legacy-' || payment_account); +ALTER TABLE debt_payments ADD COLUMN account_id TEXT REFERENCES accounts(id); +ALTER TABLE debt_payments ADD COLUMN account_name TEXT; +ALTER TABLE debt_payments ADD COLUMN account_identifier TEXT; +UPDATE debt_payments SET account_id = 'legacy-' || account, + account_name = (SELECT name FROM accounts WHERE id = 'legacy-' || account); +CREATE TABLE debts_v2 ( + id TEXT PRIMARY KEY, + customer_name TEXT NOT NULL, + phone TEXT NOT NULL, + provider TEXT NOT NULL CHECK(provider IN ('orange_money','wave','djamo')), + principal INTEGER NOT NULL CHECK(principal > 0), + remaining INTEGER NOT NULL CHECK(remaining >= 0), + issued_at TEXT NOT NULL, + due_date TEXT, + note TEXT, + status TEXT NOT NULL CHECK(status IN ('open','partial','paid','cancelled')), + cancellation_reason TEXT, + created_at TEXT NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts(id), + account_name TEXT NOT NULL, + account_identifier TEXT +); +INSERT INTO debts_v2 +SELECT d.*, 'legacy-' || d.provider, a.name, a.identifier +FROM debts d JOIN accounts a ON a.id = 'legacy-' || d.provider; +DROP TABLE debts; +ALTER TABLE debts_v2 RENAME TO debts; +CREATE INDEX idx_debts_phone ON debts(phone); +CREATE INDEX idx_debts_status ON debts(status); +CREATE INDEX idx_journal_account ON journal_entries(account_id); +CREATE INDEX idx_debts_account ON debts(account_id); +CREATE INDEX idx_payments_account ON debt_payments(account_id); +CREATE INDEX idx_inventory_account ON inventory_account_balances(account_id); +CREATE TRIGGER journal_account_required BEFORE INSERT ON journal_entries +WHEN NEW.account_id IS NULL OR NEW.account_name IS NULL +BEGIN SELECT RAISE(ABORT, 'Compte obligatoire'); END; +CREATE TRIGGER payment_account_required BEFORE INSERT ON debt_payments +WHEN NEW.account_id IS NULL OR NEW.account_name IS NULL +BEGIN SELECT RAISE(ABORT, 'Compte obligatoire'); END; +CREATE TRIGGER journal_account_valid BEFORE INSERT ON journal_entries +WHEN NOT EXISTS(SELECT 1 FROM accounts WHERE id=NEW.account_id AND provider=NEW.payment_account + AND (active=1 OR NEW.reverses_id IS NOT NULL)) +BEGIN SELECT RAISE(ABORT, 'Compte invalide ou archivé'); END; +CREATE TRIGGER debt_account_valid BEFORE INSERT ON debts +WHEN NOT EXISTS(SELECT 1 FROM accounts WHERE id=NEW.account_id AND provider=NEW.provider AND active=1) +BEGIN SELECT RAISE(ABORT, 'Compte invalide ou archivé'); END; +CREATE TRIGGER payment_account_valid BEFORE INSERT ON debt_payments +WHEN NOT EXISTS(SELECT 1 FROM accounts WHERE id=NEW.account_id AND provider=NEW.account AND active=1) +BEGIN SELECT RAISE(ABORT, 'Compte invalide ou archivé'); END; +CREATE TRIGGER inventory_balance_provider BEFORE INSERT ON inventory_account_balances +WHEN NOT EXISTS(SELECT 1 FROM accounts WHERE id=NEW.account_id AND provider=NEW.provider AND active=1) +BEGIN SELECT RAISE(ABORT, 'Compte invalide ou archivé'); END; +CREATE TRIGGER inventories_no_update BEFORE UPDATE ON inventories +BEGIN SELECT RAISE(ABORT, 'Inventaire clôturé immuable'); END; +CREATE TRIGGER inventories_no_delete BEFORE DELETE ON inventories +BEGIN SELECT RAISE(ABORT, 'Inventaire clôturé immuable'); END; +CREATE TRIGGER balances_no_update BEFORE UPDATE ON inventory_account_balances +BEGIN SELECT RAISE(ABORT, 'Solde clôturé immuable'); END; +CREATE TRIGGER balances_no_delete BEFORE DELETE ON inventory_account_balances +BEGIN SELECT RAISE(ABORT, 'Solde clôturé immuable'); END; +CREATE TRIGGER journal_no_update BEFORE UPDATE ON journal_entries +BEGIN SELECT RAISE(ABORT, 'Utilisez une contre-écriture'); END; +CREATE TRIGGER journal_no_delete BEFORE DELETE ON journal_entries +BEGIN SELECT RAISE(ABORT, 'Utilisez une contre-écriture'); END; +CREATE TRIGGER payments_no_update BEFORE UPDATE ON debt_payments +BEGIN SELECT RAISE(ABORT, 'Remboursement immuable'); END; +CREATE TRIGGER payments_no_delete BEFORE DELETE ON debt_payments +BEGIN SELECT RAISE(ABORT, 'Remboursement immuable'); END; +CREATE TRIGGER debt_origin_immutable BEFORE UPDATE OF id,provider,account_id,account_name,account_identifier,principal,issued_at,created_at ON debts +BEGIN SELECT RAISE(ABORT, 'Origine de la dette immuable'); END; +CREATE TRIGGER debts_no_delete BEFORE DELETE ON debts +BEGIN SELECT RAISE(ABORT, 'Annulez la dette par correction'); END; +CREATE TRIGGER audit_no_update BEFORE UPDATE ON audit_events +BEGIN SELECT RAISE(ABORT, 'Audit immuable'); END; +CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_events +BEGIN SELECT RAISE(ABORT, 'Audit immuable'); END; +CREATE TRIGGER account_identity_immutable BEFORE UPDATE OF id, provider ON accounts +BEGIN SELECT RAISE(ABORT, 'Identité du compte immuable'); END; +CREATE TRIGGER accounts_no_delete BEFORE DELETE ON accounts +BEGIN SELECT RAISE(ABORT, 'Archivez le compte au lieu de le supprimer'); END; +PRAGMA user_version = 2; diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 47e84e3..ad57867 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -16,10 +16,7 @@ pub struct SetupInput { pub pin: String, pub recovery_password: String, pub initial_capital: Money, - pub orange_money: Money, - pub wave: Money, - pub djamo: Money, - pub cash: Money, + pub accounts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -72,6 +69,7 @@ impl AccountBalances { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Inventory { + pub account_balances: Vec, pub id: String, pub kind: String, pub closed_at: String, @@ -89,15 +87,13 @@ pub struct Inventory { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InventoryPreviewInput { - pub orange_money: Money, - pub wave: Money, - pub djamo: Money, - pub cash: Money, + pub balances: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InventoryPreview { + pub account_balances: Vec, pub balances: AccountBalances, pub previous_balances: AccountBalances, pub delta: AccountBalances, @@ -111,10 +107,7 @@ pub struct InventoryPreview { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CloseInventoryInput { - pub orange_money: Money, - pub wave: Money, - pub djamo: Money, - pub cash: Money, + pub balances: Vec, pub variance_category: Option, pub variance_note: Option, } @@ -133,13 +126,14 @@ pub struct InventoryCorrectionInput { pub inventory_id: String, pub amount: Money, pub direction: String, - pub payment_account: String, + pub account_id: String, pub reason: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct JournalEntry { + pub account_snapshot: AccountSnapshot, pub id: String, pub entry_type: String, pub amount: Money, @@ -158,7 +152,7 @@ pub struct JournalEntry { pub struct CreateJournalEntryInput { pub entry_type: String, pub amount: Money, - pub payment_account: String, + pub account_id: String, pub occurred_at: String, pub reference: Option, pub note: Option, @@ -174,6 +168,7 @@ pub struct ReverseEntryInput { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Debt { + pub account_snapshot: AccountSnapshot, pub id: String, pub customer_name: String, pub phone: String, @@ -191,6 +186,7 @@ pub struct Debt { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DebtPayment { + pub account_snapshot: AccountSnapshot, pub id: String, pub debt_id: String, pub amount: Money, @@ -205,7 +201,7 @@ pub struct DebtPayment { pub struct CreateDebtInput { pub customer_name: String, pub phone: String, - pub provider: String, + pub account_id: String, pub amount: Money, pub issued_at: String, pub due_date: Option, @@ -217,7 +213,7 @@ pub struct CreateDebtInput { pub struct RecordPaymentInput { pub debt_id: String, pub amount: Money, - pub account: String, + pub account_id: String, pub paid_at: String, pub note: Option, } @@ -232,6 +228,7 @@ pub struct CancelDebtInput { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Dashboard { + pub accounts: Vec, pub settings: BusinessSettings, pub last_inventory: Inventory, pub expected_capital: Money, @@ -309,3 +306,89 @@ pub struct RestoreInput { pub recovery_password: String, pub new_pin: String, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountSnapshot { + pub account_id: String, + pub provider: String, + pub name: String, + pub identifier: Option, +} + +impl AccountSnapshot { + pub fn display_name(&self) -> String { + let service = match self.provider.as_str() { + "orange_money" => "Orange Money", + "wave" => "Wave", + "djamo" => "Djamo", + "cash" => "Espèces", + other => other, + }; + match &self.identifier { + Some(identifier) => format!("{} — {} ({})", service, self.name, identifier), + None => format!("{} — {}", service, self.name), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Account { + #[serde(flatten)] + pub snapshot: AccountSnapshot, + pub active: bool, + pub last_balance: Option, + pub last_measured_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAccountInput { + pub provider: String, + pub name: String, + pub identifier: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAccountInput { + pub account_id: String, + pub name: String, + pub identifier: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpeningAccount { + pub provider: String, + pub name: String, + pub identifier: Option, + pub amount: Money, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountBalanceInput { + pub account_id: String, + pub amount: Money, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountBalanceSnapshot { + #[serde(flatten)] + pub account: AccountSnapshot, + pub amount: Money, + pub previous_amount: Option, + pub delta: Option, + pub legacy: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpeningPreview { + pub balances: AccountBalances, + pub liquidity: Money, + pub difference: Money, +} diff --git a/src-tauri/src/security.rs b/src-tauri/src/security.rs index 6d0bb97..26d9b5f 100644 --- a/src-tauri/src/security.rs +++ b/src-tauri/src/security.rs @@ -233,6 +233,11 @@ pub fn write_envelope(path: &Path, envelope: &KeyEnvelope) -> AppResult<()> { Ok(()) } +#[cfg(test)] +pub(crate) fn test_envelope(key: &[u8], pin: &str, password: &str) -> AppResult { + create_envelope_with_device(key, pin, password, &[7u8; 32]) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 230206c..e1c38ae 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -9,10 +9,9 @@ use rusqlite::Connection; use zeroize::Zeroizing; use crate::{ - db, - domain::validate_initial_allocation, + accounts, backup, db, error::{AppError, AppResult}, - models::{AccountBalances, LoginInput, SetupInput, SetupStatus}, + models::{LoginInput, SetupInput, SetupStatus}, security, }; @@ -57,6 +56,13 @@ impl AppState { }) } + fn migrate_safely(&self, connection: &Connection, key: &[u8]) -> AppResult<()> { + if db::schema_version(connection)? == 1 { + backup::before_migration(connection, key, &self.paths)?; + } + db::migrate(connection) + } + pub fn setup_status(&self) -> SetupStatus { SetupStatus { initialized: self.paths.database.exists() && self.paths.security.exists(), @@ -70,21 +76,15 @@ impl AppState { "L’application a déjà été initialisée.".into(), )); } - let balances = AccountBalances { - orange_money: input.orange_money, - wave: input.wave, - djamo: input.djamo, - cash: input.cash, - }; - validate_initial_allocation(input.initial_capital, &balances)?; + accounts::validate_opening(&input)?; let (envelope, database_key) = security::create_key_envelope(&input.pin, &input.recovery_password)?; security::write_envelope(&self.paths.security, &envelope)?; let result = (|| { let mut connection = db::open_database(&self.paths.database, &database_key)?; - db::migrate(&connection)?; - db::initialize_business(&mut connection, &input, &balances)?; + self.migrate_safely(&connection, &database_key)?; + db::initialize_business(&mut connection, &input)?; db::integrity_check(&connection)?; let settings = db::get_settings(&connection)?; *self @@ -115,7 +115,7 @@ impl AppState { let envelope = security::read_envelope(&self.paths.security)?; let database_key = security::unlock_with_pin(&envelope, &input.pin)?; let connection = db::open_database(&self.paths.database, &database_key)?; - db::migrate(&connection)?; + self.migrate_safely(&connection, &database_key)?; let settings = db::get_settings(&connection)?; *self .session @@ -170,13 +170,13 @@ impl AppState { ) -> AppResult { let key = self.active_key()?; let mut connection = db::open_database(&self.paths.database, &key)?; - db::migrate(&connection)?; + self.migrate_safely(&connection, &key)?; operation(&mut connection) } pub fn set_recovered_session(&self, key: Zeroizing>) -> AppResult<()> { let connection = db::open_database(&self.paths.database, &key)?; - db::migrate(&connection)?; + self.migrate_safely(&connection, &key)?; let settings = db::get_settings(&connection)?; *self .session diff --git a/src-tauri/src/test_support.rs b/src-tauri/src/test_support.rs new file mode 100644 index 0000000..616eb35 --- /dev/null +++ b/src-tauri/src/test_support.rs @@ -0,0 +1,151 @@ +//! Isolated fixtures: never access the real application data or OS keyring. +use super::*; + +pub fn legacy_database(connection: &Connection) { + migrate_v1(connection).unwrap(); + connection.execute_batch( + r#"INSERT INTO business_settings VALUES(1,'Boutique historique','XOF','Africa/Dakar',240,15,'2026-01-01T08:00:00Z'); + INSERT INTO inventories VALUES('opening','opening','2026-01-01T08:00:00Z',1500000,1200000,800000,1500000,0,5000000,5000000,5000000,0,NULL,NULL); + INSERT INTO inventories VALUES('regular','regular','2026-01-01T12:00:00Z',1520000,1150000,800000,1520000,30000,4990000,5000000,5020000,20000,'autre','Ancien écart'); + INSERT INTO journal_entries VALUES('sale','sale',100000,100000,'cash','2026-01-01','2026-01-01T09:00:00Z','Ticket 45','Vente',NULL); + INSERT INTO journal_entries VALUES('reverse','reversal',100000,-100000,'cash','2026-01-01','2026-01-01T09:30:00Z','Correction sale','Erreur','sale'); + INSERT INTO journal_entries VALUES('correction','inventory_correction',25000,-25000,'orange_money','2026-01-01','2026-01-01T10:00:00Z','inventory:opening','Comptage',NULL); + INSERT INTO debts VALUES('debt','Awa Ndiaye','771234567','wave',50000,30000,'2026-01-01',NULL,'Transfert','partial',NULL,'2026-01-01T10:30:00Z'); + INSERT INTO debt_payments VALUES('payment','debt',20000,'cash','2026-01-01','Acompte','2026-01-01T11:00:00Z'); + INSERT INTO audit_events VALUES('audit-legacy','inventory_closed','inventory','regular','{"variance":20000}','2026-01-01T12:00:00Z');"# + ).unwrap(); +} + +pub fn historical_data(connection: &Connection) -> Vec> { + let queries = [ + "SELECT * FROM business_settings", + "SELECT * FROM inventories ORDER BY id", + "SELECT id,entry_type,amount,signed_amount,payment_account,occurred_at,posted_at,reference,note,reverses_id FROM journal_entries ORDER BY id", + "SELECT id,customer_name,phone,provider,principal,remaining,issued_at,due_date,note,status,cancellation_reason,created_at FROM debts ORDER BY id", + "SELECT id,debt_id,amount,account,paid_at,note,created_at FROM debt_payments ORDER BY id", + "SELECT * FROM audit_events WHERE id='audit-legacy'", + ]; + queries + .into_iter() + .flat_map(|sql| { + let mut statement = connection.prepare(sql).unwrap(); + let columns = statement.column_count(); + statement + .query_map([], |r| (0..columns).map(|i| r.get(i)).collect()) + .unwrap() + .collect::, _>>() + .unwrap() + }) + .collect() +} + +pub fn multi_setup() -> SetupInput { + SetupInput { + business_name: "Boutique SIM".into(), + pin: "123456".into(), + recovery_password: "une phrase de récupération solide".into(), + initial_capital: 5_000_000, + accounts: [ + ("orange_money", "Orange 1", 1_000_000), + ("orange_money", "Orange 2", 500_000), + ("wave", "Wave 1", 600_000), + ("wave", "Wave 2", 600_000), + ("djamo", "Djamo 1", 800_000), + ("cash", "Espèces", 1_500_000), + ] + .into_iter() + .map(|(provider, name, amount)| OpeningAccount { + provider: provider.into(), + name: name.into(), + amount, + identifier: Some(format!("SIM-{name}")), + }) + .collect(), + } +} + +pub fn multi_database() -> Connection { + let mut db = Connection::open_in_memory().unwrap(); + migrate(&db).unwrap(); + initialize_business(&mut db, &multi_setup()).unwrap(); + db +} + +pub fn id(db: &Connection, name: &str) -> String { + accounts::list(db) + .unwrap() + .into_iter() + .find(|a| a.snapshot.name == name) + .unwrap() + .snapshot + .account_id +} + +pub fn balances(db: &Connection) -> Vec { + accounts::list(db) + .unwrap() + .into_iter() + .filter(|a| a.active) + .map(|a| AccountBalanceInput { + account_id: a.snapshot.account_id, + amount: a.last_balance.unwrap_or(0), + }) + .collect() +} + +pub fn journal(db: &mut Connection, id: &str) -> JournalEntry { + create_journal_entry( + db, + CreateJournalEntryInput { + entry_type: "sale".into(), + amount: 10_000, + account_id: id.into(), + occurred_at: "2026-09-01".into(), + reference: Some("Reçu test".into()), + note: None, + }, + ) + .unwrap() +} + +pub fn debt(db: &mut Connection, id: &str) -> Debt { + create_debt( + db, + CreateDebtInput { + customer_name: "Awa Ndiaye".into(), + phone: "771234567".into(), + account_id: id.into(), + amount: 50_000, + issued_at: "2026-09-01".into(), + due_date: None, + note: None, + }, + ) + .unwrap() +} + +pub fn payment(db: &mut Connection, debt_id: &str, id: &str, amount: i64) -> Debt { + record_debt_payment( + db, + RecordPaymentInput { + debt_id: debt_id.into(), + amount, + account_id: id.into(), + paid_at: "2026-09-01".into(), + note: None, + }, + ) + .unwrap() +} + +pub fn close(db: &mut Connection, balances: Vec) -> Inventory { + close_inventory( + db, + CloseInventoryInput { + balances, + variance_category: Some("autre".into()), + variance_note: Some("Rapprochement de test".into()), + }, + ) + .unwrap() +} diff --git a/src/api.ts b/src/api.ts index 8e3163d..f39a73d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { + Account, AccountBalanceInput, OpeningAccount, OpeningPreview, CreateAccountInput, UpdateAccountInput, + CloseInventoryInput, InventoryCorrectionInput, CreateJournalEntryInput, CreateDebtInput, RecordPaymentInput, AuditEvent, BackupInfo, BusinessSettings, @@ -35,6 +37,12 @@ async function call(command: string, args?: Record): Promise } export const api = { + accounts: () => call("list_accounts"), + createAccount: (input: CreateAccountInput) => call("create_account", { input }), + updateAccount: (input: UpdateAccountInput) => call("update_account", { input }), + archiveAccount: (accountId: string) => call("archive_account", { accountId }), + reactivateAccount: (accountId: string) => call("reactivate_account", { accountId }), + previewOpening: (accounts: OpeningAccount[], initialCapital: number) => call("preview_opening", { accounts, initialCapital }), checkSetup: () => { const tauriAvailable = Boolean((window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__); return !tauriAvailable && import.meta.env.DEV @@ -45,21 +53,21 @@ export const api = { login: (pin: string) => call("login", { input: { pin } }), lock: () => call("lock_app"), dashboard: () => call("get_dashboard"), - previewInventory: (input: Record) => + previewInventory: (input: { balances: AccountBalanceInput[] }) => call("preview_inventory", { input }), - closeInventory: (input: Record) => + closeInventory: (input: CloseInventoryInput) => call("close_inventory", { input }), - correctInventory: (input: Record) => + correctInventory: (input: InventoryCorrectionInput) => call("create_inventory_correction", { input }), inventories: () => call("list_inventories"), journal: () => call("list_journal_entries"), - createJournal: (input: Record) => + createJournal: (input: CreateJournalEntryInput) => call("create_journal_entry", { input }), reverseJournal: (entryId: string, reason: string) => call("reverse_journal_entry", { input: { entryId, reason } }), debts: () => call("list_debts"), - createDebt: (input: Record) => call("create_debt", { input }), - payDebt: (input: Record) => call("record_debt_payment", { input }), + createDebt: (input: CreateDebtInput) => call("create_debt", { input }), + payDebt: (input: RecordPaymentInput) => call("record_debt_payment", { input }), cancelDebt: (debtId: string, reason: string) => call("cancel_debt", { input: { debtId, reason } }), report: (filters: ReportFilters) => call("get_report", { filters }), diff --git a/src/components/AccountSelect.tsx b/src/components/AccountSelect.tsx new file mode 100644 index 0000000..dee575c --- /dev/null +++ b/src/components/AccountSelect.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; +import { api } from "../api"; +import type { Account, AccountSnapshot, Provider } from "../types"; +import { label } from "../lib/format"; +import { SelectInput } from "./Fields"; + +export const providers: Provider[] = ["orange_money", "wave", "djamo", "cash"]; +export function accountLabel(account: AccountSnapshot) { + return account.name + (account.identifier ? ` — ${account.identifier}` : ""); +} +export function accountDisplayLabel(account: AccountSnapshot) { + return `${label(account.provider)} — ${accountLabel(account)}`; +} + +export function AccountSelect({ value, onChange, mobileOnly = false }: { + value: string; onChange: (id: string) => void; mobileOnly?: boolean; +}) { + const [accounts, setAccounts] = useState([]); + const [error, setError] = useState(""); + useEffect(() => { + let current = true; + api.accounts().then((items) => { if (current) setAccounts(items); }) + .catch((e) => { if (current) setError(String(e)); }); + return () => { current = false; }; + }, []); + return <> + onChange(e.target.value)}> + + {providers.filter((p) => !mobileOnly || p !== "cash").map((provider) => { + const available = accounts.filter((a) => a.active && a.provider === provider); + return available.length > 0 && + {available.map((a) => )} + ; + })} + + {error && {error}} + ; +} diff --git a/src/components/OpeningAccountsEditor.tsx b/src/components/OpeningAccountsEditor.tsx new file mode 100644 index 0000000..3404416 --- /dev/null +++ b/src/components/OpeningAccountsEditor.tsx @@ -0,0 +1,31 @@ +import { Field, MoneyInput, SelectInput, TextInput } from "./Fields"; +import { providers } from "./AccountSelect"; +import { label } from "../lib/format"; +import type { OpeningAccount } from "../types"; + +export function OpeningAccountsEditor({ accounts, onChange }: { + accounts: OpeningAccount[]; onChange: (accounts: OpeningAccount[]) => void; +}) { + function update(index: number, patch: Partial) { + onChange(accounts.map((a, i) => i === index ? { ...a, ...patch } : a)); + } + function add() { + let number = 1; + while (accounts.some((a) => a.provider === "orange_money" && a.name === `Orange SIM ${number}`)) number++; + onChange([...accounts, { provider: "orange_money", name: `Orange SIM ${number}`, amount: 0, identifier: "" }]); + } + return
+ {accounts.map((a, i) =>
+
+ update(i, { provider: e.target.value as OpeningAccount["provider"] })}> + {providers.filter((p) => p !== "cash" || a.provider === "cash").map((p) => )} + + update(i, { name: e.target.value })} /> + {a.provider !== "cash" && update(i, { identifier: e.target.value })} />} + update(i, { amount: e.target.value === "" ? NaN : Number(e.target.value) })} /> +
+ {a.provider !== "cash" && } +
)} + +
; +} diff --git a/src/screens/AccountsSettings.tsx b/src/screens/AccountsSettings.tsx new file mode 100644 index 0000000..661ae99 --- /dev/null +++ b/src/screens/AccountsSettings.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { api } from "../api"; +import { accountLabel, providers } from "../components/AccountSelect"; +import { Field, SelectInput, TextInput } from "../components/Fields"; +import { Modal } from "../components/Modal"; +import { formatMoney, formatDate, label } from "../lib/format"; +import type { Account, MobileProvider } from "../types"; + +export function AccountsSettings({ notify, onChanged }: { notify: (message: string) => void; onChanged: () => void }) { + const [accounts, setAccounts] = useState([]); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(); + const [form, setForm] = useState({ provider: "orange_money" as MobileProvider, name: "", identifier: "" }); + async function load() { setAccounts(await api.accounts()); } + useEffect(() => { load().catch((e) => setError(String(e))); }, []); + function start(account?: Account) { + setEditing(account); setError(""); + setForm({ provider: (account?.provider ?? "orange_money") as MobileProvider, name: account?.name ?? "", identifier: account?.identifier ?? "" }); + setOpen(true); + } + async function save(e: FormEvent) { + e.preventDefault(); setError(""); setBusy(true); + try { + if (editing) await api.updateAccount({ accountId: editing.accountId, name: form.name, identifier: form.identifier || null }); + else await api.createAccount({ ...form, identifier: form.identifier || null }); + setOpen(false); await load(); onChanged(); notify(editing ? "Compte mis à jour. L’historique est conservé." : "Compte créé. Son solde sera saisi au prochain inventaire."); + } catch (reason) { setError(String(reason)); } finally { setBusy(false); } + } + async function toggle(account: Account) { + setError(""); setBusy(true); + try { + if (account.active) await api.archiveAccount(account.accountId); + else await api.reactivateAccount(account.accountId); + await load(); onChanged(); notify(account.active ? "Compte archivé." : "Compte réactivé."); + } catch (reason) { setError(String(reason)); } finally { setBusy(false); } + } + return
+

Comptes et SIM

Plusieurs comptes par service, une seule caisse espèces.

+

Créer un compte ne modifie pas le capital. Renseignez son solde au prochain inventaire ; enregistrez tout apport dans le journal.

+ {error &&
{error}
} + {providers.map((provider) =>
+

{label(provider)}

+ {accounts.filter((a) => a.provider === provider).map((a) =>
+
{accountLabel(a)}{a.active ? "Actif" : "Archivé"} · {a.lastBalance == null ? "Pas encore relevé" : `${formatMoney(a.lastBalance)} au ${formatDate(a.lastMeasuredAt ?? "")}`}
+
+ {a.provider !== "cash" && }
+
)} + {!accounts.some((a) => a.provider === provider) &&

Aucun compte pour ce service.

} +
)} +

Pour archiver un compte utilisé, clôturez un inventaire avec un solde nul. Aucune nouvelle écriture ne doit avoir été enregistrée sur ce compte depuis ce relevé.

+ setOpen(false)}> +
+ setForm({ ...form, provider: e.target.value as MobileProvider })}>{providers.filter((p) => p !== "cash" || editing?.provider === "cash").map((p) => )} + setForm({ ...form, name: e.target.value })} placeholder="Ex. Orange SIM comptoir" /> + {editing?.provider !== "cash" && setForm({ ...form, identifier: e.target.value })} placeholder="77 123 45 67" />} + {error &&
{error}
} +
+
+
+
; +} diff --git a/src/screens/AuthScreens.tsx b/src/screens/AuthScreens.tsx index 54a9723..d1d1aa5 100644 --- a/src/screens/AuthScreens.tsx +++ b/src/screens/AuthScreens.tsx @@ -1,6 +1,7 @@ -import { useMemo, useState, type FormEvent } from "react"; +import { OpeningAccountsEditor } from "../components/OpeningAccountsEditor"; +import { useEffect, useState, type FormEvent } from "react"; import { ArrowRight, KeyRound, LockKeyhole, ShieldCheck, Store, WalletCards } from "lucide-react"; -import type { Dashboard, SetupInput } from "../types"; +import type { Dashboard, SetupInput, OpeningPreview } from "../types"; import { api } from "../api"; import { Field, MoneyInput, TextInput } from "../components/Fields"; import { formatMoney } from "../lib/format"; @@ -62,10 +63,12 @@ const initial: SetupInput = { pin: "", recoveryPassword: "", initialCapital: 5_000_000, - orangeMoney: 0, - wave: 0, - djamo: 0, - cash: 0 + accounts: [ + { provider: "orange_money", name: "Orange Money — Principal", amount: 0 }, + { provider: "wave", name: "Wave — Principal", amount: 0 }, + { provider: "djamo", name: "Djamo — Principal", amount: 0 }, + { provider: "cash", name: "Espèces", amount: 0 } + ] }; export function SetupScreen({ onSuccess }: { onSuccess: (dashboard: Dashboard) => void }) { @@ -75,12 +78,21 @@ export function SetupScreen({ onSuccess }: { onSuccess: (dashboard: Dashboard) = const [recoveryConfirm, setRecoveryConfirm] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); - const allocated = form.orangeMoney + form.wave + form.djamo + form.cash; - const difference = form.initialCapital - allocated; - const distributionPercent = useMemo( - () => form.initialCapital > 0 ? Math.min(100, Math.max(0, (allocated / form.initialCapital) * 100)) : 0, - [allocated, form.initialCapital] - ); + const [allocation, setAllocation] = useState(); + const [allocationError, setAllocationError] = useState(""); + useEffect(() => { + let current = true; + setAllocation(undefined); + const timer = setTimeout(() => { + if (form.accounts.some((a) => !Number.isSafeInteger(a.amount) || a.amount < 0)) { + setAllocationError("Renseignez un montant entier positif ou nul sur chaque compte."); return; + } + api.previewOpening(form.accounts, form.initialCapital).then((result) => { + if (current) { setAllocation(result); setAllocationError(""); } + }).catch((e) => { if (current) setAllocationError(String(e)); }); + }, 200); + return () => { current = false; clearTimeout(timer); }; + }, [form.accounts, form.initialCapital]); function patchValue(key: keyof SetupInput, value: string | number) { setForm((current) => ({ ...current, [key]: value })); @@ -89,7 +101,7 @@ export function SetupScreen({ onSuccess }: { onSuccess: (dashboard: Dashboard) = function next() { setError(""); if (step === 1 && form.businessName.trim().length < 2) return setError("Indiquez le nom de la boutique."); - if (step === 2 && difference !== 0) return setError("La répartition doit être exactement égale au capital initial."); + if (step === 2 && (!allocation || allocation.difference !== 0 || form.initialCapital <= 0)) return setError("La répartition doit être exactement égale au capital initial."); setStep((value) => Math.min(3, value + 1)); } @@ -155,29 +167,18 @@ export function SetupScreen({ onSuccess }: { onSuccess: (dashboard: Dashboard) =

ÉTAPE 2 SUR 3

Répartissez le capital initial

-

La somme des quatre comptes doit être exactement égale au capital indiqué.

+

Ajoutez vos SIM et répartissez le capital entre tous les comptes et la caisse.

patchValue("initialCapital", Number(e.target.value))} /> FCFA
-
- {([ - ["orangeMoney", "Orange Money", "orange"], - ["wave", "Wave", "wave"], - ["djamo", "Djamo", "djamo"], - ["cash", "Espèces", "cash"] - ] as const).map(([key, label, color]) => ( - -
patchValue(key, Number(e.target.value))} />
-
- ))} -
+ { setAllocation(undefined); setForm((current) => ({ ...current, accounts })); }} /> + {allocationError &&
{allocationError}
}
-
Réparti{formatMoney(allocated)}
-
Reste à répartir{formatMoney(difference)}
-
+
Réparti{allocation ? formatMoney(allocation.liquidity) : "Calcul…"}
+
Reste à répartir{allocation ? formatMoney(allocation.difference) : "—"}
)} diff --git a/src/screens/DashboardPage.tsx b/src/screens/DashboardPage.tsx index 2b44e49..719d0bb 100644 --- a/src/screens/DashboardPage.tsx +++ b/src/screens/DashboardPage.tsx @@ -1,3 +1,4 @@ +import { accountLabel } from "../components/AccountSelect"; import { AlertTriangle, ArrowDownRight, @@ -76,6 +77,16 @@ export function DashboardPage({ dashboard, onNavigate }: { dashboard: Dashboard; 0 ? "up" : delta < 0 ? "down" : ""}`}>{delta > 0 ? : delta < 0 ? : null}{signed(delta)} ))} + {accounts.map(({ label: service, color }) => { + const provider = service === "Orange Money" ? "orange_money" : service === "Espèces" ? "cash" : service.toLowerCase(); + const details = inventory.accountBalances.filter((a) => a.provider === provider); + const unmeasured = dashboard.accounts.filter((a) => a.active && a.provider === provider && !details.some((d) => d.accountId === a.accountId)); + return
+ Comptes {service} ({details.length + unmeasured.length}) + {details.map((a) =>
{accountLabel(a)}{a.legacy && Solde historique regroupé}{formatMoney(a.amount)}
)} + {unmeasured.map((a) =>
{accountLabel(a)}{a.lastBalance == null ? "Pas encore relevé" : "Réactivé — à relever au prochain inventaire"}
)} +
; + })} @@ -89,7 +100,7 @@ export function DashboardPage({ dashboard, onNavigate }: { dashboard: Dashboard;

Actions rapides

Enregistrer un mouvement

- + {dashboard.overdueDebtsCount > 0 &&
{dashboard.overdueDebtsCount} dette{dashboard.overdueDebtsCount > 1 ? "s" : ""} en retardConsultez les échéances clients.
}
diff --git a/src/screens/DebtsPage.tsx b/src/screens/DebtsPage.tsx index 888945e..5975470 100644 --- a/src/screens/DebtsPage.tsx +++ b/src/screens/DebtsPage.tsx @@ -1,12 +1,13 @@ +import { AccountSelect, accountDisplayLabel } from "../components/AccountSelect"; import { useEffect, useMemo, useState, type FormEvent } from "react"; import { AlertTriangle, CalendarDays, CheckCircle2, Eye, HandCoins, Phone, Plus, Search, UserRound, WalletCards } from "lucide-react"; import { api } from "../api"; -import { Field, MoneyInput, SelectInput, TextArea, TextInput } from "../components/Fields"; +import { Field, MoneyInput, TextArea, TextInput } from "../components/Fields"; import { Modal } from "../components/Modal"; import { formatDate, formatMoney, label, today } from "../lib/format"; import type { Debt } from "../types"; -const initialDebt = { customerName: "", phone: "", provider: "wave", amount: 0, issuedAt: today(), dueDate: "", note: "" }; +const initialDebt = { customerName: "", phone: "", accountId: "", amount: 0, issuedAt: today(), dueDate: "", note: "" }; export function DebtsPage({ onChanged, notify }: { onChanged: () => void; notify: (message: string) => void }) { const [items, setItems] = useState([]); @@ -14,7 +15,7 @@ export function DebtsPage({ onChanged, notify }: { onChanged: () => void; notify const [form, setForm] = useState(initialDebt); const [selected, setSelected] = useState(); const [paymentOpen, setPaymentOpen] = useState(false); - const [payment, setPayment] = useState({ amount: 0, account: "cash", paidAt: today(), note: "" }); + const [payment, setPayment] = useState({ amount: 0, accountId: "", paidAt: today(), note: "" }); const [cancelOpen, setCancelOpen] = useState(false); const [cancelReason, setCancelReason] = useState(""); const [query, setQuery] = useState(""); @@ -48,7 +49,7 @@ export function DebtsPage({ onChanged, notify }: { onChanged: () => void; notify event.preventDefault(); if (!selected) return; setLoading(true); setError(""); try { await api.payDebt({ debtId: selected.id, ...payment, note: payment.note || null }); - setPaymentOpen(false); setPayment({ amount: 0, account: "cash", paidAt: today(), note: "" }); await load(selected.id); onChanged(); notify("Remboursement enregistré."); + setPaymentOpen(false); setPayment({ amount: 0, accountId: "", paidAt: today(), note: "" }); await load(selected.id); onChanged(); notify("Remboursement enregistré."); } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } finally { setLoading(false); } } @@ -67,19 +68,19 @@ export function DebtsPage({ onChanged, notify }: { onChanged: () => void; notify
Total restant dû{formatMoney(totalOpen)}{items.filter((debt) => ["open", "partial", "overdue"].includes(debt.status)).length} créance(s) active(s)
En retard{overdue.length}{formatMoney(overdue.reduce((sum, debt) => sum + debt.remaining, 0))} à relancer
Dettes soldées{items.filter((debt) => debt.status === "paid").length}Historique conservé
setQuery(e.target.value)} />
- {visible.length === 0 ?

Aucune dette dans cette vue

Les transferts à crédit apparaîtront ici.

:
{visible.map((debt) => )}
ClientServiceDate / échéanceMontant initialReste dûStatut
{debt.customerName} {debt.phone}{label(debt.provider)}{formatDate(debt.issuedAt)}{debt.dueDate ? `Échéance ${formatDate(debt.dueDate)}` : "Sans échéance"}{formatMoney(debt.principal)}{formatMoney(debt.remaining)}{debt.principal > 0 ? `${Math.round(((debt.principal - debt.remaining) / debt.principal) * 100)} % remboursé` : ""}{label(debt.status)}
} + {visible.length === 0 ?

Aucune dette dans cette vue

Les transferts à crédit apparaîtront ici.

:
{visible.map((debt) => )}
ClientCompteDate / échéanceMontant initialReste dûStatut
{debt.customerName} {debt.phone}{accountDisplayLabel(debt.accountSnapshot)}{formatDate(debt.issuedAt)}{debt.dueDate ? `Échéance ${formatDate(debt.dueDate)}` : "Sans échéance"}{formatMoney(debt.principal)}{formatMoney(debt.remaining)}{debt.principal > 0 ? `${Math.round(((debt.principal - debt.remaining) / debt.principal) * 100)} % remboursé` : ""}{label(debt.status)}
}
setCreateOpen(false)}> -
setForm({ ...form, customerName: e.target.value })} required /> setForm({ ...form, phone: e.target.value })} placeholder="77 123 45 67" required />
setForm({ ...form, provider: e.target.value })}> setForm({ ...form, amount: Number(e.target.value) })} required />
setForm({ ...form, issuedAt: e.target.value })} required /> setForm({ ...form, dueDate: e.target.value })} />