From 19a7e9cbf8f7bd335c4e4cfd4d3b8a84a350cc41 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Thu, 6 Aug 2026 12:32:20 +0100 Subject: [PATCH] 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 +}