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:
+264
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user