Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ GOFACT_PAYEE_IBAN="FR7630001007941234567890185"
# Chromium) sur Linux, macOS et Windows.
#GOFACT_CHROME="/usr/bin/google-chrome"

# --- Export comptable ----------------------------------------------------------
# Dossier (partagé avec le comptable, par ex. synchronisé) où gofact sync écrit
# chaque mois les factures émises et reçues avec un récapitulatif CSV.
#GOFACT_EXPORT_DIR="/home/moi/Nextcloud/Comptabilité"

# --- PDP SuperPDP (uniquement pour -send) -------------------------------------
# SECRETS : ne jamais committer ce fichier renseigné.
SUPERPDP_CLIENT_ID=""
Expand Down
11 changes: 10 additions & 1 deletion docs/guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ gofact send -pdf f.pdf -poll # dépose un PDF existant

Identifiants lus dans l'environnement ou le `.env` (voir [Envoi PDP](../pdp.md)).

```sh
gofact paid -number 2026001 [-date AAAA-MM-JJ] [-amount 300.00] # signale un encaissement
gofact sync # récupère les factures reçues, met à jour l'export
gofact export -month 2026-09 -to … # export comptable d'un mois
gofact schedule [-every 1h] [-yes] # lance gofact sync à intervalle régulier
```

Voir [Export comptable](export.md).

## Serveur et installation

```sh
Expand All @@ -106,4 +115,4 @@ gofact install [-yes] # configure les clients MCP du poste
Le fichier [`.env.example`](https://github.com/kOlapsis/gofact/blob/main/.env.example)
du dépôt les documente toutes : identité `GOFACT_SELLER_*`, `GOFACT_PAYEE_IBAN`,
mentions d'exonération `GOFACT_VAT_*`, `GOFACT_CHROME`, `GOFACT_OFFLINE`,
`GOFACT_PDP`, `SUPERPDP_*`.
`GOFACT_PDP`, `GOFACT_EXPORT_DIR`, `SUPERPDP_*`.
67 changes: 67 additions & 0 deletions docs/guide/export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Export comptable

L'API de la plateforme ne permet pas de donner à votre comptable un accès en lecture.
gofact lui prépare donc chaque mois un dossier qu'il n'a qu'à ouvrir.

## Ce que contient l'export

```
<destination>/<organisation>/2026-09/
├── emises/ les PDF Factur-X émis dans le mois
├── recues/ les PDF des factures fournisseurs reçues dans le mois
└── recapitulatif.csv une ligne par facture
```

Le récapitulatif donne, pour chaque facture : le sens (émise ou reçue), le numéro,
la date, le client ou le fournisseur, les montants HT, TVA et TTC, la devise, la date
d'encaissement quand elle a été signalée, et le fichier. Il utilise le point-virgule
comme séparateur et la virgule décimale : il s'ouvre directement dans Excel ou
LibreOffice en français.

Une facture est rangée dans le mois de sa date d'émission. L'export peut être refait
autant de fois que nécessaire : un PDF identique n'est pas recopié, et le
récapitulatif est régénéré à chaque fois.

## Choisir la destination

Renseignez `GOFACT_EXPORT_DIR` dans le `.env` de l'organisation (ou dans
`~/.config/gofact/.env` pour toutes vos organisations). Le plus simple est de viser
un dossier synchronisé et partagé avec le comptable (Nextcloud, Dropbox, OneDrive…).

```sh
GOFACT_EXPORT_DIR="/home/moi/Nextcloud/Comptabilité"
```

## Exporter à la demande

```sh
gofact export -month 2026-09 # vers GOFACT_EXPORT_DIR
gofact export -month 2026-09 -to ~/export # ou vers un autre dossier
```

En conversation : « exporte les factures de septembre pour le comptable » (outil
`export_invoices`).

## Planifier

`gofact sync` récupère les factures reçues, affiche une notification pour chaque
nouvelle facture, puis met à jour l'export du mois en cours et du mois précédent si
`GOFACT_EXPORT_DIR` est renseigné. Pour qu'il tourne tout seul :

```sh
gofact schedule # montre ce qui serait installé (rien n'est modifié)
gofact schedule -yes # installe, toutes les heures
gofact schedule -every 30m -yes
gofact schedule -remove -yes # retire la planification
```

| Système | Mécanisme |
|---|---|
| Linux | timer systemd utilisateur (`~/.config/systemd/user/gofact-sync.timer`) |
| macOS | LaunchAgent (`~/Library/LaunchAgents/com.kolapsis.gofact-sync.plist`) |
| Windows | tâche planifiée `gofact-sync` |

!!! note "Identifiants de la tâche planifiée"
La tâche planifiée ne voit pas les réglages de l'extension Claude Desktop. Les
identifiants SuperPDP doivent se trouver dans le `.env` de l'organisation ou dans
`~/.config/gofact/.env`.
32 changes: 32 additions & 0 deletions docs/pdp.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,38 @@ Le cycle de vie s'affiche au dépôt et se consulte ensuite (`get_invoice_status
Chaque dépôt est tracé dans le `journal.ndjson` du dossier : quoi, quand, sous quelle
référence.

## Signaler un encaissement

Quand le client a payé, signalez-le à la plateforme : le statut `fr:212` « Encaissée »
alimente la déclaration des paiements à l'administration (e-reporting). Une facture
payée dès l'émission se signale juste après son dépôt.

En conversation, l'IA appelle `report_payment` après votre confirmation. Au terminal :

```sh
gofact paid -number 2026001 # total, encaissé aujourd'hui
gofact paid -number 2026001 -date 2026-09-12 # total, encaissé à cette date
gofact paid -number 2026001 -date 2026-09-12 -amount 300.00 # paiement partiel
```

Un encaissement daté ou partiel doit être ventilé par taux de TVA. gofact fait ce
calcul à partir des données de la facture, au prorata du TTC de chaque taux. La date
d'encaissement apparaît ensuite dans `list_invoices` et dans l'export comptable.

## Factures reçues

Les factures de vos fournisseurs arrivent sur la plateforme. `gofact sync` les
récupère : chaque nouvelle facture est rangée en PDF dans le dossier `recues/AAAA-MM/`
de l'organisation, et une notification s'affiche sur le bureau. Une facture reçue en
XML seul est convertie en PDF lisible par la plateforme.

Le fichier `pdp-sync.json` retient la dernière facture récupérée : chaque passage ne
rapatrie que les nouvelles, et un passage interrompu reprend où il s'était arrêté.

Pour être prévenu sans y penser, planifiez la commande (voir
[Export comptable](guide/export.md#planifier)). En conversation, l'IA voit les factures
reçues avec `list_received_invoices`.

## Rejets courants

| Message | Cause | Correction |
Expand Down
12 changes: 10 additions & 2 deletions internal/mcpsrv/invoices.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ func addInvoiceTools(s *mcp.Server) {
Invoices []map[string]any `json:"invoices"`
}
mcp.AddTool(s, &mcp.Tool{
Name: "list_invoices",
Description: "Factures inscrites au registre de l'organisation, de la plus récente à la plus ancienne.",
Name: "list_invoices",
Description: "Factures inscrites au registre de l'organisation, de la plus récente à la plus ancienne, " +
"avec la date d'encaissement (encaissee_le) quand il a été signalé.",
Annotations: readOnly("Lister les factures"),
}, func(ctx context.Context, req *mcp.CallToolRequest, in listIn) (*mcp.CallToolResult, listOut, error) {
o, err := resolveOrg(in.Org)
Expand All @@ -62,6 +63,10 @@ func addInvoiceTools(s *mcp.Server) {
if err != nil {
return nil, listOut{}, err
}
payments, err := o.Payments()
if err != nil {
return nil, listOut{}, err
}
limit := in.Limit
if limit <= 0 {
limit = 20
Expand All @@ -74,6 +79,9 @@ func addInvoiceTools(s *mcp.Server) {
if in.Client != "" && !strings.Contains(strings.ToLower(str(inv["client"])), strings.ToLower(in.Client)) {
continue
}
if p, ok := payments[str(inv["numero"])]; ok {
inv["encaissee_le"] = p.Date
}
out.Invoices = append(out.Invoices, inv)
if len(out.Invoices) >= limit {
break
Expand Down
128 changes: 103 additions & 25 deletions internal/mcpsrv/pdp.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package mcpsrv

import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -128,6 +127,105 @@ func addPDPTools(s *mcp.Server) {
out.Rejected, out.Reasons = pdp.Rejection(events)
return nil, out, nil
})

type paidIn struct {
orgParam
Number string `json:"number" jsonschema:"numéro de la facture émise qui a été encaissée"`
Date string `json:"date,omitempty" jsonschema:"date d'encaissement AAAA-MM-JJ ; défaut : aujourd'hui"`
Amount string `json:"amount,omitempty" jsonschema:"montant encaissé en décimal (ex. 1200.00), uniquement pour un paiement partiel ; omis = total de la facture"`
Confirm bool `json:"confirm" jsonschema:"doit valoir true, après confirmation EXPLICITE de l'utilisateur — l'encaissement est déclaré à l'administration"`
}
mcp.AddTool(s, &mcp.Tool{
Name: "report_payment",
Description: "Signale à la PDP l'encaissement d'une facture émise et déjà déposée (statut fr:212 « Encaissée »). " +
"Ce statut alimente l'e-reporting de paiement transmis à l'administration : ne l'appeler qu'une fois le " +
"règlement réellement reçu, après accord explicite de l'utilisateur, avec confirm=true. Pour une facture " +
"payée à l'émission, l'appeler juste après send_invoice.",
Annotations: &mcp.ToolAnnotations{Title: "Signaler un encaissement", ReadOnlyHint: false,
DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(true)},
}, func(ctx context.Context, req *mcp.CallToolRequest, in paidIn) (*mcp.CallToolResult, workspace.PaymentReport, error) {
if !in.Confirm {
return nil, workspace.PaymentReport{}, fmt.Errorf("signalement refusé : confirm doit valoir true. Demander à "+
"l'utilisateur une confirmation explicite (« signaler l'encaissement de la facture %s ? ») "+
"avant de rappeler cet outil", in.Number)
}
o, err := resolveOrg(in.Org)
if err != nil {
return nil, workspace.PaymentReport{}, err
}
provider, err := pdp.Open(o.Lookup)
if err != nil {
return nil, workspace.PaymentReport{}, err
}
out, err := o.ReportPayment(ctx, provider, in.Number, in.Date, in.Amount)
if errors.Is(err, workspace.ErrNotSent) {
err = fmt.Errorf("%w : la déposer d'abord avec send_invoice", err)
}
return nil, out, err
})

type receivedIn struct {
orgParam
Month string `json:"month,omitempty" jsonschema:"mois d'émission AAAA-MM ; omis = toutes"`
Refresh bool `json:"refresh,omitempty" jsonschema:"interroge d'abord la PDP pour récupérer les factures arrivées depuis le dernier passage"`
}
type receivedOut struct {
New int `json:"new"`
Invoices []workspace.Received `json:"invoices"`
}
mcp.AddTool(s, &mcp.Tool{
Name: "list_received_invoices",
Description: "Factures fournisseurs reçues via la PDP, de la plus récente à la plus ancienne, avec le chemin " +
"de leur PDF dans le dossier de l'organisation. refresh=true récupère d'abord les nouvelles arrivées " +
"(ce que fait aussi la commande planifiée « gofact sync »).",
Annotations: &mcp.ToolAnnotations{Title: "Factures reçues", ReadOnlyHint: false,
DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(true)},
}, func(ctx context.Context, req *mcp.CallToolRequest, in receivedIn) (*mcp.CallToolResult, receivedOut, error) {
o, err := resolveOrg(in.Org)
if err != nil {
return nil, receivedOut{}, err
}
var out receivedOut
if in.Refresh {
provider, err := pdp.Open(o.Lookup)
if err != nil {
return nil, receivedOut{}, err
}
fresh, err := o.SyncReceived(ctx, provider)
out.New = len(fresh)
if err != nil {
return nil, out, err
}
}
if out.Invoices, err = o.ReceivedInvoices(in.Month); err != nil {
return nil, out, err
}
return nil, out, nil
})

type exportIn struct {
orgParam
Month string `json:"month" jsonschema:"mois à exporter, AAAA-MM"`
To string `json:"to,omitempty" jsonschema:"dossier de destination ; défaut : GOFACT_EXPORT_DIR de l'organisation"`
}
mcp.AddTool(s, &mcp.Tool{
Name: "export_invoices",
Description: "Exporte pour le comptable les factures émises et reçues d'un mois : PDF dans emises/ et recues/, " +
"plus recapitulatif.csv (séparateur ;). Les factures reçues sont celles déjà récupérées : appeler " +
"list_received_invoices avec refresh=true avant pour inclure les dernières arrivées.",
Annotations: writes("Export comptable"),
}, func(ctx context.Context, req *mcp.CallToolRequest, in exportIn) (*mcp.CallToolResult, workspace.ExportResult, error) {
o, err := resolveOrg(in.Org)
if err != nil {
return nil, workspace.ExportResult{}, err
}
dest := in.To
if dest == "" {
dest = o.Lookup(workspace.EnvExportDir)
}
res, err := o.Export(in.Month, dest)
return nil, res, err
})
}

// firstStatus lit une première fois le cycle de vie d'un dépôt, après le délai
Expand Down Expand Up @@ -237,31 +335,11 @@ func invoiceSpec(o *workspace.Org, number string) (facturx.Spec, string, error)
return spec, path, nil
}

// sentReference retrouve, dans le journal, la référence et le fournisseur du
// dernier dépôt d'une facture.
// sentReference retrouve la référence et le fournisseur du dernier dépôt d'une facture.
func sentReference(o *workspace.Org, number string) (ref, provider string, err error) {
f, err := os.Open(filepath.Join(o.Path, workspace.JournalFile))
ref, provider, err = o.SentReference(number)
if err != nil {
return "", "", fmt.Errorf("aucun dépôt PDP tracé pour cette organisation")
}
defer func() { _ = f.Close() }()

sc := bufio.NewScanner(f)
for sc.Scan() {
var line struct {
Event string `json:"event"`
Data struct {
Numero string `json:"numero"`
Provider string `json:"provider"`
Reference string `json:"reference"`
} `json:"data"`
}
if json.Unmarshal(sc.Bytes(), &line) != nil {
continue
}
if line.Event == "pdp_sent" && line.Data.Numero == number {
ref, provider = line.Data.Reference, line.Data.Provider // le plus récent gagne
}
return "", "", err
}
if ref == "" {
return "", "", fmt.Errorf("la facture %s n'a pas été déposée sur la PDP (aucune trace au journal). "+
Expand Down
13 changes: 6 additions & 7 deletions internal/mcpsrv/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,24 +115,23 @@ func TestToolAnnotations(t *testing.T) {
for _, name := range []string{"list_organizations", "get_organization", "get_invoice_template",
"search_client", "find_routing_address", "preview_next_number", "preview_invoice",
"list_invoices", "create_invoice", "send_invoice", "get_invoice_status",
"init_organization", "initialize_numbering"} {
"init_organization", "initialize_numbering", "report_payment", "list_received_invoices",
"export_invoices"} {
if byName[name] == nil {
t.Errorf("outil %s absent", name)
}
}
// Seul send_invoice est destructif ; les lectures sont annoncées comme telles.
// Seuls les écrits vers la PDP sont destructifs ; les lectures sont annoncées comme telles.
destructive := map[string]bool{"send_invoice": true, "report_payment": true}
for name, tool := range byName {
ann := tool.Annotations
if ann == nil {
t.Errorf("%s sans annotations", name)
continue
}
isDestructive := ann.DestructiveHint != nil && *ann.DestructiveHint
if name == "send_invoice" && !isDestructive {
t.Error("send_invoice doit être destructif")
}
if name != "send_invoice" && isDestructive {
t.Errorf("%s ne doit pas être destructif", name)
if destructive[name] != isDestructive {
t.Errorf("%s : destructif = %v, attendu %v", name, isDestructive, destructive[name])
}
}
}
Expand Down
Loading
Loading