diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..39d9979 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,182 @@ +name: Release installers + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Existing release tag, for example v0.3.0" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + commit: ${{ steps.commit.outputs.sha }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Check release versions + id: version + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + run: node scripts/check-release-version.mjs "$RELEASE_TAG" + - name: Pin every build to this commit + id: commit + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.platform }} + needs: prepare + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - platform: macos-arm64 + runner: macos-15 + - platform: macos-x64 + runner: macos-15-intel + - platform: windows-x64 + runner: windows-2022 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.commit }} + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + key: release-${{ matrix.platform }} + - run: npm ci + - run: npm test + - run: npm run build + - run: cargo test --locked --manifest-path src-tauri/Cargo.toml --all-targets + - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings + - name: Build macOS DMG + if: runner.os == 'macOS' + env: + APPLE_SIGNING_IDENTITY: "-" + run: npm run tauri:build:mac + - name: Build Windows EXE + if: runner.os == 'Windows' + run: npm run tauri:build:windows + - name: Collect versioned installer + env: + RELEASE_PLATFORM: ${{ matrix.platform }} + run: node scripts/collect-release.mjs + - uses: actions/upload-artifact@v4 + with: + name: installer-${{ matrix.platform }} + path: release-artifacts/* + if-no-files-found: error + retention-days: 30 + + release: + name: Create draft release + needs: [prepare, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: installer-* + path: release-artifacts + merge-multiple: true + - name: Attach all installers to a draft release + uses: actions/github-script@v7 + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_COMMIT: ${{ needs.prepare.outputs.commit }} + with: + script: | + const fs = require('node:fs'); + const path = require('node:path'); + const crypto = require('node:crypto'); + const { owner, repo } = context.repo; + const version = process.env.RELEASE_VERSION; + const tag = process.env.RELEASE_TAG; + const directory = 'release-artifacts'; + const names = [ + `Ker-Finance_${version}_macos-arm64.dmg`, + `Ker-Finance_${version}_macos-x64.dmg`, + `Ker-Finance_${version}_windows-x64-setup.exe`, + ]; + for (const name of names) { + if (!fs.statSync(path.join(directory, name)).size) throw new Error(`Empty installer: ${name}`); + } + const sums = names.map(name => { + const data = fs.readFileSync(path.join(directory, name)); + return `${crypto.createHash('sha256').update(data).digest('hex')} ${name}`; + }).join('\n') + '\n'; + fs.writeFileSync(path.join(directory, 'SHA256SUMS.txt'), sums); + names.push('SHA256SUMS.txt'); + // A release tag must still point to the exact commit we built. + let tagged = (await github.rest.git.getRef({ owner, repo, ref: `tags/${tag}` })).data.object; + while (tagged.type === 'tag') { + tagged = (await github.rest.git.getTag({ owner, repo, tag_sha: tagged.sha })).data.object; + } + if (tagged.type !== 'commit' || tagged.sha !== process.env.RELEASE_COMMIT) { + throw new Error('Release tag changed during the build.'); + } + let release; + try { + release = (await github.rest.repos.getReleaseByTag({ owner, repo, tag })).data; + } catch (error) { + if (error.status !== 404) throw error; + // The tag endpoint only returns published releases; find an existing draft before retrying. + const releases = await github.paginate(github.rest.repos.listReleases, { owner, repo }); + release = releases.find(item => item.tag_name === tag); + if (!release) release = (await github.rest.repos.createRelease({ + owner, repo, tag_name: tag, target_commitish: process.env.RELEASE_COMMIT, + name: `Kër Finance ${version}`, draft: true, prerelease: false, + body: [ + 'Installateurs Kër Finance pour macOS (Apple Silicon et Intel) et Windows x64.', + '', + '- macos-arm64.dmg : Mac avec puce Apple Silicon.', + '- macos-x64.dmg : Mac Intel.', + '- windows-x64-setup.exe : installateur Windows 64 bits.', + '- SHA256SUMS.txt : sommes de contrôle des trois installateurs.', + '', + 'Les DMG sont signés ad hoc, sans notarisation Apple. L’EXE Windows ne possède pas de signature éditeur.', + 'Vérifiez les installateurs avant de publier ce brouillon.', + ].join('\n'), + })).data; + } + if (!release.draft) throw new Error('This release is already published; refusing to replace its assets.'); + const existing = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id: release.id }); + for (const name of names) { + const previous = existing.find(asset => asset.name === name); + if (previous) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: previous.id }); + await github.rest.repos.uploadReleaseAsset({ + owner, repo, release_id: release.id, name, + data: fs.readFileSync(path.join(directory, name)), + }); + } + await core.summary.addHeading(`Kër Finance ${version}`) + .addLink('Review the draft release', release.html_url) + .addList(names).write(); diff --git a/.gitignore b/.gitignore index 5519e04..924452c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ src-tauri/target/ .env .idea/ .vscode/ +release-artifacts/ diff --git a/README.md b/README.md index 2662e3f..bd57286 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,32 @@ # Kër Finance -Application desktop locale de gestion d’un multiservices au Sénégal : inventaires Orange Money, Wave, Djamo et espèces, journal de boutique, dettes clients, rapports et sauvegardes chiffrées. +Application desktop locale de gestion d’un multiservices au Sénégal : inventaires Orange Money, Wave, Djamo et espèces, produits, stock, ventes, journal de boutique, dettes clients, rapports et sauvegardes chiffrées. + +## Installer la version 0.3.0 + +Les installateurs sont disponibles dans les [releases GitHub](https://github.com/aloundoye/multiservices/releases). Les versions en brouillon restent accessibles aux mainteneurs jusqu’à leur publication. + +| Système | Fichier à télécharger | +| --- | --- | +| Mac Apple Silicon (puces M1, M2, etc.) | `Ker-Finance_0.3.0_macos-arm64.dmg` | +| Mac Intel | `Ker-Finance_0.3.0_macos-x64.dmg` | +| Windows 64 bits (x64) | `Ker-Finance_0.3.0_windows-x64-setup.exe` | + +Sur macOS, ouvrez le DMG et glissez **Kër Finance** dans **Applications**. Sur Windows, lancez l’EXE et suivez l’assistant d’installation. Node.js, Rust et les outils de compilation ne sont nécessaires que pour le développement. + +Les applications macOS sont signées ad hoc, sans notarisation Apple ; l’installateur Windows n’a pas de signature éditeur. Le système peut donc afficher une alerte à l’ouverture. + +Chaque release contient `SHA256SUMS.txt`. Pour vérifier un téléchargement, comparez la somme calculée à la ligne correspondant au fichier : + +```bash +# macOS : adapter le nom du fichier pour un Mac Intel. +shasum -a 256 Ker-Finance_0.3.0_macos-arm64.dmg +``` + +```powershell +# Windows PowerShell +Get-FileHash .\Ker-Finance_0.3.0_windows-x64-setup.exe -Algorithm SHA256 +``` ## Fonctionnalités @@ -8,6 +34,7 @@ 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 ; +- catalogue de produits, suivi du stock, ventes et réapprovisionnements reliés au capital attendu ; - 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 ; @@ -16,6 +43,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 ; les anciennes sauvegardes restent restaurables. + ## Architecture - `src/` : interface React 19 + TypeScript + Vite ; @@ -23,6 +63,8 @@ Application desktop locale de gestion d’un multiservices au Sénégal : invent - `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/stock.rs` : catalogue, ventes, réapprovisionnements et mouvements de stock ; +- `src-tauri/src/migration_v3.sql` : tables des produits et liens entre stock et journal ; - `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. @@ -44,18 +86,20 @@ Ensuite, **Paramètres → Comptes et SIM** permet de créer, renommer, archiver ## 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 version 0.3.0 utilise le **schéma SQLite 3**. Les bases en version 1 ou 2 sont migrées au prochain déverrouillage. Conservez une sauvegarde chiffrée externe avant une mise à niveau. -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. +Depuis le schéma 1, l’application crée et vérifie d’abord une sauvegarde chiffrée `avant-migration-v1-….msbackup`. Si cette étape échoue, les données restent en version 1. La migration ajoute les comptes Orange Money — Principal, Wave — Principal, Djamo — Principal et Espèces. Les anciens soldes sont marqués « historique regroupé par service » : aucune répartition ancienne entre SIM n’est inventée. -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. +Le passage au schéma 3 ajoute les produits, ventes, réapprovisionnements et mouvements de stock. Les montants, dates, références, corrections, écarts et comptes existants sont conservés. La migration est atomique, auditée et ne s’exécute qu’une fois. + +Les sauvegardes au schéma 3 contiennent les comptes, leurs relevés et libellés historiques, ainsi que les produits et opérations de stock. Une sauvegarde au schéma 1 ou 2 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 au schéma 3 avec une ancienne version de l’application. ## Développement -Prérequis : Node.js 20+, Rust stable et les [prérequis Tauri 2](https://v2.tauri.app/start/prerequisites/). +Prérequis : Node.js 24 (version utilisée en CI), Rust stable et les [prérequis Tauri 2](https://v2.tauri.app/start/prerequisites/). ```bash -npm install +npm ci npm run tauri:dev ``` @@ -64,24 +108,19 @@ Vérifications : ```bash npm run build npm test -cargo test --manifest-path src-tauri/Cargo.toml --all-targets -cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings +cargo test --locked --manifest-path src-tauri/Cargo.toml --all-targets +cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings +npm run release:check ``` -## Utiliser et installer sur macOS +## Générer un DMG sur macOS -Prérequis : macOS 11 ou plus récent, Xcode Command Line Tools, Node.js 20+ et Rust stable. - -Lancer l’application en développement : - -```bash -npm install -npm run tauri:dev -``` +Prérequis : macOS 11 ou plus récent, Xcode Command Line Tools, Node.js 24 et Rust stable. Créer l’application macOS et l’image d’installation DMG : ```bash +npm ci npm run tauri:build:mac ``` @@ -90,23 +129,51 @@ La commande construit d’abord le `.app` avec Tauri, puis crée le DMG avec l Les fichiers sont générés dans : - `src-tauri/target/release/bundle/macos/Kër Finance.app` -- `src-tauri/target/release/bundle/dmg/Kër Finance_0.1.0_.dmg` +- `src-tauri/target/release/bundle/dmg/Kër Finance_0.3.0_.dmg` -Le build local n’est pas signé par Apple. Il fonctionne sur la machine de développement ; pour le distribuer à d’autres personnes sans alerte Gatekeeper, il faudra ajouter une signature Developer ID et une notarisation Apple. +Le DMG est généré pour l’architecture du Mac qui exécute la commande (`arm64` ou `x86_64`). Pour reproduire la signature ad hoc utilisée en CI, exécutez `APPLE_SIGNING_IDENTITY=- npm run tauri:build:mac`. Une distribution notarisée nécessite une signature Developer ID et une notarisation Apple. ## Générer les installateurs Windows -Exécuter sur Windows 10/11 avec Microsoft C++ Build Tools et WebView2 : +Exécuter sur Windows avec Node.js 24, Rust stable, Microsoft C++ Build Tools et WebView2 : ```powershell npm ci -npm run tauri:build +npm run tauri:build:windows ``` -Les installateurs MSI et NSIS sont générés sous `src-tauri/target/release/bundle/`. +L’installateur EXE NSIS est généré dans `src-tauri/target/release/bundle/nsis/`. Pour générer à la fois le MSI et l’EXE NSIS, utilisez `npm run tauri:build` ; les sorties se trouvent dans les sous-dossiers `msi/` et `nsis/` de `src-tauri/target/release/bundle/`. Les tests comptables utilisent une base SQLite en mémoire et un test séparé vérifie SQLCipher avec une vraie base chiffrée. L’option avancée `cipher_memory_security` reste désactivée : avec OpenSSL statique sous Windows, son allocateur global provoque un `STATUS_STACK_OVERFLOW`. Cette option ne contrôle pas le chiffrement du fichier, qui reste actif via `PRAGMA key`. +## Releases macOS et Windows + +Le workflow [Release installers](.github/workflows/release.yml) se déclenche lorsque vous poussez un tag `vX.Y.Z`. GitHub Actions compile sur des runners macOS et Windows : vous pouvez déclencher les trois builds depuis votre Mac sans y installer de compilateur Windows. Les variantes générées sont : + +- `Ker-Finance_X.Y.Z_macos-arm64.dmg` pour les Mac Apple Silicon ; +- `Ker-Finance_X.Y.Z_macos-x64.dmg` pour les Mac Intel ; +- `Ker-Finance_X.Y.Z_windows-x64-setup.exe` pour Windows 64 bits. + +Chaque variante exécute les tests frontend et Rust, le build frontend et Clippy avant de générer son installateur. Après réussite des trois builds, les installateurs et `SHA256SUMS.txt` sont joints à un **brouillon de release GitHub**. Les artefacts `installer-macos-arm64`, `installer-macos-x64` et `installer-windows-x64` restent également disponibles pendant 30 jours dans l’onglet Actions. Le workflow vérifie que le tag, les manifestes npm/Tauri/Rust et leurs fichiers de verrouillage portent la même version, et que les trois builds proviennent du même commit. + +Pour une prochaine version : + +1. Mettez à jour `package.json`, `package-lock.json`, `src-tauri/tauri.conf.json`, `src-tauri/Cargo.toml` et l’entrée du projet dans `src-tauri/Cargo.lock`. +2. Exécutez les vérifications, puis enregistrez et poussez les changements, y compris le workflow de release. +3. Créez et poussez le tag correspondant à la nouvelle version. Par exemple, après passage à **0.3.1** : + +```bash +npm run release:check -- v0.3.1 +git tag -a v0.3.1 -m "Kër Finance 0.3.1" +git push origin v0.3.1 +``` + +Le tag `v0.3.0` existe déjà : ne le recréez pas et ne le déplacez pas. Une fois le workflow présent sur la branche principale, **Actions → Release installers → Run workflow** permet de relancer un tag existant. Sélectionnez la branche contenant le workflow à jour et indiquez le tag à reconstruire. Le code de l’application est toujours extrait du commit de ce tag. Une relance réutilise le brouillon existant et remplace ses fichiers ; elle refuse de modifier une release déjà publiée. + +Vérifiez les installateurs sur les systèmes cibles avant de publier le brouillon. Les DMG de ce workflow sont signés ad hoc, sans notarisation Apple ; l’EXE Windows n’est pas signé par un certificat éditeur. La [documentation Tauri sur les releases GitHub](https://v2.tauri.app/distribute/pipelines/github/) décrit la configuration des certificats pour une distribution signée. + +La publication du brouillon reste une action manuelle depuis la page de release GitHub. + ## Sécurité et récupération Au premier démarrage, le gérant choisit : @@ -121,5 +188,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/package-lock.json b/package-lock.json index 45e5867..2877f6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multiservices-senegal", - "version": "0.1.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multiservices-senegal", - "version": "0.1.0", + "version": "0.3.0", "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-dialog": "^2.4.2", diff --git a/package.json b/package.json index b95d324..16c529b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "multiservices-senegal", "private": true, - "version": "0.1.0", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", @@ -12,7 +12,10 @@ "tauri": "tauri", "tauri:dev": "tauri dev", "tauri:build": "tauri build", - "tauri:build:mac": "bash scripts/build-macos.sh" + "tauri:build:mac": "bash scripts/build-macos.sh", + "tauri:build:windows": "tauri build --ci --bundles nsis -- --locked", + "release:check": "node scripts/check-release-version.mjs", + "release:collect": "node scripts/collect-release.mjs" }, "dependencies": { "@tauri-apps/api": "^2.11.1", diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh index 9dfffda..d4febd3 100644 --- a/scripts/build-macos.sh +++ b/scripts/build-macos.sh @@ -17,7 +17,11 @@ cleanup() { trap cleanup EXIT cd "${PROJECT_DIR}" -npx tauri build --ci --bundles app +node scripts/check-release-version.mjs +npx tauri build --ci --bundles app -- --locked + +BUNDLE_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "${APP_PATH}/Contents/Info.plist")" +[[ "${BUNDLE_VERSION}" == "${VERSION}" ]] || { printf 'Bundle version mismatch\n' >&2; exit 1; } mkdir -p "${DMG_DIR}" cp -R "${APP_PATH}" "${STAGING_DIR}/" @@ -30,4 +34,6 @@ hdiutil create \ -format UDZO \ "${DMG_PATH}" +hdiutil verify "${DMG_PATH}" + printf '\nApplication : %s\nDMG : %s\n' "${APP_PATH}" "${DMG_PATH}" diff --git a/scripts/check-release-version.mjs b/scripts/check-release-version.mjs new file mode 100644 index 0000000..e651b1f --- /dev/null +++ b/scripts/check-release-version.mjs @@ -0,0 +1,27 @@ +import { readFileSync, appendFileSync } from 'node:fs'; + +const readJson = (file) => JSON.parse(readFileSync(file, 'utf8')); +const pkg = readJson('package.json'); +const npmLock = readJson('package-lock.json'); +const cargo = readFileSync('src-tauri/Cargo.toml', 'utf8'); +const cargoLock = readFileSync('src-tauri/Cargo.lock', 'utf8'); +const versions = { + 'package.json': pkg.version, + 'package-lock.json': npmLock.version, + 'package-lock.json root package': npmLock.packages[''].version, + 'tauri.conf.json': readJson('src-tauri/tauri.conf.json').version, + 'Cargo.toml': cargo.match(/\[package\][\s\S]*?^version = "([^"]+)"/m)?.[1], + 'Cargo.lock': cargoLock.match(/\[\[package\]\]\nname = "multiservices-senegal"\nversion = "([^"]+)"/)?.[1], +}; +if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(pkg.version)) { + throw new Error('Use a stable release version such as 0.3.0.'); +} +for (const [file, version] of Object.entries(versions)) { + if (version !== pkg.version) throw new Error(`${file}: ${version} does not match ${pkg.version}.`); +} +const tag = process.argv[2] ?? `v${pkg.version}`; +if (tag !== `v${pkg.version}`) throw new Error(`Tag ${tag} does not match version ${pkg.version}.`); +if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `version=${pkg.version}\ntag=${tag}\n`); +} +console.log(`Release versions verified: ${tag}`); diff --git a/scripts/collect-release.mjs b/scripts/collect-release.mjs new file mode 100644 index 0000000..d84ec1b --- /dev/null +++ b/scripts/collect-release.mjs @@ -0,0 +1,22 @@ +import { copyFileSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; + +const { version } = JSON.parse(readFileSync('package.json', 'utf8')); +const platform = `${process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`; +if (!['macos-arm64', 'macos-x64', 'windows-x64'].includes(platform)) { + throw new Error(`Unsupported release platform: ${platform}`); +} +if (process.env.RELEASE_PLATFORM && process.env.RELEASE_PLATFORM !== platform) { + throw new Error(`Runner architecture ${platform} does not match ${process.env.RELEASE_PLATFORM}.`); +} +const windows = process.platform === 'win32'; +const extension = windows ? '.exe' : '.dmg'; +const directory = path.join('src-tauri', 'target', 'release', 'bundle', windows ? 'nsis' : 'dmg'); +const matches = readdirSync(directory).filter(name => name.includes(`_${version}_`) && name.endsWith(extension)); +if (matches.length !== 1) throw new Error(`Expected exactly one ${platform} installer for ${version}, found: ${matches.join(', ')}`); +const source = path.join(directory, matches[0]); +if (!statSync(source).size) throw new Error(`Empty installer: ${source}`); +mkdirSync('release-artifacts', { recursive: true }); +const destination = path.join('release-artifacts', `Ker-Finance_${version}_${platform}${windows ? '-setup' : ''}${extension}`); +copyFileSync(source, destination); +console.log(`Release installer ready: ${destination}`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 06da5fa..1bd14be 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2535,7 +2535,7 @@ dependencies = [ [[package]] name = "multiservices-senegal" -version = "0.1.0" +version = "0.3.0" dependencies = [ "aes-gcm", "argon2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d7fef4d..b136d27 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "multiservices-senegal" -version = "0.1.0" +version = "0.3.0" description = "Gestion locale de trésorerie multiservices au Sénégal" authors = ["Kër Finance"] edition = "2021" 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-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0c2eb3e..08571bc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Kër Finance", - "version": "0.1.0", + "version": "0.3.0", "identifier": "sn.kerfinance.multiservices", "build": { "beforeDevCommand": "npm run dev", 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…" />