diff --git a/AGENTS.md b/AGENTS.md
index bfbbce5..ee5051e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,7 +9,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
- - `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `mails.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`GetProjectFiles`, `UploadProjectFile`, `GetTaskFiles`, `AttachFilesToTask`, `UploadTaskFile`, `DetachTaskFile`, `GetFile`, `RenameFile`, `DeleteFiles`, `DownloadFile`). **`mails.go`** — OnlyOffice Workspace Mail addon (`ListMailAccounts`, `ListMailFolders`, `ListMailMessages`, `GetMailMessage`, `RemoveMailMessages`).
+ - `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `mails.go`, `invoices.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents**. **`mails.go`** — OnlyOffice Workspace Mail. **`invoices.go`** — CRM invoices, PDF regen/cleanup, status. Association rules: [`docs/crm-associations.md`](docs/crm-associations.md).
- Pure stdlib + `google/go-querystring`; no UI, no dotenv.
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
- `main.go` — entry point (docstring lists the command tree).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a399d00..6d83a24 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,18 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## Unreleased
+
+### Added
+
+* **docs:** CRM association graph and OO quirks (`docs/crm-associations.md`)
+* **crm:** `ForceRegenerateInvoicePDF`, `SetInvoiceStatus`, `PurgeStaleInvoicePDFs`, contact/opportunity file list helpers
+* **oo:** `invoices pdf`, `pdf-cleanup`, `status`; create `--consignee`; draft-invoice force-regens PDF
+
+### Fixed
+
+* Document that invoice→deal must be set at create (`update --opportunity` often HTTP 400)
+
## [0.9.0](https://github.com/eSlider/go-onlyoffice/compare/v0.8.3...v0.9.0) (2026-07-26)
diff --git a/README.md b/README.md
index 78ff066..bcfd1ae 100644
--- a/README.md
+++ b/README.md
@@ -623,15 +623,13 @@ oo tasks files detach 208 12345
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) |
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
| `users` | `list`, `self` (alias: `oo whoami`) |
-| `contacts` | `list`, `get`, `delete`, `info-add` |
-| `persons` | `list` (filtered), `create`, `delete` |
-| `companies` | `list` (filtered), `create`, `delete` |
-| `contacts` | `list`, `get`, `delete`, `info-add`, `dedupe-info` |
+| `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info` |
| `persons` | `list`, `create`, `delete`, `dedupe` |
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
+| `invoices` | `list`, `get`, `create`, `update`, `pdf`, `pdf-cleanup`, `status`, `delete`, `items …` |
| `crm` | `cleanup` |
-| `mails` | `accounts`, `folders`, `list`, `get`, `delete` |
+| `mails` | `accounts`, `folders`, `list`, `get`, `draft`, `attach`, `draft-invoice`, `delete` |
| `cases` | `list`, `create`, `delete`, `member-add` |
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
| `applications` | `sync` |
@@ -718,6 +716,32 @@ oo opportunities dedupe-members
oo opportunities fix-titles
```
+Merge two known company ids (keeps `INTO`):
+
+```bash
+oo contacts merge FROM_ID INTO_ID
+```
+
+Company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail rules and OO quirks:
+[docs/crm-associations.md](docs/crm-associations.md).
+
+### Invoices (`oo invoices`)
+
+**Problem:** Bill a client deal as Draft, regenerate PDF, prune duplicate PDF
+attachments, prepare OnlyOffice Mail — without inventing a second bill-to company.
+
+```bash
+# Always pass --opportunity at create (update --opportunity often HTTP 400)
+oo invoices create --number INV-2026-01 --contact COMPANY_ID --item ITEM_ID \
+ --price 300 --opportunity DEAL_ID --language de-DE \
+ --line-description "…" --terms $'…' --description $'…' --po "Deal #DEAL_ID"
+
+oo invoices pdf 41 --force
+oo invoices pdf-cleanup INVOICE_ID
+oo invoices status INVOICE_ID --status draft
+oo mails draft-invoice --invoice INVOICE_ID --to info@client.de
+```
+
**Deal grouping flag** — when the same role at the same company created
separate deals (`Engineer @ Acme` vs `Engineer`):
@@ -821,8 +845,21 @@ oo mails get 5664 -o json | jq '{subject, from, to, date}'
# Remove one or more messages (server moves to trash or deletes per Mail rules)
oo mails delete 5664
oo mails delete 5664 5663 5661
+
+# Create / update a draft (HTML body field is API "body"; plain text is wrapped)
+oo mails draft --to client@example.com --subject "Rechnung INV-2026-01" \
+ --body "Guten Tag,\n\nanbei die Rechnung.\n\nMit freundlichen Grüßen"
+
+# Attach an OnlyOffice Files document (e.g. invoice PDF file id) to a draft
+oo mails attach 7301 --file-id 12345
+
+# Regenerate invoice PDF (force) + draft + attach (does not send)
+oo mails draft-invoice --invoice 16 --to info@example.com
```
+Matrix / chat URLs in signatures: use plain text
+`chat: https://matrix.to/#/@user:server` — HTML `` truncates at `#`.
+
**Table output** splits the `from` header into `fromName` and `fromAddress`
(e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw
API payload.
diff --git a/cmd/oo/cli_test.go b/cmd/oo/cli_test.go
index 9f5eca9..66467dc 100644
--- a/cmd/oo/cli_test.go
+++ b/cmd/oo/cli_test.go
@@ -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)
}
diff --git a/cmd/oo/common.go b/cmd/oo/common.go
index 5f5ab27..b9b3592 100644
--- a/cmd/oo/common.go
+++ b/cmd/oo/common.go
@@ -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,
diff --git a/cmd/oo/contacts.go b/cmd/oo/contacts.go
index 5d838c6..aa0f533 100644
--- a/cmd/oo/contacts.go
+++ b/cmd/oo/contacts.go
@@ -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
}
diff --git a/cmd/oo/invoices.go b/cmd/oo/invoices.go
new file mode 100644
index 0000000..da6555c
--- /dev/null
+++ b/cmd/oo/invoices.go
@@ -0,0 +1,441 @@
+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(invoiceUpdateCmd())
+ invoicesCmd.AddCommand(invoicePDFCmd())
+ invoicesCmd.AddCommand(invoicePDFCleanupCmd())
+ invoicesCmd.AddCommand(invoiceStatusCmd())
+ 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, consigneeID, itemID, opportunityID 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.
+
+Always pass --opportunity when a deal exists (entity link at create). Updating
+--opportunity later often fails with HTTP 400 — see docs/crm-associations.md.
+
+Example:
+ oo invoices create --number INV-2026-01 --contact CONTACT_ID --item 12 \
+ --price 300 --opportunity OPPORTUNITY_ID --line-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,
+ ConsigneeID: consigneeID,
+ EntityID: opportunityID,
+ EntityType: 0, // Opportunity
+ 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 company contact id (canonical company, not a PDF-only clone)")
+ cmd.Flags().Int64Var(&consigneeID, "consignee", 0, "optional Empfänger person/contact id")
+ cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "link to CRM opportunity/deal id (set at create)")
+ 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(¤cy, "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 invoiceUpdateCmd() *cobra.Command {
+ var opportunityID int64
+ var description, po, terms string
+ cmd := &cobra.Command{
+ Use: "update INVOICE_ID",
+ Short: "Update invoice (notes, PO, terms; opportunity link unreliable)",
+ Long: `Update Draft invoice fields.
+
+--opportunity often returns HTTP 400 on existing invoices. Prefer
+oo invoices create … --opportunity, or delete+recreate. See docs/crm-associations.md.
+`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if opportunityID == 0 && !cmd.Flags().Changed("description") && !cmd.Flags().Changed("po") && !cmd.Flags().Changed("terms") {
+ return fmt.Errorf("set --opportunity and/or --description and/or --po and/or --terms")
+ }
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ out, err := c.UpdateInvoice(cmd.Context(), args[0], onlyoffice.UpdateInvoiceParams{
+ EntityID: opportunityID,
+ EntityType: 0,
+ Description: description,
+ DescriptionSet: cmd.Flags().Changed("description"),
+ PurchaseOrder: po,
+ PurchaseOrderSet: cmd.Flags().Changed("po"),
+ Terms: terms,
+ TermsSet: cmd.Flags().Changed("terms"),
+ })
+ if err != nil {
+ return err
+ }
+ printObject(out)
+ return nil
+ },
+ }
+ cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "CRM opportunity/deal id (may 400 — prefer create)")
+ cmd.Flags().StringVar(&description, "description", "", "invoice notes (Notizen); use \\n for line breaks")
+ cmd.Flags().StringVar(&po, "po", "", "purchase order number")
+ cmd.Flags().StringVar(&terms, "terms", "", "payment terms / footer (Bedingungen)")
+ return cmd
+}
+
+func invoicePDFCmd() *cobra.Command {
+ var force bool
+ cmd := &cobra.Command{
+ Use: "pdf INVOICE_ID",
+ Short: "Return / regenerate invoice PDF file metadata",
+ Long: `GET /api/2.0/crm/invoice/{id}/pdf.
+
+With --force, touches the Draft to clear cached fileID first (layout changes).
+`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ var out map[string]any
+ if force {
+ out, err = c.ForceRegenerateInvoicePDF(cmd.Context(), args[0])
+ } else {
+ out, err = c.InvoicePDFFile(cmd.Context(), args[0])
+ }
+ if err != nil {
+ return err
+ }
+ printObject(out)
+ return nil
+ },
+ }
+ cmd.Flags().BoolVar(&force, "force", false, "clear PDF cache then regenerate")
+ return cmd
+}
+
+func invoicePDFCleanupCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "pdf-cleanup INVOICE_ID",
+ Short: "Delete older P-*.pdf copies on company/deal; keep invoice.fileID",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ deleted, err := c.PurgeStaleInvoicePDFs(cmd.Context(), args[0])
+ if err != nil {
+ return err
+ }
+ if outputFormat == "json" {
+ printObject(map[string]any{"deleted": deleted})
+ return nil
+ }
+ fmt.Printf("deleted %d stale PDF file(s): %v\n", len(deleted), deleted)
+ return nil
+ },
+ }
+}
+
+func invoiceStatusCmd() *cobra.Command {
+ var status string
+ cmd := &cobra.Command{
+ Use: "status INVOICE_ID [INVOICE_ID...]",
+ Short: "Set invoice status (draft|billed|rejected|paid)",
+ Long: `PUT /api/2.0/crm/invoice/status/{id}.
+
+Billed invoices are not content-editable. Billed→Draft often does not work —
+recreate as Draft instead (docs/crm-associations.md).
+`,
+ Args: cobra.MinimumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ statusID, err := parseInvoiceStatus(status)
+ if err != nil {
+ return err
+ }
+ ids := make([]int64, 0, len(args))
+ for _, a := range args {
+ var id int64
+ if _, err := fmt.Sscan(a, &id); err != nil || id <= 0 {
+ return fmt.Errorf("invalid invoice id %q", a)
+ }
+ ids = append(ids, id)
+ }
+ c, err := newOO(cmd)
+ if err != nil {
+ return err
+ }
+ out, err := c.SetInvoiceStatus(cmd.Context(), statusID, ids...)
+ if err != nil {
+ return err
+ }
+ printObject(out)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&status, "status", "draft", "draft|billed|rejected|paid or numeric id")
+ return cmd
+}
+
+func parseInvoiceStatus(s string) (int, error) {
+ s = strings.TrimSpace(strings.ToLower(s))
+ switch s {
+ case "", "draft", "1":
+ return onlyoffice.InvoiceStatusDraft, nil
+ case "billed", "2":
+ return onlyoffice.InvoiceStatusBilled, nil
+ case "rejected", "3":
+ return onlyoffice.InvoiceStatusRejected, nil
+ case "paid", "4":
+ return onlyoffice.InvoiceStatusPaid, nil
+ default:
+ var n int
+ if _, err := fmt.Sscan(s, &n); err != nil || n <= 0 {
+ return 0, fmt.Errorf("unknown status %q (draft|billed|rejected|paid)", s)
+ }
+ return n, nil
+ }
+}
+
+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(¤cy, "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
+ },
+ }
+}
diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go
index 4e7f254..d6dc4fe 100644
--- a/cmd/oo/mails.go
+++ b/cmd/oo/mails.go
@@ -1,6 +1,7 @@
package main
import (
+ "fmt"
"strconv"
"strings"
@@ -11,7 +12,7 @@ import (
var mailsCmd = &cobra.Command{
Use: "mails",
Aliases: []string{"mail"},
- Short: "OnlyOffice Workspace mail — list, read, delete",
+ Short: "OnlyOffice Workspace mail — list, read, draft, delete",
}
func init() {
@@ -20,6 +21,9 @@ func init() {
mailsCmd.AddCommand(mailsFoldersCmd())
mailsCmd.AddCommand(mailsListCmd())
mailsCmd.AddCommand(mailsGetCmd())
+ mailsCmd.AddCommand(mailsDraftCmd())
+ mailsCmd.AddCommand(mailsAttachCmd())
+ mailsCmd.AddCommand(mailsDraftInvoiceCmd())
mailsCmd.AddCommand(mailsDeleteCmd())
}
@@ -126,6 +130,179 @@ func mailsGetCmd() *cobra.Command {
}
}
+func mailsDraftCmd() *cobra.Command {
+ var from, to, cc, bcc, subject, body, html string
+ var id int64
+ cmd := &cobra.Command{
+ Use: "draft",
+ Short: "Create or update a mail draft (OnlyOffice Mail)",
+ Long: `Save a draft in /addons/mail (PUT /api/2.0/mail/drafts/save).
+
+ oo mails draft --to a@b.com --subject "…" --body " … … ")
+ b.WriteString(strings.ReplaceAll(p, "\n", "
"))
+ b.WriteString("
", "
\n\n
") +} + +// Int64FromMap coerces OnlyOffice numeric id fields (float64/int/string) to int64. +func Int64FromMap(m map[string]any, key string) int64 { + switch v := m[key].(type) { + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + case string: + n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + return n + default: + n, _ := strconv.ParseInt(fmt.Sprint(m[key]), 10, 64) + return n + } } // ResolveMailFolder maps a CLI folder name or numeric string to a folder id. diff --git a/mails_test.go b/mails_test.go index 3302f0c..be81255 100644 --- a/mails_test.go +++ b/mails_test.go @@ -1,6 +1,9 @@ package onlyoffice -import "testing" +import ( + "strings" + "testing" +) func TestResolveMailFolder(t *testing.T) { tests := []struct { @@ -70,3 +73,27 @@ func TestMailMessagesAsTableRows(t *testing.T) { t.Fatalf("got %+v", rows[0]) } } + +func TestPlainTextToMailHTML(t *testing.T) { + got := PlainTextToMailHTML("Hello\n\nWorld\nline2") + if !strings.Contains(got, "
Hello
") || !strings.Contains(got, "WorldAlready
" + if PlainTextToMailHTML(html) != html { + t.Fatalf("html passthrough failed") + } + spaced := MailHTMLWithBlankParagraphs("A
\nB
") + if spaced != "A
\n\n
B
" { + t.Fatalf("spaced: %q", spaced) + } +} + +func TestInt64FromMap(t *testing.T) { + if Int64FromMap(map[string]any{"id": float64(99)}, "id") != 99 { + t.Fatal("float64") + } + if Int64FromMap(map[string]any{"id": "42"}, "id") != 42 { + t.Fatal("string") + } +}