Merge pull request #20 from eSlider/feat/crm-invoices

feat(crm): invoices CLI and opportunity update
This commit is contained in:
2026-08-06 14:58:30 +01:00
committed by GitHub
co-authored by GitHub
15 changed files with 1646 additions and 16 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`. - `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle. - `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`). - `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. - 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): - **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). - `main.go` — entry point (docstring lists the command tree).
+12
View File
@@ -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 [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). 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) ## [0.9.0](https://github.com/eSlider/go-onlyoffice/compare/v0.8.3...v0.9.0) (2026-07-26)
+42 -5
View File
@@ -623,15 +623,13 @@ oo tasks files detach 208 12345
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) | | `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`) | | `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
| `users` | `list`, `self` (alias: `oo whoami`) | | `users` | `list`, `self` (alias: `oo whoami`) |
| `contacts` | `list`, `get`, `delete`, `info-add` | | `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info` |
| `persons` | `list` (filtered), `create`, `delete` |
| `companies` | `list` (filtered), `create`, `delete` |
| `contacts` | `list`, `get`, `delete`, `info-add`, `dedupe-info` |
| `persons` | `list`, `create`, `delete`, `dedupe` | | `persons` | `list`, `create`, `delete`, `dedupe` |
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` | | `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` | | `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` | | `crm` | `cleanup` |
| `mails` | `accounts`, `folders`, `list`, `get`, `delete` | | `mails` | `accounts`, `folders`, `list`, `get`, `draft`, `attach`, `draft-invoice`, `delete` |
| `cases` | `list`, `create`, `delete`, `member-add` | | `cases` | `list`, `create`, `delete`, `member-add` |
| `crm-tasks` | `list`, `create`, `delete`, `categories` | | `crm-tasks` | `list`, `create`, `delete`, `categories` |
| `applications` | `sync` | | `applications` | `sync` |
@@ -718,6 +716,32 @@ oo opportunities dedupe-members
oo opportunities fix-titles 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 **Deal grouping flag** — when the same role at the same company created
separate deals (`Engineer @ Acme` vs `Engineer`): 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) # Remove one or more messages (server moves to trash or deletes per Mail rules)
oo mails delete 5664 oo mails delete 5664
oo mails delete 5664 5663 5661 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 `<a href="…#…">` truncates at `#`.
**Table output** splits the `from` header into `fromName` and `fromAddress` **Table output** splits the `from` header into `fromName` and `fromAddress`
(e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw (e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw
API payload. API payload.
+2 -2
View File
@@ -11,7 +11,7 @@ func TestRootRegistersSubjects(t *testing.T) {
want := []string{ want := []string{
"calendar", "projects", "tasks", "users", "whoami", "calendar", "projects", "tasks", "users", "whoami",
"contacts", "persons", "companies", "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())) got := make(map[string]bool, len(rootCmd.Commands()))
for _, c := range rootCmd.Commands() { for _, c := range rootCmd.Commands() {
@@ -38,7 +38,7 @@ func TestRootHelpListsSubjects(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
help := out.String() 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) { if !strings.Contains(help, snippet) {
t.Fatalf("help missing %q", snippet) t.Fatalf("help missing %q", snippet)
} }
+1 -1
View File
@@ -20,7 +20,7 @@ var outputFormat = "table"
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "oo", Use: "oo",
Short: "OnlyOffice Workspace CLI — subject-based command tree", 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, Version: version,
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: false, SilenceErrors: false,
+35 -2
View File
@@ -201,7 +201,7 @@ func contactsInfoAddCmd() *cobra.Command {
} }
func personsCreateCmd() *cobra.Command { func personsCreateCmd() *cobra.Command {
var first, last, email, linkedin string var first, last, email, linkedin, phone string
var companyID int var companyID int
var jobTitle, about string var jobTitle, about string
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -224,6 +224,9 @@ func personsCreateCmd() *cobra.Command {
if email != "" { if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true) _, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
} }
if phone != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "Phone", phone, "Work", true)
}
if linkedin != "" { if linkedin != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false) _, _ = 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(&jobTitle, "job-title", "", "")
cmd.Flags().StringVar(&about, "about", "", "about / bio") cmd.Flags().StringVar(&about, "about", "", "about / bio")
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)") 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)") cmd.Flags().StringVar(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)")
return cmd return cmd
} }
@@ -347,7 +351,7 @@ func contactEmailsFromMap(p map[string]any) []string {
} }
func companiesCreateCmd() *cobra.Command { func companiesCreateCmd() *cobra.Command {
var name, email, website string var name, email, website, phone, about, street, city, state, zip, country string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "create", Use: "create",
Aliases: []string{"add"}, Aliases: []string{"add"},
@@ -365,12 +369,34 @@ func companiesCreateCmd() *cobra.Command {
return err return err
} }
cid := strconv.Itoa(int(flexIDFloat(out["id"]))) 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 != "" { if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true) _, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
} }
if website != "" { if website != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false) _, _ = 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) printObject(out)
return nil return nil
}, },
@@ -378,6 +404,13 @@ func companiesCreateCmd() *cobra.Command {
cmd.Flags().StringVar(&name, "name", "", "company name") cmd.Flags().StringVar(&name, "name", "", "company name")
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)") cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
cmd.Flags().StringVar(&website, "website", "", "website url (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 return cmd
} }
+441
View File
@@ -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(&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 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(&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
},
}
}
+178 -1
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"strconv" "strconv"
"strings" "strings"
@@ -11,7 +12,7 @@ import (
var mailsCmd = &cobra.Command{ var mailsCmd = &cobra.Command{
Use: "mails", Use: "mails",
Aliases: []string{"mail"}, Aliases: []string{"mail"},
Short: "OnlyOffice Workspace mail — list, read, delete", Short: "OnlyOffice Workspace mail — list, read, draft, delete",
} }
func init() { func init() {
@@ -20,6 +21,9 @@ func init() {
mailsCmd.AddCommand(mailsFoldersCmd()) mailsCmd.AddCommand(mailsFoldersCmd())
mailsCmd.AddCommand(mailsListCmd()) mailsCmd.AddCommand(mailsListCmd())
mailsCmd.AddCommand(mailsGetCmd()) mailsCmd.AddCommand(mailsGetCmd())
mailsCmd.AddCommand(mailsDraftCmd())
mailsCmd.AddCommand(mailsAttachCmd())
mailsCmd.AddCommand(mailsDraftInvoiceCmd())
mailsCmd.AddCommand(mailsDeleteCmd()) 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 "<p>…</p>"
oo mails draft --id 123 --to a@b.com --subject "…" --html "<p>…</p>"
`,
RunE: func(cmd *cobra.Command, args []string) error {
if to == "" {
return fmt.Errorf("--to is required")
}
htmlBody := body
if html != "" {
htmlBody = html
}
if htmlBody == "" {
return fmt.Errorf("--body or --html is required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{
ID: id,
From: from,
To: to,
Cc: cc,
Bcc: bcc,
Subject: subject,
Body: onlyoffice.PlainTextToMailHTML(htmlBody),
})
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().Int64Var(&id, "id", 0, "existing draft id (0 = create)")
cmd.Flags().StringVar(&from, "from", "", "from address (default: first enabled mailbox)")
cmd.Flags().StringVar(&to, "to", "", "recipient (required)")
cmd.Flags().StringVar(&cc, "cc", "", "cc")
cmd.Flags().StringVar(&bcc, "bcc", "", "bcc")
cmd.Flags().StringVar(&subject, "subject", "", "subject")
cmd.Flags().StringVar(&body, "body", "", "plain text or HTML body")
cmd.Flags().StringVar(&html, "html", "", "HTML body (alias of --body when set)")
return cmd
}
func mailsAttachCmd() *cobra.Command {
var fileID int64
cmd := &cobra.Command{
Use: "attach MESSAGE_ID",
Short: "Attach an OnlyOffice Files document to a draft/message",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if fileID <= 0 {
return fmt.Errorf("--file-id is required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.AttachMailDocument(cmd.Context(), args[0], fileID)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().Int64Var(&fileID, "file-id", 0, "OnlyOffice file id (e.g. invoice PDF)")
return cmd
}
func mailsDraftInvoiceCmd() *cobra.Command {
var to, from, subject, body string
var invoiceID int
cmd := &cobra.Command{
Use: "draft-invoice",
Short: "Create a mail draft with regenerated invoice PDF attached",
Long: `Regenerate the CRM invoice PDF, save an OnlyOffice Mail draft, and attach the PDF.
Does not send. Open /addons/mail/#drafts to review.
oo mails draft-invoice --invoice 16 --to info@example.com
`,
RunE: func(cmd *cobra.Command, args []string) error {
if invoiceID <= 0 || to == "" {
return fmt.Errorf("--invoice and --to are required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
pdf, err := c.ForceRegenerateInvoicePDF(cmd.Context(), strconv.Itoa(invoiceID))
if err != nil {
return err
}
fileID := onlyoffice.Int64FromMap(pdf, "id")
if fileID <= 0 {
return fmt.Errorf("invoice PDF has no file id: %+v", pdf)
}
inv, err := c.GetInvoice(cmd.Context(), strconv.Itoa(invoiceID))
if err != nil {
return err
}
number := strings.TrimSpace(fmt.Sprint(inv["number"]))
cost := formatInvoiceCostEUR(inv["cost"])
if subject == "" {
subject = fmt.Sprintf("Rechnung %s — Service package (%s EUR)", number, cost)
}
if body == "" {
body = fmt.Sprintf(`Guten Tag,
anbei übersende ich die Rechnung %s über %s EUR
für das Service package (OS image inkl. Backup, Monitoring, RustDesk,
gemeinsamem Datenträger und Netzwerkzugriff).
Zahlungsziel: 14 Tage netto.
Steuerhinweis: siehe Notizen auf der Rechnung
(Reverse Charge / § 13b UStG).
Mit freundlichen Grüßen
Example Author`, number, cost)
}
draft, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{
From: from,
To: to,
Subject: subject,
Body: onlyoffice.MailHTMLWithBlankParagraphs(onlyoffice.PlainTextToMailHTML(body)),
})
if err != nil {
return err
}
mid := fmt.Sprint(draft["id"])
att, err := c.AttachMailDocument(cmd.Context(), mid, fileID)
if err != nil {
return fmt.Errorf("draft %s created but attach failed: %w", mid, err)
}
if outputFormat == "json" {
printObject(map[string]any{"draft": draft, "attachment": att, "pdfFileId": fileID})
return nil
}
fmt.Printf("draft %s to=%s subject=%q pdfFileId=%d\n", mid, to, subject, fileID)
fmt.Printf("open: https://office.example.com/addons/mail/#drafts\n")
return nil
},
}
cmd.Flags().IntVar(&invoiceID, "invoice", 0, "CRM invoice id")
cmd.Flags().StringVar(&to, "to", "", "recipient")
cmd.Flags().StringVar(&from, "from", "", "from address (default mailbox)")
cmd.Flags().StringVar(&subject, "subject", "", "override subject")
cmd.Flags().StringVar(&body, "body", "", "override plain-text body")
return cmd
}
func formatInvoiceCostEUR(v any) string {
s := strings.TrimSpace(fmt.Sprint(v))
s = strings.TrimSuffix(s, ".00")
s = strings.TrimSuffix(s, ".0")
if s == "" || s == "<nil>" {
return "?"
}
return s
}
func mailsDeleteCmd() *cobra.Command { func mailsDeleteCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "delete ID [ID...]", Use: "delete ID [ID...]",
+5 -2
View File
@@ -6,17 +6,20 @@
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete) // oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach) // oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
// oo users list | self (alias: oo whoami) // oo users list | self (alias: oo whoami)
// oo contacts list | get | delete | info-add | dedupe-info // oo contacts list | get | delete | info-add | merge | dedupe-info
// oo persons list | create | delete | dedupe // oo persons list | create | delete | dedupe
// oo companies list | create | delete | dedupe | dedupe-persons // oo companies list | create | delete | dedupe | dedupe-persons
// oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles // oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles
// oo cases list | create | delete | member-add // oo cases list | create | delete | member-add
// oo crm-tasks list | create | delete | categories // oo crm-tasks list | create | delete | categories
// oo crm cleanup // oo crm cleanup
// oo mails accounts | folders | list | get | delete // oo mails accounts | folders | list | get | draft | attach | draft-invoice | delete
// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
// oo applications sync // oo applications sync
// oo catalog scan-contacts | scan-projects | scan-thunderbird | merge | match | apply // oo catalog scan-contacts | scan-projects | scan-thunderbird | merge | match | apply
// //
// CRM association rules: docs/crm-associations.md
//
// Every list supports `--output/-o json|table` (table is the default). // Every list supports `--output/-o json|table` (table is the default).
// //
// Build & install: // Build & install:
+35
View File
@@ -19,6 +19,7 @@ func init() {
opportunitiesCmd.AddCommand(oppListCmd()) opportunitiesCmd.AddCommand(oppListCmd())
opportunitiesCmd.AddCommand(oppGetCmd()) opportunitiesCmd.AddCommand(oppGetCmd())
opportunitiesCmd.AddCommand(oppCreateCmd()) opportunitiesCmd.AddCommand(oppCreateCmd())
opportunitiesCmd.AddCommand(oppUpdateCmd())
opportunitiesCmd.AddCommand(oppDeleteCmd()) opportunitiesCmd.AddCommand(oppDeleteCmd())
opportunitiesCmd.AddCommand(oppStagesCmd()) opportunitiesCmd.AddCommand(oppStagesCmd())
opportunitiesCmd.AddCommand(oppMemberAddCmd()) opportunitiesCmd.AddCommand(oppMemberAddCmd())
@@ -118,6 +119,40 @@ func oppCreateCmd() *cobra.Command {
return cmd 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 { func oppDeleteCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]", Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]",
+121
View File
@@ -0,0 +1,121 @@
# CRM associations (company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail)
Operational rules learned from real billing work (AcmeClient INV-2026-01, AcmeProject INV-2026-01).
Tooling: `oo` CLI + this library. Business SSOT remains OnlyOffice (`ASR-0002`).
## Canonical graph
One **legal company** owns the relationship. Do not invent a second “bill-to”
company just for PDF layout.
```text
Company (#CONTACT_ID)
├── Person (GF / buyer contact) oo persons create --company-id
├── Opportunity / Deal oo opportunities … ; member-add company + person
├── Project (hub) oo projects … ; contacts add company + person
│ └── Epic + subtasks
└── Invoice (Draft → …) oo invoices create --contact COMPANY --opportunity DEAL
└── PDF file oo invoices pdf ID
└── Mail draft oo mails draft-invoice --invoice ID --to …
```
| Layer | CLI | Must link |
|-------|-----|-----------|
| Company | `oo companies create` | website, email, phone, **one** Billing address |
| Person | `oo persons create --company-id` | job title; never encode employer in `lastName` |
| Deal | `oo opportunities create` + `member-add` | company **and** person as members |
| Project | `oo projects create` + `contacts add` | same company + person |
| Invoice | `oo invoices create --contact COMPANY --opportunity DEAL` | `entityId` at **create** |
| Mail | `oo mails draft-invoice` | attach current PDF; **do not send** until confirmed |
UI checks (same company card):
- `#contacts` → person
- `#deals` → opportunity
- `#projects` → hub project
- `#invoices` on the **deal** → invoice (needs `entity`)
- `#files` → preferably **one** current `P-….pdf`
## Hard rules
1. **One company per legal entity.** Duplicate “bill-to” contacts empty Deals /
Projects / Contacts tabs and break merge. Prefer
`oo contacts merge FROM INTO` (keeps `INTO`) or `oo companies dedupe`.
2. **Link invoice → deal at create.**
`POST /crm/invoice` with `entityId` + `entityType: 0` (Opportunity).
`oo invoices update … --opportunity` often returns **400**
(“Value does not fall within the expected range”). If the link is missing,
delete the Draft and recreate with `--opportunity`.
3. **Bill To = company id**, not a throwaway contact. Person stays under the
company (`companyId`). Optional `consigneeId` for Empfänger when the portal
template prints it.
4. **Stay Draft until mail is ready.** Billed (`status id=2`) is **not editable**
via content PUT. Going Billed → Draft via `…/crm/invoice/status/1` usually
**does not work** — delete + recreate Draft instead.
5. **Do not regenerate PDF in a loop** without cleanup. Each
`GET …/crm/invoice/{id}/pdf` attaches a new file to the company (and often
the deal). Keep `invoice.fileID`; delete older `P-*.pdf` with
`oo invoices pdf-cleanup ID` / Documents `fileops/delete`.
## Invoice PDF quirks
| Symptom | Workaround |
|---------|------------|
| Cached / stale PDF | Touch invoice (Draft PUT that clears `fileID`), then `GET …/pdf` — `oo invoices pdf ID --force` |
| Billing address missing on **new** PDFs | Temporary multiline `companyName` (`Line1\nLine2\n…`) on the **canonical** company → force PDF → restore clean name. Cached `fileID` keeps the multiline Bill To. |
| Separate bill-to company for newlines | **Forbidden** — merge back to the real company |
| Invoice **number** won’t change on PUT | Delete Draft and recreate with the desired number |
| Notizen / Bedingungen spacing | Leading `\n` and blank lines only — no HTML (tags print literally) |
| Issuer NIE / street lines | Organisation profile address (`street` with `\n`), not only terms |
Status ids commonly used on this portal: `1` Draft, `2` Billed, `3` Rejected, `4` Paid.
## Mail quirks
| Symptom | Workaround |
|---------|------------|
| Signature / body doubles Matrix URL | Put chat in **one** place only. UI drafts: signature. API send: body (API **does not** append signature). |
| Signature / body cuts Matrix URL at `#` | Plain text `chat: https://matrix.to/#/@user:server` — avoid `<a href="…#…">` (or encode `#` as `%23` in href) |
| German letter spacing | Blank `<p>&nbsp;</p>` between blocks (`MailHTMLWithBlankParagraphs`) |
| Send | `PUT /api/2.0/mail/messages/send.json` with `id/from/to/subject/body`; omit empty `cc`/`bcc`. Never auto-send; draft only until the human confirms |
Prefer OnlyOffice Mail (`/addons/mail/#drafts`) over Gmail MCP for invoice delivery.
## Project / task quirks
- Hub title: `CC | Company` (e.g. `DE | AcmeClient Ambulanter Pflegedienst GmbH`).
- Streams = epics/tasks under the hub, not a third title segment (unless the
project itself is a named delivery stream).
- Closing a **subtask**:
`PUT /api/2.0/project/task/{epicId}/{subtaskId}/status` with `status=2`.
`oo tasks update SUBTASK -s closed` returns **404** for subtasks.
- After deleting a CRM contact, `GET /project/contact/{deletedId}` may still
return projects (ghost). Official project contact list should only show live
ids; unlink may 400 if the contact is gone.
## Merge / cleanup cheat sheet
```bash
# Keep the human-created company (INTO), drop the duplicate (FROM)
oo contacts merge 1334 1328
# Or by normalized name (careful — whole CRM)
oo companies dedupe
# Invoice ↔ deal must exist at create
oo invoices create --number P-YYYY-NN --contact CONTACT_ID --item N \
--price 300 --opportunity OPPORTUNITY_ID --language de-DE …
# Fresh PDF + prune older P-*.pdf on company/deal
oo invoices pdf 41 --force
oo invoices pdf-cleanup INVOICE_ID
# Mail draft (no send)
oo mails draft-invoice --invoice INVOICE_ID --to info@client.de
```
## Related
- README § invoices / mail / CRM cleanup
- Skill `oo-clients-projects` (Cursor)
- inventar ASR-0014 / `ops/oo-clients-contacts-sync.md`
+52
View File
@@ -244,6 +244,58 @@ func (c *Client) putJSONObject(ctx context.Context, path string, body any) (map[
return unmarshalResponseObject(raw) return unmarshalResponseObject(raw)
} }
// postJSONObject issues an authenticated POST with JSON body and decodes response.
func (c *Client) postJSONObject(ctx context.Context, path string, body any) (map[string]any, error) {
raw, err := c.postJSON(ctx, path, body)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// postJSON issues an authenticated POST with application/json body.
func (c *Client) postJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
auth, err := c.authHeader()
if err != nil {
return nil, err
}
var rdr io.Reader
switch b := body.(type) {
case nil:
rdr = strings.NewReader("{}")
case []byte:
rdr = bytes.NewReader(b)
case string:
rdr = strings.NewReader(b)
default:
buf, err := json.Marshal(b)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", auth)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("POST JSON %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
}
return raw, nil
}
// putJSON issues an authenticated PUT with application/json body. // putJSON issues an authenticated PUT with application/json body.
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) { func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
auth, err := c.authHeader() auth, err := c.authHeader()
+545
View File
@@ -0,0 +1,545 @@
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
EntityID int64 // 0 = omit; link to opportunity/case
EntityType int // OnlyOffice EntityType; 0 = Opportunity
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
}
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)
}
// UpdateInvoiceParams are fields for PUT /api/2.0/crm/invoice/{id}.
// Loads the current invoice and merges non-zero / set fields.
type UpdateInvoiceParams struct {
EntityID int64 // link to opportunity; 0 = leave unchanged
EntityType int
ContactID int64 // 0 = leave unchanged
Description string // invoice notes; empty + DescriptionSet=false keeps existing
DescriptionSet bool
PurchaseOrder string
PurchaseOrderSet bool
Terms string
TermsSet bool
}
// UpdateInvoice PUTs a full invoice body (OnlyOffice requires complete payload).
//
// Linking an opportunity via EntityID on an existing invoice often returns HTTP 400
// on this portal — prefer CreateInvoice with EntityID set. See docs/crm-associations.md.
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.DescriptionSet {
body["description"] = p.Description
}
if p.PurchaseOrderSet {
body["purchaseOrderNumber"] = p.PurchaseOrder
}
if p.TermsSet {
body["terms"] = p.Terms
}
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.
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)))
}
// Invoice status ids used by OnlyOffice CRM on produktor.io.
const (
InvoiceStatusDraft = 1
InvoiceStatusBilled = 2
InvoiceStatusRejected = 3
InvoiceStatusPaid = 4
)
// SetInvoiceStatus sets CRM invoice status for one or more invoice ids
// (PUT /api/2.0/crm/invoice/status/{statusId} with invoiceids).
// Note: Billed→Draft often does not stick; recreate Draft instead (see docs/crm-associations.md).
func (c *Client) SetInvoiceStatus(ctx context.Context, statusID int, invoiceIDs ...int64) (map[string]any, error) {
if statusID <= 0 {
return nil, fmt.Errorf("status id is required")
}
if len(invoiceIDs) == 0 {
return nil, fmt.Errorf("at least one invoice id is required")
}
ids := make([]string, 0, len(invoiceIDs))
for _, id := range invoiceIDs {
ids = append(ids, strconv.FormatInt(id, 10))
}
fields := url.Values{}
fields.Set("invoiceids", strings.Join(ids, ","))
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/status/%d", statusID), fields)
}
// InvoicePDFFile returns invoice PDF file metadata (id, title, viewUrl).
// Without force, OnlyOffice may return a cached fileID with stale layout.
func (c *Client) InvoicePDFFile(ctx context.Context, invoiceID string) (map[string]any, error) {
id := strings.TrimSpace(invoiceID)
if id == "" {
return nil, fmt.Errorf("InvoicePDFFile: invoice id is required")
}
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s/pdf", url.PathEscape(id)))
}
// ForceRegenerateInvoicePDF clears the cached PDF (Draft touch) then requests a new file.
// No-op touch when the invoice is not editable (e.g. Billed) — falls back to GET /pdf.
func (c *Client) ForceRegenerateInvoicePDF(ctx context.Context, invoiceID string) (map[string]any, error) {
id := strings.TrimSpace(invoiceID)
if id == "" {
return nil, fmt.Errorf("ForceRegenerateInvoicePDF: invoice id is required")
}
inv, err := c.GetInvoice(ctx, id)
if err != nil {
return nil, err
}
canEdit := true
if v, ok := inv["canEdit"].(bool); ok {
canEdit = v
}
if canEdit {
desc := stringField(inv, "description")
// Append/remove a trailing space so PUT clears fileID without visible change.
touch := desc + " "
if strings.HasSuffix(desc, " ") {
touch = strings.TrimSuffix(desc, " ")
}
if _, err := c.UpdateInvoice(ctx, id, UpdateInvoiceParams{
Description: touch,
DescriptionSet: true,
}); err != nil {
return nil, fmt.Errorf("ForceRegenerateInvoicePDF: clear cache: %w", err)
}
}
return c.InvoicePDFFile(ctx, id)
}
// ListCRMContactFiles lists Documents attached on a CRM contact card (#files).
func (c *Client) ListCRMContactFiles(ctx context.Context, contactID string) ([]map[string]any, error) {
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/files.json", url.PathEscape(contactID)))
}
// ListOpportunityFiles lists Documents attached on a CRM opportunity.
func (c *Client) ListOpportunityFiles(ctx context.Context, opportunityID string) ([]map[string]any, error) {
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/files.json", url.PathEscape(opportunityID)))
}
// PurgeStaleInvoicePDFs deletes older P-*.pdf copies on the invoice contact (and linked
// opportunity) while keeping the invoice's current fileID. Returns deleted file ids.
func (c *Client) PurgeStaleInvoicePDFs(ctx context.Context, invoiceID string) ([]int, error) {
inv, err := c.GetInvoice(ctx, invoiceID)
if err != nil {
return nil, err
}
keep := flexInt(inv["fileID"])
number := strings.TrimSpace(stringField(inv, "number"))
base := number
for strings.HasSuffix(base, "b") || strings.HasSuffix(base, "B") {
base = base[:len(base)-1]
}
if base == "" {
base = "P-"
}
seen := map[int]struct{}{}
var candidates []int
addFiles := func(files []map[string]any) {
for _, f := range files {
title := stringField(f, "title")
id := int(flexInt(f["id"]))
if id == 0 || id == int(keep) {
continue
}
if !strings.HasPrefix(title, "P-") {
continue
}
if !strings.HasPrefix(title, base) {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
candidates = append(candidates, id)
}
}
if m, ok := inv["contact"].(map[string]any); ok {
cid := strconv.FormatInt(flexInt(m["id"]), 10)
if cid != "0" {
files, err := c.ListCRMContactFiles(ctx, cid)
if err != nil {
return nil, err
}
addFiles(files)
}
}
if ent, ok := inv["entity"].(map[string]any); ok && ent != nil {
if strings.EqualFold(fmt.Sprint(ent["entityType"]), "opportunity") || flexInt(ent["entityType"]) == 0 {
oid := strconv.FormatInt(flexInt(ent["entityId"]), 10)
if oid != "0" {
files, err := c.ListOpportunityFiles(ctx, oid)
if err != nil {
return nil, err
}
addFiles(files)
}
}
}
if len(candidates) == 0 {
return nil, nil
}
if err := c.DeleteFiles(ctx, candidates); err != nil {
return nil, err
}
// CRM may still list deleted files briefly; also try CRM unlink.
for _, fid := range candidates {
_, _ = c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/files/%d.json", fid))
}
return candidates, nil
}
// 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
}
+148 -1
View File
@@ -5,6 +5,7 @@ package onlyoffice
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/mail" "net/mail"
"net/url" "net/url"
@@ -96,11 +97,157 @@ func (c *Client) GetMailMessage(ctx context.Context, messageID string) (map[stri
} }
// RemoveMailMessages deletes messages by id (PUT /api/2.0/mail/messages/remove). // RemoveMailMessages deletes messages by id (PUT /api/2.0/mail/messages/remove).
// The API response "response" field may be a number or object; success is HTTP 2xx.
func (c *Client) RemoveMailMessages(ctx context.Context, ids ...int) (map[string]any, error) { func (c *Client) RemoveMailMessages(ctx context.Context, ids ...int) (map[string]any, error) {
if len(ids) == 0 { if len(ids) == 0 {
return nil, fmt.Errorf("RemoveMailMessages: at least one id is required") return nil, fmt.Errorf("RemoveMailMessages: at least one id is required")
} }
return c.putJSONObject(ctx, "/api/2.0/mail/messages/remove", map[string]any{"ids": ids}) raw, err := c.putJSON(ctx, "/api/2.0/mail/messages/remove", map[string]any{"ids": ids})
if err != nil {
return nil, err
}
out := map[string]any{"ok": true, "ids": ids}
if resp, err := responseField(raw, "response"); err == nil {
var n json.Number
if json.Unmarshal(resp, &n) == nil {
out["response"] = n.String()
} else {
var m map[string]any
if json.Unmarshal(resp, &m) == nil {
out["response"] = m
} else {
out["response"] = string(resp)
}
}
}
return out, nil
}
// SaveMailDraftParams creates or updates a draft via PUT /api/2.0/mail/drafts/save.
// Id 0 creates a new draft. Body is HTML; the API field name is "body" (not htmlBody).
type SaveMailDraftParams struct {
ID int64 // 0 = create
From string // mailbox address, e.g. eslider@gmail.com
To string // comma-separated or single address
Cc string
Bcc string
Subject string
Body string // HTML
}
// SaveMailDraft saves a draft message. Returns the saved message map (incl. id).
func (c *Client) SaveMailDraft(ctx context.Context, p SaveMailDraftParams) (map[string]any, error) {
if strings.TrimSpace(p.To) == "" {
return nil, fmt.Errorf("SaveMailDraft: to is required")
}
if strings.TrimSpace(p.From) == "" {
from, err := c.defaultMailFrom(ctx)
if err != nil {
return nil, err
}
p.From = from
}
body := map[string]any{
"id": p.ID,
"from": p.From,
"to": p.To,
"cc": p.Cc,
"bcc": p.Bcc,
"subject": p.Subject,
"body": p.Body,
}
return c.putJSONObject(ctx, "/api/2.0/mail/drafts/save", body)
}
func (c *Client) defaultMailFrom(ctx context.Context) (string, error) {
accounts, err := c.ListMailAccounts(ctx)
if err != nil {
return "", err
}
var fallback string
for _, a := range accounts {
email := strings.TrimSpace(stringFromMap(a, "email"))
if email == "" || email == "<nil>" {
continue
}
if boolField(a, "enabled") || boolField(a, "isDefault") {
return email, nil
}
if fallback == "" {
fallback = email
}
}
if fallback == "" {
return "", fmt.Errorf("SaveMailDraft: from is required (no mailbox on account)")
}
return fallback, nil
}
// AttachMailDocument attaches an OnlyOffice Files document to a mail message/draft.
// fileID is the Documents/CRM file id (e.g. from InvoicePDFFile).
func (c *Client) AttachMailDocument(ctx context.Context, messageID string, fileID int64) (map[string]any, error) {
id := strings.TrimSpace(messageID)
if id == "" {
return nil, fmt.Errorf("AttachMailDocument: message id is required")
}
if fileID <= 0 {
return nil, fmt.Errorf("AttachMailDocument: file id is required")
}
fields := url.Values{}
fields.Set("fileId", strconv.FormatInt(fileID, 10))
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/mail/messages/%s/document", url.PathEscape(id)), fields)
}
// PlainTextToMailHTML turns plain text into simple HTML paragraphs for drafts.
// If s already looks like HTML, it is returned unchanged.
func PlainTextToMailHTML(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if strings.Contains(s, "<") && strings.Contains(s, ">") {
return s
}
parts := strings.Split(s, "\n\n")
var b strings.Builder
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
b.WriteString("<p>")
b.WriteString(strings.ReplaceAll(p, "\n", "<br/>"))
b.WriteString("</p>\n")
}
return b.String()
}
// MailHTMLWithBlankParagraphs inserts empty paragraphs between blocks
// (German Geschäftsbrief-style vertical spacing in OnlyOffice Mail).
func MailHTMLWithBlankParagraphs(html string) string {
html = strings.TrimSpace(html)
if html == "" {
return ""
}
return strings.ReplaceAll(html, "</p>\n<p>", "</p>\n<p>&nbsp;</p>\n<p>")
}
// 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. // ResolveMailFolder maps a CLI folder name or numeric string to a folder id.
+28 -1
View File
@@ -1,6 +1,9 @@
package onlyoffice package onlyoffice
import "testing" import (
"strings"
"testing"
)
func TestResolveMailFolder(t *testing.T) { func TestResolveMailFolder(t *testing.T) {
tests := []struct { tests := []struct {
@@ -70,3 +73,27 @@ func TestMailMessagesAsTableRows(t *testing.T) {
t.Fatalf("got %+v", rows[0]) t.Fatalf("got %+v", rows[0])
} }
} }
func TestPlainTextToMailHTML(t *testing.T) {
got := PlainTextToMailHTML("Hello\n\nWorld\nline2")
if !strings.Contains(got, "<p>Hello</p>") || !strings.Contains(got, "World<br/>line2") {
t.Fatalf("got %q", got)
}
html := "<p>Already</p>"
if PlainTextToMailHTML(html) != html {
t.Fatalf("html passthrough failed")
}
spaced := MailHTMLWithBlankParagraphs("<p>A</p>\n<p>B</p>")
if spaced != "<p>A</p>\n<p>&nbsp;</p>\n<p>B</p>" {
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")
}
}