# API FenuaCompta (v1)

> Documentation complète de l'API REST de FenuaCompta, logiciel de facturation et de comptabilité (Polynésie française, montants en XPF). Ce fichier est fait pour être donné à un assistant IA qui écrit une intégration : chaque route, chaque champ, ses limites et ses valeurs par défaut.

URL de base : `https://VOTRE-COMPTE.fenuacompta.com/api/v1`

## Authentification

Chaque requête s'authentifie par une clé API, dans l'entête `Authorization: Bearer <clé API>`. Les clés se créent dans le logiciel, **Réglages › Documentation API**, et ne sont affichées qu'une fois. Une clé a les droits de l'utilisateur qui l'a créée et reste valable jusqu'à sa révocation : révoquez-la au moindre doute.

```
Authorization: Bearer fenuacompta_key_...
Content-Type: application/json
Accept: application/json
```

## Conventions

- **Format** : JSON en entrée et en sortie. Dates `AAAA-MM-JJ`. Les listes renvoient leurs éléments dans `data`, sans pagination (sauf le catalogue de la boutique) : 1 000 au plus, 200 pour les produits.
- **Montants** : En XPF. Devis et factures : prix HT par ligne. Taux dans `tvaBucket` : `tva16`, `tva13`, `tva5`, `tva1`, `tva0`. Commandes de la boutique : prix TTC payés.
- **Commandes** : Identifiées par `source` + `reference` : renvoyer une commande ne crée pas de doublon.
- **Limite** : 90 requêtes par minute. Au-delà : `429`.

## Erreurs

Le corps d'une erreur est `{ "code": "...", "message": "...", "fields": { "champ": ["message"] } }`.

| Statut | Signification |
| --- | --- |
| 401 | Clé absente, invalide ou révoquée |
| 402 | Abonnement inactif |
| 403 | Permission insuffisante ou module non activé |
| 404 | Ressource introuvable |
| 409 | Opération impossible dans l'état actuel |
| 422 | Données invalides, détail dans `fields` |
| 429 | Trop de requêtes |

## Routes

### Compte

#### `GET /me` : Vérifier la clé

L'utilisateur de la clé et le compte. Le bon appel pour tester une clé.

Réponse `200` :

```json
{
    "user": {
        "id": 1,
        "firstName": "Teva",
        "lastName": "Exemple",
        "email": "client@example.com",
        "role": "Administrateur"
    },
    "account": {
        "name": "Ma société",
        "tvaEnabled": true,
        "decimals": 0
    }
}
```

### Clients

#### `GET /clients` : Lister les clients

Les clients actifs, par ordre alphabétique (1 000 au plus).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `q` (query) | texte | non |  | Recherche dans le nom du client. |

Réponse `200` :

```json
{
    "data": [
        {
            "id": 42,
            "name": "Acme Sarl",
            "email": "contact@example.com",
            "outstanding": 0,
            "dueCount": 0,
            "invoiceCount": 1,
            "totalBilled": 11600
        }
    ]
}
```

#### `POST /clients` : Créer un client

Crée la fiche client et son contact. Pour réutiliser un client existant, cherchez-le avec `GET /clients?q=`.

Droit requis : clients (écriture).

**Corps JSON**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `name` | texte | requis | Max 255 caractères | Nom du client ou de la société. |
| `email` | email | non |  | Email du contact, utilisé pour envoyer devis et factures. |
| `phone` | texte | non | Max 40 caractères |  |

Exemple de requête :

```json
{
    "name": "Acme Sarl",
    "email": "contact@example.com",
    "phone": "+689 00 00 00 00"
}
```

Réponse `201` :

```json
{
    "id": 42,
    "name": "Acme Sarl",
    "email": "contact@example.com",
    "phone": "+689 00 00 00 00"
}
```

#### `GET /clients/{id}` : Détail d'un client

La fiche, le reste dû, et l'historique des factures, devis et paiements.

Droit requis : clients (lecture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant du client. |

Réponse `200` :

```json
{
    "id": 42,
    "name": "Acme Sarl",
    "email": "contact@example.com",
    "phone": "+689 00 00 00 00",
    "website": null,
    "vat": null,
    "city": "Papeete",
    "zip": "98714",
    "country": null,
    "outstanding": 0,
    "invoices": [
        {
            "id": 318,
            "number": "FACT-318",
            "date": "2026-09-23",
            "total": 11600,
            "status": "paid"
        }
    ],
    "estimates": [
        {
            "id": 108,
            "number": "DEV-108",
            "date": "2026-09-20",
            "total": 11600,
            "status": "accepted"
        }
    ],
    "payments": [
        {
            "id": 77,
            "date": "2026-09-23",
            "amount": 11600,
            "method": "Virement",
            "invoiceNumber": "FACT-318"
        }
    ]
}
```

- Erreur `404` : Client introuvable.

#### `PUT /clients/{id}` : Modifier un client

Seuls les champs envoyés changent ; un champ envoyé à `null` est effacé.

Droit requis : clients (écriture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant du client. |

**Corps JSON**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `name` | texte | requis | Max 255 caractères |  |
| `email` | email | non |  | Email du contact principal. |
| `phone` | texte | non | Max 60 caractères |  |
| `city` | texte | non | Max 120 caractères |  |
| `zip` | texte | non | Max 30 caractères |  |
| `country` | texte | non | Max 120 caractères |  |

Exemple de requête :

```json
{
    "name": "Acme Sarl"
}
```

Réponse `200` :

```json
{
    "id": 42,
    "name": "Acme Sarl",
    "email": "contact@example.com",
    "phone": "+689 00 00 00 00",
    "website": null,
    "vat": null,
    "city": "Papeete",
    "zip": "98714",
    "country": null,
    "outstanding": 0,
    "invoices": [
        {
            "id": 318,
            "number": "FACT-318",
            "date": "2026-09-23",
            "total": 11600,
            "status": "paid"
        }
    ],
    "estimates": [
        {
            "id": 108,
            "number": "DEV-108",
            "date": "2026-09-20",
            "total": 11600,
            "status": "accepted"
        }
    ],
    "payments": [
        {
            "id": 77,
            "date": "2026-09-23",
            "amount": 11600,
            "method": "Virement",
            "invoiceNumber": "FACT-318"
        }
    ]
}
```

- Erreur `404` : Client introuvable.
- Erreur `422` : L'email n'a pas pu être enregistré pour ce client.

### Devis

#### `GET /estimates` : Lister les devis

Les devis, du plus récent au plus ancien (1 000 au plus), sans leurs lignes.

Droit requis : devis (lecture).

Réponse `200` :

```json
{
    "data": [
        {
            "id": 108,
            "number": "DEV-108",
            "clientId": 42,
            "clientName": "Acme Sarl",
            "title": "",
            "status": "sent",
            "subtotal": 10000,
            "discount": {
                "type": null,
                "percentage": 0,
                "amount": 0
            },
            "tvaTotal": 1600,
            "total": 11600,
            "createdAt": "2026-09-28T08:16:30-10:00",
            "date": "2026-09-28",
            "expiryDate": "2026-10-28",
            "lines": []
        }
    ]
}
```

- Statuts : `draft`, `sent`, `accepted`, `declined`, `revised`, `expired`.

#### `POST /estimates` : Créer un devis

Droit requis : devis (écriture).

**Corps JSON**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `clientId` | entier | requis sans newClient |  | Id du client, renvoyé par `POST /clients`. |
| `newClient` | objet | non |  | Crée le client au passage, si `clientId` est absent. |
| `newClient.name` | texte | requis | Max 255 caractères |  |
| `newClient.email` | email | non |  |  |
| `title` | texte | non | Max 255 caractères | Objet, affiché sous le numéro. |
| `lines` | liste d'objets | non |  | Lignes du devis, dans l'ordre. Facultatif : sans ligne, le devis est créé vide ; sinon, la somme des lignes avant remise doit être supérieure à 0. |
| `lines[].description` | texte | requis | Max 500 caractères | Libellé de la ligne. |
| `lines[].unitPrice` | nombre | non | Min 0, Max 99999999.99, Défaut 0 | Prix unitaire HT. |
| `lines[].quantity` | nombre | requis | Min 0.01, Max 100000 |  |
| `lines[].tvaBucket` | texte | non | Valeurs tva16 · tva13 · tva5 · tva1 · tva0, Défaut tva16 | Taux de TVA de la ligne. |
| `lines[].unit` | texte | non | Max 60 caractères, Défaut u | Unité affichée : h, jour, kg... |
| `lines[].productId` | entier | non |  | Produit du catalogue lié à la ligne (`GET /products`). |
| `lines[].type` | texte | non | Valeurs plain · title · text, Défaut plain | `title` : titre de section, `text` : paragraphe de texte mis en forme. Leur prix, quantité et TVA sont ignorés. |
| `date` | date | non | Format AAAA-MM-JJ, Défaut aujourd'hui |  |
| `expiryDate` | date | non | Format AAAA-MM-JJ, Défaut aujourd'hui + délai des réglages (30 jours s'il est nul) | Date de validité. |
| `discount` | objet | non |  | Remise sur le total HT. |
| `discount.type` | texte | non | Valeurs percentage · amount |  |
| `discount.value` | nombre | non | Min 0 | Pourcentage (100 au plus) ou montant HT (le total au plus). |
| `terms` | texte | non | Défaut conditions des réglages | Conditions affichées en bas du document, texte mis en forme (gras, italique, listes). `""` les retire. |
| `notes` | texte | non |  | Note interne, jamais affichée au client. |

Exemple de requête :

```json
{
    "clientId": 42,
    "lines": [
        {
            "description": "Prestation de conseil",
            "unitPrice": 10000,
            "quantity": 1,
            "tvaBucket": "tva16"
        }
    ]
}
```

Réponse `201` :

```json
{
    "id": 108,
    "number": "DEV-108",
    "clientId": 42,
    "clientName": "Acme Sarl",
    "title": "",
    "status": "sent",
    "subtotal": 10000,
    "discount": {
        "type": null,
        "percentage": 0,
        "amount": 0
    },
    "tvaTotal": 1600,
    "total": 11600,
    "createdAt": "2026-09-28T08:16:30-10:00",
    "date": "2026-09-28",
    "expiryDate": "2026-10-28",
    "lines": [
        {
            "id": 19,
            "productId": null,
            "description": "Prestation de conseil",
            "unitPrice": 10000,
            "unitPriceExact": 10000,
            "quantity": 1,
            "unit": "u",
            "tvaBucket": "tva16",
            "lineTotal": 10000
        }
    ],
    "tvaBreakdown": [
        {
            "key": "tva16",
            "label": "TVA 16 %",
            "rate": 16,
            "amount": 1600
        }
    ],
    "terms": "<p>Mauruuru pour votre confiance.</p>",
    "hasSections": false,
    "emails": []
}
```

- Erreur `422` : Client introuvable, somme des lignes nulle, ou montant supérieur à 99 999 999 F.

#### `GET /estimates/{id}` : Détail d'un devis

Le devis avec ses lignes, le détail de la TVA et les emails envoyés.

Droit requis : devis (lecture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant du devis. |
| `sections` (query) | booléen | non |  | `1` : inclut aussi les lignes de titre et de texte. |

Réponse `200` :

```json
{
    "id": 108,
    "number": "DEV-108",
    "clientId": 42,
    "clientName": "Acme Sarl",
    "title": "",
    "status": "sent",
    "subtotal": 10000,
    "discount": {
        "type": null,
        "percentage": 0,
        "amount": 0
    },
    "tvaTotal": 1600,
    "total": 11600,
    "createdAt": "2026-09-28T08:16:30-10:00",
    "date": "2026-09-28",
    "expiryDate": "2026-10-28",
    "lines": [
        {
            "id": 19,
            "productId": null,
            "description": "Prestation de conseil",
            "unitPrice": 10000,
            "unitPriceExact": 10000,
            "quantity": 1,
            "unit": "u",
            "tvaBucket": "tva16",
            "lineTotal": 10000
        }
    ],
    "tvaBreakdown": [
        {
            "key": "tva16",
            "label": "TVA 16 %",
            "rate": 16,
            "amount": 1600
        }
    ],
    "terms": "<p>Mauruuru pour votre confiance.</p>",
    "hasSections": false,
    "emails": []
}
```

- Erreur `404` : Devis introuvable.

#### `GET /estimates/{id}/pdf` : Télécharger le PDF

Le devis en PDF (`application/pdf`), tel que l'imprime le logiciel.

Droit requis : devis (lecture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant du devis. |

Réponse `200` : fichier `application/pdf`.

- Erreur `404` : Devis introuvable.

#### `GET /estimates/{id}/share` : Obtenir le lien public

Un lien à envoyer au client pour voir et télécharger le devis, sans compte.

Droit requis : devis (lecture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant du devis. |

Réponse `200` :

```json
{
    "shareUrl": "https://VOTRE-COMPTE.fenuacompta.com/…"
}
```

- Erreur `404` : Devis introuvable.

#### `POST /estimates/{id}/send` : Envoyer par email

Envoie le devis en PDF à l'email du client. Le statut du devis ne change pas.

Droit requis : devis (écriture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant du devis. |

Réponse `200` :

```json
{
    "ok": true,
    "status": "sent"
}
```

- Erreur `404` : Devis introuvable.
- Erreur `422` : Le client n'a pas d'email (`no_recipient`).

#### `POST /estimates/{id}/convert` : Convertir en facture

Crée la facture du devis, datée du jour ; le devis est conservé. Si le devis a déjà été converti, renvoie la même facture (`200`).

Droit requis : factures (écriture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant du devis. |

Réponse `201` :

```json
{
    "id": 319,
    "number": "FACT-319"
}
```

Réponse `200` : Devis déjà converti : la facture existante.

```json
{
    "id": 319,
    "number": "FACT-319"
}
```

- Erreur `404` : Devis introuvable.

### Factures

#### `GET /invoices` : Lister les factures

Les factures, de la plus récente à la plus ancienne (1 000 au plus), avec le payé et le reste dû.

Droit requis : factures (lecture).

Réponse `200` :

```json
{
    "data": [
        {
            "id": 318,
            "number": "FACT-318",
            "clientId": 42,
            "clientName": "Acme Sarl",
            "title": "",
            "status": "due",
            "subtotal": 10000,
            "discount": {
                "type": null,
                "percentage": 0,
                "amount": 0
            },
            "tvaTotal": 1600,
            "total": 11600,
            "createdAt": "2026-09-28T08:16:30-10:00",
            "date": "2026-09-28",
            "dueDate": "2026-10-28",
            "lines": [],
            "paid": 0,
            "balance": 11600
        }
    ]
}
```

- Statuts : `draft`, `due`, `overdue`, `part_paid`, `paid`, `credit` (avoir).

#### `POST /invoices` : Créer une facture

Droit requis : factures (écriture).

**Corps JSON**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `clientId` | entier | requis sans newClient |  | Id du client, renvoyé par `POST /clients`. |
| `newClient` | objet | non |  | Crée le client au passage, si `clientId` est absent. |
| `newClient.name` | texte | requis | Max 255 caractères |  |
| `newClient.email` | email | non |  |  |
| `title` | texte | non | Max 255 caractères | Objet, affiché sous le numéro. |
| `lines` | liste d'objets | non |  | Lignes de la facture, dans l'ordre. Facultatif : sans ligne, la facture est créée vide ; sinon, la somme des lignes avant remise doit être supérieure à 0. |
| `lines[].description` | texte | requis | Max 500 caractères | Libellé de la ligne. |
| `lines[].unitPrice` | nombre | non | Min 0, Max 99999999.99, Défaut 0 | Prix unitaire HT. |
| `lines[].quantity` | nombre | requis | Min 0.01, Max 100000 |  |
| `lines[].tvaBucket` | texte | non | Valeurs tva16 · tva13 · tva5 · tva1 · tva0, Défaut tva16 | Taux de TVA de la ligne. |
| `lines[].unit` | texte | non | Max 60 caractères, Défaut u | Unité affichée : h, jour, kg... |
| `lines[].productId` | entier | non |  | Produit du catalogue lié à la ligne (`GET /products`). |
| `lines[].type` | texte | non | Valeurs plain · title · text, Défaut plain | `title` : titre de section, `text` : paragraphe de texte mis en forme. Leur prix, quantité et TVA sont ignorés. |
| `date` | date | non | Format AAAA-MM-JJ, Défaut aujourd'hui |  |
| `dueDate` | date | non | Format AAAA-MM-JJ, Défaut aujourd'hui + délai des réglages (30 jours s'il est nul) | Date d'échéance. |
| `discount` | objet | non |  | Remise sur le total HT. |
| `discount.type` | texte | non | Valeurs percentage · amount |  |
| `discount.value` | nombre | non | Min 0 | Pourcentage (100 au plus) ou montant HT (le total au plus). |
| `terms` | texte | non | Défaut conditions des réglages | Conditions affichées en bas du document, texte mis en forme (gras, italique, listes). `""` les retire. |
| `notes` | texte | non |  | Note interne, jamais affichée au client. |

Exemple de requête :

```json
{
    "clientId": 42,
    "lines": [
        {
            "description": "Prestation de conseil",
            "unitPrice": 10000,
            "quantity": 1,
            "tvaBucket": "tva16"
        }
    ]
}
```

Réponse `201` :

```json
{
    "id": 318,
    "number": "FACT-318",
    "clientId": 42,
    "clientName": "Acme Sarl",
    "title": "",
    "status": "due",
    "subtotal": 10000,
    "discount": {
        "type": null,
        "percentage": 0,
        "amount": 0
    },
    "tvaTotal": 1600,
    "total": 11600,
    "createdAt": "2026-09-28T08:16:30-10:00",
    "date": "2026-09-28",
    "dueDate": "2026-10-28",
    "lines": [
        {
            "id": 19,
            "productId": null,
            "description": "Prestation de conseil",
            "unitPrice": 10000,
            "unitPriceExact": 10000,
            "quantity": 1,
            "unit": "u",
            "tvaBucket": "tva16",
            "lineTotal": 10000
        }
    ],
    "tvaBreakdown": [
        {
            "key": "tva16",
            "label": "TVA 16 %",
            "rate": 16,
            "amount": 1600
        }
    ],
    "terms": "<p>Mauruuru pour votre confiance.</p>",
    "hasSections": false,
    "payments": [],
    "paid": 0,
    "balance": 11600,
    "emails": []
}
```

- Erreur `422` : Client introuvable, somme des lignes nulle, ou montant supérieur à 99 999 999 F.

#### `GET /invoices/{id}` : Détail d'une facture

La facture avec ses lignes, le détail de la TVA, les paiements et les emails envoyés.

Droit requis : factures (lecture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant de la facture. |
| `sections` (query) | booléen | non |  | `1` : inclut aussi les lignes de titre et de texte. |

Réponse `200` :

```json
{
    "id": 318,
    "number": "FACT-318",
    "clientId": 42,
    "clientName": "Acme Sarl",
    "title": "",
    "status": "due",
    "subtotal": 10000,
    "discount": {
        "type": null,
        "percentage": 0,
        "amount": 0
    },
    "tvaTotal": 1600,
    "total": 11600,
    "createdAt": "2026-09-28T08:16:30-10:00",
    "date": "2026-09-28",
    "dueDate": "2026-10-28",
    "lines": [
        {
            "id": 19,
            "productId": null,
            "description": "Prestation de conseil",
            "unitPrice": 10000,
            "unitPriceExact": 10000,
            "quantity": 1,
            "unit": "u",
            "tvaBucket": "tva16",
            "lineTotal": 10000
        }
    ],
    "tvaBreakdown": [
        {
            "key": "tva16",
            "label": "TVA 16 %",
            "rate": 16,
            "amount": 1600
        }
    ],
    "terms": "<p>Mauruuru pour votre confiance.</p>",
    "hasSections": false,
    "payments": [],
    "paid": 0,
    "balance": 11600,
    "emails": []
}
```

- Erreur `404` : Facture introuvable.

#### `GET /invoices/{id}/pdf` : Télécharger le PDF

La facture en PDF (`application/pdf`).

Droit requis : factures (lecture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant de la facture. |

Réponse `200` : fichier `application/pdf`.

- Erreur `404` : Facture introuvable.

#### `GET /invoices/{id}/share` : Obtenir le lien public

Un lien à envoyer au client pour voir et télécharger la facture, sans compte.

Droit requis : factures (lecture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant de la facture. |

Réponse `200` :

```json
{
    "shareUrl": "https://VOTRE-COMPTE.fenuacompta.com/…"
}
```

- Erreur `404` : Facture introuvable.

#### `POST /invoices/{id}/send` : Envoyer par email

Envoie la facture en PDF à l'email du client.

Droit requis : factures (écriture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant de la facture. |

Réponse `200` :

```json
{
    "ok": true,
    "status": "due"
}
```

- Erreur `404` : Facture introuvable.
- Erreur `422` : Le client n'a pas d'email (`no_recipient`).

### Paiements & dépenses

#### `POST /payments` : Enregistrer un paiement

Un paiement reçu sur une facture ; son statut est recalculé (payée, partiellement payée).

Droit requis : factures (écriture).

**Corps JSON**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `invoiceId` | entier | requis |  |  |
| `amount` | nombre | requis | Min 1, Max 99999999 | Montant reçu, arrondi au franc. |
| `date` | date | non | Format AAAA-MM-JJ, Défaut aujourd'hui |  |
| `method` | texte | non | Max 60 caractères, Défaut Espèces |  |
| `notes` | texte | non | Max 1000 caractères |  |

Exemple de requête :

```json
{
    "invoiceId": 318,
    "amount": 11600,
    "method": "Virement"
}
```

Réponse `201` :

```json
{
    "ok": true,
    "invoiceId": 318,
    "status": "paid"
}
```

- `status` : le statut de la facture après le paiement (`paid`, `part_paid`...).
- Erreur `404` : Facture introuvable.

#### `GET /expenses` : Lister les dépenses

Les dépenses créées par l'utilisateur de la clé, de la plus récente à la plus ancienne (1 000 au plus).

Droit requis : dépenses (lecture).

Réponse `200` :

```json
{
    "data": [
        {
            "id": 55,
            "amount": 11600,
            "tva": {
                "tva16": 1600
            },
            "importTva": null,
            "title": "Fournitures de bureau",
            "supplierId": null,
            "supplierName": null,
            "categoryId": 3,
            "clientId": null,
            "date": "2026-09-20",
            "paymentStatus": "paid",
            "paymentMethod": "Carte bancaire",
            "paymentDate": "2026-09-20",
            "dueDate": null,
            "notes": null,
            "attachmentId": null,
            "attachmentUrl": null,
            "attachmentType": null,
            "attachmentName": null,
            "createdAt": "2026-09-20T10:02:11-10:00"
        }
    ]
}
```

#### `POST /expenses` : Créer une dépense

Droit requis : dépenses (écriture).

**Corps JSON**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `amount` | nombre | requis | Min -99999999, Max 99999999 | Montant TTC payé. Négatif pour un avoir fournisseur. |
| `date` | date | requis | Format AAAA-MM-JJ |  |
| `title` | texte | non | Max 255 caractères | Libellé de la dépense. |
| `tva` | objet | non |  | TVA récupérable, par taux. |
| `tva.tva16` | entier | non | Défaut 0 | Montant de TVA à 16 %, en francs. |
| `tva.tva13` | entier | non | Défaut 0 | Montant de TVA à 13 %, en francs. |
| `tva.tva5` | entier | non | Défaut 0 | Montant de TVA à 5 %, en francs. |
| `tva.tva1` | entier | non | Défaut 0 | Montant de TVA à 1 %, en francs. |
| `importTva` | entier | non |  | TVA payée à l'importation, en francs. |
| `categoryId` | entier | non | Défaut catégorie par défaut | Catégorie de dépense. |
| `supplierId` | entier | non |  | Fournisseur, si le module Fournisseurs est actif. |
| `clientId` | entier | non |  | Client concerné. |
| `paymentStatus` | texte | non | Valeurs paid · pending, Défaut paid | `pending` : dépense à payer, avec `dueDate`. |
| `paymentMethod` | texte | non | Max 255 caractères |  |
| `paymentDate` | date | non | Format AAAA-MM-JJ, Défaut date de la dépense | Si payée. |
| `dueDate` | date | non | Format AAAA-MM-JJ | Échéance, si à payer. |
| `notes` | texte | non |  |  |

Exemple de requête :

```json
{
    "amount": 11600,
    "date": "2026-09-20",
    "title": "Fournitures de bureau",
    "tva": {
        "tva16": 1600
    },
    "paymentMethod": "Carte bancaire"
}
```

Réponse `201` :

```json
{
    "id": 55,
    "amount": 11600,
    "tva": {
        "tva16": 1600
    },
    "importTva": null,
    "title": "Fournitures de bureau",
    "supplierId": null,
    "supplierName": null,
    "categoryId": 3,
    "clientId": null,
    "date": "2026-09-20",
    "paymentStatus": "paid",
    "paymentMethod": "Carte bancaire",
    "paymentDate": "2026-09-20",
    "dueDate": null,
    "notes": null,
    "attachmentId": null,
    "attachmentUrl": null,
    "attachmentType": null,
    "attachmentName": null,
    "createdAt": "2026-09-20T10:02:11-10:00"
}
```

### Produits

#### `GET /products` : Lister les produits

Le catalogue, par ordre alphabétique (200 au plus) : de quoi remplir des lignes de devis ou de facture.

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `q` (query) | texte | non |  | Recherche dans la désignation. |

Réponse `200` :

```json
{
    "data": [
        {
            "id": 12,
            "name": "Miel de Tahiti 500 g",
            "unitPrice": 1500,
            "unitPriceExact": 1500,
            "tvaBucket": "tva5",
            "unit": "pot",
            "kind": "produit"
        }
    ]
}
```

#### `POST /products` : Créer un produit

Droit requis : produits (écriture).

**Corps JSON**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `name` | texte | requis | Max 255 caractères | Désignation. |
| `unitPrice` | nombre | requis | Min 0, Max 99999999.99 | Prix unitaire HT. |
| `tvaBucket` | texte | non | Valeurs tva16 · tva13 · tva5 · tva1 · tva0, Défaut tva16 | Taux de TVA du produit. |
| `unit` | texte | non | Max 50 caractères, Défaut / |  |
| `kind` | texte | non | Valeurs produit · service, Défaut produit | Sert à ventiler la TVA (livraisons de biens ou prestations de services). |

Exemple de requête :

```json
{
    "name": "Miel de Tahiti 500 g",
    "unitPrice": 1500,
    "tvaBucket": "tva5",
    "unit": "pot"
}
```

Réponse `201` :

```json
{
    "id": 12,
    "name": "Miel de Tahiti 500 g",
    "unitPrice": 1500,
    "unitPriceExact": 1500,
    "tvaBucket": "tva5",
    "unit": "pot",
    "kind": "produit"
}
```

- La référence (SKU), le stock, les descriptions et les images se gèrent dans le logiciel.

#### `PUT /products/{id}` : Modifier un produit

Seuls les champs envoyés changent. Cette route ne modifie pas le stock.

Droit requis : produits (écriture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant du produit. |

**Corps JSON**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `name` | texte | non | Max 255 caractères |  |
| `unitPrice` | nombre | non | Min 0, Max 99999999.99 | Prix unitaire HT. |
| `tvaBucket` | texte | non | Valeurs tva16 · tva13 · tva5 · tva1 · tva0 | Taux de TVA du produit. |
| `unit` | texte | non | Max 50 caractères |  |
| `kind` | texte | non | Valeurs produit · service |  |

Exemple de requête :

```json
{
    "unitPrice": 1600
}
```

Réponse `200` :

```json
{
    "id": 12,
    "name": "Miel de Tahiti 500 g",
    "unitPrice": 1500,
    "unitPriceExact": 1500,
    "tvaBucket": "tva5",
    "unit": "pot",
    "kind": "produit"
}
```

- Erreur `404` : Produit introuvable.
- Erreur `422` : Aucun champ à modifier.

#### `DELETE /products/{id}` : Supprimer un produit

Supprime le produit et ses images. Les factures déjà émises gardent leurs lignes.

Droit requis : produits (écriture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `id` (chemin) | entier | requis |  | Identifiant du produit. |

Réponse `200` :

```json
{
    "ok": true
}
```

- Erreur `404` : Produit introuvable.

### Boutique en ligne

#### `GET /shop/products` : Lire le catalogue et le stock

Les produits du catalogue avec leur prix, leur stock, leur catégorie, leurs descriptions et leurs images. Une synchronisation régulière ne demande que les produits modifiés depuis la précédente.

Droit requis : produits (lecture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `updatedSince` (query) | date et heure | non | Format ISO 8601 | Seulement les produits modifiés depuis cette date (stock et images compris). |
| `sku` (query) | texte | non | Max 100 caractères | Un seul produit, par sa référence. |
| `hasSku` (query) | entier | non | Valeurs 0 · 1 | `1` : seulement les produits qui ont une référence, c'est-à-dire ceux vendus en ligne. |
| `page` (query) | entier | non | Min 1, Défaut 1 | Numéro de page. |
| `perPage` (query) | entier | non | Min 1, Max 200, Défaut 100 | Éléments par page. |

Réponse `200` :

```json
{
    "data": [
        {
            "id": 12,
            "sku": "MIEL-500",
            "name": "Miel de Tahiti 500 g",
            "unit": "pot",
            "category": {
                "id": 14,
                "name": "Épicerie fine"
            },
            "shortDescription": "Miel de fleurs récolté à Taravao.",
            "description": "<p>Miel cru, non chauffé, mis en pot à la main.</p>",
            "images": [
                {
                    "id": 301,
                    "url": "https://VOTRE-COMPTE.fenuacompta.com/…/miel-500.jpg",
                    "thumbnailUrl": "https://VOTRE-COMPTE.fenuacompta.com/…/miel-500-thumb.jpg",
                    "name": "miel-500.jpg",
                    "position": 0
                }
            ],
            "priceHt": 1500,
            "tvaRate": 5,
            "priceTtc": 1575,
            "stockManaged": true,
            "stock": 42,
            "lowStockAlert": 5,
            "updatedAt": "2026-09-23T09:14:02-10:00"
        }
    ],
    "page": 1,
    "perPage": 100,
    "total": 1
}
```

- `stock` et `lowStockAlert` valent `null` si le stock n'est pas suivi. Le stock est en unités entières. `priceTtc` est arrondi à la précision du compte (franc, ou centime pour un compte à 2 décimales).
- Correspondance WooCommerce : `priceTtc` → `regular_price`, `shortDescription` → `short_description`, `category.name` → `categories`, `images[].url` → `images[].src`, `stock` → `stock_quantity`. Les URL d'images sont publiques.
- Comparez régulièrement avec le catalogue complet pour détecter les produits retirés.
- Erreur `403` : Module boutique non activé sur ce compte (`module_disabled`).

#### `POST /shop/orders` : Facturer une commande de la boutique

Retrouve ou crée le client (par email), crée la facture au prix payé et, si la commande est payée, le paiement. Le stock est décrémenté.

Droit requis : factures (écriture).

**Corps JSON**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `reference` | texte | requis | Max 100 caractères, Caractères A-Z a-z 0-9 . _ - | Numéro de la commande dans la boutique. Renvoyer une commande déjà reçue ne crée rien de plus. |
| `source` | texte | non | Max 50 caractères, Défaut woocommerce | Boutique d'origine. Avec `reference`, identifie la commande. |
| `date` | date | non | Format AAAA-MM-JJ, Défaut aujourd'hui | Date de la facture. Un horodatage UTC est ramené au jour de Tahiti. Échéance : le même jour si la commande est payée, sinon cette date + le délai de paiement des réglages (30 jours s'il est nul). |
| `customer` | objet | requis |  | L'acheteur. Un client existant avec le même email est réutilisé, sinon il est créé. |
| `customer.email` | email | requis | Max 255 caractères |  |
| `customer.company` | texte | non | Max 255 caractères | Nom du client. À défaut : prénom et nom, puis l'email. |
| `customer.firstName` | texte | non | Max 100 caractères | Prénom du contact. |
| `customer.lastName` | texte | non | Max 100 caractères | Nom du contact. |
| `customer.phone` | texte | non | Max 40 caractères |  |
| `customer.address` | objet | non |  | Adresse de facturation. |
| `customer.address.street` | texte | non | Max 255 caractères |  |
| `customer.address.city` | texte | non | Max 100 caractères |  |
| `customer.address.zip` | texte | non | Max 20 caractères |  |
| `customer.address.country` | texte | non | Max 100 caractères |  |
| `lines` | liste d'objets | requis | Min 1 élément | Les articles commandés, dans l'ordre de la facture. |
| `lines[].sku` | texte | requis | Max 100 caractères | Référence du produit dans FenuaCompta, majuscules indifférentes. Une référence inconnue refuse toute la commande. |
| `lines[].quantity` | nombre | requis | Min 0.01, Max 100000 | Nombre entier si le stock du produit est suivi. |
| `lines[].unitPriceTtc` | nombre | requis | Min 0, Max 99999999 | Prix unitaire TTC réellement payé, coupons déduits. Le HT est recalculé avec le taux de TVA du produit. |
| `lines[].name` | texte | non | Max 500 caractères, Défaut la désignation du produit | Libellé de la ligne sur la facture. |
| `shipping` | objet | non |  | Frais de livraison, ajoutés en dernière ligne de la facture. |
| `shipping.amountTtc` | nombre | requis avec shipping | Min 0, Max 99999999 | Montant TTC payé. |
| `shipping.tvaRate` | entier | requis si montant > 0 | Valeurs 0 · 1 · 5 · 13 · 16 | Taux de TVA des frais, en %. |
| `shipping.label` | texte | non | Max 255 caractères, Défaut Frais de livraison |  |
| `payment` | objet | requis |  |  |
| `payment.status` | texte | requis | Valeurs paid · pending | `paid` : la facture est soldée. `pending` : elle reste due, à payer plus tard avec `POST /shop/orders/{reference}/payment`. |
| `payment.method` | texte | non | Max 60 caractères, Défaut Boutique en ligne | Moyen de paiement affiché sur le paiement. |
| `payment.transactionId` | texte | non | Max 100 caractères | Identifiant du paiement chez le prestataire, affiché sur le paiement. |
| `payment.date` | date | non | Format AAAA-MM-JJ, Défaut aujourd'hui |  |
| `totalTtc` | nombre | non | Min 0 | Total payé sur la boutique. Il est comparé au total de la facture dans `totals`. |
| `notes` | texte | non | Max 2000 caractères | Note ajoutée à la facture. |

Exemple de requête :

```json
{
    "reference": "1042",
    "customer": {
        "email": "client@example.com",
        "firstName": "Teva",
        "lastName": "Exemple",
        "phone": "+689 00 00 00 00",
        "address": {
            "street": "Rue du Commerce",
            "city": "Papeete",
            "zip": "98714"
        }
    },
    "lines": [
        {
            "sku": "MIEL-500",
            "quantity": 2,
            "unitPriceTtc": 1575
        }
    ],
    "shipping": {
        "amountTtc": 1000,
        "tvaRate": 13,
        "label": "Livraison Tahiti"
    },
    "payment": {
        "status": "paid",
        "method": "Carte bancaire"
    },
    "totalTtc": 4150
}
```

Réponse `201` :

```json
{
    "source": "woocommerce",
    "reference": "1042",
    "status": "invoiced",
    "clientId": 57,
    "invoice": {
        "id": 318,
        "number": "FACT-318",
        "clientId": 57,
        "clientName": "Teva Exemple",
        "title": "Commande en ligne n° 1042",
        "status": "paid",
        "subtotal": 3634,
        "discount": {
            "type": null,
            "percentage": 0,
            "amount": 0
        },
        "tvaTotal": 516,
        "total": 4150,
        "createdAt": "2026-09-23T09:20:11-10:00",
        "date": "2026-09-23",
        "dueDate": "2026-09-23",
        "lines": []
    },
    "creditNote": null,
    "totals": {
        "invoice": 4150,
        "site": 4150,
        "difference": 0
    },
    "stock": [
        {
            "sku": "MIEL-500",
            "stock": 40
        }
    ],
    "paymentRecorded": true
}
```

Réponse `200` : Commande déjà reçue : rien n'est recréé.

```json
{
    "source": "woocommerce",
    "reference": "1042",
    "status": "invoiced",
    "clientId": 57,
    "invoice": {
        "id": 318,
        "number": "FACT-318",
        "clientId": 57,
        "clientName": "Teva Exemple",
        "title": "Commande en ligne n° 1042",
        "status": "paid",
        "subtotal": 3634,
        "discount": {
            "type": null,
            "percentage": 0,
            "amount": 0
        },
        "tvaTotal": 516,
        "total": 4150,
        "createdAt": "2026-09-23T09:20:11-10:00",
        "date": "2026-09-23",
        "dueDate": "2026-09-23",
        "lines": []
    },
    "creditNote": null,
    "totals": {
        "invoice": 4150,
        "site": 4150,
        "difference": 0
    },
    "stock": [
        {
            "sku": "MIEL-500",
            "stock": 40
        }
    ],
    "duplicate": true
}
```

- **Stock** : chaque réponse (réception, paiement, annulation) renvoie `stock`, le stock à jour des produits de la commande dont le stock est suivi. Reportez-le dans la boutique, qui décrémente aussi son propre stock.
- Une commande déjà reçue renvoie `200` et `"duplicate": true` : un envoi peut être rejoué sans risque.
- `paymentRecorded` indique si le paiement a été enregistré. S'il vaut `false`, enregistrez-le avec `POST /shop/orders/{reference}/payment`.
- Erreur `403` : Module boutique non activé sur ce compte (`module_disabled`).
- Erreur `422` : Référence de produit inconnue, quantité non entière pour un produit dont le stock est suivi, total nul ou taux de TVA des frais manquant : rien n'est créé.
- Erreur `409` : Opération impossible dans l'état actuel de la commande : réessayez plus tard ou consultez `GET /shop/orders/{reference}`.

#### `GET /shop/orders/{reference}` : État d'une commande facturée

La commande, sa facture et, si elle a été annulée, son avoir.

Droit requis : factures (lecture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `reference` (chemin) | texte | requis | Max 100 caractères, Caractères A-Z a-z 0-9 . _ - | Numéro de la commande dans la boutique. |
| `source` (query) | texte | non | Max 50 caractères, Défaut woocommerce | Boutique d'origine, si la commande a été envoyée avec une autre `source`. |

Réponse `200` :

```json
{
    "source": "woocommerce",
    "reference": "1042",
    "status": "invoiced",
    "clientId": 57,
    "invoice": {
        "id": 318,
        "number": "FACT-318",
        "clientId": 57,
        "clientName": "Teva Exemple",
        "title": "Commande en ligne n° 1042",
        "status": "paid",
        "subtotal": 3634,
        "discount": {
            "type": null,
            "percentage": 0,
            "amount": 0
        },
        "tvaTotal": 516,
        "total": 4150,
        "createdAt": "2026-09-23T09:20:11-10:00",
        "date": "2026-09-23",
        "dueDate": "2026-09-23",
        "lines": []
    },
    "creditNote": null,
    "totals": {
        "invoice": 4150,
        "site": 4150,
        "difference": 0
    }
}
```

- Erreur `404` : Aucune commande avec cette référence (et cette source).
- Erreur `403` : Module boutique non activé sur ce compte (`module_disabled`).

#### `POST /shop/orders/{reference}/cancel` : Annuler une commande

Annulation totale : crée un avoir et remet le stock. Le remboursement se fait dans votre moyen de paiement. Une commande déjà annulée renvoie la même réponse, sans second avoir.

Droit requis : factures (écriture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `reference` (chemin) | texte | requis | Max 100 caractères, Caractères A-Z a-z 0-9 . _ - | Numéro de la commande dans la boutique. |
| `source` (query) | texte | non | Max 50 caractères, Défaut woocommerce | Boutique d'origine, si la commande a été envoyée avec une autre `source`. |

Réponse `200` :

```json
{
    "source": "woocommerce",
    "reference": "1042",
    "status": "cancelled",
    "clientId": 57,
    "invoice": {
        "id": 318,
        "number": "FACT-318",
        "clientId": 57,
        "clientName": "Teva Exemple",
        "title": "Commande en ligne n° 1042",
        "status": "paid",
        "subtotal": 3634,
        "discount": {
            "type": null,
            "percentage": 0,
            "amount": 0
        },
        "tvaTotal": 516,
        "total": 4150,
        "createdAt": "2026-09-23T09:20:11-10:00",
        "date": "2026-09-23",
        "dueDate": "2026-09-23",
        "lines": []
    },
    "creditNote": {
        "id": 325,
        "number": "FACT-325",
        "clientId": 57,
        "clientName": "Teva Exemple",
        "title": "Commande en ligne n° 1042",
        "status": "credit",
        "subtotal": -3634,
        "discount": {
            "type": null,
            "percentage": 0,
            "amount": 0
        },
        "tvaTotal": -516,
        "total": -4150,
        "createdAt": "2026-09-24T08:02:40-10:00",
        "date": "2026-09-24",
        "dueDate": "2026-09-24",
        "lines": []
    },
    "totals": {
        "invoice": 4150,
        "site": 4150,
        "difference": 0
    },
    "stock": [
        {
            "sku": "MIEL-500",
            "stock": 42
        }
    ]
}
```

- Erreur `404` : Aucune commande avec cette référence (et cette source).
- Erreur `403` : Module boutique non activé sur ce compte (`module_disabled`).
- Erreur `409` : Opération impossible dans l'état actuel de la commande : réessayez plus tard ou consultez `GET /shop/orders/{reference}`.

#### `POST /shop/orders/{reference}/payment` : Enregistrer le paiement d'une commande

Pour une commande envoyée en `pending` puis payée sur la boutique : enregistre le paiement du reste dû. Une facture déjà soldée n'est pas payée deux fois.

Droit requis : factures (écriture).

**Paramètres**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `reference` (chemin) | texte | requis | Max 100 caractères, Caractères A-Z a-z 0-9 . _ - | Numéro de la commande dans la boutique. |
| `source` (query) | texte | non | Max 50 caractères, Défaut woocommerce | Boutique d'origine, si la commande a été envoyée avec une autre `source`. |

**Corps JSON**

| Champ | Type | Requis | Contraintes | Description |
| --- | --- | --- | --- | --- |
| `method` | texte | non | Max 60 caractères, Défaut Boutique en ligne |  |
| `transactionId` | texte | non | Max 100 caractères |  |
| `date` | date | non | Format AAAA-MM-JJ, Défaut aujourd'hui |  |

Exemple de requête :

```json
{
    "method": "Carte bancaire",
    "transactionId": "pi_3Q..."
}
```

Réponse `200` :

```json
{
    "source": "woocommerce",
    "reference": "1042",
    "status": "invoiced",
    "clientId": 57,
    "invoice": {
        "id": 318,
        "number": "FACT-318",
        "clientId": 57,
        "clientName": "Teva Exemple",
        "title": "Commande en ligne n° 1042",
        "status": "paid",
        "subtotal": 3634,
        "discount": {
            "type": null,
            "percentage": 0,
            "amount": 0
        },
        "tvaTotal": 516,
        "total": 4150,
        "createdAt": "2026-09-23T09:20:11-10:00",
        "date": "2026-09-23",
        "dueDate": "2026-09-23",
        "lines": []
    },
    "creditNote": null,
    "totals": {
        "invoice": 4150,
        "site": 4150,
        "difference": 0
    },
    "stock": [
        {
            "sku": "MIEL-500",
            "stock": 40
        }
    ],
    "paymentRecorded": true
}
```

- Erreur `404` : Aucune commande avec cette référence (et cette source).
- Erreur `403` : Module boutique non activé sur ce compte (`module_disabled`).
- Erreur `409` : Opération impossible dans l'état actuel de la commande : réessayez plus tard ou consultez `GET /shop/orders/{reference}`.
