Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;
Expand Down Expand Up @@ -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é.
2 changes: 1 addition & 1 deletion src-tauri/src/account_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
151 changes: 145 additions & 6 deletions src-tauri/src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -406,17 +409,17 @@ 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 {
backup_path: backup.path,
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();
Expand Down Expand Up @@ -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);
Expand All @@ -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<serde_json::Value> {
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();
Expand Down
89 changes: 89 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,92 @@ pub fn preview_opening(
difference,
})
}

#[tauri::command]
pub fn list_products(state: State<'_, AppState>) -> CommandResult<Vec<Product>> {
state
.with_connection(|c| crate::stock::products(c))
.map_err(command_error)
}
#[tauri::command]
pub fn list_product_operations(state: State<'_, AppState>) -> CommandResult<Vec<ProductOperation>> {
state
.with_connection(|c| crate::stock::operations(c))
.map_err(command_error)
}
#[tauri::command]
pub fn list_stock_movements(
product_id: Option<String>,
state: State<'_, AppState>,
) -> CommandResult<Vec<StockMovement>> {
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<Product> {
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<Product> {
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<Product> {
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<Product> {
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<ProductOperation> {
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<ProductOperation> {
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<ProductOperation> {
state
.with_connection(|c| crate::stock::cancel_operation(c, input))
.map_err(command_error)
}
Loading
Loading