Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions internal/api/attachments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package api

import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"strings"

"github.com/voska/qbo-cli/internal/errfmt"
)

func (c *Client) Upload(ctx context.Context, metadata []byte, filename, contentType string, fileContent io.Reader) (map[string]any, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)

metaHeader := make(textproto.MIMEHeader)
metaHeader.Set("Content-Disposition", `form-data; name="file_metadata_01"; filename="metadata.json"`)
metaHeader.Set("Content-Type", "application/json")
metaPart, err := writer.CreatePart(metaHeader)
if err != nil {
return nil, errfmt.Wrap(errfmt.ExitError, "cannot create metadata part", err)
}
if _, err := metaPart.Write(metadata); err != nil {
return nil, errfmt.Wrap(errfmt.ExitError, "cannot write metadata", err)
}

fileHeader := make(textproto.MIMEHeader)
fileHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file_content_01"; filename="%s"`, filename))
if contentType != "" {
fileHeader.Set("Content-Type", contentType)
}
filePart, err := writer.CreatePart(fileHeader)
if err != nil {
return nil, errfmt.Wrap(errfmt.ExitError, "cannot create file part", err)
}
if _, err := io.Copy(filePart, fileContent); err != nil {
return nil, errfmt.Wrap(errfmt.ExitError, "cannot write file content", err)
}

if err := writer.Close(); err != nil {
return nil, errfmt.Wrap(errfmt.ExitError, "cannot close multipart writer", err)
}

endpoint := c.url("upload")
endpoint = c.addMinorVersion(endpoint)

req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body)
if err != nil {
return nil, errfmt.Wrap(errfmt.ExitError, "cannot build request", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", writer.FormDataContentType())

result, err := c.do(req)
if err != nil {
return nil, err
}

ar, _ := result["AttachableResponse"].([]any)
if len(ar) == 0 {
return nil, errfmt.New(errfmt.ExitError, "unexpected upload response: missing AttachableResponse")
}
first, _ := ar[0].(map[string]any)
att, _ := first["Attachable"].(map[string]any)
if att == nil {
return nil, errfmt.New(errfmt.ExitError, "unexpected upload response: missing Attachable")
}
return att, nil
}

func (c *Client) FetchDownloadURL(ctx context.Context, attachableID string) (string, error) {
endpoint := c.url("download/" + attachableID)
endpoint = c.addMinorVersion(endpoint)

req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", errfmt.Wrap(errfmt.ExitError, "cannot build request", err)
}

resp, err := c.httpClient.Do(req)
if err != nil {
return "", errfmt.Wrap(errfmt.ExitRetryable, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", errfmt.Wrap(errfmt.ExitError, "cannot read response", err)
}

if resp.StatusCode >= 400 {
return "", mapHTTPError(resp.StatusCode, respBody)
}

return strings.TrimSpace(string(respBody)), nil
}

func (c *Client) Download(ctx context.Context, attachableID string) (io.ReadCloser, error) {
presignedQBOAttachableURL, err := c.FetchDownloadURL(ctx, attachableID)
if err != nil {
return nil, err
}
resp, err := http.Get(presignedQBOAttachableURL) //nolint:gosec // pre-signed download URL from QBO API
if err != nil {
return nil, errfmt.Wrap(errfmt.ExitRetryable, "download failed", err)
}
if resp.StatusCode >= 400 {
_ = resp.Body.Close()
return nil, errfmt.New(errfmt.ExitError, fmt.Sprintf("download failed (%d)", resp.StatusCode))
}
return resp.Body, nil
}
1 change: 1 addition & 0 deletions internal/api/entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type EntityInfo struct {

var entities = map[string]EntityInfo{
"account": {Name: "Account", Endpoint: "account", Queryable: true, Creatable: true, Updatable: true},
"attachable": {Name: "Attachable", Endpoint: "attachable", Queryable: true, Creatable: true, Updatable: true, Deletable: true},
"bill": {Name: "Bill", Endpoint: "bill", Queryable: true, Creatable: true, Updatable: true, Deletable: true},
"billpayment": {Name: "BillPayment", Endpoint: "billpayment", Queryable: true, Creatable: true, Updatable: true, Deletable: true},
"budget": {Name: "Budget", Endpoint: "budget", Queryable: true},
Expand Down
109 changes: 109 additions & 0 deletions internal/cmd/attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package cmd

import (
"encoding/json"
"fmt"
"mime"
"os"
"path/filepath"

"github.com/voska/qbo-cli/internal/api"
"github.com/voska/qbo-cli/internal/errfmt"
"github.com/voska/qbo-cli/internal/output"
)

type AttachCmd struct {
Args []string `arg:"" optional:"" help:"[entity-type entity-id] file-path"`
Note string `name:"note" help:"Note text to include with the attachment."`
IncludeOnSend bool `name:"include-on-send" help:"Include attachment when sending entity."`
}

const maxUploadSize = 100 * 1024 * 1024 // 100 MB

func (c *AttachCmd) Run(g *Globals) error {
var entityType, entityID, filePath string

nargs := len(c.Args)
if nargs != 1 && nargs != 3 {
return errfmt.Usage("usage: qbo attach [entity-type entity-id] <file>")
}
if nargs == 3 {
entityType, entityID, filePath = c.Args[0], c.Args[1], c.Args[2]
} else {
filePath = c.Args[0]
}

var entityName string
if entityType != "" {
entity, ok := api.LookupEntity(entityType)
if !ok {
return errfmt.Usage("unknown entity type: " + entityType)
}
entityName = entity.Name
}

fi, err := os.Stat(filePath)
if err != nil {
return errfmt.Wrap(errfmt.ExitError, "cannot access file", err)
}
if fi.Size() > maxUploadSize {
return errfmt.New(errfmt.ExitError, fmt.Sprintf("file too large (%d bytes, max %d)", fi.Size(), maxUploadSize))
}

fileName := fi.Name()
contentType := mime.TypeByExtension(filepath.Ext(filePath))

metadata := buildMetadata(fileName, contentType, c.Note, entityName, entityID, c.IncludeOnSend)
metaJSON, err := json.Marshal(metadata)
if err != nil {
return errfmt.Wrap(errfmt.ExitError, "cannot marshal metadata", err)
}

if g.CLI.DryRun {
output.Hint("[dry-run] POST /v3/company/{id}/upload")
output.Hint("File: %s (%d bytes)", fileName, fi.Size())
DryRunLog(g.CLI, "POST", "upload", metaJSON)
return nil
}

client, _, err := g.NewAPIClient()
if err != nil {
return err
}

file, err := os.Open(filePath)
if err != nil {
return errfmt.Wrap(errfmt.ExitError, "cannot open file", err)
}
defer func() { _ = file.Close() }()

result, err := client.Upload(g.Ctx, metaJSON, fileName, contentType, file)
if err != nil {
return err
}
return WriteOutput(g.Ctx, result)
}

func buildMetadata(fileName, contentType, note, entityName, entityID string, includeOnSend bool) map[string]any {
meta := map[string]any{
"FileName": fileName,
}
if contentType != "" {
meta["ContentType"] = contentType
}
if note != "" {
meta["Note"] = note
}
if entityName != "" && entityID != "" {
meta["AttachableRef"] = []map[string]any{
{
"EntityRef": map[string]any{
"type": entityName,
"value": entityID,
},
"IncludeOnSend": includeOnSend,
},
}
}
return meta
}
72 changes: 72 additions & 0 deletions internal/cmd/download.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package cmd

import (
"io"
"os"
"path/filepath"

"github.com/voska/qbo-cli/internal/errfmt"
"github.com/voska/qbo-cli/internal/output"
)

type DownloadCmd struct {
ID string `arg:"" help:"Attachable ID to download."`
Output string `name:"output" short:"o" help:"Save to this file path instead of original filename."`
URL bool `name:"url" help:"Print the temporary download URL instead of saving the file."`
}

func (c *DownloadCmd) Run(g *Globals) error {
if g.CLI.DryRun {
output.Hint("[dry-run] GET /v3/company/{id}/download/%s", c.ID)
return nil
}

client, _, err := g.NewAPIClient()
if err != nil {
return err
}

if c.URL {
url, err := client.FetchDownloadURL(g.Ctx, c.ID)
if err != nil {
return err
}
return WriteOutput(g.Ctx, map[string]any{"url": url})
}

savePath := c.Output
if savePath == "" {
meta, err := client.Read(g.Ctx, "attachable", c.ID)
if err != nil {
return err
}
savePath = extractFileName(meta)
}

body, err := client.Download(g.Ctx, c.ID)
if err != nil {
return err
}
defer func() { _ = body.Close() }()

f, err := os.Create(savePath)
if err != nil {
return errfmt.Wrap(errfmt.ExitError, "cannot create file", err)
}

_, err = io.Copy(f, body)
_ = f.Close()
if err != nil {
_ = os.Remove(savePath)
return errfmt.Wrap(errfmt.ExitError, "cannot write file", err)
}

output.Hint("saved %s", savePath)
return nil
}

func extractFileName(meta map[string]any) string {
att, _ := meta["Attachable"].(map[string]any)
name, _ := att["FileName"].(string)
return filepath.Base(name)
}
2 changes: 2 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type CLI struct {
CDC CDCCmd `cmd:"" name:"cdc" help:"Change data capture polling."`
Report ReportCmd `cmd:"" help:"Run a QBO report."`
Company CompanyCmd `cmd:"" help:"Company info and switching."`
Attach AttachCmd `cmd:"" help:"Upload a file attachment to an entity."`
Download DownloadCmd `cmd:"" help:"Download an attachment file."`
Schema SchemaCmd `cmd:"" help:"Dump CLI schema as JSON for agent introspection."`
ExitCodes ExitCodesCmd `cmd:"" name:"exit-codes" help:"Print exit code table."`
}
Expand Down
12 changes: 12 additions & 0 deletions internal/cmd/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@ func fullSchema(version string) map[string]any {
{"name": "switch", "help": "Set default company", "args": []string{"realm-id"}},
},
},
{
"name": "attach",
"help": "Upload a file attachment to an entity",
"args": []string{"entity-type?", "entity-id?", "file"},
"flags": []string{"--note", "--include-on-send"},
},
{
"name": "download",
"help": "Download an attachment file",
"args": []string{"id"},
"flags": []string{"--output", "--url"},
},
{
"name": "schema",
"help": "Dump CLI schema as JSON for agent introspection",
Expand Down
10 changes: 9 additions & 1 deletion skills/qbo/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: qbo
description: Queries, creates, updates, and manages QuickBooks Online data via the qbo CLI. Use when working with QBO entities (invoices, customers, bills, payments, vendors, accounts, items, estimates), running reports, or setting up install, auth, sandbox, and company switching.
description: Queries, creates, updates, and manages QuickBooks Online data via the qbo CLI. Use when working with QBO entities (invoices, customers, bills, payments, vendors, accounts, items, estimates, attachables), running reports, or setting up install, auth, sandbox, and company switching.
allowed-tools: Bash(qbo *)
---

Expand Down Expand Up @@ -149,6 +149,14 @@ echo '{"CustomerRef":{"value":"CUSTOMER_ID"},"TotalAmt":1500,"Line":[{"Amount":1
| qbo create payment -f - --sandbox --json
```

## Attachments

```bash
qbo attach invoice 123 receipt.pdf --json
qbo list attachable --where "AttachableRef.EntityRef.value = '123'" --json --results-only
qbo download <id>
```

## Agent Introspection

```bash
Expand Down
16 changes: 15 additions & 1 deletion skills/qbo/references/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,20 @@ qbo company list # List configured companies
qbo company switch <realm-id> # Set default company
```

### attach

```bash
qbo attach [entity-type entity-id] <file> [--note TEXT] [--include-on-send]
```

### download

```bash
qbo download <id> [-o path] [--url]
```

`--url` prints the temporary download URL instead of saving the file.

### schema

```bash
Expand All @@ -159,7 +173,7 @@ qbo exit-codes --json

## Supported Entities

Account, Bill, BillPayment, Budget, Class, CompanyInfo, CreditMemo, Customer, Department, Deposit, Employee, Estimate, Invoice, Item, JournalEntry, Payment, PaymentMethod, Preferences, Purchase, PurchaseOrder, RefundReceipt, SalesReceipt, TaxCode, TaxRate, Term, TimeActivity, Transfer, Vendor, VendorCredit.
Account, Attachable, Bill, BillPayment, Budget, Class, CompanyInfo, CreditMemo, Customer, Department, Deposit, Employee, Estimate, Invoice, Item, JournalEntry, Payment, PaymentMethod, Preferences, Purchase, PurchaseOrder, RefundReceipt, SalesReceipt, TaxCode, TaxRate, Term, TimeActivity, Transfer, Vendor, VendorCredit.

## Environment Variables

Expand Down