From 19a7e9cbf8f7bd335c4e4cfd4d3b8a84a350cc41 Mon Sep 17 00:00:00 2001
From: Andriy Oblivantsev
Date: Thu, 6 Aug 2026 12:32:20 +0100
Subject: [PATCH 1/8] feat(crm): add invoices CLI and opportunity update
Expose CRM invoice list/get/create/delete and catalog items via oo,
plus contact address/about helpers and opportunity bid/stage update.
Needed for Medex VM billing and other draft invoices.
---
cmd/oo/cli_test.go | 4 +-
cmd/oo/common.go | 2 +-
cmd/oo/contacts.go | 37 +++++-
cmd/oo/invoices.go | 269 ++++++++++++++++++++++++++++++++++++++++
cmd/oo/main.go | 1 +
cmd/oo/opportunities.go | 35 ++++++
http.go | 52 ++++++++
invoices.go | 264 +++++++++++++++++++++++++++++++++++++++
8 files changed, 659 insertions(+), 5 deletions(-)
create mode 100644 cmd/oo/invoices.go
create mode 100644 invoices.go
diff --git a/cmd/oo/cli_test.go b/cmd/oo/cli_test.go
index 9f5eca9..66467dc 100644
--- a/cmd/oo/cli_test.go
+++ b/cmd/oo/cli_test.go
@@ -11,7 +11,7 @@ func TestRootRegistersSubjects(t *testing.T) {
want := []string{
"calendar", "projects", "tasks", "users", "whoami",
"contacts", "persons", "companies",
- "opportunities", "cases", "crm-tasks", "applications", "crm", "mails", "catalog",
+ "opportunities", "cases", "crm-tasks", "applications", "crm", "mails", "catalog", "invoices",
}
got := make(map[string]bool, len(rootCmd.Commands()))
for _, c := range rootCmd.Commands() {
@@ -38,7 +38,7 @@ func TestRootHelpListsSubjects(t *testing.T) {
t.Fatal(err)
}
help := out.String()
- for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities"} {
+ for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities", "invoices"} {
if !strings.Contains(help, snippet) {
t.Fatalf("help missing %q", snippet)
}
diff --git a/cmd/oo/common.go b/cmd/oo/common.go
index 5f5ab27..b9b3592 100644
--- a/cmd/oo/common.go
+++ b/cmd/oo/common.go
@@ -20,7 +20,7 @@ var outputFormat = "table"
var rootCmd = &cobra.Command{
Use: "oo",
Short: "OnlyOffice Workspace CLI — subject-based command tree",
- Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications, catalog).",
+ Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications, catalog, invoices).",
Version: version,
SilenceUsage: true,
SilenceErrors: false,
diff --git a/cmd/oo/contacts.go b/cmd/oo/contacts.go
index 5d838c6..aa0f533 100644
--- a/cmd/oo/contacts.go
+++ b/cmd/oo/contacts.go
@@ -201,7 +201,7 @@ func contactsInfoAddCmd() *cobra.Command {
}
func personsCreateCmd() *cobra.Command {
- var first, last, email, linkedin string
+ var first, last, email, linkedin, phone string
var companyID int
var jobTitle, about string
cmd := &cobra.Command{
@@ -224,6 +224,9 @@ func personsCreateCmd() *cobra.Command {
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
}
+ if phone != "" {
+ _, _ = c.AddContactInfo(cmd.Context(), pid, "Phone", phone, "Work", true)
+ }
if linkedin != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
}
@@ -237,6 +240,7 @@ func personsCreateCmd() *cobra.Command {
cmd.Flags().StringVar(&jobTitle, "job-title", "", "")
cmd.Flags().StringVar(&about, "about", "", "about / bio")
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
+ cmd.Flags().StringVar(&phone, "phone", "", "primary phone (adds ContactInfo)")
cmd.Flags().StringVar(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)")
return cmd
}
@@ -347,7 +351,7 @@ func contactEmailsFromMap(p map[string]any) []string {
}
func companiesCreateCmd() *cobra.Command {
- var name, email, website string
+ var name, email, website, phone, about, street, city, state, zip, country string
cmd := &cobra.Command{
Use: "create",
Aliases: []string{"add"},
@@ -365,12 +369,34 @@ func companiesCreateCmd() *cobra.Command {
return err
}
cid := strconv.Itoa(int(flexIDFloat(out["id"])))
+ if about != "" || street != "" {
+ aboutText := about
+ if street != "" {
+ addr := strings.TrimSpace(strings.Join([]string{street, zip, city, state, country}, ", "))
+ if aboutText != "" {
+ aboutText = aboutText + "\n" + addr
+ } else {
+ aboutText = "Billing address: " + addr
+ }
+ }
+ if updated, err := c.UpdateCompany(cmd.Context(), cid, name, aboutText); err == nil {
+ out = updated
+ }
+ }
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
}
if website != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false)
}
+ if phone != "" {
+ _, _ = c.AddContactInfo(cmd.Context(), cid, "Phone", phone, "Work", true)
+ }
+ if street != "" {
+ if _, err := c.AddContactAddress(cmd.Context(), cid, street, city, state, zip, country, "Billing", true); err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), "warning: address API failed (stored in about): %v\n", err)
+ }
+ }
printObject(out)
return nil
},
@@ -378,6 +404,13 @@ func companiesCreateCmd() *cobra.Command {
cmd.Flags().StringVar(&name, "name", "", "company name")
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
+ cmd.Flags().StringVar(&phone, "phone", "", "primary phone (adds ContactInfo)")
+ cmd.Flags().StringVar(&about, "about", "", "about / notes")
+ cmd.Flags().StringVar(&street, "street", "", "billing street")
+ cmd.Flags().StringVar(&city, "city", "", "billing city")
+ cmd.Flags().StringVar(&state, "state", "", "billing state")
+ cmd.Flags().StringVar(&zip, "zip", "", "billing zip")
+ cmd.Flags().StringVar(&country, "country", "", "billing country")
return cmd
}
diff --git a/cmd/oo/invoices.go b/cmd/oo/invoices.go
new file mode 100644
index 0000000..82e6fe3
--- /dev/null
+++ b/cmd/oo/invoices.go
@@ -0,0 +1,269 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ onlyoffice "github.com/eslider/go-onlyoffice"
+ "github.com/spf13/cobra"
+)
+
+var invoicesCmd = &cobra.Command{
+ Use: "invoices",
+ Aliases: []string{"invoice"},
+ Short: "CRM invoices (billing)",
+}
+
+func init() {
+ rootCmd.AddCommand(invoicesCmd)
+ invoicesCmd.AddCommand(invoiceListCmd())
+ invoicesCmd.AddCommand(invoiceGetCmd())
+ invoicesCmd.AddCommand(invoiceCreateCmd())
+ invoicesCmd.AddCommand(invoiceDeleteCmd())
+ invoicesCmd.AddCommand(invoiceItemsCmd())
+}
+
+func invoiceListCmd() *cobra.Command {
+ var count, offset int
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List CRM invoices",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ list, total, err := c.ListInvoices(cmd.Context(), count, offset)
+ if err != nil {
+ return err
+ }
+ rows := make([]map[string]any, 0, len(list))
+ for _, inv := range list {
+ rows = append(rows, onlyoffice.FlattenInvoiceRow(inv))
+ }
+ if outputFormat == "table" {
+ fmt.Printf("total: %d (shown: %d)\n", total, len(rows))
+ }
+ printTable([]string{"id", "number", "cost", "statusTitle", "contactName", "issueDate", "dueDate"}, rows)
+ return nil
+ },
+ }
+ cmd.Flags().IntVar(&count, "count", 50, "")
+ cmd.Flags().IntVar(&offset, "offset", 0, "")
+ return cmd
+}
+
+func invoiceGetCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "get INVOICE_ID",
+ Short: "Show an invoice by id (incl. lines)",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ out, err := c.GetInvoice(cmd.Context(), args[0])
+ if err != nil {
+ return err
+ }
+ printObject(out)
+ return nil
+ },
+ }
+}
+
+func invoiceCreateCmd() *cobra.Command {
+ var (
+ number, issueDate, dueDate, language, currency, terms, description, po string
+ contactID, itemID int64
+ price, qty float64
+ lineDesc string
+ )
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a draft invoice with one line",
+ Long: `Create a CRM invoice (Draft) with a single line.
+
+Example:
+ oo invoices create --number INV-2026-01 --contact 123 --item 10 \
+ --price 300 --description "Service package" \
+ --terms "…payment terms…"
+`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if number == "" || contactID == 0 || itemID == 0 {
+ return fmt.Errorf("--number, --contact and --item are required")
+ }
+ if issueDate == "" {
+ issueDate = time.Now().Format("2006-01-02") + "T00:00:00.0000000+01:00"
+ } else if !strings.Contains(issueDate, "T") {
+ issueDate = issueDate + "T00:00:00.0000000+01:00"
+ }
+ if dueDate == "" {
+ dueDate = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02") + "T00:00:00.0000000+01:00"
+ } else if !strings.Contains(dueDate, "T") {
+ dueDate = dueDate + "T00:00:00.0000000+01:00"
+ }
+ if qty == 0 {
+ qty = 1
+ }
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ out, err := c.CreateInvoice(cmd.Context(), onlyoffice.CreateInvoiceParams{
+ Number: number,
+ IssueDate: issueDate,
+ DueDate: dueDate,
+ ContactID: contactID,
+ Language: language,
+ Currency: currency,
+ ExchangeRate: 1,
+ PurchaseOrderNumber: po,
+ Terms: terms,
+ Description: description,
+ Lines: []onlyoffice.InvoiceLine{{
+ InvoiceItemID: itemID,
+ Description: lineDesc,
+ Quantity: qty,
+ Price: price,
+ SortOrder: 0,
+ }},
+ })
+ if err != nil {
+ return err
+ }
+ printObject(out)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&number, "number", "", "invoice number (e.g. INV-2026-01)")
+ cmd.Flags().Int64Var(&contactID, "contact", 0, "bill-to contact id (company preferred)")
+ cmd.Flags().Int64Var(&itemID, "item", 0, "catalog invoice item id")
+ cmd.Flags().Float64Var(&price, "price", 0, "line price")
+ cmd.Flags().Float64Var(&qty, "qty", 1, "line quantity")
+ cmd.Flags().StringVar(&lineDesc, "line-description", "", "line description override")
+ cmd.Flags().StringVar(&issueDate, "issue-date", "", "YYYY-MM-DD (default today)")
+ cmd.Flags().StringVar(&dueDate, "due-date", "", "YYYY-MM-DD (default +14d)")
+ cmd.Flags().StringVar(&language, "language", "de-DE", "invoice language")
+ cmd.Flags().StringVar(¤cy, "currency", "EUR", "currency abbreviation")
+ cmd.Flags().StringVar(&terms, "terms", "", "payment terms / footer")
+ cmd.Flags().StringVar(&description, "description", "", "invoice description")
+ cmd.Flags().StringVar(&po, "po", "", "purchase order number")
+ return cmd
+}
+
+func invoiceDeleteCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "delete INVOICE_ID [INVOICE_ID...]",
+ Short: "Delete one or more invoices",
+ Args: cobra.MinimumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ for _, id := range args {
+ if _, err := c.DeleteInvoice(cmd.Context(), id); err != nil {
+ return err
+ }
+ fmt.Println("deleted", id)
+ }
+ return nil
+ },
+ }
+}
+
+func invoiceItemsCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "items",
+ Aliases: []string{"item"},
+ Short: "Invoice catalog items",
+ }
+ cmd.AddCommand(invoiceItemsListCmd())
+ cmd.AddCommand(invoiceItemsCreateCmd())
+ cmd.AddCommand(invoiceItemsDeleteCmd())
+ return cmd
+}
+
+func invoiceItemsListCmd() *cobra.Command {
+ var count, offset int
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List catalog invoice items",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ list, total, err := c.ListInvoiceItems(cmd.Context(), count, offset)
+ if err != nil {
+ return err
+ }
+ if outputFormat == "table" {
+ fmt.Printf("total: %d (shown: %d)\n", total, len(list))
+ for _, row := range list {
+ if cur, ok := row["currency"].(map[string]any); ok {
+ row["currency"] = cur["abbreviation"]
+ }
+ }
+ }
+ printTable([]string{"id", "title", "price", "currency", "description"}, list)
+ return nil
+ },
+ }
+ cmd.Flags().IntVar(&count, "count", 50, "")
+ cmd.Flags().IntVar(&offset, "offset", 0, "")
+ return cmd
+}
+
+func invoiceItemsCreateCmd() *cobra.Command {
+ var title, description, currency string
+ var price float64
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a catalog invoice item",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if title == "" {
+ return fmt.Errorf("--title is required")
+ }
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ out, err := c.CreateInvoiceItem(cmd.Context(), title, description, price, currency)
+ if err != nil {
+ return err
+ }
+ printObject(out)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&title, "title", "", "item title")
+ cmd.Flags().StringVar(&description, "description", "", "item description")
+ cmd.Flags().Float64Var(&price, "price", 0, "unit price")
+ cmd.Flags().StringVar(¤cy, "currency", "EUR", "currency")
+ return cmd
+}
+
+func invoiceItemsDeleteCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "delete ITEM_ID [ITEM_ID...]",
+ Short: "Delete catalog invoice items",
+ Args: cobra.MinimumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ for _, id := range args {
+ if _, err := c.DeleteInvoiceItem(cmd.Context(), id); err != nil {
+ return err
+ }
+ fmt.Println("deleted", id)
+ }
+ return nil
+ },
+ }
+}
diff --git a/cmd/oo/main.go b/cmd/oo/main.go
index 4300879..e4825ab 100644
--- a/cmd/oo/main.go
+++ b/cmd/oo/main.go
@@ -14,6 +14,7 @@
// oo crm-tasks list | create | delete | categories
// oo crm cleanup
// oo mails accounts | folders | list | get | delete
+// oo invoices list | get | create | delete | items (list|create|delete)
// oo applications sync
// oo catalog scan-contacts | scan-projects | scan-thunderbird | merge | match | apply
//
diff --git a/cmd/oo/opportunities.go b/cmd/oo/opportunities.go
index b20e8e5..b65632f 100644
--- a/cmd/oo/opportunities.go
+++ b/cmd/oo/opportunities.go
@@ -19,6 +19,7 @@ func init() {
opportunitiesCmd.AddCommand(oppListCmd())
opportunitiesCmd.AddCommand(oppGetCmd())
opportunitiesCmd.AddCommand(oppCreateCmd())
+ opportunitiesCmd.AddCommand(oppUpdateCmd())
opportunitiesCmd.AddCommand(oppDeleteCmd())
opportunitiesCmd.AddCommand(oppStagesCmd())
opportunitiesCmd.AddCommand(oppMemberAddCmd())
@@ -118,6 +119,40 @@ func oppCreateCmd() *cobra.Command {
return cmd
}
+func oppUpdateCmd() *cobra.Command {
+ var title, desc string
+ var stage int64
+ var bid float64
+ cmd := &cobra.Command{
+ Use: "update OPPORTUNITY_ID",
+ Short: "Update opportunity title, description, stage, or bid",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ out, err := c.UpdateOpportunity(cmd.Context(), args[0], onlyoffice.UpdateOpportunityParams{
+ Title: title,
+ Description: desc,
+ StageID: stage,
+ BidValue: bid,
+ BidValueSet: cmd.Flags().Changed("bid"),
+ })
+ if err != nil {
+ return err
+ }
+ printObject(out)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&title, "title", "", "new title")
+ cmd.Flags().StringVar(&desc, "description", "", "new description")
+ cmd.Flags().Int64Var(&stage, "stage", 0, "pipeline stage id")
+ cmd.Flags().Float64Var(&bid, "bid", 0, "bid value")
+ return cmd
+}
+
func oppDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]",
diff --git a/http.go b/http.go
index 3d94999..570286f 100644
--- a/http.go
+++ b/http.go
@@ -244,6 +244,58 @@ func (c *Client) putJSONObject(ctx context.Context, path string, body any) (map[
return unmarshalResponseObject(raw)
}
+// postJSONObject issues an authenticated POST with JSON body and decodes response.
+func (c *Client) postJSONObject(ctx context.Context, path string, body any) (map[string]any, error) {
+ raw, err := c.postJSON(ctx, path, body)
+ if err != nil {
+ return nil, err
+ }
+ return unmarshalResponseObject(raw)
+}
+
+// postJSON issues an authenticated POST with application/json body.
+func (c *Client) postJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
+ auth, err := c.authHeader()
+ if err != nil {
+ return nil, err
+ }
+ var rdr io.Reader
+ switch b := body.(type) {
+ case nil:
+ rdr = strings.NewReader("{}")
+ case []byte:
+ rdr = bytes.NewReader(b)
+ case string:
+ rdr = strings.NewReader(b)
+ default:
+ buf, err := json.Marshal(b)
+ if err != nil {
+ return nil, err
+ }
+ rdr = bytes.NewReader(buf)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, rdr)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", auth)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode >= 400 {
+ return nil, fmt.Errorf("POST JSON %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
+ }
+ return raw, nil
+}
+
// putJSON issues an authenticated PUT with application/json body.
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
auth, err := c.authHeader()
diff --git a/invoices.go b/invoices.go
new file mode 100644
index 0000000..0918e3d
--- /dev/null
+++ b/invoices.go
@@ -0,0 +1,264 @@
+package onlyoffice
+
+// CRM Invoices and invoice catalog items (billing).
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+// InvoiceLine is one line on a create-invoice request.
+type InvoiceLine struct {
+ InvoiceItemID int64 `json:"invoiceItemID"`
+ InvoiceTax1ID int64 `json:"invoiceTax1ID"`
+ InvoiceTax2ID int64 `json:"invoiceTax2ID"`
+ Description string `json:"description"`
+ Quantity float64 `json:"quantity"`
+ Price float64 `json:"price"`
+ Discount float64 `json:"discount"`
+ SortOrder int `json:"sortOrder"`
+}
+
+// CreateInvoiceParams holds fields for POST /api/2.0/crm/invoice.
+type CreateInvoiceParams struct {
+ Number string
+ IssueDate string // ISO-8601
+ DueDate string // ISO-8601
+ ContactID int64
+ ConsigneeID int64 // 0 = omit
+ Language string
+ Currency string
+ ExchangeRate float64
+ PurchaseOrderNumber string
+ Terms string
+ Description string
+ TemplateType int
+ Lines []InvoiceLine
+}
+
+// ListInvoices returns a page of CRM invoices and the total count.
+func (c *Client) ListInvoices(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
+ q := url.Values{}
+ q.Set("count", strconv.Itoa(count))
+ q.Set("startIndex", strconv.Itoa(startIndex))
+ raw, err := c.getJSON(ctx, "/api/2.0/crm/invoice/filter.json?"+q.Encode())
+ if err != nil {
+ return nil, 0, err
+ }
+ var env struct {
+ Response []map[string]any `json:"response"`
+ Total int `json:"total"`
+ }
+ if err := json.Unmarshal(raw, &env); err != nil {
+ return nil, 0, err
+ }
+ total := env.Total
+ if total == 0 && len(env.Response) > 0 {
+ total = len(env.Response)
+ }
+ return env.Response, total, nil
+}
+
+// GetInvoice returns a single invoice by id (includes invoiceLines).
+func (c *Client) GetInvoice(ctx context.Context, id string) (map[string]any, error) {
+ return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s.json", url.PathEscape(id)))
+}
+
+// CreateInvoice creates a draft invoice with the given lines.
+func (c *Client) CreateInvoice(ctx context.Context, p CreateInvoiceParams) (map[string]any, error) {
+ if p.ContactID == 0 {
+ return nil, fmt.Errorf("contactId is required")
+ }
+ if p.Number == "" {
+ return nil, fmt.Errorf("number is required")
+ }
+ if len(p.Lines) == 0 {
+ return nil, fmt.Errorf("at least one invoice line is required")
+ }
+ if p.Language == "" {
+ p.Language = "de-DE"
+ }
+ if p.Currency == "" {
+ p.Currency = "EUR"
+ }
+ if p.ExchangeRate == 0 {
+ p.ExchangeRate = 1
+ }
+ body := map[string]any{
+ "number": p.Number,
+ "issueDate": p.IssueDate,
+ "dueDate": p.DueDate,
+ "contactId": p.ContactID,
+ "language": p.Language,
+ "currency": p.Currency,
+ "exchangeRate": p.ExchangeRate,
+ "purchaseOrderNumber": p.PurchaseOrderNumber,
+ "terms": p.Terms,
+ "description": p.Description,
+ "templateType": p.TemplateType,
+ "invoiceLines": p.Lines,
+ }
+ if p.ConsigneeID != 0 {
+ body["consigneeId"] = p.ConsigneeID
+ }
+ return c.postJSONObject(ctx, "/api/2.0/crm/invoice", body)
+}
+
+// DeleteInvoice removes an invoice by id.
+func (c *Client) DeleteInvoice(ctx context.Context, id string) (map[string]any, error) {
+ return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s.json", url.PathEscape(id)))
+}
+
+// ListInvoiceItems returns catalog invoice items.
+func (c *Client) ListInvoiceItems(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
+ q := url.Values{}
+ q.Set("count", strconv.Itoa(count))
+ q.Set("startIndex", strconv.Itoa(startIndex))
+ raw, err := c.getJSON(ctx, "/api/2.0/crm/invoiceitem/filter.json?"+q.Encode())
+ if err != nil {
+ return nil, 0, err
+ }
+ var env struct {
+ Response []map[string]any `json:"response"`
+ Total int `json:"total"`
+ }
+ if err := json.Unmarshal(raw, &env); err != nil {
+ return nil, 0, err
+ }
+ total := env.Total
+ if total == 0 && len(env.Response) > 0 {
+ total = len(env.Response)
+ }
+ return env.Response, total, nil
+}
+
+// CreateInvoiceItem creates a reusable catalog line item.
+func (c *Client) CreateInvoiceItem(ctx context.Context, title, description string, price float64, currency string) (map[string]any, error) {
+ if title == "" {
+ return nil, fmt.Errorf("title is required")
+ }
+ if currency == "" {
+ currency = "EUR"
+ }
+ fields := url.Values{}
+ fields.Set("title", title)
+ fields.Set("description", description)
+ fields.Set("price", strconv.FormatFloat(price, 'f', 2, 64))
+ fields.Set("currency", currency)
+ fields.Set("stockKeepingUnit", "")
+ fields.Set("trackInventory", "false")
+ fields.Set("quantity", "0")
+ fields.Set("invoiceTax1ID", "0")
+ fields.Set("invoiceTax2ID", "0")
+ return c.postFormObject(ctx, "/api/2.0/crm/invoiceitem", fields)
+}
+
+// DeleteInvoiceItem removes a catalog invoice item.
+func (c *Client) DeleteInvoiceItem(ctx context.Context, id string) (map[string]any, error) {
+ return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/invoiceitem/%s.json", url.PathEscape(id)))
+}
+
+// AddContactAddress attaches a postal address to a contact.
+// category: Home|Postal|Office|Billing|Other|Work (or numeric string).
+func (c *Client) AddContactAddress(ctx context.Context, contactID, street, city, state, zip, country, category string, isPrimary bool) (map[string]any, error) {
+ if category == "" {
+ category = "Billing"
+ }
+ fields := url.Values{}
+ fields.Set("street", street)
+ fields.Set("city", city)
+ fields.Set("state", state)
+ fields.Set("zip", zip)
+ fields.Set("country", country)
+ fields.Set("category", category)
+ fields.Set("isPrimary", strconv.FormatBool(isPrimary))
+ return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/address", url.PathEscape(contactID)), fields)
+}
+
+// UpdateCompany updates company name and optional about text.
+func (c *Client) UpdateCompany(ctx context.Context, companyID, name, about string) (map[string]any, error) {
+ fields := url.Values{}
+ fields.Set("companyName", name)
+ if about != "" {
+ fields.Set("about", about)
+ }
+ return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s.json", url.PathEscape(companyID)), fields)
+}
+
+// UpdateOpportunityParams are optional fields for UpdateOpportunity.
+// Zero/empty values mean "keep existing" except BidValueSet.
+type UpdateOpportunityParams struct {
+ Title string
+ Description string
+ StageID int64
+ BidValue float64
+ BidValueSet bool
+}
+
+// UpdateOpportunity loads a deal and PUTs updated fields.
+func (c *Client) UpdateOpportunity(ctx context.Context, id string, p UpdateOpportunityParams) (map[string]any, error) {
+ opp, err := c.GetOpportunity(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ title := p.Title
+ if title == "" {
+ title = stringField(opp, "title")
+ }
+ body := opportunityUpdateBody(opp, title)
+ if p.Description != "" {
+ body["description"] = p.Description
+ }
+ if p.StageID != 0 {
+ body["stageid"] = p.StageID
+ }
+ if p.BidValueSet {
+ body["bidValue"] = p.BidValue
+ }
+ return c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)), body)
+}
+
+// InvoiceContactName extracts displayName from nested contact on an invoice row.
+func InvoiceContactName(inv map[string]any) string {
+ if m, ok := inv["contact"].(map[string]any); ok {
+ return stringField(m, "displayName")
+ }
+ return ""
+}
+
+// InvoiceStatusTitle extracts status.title from an invoice row.
+func InvoiceStatusTitle(inv map[string]any) string {
+ if m, ok := inv["status"].(map[string]any); ok {
+ return stringField(m, "title")
+ }
+ return ""
+}
+
+// FlattenInvoiceRow copies nested fields for table output.
+func FlattenInvoiceRow(inv map[string]any) map[string]any {
+ out := map[string]any{}
+ for k, v := range inv {
+ out[k] = v
+ }
+ out["contactName"] = InvoiceContactName(inv)
+ out["statusTitle"] = InvoiceStatusTitle(inv)
+ if cur, ok := inv["currency"].(map[string]any); ok {
+ out["currency"] = stringField(cur, "abbreviation")
+ }
+ return out
+}
+
+// FindInvoiceItemByTitle returns the first catalog item with an exact title match.
+func FindInvoiceItemByTitle(items []map[string]any, title string) map[string]any {
+ want := strings.TrimSpace(title)
+ for _, it := range items {
+ if strings.TrimSpace(stringField(it, "title")) == want {
+ return it
+ }
+ }
+ return nil
+}
From 3201a58df75bceb7d41034970cf384a9169163ed Mon Sep 17 00:00:00 2001
From: Andriy Oblivantsev
Date: Thu, 6 Aug 2026 12:45:09 +0100
Subject: [PATCH 2/8] fix(crm): link invoices to opportunities
Create/update accept entityId (opportunity); oo invoices create
--opportunity and invoices update --opportunity so deal #invoices
tabs show the draft.
---
cmd/oo/invoices.go | 37 +++++++++++++++-
invoices.go | 108 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 143 insertions(+), 2 deletions(-)
diff --git a/cmd/oo/invoices.go b/cmd/oo/invoices.go
index 82e6fe3..5d55523 100644
--- a/cmd/oo/invoices.go
+++ b/cmd/oo/invoices.go
@@ -20,6 +20,7 @@ func init() {
invoicesCmd.AddCommand(invoiceListCmd())
invoicesCmd.AddCommand(invoiceGetCmd())
invoicesCmd.AddCommand(invoiceCreateCmd())
+ invoicesCmd.AddCommand(invoiceUpdateCmd())
invoicesCmd.AddCommand(invoiceDeleteCmd())
invoicesCmd.AddCommand(invoiceItemsCmd())
}
@@ -77,7 +78,7 @@ func invoiceGetCmd() *cobra.Command {
func invoiceCreateCmd() *cobra.Command {
var (
number, issueDate, dueDate, language, currency, terms, description, po string
- contactID, itemID int64
+ contactID, itemID, opportunityID int64
price, qty float64
lineDesc string
)
@@ -88,7 +89,7 @@ func invoiceCreateCmd() *cobra.Command {
Example:
oo invoices create --number INV-2026-01 --contact 123 --item 10 \
- --price 300 --description "Service package" \
+ --price 300 --opportunity OPPORTUNITY_ID --line-description "Service package" \
--terms "…payment terms…"
`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -117,6 +118,8 @@ Example:
IssueDate: issueDate,
DueDate: dueDate,
ContactID: contactID,
+ EntityID: opportunityID,
+ EntityType: 0, // Opportunity
Language: language,
Currency: currency,
ExchangeRate: 1,
@@ -140,6 +143,7 @@ Example:
}
cmd.Flags().StringVar(&number, "number", "", "invoice number (e.g. INV-2026-01)")
cmd.Flags().Int64Var(&contactID, "contact", 0, "bill-to contact id (company preferred)")
+ cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "link to CRM opportunity/deal id")
cmd.Flags().Int64Var(&itemID, "item", 0, "catalog invoice item id")
cmd.Flags().Float64Var(&price, "price", 0, "line price")
cmd.Flags().Float64Var(&qty, "qty", 1, "line quantity")
@@ -154,6 +158,35 @@ Example:
return cmd
}
+func invoiceUpdateCmd() *cobra.Command {
+ var opportunityID int64
+ cmd := &cobra.Command{
+ Use: "update INVOICE_ID",
+ Short: "Update invoice (link to opportunity)",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if opportunityID == 0 {
+ return fmt.Errorf("--opportunity is required")
+ }
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ out, err := c.UpdateInvoice(cmd.Context(), args[0], onlyoffice.UpdateInvoiceParams{
+ EntityID: opportunityID,
+ EntityType: 0,
+ })
+ if err != nil {
+ return err
+ }
+ printObject(out)
+ return nil
+ },
+ }
+ cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "CRM opportunity/deal id to link")
+ return cmd
+}
+
func invoiceDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete INVOICE_ID [INVOICE_ID...]",
diff --git a/invoices.go b/invoices.go
index 0918e3d..4e06f35 100644
--- a/invoices.go
+++ b/invoices.go
@@ -30,6 +30,8 @@ type CreateInvoiceParams struct {
DueDate string // ISO-8601
ContactID int64
ConsigneeID int64 // 0 = omit
+ EntityID int64 // 0 = omit; link to opportunity/case
+ EntityType int // OnlyOffice EntityType; 0 = Opportunity
Language string
Currency string
ExchangeRate float64
@@ -105,9 +107,115 @@ func (c *Client) CreateInvoice(ctx context.Context, p CreateInvoiceParams) (map[
if p.ConsigneeID != 0 {
body["consigneeId"] = p.ConsigneeID
}
+ if p.EntityID != 0 {
+ body["entityId"] = p.EntityID
+ body["entityType"] = p.EntityType // 0 = Opportunity on this portal
+ }
return c.postJSONObject(ctx, "/api/2.0/crm/invoice", body)
}
+// UpdateInvoiceParams are fields for PUT /api/2.0/crm/invoice/{id}.
+// Loads the current invoice and merges non-zero entity / contact updates.
+type UpdateInvoiceParams struct {
+ EntityID int64 // link to opportunity; 0 = leave unchanged
+ EntityType int
+ ContactID int64 // 0 = leave unchanged
+}
+
+// UpdateInvoice PUTs a full invoice body (OnlyOffice requires complete payload).
+func (c *Client) UpdateInvoice(ctx context.Context, id string, p UpdateInvoiceParams) (map[string]any, error) {
+ inv, err := c.GetInvoice(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ contactID := int64(0)
+ if m, ok := inv["contact"].(map[string]any); ok {
+ contactID = flexInt(m["id"])
+ }
+ if p.ContactID != 0 {
+ contactID = p.ContactID
+ }
+ cur := "EUR"
+ if m, ok := inv["currency"].(map[string]any); ok {
+ if a := stringField(m, "abbreviation"); a != "" {
+ cur = a
+ }
+ }
+ statusID := 1
+ if m, ok := inv["status"].(map[string]any); ok {
+ statusID = int(flexInt(m["id"]))
+ }
+ var lines []map[string]any
+ switch raw := inv["invoiceLines"].(type) {
+ case []any:
+ for _, row := range raw {
+ m, ok := row.(map[string]any)
+ if !ok {
+ continue
+ }
+ lines = append(lines, map[string]any{
+ "id": flexInt(m["id"]),
+ "invoiceItemID": flexInt(m["invoiceItemID"]),
+ "invoiceTax1ID": flexInt(m["invoiceTax1ID"]),
+ "invoiceTax2ID": flexInt(m["invoiceTax2ID"]),
+ "description": stringField(m, "description"),
+ "quantity": floatField(m, "quantity"),
+ "price": floatField(m, "price"),
+ "discount": floatField(m, "discount"),
+ "sortOrder": int(flexInt(m["sortOrder"])),
+ })
+ }
+ case []map[string]any:
+ for _, m := range raw {
+ lines = append(lines, map[string]any{
+ "id": flexInt(m["id"]),
+ "invoiceItemID": flexInt(m["invoiceItemID"]),
+ "invoiceTax1ID": flexInt(m["invoiceTax1ID"]),
+ "invoiceTax2ID": flexInt(m["invoiceTax2ID"]),
+ "description": stringField(m, "description"),
+ "quantity": floatField(m, "quantity"),
+ "price": floatField(m, "price"),
+ "discount": floatField(m, "discount"),
+ "sortOrder": int(flexInt(m["sortOrder"])),
+ })
+ }
+ }
+ body := map[string]any{
+ "id": flexInt(inv["id"]),
+ "number": stringField(inv, "number"),
+ "issueDate": stringField(inv, "issueDate"),
+ "dueDate": stringField(inv, "dueDate"),
+ "contactId": contactID,
+ "language": stringField(inv, "language"),
+ "currency": cur,
+ "exchangeRate": floatField(inv, "exchangeRate"),
+ "purchaseOrderNumber": stringField(inv, "purchaseOrderNumber"),
+ "terms": stringField(inv, "terms"),
+ "description": stringField(inv, "description"),
+ "templateType": int(flexInt(inv["templateType"])),
+ "status": statusID,
+ "invoiceLines": lines,
+ }
+ if p.EntityID != 0 {
+ body["entityId"] = p.EntityID
+ body["entityType"] = p.EntityType
+ } else if ent, ok := inv["entity"].(map[string]any); ok && ent != nil {
+ body["entityId"] = flexInt(ent["entityId"])
+ // API returns entityType as string ("opportunity"); create/update want int.
+ switch v := ent["entityType"].(type) {
+ case float64:
+ body["entityType"] = int(v)
+ case int:
+ body["entityType"] = v
+ case string:
+ if strings.EqualFold(v, "opportunity") {
+ body["entityType"] = 0
+ }
+ }
+ }
+ return c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s", url.PathEscape(id)), body)
+}
+
// DeleteInvoice removes an invoice by id.
func (c *Client) DeleteInvoice(ctx context.Context, id string) (map[string]any, error) {
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s.json", url.PathEscape(id)))
From d81e7de9114a29439142efba67f205653f562730 Mon Sep 17 00:00:00 2001
From: Andriy Oblivantsev
Date: Thu, 6 Aug 2026 12:47:06 +0100
Subject: [PATCH 3/8] feat(crm): invoice update notes and PO fields
Allow oo invoices update --description/--po so Notizen can be
multiline refs without portal URLs; keep opportunity link support.
---
cmd/oo/invoices.go | 17 ++++++++++++-----
invoices.go | 18 ++++++++++++++----
2 files changed, 26 insertions(+), 9 deletions(-)
diff --git a/cmd/oo/invoices.go b/cmd/oo/invoices.go
index 5d55523..3a2ff61 100644
--- a/cmd/oo/invoices.go
+++ b/cmd/oo/invoices.go
@@ -160,21 +160,26 @@ Example:
func invoiceUpdateCmd() *cobra.Command {
var opportunityID int64
+ var description, po string
cmd := &cobra.Command{
Use: "update INVOICE_ID",
- Short: "Update invoice (link to opportunity)",
+ Short: "Update invoice (opportunity link, notes, PO)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- if opportunityID == 0 {
- return fmt.Errorf("--opportunity is required")
+ if opportunityID == 0 && !cmd.Flags().Changed("description") && !cmd.Flags().Changed("po") {
+ return fmt.Errorf("set --opportunity and/or --description and/or --po")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.UpdateInvoice(cmd.Context(), args[0], onlyoffice.UpdateInvoiceParams{
- EntityID: opportunityID,
- EntityType: 0,
+ EntityID: opportunityID,
+ EntityType: 0,
+ Description: description,
+ DescriptionSet: cmd.Flags().Changed("description"),
+ PurchaseOrder: po,
+ PurchaseOrderSet: cmd.Flags().Changed("po"),
})
if err != nil {
return err
@@ -184,6 +189,8 @@ func invoiceUpdateCmd() *cobra.Command {
},
}
cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "CRM opportunity/deal id to link")
+ cmd.Flags().StringVar(&description, "description", "", "invoice notes (Notizen); use \\n for line breaks")
+ cmd.Flags().StringVar(&po, "po", "", "purchase order number")
return cmd
}
diff --git a/invoices.go b/invoices.go
index 4e06f35..f5c2fdd 100644
--- a/invoices.go
+++ b/invoices.go
@@ -115,11 +115,15 @@ func (c *Client) CreateInvoice(ctx context.Context, p CreateInvoiceParams) (map[
}
// UpdateInvoiceParams are fields for PUT /api/2.0/crm/invoice/{id}.
-// Loads the current invoice and merges non-zero entity / contact updates.
+// Loads the current invoice and merges non-zero / set fields.
type UpdateInvoiceParams struct {
- EntityID int64 // link to opportunity; 0 = leave unchanged
- EntityType int
- ContactID int64 // 0 = leave unchanged
+ EntityID int64 // link to opportunity; 0 = leave unchanged
+ EntityType int
+ ContactID int64 // 0 = leave unchanged
+ Description string // invoice notes; empty + DescriptionSet=false keeps existing
+ DescriptionSet bool
+ PurchaseOrder string
+ PurchaseOrderSet bool
}
// UpdateInvoice PUTs a full invoice body (OnlyOffice requires complete payload).
@@ -196,6 +200,12 @@ func (c *Client) UpdateInvoice(ctx context.Context, id string, p UpdateInvoicePa
"status": statusID,
"invoiceLines": lines,
}
+ if p.DescriptionSet {
+ body["description"] = p.Description
+ }
+ if p.PurchaseOrderSet {
+ body["purchaseOrderNumber"] = p.PurchaseOrder
+ }
if p.EntityID != 0 {
body["entityId"] = p.EntityID
body["entityType"] = p.EntityType
From 73b1050a83e9e49dcf99d8a966a84746376d7dd9 Mon Sep 17 00:00:00 2001
From: Andriy Oblivantsev
Date: Thu, 6 Aug 2026 13:18:02 +0100
Subject: [PATCH 4/8] feat(mail): draft, attach, and draft-invoice CLI
Save OO Mail drafts via body field, attach Files documents, and one-shot
invoice PDF regenerate + draft for client billing emails.
---
README.md | 12 +++-
cmd/oo/mails.go | 176 +++++++++++++++++++++++++++++++++++++++++++++++-
mails.go | 148 +++++++++++++++++++++++++++++++++++++++-
mails_test.go | 25 ++++++-
4 files changed, 357 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 78ff066..3fab0de 100644
--- a/README.md
+++ b/README.md
@@ -631,7 +631,7 @@ oo tasks files detach 208 12345
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
| `crm` | `cleanup` |
-| `mails` | `accounts`, `folders`, `list`, `get`, `delete` |
+| `mails` | `accounts`, `folders`, `list`, `get`, `draft`, `attach`, `draft-invoice`, `delete` |
| `cases` | `list`, `create`, `delete`, `member-add` |
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
| `applications` | `sync` |
@@ -821,6 +821,16 @@ oo mails get 5664 -o json | jq '{subject, from, to, date}'
# Remove one or more messages (server moves to trash or deletes per Mail rules)
oo mails delete 5664
oo mails delete 5664 5663 5661
+
+# Create / update a draft (HTML body field is API "body"; plain text is wrapped)
+oo mails draft --to client@example.com --subject "Rechnung INV-2026-01" \
+ --body "Guten Tag,\n\nanbei die Rechnung.\n\nMit freundlichen Grüßen"
+
+# Attach an OnlyOffice Files document (e.g. invoice PDF file id) to a draft
+oo mails attach 7301 --file-id 12345
+
+# Regenerate invoice PDF + draft + attach (does not send)
+oo mails draft-invoice --invoice 16 --to info@example.com
```
**Table output** splits the `from` header into `fromName` and `fromAddress`
diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go
index 4e7f254..9dd238c 100644
--- a/cmd/oo/mails.go
+++ b/cmd/oo/mails.go
@@ -1,6 +1,7 @@
package main
import (
+ "fmt"
"strconv"
"strings"
@@ -11,7 +12,7 @@ import (
var mailsCmd = &cobra.Command{
Use: "mails",
Aliases: []string{"mail"},
- Short: "OnlyOffice Workspace mail — list, read, delete",
+ Short: "OnlyOffice Workspace mail — list, read, draft, delete",
}
func init() {
@@ -20,6 +21,9 @@ func init() {
mailsCmd.AddCommand(mailsFoldersCmd())
mailsCmd.AddCommand(mailsListCmd())
mailsCmd.AddCommand(mailsGetCmd())
+ mailsCmd.AddCommand(mailsDraftCmd())
+ mailsCmd.AddCommand(mailsAttachCmd())
+ mailsCmd.AddCommand(mailsDraftInvoiceCmd())
mailsCmd.AddCommand(mailsDeleteCmd())
}
@@ -126,6 +130,176 @@ func mailsGetCmd() *cobra.Command {
}
}
+func mailsDraftCmd() *cobra.Command {
+ var from, to, cc, bcc, subject, body, html string
+ var id int64
+ cmd := &cobra.Command{
+ Use: "draft",
+ Short: "Create or update a mail draft (OnlyOffice Mail)",
+ Long: `Save a draft in /addons/mail (PUT /api/2.0/mail/drafts/save).
+
+ oo mails draft --to a@b.com --subject "…" --body "…
"
+ oo mails draft --id 123 --to a@b.com --subject "…" --html "…
"
+`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if to == "" {
+ return fmt.Errorf("--to is required")
+ }
+ htmlBody := body
+ if html != "" {
+ htmlBody = html
+ }
+ if htmlBody == "" {
+ return fmt.Errorf("--body or --html is required")
+ }
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ out, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{
+ ID: id,
+ From: from,
+ To: to,
+ Cc: cc,
+ Bcc: bcc,
+ Subject: subject,
+ Body: onlyoffice.PlainTextToMailHTML(htmlBody),
+ })
+ if err != nil {
+ return err
+ }
+ printObject(out)
+ return nil
+ },
+ }
+ cmd.Flags().Int64Var(&id, "id", 0, "existing draft id (0 = create)")
+ cmd.Flags().StringVar(&from, "from", "", "from address (default: first enabled mailbox)")
+ cmd.Flags().StringVar(&to, "to", "", "recipient (required)")
+ cmd.Flags().StringVar(&cc, "cc", "", "cc")
+ cmd.Flags().StringVar(&bcc, "bcc", "", "bcc")
+ cmd.Flags().StringVar(&subject, "subject", "", "subject")
+ cmd.Flags().StringVar(&body, "body", "", "plain text or HTML body")
+ cmd.Flags().StringVar(&html, "html", "", "HTML body (alias of --body when set)")
+ return cmd
+}
+
+func mailsAttachCmd() *cobra.Command {
+ var fileID int64
+ cmd := &cobra.Command{
+ Use: "attach MESSAGE_ID",
+ Short: "Attach an OnlyOffice Files document to a draft/message",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if fileID <= 0 {
+ return fmt.Errorf("--file-id is required")
+ }
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ out, err := c.AttachMailDocument(cmd.Context(), args[0], fileID)
+ if err != nil {
+ return err
+ }
+ printObject(out)
+ return nil
+ },
+ }
+ cmd.Flags().Int64Var(&fileID, "file-id", 0, "OnlyOffice file id (e.g. invoice PDF)")
+ return cmd
+}
+
+func mailsDraftInvoiceCmd() *cobra.Command {
+ var to, from, subject, body string
+ var invoiceID int
+ cmd := &cobra.Command{
+ Use: "draft-invoice",
+ Short: "Create a mail draft with regenerated invoice PDF attached",
+ Long: `Regenerate the CRM invoice PDF, save an OnlyOffice Mail draft, and attach the PDF.
+
+Does not send. Open /addons/mail/#drafts to review.
+
+ oo mails draft-invoice --invoice 16 --to info@example.com
+`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if invoiceID <= 0 || to == "" {
+ return fmt.Errorf("--invoice and --to are required")
+ }
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ pdf, err := c.InvoicePDFFile(cmd.Context(), strconv.Itoa(invoiceID))
+ if err != nil {
+ return err
+ }
+ fileID := onlyoffice.Int64FromMap(pdf, "id")
+ if fileID <= 0 {
+ return fmt.Errorf("invoice PDF has no file id: %+v", pdf)
+ }
+ inv, err := c.GetInvoice(cmd.Context(), strconv.Itoa(invoiceID))
+ if err != nil {
+ return err
+ }
+ number := strings.TrimSpace(fmt.Sprint(inv["number"]))
+ cost := formatInvoiceCostEUR(inv["cost"])
+ if subject == "" {
+ subject = fmt.Sprintf("Rechnung %s — Service package (%s EUR)", number, cost)
+ }
+ if body == "" {
+ body = fmt.Sprintf(`Guten Tag,
+
+anbei übersende ich die Rechnung %s über %s EUR
+für das Service package (OS image inkl. Backup, Monitoring, RustDesk,
+gemeinsamem Datenträger und Netzwerkzugriff).
+
+Zahlungsziel: 14 Tage netto.
+Steuerhinweis: siehe Notizen auf der Rechnung (Reverse Charge / § 13b UStG).
+
+Mit freundlichen Grüßen
+Example Author`, number, cost)
+ }
+ draft, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{
+ From: from,
+ To: to,
+ Subject: subject,
+ Body: onlyoffice.PlainTextToMailHTML(body),
+ })
+ if err != nil {
+ return err
+ }
+ mid := fmt.Sprint(draft["id"])
+ att, err := c.AttachMailDocument(cmd.Context(), mid, fileID)
+ if err != nil {
+ return fmt.Errorf("draft %s created but attach failed: %w", mid, err)
+ }
+ if outputFormat == "json" {
+ printObject(map[string]any{"draft": draft, "attachment": att, "pdfFileId": fileID})
+ return nil
+ }
+ fmt.Printf("draft %s to=%s subject=%q pdfFileId=%d\n", mid, to, subject, fileID)
+ fmt.Printf("open: https://office.example.com/addons/mail/#drafts\n")
+ return nil
+ },
+ }
+ cmd.Flags().IntVar(&invoiceID, "invoice", 0, "CRM invoice id")
+ cmd.Flags().StringVar(&to, "to", "", "recipient")
+ cmd.Flags().StringVar(&from, "from", "", "from address (default mailbox)")
+ cmd.Flags().StringVar(&subject, "subject", "", "override subject")
+ cmd.Flags().StringVar(&body, "body", "", "override plain-text body")
+ return cmd
+}
+
+func formatInvoiceCostEUR(v any) string {
+ s := strings.TrimSpace(fmt.Sprint(v))
+ s = strings.TrimSuffix(s, ".00")
+ s = strings.TrimSuffix(s, ".0")
+ if s == "" || s == "" {
+ return "?"
+ }
+ return s
+}
+
func mailsDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete ID [ID...]",
diff --git a/mails.go b/mails.go
index ac73997..5ac48a4 100644
--- a/mails.go
+++ b/mails.go
@@ -5,6 +5,7 @@ package onlyoffice
import (
"context"
+ "encoding/json"
"fmt"
"net/mail"
"net/url"
@@ -96,11 +97,156 @@ func (c *Client) GetMailMessage(ctx context.Context, messageID string) (map[stri
}
// RemoveMailMessages deletes messages by id (PUT /api/2.0/mail/messages/remove).
+// The API response "response" field may be a number or object; success is HTTP 2xx.
func (c *Client) RemoveMailMessages(ctx context.Context, ids ...int) (map[string]any, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("RemoveMailMessages: at least one id is required")
}
- return c.putJSONObject(ctx, "/api/2.0/mail/messages/remove", map[string]any{"ids": ids})
+ raw, err := c.putJSON(ctx, "/api/2.0/mail/messages/remove", map[string]any{"ids": ids})
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]any{"ok": true, "ids": ids}
+ if resp, err := responseField(raw, "response"); err == nil {
+ var n json.Number
+ if json.Unmarshal(resp, &n) == nil {
+ out["response"] = n.String()
+ } else {
+ var m map[string]any
+ if json.Unmarshal(resp, &m) == nil {
+ out["response"] = m
+ } else {
+ out["response"] = string(resp)
+ }
+ }
+ }
+ return out, nil
+}
+
+// SaveMailDraftParams creates or updates a draft via PUT /api/2.0/mail/drafts/save.
+// Id 0 creates a new draft. Body is HTML; the API field name is "body" (not htmlBody).
+type SaveMailDraftParams struct {
+ ID int64 // 0 = create
+ From string // mailbox address, e.g. eslider@gmail.com
+ To string // comma-separated or single address
+ Cc string
+ Bcc string
+ Subject string
+ Body string // HTML
+}
+
+// SaveMailDraft saves a draft message. Returns the saved message map (incl. id).
+func (c *Client) SaveMailDraft(ctx context.Context, p SaveMailDraftParams) (map[string]any, error) {
+ if strings.TrimSpace(p.To) == "" {
+ return nil, fmt.Errorf("SaveMailDraft: to is required")
+ }
+ if strings.TrimSpace(p.From) == "" {
+ from, err := c.defaultMailFrom(ctx)
+ if err != nil {
+ return nil, err
+ }
+ p.From = from
+ }
+ body := map[string]any{
+ "id": p.ID,
+ "from": p.From,
+ "to": p.To,
+ "cc": p.Cc,
+ "bcc": p.Bcc,
+ "subject": p.Subject,
+ "body": p.Body,
+ }
+ return c.putJSONObject(ctx, "/api/2.0/mail/drafts/save", body)
+}
+
+func (c *Client) defaultMailFrom(ctx context.Context) (string, error) {
+ accounts, err := c.ListMailAccounts(ctx)
+ if err != nil {
+ return "", err
+ }
+ var fallback string
+ for _, a := range accounts {
+ email := strings.TrimSpace(stringFromMap(a, "email"))
+ if email == "" || email == "" {
+ continue
+ }
+ if boolField(a, "enabled") || boolField(a, "isDefault") {
+ return email, nil
+ }
+ if fallback == "" {
+ fallback = email
+ }
+ }
+ if fallback == "" {
+ return "", fmt.Errorf("SaveMailDraft: from is required (no mailbox on account)")
+ }
+ return fallback, nil
+}
+
+// AttachMailDocument attaches an OnlyOffice Files document to a mail message/draft.
+// fileID is the Documents/CRM file id (e.g. from InvoicePDFFile).
+func (c *Client) AttachMailDocument(ctx context.Context, messageID string, fileID int64) (map[string]any, error) {
+ id := strings.TrimSpace(messageID)
+ if id == "" {
+ return nil, fmt.Errorf("AttachMailDocument: message id is required")
+ }
+ if fileID <= 0 {
+ return nil, fmt.Errorf("AttachMailDocument: file id is required")
+ }
+ fields := url.Values{}
+ fields.Set("fileId", strconv.FormatInt(fileID, 10))
+ return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/mail/messages/%s/document", url.PathEscape(id)), fields)
+}
+
+// InvoicePDFFile regenerates/returns the invoice PDF file metadata (id, title, viewUrl).
+func (c *Client) InvoicePDFFile(ctx context.Context, invoiceID string) (map[string]any, error) {
+ id := strings.TrimSpace(invoiceID)
+ if id == "" {
+ return nil, fmt.Errorf("InvoicePDFFile: invoice id is required")
+ }
+ return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s/pdf", url.PathEscape(id)))
+}
+
+// PlainTextToMailHTML turns plain text into simple HTML paragraphs for drafts.
+// If s already looks like HTML, it is returned unchanged.
+func PlainTextToMailHTML(s string) string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return ""
+ }
+ if strings.Contains(s, "<") && strings.Contains(s, ">") {
+ return s
+ }
+ parts := strings.Split(s, "\n\n")
+ var b strings.Builder
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ b.WriteString("")
+ b.WriteString(strings.ReplaceAll(p, "\n", "
"))
+ b.WriteString("
\n")
+ }
+ return b.String()
+}
+
+// Int64FromMap coerces OnlyOffice numeric id fields (float64/int/string) to int64.
+func Int64FromMap(m map[string]any, key string) int64 {
+ switch v := m[key].(type) {
+ case float64:
+ return int64(v)
+ case int64:
+ return v
+ case int:
+ return int64(v)
+ case string:
+ n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
+ return n
+ default:
+ n, _ := strconv.ParseInt(fmt.Sprint(m[key]), 10, 64)
+ return n
+ }
}
// ResolveMailFolder maps a CLI folder name or numeric string to a folder id.
diff --git a/mails_test.go b/mails_test.go
index 3302f0c..89714b9 100644
--- a/mails_test.go
+++ b/mails_test.go
@@ -1,6 +1,9 @@
package onlyoffice
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestResolveMailFolder(t *testing.T) {
tests := []struct {
@@ -70,3 +73,23 @@ func TestMailMessagesAsTableRows(t *testing.T) {
t.Fatalf("got %+v", rows[0])
}
}
+
+func TestPlainTextToMailHTML(t *testing.T) {
+ got := PlainTextToMailHTML("Hello\n\nWorld\nline2")
+ if !strings.Contains(got, "Hello
") || !strings.Contains(got, "World
line2") {
+ t.Fatalf("got %q", got)
+ }
+ html := "Already
"
+ if PlainTextToMailHTML(html) != html {
+ t.Fatalf("html passthrough failed")
+ }
+}
+
+func TestInt64FromMap(t *testing.T) {
+ if Int64FromMap(map[string]any{"id": float64(99)}, "id") != 99 {
+ t.Fatal("float64")
+ }
+ if Int64FromMap(map[string]any{"id": "42"}, "id") != 42 {
+ t.Fatal("string")
+ }
+}
From 7f35b0e91075d871f87de95b6c84b2353e02f9cf Mon Sep 17 00:00:00 2001
From: Andriy Oblivantsev
Date: Thu, 6 Aug 2026 13:25:53 +0100
Subject: [PATCH 5/8] fix(mail): German spacing for invoice draft template
Separate paragraphs with blank lines; mailbox signature uses Synapse MXID.
---
cmd/oo/mails.go | 7 +++++--
mails.go | 10 ++++++++++
mails_test.go | 4 ++++
3 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go
index 9dd238c..02661bb 100644
--- a/cmd/oo/mails.go
+++ b/cmd/oo/mails.go
@@ -254,16 +254,19 @@ für das Service package (OS image inkl. Backup, Monitoring, RustDesk,
gemeinsamem Datenträger und Netzwerkzugriff).
Zahlungsziel: 14 Tage netto.
-Steuerhinweis: siehe Notizen auf der Rechnung (Reverse Charge / § 13b UStG).
+
+Steuerhinweis: siehe Notizen auf der Rechnung
+(Reverse Charge / § 13b UStG).
Mit freundlichen Grüßen
+
Example Author`, number, cost)
}
draft, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{
From: from,
To: to,
Subject: subject,
- Body: onlyoffice.PlainTextToMailHTML(body),
+ Body: onlyoffice.MailHTMLWithBlankParagraphs(onlyoffice.PlainTextToMailHTML(body)),
})
if err != nil {
return err
diff --git a/mails.go b/mails.go
index 5ac48a4..e5ab907 100644
--- a/mails.go
+++ b/mails.go
@@ -231,6 +231,16 @@ func PlainTextToMailHTML(s string) string {
return b.String()
}
+// MailHTMLWithBlankParagraphs inserts empty paragraphs between blocks
+// (German Geschäftsbrief-style vertical spacing in OnlyOffice Mail).
+func MailHTMLWithBlankParagraphs(html string) string {
+ html = strings.TrimSpace(html)
+ if html == "" {
+ return ""
+ }
+ return strings.ReplaceAll(html, "
\n", "
\n
\n")
+}
+
// Int64FromMap coerces OnlyOffice numeric id fields (float64/int/string) to int64.
func Int64FromMap(m map[string]any, key string) int64 {
switch v := m[key].(type) {
diff --git a/mails_test.go b/mails_test.go
index 89714b9..be81255 100644
--- a/mails_test.go
+++ b/mails_test.go
@@ -83,6 +83,10 @@ func TestPlainTextToMailHTML(t *testing.T) {
if PlainTextToMailHTML(html) != html {
t.Fatalf("html passthrough failed")
}
+ spaced := MailHTMLWithBlankParagraphs("
A
\nB
")
+ if spaced != "A
\n
\nB
" {
+ t.Fatalf("spaced: %q", spaced)
+ }
}
func TestInt64FromMap(t *testing.T) {
From 31c556d34cedd65d6957441f448520af9a7e380b Mon Sep 17 00:00:00 2001
From: Andriy Oblivantsev
Date: Thu, 6 Aug 2026 13:35:08 +0100
Subject: [PATCH 6/8] feat(crm): allow updating invoice terms via oo
Needed to put issuer NIE in Bedingungen without a full recreate.
---
cmd/oo/invoices.go | 11 +++++++----
invoices.go | 17 +++++++++++------
2 files changed, 18 insertions(+), 10 deletions(-)
diff --git a/cmd/oo/invoices.go b/cmd/oo/invoices.go
index 3a2ff61..8046fab 100644
--- a/cmd/oo/invoices.go
+++ b/cmd/oo/invoices.go
@@ -160,14 +160,14 @@ Example:
func invoiceUpdateCmd() *cobra.Command {
var opportunityID int64
- var description, po string
+ var description, po, terms string
cmd := &cobra.Command{
Use: "update INVOICE_ID",
- Short: "Update invoice (opportunity link, notes, PO)",
+ Short: "Update invoice (opportunity link, notes, PO, terms)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- if opportunityID == 0 && !cmd.Flags().Changed("description") && !cmd.Flags().Changed("po") {
- return fmt.Errorf("set --opportunity and/or --description and/or --po")
+ if opportunityID == 0 && !cmd.Flags().Changed("description") && !cmd.Flags().Changed("po") && !cmd.Flags().Changed("terms") {
+ return fmt.Errorf("set --opportunity and/or --description and/or --po and/or --terms")
}
c, err := newOO(cmd)
if err != nil {
@@ -180,6 +180,8 @@ func invoiceUpdateCmd() *cobra.Command {
DescriptionSet: cmd.Flags().Changed("description"),
PurchaseOrder: po,
PurchaseOrderSet: cmd.Flags().Changed("po"),
+ Terms: terms,
+ TermsSet: cmd.Flags().Changed("terms"),
})
if err != nil {
return err
@@ -191,6 +193,7 @@ func invoiceUpdateCmd() *cobra.Command {
cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "CRM opportunity/deal id to link")
cmd.Flags().StringVar(&description, "description", "", "invoice notes (Notizen); use \\n for line breaks")
cmd.Flags().StringVar(&po, "po", "", "purchase order number")
+ cmd.Flags().StringVar(&terms, "terms", "", "payment terms / footer (Bedingungen)")
return cmd
}
diff --git a/invoices.go b/invoices.go
index f5c2fdd..52f88ef 100644
--- a/invoices.go
+++ b/invoices.go
@@ -117,13 +117,15 @@ func (c *Client) CreateInvoice(ctx context.Context, p CreateInvoiceParams) (map[
// UpdateInvoiceParams are fields for PUT /api/2.0/crm/invoice/{id}.
// Loads the current invoice and merges non-zero / set fields.
type UpdateInvoiceParams struct {
- EntityID int64 // link to opportunity; 0 = leave unchanged
- EntityType int
- ContactID int64 // 0 = leave unchanged
- Description string // invoice notes; empty + DescriptionSet=false keeps existing
- DescriptionSet bool
- PurchaseOrder string
+ EntityID int64 // link to opportunity; 0 = leave unchanged
+ EntityType int
+ ContactID int64 // 0 = leave unchanged
+ Description string // invoice notes; empty + DescriptionSet=false keeps existing
+ DescriptionSet bool
+ PurchaseOrder string
PurchaseOrderSet bool
+ Terms string
+ TermsSet bool
}
// UpdateInvoice PUTs a full invoice body (OnlyOffice requires complete payload).
@@ -206,6 +208,9 @@ func (c *Client) UpdateInvoice(ctx context.Context, id string, p UpdateInvoicePa
if p.PurchaseOrderSet {
body["purchaseOrderNumber"] = p.PurchaseOrder
}
+ if p.TermsSet {
+ body["terms"] = p.Terms
+ }
if p.EntityID != 0 {
body["entityId"] = p.EntityID
body["entityType"] = p.EntityType
From 490426c2779160f1a9b168f6ccae9dd8dccf89e9 Mon Sep 17 00:00:00 2001
From: Andriy Oblivantsev
Date: Thu, 6 Aug 2026 14:26:43 +0100
Subject: [PATCH 7/8] docs(crm): capture association graph and invoice/mail
quirks
Document Medex-learned OO rules (one company, entityId at create, PDF
cache/cleanup, Matrix # truncation) and add invoices pdf/status helpers.
---
AGENTS.md | 2 +-
CHANGELOG.md | 12 +++
README.md | 37 +++++++--
cmd/oo/invoices.go | 143 +++++++++++++++++++++++++++++++++--
cmd/oo/mails.go | 2 +-
cmd/oo/main.go | 8 +-
docs/crm-associations.md | 120 +++++++++++++++++++++++++++++
invoices.go | 158 +++++++++++++++++++++++++++++++++++++++
mails.go | 9 ---
9 files changed, 465 insertions(+), 26 deletions(-)
create mode 100644 docs/crm-associations.md
diff --git a/AGENTS.md b/AGENTS.md
index bfbbce5..ee5051e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,7 +9,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
- - `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `mails.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`GetProjectFiles`, `UploadProjectFile`, `GetTaskFiles`, `AttachFilesToTask`, `UploadTaskFile`, `DetachTaskFile`, `GetFile`, `RenameFile`, `DeleteFiles`, `DownloadFile`). **`mails.go`** — OnlyOffice Workspace Mail addon (`ListMailAccounts`, `ListMailFolders`, `ListMailMessages`, `GetMailMessage`, `RemoveMailMessages`).
+ - `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `mails.go`, `invoices.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents**. **`mails.go`** — OnlyOffice Workspace Mail. **`invoices.go`** — CRM invoices, PDF regen/cleanup, status. Association rules: [`docs/crm-associations.md`](docs/crm-associations.md).
- Pure stdlib + `google/go-querystring`; no UI, no dotenv.
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
- `main.go` — entry point (docstring lists the command tree).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a399d00..6d83a24 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,18 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## Unreleased
+
+### Added
+
+* **docs:** CRM association graph and OO quirks (`docs/crm-associations.md`)
+* **crm:** `ForceRegenerateInvoicePDF`, `SetInvoiceStatus`, `PurgeStaleInvoicePDFs`, contact/opportunity file list helpers
+* **oo:** `invoices pdf`, `pdf-cleanup`, `status`; create `--consignee`; draft-invoice force-regens PDF
+
+### Fixed
+
+* Document that invoice→deal must be set at create (`update --opportunity` often HTTP 400)
+
## [0.9.0](https://github.com/eSlider/go-onlyoffice/compare/v0.8.3...v0.9.0) (2026-07-26)
diff --git a/README.md b/README.md
index 3fab0de..bcfd1ae 100644
--- a/README.md
+++ b/README.md
@@ -623,13 +623,11 @@ oo tasks files detach 208 12345
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) |
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
| `users` | `list`, `self` (alias: `oo whoami`) |
-| `contacts` | `list`, `get`, `delete`, `info-add` |
-| `persons` | `list` (filtered), `create`, `delete` |
-| `companies` | `list` (filtered), `create`, `delete` |
-| `contacts` | `list`, `get`, `delete`, `info-add`, `dedupe-info` |
+| `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info` |
| `persons` | `list`, `create`, `delete`, `dedupe` |
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
+| `invoices` | `list`, `get`, `create`, `update`, `pdf`, `pdf-cleanup`, `status`, `delete`, `items …` |
| `crm` | `cleanup` |
| `mails` | `accounts`, `folders`, `list`, `get`, `draft`, `attach`, `draft-invoice`, `delete` |
| `cases` | `list`, `create`, `delete`, `member-add` |
@@ -718,6 +716,32 @@ oo opportunities dedupe-members
oo opportunities fix-titles
```
+Merge two known company ids (keeps `INTO`):
+
+```bash
+oo contacts merge FROM_ID INTO_ID
+```
+
+Company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail rules and OO quirks:
+[docs/crm-associations.md](docs/crm-associations.md).
+
+### Invoices (`oo invoices`)
+
+**Problem:** Bill a client deal as Draft, regenerate PDF, prune duplicate PDF
+attachments, prepare OnlyOffice Mail — without inventing a second bill-to company.
+
+```bash
+# Always pass --opportunity at create (update --opportunity often HTTP 400)
+oo invoices create --number INV-2026-01 --contact COMPANY_ID --item ITEM_ID \
+ --price 300 --opportunity DEAL_ID --language de-DE \
+ --line-description "…" --terms $'…' --description $'…' --po "Deal #DEAL_ID"
+
+oo invoices pdf 41 --force
+oo invoices pdf-cleanup INVOICE_ID
+oo invoices status INVOICE_ID --status draft
+oo mails draft-invoice --invoice INVOICE_ID --to info@client.de
+```
+
**Deal grouping flag** — when the same role at the same company created
separate deals (`Engineer @ Acme` vs `Engineer`):
@@ -829,10 +853,13 @@ oo mails draft --to client@example.com --subject "Rechnung INV-2026-01" \
# Attach an OnlyOffice Files document (e.g. invoice PDF file id) to a draft
oo mails attach 7301 --file-id 12345
-# Regenerate invoice PDF + draft + attach (does not send)
+# Regenerate invoice PDF (force) + draft + attach (does not send)
oo mails draft-invoice --invoice 16 --to info@example.com
```
+Matrix / chat URLs in signatures: use plain text
+`chat: https://matrix.to/#/@user:server` — HTML `` truncates at `#`.
+
**Table output** splits the `from` header into `fromName` and `fromAddress`
(e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw
API payload.
diff --git a/cmd/oo/invoices.go b/cmd/oo/invoices.go
index 8046fab..da6555c 100644
--- a/cmd/oo/invoices.go
+++ b/cmd/oo/invoices.go
@@ -21,6 +21,9 @@ func init() {
invoicesCmd.AddCommand(invoiceGetCmd())
invoicesCmd.AddCommand(invoiceCreateCmd())
invoicesCmd.AddCommand(invoiceUpdateCmd())
+ invoicesCmd.AddCommand(invoicePDFCmd())
+ invoicesCmd.AddCommand(invoicePDFCleanupCmd())
+ invoicesCmd.AddCommand(invoiceStatusCmd())
invoicesCmd.AddCommand(invoiceDeleteCmd())
invoicesCmd.AddCommand(invoiceItemsCmd())
}
@@ -78,7 +81,7 @@ func invoiceGetCmd() *cobra.Command {
func invoiceCreateCmd() *cobra.Command {
var (
number, issueDate, dueDate, language, currency, terms, description, po string
- contactID, itemID, opportunityID int64
+ contactID, consigneeID, itemID, opportunityID int64
price, qty float64
lineDesc string
)
@@ -87,8 +90,11 @@ func invoiceCreateCmd() *cobra.Command {
Short: "Create a draft invoice with one line",
Long: `Create a CRM invoice (Draft) with a single line.
+Always pass --opportunity when a deal exists (entity link at create). Updating
+--opportunity later often fails with HTTP 400 — see docs/crm-associations.md.
+
Example:
- oo invoices create --number INV-2026-01 --contact 123 --item 10 \
+ oo invoices create --number INV-2026-01 --contact CONTACT_ID --item 12 \
--price 300 --opportunity OPPORTUNITY_ID --line-description "Service package" \
--terms "…payment terms…"
`,
@@ -118,6 +124,7 @@ Example:
IssueDate: issueDate,
DueDate: dueDate,
ContactID: contactID,
+ ConsigneeID: consigneeID,
EntityID: opportunityID,
EntityType: 0, // Opportunity
Language: language,
@@ -142,8 +149,9 @@ Example:
},
}
cmd.Flags().StringVar(&number, "number", "", "invoice number (e.g. INV-2026-01)")
- cmd.Flags().Int64Var(&contactID, "contact", 0, "bill-to contact id (company preferred)")
- cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "link to CRM opportunity/deal id")
+ cmd.Flags().Int64Var(&contactID, "contact", 0, "bill-to company contact id (canonical company, not a PDF-only clone)")
+ cmd.Flags().Int64Var(&consigneeID, "consignee", 0, "optional Empfänger person/contact id")
+ cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "link to CRM opportunity/deal id (set at create)")
cmd.Flags().Int64Var(&itemID, "item", 0, "catalog invoice item id")
cmd.Flags().Float64Var(&price, "price", 0, "line price")
cmd.Flags().Float64Var(&qty, "qty", 1, "line quantity")
@@ -163,8 +171,13 @@ func invoiceUpdateCmd() *cobra.Command {
var description, po, terms string
cmd := &cobra.Command{
Use: "update INVOICE_ID",
- Short: "Update invoice (opportunity link, notes, PO, terms)",
- Args: cobra.ExactArgs(1),
+ Short: "Update invoice (notes, PO, terms; opportunity link unreliable)",
+ Long: `Update Draft invoice fields.
+
+--opportunity often returns HTTP 400 on existing invoices. Prefer
+oo invoices create … --opportunity, or delete+recreate. See docs/crm-associations.md.
+`,
+ Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if opportunityID == 0 && !cmd.Flags().Changed("description") && !cmd.Flags().Changed("po") && !cmd.Flags().Changed("terms") {
return fmt.Errorf("set --opportunity and/or --description and/or --po and/or --terms")
@@ -190,13 +203,129 @@ func invoiceUpdateCmd() *cobra.Command {
return nil
},
}
- cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "CRM opportunity/deal id to link")
+ cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "CRM opportunity/deal id (may 400 — prefer create)")
cmd.Flags().StringVar(&description, "description", "", "invoice notes (Notizen); use \\n for line breaks")
cmd.Flags().StringVar(&po, "po", "", "purchase order number")
cmd.Flags().StringVar(&terms, "terms", "", "payment terms / footer (Bedingungen)")
return cmd
}
+func invoicePDFCmd() *cobra.Command {
+ var force bool
+ cmd := &cobra.Command{
+ Use: "pdf INVOICE_ID",
+ Short: "Return / regenerate invoice PDF file metadata",
+ Long: `GET /api/2.0/crm/invoice/{id}/pdf.
+
+With --force, touches the Draft to clear cached fileID first (layout changes).
+`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ var out map[string]any
+ if force {
+ out, err = c.ForceRegenerateInvoicePDF(cmd.Context(), args[0])
+ } else {
+ out, err = c.InvoicePDFFile(cmd.Context(), args[0])
+ }
+ if err != nil {
+ return err
+ }
+ printObject(out)
+ return nil
+ },
+ }
+ cmd.Flags().BoolVar(&force, "force", false, "clear PDF cache then regenerate")
+ return cmd
+}
+
+func invoicePDFCleanupCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "pdf-cleanup INVOICE_ID",
+ Short: "Delete older P-*.pdf copies on company/deal; keep invoice.fileID",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ deleted, err := c.PurgeStaleInvoicePDFs(cmd.Context(), args[0])
+ if err != nil {
+ return err
+ }
+ if outputFormat == "json" {
+ printObject(map[string]any{"deleted": deleted})
+ return nil
+ }
+ fmt.Printf("deleted %d stale PDF file(s): %v\n", len(deleted), deleted)
+ return nil
+ },
+ }
+}
+
+func invoiceStatusCmd() *cobra.Command {
+ var status string
+ cmd := &cobra.Command{
+ Use: "status INVOICE_ID [INVOICE_ID...]",
+ Short: "Set invoice status (draft|billed|rejected|paid)",
+ Long: `PUT /api/2.0/crm/invoice/status/{id}.
+
+Billed invoices are not content-editable. Billed→Draft often does not work —
+recreate as Draft instead (docs/crm-associations.md).
+`,
+ Args: cobra.MinimumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ statusID, err := parseInvoiceStatus(status)
+ if err != nil {
+ return err
+ }
+ ids := make([]int64, 0, len(args))
+ for _, a := range args {
+ var id int64
+ if _, err := fmt.Sscan(a, &id); err != nil || id <= 0 {
+ return fmt.Errorf("invalid invoice id %q", a)
+ }
+ ids = append(ids, id)
+ }
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ out, err := c.SetInvoiceStatus(cmd.Context(), statusID, ids...)
+ if err != nil {
+ return err
+ }
+ printObject(out)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&status, "status", "draft", "draft|billed|rejected|paid or numeric id")
+ return cmd
+}
+
+func parseInvoiceStatus(s string) (int, error) {
+ s = strings.TrimSpace(strings.ToLower(s))
+ switch s {
+ case "", "draft", "1":
+ return onlyoffice.InvoiceStatusDraft, nil
+ case "billed", "2":
+ return onlyoffice.InvoiceStatusBilled, nil
+ case "rejected", "3":
+ return onlyoffice.InvoiceStatusRejected, nil
+ case "paid", "4":
+ return onlyoffice.InvoiceStatusPaid, nil
+ default:
+ var n int
+ if _, err := fmt.Sscan(s, &n); err != nil || n <= 0 {
+ return 0, fmt.Errorf("unknown status %q (draft|billed|rejected|paid)", s)
+ }
+ return n, nil
+ }
+}
+
func invoiceDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete INVOICE_ID [INVOICE_ID...]",
diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go
index 02661bb..d6dc4fe 100644
--- a/cmd/oo/mails.go
+++ b/cmd/oo/mails.go
@@ -229,7 +229,7 @@ Does not send. Open /addons/mail/#drafts to review.
if err != nil {
return err
}
- pdf, err := c.InvoicePDFFile(cmd.Context(), strconv.Itoa(invoiceID))
+ pdf, err := c.ForceRegenerateInvoicePDF(cmd.Context(), strconv.Itoa(invoiceID))
if err != nil {
return err
}
diff --git a/cmd/oo/main.go b/cmd/oo/main.go
index e4825ab..155cebe 100644
--- a/cmd/oo/main.go
+++ b/cmd/oo/main.go
@@ -6,18 +6,20 @@
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
// oo users list | self (alias: oo whoami)
-// oo contacts list | get | delete | info-add | dedupe-info
+// oo contacts list | get | delete | info-add | merge | dedupe-info
// oo persons list | create | delete | dedupe
// oo companies list | create | delete | dedupe | dedupe-persons
// oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles
// oo cases list | create | delete | member-add
// oo crm-tasks list | create | delete | categories
// oo crm cleanup
-// oo mails accounts | folders | list | get | delete
-// oo invoices list | get | create | delete | items (list|create|delete)
+// oo mails accounts | folders | list | get | draft | attach | draft-invoice | delete
+// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
// oo applications sync
// oo catalog scan-contacts | scan-projects | scan-thunderbird | merge | match | apply
//
+// CRM association rules: docs/crm-associations.md
+//
// Every list supports `--output/-o json|table` (table is the default).
//
// Build & install:
diff --git a/docs/crm-associations.md b/docs/crm-associations.md
new file mode 100644
index 0000000..9877b03
--- /dev/null
+++ b/docs/crm-associations.md
@@ -0,0 +1,120 @@
+# CRM associations (company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail)
+
+Operational rules learned from real billing work (AcmeClient INV-2026-01, AcmeProject INV-2026-01).
+Tooling: `oo` CLI + this library. Business SSOT remains OnlyOffice (`ASR-0002`).
+
+## Canonical graph
+
+One **legal company** owns the relationship. Do not invent a second “bill-to”
+company just for PDF layout.
+
+```text
+Company (#CONTACT_ID)
+├── Person (GF / buyer contact) oo persons create --company-id
+├── Opportunity / Deal oo opportunities … ; member-add company + person
+├── Project (hub) oo projects … ; contacts add company + person
+│ └── Epic + subtasks
+└── Invoice (Draft → …) oo invoices create --contact COMPANY --opportunity DEAL
+ └── PDF file oo invoices pdf ID
+ └── Mail draft oo mails draft-invoice --invoice ID --to …
+```
+
+| Layer | CLI | Must link |
+|-------|-----|-----------|
+| Company | `oo companies create` | website, email, phone, **one** Billing address |
+| Person | `oo persons create --company-id` | job title; never encode employer in `lastName` |
+| Deal | `oo opportunities create` + `member-add` | company **and** person as members |
+| Project | `oo projects create` + `contacts add` | same company + person |
+| Invoice | `oo invoices create --contact COMPANY --opportunity DEAL` | `entityId` at **create** |
+| Mail | `oo mails draft-invoice` | attach current PDF; **do not send** until confirmed |
+
+UI checks (same company card):
+
+- `#contacts` → person
+- `#deals` → opportunity
+- `#projects` → hub project
+- `#invoices` on the **deal** → invoice (needs `entity`)
+- `#files` → preferably **one** current `P-….pdf`
+
+## Hard rules
+
+1. **One company per legal entity.** Duplicate “bill-to” contacts empty Deals /
+ Projects / Contacts tabs and break merge. Prefer
+ `oo contacts merge FROM INTO` (keeps `INTO`) or `oo companies dedupe`.
+2. **Link invoice → deal at create.**
+ `POST /crm/invoice` with `entityId` + `entityType: 0` (Opportunity).
+ `oo invoices update … --opportunity` often returns **400**
+ (“Value does not fall within the expected range”). If the link is missing,
+ delete the Draft and recreate with `--opportunity`.
+3. **Bill To = company id**, not a throwaway contact. Person stays under the
+ company (`companyId`). Optional `consigneeId` for Empfänger when the portal
+ template prints it.
+4. **Stay Draft until mail is ready.** Billed (`status id=2`) is **not editable**
+ via content PUT. Going Billed → Draft via `…/crm/invoice/status/1` usually
+ **does not work** — delete + recreate Draft instead.
+5. **Do not regenerate PDF in a loop** without cleanup. Each
+ `GET …/crm/invoice/{id}/pdf` attaches a new file to the company (and often
+ the deal). Keep `invoice.fileID`; delete older `P-*.pdf` with
+ `oo invoices pdf-cleanup ID` / Documents `fileops/delete`.
+
+## Invoice PDF quirks
+
+| Symptom | Workaround |
+|---------|------------|
+| Cached / stale PDF | Touch invoice (Draft PUT that clears `fileID`), then `GET …/pdf` — `oo invoices pdf ID --force` |
+| Billing address missing on **new** PDFs | Temporary multiline `companyName` (`Line1\nLine2\n…`) on the **canonical** company → force PDF → restore clean name. Cached `fileID` keeps the multiline Bill To. |
+| Separate bill-to company for newlines | **Forbidden** — merge back to the real company |
+| Invoice **number** won’t change on PUT | Delete Draft and recreate with the desired number |
+| Notizen / Bedingungen spacing | Leading `\n` and blank lines only — no HTML (tags print literally) |
+| Issuer NIE / street lines | Organisation profile address (`street` with `\n`), not only terms |
+
+Status ids commonly used on this portal: `1` Draft, `2` Billed, `3` Rejected, `4` Paid.
+
+## Mail quirks
+
+| Symptom | Workaround |
+|---------|------------|
+| Signature / body cuts Matrix URL at `#` | Plain text `chat: https://matrix.to/#/@user:server` — avoid `` (or encode `#` as `%23` in href) |
+| German letter spacing | Blank `
` between blocks (`MailHTMLWithBlankParagraphs`) |
+| Send | Never auto-send; draft only until the human confirms |
+
+Prefer OnlyOffice Mail (`/addons/mail/#drafts`) over Gmail MCP for invoice delivery.
+
+## Project / task quirks
+
+- Hub title: `CC | Company` (e.g. `DE | AcmeClient Ambulanter Pflegedienst GmbH`).
+- Streams = epics/tasks under the hub, not a third title segment (unless the
+ project itself is a named delivery stream).
+- Closing a **subtask**:
+ `PUT /api/2.0/project/task/{epicId}/{subtaskId}/status` with `status=2`.
+ `oo tasks update SUBTASK -s closed` returns **404** for subtasks.
+- After deleting a CRM contact, `GET /project/contact/{deletedId}` may still
+ return projects (ghost). Official project contact list should only show live
+ ids; unlink may 400 if the contact is gone.
+
+## Merge / cleanup cheat sheet
+
+```bash
+# Keep the human-created company (INTO), drop the duplicate (FROM)
+oo contacts merge 1334 1328
+
+# Or by normalized name (careful — whole CRM)
+oo companies dedupe
+
+# Invoice ↔ deal must exist at create
+oo invoices create --number P-YYYY-NN --contact CONTACT_ID --item N \
+ --price 300 --opportunity OPPORTUNITY_ID --language de-DE …
+
+# Fresh PDF + prune older P-*.pdf on company/deal
+oo invoices pdf 41 --force
+oo invoices pdf-cleanup INVOICE_ID
+
+# Mail draft (no send)
+oo mails draft-invoice --invoice INVOICE_ID --to info@client.de
+```
+
+## Related
+
+- README § invoices / mail / CRM cleanup
+- Skill `oo-clients-projects` (Cursor)
+- inventar ASR-0014 / `ops/oo-clients-contacts-sync.md`
diff --git a/invoices.go b/invoices.go
index 52f88ef..6c9cb1d 100644
--- a/invoices.go
+++ b/invoices.go
@@ -129,6 +129,9 @@ type UpdateInvoiceParams struct {
}
// UpdateInvoice PUTs a full invoice body (OnlyOffice requires complete payload).
+//
+// Linking an opportunity via EntityID on an existing invoice often returns HTTP 400
+// on this portal — prefer CreateInvoice with EntityID set. See docs/crm-associations.md.
func (c *Client) UpdateInvoice(ctx context.Context, id string, p UpdateInvoiceParams) (map[string]any, error) {
inv, err := c.GetInvoice(ctx, id)
if err != nil {
@@ -236,6 +239,161 @@ func (c *Client) DeleteInvoice(ctx context.Context, id string) (map[string]any,
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s.json", url.PathEscape(id)))
}
+// Invoice status ids used by OnlyOffice CRM on produktor.io.
+const (
+ InvoiceStatusDraft = 1
+ InvoiceStatusBilled = 2
+ InvoiceStatusRejected = 3
+ InvoiceStatusPaid = 4
+)
+
+// SetInvoiceStatus sets CRM invoice status for one or more invoice ids
+// (PUT /api/2.0/crm/invoice/status/{statusId} with invoiceids).
+// Note: Billed→Draft often does not stick; recreate Draft instead (see docs/crm-associations.md).
+func (c *Client) SetInvoiceStatus(ctx context.Context, statusID int, invoiceIDs ...int64) (map[string]any, error) {
+ if statusID <= 0 {
+ return nil, fmt.Errorf("status id is required")
+ }
+ if len(invoiceIDs) == 0 {
+ return nil, fmt.Errorf("at least one invoice id is required")
+ }
+ ids := make([]string, 0, len(invoiceIDs))
+ for _, id := range invoiceIDs {
+ ids = append(ids, strconv.FormatInt(id, 10))
+ }
+ fields := url.Values{}
+ fields.Set("invoiceids", strings.Join(ids, ","))
+ return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/status/%d", statusID), fields)
+}
+
+// InvoicePDFFile returns invoice PDF file metadata (id, title, viewUrl).
+// Without force, OnlyOffice may return a cached fileID with stale layout.
+func (c *Client) InvoicePDFFile(ctx context.Context, invoiceID string) (map[string]any, error) {
+ id := strings.TrimSpace(invoiceID)
+ if id == "" {
+ return nil, fmt.Errorf("InvoicePDFFile: invoice id is required")
+ }
+ return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s/pdf", url.PathEscape(id)))
+}
+
+// ForceRegenerateInvoicePDF clears the cached PDF (Draft touch) then requests a new file.
+// No-op touch when the invoice is not editable (e.g. Billed) — falls back to GET /pdf.
+func (c *Client) ForceRegenerateInvoicePDF(ctx context.Context, invoiceID string) (map[string]any, error) {
+ id := strings.TrimSpace(invoiceID)
+ if id == "" {
+ return nil, fmt.Errorf("ForceRegenerateInvoicePDF: invoice id is required")
+ }
+ inv, err := c.GetInvoice(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ canEdit := true
+ if v, ok := inv["canEdit"].(bool); ok {
+ canEdit = v
+ }
+ if canEdit {
+ desc := stringField(inv, "description")
+ // Append/remove a trailing space so PUT clears fileID without visible change.
+ touch := desc + " "
+ if strings.HasSuffix(desc, " ") {
+ touch = strings.TrimSuffix(desc, " ")
+ }
+ if _, err := c.UpdateInvoice(ctx, id, UpdateInvoiceParams{
+ Description: touch,
+ DescriptionSet: true,
+ }); err != nil {
+ return nil, fmt.Errorf("ForceRegenerateInvoicePDF: clear cache: %w", err)
+ }
+ }
+ return c.InvoicePDFFile(ctx, id)
+}
+
+// ListCRMContactFiles lists Documents attached on a CRM contact card (#files).
+func (c *Client) ListCRMContactFiles(ctx context.Context, contactID string) ([]map[string]any, error) {
+ return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/files.json", url.PathEscape(contactID)))
+}
+
+// ListOpportunityFiles lists Documents attached on a CRM opportunity.
+func (c *Client) ListOpportunityFiles(ctx context.Context, opportunityID string) ([]map[string]any, error) {
+ return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/files.json", url.PathEscape(opportunityID)))
+}
+
+// PurgeStaleInvoicePDFs deletes older P-*.pdf copies on the invoice contact (and linked
+// opportunity) while keeping the invoice's current fileID. Returns deleted file ids.
+func (c *Client) PurgeStaleInvoicePDFs(ctx context.Context, invoiceID string) ([]int, error) {
+ inv, err := c.GetInvoice(ctx, invoiceID)
+ if err != nil {
+ return nil, err
+ }
+ keep := flexInt(inv["fileID"])
+ number := strings.TrimSpace(stringField(inv, "number"))
+ base := number
+ for strings.HasSuffix(base, "b") || strings.HasSuffix(base, "B") {
+ base = base[:len(base)-1]
+ }
+ if base == "" {
+ base = "P-"
+ }
+
+ seen := map[int]struct{}{}
+ var candidates []int
+ addFiles := func(files []map[string]any) {
+ for _, f := range files {
+ title := stringField(f, "title")
+ id := int(flexInt(f["id"]))
+ if id == 0 || id == int(keep) {
+ continue
+ }
+ if !strings.HasPrefix(title, "P-") {
+ continue
+ }
+ if !strings.HasPrefix(title, base) {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ candidates = append(candidates, id)
+ }
+ }
+
+ if m, ok := inv["contact"].(map[string]any); ok {
+ cid := strconv.FormatInt(flexInt(m["id"]), 10)
+ if cid != "0" {
+ files, err := c.ListCRMContactFiles(ctx, cid)
+ if err != nil {
+ return nil, err
+ }
+ addFiles(files)
+ }
+ }
+ if ent, ok := inv["entity"].(map[string]any); ok && ent != nil {
+ if strings.EqualFold(fmt.Sprint(ent["entityType"]), "opportunity") || flexInt(ent["entityType"]) == 0 {
+ oid := strconv.FormatInt(flexInt(ent["entityId"]), 10)
+ if oid != "0" {
+ files, err := c.ListOpportunityFiles(ctx, oid)
+ if err != nil {
+ return nil, err
+ }
+ addFiles(files)
+ }
+ }
+ }
+
+ if len(candidates) == 0 {
+ return nil, nil
+ }
+ if err := c.DeleteFiles(ctx, candidates); err != nil {
+ return nil, err
+ }
+ // CRM may still list deleted files briefly; also try CRM unlink.
+ for _, fid := range candidates {
+ _, _ = c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/files/%d.json", fid))
+ }
+ return candidates, nil
+}
+
// ListInvoiceItems returns catalog invoice items.
func (c *Client) ListInvoiceItems(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
q := url.Values{}
diff --git a/mails.go b/mails.go
index e5ab907..5689c6c 100644
--- a/mails.go
+++ b/mails.go
@@ -198,15 +198,6 @@ func (c *Client) AttachMailDocument(ctx context.Context, messageID string, fileI
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/mail/messages/%s/document", url.PathEscape(id)), fields)
}
-// InvoicePDFFile regenerates/returns the invoice PDF file metadata (id, title, viewUrl).
-func (c *Client) InvoicePDFFile(ctx context.Context, invoiceID string) (map[string]any, error) {
- id := strings.TrimSpace(invoiceID)
- if id == "" {
- return nil, fmt.Errorf("InvoicePDFFile: invoice id is required")
- }
- return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s/pdf", url.PathEscape(id)))
-}
-
// PlainTextToMailHTML turns plain text into simple HTML paragraphs for drafts.
// If s already looks like HTML, it is returned unchanged.
func PlainTextToMailHTML(s string) string {
From 6b5a82bef831341aee16b0b0ea1fe6db2c24567d Mon Sep 17 00:00:00 2001
From: Andriy Oblivantsev
Date: Thu, 6 Aug 2026 14:52:35 +0100
Subject: [PATCH 8/8] docs(crm): clarify mail API send vs signature for chat
links
---
docs/crm-associations.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/crm-associations.md b/docs/crm-associations.md
index 9877b03..2405561 100644
--- a/docs/crm-associations.md
+++ b/docs/crm-associations.md
@@ -74,9 +74,10 @@ Status ids commonly used on this portal: `1` Draft, `2` Billed, `3` Rejected, `4
| Symptom | Workaround |
|---------|------------|
+| Signature / body doubles Matrix URL | Put chat in **one** place only. UI drafts: signature. API send: body (API **does not** append signature). |
| Signature / body cuts Matrix URL at `#` | Plain text `chat: https://matrix.to/#/@user:server` — avoid `` (or encode `#` as `%23` in href) |
| German letter spacing | Blank `
` between blocks (`MailHTMLWithBlankParagraphs`) |
-| Send | Never auto-send; draft only until the human confirms |
+| Send | `PUT /api/2.0/mail/messages/send.json` with `id/from/to/subject/body`; omit empty `cc`/`bcc`. Never auto-send; draft only until the human confirms |
Prefer OnlyOffice Mail (`/addons/mail/#drafts`) over Gmail MCP for invoice delivery.