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.
This commit is contained in:
+35
-2
@@ -20,6 +20,7 @@ func init() {
|
|||||||
invoicesCmd.AddCommand(invoiceListCmd())
|
invoicesCmd.AddCommand(invoiceListCmd())
|
||||||
invoicesCmd.AddCommand(invoiceGetCmd())
|
invoicesCmd.AddCommand(invoiceGetCmd())
|
||||||
invoicesCmd.AddCommand(invoiceCreateCmd())
|
invoicesCmd.AddCommand(invoiceCreateCmd())
|
||||||
|
invoicesCmd.AddCommand(invoiceUpdateCmd())
|
||||||
invoicesCmd.AddCommand(invoiceDeleteCmd())
|
invoicesCmd.AddCommand(invoiceDeleteCmd())
|
||||||
invoicesCmd.AddCommand(invoiceItemsCmd())
|
invoicesCmd.AddCommand(invoiceItemsCmd())
|
||||||
}
|
}
|
||||||
@@ -77,7 +78,7 @@ func invoiceGetCmd() *cobra.Command {
|
|||||||
func invoiceCreateCmd() *cobra.Command {
|
func invoiceCreateCmd() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
number, issueDate, dueDate, language, currency, terms, description, po string
|
number, issueDate, dueDate, language, currency, terms, description, po string
|
||||||
contactID, itemID int64
|
contactID, itemID, opportunityID int64
|
||||||
price, qty float64
|
price, qty float64
|
||||||
lineDesc string
|
lineDesc string
|
||||||
)
|
)
|
||||||
@@ -88,7 +89,7 @@ func invoiceCreateCmd() *cobra.Command {
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
oo invoices create --number INV-2026-01 --contact 123 --item 10 \
|
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…"
|
--terms "…payment terms…"
|
||||||
`,
|
`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -117,6 +118,8 @@ Example:
|
|||||||
IssueDate: issueDate,
|
IssueDate: issueDate,
|
||||||
DueDate: dueDate,
|
DueDate: dueDate,
|
||||||
ContactID: contactID,
|
ContactID: contactID,
|
||||||
|
EntityID: opportunityID,
|
||||||
|
EntityType: 0, // Opportunity
|
||||||
Language: language,
|
Language: language,
|
||||||
Currency: currency,
|
Currency: currency,
|
||||||
ExchangeRate: 1,
|
ExchangeRate: 1,
|
||||||
@@ -140,6 +143,7 @@ Example:
|
|||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&number, "number", "", "invoice number (e.g. INV-2026-01)")
|
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(&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().Int64Var(&itemID, "item", 0, "catalog invoice item id")
|
||||||
cmd.Flags().Float64Var(&price, "price", 0, "line price")
|
cmd.Flags().Float64Var(&price, "price", 0, "line price")
|
||||||
cmd.Flags().Float64Var(&qty, "qty", 1, "line quantity")
|
cmd.Flags().Float64Var(&qty, "qty", 1, "line quantity")
|
||||||
@@ -154,6 +158,35 @@ Example:
|
|||||||
return cmd
|
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 {
|
func invoiceDeleteCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "delete INVOICE_ID [INVOICE_ID...]",
|
Use: "delete INVOICE_ID [INVOICE_ID...]",
|
||||||
|
|||||||
+108
@@ -30,6 +30,8 @@ type CreateInvoiceParams struct {
|
|||||||
DueDate string // ISO-8601
|
DueDate string // ISO-8601
|
||||||
ContactID int64
|
ContactID int64
|
||||||
ConsigneeID int64 // 0 = omit
|
ConsigneeID int64 // 0 = omit
|
||||||
|
EntityID int64 // 0 = omit; link to opportunity/case
|
||||||
|
EntityType int // OnlyOffice EntityType; 0 = Opportunity
|
||||||
Language string
|
Language string
|
||||||
Currency string
|
Currency string
|
||||||
ExchangeRate float64
|
ExchangeRate float64
|
||||||
@@ -105,9 +107,115 @@ func (c *Client) CreateInvoice(ctx context.Context, p CreateInvoiceParams) (map[
|
|||||||
if p.ConsigneeID != 0 {
|
if p.ConsigneeID != 0 {
|
||||||
body["consigneeId"] = p.ConsigneeID
|
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)
|
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.
|
// DeleteInvoice removes an invoice by id.
|
||||||
func (c *Client) DeleteInvoice(ctx context.Context, id string) (map[string]any, error) {
|
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)))
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s.json", url.PathEscape(id)))
|
||||||
|
|||||||
Reference in New Issue
Block a user