diff --git a/.env.example b/.env.example index 095241a..fed60da 100644 --- a/.env.example +++ b/.env.example @@ -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="" diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 84ddac4..6074f6f 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -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 @@ -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_*`. diff --git a/docs/guide/export.md b/docs/guide/export.md new file mode 100644 index 0000000..ff62b8f --- /dev/null +++ b/docs/guide/export.md @@ -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 + +``` +//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`. diff --git a/docs/pdp.md b/docs/pdp.md index f468ef7..b0bb593 100644 --- a/docs/pdp.md +++ b/docs/pdp.md @@ -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 | diff --git a/internal/mcpsrv/invoices.go b/internal/mcpsrv/invoices.go index b6703b1..f485f74 100644 --- a/internal/mcpsrv/invoices.go +++ b/internal/mcpsrv/invoices.go @@ -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) @@ -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 @@ -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 diff --git a/internal/mcpsrv/pdp.go b/internal/mcpsrv/pdp.go index 1795dc8..01fa09d 100644 --- a/internal/mcpsrv/pdp.go +++ b/internal/mcpsrv/pdp.go @@ -1,9 +1,8 @@ package mcpsrv import ( - "bufio" "context" - "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -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 @@ -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). "+ diff --git a/internal/mcpsrv/server_test.go b/internal/mcpsrv/server_test.go index 65cfdb7..fde0dd0 100644 --- a/internal/mcpsrv/server_test.go +++ b/internal/mcpsrv/server_test.go @@ -115,12 +115,14 @@ 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 { @@ -128,11 +130,8 @@ func TestToolAnnotations(t *testing.T) { 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]) } } } diff --git a/internal/mcpsrv/sync_test.go b/internal/mcpsrv/sync_test.go new file mode 100644 index 0000000..a2a16c9 --- /dev/null +++ b/internal/mcpsrv/sync_test.go @@ -0,0 +1,191 @@ +package mcpsrv + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/kolapsis/gofact/internal/facturx" + "github.com/kolapsis/gofact/internal/workspace" +) + +type syncMock struct { + mu sync.Mutex + events []map[string]any + afters []string +} + +// superPDPSyncMock imite SuperPDP pour un dépôt accepté, les événements de cycle de vie et deux pages de factures reçues. +func superPDPSyncMock(t *testing.T) (*httptest.Server, *syncMock) { + t.Helper() + m := &syncMock{} + mux := http.NewServeMux() + mux.HandleFunc("POST /oauth2/token", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"access_token":"jeton-test"}`)) + }) + mux.HandleFunc("POST /v1.beta/invoices", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":453372,"events":[{"status_code":"api:uploaded","status_text":"Déposée"}]}`)) + }) + mux.HandleFunc("GET /v1.beta/invoices/453372", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"id":453372,"events":[{"status_code":"fr:201","status_text":"Émise"}]}`)) + }) + mux.HandleFunc("POST /v1.beta/invoice_events", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + m.mu.Lock() + m.events = append(m.events, body) + m.mu.Unlock() + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":1}`)) + }) + mux.HandleFunc("GET /v1.beta/invoices", func(w http.ResponseWriter, r *http.Request) { + after := r.URL.Query().Get("starting_after_id") + m.mu.Lock() + m.afters = append(m.afters, after) + m.mu.Unlock() + if r.URL.Query().Get("direction") != "in" { + w.WriteHeader(http.StatusBadRequest) + return + } + switch after { + case "0": + _, _ = w.Write([]byte(`{"count":1,"has_after":true,"has_before":false,"data":[ + {"id":9001,"direction":"in","created_at":"2026-09-10T08:00:00Z","en_invoice":{ + "number":"F-77","issue_date":"2026-09-09","currency_code":"EUR", + "seller":{"name":"Fournisseur A","legal_registration_identifier":{"scheme":"0002","value":"111111111"}}, + "totals":{"total_without_vat":"100.00","total_vat_amount":{"value":"20.00","currency_code":"EUR"},"total_with_vat":"120.00"}}}]}`)) + case "9001": + _, _ = w.Write([]byte(`{"count":1,"has_after":false,"has_before":true,"data":[ + {"id":9002,"direction":"in","created_at":"2026-09-11T08:00:00Z","en_invoice":{ + "number":"B/12","issue_date":"2026-08-30", + "seller":{"name":"Fournisseur: B"}, + "totals":{"total_without_vat":"50.00","total_vat_amount":"10.00","total_with_vat":"60.00"}}}]}`)) + default: + _, _ = w.Write([]byte(`{"count":0,"has_after":false,"has_before":true,"data":[]}`)) + } + }) + mux.HandleFunc("GET /v1.beta/invoices/{id}", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("format") != "factur-x" { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/pdf") + _, _ = w.Write([]byte("%PDF-1.7 reçue " + r.PathValue("id"))) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv, m +} + +func TestReceivedPaymentAndExport(t *testing.T) { + srv, mock := superPDPSyncMock(t) + org := testOrgWith(t, map[string]string{ + "SUPERPDP_BASE": srv.URL, "SUPERPDP_CLIENT_ID": "id-test", "SUPERPDP_CLIENT_SECRET": "secret-test", + }) + cs := session(t) + prev := statusDelay + statusDelay = 0 + t.Cleanup(func() { statusDelay = prev }) + + number := registerInvoice(t, org, buyerSpec(facturx.PartySpec{ + Name: "ACME SAS", SIRET: "55208131700015", EAddr: "552081317", EAddrSchema: "0002", + Address: "1 rue de la Paix", PostalCode: "75002", City: "Paris", + })) + + // Encaissement d'une facture jamais déposée : refusé, sans appel à la plateforme. + res, raw := call(t, cs, "report_payment", map[string]any{"number": number, "confirm": true}) + if !res.IsError || !strings.Contains(raw, "send_invoice") { + t.Fatalf("une facture non déposée ne s'encaisse pas : %s", raw) + } + if res, raw = call(t, cs, "send_invoice", map[string]any{"number": number, "confirm": true}); res.IsError { + t.Fatalf("send_invoice : %s", raw) + } + if res, raw = call(t, cs, "report_payment", map[string]any{"number": number}); !res.IsError { + t.Fatalf("report_payment sans confirm doit être refusé : %s", raw) + } + if res, raw = call(t, cs, "report_payment", map[string]any{"number": number, "confirm": true}); res.IsError { + t.Fatalf("report_payment total : %s", raw) + } + if res, raw = call(t, cs, "report_payment", map[string]any{"number": number, "confirm": true, + "date": "2026-09-12", "amount": "300,00"}); res.IsError { + t.Fatalf("report_payment partiel : %s", raw) + } + if len(mock.events) != 2 { + t.Fatalf("2 événements attendus, %d reçus", len(mock.events)) + } + if mock.events[0]["status_code"] != "fr:212" || mock.events[0]["invoice_id"] != float64(453372) || mock.events[0]["details"] != nil { + t.Errorf("encaissement total mal formé : %v", mock.events[0]) + } + partial, _ := json.Marshal(mock.events[1]["details"]) + for _, want := range []string{`"type_code":"MEN"`, `"amount":"300.00"`, `"date":"2026-09-12"`} { + if !strings.Contains(string(partial), want) { + t.Errorf("encaissement partiel sans %s : %s", want, partial) + } + } + // Une date sans montant : le montant transmis est le total de la facture. + if res, raw = call(t, cs, "report_payment", map[string]any{"number": number, "confirm": true, + "date": "2026-09-14"}); res.IsError { + t.Fatalf("report_payment daté : %s", raw) + } + dated, _ := json.Marshal(mock.events[2]["details"]) + if !strings.Contains(string(dated), `"date":"2026-09-14"`) || !strings.Contains(string(dated), `"amount":"`) { + t.Errorf("encaissement daté sans montant du total : %s", dated) + } + if _, raw = call(t, cs, "list_invoices", map[string]any{}); !strings.Contains(raw, `"encaissee_le":"2026-09-14"`) { + t.Errorf("list_invoices doit montrer l'encaissement : %s", raw) + } + + // Réception : deux pages, puis plus rien au passage suivant. + res, raw = call(t, cs, "list_received_invoices", map[string]any{"refresh": true}) + if res.IsError || !strings.Contains(raw, `"new":2`) { + t.Fatalf("2 nouvelles factures attendues : %s", raw) + } + res, raw = call(t, cs, "list_received_invoices", map[string]any{"refresh": true}) + if res.IsError || !strings.Contains(raw, `"new":0`) { + t.Fatalf("un second passage ne doit rien rapatrier : %s", raw) + } + if got := strings.Join(mock.afters, ","); got != "0,9001,9002" { + t.Errorf("curseurs interrogés = %s", got) + } + pdf := filepath.Join(org.Path, workspace.ReceivedDir, "2026-08", "2026-08-30 - Fournisseur_ B - B_12.pdf") + if data, err := os.ReadFile(pdf); err != nil || string(data) != "%PDF-1.7 reçue 9002" { + t.Errorf("PDF reçu absent ou faux (%v) : %q", err, data) + } + + // Export du mois : factures émises et reçues, récapitulatif CSV. + dest := t.TempDir() + res, raw = call(t, cs, "export_invoices", map[string]any{"month": "2026-09", "to": dest}) + if res.IsError { + t.Fatalf("export_invoices : %s", raw) + } + dir := filepath.Join(dest, "Studio Exemple", "2026-09") + csv, err := os.ReadFile(filepath.Join(dir, "recapitulatif.csv")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"emise;" + number + ";2026-09-05;ACME SAS;600,00;", ";2026-09-14;emises/FACTURE - Client.pdf", + "recue;F-77;2026-09-09;Fournisseur A;100,00;20,00;120,00;EUR;"} { + if !strings.Contains(string(csv), want) { + t.Errorf("CSV sans %q :\n%s", want, csv) + } + } + if strings.Contains(string(csv), "B/12") { + t.Error("une facture d'août ne doit pas figurer dans l'export de septembre") + } + for _, f := range []string{"emises/FACTURE - Client.pdf", "recues/2026-09-09 - Fournisseur A - F-77.pdf"} { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + t.Errorf("export sans %s", f) + } + } + if _, raw = call(t, cs, "export_invoices", map[string]any{"month": "2026-09", "to": dest}); !strings.Contains(raw, `"copied":0`) { + t.Errorf("un second export ne recopie rien : %s", raw) + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..7150ecd --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,44 @@ +// Package notify affiche une notification sur le bureau de l'utilisateur. +package notify + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" +) + +// Send affiche une notification ; sans notificateur disponible, le message part sur stderr. +func Send(title, body string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "linux", "freebsd", "openbsd": + if path, err := exec.LookPath("notify-send"); err == nil { + cmd = exec.Command(path, "--app-name=gofact", title, body) + } + case "darwin": + cmd = exec.Command("osascript", "-e", + fmt.Sprintf("display notification %s with title %s", appleString(body), appleString(title))) + case "windows": + cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", windowsToast(title, body)) + } + if cmd == nil || cmd.Run() != nil { + fmt.Fprintf(os.Stderr, "%s — %s\n", title, body) + } +} + +func appleString(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` +} + +// Windows n'affiche un toast que pour une application inscrite : on emprunte l'identité de PowerShell. +func windowsToast(title, body string) string { + esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") } + return `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null;` + + `$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02);` + + `$x = $t.GetElementsByTagName('text');` + + `$x.Item(0).AppendChild($t.CreateTextNode('` + esc(title) + `')) > $null;` + + `$x.Item(1).AppendChild($t.CreateTextNode('` + esc(body) + `')) > $null;` + + `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe').Show([Windows.UI.Notifications.ToastNotification]::new($t))` +} diff --git a/internal/pdp/pdp.go b/internal/pdp/pdp.go index 9b4857b..05970f7 100644 --- a/internal/pdp/pdp.go +++ b/internal/pdp/pdp.go @@ -1,7 +1,6 @@ // Package pdp abstrait la plateforme de dématérialisation partenaire (PDP) sur -// laquelle les factures sont déposées. L'interface est volontairement minimale -// — déposer, suivre — et ne s'élargira qu'à l'arrivée d'un second fournisseur -// réel : généraliser sur un seul cas produit des abstractions fausses. +// laquelle les factures sont déposées et reçues. L'interface ne couvre que ce +// dont gofact se sert : déposer, suivre, signaler un encaissement, recevoir. package pdp import ( @@ -75,6 +74,41 @@ type Provider interface { Send(ctx context.Context, pdfPath string) (Receipt, error) // Status renvoie le cycle de vie d'un dépôt antérieur. Status(ctx context.Context, reference string) ([]Event, error) + // ReportPayment signale l'encaissement d'une facture émise. + ReportPayment(ctx context.Context, reference string, p Payment) error + // Received liste les factures reçues après le curseur afterID, dans l'ordre d'arrivée. + Received(ctx context.Context, afterID int64) ([]Incoming, error) + // Download renvoie le PDF Factur-X lisible d'une facture, émise ou reçue. + Download(ctx context.Context, reference string) ([]byte, error) +} + +// Payment décrit un encaissement ; sans ventilation, la plateforme retient le total de la facture à la date du signalement. +type Payment struct { + Date string // ISO YYYY-MM-DD + Currency string + Parts []PaymentPart +} + +// PaymentPart est la part d'un encaissement soumise à un taux de TVA. +type PaymentPart struct { + Amount string // décimal, ex. "1200.00" + VATRate string // pourcentage, ex. "20.00" +} + +// Incoming est une facture reçue par la plateforme. +type Incoming struct { + Provider string `json:"provider"` + Reference string `json:"reference"` + Cursor int64 `json:"cursor"` + ReceivedAt string `json:"received_at"` + Number string `json:"number"` + IssueDate string `json:"issue_date"` + Seller string `json:"seller"` + SellerID string `json:"seller_id,omitempty"` + TotalHT string `json:"total_ht"` + TotalVAT string `json:"total_vat"` + TotalTTC string `json:"total_ttc"` + Currency string `json:"currency"` } // Factory construit un fournisseur depuis une source de configuration (le .env diff --git a/internal/pdp/superpdp/client.go b/internal/pdp/superpdp/client.go index 49da88b..2998eed 100644 --- a/internal/pdp/superpdp/client.go +++ b/internal/pdp/superpdp/client.go @@ -1,5 +1,6 @@ // Package superpdp est un client minimal de la PDP SuperPDP (superpdp.tech) : -// authentification OAuth2 (client credentials) et dépôt d'une facture Factur-X. +// authentification OAuth2 (client credentials), dépôt, suivi et encaissement +// des factures émises, récupération des factures reçues. package superpdp import ( @@ -169,21 +170,22 @@ func (c *Client) SendPDF(ctx context.Context, pdfPath string) (*Invoice, error) if err != nil { return nil, fmt.Errorf("superpdp: lecture PDF: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.Base+"/v1.beta/invoices", bytes.NewReader(pdf)) + body, err := c.do(ctx, http.MethodPost, "/v1.beta/invoices", "application/pdf", bytes.NewReader(pdf)) if err != nil { - return nil, fmt.Errorf("superpdp: requête dépôt: %w", err) + return nil, err } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Content-Type", "application/pdf") + var inv Invoice + if err := json.Unmarshal(body, &inv); err != nil { + return nil, fmt.Errorf("superpdp: réponse illisible: %s", strings.TrimSpace(string(body))) + } + return &inv, nil +} - resp, err := c.http.Do(req) +// GetInvoice récupère une facture et ses statuts (suivi du cycle de vie). +func (c *Client) GetInvoice(ctx context.Context, id int64) (*Invoice, error) { + body, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/v1.beta/invoices/%d", id), "", nil) if err != nil { - return nil, fmt.Errorf("superpdp: appel dépôt: %w", err) - } - defer func() { _ = resp.Body.Close() }() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, decodeError(resp.StatusCode, body) + return nil, err } var inv Invoice if err := json.Unmarshal(body, &inv); err != nil { @@ -192,28 +194,130 @@ func (c *Client) SendPDF(ctx context.Context, pdfPath string) (*Invoice, error) return &inv, nil } -// GetInvoice récupère une facture et ses statuts (suivi du cycle de vie). -func (c *Client) GetInvoice(ctx context.Context, id int64) (*Invoice, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, - fmt.Sprintf("%s/v1.beta/invoices/%d", c.cfg.Base, id), nil) +// GetInvoiceFile télécharge une facture dans le format demandé (factur-x, cii, ubl, original). +func (c *Client) GetInvoiceFile(ctx context.Context, id int64, format string) ([]byte, error) { + return c.do(ctx, http.MethodGet, + fmt.Sprintf("/v1.beta/invoices/%d?format=%s", id, url.QueryEscape(format)), "", nil) +} + +// Overview est une facture de la liste, avec son contenu EN 16931 résumé. +type Overview struct { + ID int64 `json:"id"` + Direction string `json:"direction"` + CreatedAt string `json:"created_at"` + EN ENInvoice `json:"en_invoice"` +} + +// ENInvoice est la partie du contenu EN 16931 exploitée par gofact. +type ENInvoice struct { + Number string `json:"number"` + IssueDate string `json:"issue_date"` + CurrencyCode string `json:"currency_code"` + Seller struct { + Name string `json:"name"` + Legal struct { + Value string `json:"value"` + } `json:"legal_registration_identifier"` + } `json:"seller"` + Totals struct { + WithoutVAT Amount `json:"total_without_vat"` + VAT Amount `json:"total_vat_amount"` + WithVAT Amount `json:"total_with_vat"` + } `json:"totals"` +} + +// Amount est un montant décimal que l'API sert tantôt en chaîne, tantôt en +// objet {value, currency_code}. +type Amount string + +func (a *Amount) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) == nil { + *a = Amount(s) + return nil + } + var o struct { + Value json.RawMessage `json:"value"` + } + if json.Unmarshal(b, &o) == nil && len(o.Value) > 0 { + *a = Amount(strings.Trim(string(o.Value), `"`)) + } + return nil +} + +// ListInvoices renvoie, toutes pages comprises, les factures d'un sens ("in" ou "out") d'id supérieur à afterID. +func (c *Client) ListInvoices(ctx context.Context, direction string, afterID int64) ([]Overview, error) { + var out []Overview + for { + q := url.Values{ + "direction": {direction}, + "order": {"asc"}, + "limit": {"1000"}, + "starting_after_id": {fmt.Sprint(afterID)}, + "expand[]": {"en_invoice", "en_invoice.seller"}, + } + body, err := c.do(ctx, http.MethodGet, "/v1.beta/invoices?"+q.Encode(), "", nil) + if err != nil { + return nil, err + } + var page struct { + Data []Overview `json:"data"` + HasAfter bool `json:"has_after"` + } + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("superpdp: liste illisible: %s", strings.TrimSpace(string(body))) + } + out = append(out, page.Data...) + if !page.HasAfter || len(page.Data) == 0 { + return out, nil + } + afterID = page.Data[len(page.Data)-1].ID + } +} + +// ReportedData est une donnée jointe à un événement de cycle de vie (MDG-43). +type ReportedData struct { + TypeCode string `json:"type_code"` + Amount string `json:"amount,omitempty"` + CurrencyCode string `json:"currency_code,omitempty"` + Date string `json:"date,omitempty"` + ValuePercent string `json:"value_percent,omitempty"` +} + +// PostInvoiceEvent ajoute un statut au cycle de vie d'une facture (fr:212 « Encaissée »…). +func (c *Client) PostInvoiceEvent(ctx context.Context, id int64, status string, data []ReportedData) error { + payload := map[string]any{"invoice_id": id, "status_code": status} + if len(data) > 0 { + payload["details"] = []map[string]any{{"reported_data": data}} + } + raw, err := json.Marshal(payload) + if err != nil { + return err + } + _, err = c.do(ctx, http.MethodPost, "/v1.beta/invoice_events", "application/json", bytes.NewReader(raw)) + return err +} + +// do exécute un appel authentifié et renvoie le corps d'une réponse 2xx. +func (c *Client) do(ctx context.Context, method, path, contentType string, body io.Reader) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, method, c.cfg.Base+path, body) if err != nil { - return nil, fmt.Errorf("superpdp: requête statut: %w", err) + return nil, fmt.Errorf("superpdp: requête %s: %w", path, err) } req.Header.Set("Authorization", "Bearer "+c.token) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } resp, err := c.http.Do(req) if err != nil { - return nil, fmt.Errorf("superpdp: appel statut: %w", err) + return nil, fmt.Errorf("superpdp: appel %s: %w", path, err) } defer func() { _ = resp.Body.Close() }() - body, _ := io.ReadAll(resp.Body) + raw, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, decodeError(resp.StatusCode, body) - } - var inv Invoice - if err := json.Unmarshal(body, &inv); err != nil { - return nil, fmt.Errorf("superpdp: réponse illisible: %s", strings.TrimSpace(string(body))) + return nil, decodeError(resp.StatusCode, raw) } - return &inv, nil + return raw, nil } func decodeError(status int, body []byte) error { diff --git a/internal/pdp/superpdp/client_test.go b/internal/pdp/superpdp/client_test.go index d47fe46..abf6944 100644 --- a/internal/pdp/superpdp/client_test.go +++ b/internal/pdp/superpdp/client_test.go @@ -89,3 +89,16 @@ func TestUnexpectedEventShapesAreTolerated(t *testing.T) { } } } + +func TestAmountAcceptsStringAndObject(t *testing.T) { + var totals struct { + HT Amount `json:"total_without_vat"` + VAT Amount `json:"total_vat_amount"` + } + if err := json.Unmarshal([]byte(`{"total_without_vat":"590.00","total_vat_amount":{"value":"118.00","currency_code":"EUR"}}`), &totals); err != nil { + t.Fatal(err) + } + if totals.HT != "590.00" || totals.VAT != "118.00" { + t.Errorf("montants décodés : %q, %q", totals.HT, totals.VAT) + } +} diff --git a/internal/pdp/superpdp/provider.go b/internal/pdp/superpdp/provider.go index 4e0fff6..6a0a0ff 100644 --- a/internal/pdp/superpdp/provider.go +++ b/internal/pdp/superpdp/provider.go @@ -47,9 +47,9 @@ func (p *provider) Send(ctx context.Context, pdfPath string) (pdp.Receipt, error } func (p *provider) Status(ctx context.Context, reference string) ([]pdp.Event, error) { - id, err := strconv.ParseInt(reference, 10, 64) + id, err := parseRef(reference) if err != nil { - return nil, fmt.Errorf("superpdp: référence %q invalide", reference) + return nil, err } if err := p.cli.Authenticate(ctx); err != nil { return nil, err @@ -61,6 +61,73 @@ func (p *provider) Status(ctx context.Context, reference string) ([]pdp.Event, e return convertEvents(inv.Events), nil } +func (p *provider) ReportPayment(ctx context.Context, reference string, pay pdp.Payment) error { + id, err := parseRef(reference) + if err != nil { + return err + } + if err := p.cli.Authenticate(ctx); err != nil { + return err + } + var data []ReportedData + for _, part := range pay.Parts { + data = append(data, ReportedData{TypeCode: "MEN", Amount: part.Amount, CurrencyCode: pay.Currency, + Date: pay.Date, ValuePercent: part.VATRate}) + } + return p.cli.PostInvoiceEvent(ctx, id, "fr:212", data) +} + +func (p *provider) Received(ctx context.Context, afterID int64) ([]pdp.Incoming, error) { + if err := p.cli.Authenticate(ctx); err != nil { + return nil, err + } + list, err := p.cli.ListInvoices(ctx, "in", afterID) + if err != nil { + return nil, err + } + out := make([]pdp.Incoming, 0, len(list)) + for _, inv := range list { + currency := inv.EN.CurrencyCode + if currency == "" { + currency = "EUR" + } + out = append(out, pdp.Incoming{ + Provider: p.Name(), + Reference: strconv.FormatInt(inv.ID, 10), + Cursor: inv.ID, + ReceivedAt: inv.CreatedAt, + Number: inv.EN.Number, + IssueDate: inv.EN.IssueDate, + Seller: inv.EN.Seller.Name, + SellerID: inv.EN.Seller.Legal.Value, + TotalHT: string(inv.EN.Totals.WithoutVAT), + TotalVAT: string(inv.EN.Totals.VAT), + TotalTTC: string(inv.EN.Totals.WithVAT), + Currency: currency, + }) + } + return out, nil +} + +func (p *provider) Download(ctx context.Context, reference string) ([]byte, error) { + id, err := parseRef(reference) + if err != nil { + return nil, err + } + if err := p.cli.Authenticate(ctx); err != nil { + return nil, err + } + return p.cli.GetInvoiceFile(ctx, id, "factur-x") +} + +func parseRef(reference string) (int64, error) { + id, err := strconv.ParseInt(reference, 10, 64) + if err != nil { + return 0, fmt.Errorf("superpdp: référence %q invalide", reference) + } + return id, nil +} + func convertEvents(events []Event) []pdp.Event { out := make([]pdp.Event, 0, len(events)) for _, e := range events { diff --git a/internal/workspace/export.go b/internal/workspace/export.go new file mode 100644 index 0000000..71afff3 --- /dev/null +++ b/internal/workspace/export.go @@ -0,0 +1,143 @@ +package workspace + +import ( + "bytes" + "encoding/csv" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// EnvExportDir désigne le dossier où l'export comptable est écrit. +const EnvExportDir = "GOFACT_EXPORT_DIR" + +// ExportResult résume un export mensuel. +type ExportResult struct { + Dir string `json:"dir"` + Issued int `json:"issued"` + Received int `json:"received"` + Copied int `json:"copied"` + Missing []string `json:"missing,omitempty"` +} + +// Export écrit dans dest/// les PDF émis et reçus du mois, avec un récapitulatif CSV. +func (o *Org) Export(month, dest string) (ExportResult, error) { + if _, err := time.Parse("2006-01", month); err != nil { + return ExportResult{}, fmt.Errorf("mois %q invalide : format attendu AAAA-MM", month) + } + if strings.TrimSpace(dest) == "" { + return ExportResult{}, fmt.Errorf("aucune destination d'export : renseigner %s dans le .env de l'organisation, "+ + "ou indiquer un dossier", EnvExportDir) + } + res := ExportResult{Dir: filepath.Join(dest, safeName(o.Name()), month)} + for _, sub := range []string{"emises", "recues"} { + if err := os.MkdirAll(filepath.Join(res.Dir, sub), 0o755); err != nil { + return res, err + } + } + + payments, err := o.Payments() + if err != nil { + return res, err + } + rows := [][]string{{"sens", "numero", "date", "tiers", "montant_ht", "tva", "montant_ttc", "devise", + "encaissement", "fichier"}} + + invoices, err := o.Invoices() + if err != nil { + return res, err + } + for i := len(invoices) - 1; i >= 0; i-- { + inv := invoices[i] + date, _ := inv["date_emission"].(string) + if !strings.HasPrefix(date, month) { + continue + } + number, _ := inv["numero"].(string) + client, _ := inv["client"].(string) + file, _ := inv["fichier"].(string) + res.Issued++ + pdf := strings.TrimSuffix(file, filepath.Ext(file)) + ".pdf" + copied, err := copyIfChanged(filepath.Join(o.Path, pdf), filepath.Join(res.Dir, "emises", filepath.Base(pdf))) + if err != nil { + res.Missing = append(res.Missing, number) + } else if copied { + res.Copied++ + } + ht, vat, ttc, currency := o.issuedTotals(inv) + rows = append(rows, []string{"emise", number, date, client, ht, vat, ttc, currency, + payments[number].Date, "emises/" + filepath.Base(pdf)}) + } + + received, err := o.ReceivedInvoices(month) + if err != nil { + return res, err + } + for i := len(received) - 1; i >= 0; i-- { + r := received[i] + res.Received++ + copied, err := copyIfChanged(filepath.Join(o.Path, r.File), filepath.Join(res.Dir, "recues", filepath.Base(r.File))) + if err != nil { + res.Missing = append(res.Missing, r.Number) + } else if copied { + res.Copied++ + } + rows = append(rows, []string{"recue", r.Number, r.IssueDate, r.Seller, frDecimal(r.TotalHT), + frDecimal(r.TotalVAT), frDecimal(r.TotalTTC), r.Currency, "", "recues/" + filepath.Base(r.File)}) + } + + var buf bytes.Buffer + buf.WriteString("\xef\xbb\xbf") // BOM : sans lui, Excel lit le CSV en Windows-1252. + w := csv.NewWriter(&buf) + w.Comma = ';' + if err := w.WriteAll(rows); err != nil { + return res, err + } + return res, writeAtomic(filepath.Join(res.Dir, "recapitulatif.csv"), buf.Bytes()) +} + +// issuedTotals lit les totaux d'une facture émise dans son sidecar, avec repli sur le HT du registre. +func (o *Org) issuedTotals(inv map[string]any) (ht, vat, ttc, currency string) { + currency = "EUR" + if cents, ok := inv["montant_ht_cents"].(float64); ok { + ht = frCents(int64(cents)) + } + number, _ := inv["numero"].(string) + full, err := o.issuedInvoice(number) + if err != nil { + return ht, "", "", currency + } + if full.Currency != "" { + currency = full.Currency + } + return frCents(full.TaxBasisTotal), frCents(full.TaxTotal), frCents(full.GrandTotal), currency +} + +func copyIfChanged(src, dst string) (bool, error) { + data, err := os.ReadFile(src) + if err != nil { + return false, err + } + if prev, err := os.ReadFile(dst); err == nil && bytes.Equal(prev, data) { + return false, nil + } + return true, writeAtomic(dst, data) +} + +func frCents(c int64) string { + sign := "" + if c < 0 { + sign, c = "-", -c + } + return fmt.Sprintf("%s%d,%02d", sign, c/100, c%100) +} + +func frDecimal(s string) string { + if _, err := strconv.ParseFloat(s, 64); err != nil { + return s + } + return strings.Replace(s, ".", ",", 1) +} diff --git a/internal/workspace/received.go b/internal/workspace/received.go new file mode 100644 index 0000000..fd0a06f --- /dev/null +++ b/internal/workspace/received.go @@ -0,0 +1,357 @@ +package workspace + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/kolapsis/gofact/internal/facturx" + "github.com/kolapsis/gofact/internal/pdp" +) + +// ReceivedDir range les factures reçues de la plateforme, un sous-dossier par mois d'émission. +const ReceivedDir = "recues" + +// SyncStateFile garde le curseur de la dernière facture reçue. +const SyncStateFile = "pdp-sync.json" + +// Received est une facture reçue, rangée dans le dossier. +type Received struct { + pdp.Incoming + File string `json:"file"` +} + +type syncState struct { + LastInID int64 `json:"last_in_id"` +} + +// SyncReceived récupère les factures reçues depuis le dernier passage et renvoie les nouvelles. +func (o *Org) SyncReceived(ctx context.Context, p pdp.Provider) ([]Received, error) { + state, err := o.readSyncState() + if err != nil { + return nil, err + } + incoming, err := p.Received(ctx, state.LastInID) + if err != nil { + return nil, err + } + var fresh []Received + for _, inc := range incoming { + r, created, err := o.storeReceived(ctx, p, inc) + if err != nil { + return fresh, err + } + if created { + fresh = append(fresh, r) + } + // Le curseur n'avance qu'une fois le PDF écrit : un passage interrompu reprend là où il s'est arrêté. + if inc.Cursor > state.LastInID { + state.LastInID = inc.Cursor + if err := o.writeSyncState(state); err != nil { + return fresh, err + } + } + } + return fresh, nil +} + +func (o *Org) storeReceived(ctx context.Context, p pdp.Provider, inc pdp.Incoming) (Received, bool, error) { + month := monthOf(inc.IssueDate, inc.ReceivedAt) + dir := filepath.Join(o.Path, ReceivedDir, month) + base := safeName(fmt.Sprintf("%s - %s - %s", firstNonEmpty(inc.IssueDate, month), firstNonEmpty(inc.Seller, "vendeur inconnu"), + firstNonEmpty(inc.Number, inc.Reference))) + r := Received{Incoming: inc, File: filepath.Join(ReceivedDir, month, base+".pdf")} + pdfPath := filepath.Join(o.Path, r.File) + if _, err := os.Stat(pdfPath); err == nil { + return r, false, nil + } + pdf, err := p.Download(ctx, inc.Reference) + if err != nil { + return r, false, fmt.Errorf("téléchargement de la facture reçue %s : %w", inc.Reference, err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return r, false, err + } + meta, err := json.MarshalIndent(r, "", " ") + if err != nil { + return r, false, err + } + if err := writeAtomic(filepath.Join(dir, base+".json"), meta); err != nil { + return r, false, err + } + if err := writeAtomic(pdfPath, pdf); err != nil { + return r, false, err + } + _ = o.Journal("pdp_received", map[string]any{"provider": inc.Provider, "reference": inc.Reference, + "numero": inc.Number, "vendeur": inc.Seller, "fichier": r.File}) + return r, true, nil +} + +// ReceivedInvoices liste les factures reçues rangées dans le dossier, pour un mois AAAA-MM ou toutes si month est vide. +func (o *Org) ReceivedInvoices(month string) ([]Received, error) { + pattern := filepath.Join(o.Path, ReceivedDir, "*", "*.json") + if month != "" { + pattern = filepath.Join(o.Path, ReceivedDir, month, "*.json") + } + files, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + out := []Received{} + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + return nil, err + } + var r Received + if json.Unmarshal(raw, &r) != nil || r.Reference == "" { + continue + } + out = append(out, r) + } + sort.Slice(out, func(i, j int) bool { return out[i].Cursor > out[j].Cursor }) + return out, nil +} + +func (o *Org) readSyncState() (syncState, error) { + var s syncState + raw, err := os.ReadFile(filepath.Join(o.Path, SyncStateFile)) + if os.IsNotExist(err) { + return s, nil + } + if err != nil { + return s, err + } + if err := json.Unmarshal(raw, &s); err != nil { + return s, fmt.Errorf("workspace: %s illisible : %w", SyncStateFile, err) + } + return s, nil +} + +func (o *Org) writeSyncState(s syncState) error { + raw, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return writeAtomic(filepath.Join(o.Path, SyncStateFile), raw) +} + +// PaymentRecord est un encaissement signalé à la plateforme. +type PaymentRecord struct { + Date string `json:"date"` + Amount string `json:"amount,omitempty"` +} + +// Payments renvoie, par numéro de facture, le dernier encaissement inscrit au journal. +func (o *Org) Payments() (map[string]PaymentRecord, error) { + out := map[string]PaymentRecord{} + err := o.scanJournal(func(event string, data json.RawMessage) { + if event != "payment_reported" { + return + } + var d struct { + Numero string `json:"numero"` + PaymentRecord + } + if json.Unmarshal(data, &d) == nil && d.Numero != "" { + out[d.Numero] = d.PaymentRecord + } + }) + return out, err +} + +// SentReference renvoie la référence et le fournisseur du dernier dépôt d'une facture, vides si elle n'a jamais été déposée. +func (o *Org) SentReference(number string) (ref, provider string, err error) { + err = o.scanJournal(func(event string, data json.RawMessage) { + if event != "pdp_sent" { + return + } + var d struct { + Numero string `json:"numero"` + Provider string `json:"provider"` + Reference string `json:"reference"` + } + if json.Unmarshal(data, &d) == nil && d.Numero == number { + ref, provider = d.Reference, d.Provider + } + }) + return ref, provider, err +} + +func (o *Org) scanJournal(fn func(event string, data json.RawMessage)) error { + f, err := os.Open(filepath.Join(o.Path, JournalFile)) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + defer func() { _ = f.Close() }() + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 64*1024), 4*1024*1024) + for sc.Scan() { + var line struct { + Event string `json:"event"` + Data json.RawMessage `json:"data"` + } + if json.Unmarshal(sc.Bytes(), &line) == nil { + fn(line.Event, line.Data) + } + } + return sc.Err() +} + +func monthOf(dates ...string) string { + for _, d := range dates { + if len(d) >= 7 && d[4] == '-' { + return d[:7] + } + } + return "sans-date" +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + return "" +} + +// safeName retire d'un nom de fichier les caractères interdits sous Windows ou macOS. +func safeName(s string) string { + s = strings.Map(func(r rune) rune { + if r < 32 || strings.ContainsRune(`<>:"/\|?*`, r) { + return '_' + } + return r + }, s) + return strings.TrimRight(strings.TrimSpace(s), ". ") +} + +func writeAtomic(path string, data []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// ErrNotSent signale une facture qui n'a jamais été déposée sur la plateforme. +var ErrNotSent = errors.New("facture jamais déposée sur la PDP") + +// PaymentReport décrit un encaissement signalé. +type PaymentReport struct { + Number string `json:"number"` + Reference string `json:"reference"` + Date string `json:"date"` + Amount string `json:"amount"` +} + +// ReportPayment signale à la plateforme l'encaissement d'une facture déposée et l'inscrit au journal. +func (o *Org) ReportPayment(ctx context.Context, p pdp.Provider, number, date, amount string) (PaymentReport, error) { + today := time.Now().Format("2006-01-02") + out := PaymentReport{Number: number, Date: strings.TrimSpace(date), Amount: strings.TrimSpace(amount)} + if out.Date == "" { + out.Date = today + } + if _, err := time.Parse("2006-01-02", out.Date); err != nil { + return out, fmt.Errorf("date d'encaissement %q invalide : format attendu AAAA-MM-JJ", date) + } + var paid int64 + if out.Amount != "" { + v, err := strconv.ParseFloat(strings.Replace(out.Amount, ",", ".", 1), 64) + if err != nil || v <= 0 { + return out, fmt.Errorf("montant %q invalide : un décimal positif est attendu (ex. 1200.00)", amount) + } + paid = int64(math.Round(v * 100)) + } + ref, _, err := o.SentReference(number) + if err != nil { + return out, err + } + if ref == "" { + return out, fmt.Errorf("facture %s : %w", number, ErrNotSent) + } + out.Reference = ref + + pay := pdp.Payment{Date: out.Date, Currency: "EUR"} + // La plateforme refuse un encaissement daté ou partiel qui n'est pas ventilé par taux de TVA. + if paid > 0 || out.Date != today { + inv, err := o.issuedInvoice(number) + if err != nil { + return out, fmt.Errorf("facture %s : ventilation par taux de TVA introuvable (%v) — signaler "+ + "l'encaissement total sans date ni montant", number, err) + } + if inv.Currency != "" { + pay.Currency = inv.Currency + } + if paid == 0 { + paid = inv.GrandTotal + } + if paid > inv.GrandTotal { + return out, fmt.Errorf("montant encaissé (%s) supérieur au total de la facture (%s)", + frCents(paid), frCents(inv.GrandTotal)) + } + pay.Parts = splitByRate(paid, inv.VATBreakdown) + } + if err := p.ReportPayment(ctx, ref, pay); err != nil { + return out, err + } + out.Amount = "total" + if paid > 0 { + out.Amount = strings.Replace(frCents(paid), ",", ".", 1) + } + _ = o.Journal("payment_reported", map[string]any{"numero": number, "reference": ref, + "date": out.Date, "amount": out.Amount}) + return out, nil +} + +// splitByRate répartit un montant encaissé entre les taux de TVA, au prorata du TTC de chacun. +func splitByRate(paid int64, breakdown []facturx.VATSubtotal) []pdp.PaymentPart { + var total int64 + for _, b := range breakdown { + total += b.BasisAmount + b.CalculatedTax + } + parts := make([]pdp.PaymentPart, 0, len(breakdown)) + var assigned int64 + for i, b := range breakdown { + share := paid - assigned + if i < len(breakdown)-1 && total != 0 { + share = int64(math.Round(float64(paid) * float64(b.BasisAmount+b.CalculatedTax) / float64(total))) + } + assigned += share + parts = append(parts, pdp.PaymentPart{Amount: strings.Replace(frCents(share), ",", ".", 1), VATRate: b.RatePct}) + } + return parts +} + +// issuedInvoice reconstitue une facture émise depuis son sidecar. +func (o *Org) issuedInvoice(number string) (facturx.Invoice, error) { + invoices, err := o.Invoices() + if err != nil { + return facturx.Invoice{}, err + } + for _, inv := range invoices { + if n, _ := inv["numero"].(string); n != number { + continue + } + file, _ := inv["fichier"].(string) + spec, err := facturx.LoadSpec(filepath.Join(o.Path, strings.TrimSuffix(file, filepath.Ext(file))+".json")) + if err != nil { + return facturx.Invoice{}, err + } + return spec.ToInvoiceWith(o.Config()) + } + return facturx.Invoice{}, fmt.Errorf("facture inconnue du registre") +} diff --git a/internal/workspace/received_test.go b/internal/workspace/received_test.go new file mode 100644 index 0000000..8df37dc --- /dev/null +++ b/internal/workspace/received_test.go @@ -0,0 +1,30 @@ +package workspace + +import ( + "testing" + + "github.com/kolapsis/gofact/internal/facturx" +) + +func TestSplitByRateKeepsTheExactTotal(t *testing.T) { + breakdown := []facturx.VATSubtotal{ + {RatePct: "20.00", BasisAmount: 10000, CalculatedTax: 2000}, + {RatePct: "5.50", BasisAmount: 3333, CalculatedTax: 183}, + } + parts := splitByRate(10001, breakdown) + if len(parts) != 2 || parts[0].VATRate != "20.00" || parts[1].VATRate != "5.50" { + t.Fatalf("ventilation inattendue : %+v", parts) + } + if parts[0].Amount != "77.35" || parts[1].Amount != "22.66" { + t.Errorf("parts = %s + %s, attendu 77.35 + 22.66 (somme 100.01)", parts[0].Amount, parts[1].Amount) + } + if one := splitByRate(59000, []facturx.VATSubtotal{{RatePct: "0.00", BasisAmount: 59000}}); one[0].Amount != "590.00" { + t.Errorf("taux unique : %+v", one) + } +} + +func TestSafeName(t *testing.T) { + if got := safeName(`2026-08-30 - Fournisseur: B - B/12. `); got != "2026-08-30 - Fournisseur_ B - B_12" { + t.Errorf("safeName = %q", got) + } +} diff --git a/main.go b/main.go index 9bc1cea..923d9e4 100644 --- a/main.go +++ b/main.go @@ -58,6 +58,18 @@ func main() { case "install": runInstall(os.Args[2:]) return + case "sync": + runSync(os.Args[2:]) + return + case "export": + runExport(os.Args[2:]) + return + case "paid": + runPaid(os.Args[2:]) + return + case "schedule": + runSchedule(os.Args[2:]) + return case "version": fmt.Println("gofact", version) return @@ -79,6 +91,10 @@ Commandes : gofact org init|list|show|set-counter gère les dossiers d'organisation et la numérotation gofact send -pdf dépose un Factur-X existant sur votre plateforme agréée + gofact paid -number signale à la plateforme l'encaissement d'une facture + gofact sync récupère les factures reçues et met à jour l'export comptable + gofact schedule lance gofact sync à intervalle régulier + gofact export -month AAAA-MM exporte les factures émises et reçues du mois gofact version affiche la version Mode direct, sans IA : diff --git a/mkdocs.yml b/mkdocs.yml index 1622973..607ed41 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,7 @@ nav: - Parler à son IA (MCP): guide/mcp.md - Ligne de commande: guide/cli.md - Le dossier d'organisation: guide/organisation.md + - Export comptable: guide/export.md - Comprendre la réforme: - Ce qui s'applique à un indépendant: comprendre/reforme-independant.md - Choisir sa plateforme agréée: comprendre/choisir-plateforme-agreee.md diff --git a/schedule.go b/schedule.go new file mode 100644 index 0000000..4f1f6ba --- /dev/null +++ b/schedule.go @@ -0,0 +1,169 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "time" +) + +const scheduleName = "gofact-sync" + +// runSchedule installe ou retire l'exécution périodique de « gofact sync ». +func runSchedule(argv []string) { + fs := flag.NewFlagSet("gofact schedule", flag.ExitOnError) + every := fs.Duration("every", time.Hour, "intervalle entre deux synchronisations (minimum 5m)") + apply := fs.Bool("yes", false, "applique (défaut : affiche seulement ce qui serait fait)") + remove := fs.Bool("remove", false, "retire la planification") + _ = fs.Parse(argv) + + if *every < 5*time.Minute { + fail(fmt.Errorf("-every doit valoir au moins 5m : la plateforme n'a pas à être interrogée plus souvent")) + } + exe, err := os.Executable() + if err != nil { + fail(err) + } + if exe, err = filepath.EvalSymlinks(exe); err != nil { + fail(err) + } + + var plan schedulePlan + switch runtime.GOOS { + case "linux": + plan, err = systemdPlan(exe, *every, *remove) + case "darwin": + plan, err = launchdPlan(exe, *every, *remove) + case "windows": + plan = schtasksPlan(exe, *every, *remove) + default: + err = fmt.Errorf("planification non prise en charge sur %s : lancer « %s sync » depuis cron", runtime.GOOS, exe) + } + if err != nil { + fail(err) + } + + for path, content := range plan.files { + if content == "" { + fmt.Printf("→ supprimer %s\n", path) + } else { + fmt.Printf("→ écrire %s :\n%s\n", path, content) + } + } + for _, c := range plan.commands { + fmt.Printf("→ %q\n", c) + } + if !*apply { + fmt.Println("\nRien n'a été modifié. Pour appliquer : gofact schedule -yes") + return + } + + for path, content := range plan.files { + if content == "" { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + fail(err) + } + continue + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + fail(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + fail(err) + } + } + for _, c := range plan.commands { + if out, err := exec.Command(c[0], c[1:]...).CombinedOutput(); err != nil && !plan.tolerant[c[0]+" "+c[1]] { + fail(fmt.Errorf("%v : %s", c, out)) + } + } + if *remove { + fmt.Println("✓ Planification retirée") + } else { + fmt.Printf("✓ gofact sync tournera toutes les %s\n", *every) + } +} + +type schedulePlan struct { + files map[string]string // contenu vide : fichier à supprimer + commands [][]string + tolerant map[string]bool // commandes dont l'échec est attendu (retrait d'une planification absente) +} + +func systemdPlan(exe string, every time.Duration, remove bool) (schedulePlan, error) { + if _, err := exec.LookPath("systemctl"); err != nil { + return schedulePlan{}, fmt.Errorf("systemd introuvable : ajouter à la crontab la ligne\n */%d * * * * %q sync -q", + max(int(every.Minutes()), 5), exe) + } + home, err := os.UserHomeDir() + if err != nil { + return schedulePlan{}, err + } + dir := filepath.Join(home, ".config", "systemd", "user") + service := filepath.Join(dir, scheduleName+".service") + timer := filepath.Join(dir, scheduleName+".timer") + if remove { + return schedulePlan{ + files: map[string]string{service: "", timer: ""}, + commands: [][]string{{"systemctl", "--user", "disable", "--now", scheduleName + ".timer"}, {"systemctl", "--user", "daemon-reload"}}, + tolerant: map[string]bool{"systemctl --user": true}, + }, nil + } + return schedulePlan{ + files: map[string]string{ + service: fmt.Sprintf("[Unit]\nDescription=gofact : factures reçues et export comptable\n\n"+ + "[Service]\nType=oneshot\nExecStart=%q sync\n", exe), + timer: fmt.Sprintf("[Unit]\nDescription=gofact sync périodique\n\n"+ + "[Timer]\nOnBootSec=5min\nOnUnitActiveSec=%s\n\n[Install]\nWantedBy=timers.target\n", + strconv.Itoa(int(every.Seconds()))+"s"), + }, + commands: [][]string{{"systemctl", "--user", "daemon-reload"}, {"systemctl", "--user", "enable", "--now", scheduleName + ".timer"}}, + }, nil +} + +func launchdPlan(exe string, every time.Duration, remove bool) (schedulePlan, error) { + home, err := os.UserHomeDir() + if err != nil { + return schedulePlan{}, err + } + label := "com.kolapsis." + scheduleName + plist := filepath.Join(home, "Library", "LaunchAgents", label+".plist") + domain := "gui/" + strconv.Itoa(os.Getuid()) + unload := []string{"launchctl", "bootout", domain + "/" + label} + if remove { + return schedulePlan{files: map[string]string{plist: ""}, commands: [][]string{unload}, + tolerant: map[string]bool{"launchctl bootout": true}}, nil + } + content := fmt.Sprintf(` + + + + Label%s + ProgramArguments%ssync + StartInterval%d + RunAtLoad + + +`, label, exe, int(every.Seconds())) + return schedulePlan{ + files: map[string]string{plist: content}, + commands: [][]string{unload, {"launchctl", "bootstrap", domain, plist}}, + tolerant: map[string]bool{"launchctl bootout": true}, + }, nil +} + +func schtasksPlan(exe string, every time.Duration, remove bool) schedulePlan { + if remove { + return schedulePlan{commands: [][]string{{"schtasks", "/Delete", "/F", "/TN", scheduleName}}} + } + unit, n := "MINUTE", int(every.Minutes()) + if n%60 == 0 { + unit, n = "HOURLY", min(n/60, 23) + } + return schedulePlan{commands: [][]string{{"schtasks", "/Create", "/F", "/SC", unit, + "/MO", strconv.Itoa(min(n, 1439)), "/TN", scheduleName, "/TR", fmt.Sprintf(`"%s" sync`, exe)}}} +} diff --git a/skills/creer-facture/SKILL.md b/skills/creer-facture/SKILL.md index b80fa3a..67f1438 100644 --- a/skills/creer-facture/SKILL.md +++ b/skills/creer-facture/SKILL.md @@ -84,6 +84,10 @@ Uniquement si l'utilisateur le demande, avec une confirmation explicite (« dép facture N sur la PDP ? ») : `send_invoice` avec `confirm=true`, puis `get_invoice_status` pour le cycle de vie (`fr:200` déposée → `fr:201` émise → `fr:202` reçue). +Si la facture est déjà payée, proposer ensuite de signaler l'encaissement : +`report_payment` avec `confirm=true`, après accord explicite. Ce statut est déclaré à +l'administration, il ne se signale donc qu'une fois le règlement réellement reçu. + ## Règles à ne pas transgresser - Le numéro vient **toujours** du serveur : jamais inventé, jamais réutilisé, jamais antidaté. `preview_next_number` annonce, `create_invoice` attribue. diff --git a/sync.go b/sync.go new file mode 100644 index 0000000..9872a3b --- /dev/null +++ b/sync.go @@ -0,0 +1,179 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "strings" + "time" + + "github.com/kolapsis/gofact/internal/dotenv" + "github.com/kolapsis/gofact/internal/notify" + "github.com/kolapsis/gofact/internal/pdp" + "github.com/kolapsis/gofact/internal/workspace" +) + +// runSync récupère les factures reçues de chaque organisation et met à jour l'export comptable. +func runSync(argv []string) { + fs := flag.NewFlagSet("gofact sync", flag.ExitOnError) + dir := fs.String("org", "", "dossier de l'organisation (défaut : toutes celles découvertes)") + envPath := fs.String("env", "", "fichier .env ; défaut ./.env puis ~/.config/gofact/.env") + quiet := fs.Bool("q", false, "pas de notification sur le bureau") + _ = fs.Parse(argv) + + if err := dotenv.LoadDefault(*envPath); err != nil { + fail(err) + } + orgs, err := workspace.Discover(*dir) + if err != nil { + fail(err) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + failed := false + for _, o := range orgs { + if !o.Identity().HasPDP { + if *dir != "" { + fail(fmt.Errorf("%s n'a pas de compte PDP configuré", o.Name())) + } + continue + } + if err := syncOrg(ctx, o, !*quiet); err != nil { + fmt.Fprintf(os.Stderr, "✗ %s : %v\n", o.Name(), err) + failed = true + } + } + if failed { + os.Exit(1) + } +} + +func syncOrg(ctx context.Context, o *workspace.Org, notifyDesktop bool) error { + provider, err := pdp.Open(o.Lookup) + if err != nil { + return err + } + fresh, err := o.SyncReceived(ctx, provider) + for _, r := range fresh { + fmt.Printf("✓ %s : facture reçue de %s, n° %s, %s %s → %s\n", o.Name(), r.Seller, r.Number, r.TotalTTC, r.Currency, r.File) + } + if notifyDesktop { + switch { + case len(fresh) == 1: + r := fresh[0] + notify.Send("gofact — facture reçue", fmt.Sprintf("%s · n° %s · %s %s", r.Seller, r.Number, r.TotalTTC, r.Currency)) + case len(fresh) > 1: + notify.Send("gofact — factures reçues", fmt.Sprintf("%d nouvelles factures pour %s", len(fresh), o.Name())) + } + } + if err != nil { + return err + } + + dest := o.Lookup(workspace.EnvExportDir) + if dest == "" { + return nil + } + now := time.Now() + for _, month := range []string{now.AddDate(0, -1, 0).Format("2006-01"), now.Format("2006-01")} { + res, err := o.Export(month, dest) + if err != nil { + return fmt.Errorf("export %s : %w", month, err) + } + if res.Copied > 0 { + fmt.Printf("✓ %s : export %s à jour (%s)\n", o.Name(), month, res.Dir) + } + } + return nil +} + +// runExport écrit l'export comptable d'un mois. +func runExport(argv []string) { + fs := flag.NewFlagSet("gofact export", flag.ExitOnError) + dir := fs.String("org", "", "dossier de l'organisation (défaut : découverte)") + month := fs.String("month", time.Now().Format("2006-01"), "mois à exporter, AAAA-MM") + to := fs.String("to", "", "dossier de destination (défaut : GOFACT_EXPORT_DIR)") + envPath := fs.String("env", "", "fichier .env ; défaut ./.env puis ~/.config/gofact/.env") + _ = fs.Parse(argv) + + if err := dotenv.LoadDefault(*envPath); err != nil { + fail(err) + } + o := singleOrg(*dir) + dest := *to + if dest == "" { + dest = o.Lookup(workspace.EnvExportDir) + } + res, err := o.Export(*month, dest) + if err != nil { + fail(err) + } + fmt.Printf("✓ Export %s : %d émise(s), %d reçue(s) → %s\n", *month, res.Issued, res.Received, res.Dir) + if len(res.Missing) > 0 { + fmt.Fprintf(os.Stderr, " PDF introuvable pour : %s\n", strings.Join(res.Missing, ", ")) + } +} + +// runPaid signale à la PDP l'encaissement d'une facture émise. +func runPaid(argv []string) { + fs := flag.NewFlagSet("gofact paid", flag.ExitOnError) + dir := fs.String("org", "", "dossier de l'organisation (défaut : découverte)") + number := fs.String("number", "", "numéro de la facture encaissée (requis)") + date := fs.String("date", "", "date d'encaissement AAAA-MM-JJ (défaut : aujourd'hui)") + amount := fs.String("amount", "", "montant encaissé, pour un paiement partiel (défaut : total)") + yes := fs.Bool("yes", false, "ne pas demander de confirmation") + envPath := fs.String("env", "", "fichier .env ; défaut ./.env puis ~/.config/gofact/.env") + _ = fs.Parse(argv) + + if *number == "" { + fmt.Fprintln(os.Stderr, "erreur : -number est requis") + fs.Usage() + os.Exit(2) + } + if err := dotenv.LoadDefault(*envPath); err != nil { + fail(err) + } + o := singleOrg(*dir) + if !*yes { + what := "le total" + if *amount != "" { + what = *amount + } + fmt.Printf("Signaler à la PDP l'encaissement de la facture %s (%s) ? Il sera déclaré à l'administration. [o/N] ", *number, what) + answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') + if a := strings.ToLower(strings.TrimSpace(answer)); a != "o" && a != "oui" && a != "y" && a != "yes" { + fmt.Println("Rien n'a été signalé.") + return + } + } + provider, err := pdp.Open(o.Lookup) + if err != nil { + fail(err) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + res, err := o.ReportPayment(ctx, provider, *number, *date, *amount) + if errors.Is(err, workspace.ErrNotSent) { + err = fmt.Errorf("%w : la déposer d'abord", err) + } + if err != nil { + fail(err) + } + fmt.Printf("✓ Encaissement de la facture %s signalé (%s, %s)\n", res.Number, res.Date, res.Amount) +} + +func singleOrg(dir string) *workspace.Org { + orgs, err := workspace.Discover(dir) + if err != nil { + fail(err) + } + if len(orgs) != 1 { + fail(fmt.Errorf("préciser l'organisation avec -org (trouvées : %d)", len(orgs))) + } + return orgs[0] +}