From 3201a58df75bceb7d41034970cf384a9169163ed Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Thu, 6 Aug 2026 12:45:09 +0100 Subject: [PATCH] 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)))