From 6ff6c10e3f935a7f0c5ac92ec30fc9e89cf118c0 Mon Sep 17 00:00:00 2001 From: Kevin Courbet Date: Sat, 21 Feb 2026 04:15:45 +0100 Subject: [PATCH] chore: add AGENTS.md, agent docs, oxlint config, remove emojis from logs --- AGENTS.md | 56 +++++ .../agents/accounting-financial-statements.md | 35 +++ docs/agents/effect-patterns.md | 218 ++++++++++++++++++ docs/agents/effect.md | 61 +++++ docs/agents/logging.md | 125 ++++++++++ docs/agents/monetary.md | 54 +++++ oxlintrc.json | 49 ++++ src/models/company-structure.ts | 20 +- test/company-structure.test.ts | 14 +- 9 files changed, 615 insertions(+), 17 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/agents/accounting-financial-statements.md create mode 100644 docs/agents/effect-patterns.md create mode 100644 docs/agents/effect.md create mode 100644 docs/agents/logging.md create mode 100644 docs/agents/monetary.md create mode 100644 oxlintrc.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f457a88 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md + +`@autonomynexus/accounting` — French accounting logic library (PCG, FEC, VAT, financial statements, liasse fiscale). Built with Effect.ts; uses `monetary` as a peer dependency. + +## Commands +- Install: `bun install` +- Test: `bun run test` +- Typecheck: `bunx tsc -p tsconfig.build.json` +- Build: `bun run build` (tsc emit) +- Lint: `bun run lint` (oxlint --fix) +- Format: `bun run format` (oxfmt) + +## Repo Map +- Entry point: `src/index.ts` +- Models & schemas: `src/models/`, `src/models.ts` +- Chart of accounts (PCG): `src/pcg/`, `src/chart-of-accounts.ts` +- Journal entries: `src/journal/` +- Financial statements (bilan, compte de résultat): `src/financial-statements/` +- Liasse fiscale (BNC-2035 etc.): `src/liasse-fiscale/`, `src/bnc-2035/` +- FEC export: `src/fec/` +- VAT declarations: `src/vat/` +- Amortization: `src/amortization/` +- URSSAF: `src/urssaf/` +- Exercice (fiscal year): `src/exercice/` +- Engine (computation): `src/engine/` +- Regime: `src/regime/` +- Threshold logic: `src/threshold/` +- Bespoke helpers: `src/bespoke/` +- Ports (interfaces): `src/ports/` +- Solde helpers: `src/is-solde/` +- Tests: `test/` + +## Conventions (Always Apply) +- Package manager: bun only; do not add npm/yarn/pnpm lockfiles. +- ESM: `.js` extensions required in all imports (tsc emit, not bundled). +- Effect errors: prefer `Schema.TaggedError`, `Effect.catchTag/catchTags`, `Cause.pretty`. +- Effect guards: use library guards (e.g. `Exit.isSuccess`) vs ad-hoc `_tag` checks. +- Monetary: all money values use `monetary` types — never raw `number` for currency amounts. +- Logging: prefer `Effect.log*` over `console.*` (oxlint enforces `no-console`). +- Linting: oxlint enforced (`no-console`, `no-explicit-any`, `no-barrel-file` except `src/index.ts`, `no-namespace`). +- No barrel files: only `src/index.ts` is allowed as a barrel (re-export) file. + +--- + +## Retrievable Documentation + + +Prefer retrieval-led reasoning over pre-training-led reasoning. + +[Project Docs]|root: ./docs/agents|Retrievable docs for project patterns and conventions +|effect.md: Tag/Layer, runtime, Effect.gen, Schema.TaggedError, Cause/Exit, logging, error matching +|effect-patterns.md: Effect.ts service pattern, layers, error handling, dependency injection +|monetary.md: Monetary types, snapshots, arithmetic helpers, Drizzle serialization, tax rates +|logging.md: wide events, 1 log per request, spans, structured logging standards +|accounting-financial-statements.md: PCG income statement, balance sheet, journal aggregation, money rules + diff --git a/docs/agents/accounting-financial-statements.md b/docs/agents/accounting-financial-statements.md new file mode 100644 index 0000000..98424ed --- /dev/null +++ b/docs/agents/accounting-financial-statements.md @@ -0,0 +1,35 @@ +--- +updated: 2026-02-09 +--- + +# Accounting: Financial Statements + +This repo generates French accounting financial statements from validated journal entries, +following PCG (Plan Comptable General) conventions. + +## Where +- Feature folder: `src/server/features/accounting/financial-statements/` +- Services include income statement and balance sheet. + +## Income Statement (Compte de resultat) +The income statement is derived by aggregating balances by account code: +- Operating result: class 7 revenue - class 6 expenses +- Financial result: 76x revenue - 66x expenses +- Exceptional result: 77x/78x revenue - 67x/68x expenses +- Net result: operating + financial + exceptional - income tax (69x) + +## Inputs +- Validated journal entries only. +- Multi-tenant boundaries are enforced (user-scoped). + +## Output Shape +- Use `Monetary` types throughout; no floating-point. +- Support year-over-year comparison when previous-year data is provided. + +## Implementation Notes +- Prefer business logic in services; keep queries in repositories. +- For multi-step compute + persistence flows, wrap with `withTransaction(...)`. + +## Related Money Rules +- Use `monetary` helpers (see `docs/agents/monetary.md`). +- Avoid serializing rich `Monetary` objects; use snapshots for transport. diff --git a/docs/agents/effect-patterns.md b/docs/agents/effect-patterns.md new file mode 100644 index 0000000..73abe92 --- /dev/null +++ b/docs/agents/effect-patterns.md @@ -0,0 +1,218 @@ +--- +updated: 2026-02-11 +--- + +# Effect.ts Patterns + +## Service Pattern +```typescript +// 1. Interface (R = never - deps resolved in Layer) +export type InvoiceServiceInterface = { + readonly createInvoice: (params) => Effect.Effect; +}; + +// 2. Tag (MUST use Effect.Tag - pre-commit enforced) +// Use @app/ServiceName prefix for uniqueness +export class InvoiceService extends Effect.Tag("@app/InvoiceService")< + InvoiceService, + InvoiceServiceInterface +>() {} + +// 3. Layer (NO global imports - use yield*) +// Naming: camelCase + Layer suffix (layer, testLayer, mockLayer) +// Use 'layer' for production, NOT 'liveLayer' +export const InvoiceServiceLayer = Layer.effect( + InvoiceService, + Effect.gen(function* () { + const db = yield* Database; + const revalidator = yield* RevalidationService; + + const createInvoice = (params) => + Effect.fn("createInvoice")(function* () { + const data = yield* Effect.tryPromise({ + try: () => db.insert(invoice).values(params), + catch: (e) => new DatabaseError({ operation: "create", details: e }), + }); + yield* revalidator.revalidatePaths(["/invoices"]); + return data; + }); + + return InvoiceService.of({ createInvoice }); + }) +); + +// Test layer +export const InvoiceServiceTestLayer = Layer.succeed( + InvoiceService, + InvoiceService.of({ + createInvoice: () => Effect.succeed(mockInvoice), + }) +); +``` + +**Service Design Pattern**: Start by sketching service tags (no implementations) to reason about boundaries and dependencies before writing production code. Services should have `R = never` (resolve deps in Layer, not interface), **except `R = Database`**: repositories require it for tx propagation, services inherit it from repo calls (services never do direct db calls). + +## Effect.fn Pattern +```typescript +// Effect.fn for named, traced effects (tracing spans, stack traces) +const processInvoice = Effect.fn("processInvoice")(function* (invoiceId: InvoiceId) { + const invoice = yield* getInvoice(invoiceId) + return yield* processData(invoice) +}) + +// Effect.gen for simple inline Effects +const simpleEffect = Effect.gen(function* () { + return yield* fetchData +}) +``` + +## Tagged Errors + +- **Expected errors** (Schema.TaggedError): Domain failures callers handle (validation, not found, permission) +- **Defects** (Schema.Defect): Unrecoverable (bugs, invariant violations, external crashes) + +```typescript +export class ValidationError extends Schema.TaggedError()( + "ValidationError", { message: Schema.String } +) {} + +export class HttpError extends Schema.Defect {} // Unrecoverable + +// Recovery: catchAll, catchTag("HttpError", ...), catchTags({...}) +``` + +## NotFoundError Pattern (Page Builder) +```typescript +// app/lib/errors.ts - Domain errors MUST extend this +export abstract class NotFoundError extends Schema.TaggedError()( + "NotFoundError", { message: Schema.String } +) {} + +export class InvoiceNotFoundError extends NotFoundError {} // Shares _tag +``` + +Page builder catches ANY `*NotFoundError` via `Predicate.isTagged("NotFoundError")` → `notFound()` → 404. + +## Repository NotFoundError Pattern (CRITICAL) + +Repository `find*`/`get*` MUST fail with NotFoundError. **Never return undefined/null.** + +```typescript +// ✅ CORRECT: Fail with NotFoundError +findById: (id, userId) => Effect.gen(function* () { + const db = yield* Database; + const result = yield* Effect.tryPromise({...}); + if (!result) return yield* new InvoiceNotFoundError({ message: "not found" }); + return mapDbRowToDomain(result); +}), + +// ❌ WRONG: filterOrFail is fragile (undefined !== null is TRUE!) +repo.findById(id, userId).pipe(Effect.filterOrFail((r) => r !== null, ...)) +``` + +**Null check rule**: Use `!result` or `!= null`. **Never** `!== null`. + +## Schemas (UI/Server Contract) + +**Date handling**: `Schema.DateFromSelf` for native Dates. `Schema.Date` only for JSON deserialization. + +**Branded Types**: Brand ALL primitives, not just IDs +```typescript +export class InvoiceId extends Schema.String.pipe(Schema.brand("InvoiceId")) {} +export class Email extends Schema.String.pipe(Schema.brand("Email")) {} + +// Schema.Class for composite types (use .make() to instantiate) +export class CreateInvoiceInput extends Schema.Class("CreateInvoiceInput")({ + issuerId: Schema.String, + status: Schema.Literal("draft", "final"), +}) {} + +const input = CreateInvoiceInput.make({ issuerId: "123", status: "draft" }) +``` + +Validate at boundary (actions), services use inferred types (no validation). + +## Config Management + +```typescript +class AppConfig extends Context.Tag("@app/AppConfig")< + AppConfig, { apiUrl: string; apiKey: string } +>() { + static readonly layer = Layer.effect(AppConfig, Effect.gen(function* () { + const apiUrl = yield* Config.string("API_URL") + const apiKey = yield* Config.redacted("API_KEY") // Auto-hidden + return { apiUrl, apiKey: Config.value(apiKey) } + })) + + static readonly testLayer = Layer.succeed(AppConfig, { apiUrl: "http://localhost:3000", apiKey: "test" }) +} + +// Defaults: Config.orElse(() => Config.succeed(3000)) +// Validation: Config.mapOrFail((p) => p > 0 ? Effect.succeed(p) : Effect.fail(...)) +``` + +## HTTP Clients + +```typescript +class ApiClient extends Context.Tag("@app/ApiClient")() { + static readonly layer = Layer.effect(ApiClient, Effect.gen(function* () { + return HttpClient.make().pipe( + HttpClient.mapRequest(HttpClientRequest.bearerToken(token)), + HttpClient.mapRequest(HttpClientRequest.acceptJson) + ) + })) +} + +const getUser = (id: string) => Effect.gen(function* () { + const client = yield* ApiClient + return yield* client.get(`/users/${id}`).pipe(HttpClientResponse.schemaBodyJson(UserSchema)) +}) +``` + +## Validation + +**Option types** (prefer over T | null): +```typescript +const getOrg = (id: number | null) => Option.fromNullable(id).pipe( + Option.flatMap((id) => Option.fromNullable(orgs.find(o => o.id === id))) +); + +// Usage: Option.match({ onNone: () => "—", onSome: (org) => }) +``` + +**NO `any` types. Use tagged errors, not throws.** + +**Union Types**: `Schema.Literal` for enums, `Schema.TaggedClass + Schema.Union` for complex variants. +Pattern match with `Match.value(...).pipe(Match.tag("Draft", ...), Match.exhaustive)`. + +## Testing + +```typescript +import { it, layer } from "@effect/vitest" + +layer(InvoiceServiceTestLayer) // Memoize once per file + +it.effect("creates invoice", () => Effect.gen(function* () { + const service = yield* InvoiceService + const invoice = yield* service.createInvoice(params) + expect(invoice.status).toBe("draft") +})) + +it.scoped("handles resources", () => Effect.gen(function* () { + const resource = yield* acquireResource // auto cleanup +})) +``` + +## Observability + +```typescript +const OtelLayer = NodeSdk.layer(() => ({ + resource: { serviceName: "myapp", serviceVersion: "1.0.0" }, + spanProcessor: new BatchSpanProcessor(new OTLPTraceExporter({ url: "..." })) +})) + +// Named spans with Effect.fn + withSpan +const fetchUser = Effect.fn("fetchUser")(function* (userId: string) { + return yield* Effect.tryPromise(...).pipe(Effect.withSpan("fetchUser", { attributes: { userId } })) +}) +``` diff --git a/docs/agents/effect.md b/docs/agents/effect.md new file mode 100644 index 0000000..f3e646d --- /dev/null +++ b/docs/agents/effect.md @@ -0,0 +1,61 @@ +--- +updated: 2026-02-15 +--- + +# Effect.ts Patterns + +Server-side code uses Effect for services, repositories, infrastructure, and runtime wiring. +The goal is: composable business logic, typed failure, structured logging, and testable layers. + +## Project Structure +- Runtime wiring: `src/server/runtime.ts`. +- Feature services/repos: `src/server/features/**`. +- Shared infra: `src/server/shared/infrastructure/**`. +- Shared error formatting: `src/server/shared/errors.ts`. + +## Tags + Layers +- Use `Effect.Tag` / `Context.Tag` for capabilities (services, repos, infra). +- Provide implementations with `Layer.*`. +- Compose layers into a `ManagedRuntime` once at startup. + +Notes from `src/server/runtime.ts`: +- Infrastructure layers depend on config. +- Repository layers depend on `Database`. +- Service layers depend on repos + infra. +- `ConsolaLoggerLive` replaces the default Effect logger. + +## Writing Services +- Prefer `Effect.gen(function* () { ... })` for readable orchestration. +- Keep DB access in repos; keep business rules in services. +- For cross-repo write flows, wrap the orchestrator with `withTransaction(...)`. + +## Error Modeling +- Define domain errors with `Schema.TaggedError`. +- Always fail with typed/tagged errors for expected failures. +- Avoid silent fallbacks; either return a typed error or throw a deliberate redirect/notFound. + +Matching and formatting: +- Catch typed failures with `Effect.catchTag` or `Effect.catchTags`. +- For diagnostics, use `Cause.pretty(cause)`. +- For user-friendly logs, prefer the formatter in `src/server/shared/errors.ts`. + +## Guards / Type Checks +- Prefer Effect-provided guards (examples: `Exit.isSuccess`, `Option.isSome`, `Either.isRight`). +- Avoid ad-hoc `_tag` string checks when the library already provides a guard. + +## Logging +- Prefer `Effect.logDebug/info/warn/error` over `console.*`. +- Add request correlation via `Effect.annotateLogs({ requestId, userId, ... })`. +- `src/server/shared/lib/consola-logger.ts` routes Effect logs to the app logger. + +## Concurrency +- Use `Effect.all([...])` for parallelizable work (independent reads). +- Use sequential composition for dependent steps (write -> read, idempotency locks, etc.). + +## Practical Patterns + +Error shaping for server boundaries: +- Keep typed failures as typed RPC errors and let the boundary transport them. + +Request-local annotation: +- Put `requestId` in annotations once and let the logger attach it to every message. diff --git a/docs/agents/logging.md b/docs/agents/logging.md new file mode 100644 index 0000000..d967f29 --- /dev/null +++ b/docs/agents/logging.md @@ -0,0 +1,125 @@ +--- +updated: 2026-02-11 +--- + +# Logging Standards + +## Philosophy: Wide Events + +**1 log per request max.** Services use spans for tracing, not logs. + +Source: `docs/plans/wide-events-refactor.md` + +## When to Log + +| Level | When to Use | +| ------------ | ---------------------------------------- | +| `logError` | Sentry-worthy failures only | +| `logWarning` | Anomalies worth investigating | +| `logInfo` | NEVER in services (action builder emits) | +| `logDebug` | NEVER in committed code | + +## Exception: Debugging + +Quick console.log / Effect.logDebug while debugging is FINE. +Remove before committing. + +## Critical: Cause as 2nd Arg + +Effect's Logger receives cause ONLY when passed as 2nd positional arg. +Passing in data object shows `[object Object]`. + +```typescript +// ✅ CORRECT +Effect.tapErrorCause((cause) => + Effect.logError("PDF generation failed", cause).pipe( + Effect.annotateLogs({ documentId, documentType }), + ), +); + +// ❌ WRONG - logs [object Object] +Effect.tapErrorCause((cause) => + Effect.logError("PDF generation failed", { + documentId, + cause, // NOT formatted! + }), +); +``` + +## Services: Spans, Not Logs + +```typescript +// ✅ CORRECT +yield* addRequestContext({ entityType: "invoice", entityId: id }); +const buffer = yield* generatePdf(...).pipe(Effect.withSpan("generate-pdf")); + +// ❌ WRONG +yield* Effect.logDebug("Generating PDF", { documentId }); +const buffer = yield* generatePdf(...); +``` + +## Context Enrichment + +Use `addRequestContext()` to add business data to the wide event: + +```typescript +yield * + addRequestContext({ + entityType: "invoice", + entityId: invoice.id, + documentNumber: invoice.number, + }); +``` + +Action builder emits this context in `ActionCompleted`/`ActionFailed`. + +--- + +## Logging Review Checklist + +Review logging changes. ONLY report violations - do NOT list correct patterns. + +### Check 1: Cause Handling (CRITICAL) + +Look for `tapErrorCause` or `logError` with cause. + +**Violation**: Cause in data object +```typescript +Effect.logError("message", { cause, ...data }); +``` + +**Correct**: Cause as 2nd arg +```typescript +Effect.logError("message", cause).pipe(Effect.annotateLogs({ ...data })); +``` + +### Check 2: No Service Logs (HIGH) + +Services (`*.service.ts`) should NOT contain: +- `Effect.logInfo` +- `Effect.logDebug` +- `Effect.log` + +**Exception**: `Effect.logError` for Sentry-worthy failures is OK. +**Correct**: Use `Effect.withSpan` for tracing. + +### Check 3: Debug Logs in Production (MEDIUM) + +`Effect.logDebug` should not exist in committed service/repository code. +**Exception**: Actively being debugged (should be removed before merge). + +### Severity Guide + +- **Critical**: Cause formatting broken (will log `[object Object]`) +- **High**: Service logging (violates wide events) +- **Medium**: Debug logs in production code + +### Output Format + +Report ONLY violations: + +| # | Severity | Issue | File:Line | +| --- | -------- | -------------------- | ------------- | +| 1 | Critical | Cause in data object | service.ts:42 | + +If no violations: "No logging violations found." diff --git a/docs/agents/monetary.md b/docs/agents/monetary.md new file mode 100644 index 0000000..3538551 --- /dev/null +++ b/docs/agents/monetary.md @@ -0,0 +1,54 @@ +--- +updated: 2026-02-09 +--- + +# Monetary (Internal Money Library) + +This repo uses the workspace package `monetary` for all money math. +Do not use floating-point arithmetic for currency amounts. + +## Core Types +- `Monetary`: rich money object (arithmetic + formatting). +- `MonetarySnapshot`: plain JSON-serializable representation. +- `ScaledAmount`: `{ amount, scale }` for scaled integers (e.g. rates). + +## Rules +- Prefer `Monetary` inside domain/server logic. +- When crossing a network or serialization boundary (server fn response, DB JSON, route + loader return), prefer `MonetarySnapshot`. + +Why: rich `Monetary` objects are not safe to JSON stringify across all environments. + +## Common Helpers +- Create money: `monetary({ amount, currency, scale? })`. +- Convert for transport: `toSnapshot(money)`. +- Render: project helper `renderMoney` in `src/lib/monetary.ts`. + +Related project code: +- Monetary utilities + schemas: `src/lib/monetary.ts` +- Display helpers: `src/lib/money.ts` + +## DB Serialization +Drizzle custom type: +- `src/db/schema/monetary.ts` stores `Monetary` as `text` JSON of `MonetarySnapshot`. + +Implications: +- DB columns using `monetaryType` round-trip as `Monetary` in app code. +- Do not store raw decimals; store snapshots or use the custom type. + +## Tax Rates (ScaledAmount) +Tax rates use a `ScaledAmount` backed by `numeric(5,3)`: +- Type: `taxRateType` in `src/db/schema/monetary.ts` +- Conversions: + - `percentageToTaxRate(5.5) -> { amount: 55, scale: 3 }` (represents 0.055) + - `taxRateToPercentage({ amount: 55, scale: 3 }) -> 5.5` + +## When Adding New Money Fields +- Decide storage form: `Monetary` (custom type) vs `ScaledAmount` vs plain integer. +- Decide transport form: `MonetarySnapshot` for anything serialized. +- Add schemas at the boundary if the value comes from user input. + +## Pitfalls +- Mixing currencies without explicit conversion. +- Returning `Monetary` objects from server functions (prefer snapshots). +- Using JS `number` for money math (only OK for display after snapshot conversion). diff --git a/oxlintrc.json b/oxlintrc.json new file mode 100644 index 0000000..6a76938 --- /dev/null +++ b/oxlintrc.json @@ -0,0 +1,49 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "oxc", "import"], + "env": { + "node": true + }, + "rules": { + "eslint/no-unused-expressions": "off", + "eslint/max-params": "off", + "eslint/no-console": "error", + "typescript/no-explicit-any": "error", + "eslint/no-nested-ternary": "error", + "import/no-namespace": "error", + "oxc/no-barrel-file": "error" + }, + "overrides": [ + { + "files": ["src/index.ts"], + "rules": { + "oxc/no-barrel-file": "off" + } + }, + { + "files": [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx" + ], + "env": { + "jest": true + }, + "rules": { + "eslint/no-console": "off" + }, + "globals": { + "describe": "readonly", + "it": "readonly", + "expect": "readonly", + "beforeEach": "readonly", + "afterEach": "readonly", + "beforeAll": "readonly", + "afterAll": "readonly", + "vi": "readonly", + "test": "readonly" + } + } + ] +} diff --git a/src/models/company-structure.ts b/src/models/company-structure.ts index d053642..2655e2a 100644 --- a/src/models/company-structure.ts +++ b/src/models/company-structure.ts @@ -60,18 +60,18 @@ export function isPMEEligible(structure: CompanyStructure): PMEEligibilityResult // Condition 1: CA HT < €10M (CGI Art. 219-I-b) if (greaterThan(structure.chiffreAffairesHT, TEN_MILLION) || structure.chiffreAffairesHT.amount === TEN_MILLION.amount) { - reasons.push("❌ CA HT ≥ €10M — condition non remplie (CGI Art. 219-I-b)"); + reasons.push("FAIL: CA HT ≥ €10M — condition non remplie (CGI Art. 219-I-b)"); eligible = false; } else { - reasons.push("✅ CA HT < €10M"); + reasons.push("OK: CA HT < €10M"); } // Condition 2: Capital entièrement libéré (CGI Art. 219-I-b) if (greaterThan(structure.capitalSocial, structure.capitalLibere)) { - reasons.push("❌ Capital non entièrement libéré — condition non remplie (CGI Art. 219-I-b)"); + reasons.push("FAIL: Capital non entièrement libéré — condition non remplie (CGI Art. 219-I-b)"); eligible = false; } else { - reasons.push("✅ Capital entièrement libéré"); + reasons.push("OK: Capital entièrement libéré"); } // Condition 3: Capital détenu ≥75% par des personnes physiques (CGI Art. 219-I-b) @@ -81,26 +81,26 @@ export function isPMEEligible(structure: CompanyStructure): PMEEligibilityResult if (naturalPersonPercentage < 75) { reasons.push( - `❌ Capital détenu à ${naturalPersonPercentage.toFixed(1)}% par des personnes physiques (< 75%) — condition non remplie (CGI Art. 219-I-b)`, + `FAIL: Capital détenu à ${naturalPersonPercentage.toFixed(1)}% par des personnes physiques (< 75%) — condition non remplie (CGI Art. 219-I-b)`, ); eligible = false; } else { - reasons.push(`✅ Capital détenu à ${naturalPersonPercentage.toFixed(1)}% par des personnes physiques (≥ 75%)`); + reasons.push(`OK: Capital détenu à ${naturalPersonPercentage.toFixed(1)}% par des personnes physiques (≥ 75%)`); } // Condition 4: Effectif < 50 (EU PME, Annexe I Reg. 651/2014) if (structure.effectifMoyen >= 50) { - reasons.push(`⚠️ Effectif moyen = ${structure.effectifMoyen} (≥ 50) — critère EU PME non rempli`); + reasons.push(`WARN: Effectif moyen = ${structure.effectifMoyen} (≥ 50) — critère EU PME non rempli`); // Note: this is EU PME, not strictly required for IS taux réduit per CGI 219-I-b } else { - reasons.push(`✅ Effectif moyen = ${structure.effectifMoyen} (< 50)`); + reasons.push(`OK: Effectif moyen = ${structure.effectifMoyen} (< 50)`); } // Condition 5: Total bilan < €10M (EU PME, Annexe I Reg. 651/2014) if (greaterThan(structure.totalBilan, TEN_MILLION) || structure.totalBilan.amount === TEN_MILLION.amount) { - reasons.push("⚠️ Total bilan ≥ €10M — critère EU PME non rempli"); + reasons.push("WARN: Total bilan ≥ €10M — critère EU PME non rempli"); } else { - reasons.push("✅ Total bilan < €10M"); + reasons.push("OK: Total bilan < €10M"); } return { eligible, reasons }; diff --git a/test/company-structure.test.ts b/test/company-structure.test.ts index 9f368dd..b65994d 100644 --- a/test/company-structure.test.ts +++ b/test/company-structure.test.ts @@ -23,20 +23,20 @@ describe("isPMEEligible", () => { const result = isPMEEligible(makeStructure()); expect(result.eligible).toBe(true); expect(result.reasons).toHaveLength(5); - expect(result.reasons.every((r) => r.startsWith("✅"))).toBe(true); + expect(result.reasons.every((r) => r.startsWith("OK: "))).toBe(true); }); it("ineligible when CA >= 10M", () => { const result = isPMEEligible(makeStructure({ chiffreAffairesHT: m(10_000_000) })); expect(result.eligible).toBe(false); - expect(result.reasons[0]).toContain("❌"); + expect(result.reasons[0]).toContain("FAIL: "); expect(result.reasons[0]).toContain("CA HT"); }); it("ineligible when capital not fully paid up", () => { const result = isPMEEligible(makeStructure({ capitalLibere: m(5000) })); expect(result.eligible).toBe(false); - expect(result.reasons[1]).toContain("❌"); + expect(result.reasons[1]).toContain("FAIL: "); expect(result.reasons[1]).toContain("libéré"); }); @@ -47,7 +47,7 @@ describe("isPMEEligible", () => { ]; const result = isPMEEligible(makeStructure({ shareholders })); expect(result.eligible).toBe(false); - expect(result.reasons[2]).toContain("❌"); + expect(result.reasons[2]).toContain("FAIL: "); expect(result.reasons[2]).toContain("40.0%"); }); @@ -64,14 +64,14 @@ describe("isPMEEligible", () => { const result = isPMEEligible(makeStructure({ effectifMoyen: 55 })); // Still eligible for CGI 219-I-b (EU PME is informational) expect(result.eligible).toBe(true); - expect(result.reasons[3]).toContain("⚠️"); + expect(result.reasons[3]).toContain("WARN: "); expect(result.reasons[3]).toContain("55"); }); it("shows EU PME warnings for total bilan >= 10M", () => { const result = isPMEEligible(makeStructure({ totalBilan: m(15_000_000) })); expect(result.eligible).toBe(true); - expect(result.reasons[4]).toContain("⚠️"); + expect(result.reasons[4]).toContain("WARN: "); }); it("multiple conditions can fail simultaneously", () => { @@ -81,7 +81,7 @@ describe("isPMEEligible", () => { shareholders: [{ name: "Corp", type: "legal_entity", sharePercentage: 100 }], })); expect(result.eligible).toBe(false); - const failures = result.reasons.filter((r) => r.startsWith("❌")); + const failures = result.reasons.filter((r) => r.startsWith("FAIL: ")); expect(failures).toHaveLength(3); }); });