Merge pull request #20 from eSlider/feat/crm-invoices
feat(crm): invoices CLI and opportunity update
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+178
-1
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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...]",
|
||||||
|
|||||||
@@ -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> </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`
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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> </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
@@ -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> </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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user