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

\n

B

") + if spaced != "

A

\n

 

\n

B

" { + 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.