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
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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,
+35 -2
View File
@@ -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
}
+269
View File
@@ -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(&currency, "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(&currency, "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
},
}
}
+1
View File
@@ -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
//
+35
View File
@@ -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...]",