diff --git a/README.md b/README.md index 2662e3f..6896457 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,19 @@ Application desktop locale de gestion d’un multiservices au Sénégal : invent - clé quotidienne protégée par le PIN et le coffre système (Trousseau macOS ou Gestionnaire d’identifiants Windows) ; - sauvegardes automatiques chiffrées et restauration par mot de passe de récupération. +## Produits et ventes + +Le catalogue permet de saisir le prix de vente et le stock initial déjà détenu (sans dépense supplémentaire). Le gérant peut ensuite : + +- vendre plusieurs produits à la fois avec un prix ajustable et un compte d’encaissement précis ; +- recevoir du stock en enregistrant automatiquement le montant total payé comme achat ; +- corriger les quantités après comptage, avec un motif et sans mouvement d’argent ; +- annuler une opération complète, depuis son détail ou le journal, avec une correction simultanée du stock et du budget. + +Les ventes augmentent le **Capital attendu** du montant encaissé et les achats le diminuent. Les soldes vérifiés restent ceux du dernier inventaire financier. Le stock est suivi en unités entières et sa valeur n’est pas ajoutée au capital. L’historique conserve les noms et tarifs d’origine. Un produit à stock nul peut être archivé ; annuler une ancienne vente le réactive si des articles reviennent en stock. + +Les écritures automatiques figurent déjà dans les rapports et exports : ne les ressaisissez pas dans le journal. La migration vers le schéma 3 conserve les comptes et données précédentes, avec sauvegarde chiffrée préalable et prise en charge des anciennes sauvegardes. + ## Architecture - `src/` : interface React 19 + TypeScript + Vite ; @@ -121,5 +134,5 @@ Le mot de passe de récupération doit être conservé hors du PC. Il est indisp - un seul PC, une seule boutique et un seul profil gérant ; - pas de synchronisation cloud ; - pas de connexion aux API Orange Money, Wave ou Djamo ; -- pas de gestion de stock ; +- ventes de produits payées intégralement, sans crédit ni retour partiel ; - les soldes affichés sont ceux du dernier inventaire validé. diff --git a/src-tauri/src/account_tests.rs b/src-tauri/src/account_tests.rs index 641da51..af2d27f 100644 --- a/src-tauri/src/account_tests.rs +++ b/src-tauri/src/account_tests.rs @@ -366,7 +366,7 @@ fn v1_migration_is_atomic_preserves_all_original_columns_and_runs_once() { legacy_database(&db); let before = historical_data(&db); migrate(&db).unwrap(); - assert_eq!(schema_version(&db).unwrap(), 2); + assert_eq!(schema_version(&db).unwrap(), crate::db::SCHEMA_VERSION); assert_eq!(historical_data(&db), before); assert_eq!(accounts::list(&db).unwrap().len(), 4); let inventories = list_inventories(&db, None).unwrap(); diff --git a/src-tauri/src/backup.rs b/src-tauri/src/backup.rs index 2989a2b..139a03f 100644 --- a/src-tauri/src/backup.rs +++ b/src-tauri/src/backup.rs @@ -340,7 +340,10 @@ mod tests { 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); + assert_eq!( + db::schema_version(&connection).unwrap(), + crate::db::SCHEMA_VERSION + ); let backups = list_backups(&state).unwrap(); assert_eq!(backups.len(), 1); let extracted = extract_and_validate(Path::new(&backups[0].path)).unwrap(); @@ -356,7 +359,7 @@ mod tests { } #[test] - fn restores_v1_and_v2_archives_in_staging_and_preserves_multi_account_history() { + fn restores_legacy_and_current_archives_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(); @@ -406,9 +409,9 @@ mod tests { Ok(()) }) .unwrap(); - let v2 = create_backup(&source, None).unwrap(); + let current = create_backup(&source, None).unwrap(); let expected = source.with_connection(|db| Ok(snapshot(db))).unwrap(); - for (version, backup) in [(1, v1), (2, v2)] { + for (version, backup) in [(1, v1), (db::SCHEMA_VERSION, current)] { let target_dir = TempDir::new().unwrap(); let target = AppState::new(target_dir.path().to_path_buf()).unwrap(); let input = RestoreInput { @@ -416,7 +419,7 @@ mod tests { recovery_password: PASSWORD.into(), new_pin: "654321".into(), }; - if version == 2 { + if version == db::SCHEMA_VERSION { // 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(); @@ -450,7 +453,7 @@ mod tests { assert!(target.setup_status().unlocked); target .with_connection(|db| { - assert_eq!(db::schema_version(db)?, 2); + assert_eq!(db::schema_version(db)?, crate::db::SCHEMA_VERSION); if version == 1 { assert_eq!(historical_data(db), legacy); assert_eq!(accounts::list(db)?.len(), 4); @@ -470,6 +473,142 @@ mod tests { } } + #[test] + fn restores_v2_then_roundtrips_stock_and_idempotency_in_encrypted_v3_backup() { + use crate::stock; + 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![41u8; 32]; + let connection = db::open_database(&source.paths.database, &key).unwrap(); + legacy_database(&connection); + connection + .pragma_update(None, "foreign_keys", false) + .unwrap(); + connection + .execute_batch(include_str!("migration_v2.sql")) + .unwrap(); + connection + .pragma_update(None, "foreign_keys", true) + .unwrap(); + let historical = historical_data(&connection); + security::write_envelope( + &source.paths.security, + &security::test_envelope(&key, "123456", PASSWORD).unwrap(), + ) + .unwrap(); + let v2 = before_migration(&connection, &key, &source.paths).unwrap(); + assert_eq!( + extract_and_validate(&v2).unwrap().manifest.schema_version, + 2 + ); + drop(connection); + let restored_dir = TempDir::new().unwrap(); + let restored = AppState::new(restored_dir.path().to_path_buf()).unwrap(); + restore_backup_with( + &restored, + RestoreInput { + backup_path: v2.to_string_lossy().into(), + recovery_password: PASSWORD.into(), + new_pin: "654321".into(), + }, + security::test_envelope, + ) + .unwrap(); + let retry = restored + .with_connection(|db| { + assert_eq!(historical_data(db), historical); + assert!(stock::products(db)?.is_empty()); + let product = stock::create_product( + db, + CreateProductInput { + request_id: uuid::Uuid::new_v4().to_string(), + name: "Chargeur".into(), + price: 2000, + initial_stock: 10, + }, + )?; + let sale = CreateSaleInput { + request_id: uuid::Uuid::new_v4().to_string(), + account_id: "legacy-cash".into(), + occurred_at: "2026-09-07".into(), + note: None, + lines: vec![SaleLineInput { + product_id: product.id.clone(), + quantity: 2, + unit_price: 1750, + }], + }; + stock::create_sale(db, sale.clone())?; + let receipt = stock::receive_stock( + db, + ReceiveStockInput { + request_id: uuid::Uuid::new_v4().to_string(), + product_id: product.id.clone(), + quantity: 5, + amount: 6000, + account_id: "legacy-wave".into(), + occurred_at: "2026-09-07".into(), + note: Some("Livraison".into()), + }, + )?; + stock::cancel_operation( + db, + CancelProductOperationInput { + request_id: uuid::Uuid::new_v4().to_string(), + operation_id: receipt.id, + reason: "Achat saisi en double".into(), + }, + )?; + stock::adjust_stock( + db, + AdjustStockInput { + request_id: uuid::Uuid::new_v4().to_string(), + product_id: product.id, + quantity: 7, + reason: "Un article abîmé".into(), + }, + )?; + Ok(sale) + }) + .unwrap(); + let stock_snapshot = |db: &Connection| -> AppResult { + Ok( + serde_json::json!({ "products": stock::products(db)?, "operations": stock::operations(db)?, + "movements": stock::movements(db, None)?, "finance": snapshot(db) }), + ) + }; + let expected = restored.with_connection(|db| stock_snapshot(db)).unwrap(); + let archive = create_backup(&restored, None).unwrap(); + assert_eq!( + extract_and_validate(Path::new(&archive.path)) + .unwrap() + .manifest + .schema_version, + 3 + ); + let target_dir = TempDir::new().unwrap(); + let target = AppState::new(target_dir.path().to_path_buf()).unwrap(); + restore_backup_with( + &target, + RestoreInput { + backup_path: archive.path, + recovery_password: PASSWORD.into(), + new_pin: "123456".into(), + }, + security::test_envelope, + ) + .unwrap(); + target + .with_connection(|db| { + assert_eq!(stock_snapshot(db)?, expected); + stock::create_sale(db, retry)?; + assert_eq!(stock_snapshot(db)?, expected); + db::integrity_check(db) + }) + .unwrap(); + } + #[test] fn backup_archive_roundtrip_checks_hash_and_manifest() { let temp = TempDir::new().unwrap(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 39c9754..4fe0e7e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -277,3 +277,92 @@ pub fn preview_opening( difference, }) } + +#[tauri::command] +pub fn list_products(state: State<'_, AppState>) -> CommandResult> { + state + .with_connection(|c| crate::stock::products(c)) + .map_err(command_error) +} +#[tauri::command] +pub fn list_product_operations(state: State<'_, AppState>) -> CommandResult> { + state + .with_connection(|c| crate::stock::operations(c)) + .map_err(command_error) +} +#[tauri::command] +pub fn list_stock_movements( + product_id: Option, + state: State<'_, AppState>, +) -> CommandResult> { + state + .with_connection(|c| crate::stock::movements(c, product_id.as_deref())) + .map_err(command_error) +} + +#[tauri::command] +pub fn create_product( + input: CreateProductInput, + state: State<'_, AppState>, +) -> CommandResult { + state + .with_connection(|c| crate::stock::create_product(c, input)) + .map_err(command_error) +} + +#[tauri::command] +pub fn update_product( + input: UpdateProductInput, + state: State<'_, AppState>, +) -> CommandResult { + state + .with_connection(|c| crate::stock::update_product(c, input)) + .map_err(command_error) +} + +#[tauri::command] +pub fn archive_product( + input: ArchiveProductInput, + state: State<'_, AppState>, +) -> CommandResult { + state + .with_connection(|c| crate::stock::archive_product(c, input)) + .map_err(command_error) +} + +#[tauri::command] +pub fn adjust_stock(input: AdjustStockInput, state: State<'_, AppState>) -> CommandResult { + state + .with_connection(|c| crate::stock::adjust_stock(c, input)) + .map_err(command_error) +} + +#[tauri::command] +pub fn create_product_sale( + input: CreateSaleInput, + state: State<'_, AppState>, +) -> CommandResult { + state + .with_connection(|c| crate::stock::create_sale(c, input)) + .map_err(command_error) +} + +#[tauri::command] +pub fn receive_stock( + input: ReceiveStockInput, + state: State<'_, AppState>, +) -> CommandResult { + state + .with_connection(|c| crate::stock::receive_stock(c, input)) + .map_err(command_error) +} + +#[tauri::command] +pub fn cancel_product_operation( + input: CancelProductOperationInput, + state: State<'_, AppState>, +) -> CommandResult { + state + .with_connection(|c| crate::stock::cancel_operation(c, input)) + .map_err(command_error) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 2c694a2..93379cf 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -15,7 +15,7 @@ use crate::{ models::*, }; -pub const SCHEMA_VERSION: i64 = 2; +pub const SCHEMA_VERSION: i64 = 3; pub fn open_database(path: &Path, database_key: &[u8]) -> AppResult { let connection = Connection::open(path)?; @@ -52,33 +52,37 @@ pub fn migrate(connection: &Connection) -> AppResult<()> { 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(), - )); + if version < 2 { + 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(), + )); + } } + tx.execute_batch(include_str!("migration_v3.sql"))?; 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 { + if version > 0 { audit_tx( &tx, "schema_migrated", "business", Some("1"), - json!({"from":1,"to":2}), + json!({"from":version,"to":SCHEMA_VERSION}), )?; } tx.commit()?; @@ -654,6 +658,7 @@ pub fn create_inventory_correction( fn map_journal_row(row: &Row<'_>) -> rusqlite::Result { Ok(JournalEntry { + product_operation: None, account_snapshot: AccountSnapshot { account_id: row.get(11)?, provider: row.get(4)?, @@ -684,12 +689,15 @@ pub fn list_journal_entries( 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 + let mut entries: Vec = statement .query_map([], map_journal_row)? .collect::, _>>()? .into_iter() .filter(|entry| filters.is_none_or(|f| date_in_filter(&entry.occurred_at, f))) .collect(); + for entry in &mut entries { + entry.product_operation = crate::stock::journal_link(connection, &entry.id)?; + } Ok(entries) } @@ -698,8 +706,17 @@ pub fn create_journal_entry( input: CreateJournalEntryInput, ) -> AppResult { let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let result = create_journal_entry_tx(&tx, input)?; + tx.commit()?; + Ok(result) +} + +pub(crate) fn create_journal_entry_tx( + tx: &Transaction<'_>, + input: CreateJournalEntryInput, +) -> AppResult { let signed_amount = signed_journal_amount(&input.entry_type, input.amount)?; - let account = accounts::get(&tx, &input.account_id, true)?.snapshot; + 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); @@ -723,7 +740,7 @@ pub fn create_journal_entry( ], )?; audit_tx( - &tx, + tx, "journal_entry_created", "journal_entry", Some(&id), @@ -734,8 +751,7 @@ pub fn create_journal_entry( "account": account }), )?; - tx.commit()?; - connection + 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, 0, j.account_id, j.account_name, j.account_identifier @@ -751,6 +767,15 @@ pub fn reverse_journal_entry( input: ReverseEntryInput, ) -> AppResult { let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let result = reverse_journal_entry_tx(&tx, input)?; + tx.commit()?; + Ok(result) +} + +pub(crate) fn reverse_journal_entry_tx( + tx: &Transaction<'_>, + input: ReverseEntryInput, +) -> AppResult { let reason = clean_required(&input.reason, "Le motif", 3)?; let original: JournalEntry = tx .query_row( @@ -768,6 +793,7 @@ pub fn reverse_journal_entry( "Cette écriture est déjà une correction ou a déjà été corrigée.".into(), )); } + crate::stock::reverse_stock_tx(tx, &original.id, &reason)?; let signed_amount = original .signed_amount .checked_neg() @@ -791,15 +817,17 @@ pub fn reverse_journal_entry( original.account_snapshot.account_id, original.account_snapshot.name, original.account_snapshot.identifier ], )?; + if crate::stock::journal_link(tx, &original.id)?.is_some() { + crate::stock::check_capital(tx)?; + } audit_tx( - &tx, + tx, "journal_entry_reversed", "journal_entry", Some(&original.id), json!({ "reversalId": id, "reason": reason }), )?; - tx.commit()?; - connection + 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, 0, j.account_id, j.account_name, j.account_identifier diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1ad3db1..8b87aa5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod export; mod models; mod security; mod state; +mod stock; use tauri::Manager; @@ -26,6 +27,16 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ commands::check_setup, + commands::list_products, + commands::create_product, + commands::update_product, + commands::archive_product, + commands::adjust_stock, + commands::create_product_sale, + commands::receive_stock, + commands::list_product_operations, + commands::cancel_product_operation, + commands::list_stock_movements, commands::list_accounts, commands::create_account, commands::update_account, diff --git a/src-tauri/src/migration_v3.sql b/src-tauri/src/migration_v3.sql new file mode 100644 index 0000000..fee5b37 --- /dev/null +++ b/src-tauri/src/migration_v3.sql @@ -0,0 +1,45 @@ +CREATE TABLE products ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL CHECK(length(trim(name)) > 0), + price INTEGER NOT NULL CHECK(price > 0 AND price <= 9007199254740991), + stock INTEGER NOT NULL CHECK(stock >= 0 AND stock <= 9007199254740991), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + CHECK(active = 1 OR stock = 0) +); +CREATE TABLE product_operations ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK(kind IN ('sale','receipt')), + journal_entry_id TEXT NOT NULL UNIQUE REFERENCES journal_entries(id), + cancelled_at TEXT, + cancellation_reason TEXT +); +CREATE TABLE product_operation_lines ( + operation_id TEXT NOT NULL REFERENCES product_operations(id), + product_id TEXT NOT NULL REFERENCES products(id), + product_name TEXT NOT NULL, + quantity INTEGER NOT NULL CHECK(quantity > 0), + unit_price INTEGER CHECK(unit_price > 0), + total INTEGER NOT NULL CHECK(total > 0), + PRIMARY KEY(operation_id, product_id) +); +CREATE TABLE stock_movements ( + id TEXT PRIMARY KEY, + product_id TEXT NOT NULL REFERENCES products(id), + product_name TEXT NOT NULL, + operation_id TEXT REFERENCES product_operations(id), + kind TEXT NOT NULL CHECK(kind IN ('initial','sale','receipt','adjustment','cancellation')), + quantity INTEGER NOT NULL, + balance_after INTEGER NOT NULL CHECK(balance_after >= 0), + reason TEXT, + occurred_at TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX idx_stock_product ON stock_movements(product_id, created_at); +CREATE TABLE stock_requests ( + id TEXT PRIMARY KEY, + action TEXT NOT NULL, + input_json TEXT NOT NULL, + result_json TEXT NOT NULL +); +PRAGMA user_version = 3; diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index ad57867..7cf702e 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -133,6 +133,7 @@ pub struct InventoryCorrectionInput { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct JournalEntry { + pub product_operation: Option, pub account_snapshot: AccountSnapshot, pub id: String, pub entry_type: String, @@ -392,3 +393,133 @@ pub struct OpeningPreview { pub liquidity: Money, pub difference: Money, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Product { + pub id: String, + pub name: String, + pub price: Money, + pub stock: i64, + pub active: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateProductInput { + pub request_id: String, + pub name: String, + pub price: Money, + pub initial_stock: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProductInput { + pub request_id: String, + pub product_id: String, + pub name: String, + pub price: Money, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveProductInput { + pub request_id: String, + pub product_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdjustStockInput { + pub request_id: String, + pub product_id: String, + pub quantity: i64, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaleLineInput { + pub product_id: String, + pub quantity: i64, + pub unit_price: Money, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSaleInput { + pub request_id: String, + pub lines: Vec, + pub account_id: String, + pub occurred_at: String, + pub note: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReceiveStockInput { + pub request_id: String, + pub product_id: String, + pub quantity: i64, + pub amount: Money, + pub account_id: String, + pub occurred_at: String, + pub note: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelProductOperationInput { + pub request_id: String, + pub operation_id: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProductOperationLink { + pub id: String, + pub kind: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProductOperationLine { + pub product_id: String, + pub product_name: String, + pub quantity: i64, + pub unit_price: Option, + pub total: Money, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProductOperation { + pub id: String, + pub kind: String, + pub journal_entry_id: String, + pub account_snapshot: AccountSnapshot, + pub occurred_at: String, + pub created_at: String, + pub amount: Money, + pub note: Option, + pub cancelled_at: Option, + pub cancellation_reason: Option, + pub lines: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StockMovement { + pub id: String, + pub product_id: String, + pub product_name: String, + pub operation_id: Option, + pub kind: String, + pub quantity: i64, + pub balance_after: i64, + pub reason: Option, + pub occurred_at: String, + pub created_at: String, +} diff --git a/src-tauri/src/stock.rs b/src-tauri/src/stock.rs new file mode 100644 index 0000000..fa6e943 --- /dev/null +++ b/src-tauri/src/stock.rs @@ -0,0 +1,617 @@ +use std::collections::HashSet; + +use chrono::Utc; +use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::json; +use uuid::Uuid; + +use crate::{ + db, + domain::{clean_optional, clean_required}, + error::{AppError, AppResult}, + models::*, +}; + +// Amounts and quantities cross the JavaScript boundary as exact integers. +const MAX_INTEGER: i64 = 9_007_199_254_740_991; + +fn invalid(message: &str) -> AppError { + AppError::Validation(message.into()) +} + +fn whole(value: i64, positive: bool) -> AppResult { + if value < i64::from(positive) || value > MAX_INTEGER { + return Err(invalid( + "Montant ou quantité invalide : utilisez un entier positif dans la limite autorisée.", + )); + } + Ok(value) +} + +fn add(a: i64, b: i64) -> AppResult { + whole( + a.checked_add(b) + .ok_or_else(|| invalid("Limite de calcul dépassée."))?, + false, + ) +} + +// Keep the result with the request in the same transaction: a retry after a lost +// response returns the original result, even if the product has since changed. +fn once( + connection: &mut Connection, + request_id: &str, + action: &str, + input: &I, + work: impl FnOnce(&Transaction<'_>) -> AppResult, +) -> AppResult { + Uuid::parse_str(request_id).map_err(|_| invalid("Identifiant d’opération invalide."))?; + let payload = serde_json::to_string(input)?; + let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let previous: Option<(String, String, String)> = tx + .query_row( + "SELECT action, input_json, result_json FROM stock_requests WHERE id=?1", + [request_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional()?; + if let Some((previous_action, previous_input, result)) = previous { + if previous_action != action || previous_input != payload { + return Err(invalid("Cet identifiant a déjà été utilisé pour une autre opération. Fermez puis rouvrez le formulaire.")); + } + return Ok(serde_json::from_str(&result)?); + } + let result = work(&tx)?; + tx.execute( + "INSERT INTO stock_requests VALUES (?1,?2,?3,?4)", + params![request_id, action, payload, serde_json::to_string(&result)?], + )?; + tx.commit()?; + Ok(result) +} + +fn map_product(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Product { + id: r.get(0)?, + name: r.get(1)?, + price: r.get(2)?, + stock: r.get(3)?, + active: r.get(4)?, + }) +} + +pub fn products(connection: &Connection) -> AppResult> { + let mut stmt = connection.prepare("SELECT id,name,price,stock,active FROM products ORDER BY active DESC, name COLLATE NOCASE, id")?; + let result = stmt + .query_map([], map_product)? + .collect::, _>>()?; + Ok(result) +} + +fn product(connection: &Connection, id: &str, require_active: bool) -> AppResult { + let product = connection + .query_row( + "SELECT id,name,price,stock,active FROM products WHERE id=?1", + [id], + map_product, + ) + .optional()? + .ok_or(AppError::NotFound)?; + if require_active && !product.active { + return Err(invalid("Ce produit est archivé.")); + } + Ok(product) +} + +struct Movement<'a> { + operation_id: Option<&'a str>, + kind: &'a str, + delta: i64, + reason: Option<&'a str>, + date: &'a str, +} + +fn move_stock(tx: &Transaction<'_>, product: &Product, movement: Movement<'_>) -> AppResult<()> { + let after = product + .stock + .checked_add(movement.delta) + .ok_or_else(|| invalid("Limite de stock dépassée."))?; + if after < 0 { + return Err(invalid(&format!( + "Stock insuffisant pour {} ({} disponible).", + product.name, product.stock + ))); + } + whole(after, false)?; + // Cancelling an old sale may return an archived product to the catalogue. + tx.execute( + "UPDATE products SET stock=?1, active=CASE WHEN ?1>0 THEN 1 ELSE active END WHERE id=?2", + params![after, product.id], + )?; + tx.execute( + "INSERT INTO stock_movements (id,product_id,product_name,operation_id,kind,quantity,balance_after,reason,occurred_at,created_at) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", + params![Uuid::new_v4().to_string(), product.id, product.name, movement.operation_id, movement.kind, + movement.delta, after, movement.reason, movement.date, Utc::now().to_rfc3339()], + )?; + Ok(()) +} + +pub fn create_product( + connection: &mut Connection, + input: CreateProductInput, +) -> AppResult { + once( + connection, + &input.request_id, + "product_created", + &input, + |tx| { + let name = clean_required(&input.name, "Le nom du produit", 1)?; + whole(input.price, true)?; + whole(input.initial_stock, false)?; + let id = Uuid::new_v4().to_string(); + tx.execute( + "INSERT INTO products (id,name,price,stock,created_at) VALUES (?1,?2,?3,0,?4)", + params![id, name, input.price, Utc::now().to_rfc3339()], + )?; + let initial = product(tx, &id, true)?; + move_stock( + tx, + &initial, + Movement { + operation_id: None, + kind: "initial", + delta: input.initial_stock, + reason: Some("Marchandises déjà détenues"), + date: &Utc::now().date_naive().to_string(), + }, + )?; + db::audit_tx( + tx, + "product_created", + "product", + Some(&id), + json!({"name": name, "price": input.price, "initialStock": input.initial_stock}), + )?; + product(tx, &id, true) + }, + ) +} + +pub fn update_product( + connection: &mut Connection, + input: UpdateProductInput, +) -> AppResult { + once( + connection, + &input.request_id, + "product_updated", + &input, + |tx| { + let before = product(tx, &input.product_id, true)?; + let name = clean_required(&input.name, "Le nom du produit", 1)?; + whole(input.price, true)?; + tx.execute( + "UPDATE products SET name=?1,price=?2 WHERE id=?3", + params![name, input.price, input.product_id], + )?; + db::audit_tx( + tx, + "product_updated", + "product", + Some(&input.product_id), + json!({"before": before, "name": name, "price": input.price}), + )?; + product(tx, &input.product_id, true) + }, + ) +} + +pub fn archive_product( + connection: &mut Connection, + input: ArchiveProductInput, +) -> AppResult { + once( + connection, + &input.request_id, + "product_archived", + &input, + |tx| { + let current = product(tx, &input.product_id, true)?; + if current.stock != 0 { + return Err(invalid("Le stock doit être nul pour archiver un produit.")); + } + tx.execute( + "UPDATE products SET active=0 WHERE id=?1", + [&input.product_id], + )?; + db::audit_tx( + tx, + "product_archived", + "product", + Some(&input.product_id), + json!({"name": current.name}), + )?; + product(tx, &input.product_id, false) + }, + ) +} + +pub fn adjust_stock(connection: &mut Connection, input: AdjustStockInput) -> AppResult { + once( + connection, + &input.request_id, + "stock_adjusted", + &input, + |tx| { + let current = product(tx, &input.product_id, true)?; + whole(input.quantity, false)?; + let reason = clean_required(&input.reason, "Le motif", 3)?; + if current.stock == input.quantity { + return Err(invalid( + "La quantité comptée est identique au stock actuel.", + )); + } + move_stock( + tx, + ¤t, + Movement { + operation_id: None, + kind: "adjustment", + delta: input.quantity - current.stock, + reason: Some(&reason), + date: &Utc::now().date_naive().to_string(), + }, + )?; + db::audit_tx( + tx, + "stock_adjusted", + "product", + Some(&input.product_id), + json!({"before": current.stock, "after": input.quantity, "reason": reason}), + )?; + product(tx, &input.product_id, true) + }, + ) +} + +pub fn journal_link( + connection: &Connection, + entry_id: &str, +) -> AppResult> { + Ok(connection + .query_row( + "SELECT o.id,o.kind FROM product_operations o WHERE o.journal_entry_id=?1 + OR o.journal_entry_id=(SELECT reverses_id FROM journal_entries WHERE id=?1)", + [entry_id], + |r| { + Ok(ProductOperationLink { + id: r.get(0)?, + kind: r.get(1)?, + }) + }, + ) + .optional()?) +} + +fn operation(connection: &Connection, id: &str) -> AppResult { + let mut result = connection.query_row( + "SELECT o.id,o.kind,o.journal_entry_id,o.cancelled_at,o.cancellation_reason, + j.account_id,j.payment_account,j.account_name,j.account_identifier,j.occurred_at,j.posted_at,j.amount,j.note + FROM product_operations o JOIN journal_entries j ON j.id=o.journal_entry_id WHERE o.id=?1", [id], |r| { + Ok(ProductOperation { id: r.get(0)?, kind: r.get(1)?, journal_entry_id: r.get(2)?, + cancelled_at: r.get(3)?, cancellation_reason: r.get(4)?, + account_snapshot: AccountSnapshot { account_id: r.get(5)?, provider: r.get(6)?, name: r.get(7)?, identifier: r.get(8)? }, + occurred_at: r.get(9)?, created_at: r.get(10)?, amount: r.get(11)?, note: r.get(12)?, lines: vec![] }) + }).optional()?.ok_or(AppError::NotFound)?; + let mut stmt = connection.prepare("SELECT product_id,product_name,quantity,unit_price,total FROM product_operation_lines WHERE operation_id=?1 ORDER BY rowid")?; + result.lines = stmt + .query_map([id], |r| { + Ok(ProductOperationLine { + product_id: r.get(0)?, + product_name: r.get(1)?, + quantity: r.get(2)?, + unit_price: r.get(3)?, + total: r.get(4)?, + }) + })? + .collect::, _>>()?; + Ok(result) +} + +pub fn operations(connection: &Connection) -> AppResult> { + let mut stmt = connection.prepare("SELECT o.id FROM product_operations o JOIN journal_entries j ON j.id=o.journal_entry_id ORDER BY j.occurred_at DESC,j.posted_at DESC")?; + let ids = stmt + .query_map([], |r| r.get::<_, String>(0))? + .collect::, _>>()?; + ids.iter().map(|id| operation(connection, id)).collect() +} + +struct Posting<'a> { + id: &'a str, + kind: &'a str, + account_id: &'a str, + date: &'a str, + note: Option, + lines: Vec, +} + +fn post(tx: &Transaction<'_>, posting: Posting<'_>) -> AppResult { + let amount = posting + .lines + .iter() + .try_fold(0, |sum, line| add(sum, line.total))?; + let description = posting + .lines + .iter() + .map(|l| format!("{} × {}", l.quantity, l.product_name)) + .collect::>() + .join(", "); + let note = match clean_optional(posting.note) { + Some(note) => format!("{description} — {note}"), + None => description, + }; + let entry = db::create_journal_entry_tx( + tx, + CreateJournalEntryInput { + entry_type: if posting.kind == "sale" { + "sale" + } else { + "purchase" + } + .into(), + amount, + account_id: posting.account_id.into(), + occurred_at: posting.date.into(), + reference: Some(format!( + "{} {}", + if posting.kind == "sale" { + "Vente" + } else { + "Réapprovisionnement" + }, + posting.id + )), + note: Some(note), + }, + )?; + check_capital(tx)?; + tx.execute( + "INSERT INTO product_operations (id,kind,journal_entry_id) VALUES (?1,?2,?3)", + params![posting.id, posting.kind, entry.id], + )?; + for line in &posting.lines { + tx.execute( + "INSERT INTO product_operation_lines VALUES (?1,?2,?3,?4,?5,?6)", + params![ + posting.id, + line.product_id, + line.product_name, + line.quantity, + line.unit_price, + line.total + ], + )?; + let current = product(tx, &line.product_id, true)?; + move_stock( + tx, + ¤t, + Movement { + operation_id: Some(posting.id), + kind: posting.kind, + delta: if posting.kind == "sale" { + -line.quantity + } else { + line.quantity + }, + reason: None, + date: posting.date, + }, + )?; + } + db::audit_tx( + tx, + if posting.kind == "sale" { + "product_sale_created" + } else { + "stock_received" + }, + "product_operation", + Some(posting.id), + json!({"amount": amount, "journalEntryId": entry.id, "lines": posting.lines}), + )?; + operation(tx, posting.id) +} + +pub(crate) fn check_capital(connection: &Connection) -> AppResult<()> { + let capital = db::get_dashboard(connection)?.expected_capital; + if !(-MAX_INTEGER..=MAX_INTEGER).contains(&capital) { + return Err(invalid("Le capital attendu dépasse la limite autorisée.")); + } + Ok(()) +} + +pub fn create_sale( + connection: &mut Connection, + input: CreateSaleInput, +) -> AppResult { + once( + connection, + &input.request_id, + "product_sale_created", + &input, + |tx| { + if input.lines.is_empty() { + return Err(invalid("Ajoutez au moins un produit à la vente.")); + } + let mut seen = HashSet::new(); + let mut lines = Vec::new(); + for line in &input.lines { + if !seen.insert(&line.product_id) { + return Err(invalid( + "Regroupez les quantités d’un même produit sur une seule ligne.", + )); + } + whole(line.quantity, true)?; + whole(line.unit_price, true)?; + let current = product(tx, &line.product_id, true)?; + let total = whole( + line.quantity + .checked_mul(line.unit_price) + .ok_or_else(|| invalid("Le total dépasse la limite autorisée."))?, + true, + )?; + lines.push(ProductOperationLine { + product_id: current.id, + product_name: current.name, + quantity: line.quantity, + unit_price: Some(line.unit_price), + total, + }); + } + post( + tx, + Posting { + id: &input.request_id, + kind: "sale", + account_id: &input.account_id, + date: &input.occurred_at, + note: input.note.clone(), + lines, + }, + ) + }, + ) +} + +pub fn receive_stock( + connection: &mut Connection, + input: ReceiveStockInput, +) -> AppResult { + once( + connection, + &input.request_id, + "stock_received", + &input, + |tx| { + whole(input.quantity, true)?; + whole(input.amount, true)?; + let current = product(tx, &input.product_id, true)?; + post( + tx, + Posting { + id: &input.request_id, + kind: "receipt", + account_id: &input.account_id, + date: &input.occurred_at, + note: input.note.clone(), + lines: vec![ProductOperationLine { + product_id: current.id, + product_name: current.name, + quantity: input.quantity, + unit_price: None, + total: input.amount, + }], + }, + ) + }, + ) +} + +// Called by the journal reversal itself, so no caller can reverse money alone. +pub(crate) fn reverse_stock_tx( + tx: &Transaction<'_>, + journal_id: &str, + reason: &str, +) -> AppResult<()> { + let Some(link) = journal_link(tx, journal_id)? else { + return Ok(()); + }; + let original = operation(tx, &link.id)?; + if original.cancelled_at.is_some() { + return Err(invalid("Cette opération est déjà annulée.")); + } + for line in &original.lines { + let current = product(tx, &line.product_id, false)?; + move_stock( + tx, + ¤t, + Movement { + operation_id: Some(&original.id), + kind: "cancellation", + delta: if original.kind == "sale" { + line.quantity + } else { + -line.quantity + }, + reason: Some(reason), + date: &Utc::now().date_naive().to_string(), + }, + )?; + } + tx.execute( + "UPDATE product_operations SET cancelled_at=?1,cancellation_reason=?2 WHERE id=?3", + params![Utc::now().to_rfc3339(), reason, original.id], + )?; + db::audit_tx( + tx, + "product_operation_cancelled", + "product_operation", + Some(&original.id), + json!({"reason": reason, "kind": original.kind}), + )?; + Ok(()) +} + +pub fn cancel_operation( + connection: &mut Connection, + input: CancelProductOperationInput, +) -> AppResult { + once( + connection, + &input.request_id, + "product_operation_cancelled", + &input, + |tx| { + let original = operation(tx, &input.operation_id)?; + db::reverse_journal_entry_tx( + tx, + ReverseEntryInput { + entry_id: original.journal_entry_id, + reason: input.reason.clone(), + }, + )?; + operation(tx, &input.operation_id) + }, + ) +} + +pub fn movements( + connection: &Connection, + product_id: Option<&str>, +) -> AppResult> { + let mut stmt = connection.prepare("SELECT id,product_id,product_name,operation_id,kind,quantity,balance_after,reason,occurred_at,created_at FROM stock_movements WHERE (?1 IS NULL OR product_id=?1) ORDER BY created_at DESC,rowid DESC")?; + let result = stmt + .query_map([product_id], |r| { + Ok(StockMovement { + id: r.get(0)?, + product_id: r.get(1)?, + product_name: r.get(2)?, + operation_id: r.get(3)?, + kind: r.get(4)?, + quantity: r.get(5)?, + balance_after: r.get(6)?, + reason: r.get(7)?, + occurred_at: r.get(8)?, + created_at: r.get(9)?, + }) + })? + .collect::, _>>()?; + Ok(result) +} + +#[cfg(test)] +#[path = "stock_tests.rs"] +mod tests; diff --git a/src-tauri/src/stock_tests.rs b/src-tauri/src/stock_tests.rs new file mode 100644 index 0000000..ae9387c --- /dev/null +++ b/src-tauri/src/stock_tests.rs @@ -0,0 +1,335 @@ +use super::*; +use crate::db::test_support as fixture; + +fn item(db: &mut Connection, stock: i64) -> Product { + create_product( + db, + CreateProductInput { + request_id: Uuid::new_v4().to_string(), + name: "Chargeur".into(), + price: 2_000, + initial_stock: stock, + }, + ) + .unwrap() +} +fn sale_input(db: &Connection, product: &Product, quantity: i64) -> CreateSaleInput { + CreateSaleInput { + request_id: Uuid::new_v4().to_string(), + lines: vec![SaleLineInput { + product_id: product.id.clone(), + quantity, + unit_price: 2_000, + }], + account_id: fixture::id(db, "Espèces"), + occurred_at: "2026-09-07".into(), + note: None, + } +} +fn receipt_input(db: &Connection, product: &Product, quantity: i64) -> ReceiveStockInput { + ReceiveStockInput { + request_id: Uuid::new_v4().to_string(), + product_id: product.id.clone(), + quantity, + amount: 6_000, + account_id: fixture::id(db, "Espèces"), + occurred_at: "2026-09-07".into(), + note: None, + } +} +fn counts(db: &Connection) -> Vec { + [ + "products", + "product_operations", + "product_operation_lines", + "stock_movements", + "journal_entries", + "audit_events", + "stock_requests", + ] + .iter() + .map(|table| { + db.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0)) + .unwrap() + }) + .collect() +} +fn cancel(db: &mut Connection, op: &ProductOperation) -> AppResult { + cancel_operation( + db, + CancelProductOperationInput { + request_id: Uuid::new_v4().to_string(), + operation_id: op.id.clone(), + reason: "Erreur de saisie".into(), + }, + ) +} + +#[test] +fn sales_receipts_inventory_and_reports_count_money_once() { + let mut db = fixture::multi_database(); + let p = item(&mut db, 10); + let start = db::get_dashboard(&db).unwrap().expected_capital; + let input = sale_input(&db, &p, 2); + create_sale(&mut db, input).unwrap(); + assert_eq!(product(&db, &p.id, true).unwrap().stock, 8); + assert_eq!( + db::get_dashboard(&db).unwrap().expected_capital, + start + 4_000 + ); + let input = receipt_input(&db, &p, 5); + receive_stock(&mut db, input).unwrap(); + assert_eq!(product(&db, &p.id, true).unwrap().stock, 13); + assert_eq!( + db::get_dashboard(&db).unwrap().expected_capital, + start - 2_000 + ); + let report = db::get_report( + &db, + ReportFilters { + from: None, + to: None, + }, + ) + .unwrap(); + assert_eq!( + (report.total_positive, report.total_negative), + (4_000, -6_000) + ); + assert_eq!(report.journal.len(), 2); + assert!(report.journal.iter().all(|j| j.product_operation.is_some())); + let mut balances = fixture::balances(&db); + balances + .iter_mut() + .find(|b| b.account_id == fixture::id(&db, "Espèces")) + .unwrap() + .amount -= 2_000; + let closed = fixture::close(&mut db, balances); + assert_eq!(closed.variance, 0); + assert_eq!( + db::get_dashboard(&db).unwrap().expected_capital, + start - 2_000 + ); + assert_eq!( + db::get_dashboard(&db).unwrap().journal_net_since_inventory, + 0 + ); +} + +#[test] +fn baskets_custom_prices_accounts_and_immutable_snapshots() { + let mut db = fixture::multi_database(); + let first = item(&mut db, 10); + let second = item(&mut db, 10); + for account in ["Espèces", "Orange 2", "Wave 2", "Djamo 1"] { + let mut input = sale_input(&db, &first, 1); + input.account_id = fixture::id(&db, account); + input.lines[0].unit_price = 1_750; + input.lines.push(SaleLineInput { + product_id: second.id.clone(), + quantity: 2, + unit_price: 500, + }); + let op = create_sale(&mut db, input).unwrap(); + assert_eq!(op.amount, 2_750); + assert_eq!(op.account_snapshot.name, account); + } + update_product( + &mut db, + UpdateProductInput { + request_id: Uuid::new_v4().to_string(), + product_id: first.id.clone(), + name: "Nouveau nom".into(), + price: 4_000, + }, + ) + .unwrap(); + let ops = operations(&db).unwrap(); + assert!(ops + .iter() + .all(|o| o.lines[0].product_name == "Chargeur" && o.lines[0].unit_price == Some(1_750))); + assert_eq!(product(&db, &first.id, true).unwrap().stock, 6); + assert_eq!(product(&db, &second.id, true).unwrap().stock, 2); +} + +#[test] +fn retries_are_idempotent_and_reusing_a_key_with_other_data_is_rejected() { + let mut db = fixture::multi_database(); + let create = CreateProductInput { + request_id: Uuid::new_v4().to_string(), + name: "Câble".into(), + price: 500, + initial_stock: 10, + }; + let p = create_product(&mut db, create.clone()).unwrap(); + assert_eq!(create_product(&mut db, create).unwrap().id, p.id); + let mut input = sale_input(&db, &p, 2); + let op = create_sale(&mut db, input.clone()).unwrap(); + let saved = counts(&db); + assert_eq!(create_sale(&mut db, input.clone()).unwrap().id, op.id); + assert_eq!(saved, counts(&db)); + input.lines[0].quantity = 3; + assert!(create_sale(&mut db, input).is_err()); + assert_eq!(saved, counts(&db)); + let receive = receipt_input(&db, &p, 1); + receive_stock(&mut db, receive.clone()).unwrap(); + let saved = counts(&db); + receive_stock(&mut db, receive).unwrap(); + assert_eq!(saved, counts(&db)); +} + +#[test] +fn invalid_and_partial_operations_leave_no_writes() { + let mut db = fixture::multi_database(); + let p = item(&mut db, 10); + let empty = item(&mut db, 0); + let initial = counts(&db); + let valid = sale_input(&db, &p, 2); + let mut cases = vec![]; + let mut input = valid.clone(); + input.lines.clear(); + cases.push(input); + let mut input = valid.clone(); + input.lines[0].quantity = 0; + cases.push(input); + let mut input = valid.clone(); + input.lines[0].quantity = -1; + cases.push(input); + let mut input = valid.clone(); + input.lines[0].unit_price = 0; + cases.push(input); + let mut input = valid.clone(); + input.lines[0].unit_price = MAX_INTEGER; + cases.push(input); + let mut input = valid.clone(); + input.lines[0].quantity = 11; + cases.push(input); + let mut input = valid.clone(); + input.lines.push(input.lines[0].clone()); + cases.push(input); + let mut input = valid.clone(); + input.account_id = "cash".into(); + cases.push(input); + let mut input = valid.clone(); + input.occurred_at = "pas une date".into(); + cases.push(input); + // The second line fails after the first line and the journal were written. + let mut input = valid.clone(); + input.lines.push(SaleLineInput { + product_id: empty.id, + quantity: 1, + unit_price: 500, + }); + cases.push(input); + for input in cases { + assert!(create_sale(&mut db, input).is_err()); + assert_eq!(counts(&db), initial); + assert_eq!(product(&db, &p.id, true).unwrap().stock, 10); + } + db.execute_batch("CREATE TRIGGER fail_stock_audit BEFORE INSERT ON audit_events WHEN NEW.action='product_sale_created' BEGIN SELECT RAISE(ABORT,'Simulated failure'); END;").unwrap(); + assert!(create_sale(&mut db, valid).is_err()); + assert_eq!(counts(&db), initial); + assert_eq!(product(&db, &p.id, true).unwrap().stock, 10); + let fractional = json!({"requestId":Uuid::new_v4().to_string(),"lines":[{"productId":p.id,"quantity":0.5,"unitPrice":100}],"accountId":"cash","occurredAt":"2026-09-07"}); + assert!(serde_json::from_value::(fractional).is_err()); +} + +#[test] +fn journal_reversal_restores_stock_even_for_archived_products() { + let mut db = fixture::multi_database(); + let p = item(&mut db, 2); + let input = sale_input(&db, &p, 2); + let op = create_sale(&mut db, input).unwrap(); + archive_product( + &mut db, + ArchiveProductInput { + request_id: Uuid::new_v4().to_string(), + product_id: p.id.clone(), + }, + ) + .unwrap(); + db::reverse_journal_entry( + &mut db, + ReverseEntryInput { + entry_id: op.journal_entry_id.clone(), + reason: "Vente annulée".into(), + }, + ) + .unwrap(); + assert_eq!(product(&db, &p.id, true).unwrap().stock, 2); + assert_eq!(db::get_dashboard(&db).unwrap().expected_capital, 5_000_000); + assert!(operation(&db, &op.id).unwrap().cancelled_at.is_some()); + let saved = counts(&db); + assert!(cancel(&mut db, &op).is_err()); + assert_eq!(counts(&db), saved); +} + +#[test] +fn receipt_cancellation_checks_stock_and_cancellation_retries_are_safe() { + let mut db = fixture::multi_database(); + let p = item(&mut db, 0); + let input = receipt_input(&db, &p, 5); + let receipt = receive_stock(&mut db, input).unwrap(); + let input = sale_input(&db, &p, 2); + let sale = create_sale(&mut db, input).unwrap(); + let saved = counts(&db); + assert!(cancel(&mut db, &receipt).is_err()); + assert_eq!(counts(&db), saved); + cancel(&mut db, &sale).unwrap(); + let input = CancelProductOperationInput { + request_id: Uuid::new_v4().to_string(), + operation_id: receipt.id, + reason: "Réception incorrecte".into(), + }; + cancel_operation(&mut db, input.clone()).unwrap(); + let saved = counts(&db); + cancel_operation(&mut db, input).unwrap(); + assert_eq!(counts(&db), saved); + assert_eq!(product(&db, &p.id, true).unwrap().stock, 0); + assert_eq!(db::get_dashboard(&db).unwrap().expected_capital, 5_000_000); +} + +#[test] +fn physical_counts_initial_stock_and_archiving_have_no_money_effect() { + let mut db = fixture::multi_database(); + let p = item(&mut db, 10); + let input = ArchiveProductInput { + request_id: Uuid::new_v4().to_string(), + product_id: p.id.clone(), + }; + assert!(archive_product(&mut db, input.clone()).is_err()); + let mut adjust = AdjustStockInput { + request_id: Uuid::new_v4().to_string(), + product_id: p.id.clone(), + quantity: 0, + reason: "".into(), + }; + assert!(adjust_stock(&mut db, adjust.clone()).is_err()); + adjust.reason = "Articles abîmés".into(); + adjust_stock(&mut db, adjust.clone()).unwrap(); + adjust_stock(&mut db, adjust).unwrap(); + archive_product(&mut db, input).unwrap(); + assert!(db::list_journal_entries(&db, None).unwrap().is_empty()); + assert_eq!(db::get_dashboard(&db).unwrap().expected_capital, 5_000_000); + assert_eq!(movements(&db, Some(&p.id)).unwrap().len(), 2); + let input = sale_input(&db, &p, 1); + assert!(create_sale(&mut db, input).is_err()); +} + +#[test] +fn migration_from_v2_preserves_history_and_runs_once() { + let db = Connection::open_in_memory().unwrap(); + fixture::legacy_database(&db); + db.pragma_update(None, "foreign_keys", false).unwrap(); + db.execute_batch(include_str!("migration_v2.sql")).unwrap(); + db.pragma_update(None, "foreign_keys", true).unwrap(); + let before = fixture::historical_data(&db); + db::migrate(&db).unwrap(); + assert_eq!(db::schema_version(&db).unwrap(), 3); + assert_eq!(before, fixture::historical_data(&db)); + assert!(products(&db).unwrap().is_empty()); + let saved = counts(&db); + db::migrate(&db).unwrap(); + assert_eq!(counts(&db), saved); + db::integrity_check(&db).unwrap(); +} diff --git a/src/App.tsx b/src/App.tsx index d5867c9..70c9e28 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { LoginScreen, SetupScreen } from "./screens/AuthScreens"; import { DashboardPage } from "./screens/DashboardPage"; import { DebtsPage } from "./screens/DebtsPage"; import { InventoryHistoryPage, InventoryPage } from "./screens/InventoryPages"; +import { ProductsPage } from "./screens/ProductsPage"; import { JournalPage } from "./screens/JournalPage"; import { ReportsPage } from "./screens/ReportsPage"; import { SettingsPage } from "./screens/SettingsPage"; @@ -72,6 +73,7 @@ export default function App() { case "dashboard": return ; case "inventory": return ; case "history": return void refresh()} notify={setToast} />; + case "products": return ; case "journal": return void refresh()} notify={setToast} />; case "debts": return void refresh()} notify={setToast} />; case "reports": return ; diff --git a/src/api.ts b/src/api.ts index f39a73d..c74730e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { + Product, ProductOperation, StockMovement, CreateProductInput, UpdateProductInput, ArchiveProductInput, + AdjustStockInput, CreateSaleInput, ReceiveStockInput, CancelProductOperationInput, Account, AccountBalanceInput, OpeningAccount, OpeningPreview, CreateAccountInput, UpdateAccountInput, CloseInventoryInput, InventoryCorrectionInput, CreateJournalEntryInput, CreateDebtInput, RecordPaymentInput, AuditEvent, @@ -37,6 +39,16 @@ async function call(command: string, args?: Record): Promise } export const api = { + products: () => call("list_products"), + createProduct: (input: CreateProductInput) => call("create_product", { input }), + updateProduct: (input: UpdateProductInput) => call("update_product", { input }), + archiveProduct: (input: ArchiveProductInput) => call("archive_product", { input }), + adjustStock: (input: AdjustStockInput) => call("adjust_stock", { input }), + createSale: (input: CreateSaleInput) => call("create_product_sale", { input }), + receiveStock: (input: ReceiveStockInput) => call("receive_stock", { input }), + productOperations: () => call("list_product_operations"), + cancelProductOperation: (input: CancelProductOperationInput) => call("cancel_product_operation", { input }), + stockMovements: (productId?: string) => call("list_stock_movements", { productId }), accounts: () => call("list_accounts"), createAccount: (input: CreateAccountInput) => call("create_account", { input }), updateAccount: (input: UpdateAccountInput) => call("update_account", { input }), diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 03c097b..917d130 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -7,6 +7,7 @@ import { LayoutDashboard, LockKeyhole, Settings, + Package, WalletCards } from "lucide-react"; import type { Dashboard, PageId } from "../types"; @@ -16,6 +17,7 @@ const navigation: Array<{ id: PageId; label: string; icon: typeof LayoutDashboar { id: "dashboard", label: "Tableau de bord", icon: LayoutDashboard }, { id: "inventory", label: "Nouvel inventaire", icon: Clock3 }, { id: "history", label: "Historique", icon: FileClock }, + { id: "products", label: "Produits et ventes", icon: Package }, { id: "journal", label: "Journal boutique", icon: BookOpenText }, { id: "debts", label: "Dettes clients", icon: HandCoins }, { id: "reports", label: "Rapports", icon: BarChart3 }, @@ -41,11 +43,11 @@ export function AppShell({
Kër FinanceGestion multiservices
diff --git a/src/screens/DashboardPage.tsx b/src/screens/DashboardPage.tsx index 719d0bb..556f5f1 100644 --- a/src/screens/DashboardPage.tsx +++ b/src/screens/DashboardPage.tsx @@ -99,6 +99,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/JournalPage.tsx b/src/screens/JournalPage.tsx index b73812a..95c18dc 100644 --- a/src/screens/JournalPage.tsx +++ b/src/screens/JournalPage.tsx @@ -64,12 +64,13 @@ export function JournalPage({ onChanged, notify }: { onChanged: () => void; noti

Entrées enregistrées{formatMoney(totals.positive)}

Sorties enregistrées{formatMoney(totals.negative)}

Impact net= 0 ? "positive" : "negative"}>{signed(totals.positive + totals.negative)}

setQuery(e.target.value)} />
- {visible.length === 0 ?

Aucune écriture

Ajoutez une recette, un achat ou une dépense.

:
{visible.map((item) => )}
DateTypeCompteRéférence / noteMontant
{formatDate(item.occurredAt)}= 0 ? "income" : "outcome"}`}>{label(item.entryType)}{item.reversed && Corrigée}{accountDisplayLabel(item.accountSnapshot)}{item.reference || "—"}{item.note || "Aucune note"}= 0 ? "positive" : "negative"}>{signed(item.signedAmount)}{!item.reversed && !item.reversesId && }
} + {visible.length === 0 ?

Aucune écriture

Ajoutez une recette, un achat ou une dépense.

:
{visible.map((item) => )}
DateTypeCompteRéférence / noteMontant
{formatDate(item.occurredAt)}= 0 ? "income" : "outcome"}`}>{item.productOperation ? (item.productOperation.kind === "sale" ? "Vente de produits" : "Réapprovisionnement") + (item.reversesId ? " — Annulation" : "") : label(item.entryType)}{item.reversed && Corrigée}{accountDisplayLabel(item.accountSnapshot)}{item.reference || "—"}{item.note || "Aucune note"}= 0 ? "positive" : "negative"}>{signed(item.signedAmount)}{!item.reversed && !item.reversesId && }
}
setOpen(false)}>
setForm({ ...form, entryType: e.target.value })}> + {["sale", "purchase"].includes(form.entryType) &&

Pour les produits suivis en stock, utilisez « Produits et ventes » : leurs ventes et achats sont déjà ajoutés automatiquement au journal.

}
setForm({ ...form, amount: Number(e.target.value) })} required /> setForm({ ...form, accountId })} />
setForm({ ...form, occurredAt: e.target.value })} required /> setForm({ ...form, reference: e.target.value })} placeholder="Facture, reçu…" />