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
1 change: 1 addition & 0 deletions .dev/Test-Compta-Demat
Submodule Test-Compta-Demat added at 1563a4
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ build
.continue
.old
.vscode
# .dev

.pnpm-store

Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ L'ensemble du traitement est effectué **localement dans le navigateur** : aucun
## Fonctionnalités

- Validation de fichiers FEC au format texte tabulé (`.txt`) et XML (`.xml`)
- 19 contrôles de conformité : structure, colonnes, formats, cohérence comptable
- 25 contrôles de conformité : structure, colonnes, formats, cohérence comptable
- Distinction erreurs / avertissements avec localisation (ligne, champ)
- Traitement 100% local, gratuit et sans inscription

Expand All @@ -38,6 +38,12 @@ L'ensemble du traitement est effectué **localement dans le navigateur** : aucun
| 17 | Écritures d'ouverture (à-nouveaux) | Avertissement |
| 18 | Équilibre débit/crédit par écriture | Avertissement |
| 19 | Cohérence PieceDate / EcritureDate | Avertissement |
| 20 | Lignes vides dans les données | Erreur |
| 21 | Nombre de champs par ligne cohérent avec l'en-tête | Erreur |
| 22 | Point interdit comme séparateur décimal | Erreur |
| 23 | Séparateurs de milliers interdits | Erreur |
| 24 | Année des dates entre 1900 et 2099 | Avertissement |
| 25 | Débit et Crédit mutuellement exclusifs | Avertissement |

## Architecture

Expand Down Expand Up @@ -150,6 +156,12 @@ Variables d'environnement requises (voir `workflows/build/.env.example`) :
| CI/CD | GitHub Actions |
| Production | Docker, Nginx |

## Références

- [Test-Compta-Demat (DGFiP)](https://github.com/DGFiP/Test-Compta-Demat) — outil de référence de l'administration fiscale pour la validation des FEC
- [Article A47 A-1 du LPF](https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000027804775/) — base légale définissant le format FEC
- [Fichiers standards des écritures comptables (impots.gouv.fr)](https://www.impots.gouv.fr/fichiers-standards-des-ecritures-comptables) — documentation officielle et schémas XSD

## Licence

[AGPL-3.0](LICENSE)
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.1.0
v0.2.0
4 changes: 2 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ dev-up:
dev-down:
./scripts/dev-down.sh

build:
./scripts/build.sh
build *args:
./scripts/build.sh {{args}}
8 changes: 6 additions & 2 deletions packages/engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
"private": true,
"scripts": {
"build": "tsc --build",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "24.10.1",
"typescript": "5.9.3"
"jsdom": "26.1.0",
"typescript": "5.9.3",
"vitest": "4.0.18"
},
"dependencies": {
"nanoid": "5.1.6",
Expand Down
171 changes: 171 additions & 0 deletions packages/engine/src/fec/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,177 @@ export function checkPieceDateCoherence(parsed: FecParsedFile): FecCheckResult[]
return results
}

// ---------------------------------------------------------------------------
// 20. checkFieldCountMismatch - Chaque ligne doit avoir le même nombre de champs que l'en-tête
// ---------------------------------------------------------------------------

export function checkFieldCountMismatch(parsed: FecParsedFile): FecCheckResult[] {
const results: FecCheckResult[] = []
if (!parsed.lineIssues) return results

for (const issue of parsed.lineIssues) {
if (issue.kind === "field_count_mismatch") {
results.push({
id: "FIELD_COUNT_MISMATCH",
severity: "error",
message: `La structure du fichier est incorrecte : en ligne ${issue.line} : il y a ${issue.actualFields} champs au lieu des ${issue.expectedFields} champs attendus.`,
line: issue.line,
})
}
}
return results
}

// ---------------------------------------------------------------------------
// 21. checkEmptyLines - Détection des lignes vides dans le fichier (hors fin de fichier)
// ---------------------------------------------------------------------------

export function checkEmptyLines(parsed: FecParsedFile): FecCheckResult[] {
const results: FecCheckResult[] = []
if (!parsed.lineIssues) return results

for (const issue of parsed.lineIssues) {
if (issue.kind === "empty") {
results.push({
id: "EMPTY_LINE",
severity: "error",
message: `La structure du fichier est incorrecte, la ligne ${issue.line} est vide.`,
line: issue.line,
})
}
}
return results
}

// ---------------------------------------------------------------------------
// 22. checkDebitCreditExclusive - Débit et Crédit ne doivent pas être tous deux renseignés ou tous deux nuls
// ---------------------------------------------------------------------------

export function checkDebitCreditExclusive(parsed: FecParsedFile): FecCheckResult[] {
const results: FecCheckResult[] = []
if (!parsed.headers.includes("Debit") || !parsed.headers.includes("Credit")) {
return results
}

for (let i = 0; i < parsed.entries.length; i++) {
const entry = parsed.entries[i]!
const debitStr = entry["Debit"]?.trim() ?? ""
const creditStr = entry["Credit"]?.trim() ?? ""

// Only check lines where both fields have valid numeric values
if (debitStr === "" || creditStr === "") continue

const debit = parseDecimal(debitStr)
const credit = parseDecimal(creditStr)

// Both non-zero on the same line
if (Math.abs(debit) > 0.005 && Math.abs(credit) > 0.005) {
results.push({
id: "DEBIT_CREDIT_EXCLUSIVE",
severity: "warning",
message: `Ligne ${i + 2} : débit (${debitStr}) et crédit (${creditStr}) sont tous les deux renseignés sur une même ligne.`,
line: i + 2,
})
}
// Both zero (debit = credit = 0)
if (Math.abs(debit) <= 0.005 && Math.abs(credit) <= 0.005) {
results.push({
id: "DEBIT_CREDIT_EXCLUSIVE",
severity: "warning",
message: `Ligne ${i + 2} : débit et crédit sont tous les deux à zéro.`,
line: i + 2,
})
}
}
return results
}

// ---------------------------------------------------------------------------
// 23. checkNumericDotSeparator - Le point est interdit comme séparateur décimal
// ---------------------------------------------------------------------------

export function checkNumericDotSeparator(parsed: FecParsedFile): FecCheckResult[] {
const results: FecCheckResult[] = []
const numericFields = ["Debit", "Credit", "Montantdevise"]

for (let i = 0; i < parsed.entries.length; i++) {
const entry = parsed.entries[i]!
for (const field of numericFields) {
const value = entry[field]
if (value && value.trim() !== "" && value.includes(".")) {
results.push({
id: "NUMERIC_DOT_SEPARATOR",
severity: "error",
message: `Ligne ${i + 2} : le champ "${field}" contient "${value}" — un format numérique avec une virgule au lieu d'un point est attendu.`,
line: i + 2,
field,
})
}
}
}
return results
}

// ---------------------------------------------------------------------------
// 24. checkNumericThousandsSeparator - Pas de séparateur de milliers (espace ou caractères multiples)
// ---------------------------------------------------------------------------

export function checkNumericThousandsSeparator(parsed: FecParsedFile): FecCheckResult[] {
const results: FecCheckResult[] = []
const numericFields = ["Debit", "Credit", "Montantdevise"]
// Detects patterns like "1 000" or "1,000,000"
const thousandsPattern = /^\s*[+-]?\d+\s+\d+/
const multiSepPattern = /[.,].*[.,]/

for (let i = 0; i < parsed.entries.length; i++) {
const entry = parsed.entries[i]!
for (const field of numericFields) {
const value = entry[field]
if (value && value.trim() !== "") {
if (thousandsPattern.test(value) || multiSepPattern.test(value)) {
results.push({
id: "NUMERIC_THOUSANDS_SEPARATOR",
severity: "error",
message: `Ligne ${i + 2} : le champ "${field}" contient "${value}" — un format numérique sans séparateur de milliers est attendu.`,
line: i + 2,
field,
})
}
}
}
}
return results
}

// ---------------------------------------------------------------------------
// 25. checkDateYearRange - Les dates doivent avoir une année entre 1900 et 2099
// ---------------------------------------------------------------------------

export function checkDateYearRange(parsed: FecParsedFile): FecCheckResult[] {
const results: FecCheckResult[] = []
const dateFields = ["EcritureDate", "PieceDate", "ValidDate", "DateLet"]

for (let i = 0; i < parsed.entries.length; i++) {
const entry = parsed.entries[i]!
for (const field of dateFields) {
const value = entry[field]
if (value && /^\d{8}$/.test(value)) {
const year = parseInt(value.substring(0, 4), 10)
if (year < 1900 || year > 2099) {
results.push({
id: "DATE_YEAR_RANGE",
severity: "warning",
message: `Ligne ${i + 2} : le champ "${field}" contient une date (${value}) en dehors de la période 1900–2099.`,
line: i + 2,
field,
})
}
}
}
}
return results
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions packages/engine/src/fec/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type {
FecColumnDefinition,
FecEntry,
FecParsedFile,
FecParsedLineIssue,
} from "./types.js"

export { BIC_COLUMNS, BIC_COLUMN_NAMES } from "./types.js"
Expand Down Expand Up @@ -34,4 +35,10 @@ export {
checkEmptyFile,
checkDateValidity,
checkPieceDateCoherence,
checkFieldCountMismatch,
checkEmptyLines,
checkDebitCreditExclusive,
checkNumericDotSeparator,
checkNumericThousandsSeparator,
checkDateYearRange,
} from "./checks.js"
118 changes: 68 additions & 50 deletions packages/engine/src/fec/parseFlatFile.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,76 @@
import type { FecEntry, FecParsedFile } from "./types.js";
import type { FecEntry, FecParsedFile, FecParsedLineIssue } from "./types.js"

export function parseFlatFile(content: string, fileName: string): FecParsedFile {
// Split lines, handling both \r\n and \n
const lines = content.split(/\r?\n/);
// Split lines, handling both \r\n and \n
const lines = content.split(/\r?\n/)

// Remove trailing empty lines
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") {
lines.pop();
}
// Remove trailing empty lines
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") {
lines.pop()
}

if (lines.length === 0) {
return {
fileType: "flat",
headers: [],
entries: [],
separator: "\t",
};
}

const headerLine = lines[0]!;

// Detect separator: try tab first, then pipe, default to tab
let separator: string;
if (headerLine.split("\t").length >= 5) {
separator = "\t";
} else if (headerLine.split("|").length >= 5) {
separator = "|";
} else {
separator = "\t";
}

// Parse header
const headers = headerLine.split(separator).map((h) => h.trim());

// Parse data rows
const entries: FecEntry[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i]!;
if (line.trim() === "") {
continue;
if (lines.length === 0) {
return {
fileType: "flat",
headers: [],
entries: [],
separator: "\t",
lineIssues: [],
}
}

const headerLine = lines[0]!

// Detect separator: try tab first, then pipe, default to tab
let separator: string
if (headerLine.split("\t").length >= 5) {
separator = "\t"
} else if (headerLine.split("|").length >= 5) {
separator = "|"
} else {
separator = "\t"
}

// Parse header
const headers = headerLine.split(separator).map((h) => h.trim())

// Parse data rows
const entries: FecEntry[] = []
const lineIssues: FecParsedLineIssue[] = []

for (let i = 1; i < lines.length; i++) {
const line = lines[i]!
const lineNumber = i + 1 // 1-based

if (line.trim() === "") {
lineIssues.push({ line: lineNumber, kind: "empty" })
continue
}

const values = line.split(separator)

// Track field count mismatch (Alto2 check #13)
if (values.length !== headers.length) {
lineIssues.push({
line: lineNumber,
kind: "field_count_mismatch",
expectedFields: headers.length,
actualFields: values.length,
})
}

const entry: FecEntry = {}
for (let j = 0; j < headers.length; j++) {
entry[headers[j]!] = (values[j] ?? "").trim()
}
entries.push(entry)
}

const values = line.split(separator);
const entry: FecEntry = {};
for (let j = 0; j < headers.length; j++) {
entry[headers[j]!] = (values[j] ?? "").trim();
return {
fileType: "flat",
headers,
entries,
separator,
lineIssues,
}
entries.push(entry);
}

return {
fileType: "flat",
headers,
entries,
separator,
};
}
Loading
Loading