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.
This commit is contained in:
2026-08-06 12:32:20 +01:00
parent 2e1624920e
commit 19a7e9cbf8
8 changed files with 659 additions and 5 deletions
+264
View File
@@ -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
}