diff --git a/.claude/rules/cross-sdk-parity.md b/.claude/rules/cross-sdk-parity.md index 13477aee..34fa19b3 100644 --- a/.claude/rules/cross-sdk-parity.md +++ b/.claude/rules/cross-sdk-parity.md @@ -210,25 +210,30 @@ Canonical (JS) names; every SDK implements all 7 with its idiomatic casing (see Canonical (JS) names grouped by sub-resource; every SDK implements ALL of these with its idiomatic casing (Python/Ruby snake_case, Go PascalCase with upper-cased initialisms). -68 operations total. +79 operations total. -- **Number config (admin only):** `getQuoteNumberConfig`, `updateQuoteNumberConfig` +- **Number config (admin only):** `getQuoteNumberConfig`, `updateQuoteNumberConfig`; + `getNextQuoteNumber` (any role that can create quotes, read only) - **Quotes CRUD:** `listQuotes`, `createQuote`, `getQuote`, `updateQuote`, `deleteQuote`, - `duplicateQuote`, `applyPriceBook`, `removePriceBook`, `downloadQuotePdf` + `duplicateQuote`, `applyPriceBook`, `removePriceBook`, `downloadQuotePdf`, + `getQuoteSummary`, `listQuoteCreators` - **Quote status transitions:** `sendQuote`, `sendQuoteWithDeliverable`, `declineQuote`, `voidQuote`, `handleExpiredQuote` - **Line items:** `listLineItems`, `addLineItems`, `addBundleLineItems`, `updateLineItem`, `removeLineItem` +- **Custom line item images:** `getQuoteLineItemImages`, `getLineItemImages`, `addLineItemImages`, + `reorderLineItemImages`, `removeLineItemImage` - **Products:** `listProducts`, `createProduct`, `bulkCreateProducts`, `getProduct`, - `updateProduct`, `deleteProduct`, `duplicateProduct`, `getProductPrimaryImages` + `updateProduct`, `deleteProduct`, `duplicateProduct`, `getProductPrimaryImages`, + `getProductQuoteImages` - **Price books:** `listPriceBooks`, `createPriceBook`, `bulkCreatePriceBooks`, `getPriceBook`, `updatePriceBook`, `deletePriceBook`, `duplicatePriceBook`, `listPriceBookProducts` - **Bundles:** `listBundles`, `createBundle`, `bulkCreateBundles`, `getBundle`, `updateBundle`, `deleteBundle`, `duplicateBundle` - **Companies:** `listCompanies`, `createCompany`, `bulkCreateCompanies`, `getCompany`, - `updateCompany`, `deleteCompany`, `listCompanyContacts` + `updateCompany`, `deleteCompany`, `listCompanyContacts`, `getCompanyOpenQuotes` - **Contacts:** `listContacts`, `createContact`, `bulkCreateContacts`, `updateContact`, - `deleteContact` + `deleteContact`, `getContactOpenQuotes` - **Quote templates:** `listTemplates`, `getTemplate` (org default, singular endpoint), `getTemplateById`, `createTemplate`, `updateTemplate`, `deleteTemplate` - **Types/categories:** `listTypes`, `createType`, `bulkCreateTypes`, `updateType`, `deleteType` @@ -240,6 +245,30 @@ use the single-create request shape. Response is a partial-success report row does NOT throw and does not roll back earlier rows. Max 500 rows per request (400 above the cap). Admin + contributor roles. SDKs do NOT validate rows or the cap client-side. +**List sorting and filters:** every list takes `sortBy` + `sortDirection` (`asc` | `desc`), sent +only when set, so a caller that leaves them out keeps the list's old order. Multi-value filters go +out as repeated keys. Where the API uses ONE key for one or several values (`billingFrequency` / +`currency` on products, `currency` on bundles), the typed SDKs keep the single field and add a list +field for the same key (Go `BillingFrequencies` / `CurrencyCodes` with a `query:"…"` tag, Java +`setBillingFrequencies` / `setCurrencies`), so existing code compiles unchanged; JS, Python, PHP and +Ruby take a string or a list. The quote list's own plural keys (`companyIds`, `currencies`) are +separate params. `expired` in `statuses` is a filter value, not a stored status. + +**Duplicates take an optional name:** the four `duplicate*` calls send `{ name }` only when a name is +given and otherwise send no body (Java sends `{}` as before), so the API's "Copy of …" default stands. +Go uses a variadic `DuplicateOptions`, Java a `(id, name)` overload, PHP / Python / Ruby an optional +argument — all source-compatible. + +**Custom line item images** go up as `images` multipart parts only (no `data` part), each with the +MIME type detected from its bytes. Reorder is the only `PUT` in the SDKs. + +**Deletes the API refuses** (409 conflict class unless noted): a sent quote +(`CANNOT_DELETE_SENT_QUOTE`) or an accepted one (`CANNOT_DELETE_ACCEPTED_QUOTE`); a company with open +quotes (`CompanyHasOpenQuotes`); a contact who is the company's last (`LastContact`) or on open quotes +(`ContactHasOpenQuotes`); the default price book (400 `PriceBookIsDefault`). Every SDK documents these +on the delete methods. The SDKs don't expose the 409's `data.openQuotes`; the `get*OpenQuotes` calls +return the same list. + ## Deliberate Exclusions (do not re-add without a decision) Backend endpoints in SDK domains that are intentionally NOT wrapped — the UI-flow rule @@ -247,9 +276,8 @@ Backend endpoints in SDK domains that are intentionally NOT wrapped — the UI-f - **TurboQuote:** `POST /v1/quotes/number-config/preview-floor` (UI preview); `POST /v1/quotes/:id/items/reorder` + `/items/category-order` (drag-and-drop presentation); - the **approval workflow** family (`/v1/quotes/workflows*`, `/:id/approve`, - `/approval-requests`, `/:id/approval-activity`) — backend feature not yet - production-complete; revisit when it ships. + **unreleased quote features**: any `/v1/quotes` endpoint, field or status value the product + UI doesn't offer yet is not wrapped, documented or listed in a type; revisit when it ships. - **TurboSign:** `GET /turbosign/documents/signature-documents` (list) and the `/turbosign/bulk/*` mail-merge family — deferred to a future pass, not rejected; the two-step prep flow (`upload` / `from-deliverable` / `from-template` / diff --git a/openapi.yaml b/openapi.yaml index 587bc697..53046291 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -682,6 +682,30 @@ paths: $ref: '#/components/responses/Conflict' '429': $ref: '#/components/responses/TooManyRequests' + /v1/quotes/number-config/next: + get: + tags: + - TurboQuote + operationId: getV1QuotesNumberConfigNext + summary: GET /v1/quotes/number-config/next + description: 'The number the next new quote will get. Read only.' + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' /v1/quotes: get: tags: @@ -735,6 +759,54 @@ paths: $ref: '#/components/responses/Conflict' '429': $ref: '#/components/responses/TooManyRequests' + /v1/quotes/summary: + get: + tags: + - TurboQuote + operationId: getV1QuotesSummary + summary: GET /v1/quotes/summary + description: 'The quote list''s summary cards for the list filters (the GET /v1/quotes query params), in `summaryCurrency`.' + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + /v1/quotes/creators: + get: + tags: + - TurboQuote + operationId: getV1QuotesCreators + summary: GET /v1/quotes/creators + description: 'The people and API keys who created the org''s quotes (ids for the `createdBy` filter).' + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' /v1/quotes/{id}: get: tags: @@ -1269,6 +1341,211 @@ paths: $ref: '#/components/responses/Conflict' '429': $ref: '#/components/responses/TooManyRequests' + /v1/quotes/{id}/items/images: + get: + tags: + - TurboQuote + operationId: getV1QuotesByIdItemsImages + summary: GET /v1/quotes/{id}/items/images + description: 'Every custom line item''s images (up to three, in order), keyed by line item id.' + parameters: + - name: id + in: path + required: true + description: Resource identifier. + schema: + type: string + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + /v1/quotes/{id}/items/{itemId}/images: + get: + tags: + - TurboQuote + operationId: getV1QuotesByIdItemsByItemIdImages + summary: GET /v1/quotes/{id}/items/{itemId}/images + description: 'One custom line item''s images, in order.' + parameters: + - name: id + in: path + required: true + description: Resource identifier. + schema: + type: string + - name: itemId + in: path + required: true + description: Line item identifier. + schema: + type: string + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + post: + tags: + - TurboQuote + operationId: postV1QuotesByIdItemsByItemIdImages + summary: POST /v1/quotes/{id}/items/{itemId}/images + description: 'Add images to a custom line item (draft quotes only). Returns all of its images.' + parameters: + - name: id + in: path + required: true + description: Resource identifier. + schema: + type: string + - name: itemId + in: path + required: true + description: Line item identifier. + schema: + type: string + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + images: + type: array + items: + type: string + format: binary + description: Up to three PNG or JPEG images, 2 MB each (repeated `images` part). + required: + - images + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + /v1/quotes/{id}/items/{itemId}/images/order: + put: + tags: + - TurboQuote + operationId: putV1QuotesByIdItemsByItemIdImagesOrder + summary: PUT /v1/quotes/{id}/items/{itemId}/images/order + description: 'Set the order of a custom line item''s images (`imageIds`, first to last).' + parameters: + - name: id + in: path + required: true + description: Resource identifier. + schema: + type: string + - name: itemId + in: path + required: true + description: Line item identifier. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericRequest' + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + /v1/quotes/{id}/items/{itemId}/images/{imageId}: + delete: + tags: + - TurboQuote + operationId: deleteV1QuotesByIdItemsByItemIdImagesByImageId + summary: DELETE /v1/quotes/{id}/items/{itemId}/images/{imageId} + description: 'Remove one image from a custom line item. Returns the images left.' + parameters: + - name: id + in: path + required: true + description: Resource identifier. + schema: + type: string + - name: itemId + in: path + required: true + description: Line item identifier. + schema: + type: string + - name: imageId + in: path + required: true + description: Image identifier. + schema: + type: string + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' /v1/quotes/{id}/items/{itemId}: patch: tags: @@ -1625,6 +1902,36 @@ paths: $ref: '#/components/responses/Conflict' '429': $ref: '#/components/responses/TooManyRequests' + /v1/products/quote-images: + post: + tags: + - TurboQuote + operationId: postV1ProductsQuoteImages + summary: POST /v1/products/quote-images + description: 'The images each product shows on a quote (up to three, in order), for `productIds`.' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericRequest' + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' /v1/pricebooks: get: tags: @@ -2295,6 +2602,37 @@ paths: $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' + /v1/companies/{id}/open-quotes: + get: + tags: + - TurboQuote + operationId: getV1CompaniesByIdOpenQuotes + summary: GET /v1/companies/{id}/open-quotes + description: 'The open quotes (draft or sent) for a company, which block deleting it.' + parameters: + - name: id + in: path + required: true + description: Resource identifier. + schema: + type: string + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' /v1/contacts: get: tags: @@ -2448,6 +2786,37 @@ paths: $ref: '#/components/responses/Conflict' '429': $ref: '#/components/responses/TooManyRequests' + /v1/contacts/{id}/open-quotes: + get: + tags: + - TurboQuote + operationId: getV1ContactsByIdOpenQuotes + summary: GET /v1/contacts/{id}/open-quotes + description: 'The open quotes (draft or sent) made out to a contact, which block deleting them.' + parameters: + - name: id + in: path + required: true + description: Resource identifier. + schema: + type: string + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' /v1/quote-templates: get: tags: diff --git a/packages/go-sdk/README.md b/packages/go-sdk/README.md index dbf36c0e..f9e7a19b 100644 --- a/packages/go-sdk/README.md +++ b/packages/go-sdk/README.md @@ -883,15 +883,15 @@ No `SenderEmail` on the client — but sending a quote *does* create a signature | Group | Methods | |---|---| -| **Quotes** | `ListQuotes`, `CreateQuote`, `GetQuote`, `UpdateQuote`, `DeleteQuote`, `DuplicateQuote`, `DownloadQuotePdf` | +| **Quotes** | `ListQuotes`, `CreateQuote`, `GetQuote`, `UpdateQuote`, `DeleteQuote`, `DuplicateQuote`, `DownloadQuotePdf`, `GetQuoteSummary`, `ListQuoteCreators`, `GetNextQuoteNumber` | | **Quote status** | `SendQuote`, `SendQuoteWithDeliverable`, `DeclineQuote`, `VoidQuote`, `HandleExpiredQuote` (void/decline an expired quote and reissue it) | | **Price book application** | `ApplyPriceBook`, `RemovePriceBook` | -| **Line items** | `ListLineItems`, `AddLineItems`, `AddBundleLineItems`, `UpdateLineItem`, `RemoveLineItem` | -| **Products** | `ListProducts`, `CreateProduct`, `GetProduct`, `UpdateProduct`, `DeleteProduct`, `DuplicateProduct`, `GetProductPrimaryImages` | +| **Line items** | `ListLineItems`, `AddLineItems`, `AddBundleLineItems`, `UpdateLineItem`, `RemoveLineItem`, `GetQuoteLineItemImages`, `GetLineItemImages`, `AddLineItemImages`, `ReorderLineItemImages`, `RemoveLineItemImage` | +| **Products** | `ListProducts`, `CreateProduct`, `GetProduct`, `UpdateProduct`, `DeleteProduct`, `DuplicateProduct`, `GetProductPrimaryImages`, `GetProductQuoteImages` | | **Price books** | `ListPriceBooks`, `CreatePriceBook`, `GetPriceBook`, `UpdatePriceBook`, `DeletePriceBook`, `DuplicatePriceBook`, `ListPriceBookProducts` | | **Bundles** | `ListBundles`, `CreateBundle`, `GetBundle`, `UpdateBundle`, `DeleteBundle`, `DuplicateBundle` | -| **Companies** | `ListCompanies`, `CreateCompany`, `GetCompany`, `UpdateCompany`, `DeleteCompany`, `ListCompanyContacts` | -| **Contacts** | `ListContacts`, `CreateContact`, `UpdateContact`, `DeleteContact` | +| **Companies** | `ListCompanies`, `CreateCompany`, `GetCompany`, `UpdateCompany`, `DeleteCompany`, `ListCompanyContacts`, `GetCompanyOpenQuotes` | +| **Contacts** | `ListContacts`, `CreateContact`, `UpdateContact`, `DeleteContact`, `GetContactOpenQuotes` | | **Templates** | `ListTemplates`, `GetTemplate`, `GetTemplateByID`, `CreateTemplate`, `UpdateTemplate`, `DeleteTemplate` | | **Types/categories** | `ListTypes`, `CreateType`, `UpdateType`, `DeleteType` | | **Convenience** | `CreateAndSend` | @@ -951,6 +951,68 @@ resp, err := client.SendQuote(ctx, quote.ID, &turbodocx.SendQuoteRequest{ }) ``` +#### Sorting, filters and the list summary + +Every list takes `sortBy` / `sortDirection` (`asc` | `desc`); leave them out for the order the +list always had. Sort columns: quotes `updatedOn` · `createdOn` · `total` · `validUntil` · `quoteNumber`; +products `name` · `listPrice` · `createdOn`; price books `name` · `discountPercent` · `overrideCount` · +`validFrom` · `validTo` · `createdOn`; bundles `name` · `itemCount` · `totalListPrice` · `totalFinalPrice` · +`createdOn`; companies `name` · `contactCount` · `lastActivityDate`; contacts `name` · `createdOn`; +types `name` · `usageCount`. + +The quote list also filters by several companies (`companyIds`) and currencies (`currencies`), by +`expired` in `statuses` (a draft or sent quote past its Valid until day, UTC, or whose signature +expired), by creator (`createdBy`, ids from the creators call; `createdByApiKeys` for API-key quotes) +and by Valid until (`validUntilWindow` = `thisWeek` | `thisMonth` | `past`, `validUntilFrom` / +`validUntilTo`). Each quote carries `isExpired` and `lineItemCount`; the response adds +`statusCounts` (per tab) and `summary` (pipeline, win rate, monthly recurring, in `summaryCurrency`). + +Go option structs take the sort as `SortBy` / `SortDirection` (`SortAsc` / `SortDesc`). Products and +bundles take several values in `BillingFrequencies` / `CurrencyCodes` next to the single fields. + +```go +str := func(s string) *string { return &s } + +page, err := client.ListQuotes(ctx, &turbodocx.ListQuotesOptions{ + Statuses: []string{"sent", turbodocx.QuoteListStatusExpired}, + Currencies: []string{"USD", "EUR"}, + ValidUntilWindow: str(turbodocx.ValidUntilThisMonth), + SortBy: str(turbodocx.QuoteSortByValidUntil), + SortDirection: str(turbodocx.SortAsc), +}) +fmt.Println(page.StatusCounts.Expired, page.Summary.WinRate.Percent) + +creators, _ := client.ListQuoteCreators(ctx) // IDs for CreatedBy +summary, _ := client.GetQuoteSummary(ctx, &turbodocx.QuoteSummaryOptions{CreatedBy: []string{creators[0].ID}}) +next, _ := client.GetNextQuoteNumber(ctx) // read only + +copy, _ := client.DuplicateQuote(ctx, quote.ID, turbodocx.DuplicateOptions{Name: "Q4 renewal"}) // name is optional +``` + +`DuplicateQuote`, `DuplicateProduct`, `DuplicatePriceBook` and `DuplicateBundle` take an optional +`DuplicateOptions{Name: …}` (otherwise "Copy of …"). Companies take an optional `Email` (`ClearEmail()` on +update); `UpdatePriceBookRequest.ResetProductPricing` puts every product back on the book's discount. +`ListCompanyContacts` now takes `*ListCompanyContactsOptions` (pagination plus the sort). + +#### Custom line item images + +A custom line item (one with no catalog product) can show up to three images on the quote (PNG or +JPEG, 2 MB each; draft quotes only). Catalog products use `GetProductQuoteImages(ctx, productIDs)`. + +```go +images, err := client.AddLineItemImages(ctx, quote.ID, itemID, []turbodocx.ProductImageInput{{FilePath: "./diagram.png"}}) +_, err = client.ReorderLineItemImages(ctx, quote.ID, itemID, []string{images[1].ID, images[0].ID}) +_, err = client.RemoveLineItemImage(ctx, quote.ID, itemID, images[0].ID) +byItem, err := client.GetQuoteLineItemImages(ctx, quote.ID) // map[lineItemID][]LineItemImage +``` + +**Deletes the API refuses.** `DeleteQuote` on a **sent** quote is a 409 `*ConflictError` with code +`CANNOT_DELETE_SENT_QUOTE` — void it instead; an accepted quote is a 409 `CANNOT_DELETE_ACCEPTED_QUOTE`. +`DeleteCompany` is a 409 `CompanyHasOpenQuotes` while the company has open quotes (draft or +sent), and `DeleteContact` is a 409 `LastContact` (the company's only contact) or `ContactHasOpenQuotes`. +`DeletePriceBook` on the org's default price book is a 400 `*ValidationError` `PriceBookIsDefault`. Branch on +`err.Code`; `GetCompanyOpenQuotes` / `GetContactOpenQuotes` list the open quotes that block a delete. + #### Sender identity — "Prepared by" The quote's **"Prepared by"** name and email are resolved by the server, not by whoever diff --git a/packages/go-sdk/http.go b/packages/go-sdk/http.go index f5527548..eaaca151 100644 --- a/packages/go-sdk/http.go +++ b/packages/go-sdk/http.go @@ -486,6 +486,38 @@ func (c *HTTPClient) Patch(ctx context.Context, path string, data interface{}, r return c.handleResponse(resp, result) } +// Put performs a PUT request with JSON body +func (c *HTTPClient) Put(ctx context.Context, path string, data interface{}, result interface{}) error { + var body io.Reader + if data != nil { + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + body = bytes.NewReader(jsonData) + } + + req, err := http.NewRequestWithContext(ctx, "PUT", c.baseURL+path, body) + if err != nil { + return &NetworkError{TurboDocxError: TurboDocxError{ + Code: "NETWORK_ERROR", + Message: fmt.Sprintf("failed to create request: %v", err), + }} + } + + c.setHeaders(req, "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return &NetworkError{TurboDocxError: TurboDocxError{ + Code: "NETWORK_ERROR", + Message: fmt.Sprintf("request failed: %v", err), + }} + } + + return c.handleResponse(resp, result) +} + // Delete performs a DELETE request func (c *HTTPClient) Delete(ctx context.Context, path string, result interface{}) error { req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+path, nil) @@ -613,6 +645,11 @@ func (c *HTTPClient) PostProductMultipart(ctx context.Context, path string, data return c.doProductMultipart(ctx, "POST", path, dataJSON, images, result) } +// PostImagesMultipart performs a POST multipart request with "images" file parts only (no "data" field) +func (c *HTTPClient) PostImagesMultipart(ctx context.Context, path string, images []ProductImageFile, result interface{}) error { + return c.doProductMultipart(ctx, "POST", path, nil, images, result) +} + // PatchProductMultipart performs a PATCH multipart request with a "data" JSON field and "images" file parts func (c *HTTPClient) PatchProductMultipart(ctx context.Context, path string, dataJSON []byte, images []ProductImageFile, result interface{}) error { return c.doProductMultipart(ctx, "PATCH", path, dataJSON, images, result) @@ -622,9 +659,11 @@ func (c *HTTPClient) doProductMultipart(ctx context.Context, method, path string var buf bytes.Buffer writer := multipart.NewWriter(&buf) - // Add the "data" field containing the JSON-encoded product fields - if err := writer.WriteField("data", string(dataJSON)); err != nil { - return fmt.Errorf("failed to write data field: %w", err) + // Add the "data" field containing the JSON-encoded product fields (none for an images-only upload) + if dataJSON != nil { + if err := writer.WriteField("data", string(dataJSON)); err != nil { + return fmt.Errorf("failed to write data field: %w", err) + } } // Add each image as a multipart file part named "images" diff --git a/packages/go-sdk/turboquote.go b/packages/go-sdk/turboquote.go index e42f0fb5..c572894a 100644 --- a/packages/go-sdk/turboquote.go +++ b/packages/go-sdk/turboquote.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "os" + "reflect" "strconv" ) @@ -97,6 +98,8 @@ func quoteQueryParams(opts interface{}) string { } } + addQueryTaggedSlices(q, opts) + encoded := q.Encode() if encoded == "" { return "" @@ -104,6 +107,46 @@ func quoteQueryParams(opts interface{}) string { return "?" + encoded } +// addQueryTaggedSlices adds the []string fields tagged `query:"name"` as repeated params. They +// send several values under a key a single-value field already uses (e.g. BillingFrequencies +// next to BillingFrequency, both "billingFrequency"), which a JSON tag can't express. +func addQueryTaggedSlices(q url.Values, opts interface{}) { + v := reflect.ValueOf(opts) + for v.Kind() == reflect.Ptr { + if v.IsNil() { + return + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return + } + t := v.Type() + for i := 0; i < t.NumField(); i++ { + key := t.Field(i).Tag.Get("query") + values, ok := v.Field(i).Interface().([]string) + if key == "" || !ok { + continue + } + for _, value := range values { + q.Add(key, value) + } + } +} + +// duplicate POSTs to a /duplicate path: the copy's name when given, else no body (the API names +// the copy "Copy of …"), and unwraps { result }. +func (c *QuoteClient) duplicate(ctx context.Context, path string, opts []DuplicateOptions, result interface{}) error { + var body interface{} + if len(opts) > 0 && opts[0].Name != "" { + body = map[string]string{"name": opts[0].Name} + } + resp := struct { + Result interface{} `json:"result"` + }{Result: result} + return c.http.Post(ctx, path, body, &resp) +} + // --- Bulk import helper --- // bulkImport POSTs { "rows": [...] } to the given /bulk path and unwraps the @@ -134,6 +177,30 @@ func (c *QuoteClient) ListQuotes(ctx context.Context, options *ListQuotesOptions return &result, nil } +// GetQuoteSummary retrieves the quote list's summary cards (pipeline, win rate, monthly +// recurring…) for a set of filters, in one currency. The status filter is ignored. +func (c *QuoteClient) GetQuoteSummary(ctx context.Context, options *QuoteSummaryOptions) (*QuoteSummary, error) { + var resp struct { + Summary QuoteSummary `json:"summary"` + } + if err := c.http.Get(ctx, "/v1/quotes/summary"+quoteQueryParams(options), &resp); err != nil { + return nil, err + } + return &resp.Summary, nil +} + +// ListQuoteCreators retrieves the people and API keys who created the org's quotes — the ids for +// the CreatedBy filter +func (c *QuoteClient) ListQuoteCreators(ctx context.Context) ([]QuoteCreator, error) { + var resp struct { + Results []QuoteCreator `json:"results"` + } + if err := c.http.Get(ctx, "/v1/quotes/creators", &resp); err != nil { + return nil, err + } + return resp.Results, nil +} + // CreateQuote creates a new quote func (c *QuoteClient) CreateQuote(ctx context.Context, request *CreateQuoteRequest) (*Quote, error) { var resp struct { @@ -178,7 +245,9 @@ func (c *QuoteClient) UpdateQuote(ctx context.Context, id string, request *Updat return &resp.Result, nil } -// DeleteQuote deletes a quote by ID +// DeleteQuote deletes a quote. A sent quote is refused with a 409 ConflictError +// (CANNOT_DELETE_SENT_QUOTE) — void it instead — and so is an accepted one +// (CANNOT_DELETE_ACCEPTED_QUOTE). by ID func (c *QuoteClient) DeleteQuote(ctx context.Context, id string) (*QuoteSuccessResponse, error) { var result QuoteSuccessResponse if err := c.http.Delete(ctx, "/v1/quotes/"+id, &result); err != nil { @@ -187,15 +256,14 @@ func (c *QuoteClient) DeleteQuote(ctx context.Context, id string) (*QuoteSuccess return &result, nil } -// DuplicateQuote duplicates a quote -func (c *QuoteClient) DuplicateQuote(ctx context.Context, id string) (*Quote, error) { - var resp struct { - Result Quote `json:"result"` - } - if err := c.http.Post(ctx, "/v1/quotes/"+id+"/duplicate", nil, &resp); err != nil { +// DuplicateQuote duplicates a quote. Pass DuplicateOptions{Name: …} to name the copy; without it the +// copy is called "Copy of ". +func (c *QuoteClient) DuplicateQuote(ctx context.Context, id string, opts ...DuplicateOptions) (*Quote, error) { + var result Quote + if err := c.duplicate(ctx, "/v1/quotes/"+id+"/duplicate", opts, &result); err != nil { return nil, err } - return &resp.Result, nil + return &result, nil } // ApplyPriceBook applies a price book to a quote @@ -241,7 +309,7 @@ func (c *QuoteClient) DownloadQuotePdf(ctx context.Context, id string) ([]byte, // QUOTES -- STATUS TRANSITIONS // ============================================ -// SendQuote sends a quote for approval +// SendQuote sends a quote to its contact for signature func (c *QuoteClient) SendQuote(ctx context.Context, id string, request *SendQuoteRequest) (*SendQuoteResponse, error) { // API returns { result, message } but our public type uses json:"quote". var resp struct { @@ -367,6 +435,71 @@ func (c *QuoteClient) RemoveLineItem(ctx context.Context, quoteID string, itemID return &result, nil } +// ============================================ +// CUSTOM LINE ITEM IMAGES +// Up to three per custom item (a line with no catalog product), PNG or JPEG, 2MB each. +// Changes are allowed on draft quotes only. +// ============================================ + +// lineItemImagesResult unwraps { results: [...] } from the line item image endpoints +type lineItemImagesResult struct { + Results []LineItemImage `json:"results"` +} + +// GetQuoteLineItemImages retrieves every custom line item's images (up to three, in order), +// keyed by line item ID +func (c *QuoteClient) GetQuoteLineItemImages(ctx context.Context, quoteID string) (QuoteLineItemImagesResponse, error) { + var resp struct { + Results QuoteLineItemImagesResponse `json:"results"` + } + if err := c.http.Get(ctx, "/v1/quotes/"+quoteID+"/items/images", &resp); err != nil { + return nil, err + } + return resp.Results, nil +} + +// GetLineItemImages retrieves one custom line item's images, in order +func (c *QuoteClient) GetLineItemImages(ctx context.Context, quoteID string, itemID string) ([]LineItemImage, error) { + var resp lineItemImagesResult + if err := c.http.Get(ctx, "/v1/quotes/"+quoteID+"/items/"+itemID+"/images", &resp); err != nil { + return nil, err + } + return resp.Results, nil +} + +// AddLineItemImages adds images (file paths or bytes) to a custom line item and returns all of +// its images. Each image is sent as an "images" part with the type detected from its bytes. +func (c *QuoteClient) AddLineItemImages(ctx context.Context, quoteID string, itemID string, images []ProductImageInput) ([]LineItemImage, error) { + files, err := resolveProductImages(images) + if err != nil { + return nil, err + } + var resp lineItemImagesResult + if err := c.http.PostImagesMultipart(ctx, "/v1/quotes/"+quoteID+"/items/"+itemID+"/images", files, &resp); err != nil { + return nil, err + } + return resp.Results, nil +} + +// ReorderLineItemImages sets the order of a custom line item's images, first to last +func (c *QuoteClient) ReorderLineItemImages(ctx context.Context, quoteID string, itemID string, imageIDs []string) ([]LineItemImage, error) { + var resp lineItemImagesResult + body := map[string][]string{"imageIds": imageIDs} + if err := c.http.Put(ctx, "/v1/quotes/"+quoteID+"/items/"+itemID+"/images/order", body, &resp); err != nil { + return nil, err + } + return resp.Results, nil +} + +// RemoveLineItemImage removes one image from a custom line item and returns the images left +func (c *QuoteClient) RemoveLineItemImage(ctx context.Context, quoteID string, itemID string, imageID string) ([]LineItemImage, error) { + var resp lineItemImagesResult + if err := c.http.Delete(ctx, "/v1/quotes/"+quoteID+"/items/"+itemID+"/images/"+imageID, &resp); err != nil { + return nil, err + } + return resp.Results, nil +} + // ============================================ // PRODUCTS // ============================================ @@ -461,15 +594,14 @@ func (c *QuoteClient) DeleteProduct(ctx context.Context, id string) (*QuoteSucce return &result, nil } -// DuplicateProduct duplicates a product -func (c *QuoteClient) DuplicateProduct(ctx context.Context, id string) (*Product, error) { - var resp struct { - Result Product `json:"result"` - } - if err := c.http.Post(ctx, "/v1/products/"+id+"/duplicate", nil, &resp); err != nil { +// DuplicateProduct duplicates a product. Pass DuplicateOptions{Name: …} to name the copy; without it the +// copy is called "Copy of ". +func (c *QuoteClient) DuplicateProduct(ctx context.Context, id string, opts ...DuplicateOptions) (*Product, error) { + var result Product + if err := c.duplicate(ctx, "/v1/products/"+id+"/duplicate", opts, &result); err != nil { return nil, err } - return &resp.Result, nil + return &result, nil } // GetProductPrimaryImages retrieves primary images for the given product IDs @@ -484,6 +616,19 @@ func (c *QuoteClient) GetProductPrimaryImages(ctx context.Context, productIDs [] return resp.Results, nil } +// GetProductQuoteImages retrieves the images each product shows on a quote: up to three, in +// display order (1–100 product IDs) +func (c *QuoteClient) GetProductQuoteImages(ctx context.Context, productIDs []string) (ProductQuoteImagesResponse, error) { + var resp struct { + Results ProductQuoteImagesResponse `json:"results"` + } + body := map[string][]string{"productIds": productIDs} + if err := c.http.Post(ctx, "/v1/products/quote-images", body, &resp); err != nil { + return nil, err + } + return resp.Results, nil +} + // resolveProductImages converts ProductImageInput entries to ProductImageFile entries func resolveProductImages(inputs []ProductImageInput) ([]ProductImageFile, error) { var images []ProductImageFile @@ -578,7 +723,8 @@ func (c *QuoteClient) UpdatePriceBook(ctx context.Context, id string, request *U return &resp.Result, nil } -// DeletePriceBook deletes a price book by ID +// DeletePriceBook deletes a price book by ID. The org's default price book is refused (400 +// PriceBookIsDefault). func (c *QuoteClient) DeletePriceBook(ctx context.Context, id string) (*QuoteSuccessResponse, error) { var result QuoteSuccessResponse if err := c.http.Delete(ctx, "/v1/pricebooks/"+id, &result); err != nil { @@ -587,15 +733,14 @@ func (c *QuoteClient) DeletePriceBook(ctx context.Context, id string) (*QuoteSuc return &result, nil } -// DuplicatePriceBook duplicates a price book -func (c *QuoteClient) DuplicatePriceBook(ctx context.Context, id string) (*PriceBook, error) { - var resp struct { - Result PriceBook `json:"result"` - } - if err := c.http.Post(ctx, "/v1/pricebooks/"+id+"/duplicate", nil, &resp); err != nil { +// DuplicatePriceBook duplicates a price book. Pass DuplicateOptions{Name: …} to name the copy; without it the +// copy is called "Copy of ". +func (c *QuoteClient) DuplicatePriceBook(ctx context.Context, id string, opts ...DuplicateOptions) (*PriceBook, error) { + var result PriceBook + if err := c.duplicate(ctx, "/v1/pricebooks/"+id+"/duplicate", opts, &result); err != nil { return nil, err } - return &resp.Result, nil + return &result, nil } // ListPriceBookProducts retrieves products associated with a price book @@ -671,15 +816,14 @@ func (c *QuoteClient) DeleteBundle(ctx context.Context, id string) (*QuoteSucces return &result, nil } -// DuplicateBundle duplicates a bundle -func (c *QuoteClient) DuplicateBundle(ctx context.Context, id string) (*Bundle, error) { - var resp struct { - Result Bundle `json:"result"` - } - if err := c.http.Post(ctx, "/v1/bundles/"+id+"/duplicate", nil, &resp); err != nil { +// DuplicateBundle duplicates a bundle. Pass DuplicateOptions{Name: …} to name the copy; without it the +// copy is called "Copy of ". +func (c *QuoteClient) DuplicateBundle(ctx context.Context, id string, opts ...DuplicateOptions) (*Bundle, error) { + var result Bundle + if err := c.duplicate(ctx, "/v1/bundles/"+id+"/duplicate", opts, &result); err != nil { return nil, err } - return &resp.Result, nil + return &result, nil } // ============================================ @@ -736,7 +880,9 @@ func (c *QuoteClient) UpdateCompany(ctx context.Context, id string, request *Upd return &resp.Result, nil } -// DeleteCompany deletes a company by ID +// DeleteCompany deletes a company and its contacts. Refused with a 409 ConflictError +// (CompanyHasOpenQuotes) while it has open quotes (draft or sent) — see +// GetCompanyOpenQuotes. func (c *QuoteClient) DeleteCompany(ctx context.Context, id string) (*QuoteSuccessResponse, error) { var result QuoteSuccessResponse if err := c.http.Delete(ctx, "/v1/companies/"+id, &result); err != nil { @@ -745,8 +891,18 @@ func (c *QuoteClient) DeleteCompany(ctx context.Context, id string) (*QuoteSucce return &result, nil } +// GetCompanyOpenQuotes retrieves the open quotes (draft or sent) for a company: +// what blocks deleting it +func (c *QuoteClient) GetCompanyOpenQuotes(ctx context.Context, companyID string) (*OpenQuotesResponse, error) { + var result OpenQuotesResponse + if err := c.http.Get(ctx, "/v1/companies/"+companyID+"/open-quotes", &result); err != nil { + return nil, err + } + return &result, nil +} + // ListCompanyContacts retrieves contacts for a specific company -func (c *QuoteClient) ListCompanyContacts(ctx context.Context, companyID string, options *PaginationParams) (*ContactListResponse, error) { +func (c *QuoteClient) ListCompanyContacts(ctx context.Context, companyID string, options *ListCompanyContactsOptions) (*ContactListResponse, error) { var result ContactListResponse qs := quoteQueryParams(options) if err := c.http.Get(ctx, "/v1/companies/"+companyID+"/contacts"+qs, &result); err != nil { @@ -798,7 +954,9 @@ func (c *QuoteClient) UpdateContact(ctx context.Context, id string, request *Upd return &resp.Result, nil } -// DeleteContact deletes a contact by ID +// DeleteContact deletes a contact. Refused with a 409 ConflictError when they are the +// company's only contact (LastContact) or the contact on open quotes (ContactHasOpenQuotes) — +// see GetContactOpenQuotes. func (c *QuoteClient) DeleteContact(ctx context.Context, id string) (*QuoteSuccessResponse, error) { var result QuoteSuccessResponse if err := c.http.Delete(ctx, "/v1/contacts/"+id, &result); err != nil { @@ -807,6 +965,16 @@ func (c *QuoteClient) DeleteContact(ctx context.Context, id string) (*QuoteSucce return &result, nil } +// GetContactOpenQuotes retrieves the open quotes (draft or sent) made out to a +// contact: what blocks deleting them +func (c *QuoteClient) GetContactOpenQuotes(ctx context.Context, contactID string) (*OpenQuotesResponse, error) { + var result OpenQuotesResponse + if err := c.http.Get(ctx, "/v1/contacts/"+contactID+"/open-quotes", &result); err != nil { + return nil, err + } + return &result, nil +} + // ============================================ // TEMPLATES // ============================================ @@ -1003,3 +1171,15 @@ func (c *QuoteClient) UpdateQuoteNumberConfig(ctx context.Context, format *Quote } return &resp.Results, nil } + +// GetNextQuoteNumber retrieves the number the next new quote will get. Read only — the counter +// doesn't move, so a quote created by someone else in between takes it first. +func (c *QuoteClient) GetNextQuoteNumber(ctx context.Context) (*NextQuoteNumber, error) { + var resp struct { + Results NextQuoteNumber `json:"results"` + } + if err := c.http.Get(ctx, "/v1/quotes/number-config/next", &resp); err != nil { + return nil, err + } + return &resp.Results, nil +} diff --git a/packages/go-sdk/turboquote_lists_images_test.go b/packages/go-sdk/turboquote_lists_images_test.go new file mode 100644 index 00000000..5ee7c0e5 --- /dev/null +++ b/packages/go-sdk/turboquote_lists_images_test.go @@ -0,0 +1,562 @@ +package turbodocx + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TurboQuote: list sorting and filters, the quote summary / creators / next number reads, +// open quotes, product quote images, custom line item images, named duplicates, company +// email and price book reset. + +var ( + testPNG = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00} + testJPEG = []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00} +) + +// jsonServer answers every request with body, after letting check inspect it. +func jsonServer(t *testing.T, check func(r *http.Request), body interface{}) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + check(r) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(body) + })) +} + +func strPtr(s string) *string { return &s } + +func TestQuoteClient_ListQuotes_SortAndNewFilters(t *testing.T) { + var got url.Values + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "/v1/quotes", r.URL.Path) + got = r.URL.Query() + }, map[string]interface{}{"results": []interface{}{}, "totalRecords": 0}) + defer server.Close() + + yes := true + _, err := newTestQuoteClient(t, server.URL).ListQuotes(context.Background(), &ListQuotesOptions{ + SortBy: strPtr("total"), + SortDirection: strPtr("asc"), + Statuses: []string{"sent", "expired"}, + CompanyIDs: []string{"c-1", "c-2"}, + Currencies: []string{"USD", "EUR"}, + CreatedBy: []string{"u-1"}, + CreatedByAPIKeys: &yes, + ValidUntilWindow: strPtr("thisMonth"), + ValidUntilFrom: strPtr("2026-10-01"), + ValidUntilTo: strPtr("2026-10-31"), + SummaryCurrency: strPtr("EUR"), + }) + + require.NoError(t, err) + assert.Equal(t, url.Values{ + "sortBy": {"total"}, + "sortDirection": {"asc"}, + "statuses": {"sent", "expired"}, + "companyIds": {"c-1", "c-2"}, + "currencies": {"USD", "EUR"}, + "createdBy": {"u-1"}, + "createdByApiKeys": {"true"}, + "validUntilWindow": {"thisMonth"}, + "validUntilFrom": {"2026-10-01"}, + "validUntilTo": {"2026-10-31"}, + "summaryCurrency": {"EUR"}, + }, got) +} + +func TestQuoteClient_ListQuotes_ReturnsCountsSummaryAndExpiry(t *testing.T) { + server := jsonServer(t, func(r *http.Request) {}, map[string]interface{}{ + "results": []interface{}{ + map[string]interface{}{"id": "q-1", "status": "sent", "isExpired": true, "isSignatureExpired": false, "lineItemCount": 3}, + }, + "totalRecords": 1, + "statusCounts": map[string]interface{}{"all": 1, "sent": 1, "expired": 1}, + "summary": map[string]interface{}{ + "currency": "USD", + "currencies": []string{"USD"}, + "winRate": map[string]interface{}{"percent": nil, "accepted": 0, "declined": 0, "windowDays": 90}, + "openPipeline": map[string]interface{}{"total": 1200.5, "count": 2}, + }, + }) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).ListQuotes(context.Background(), nil) + + require.NoError(t, err) + require.NotNil(t, result.StatusCounts) + assert.Equal(t, 1, result.StatusCounts.Expired) + require.NotNil(t, result.Summary) + assert.Nil(t, result.Summary.WinRate.Percent) + assert.Equal(t, 90, result.Summary.WinRate.WindowDays) + assert.Equal(t, 1200.5, result.Summary.OpenPipeline.Total) + require.NotNil(t, result.Results[0].IsExpired) + assert.True(t, *result.Results[0].IsExpired) + assert.Equal(t, 3, *result.Results[0].LineItemCount) +} + +func TestQuoteClient_ListProducts_SeveralValuesAndSort(t *testing.T) { + var got url.Values + server := jsonServer(t, func(r *http.Request) { got = r.URL.Query() }, + map[string]interface{}{"results": []interface{}{}, "totalRecords": 0}) + defer server.Close() + + _, err := newTestQuoteClient(t, server.URL).ListProducts(context.Background(), &ListProductsOptions{ + BillingFrequencies: []string{"monthly", "annual"}, + CurrencyCodes: []string{"USD", "INR"}, + SortBy: strPtr("listPrice"), + SortDirection: strPtr("desc"), + }) + + require.NoError(t, err) + assert.Equal(t, url.Values{ + "billingFrequency": {"monthly", "annual"}, + "currency": {"USD", "INR"}, + "sortBy": {"listPrice"}, + "sortDirection": {"desc"}, + }, got) +} + +func TestQuoteClient_ListProducts_OneBillingFrequencyStillWorks(t *testing.T) { + var got url.Values + server := jsonServer(t, func(r *http.Request) { got = r.URL.Query() }, + map[string]interface{}{"results": []interface{}{}, "totalRecords": 0}) + defer server.Close() + + _, err := newTestQuoteClient(t, server.URL).ListProducts(context.Background(), &ListProductsOptions{ + BillingFrequency: strPtr("monthly"), + }) + + require.NoError(t, err) + assert.Equal(t, url.Values{"billingFrequency": {"monthly"}}, got) +} + +func TestQuoteClient_ListPriceBooks_Sort(t *testing.T) { + var got url.Values + server := jsonServer(t, func(r *http.Request) { got = r.URL.Query() }, map[string]interface{}{ + "results": []interface{}{map[string]interface{}{"id": "pb-1", "overrideCount": 2}}, "totalRecords": 1, + }) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).ListPriceBooks(context.Background(), &ListPriceBooksOptions{ + SortBy: strPtr("overrideCount"), SortDirection: strPtr("desc"), + }) + + require.NoError(t, err) + assert.Equal(t, url.Values{"sortBy": {"overrideCount"}, "sortDirection": {"desc"}}, got) + assert.Equal(t, 2, *result.Results[0].OverrideCount) +} + +func TestQuoteClient_ListBundles_SeveralCurrenciesAndSort(t *testing.T) { + var got url.Values + server := jsonServer(t, func(r *http.Request) { got = r.URL.Query() }, map[string]interface{}{ + "results": []interface{}{map[string]interface{}{ + "id": "b-1", "itemCount": 2, "billingTotals": map[string]interface{}{"monthly": 100, "one-time": 500}, + }}, + "totalRecords": 1, + }) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).ListBundles(context.Background(), &ListBundlesOptions{ + CurrencyCodes: []string{"USD", "EUR"}, SortBy: strPtr("itemCount"), SortDirection: strPtr("asc"), + }) + + require.NoError(t, err) + assert.Equal(t, url.Values{"currency": {"USD", "EUR"}, "sortBy": {"itemCount"}, "sortDirection": {"asc"}}, got) + assert.Equal(t, 2, *result.Results[0].ItemCount) + assert.Equal(t, 500.0, result.Results[0].BillingTotals["one-time"]) +} + +func TestQuoteClient_ListCompanies_Sort(t *testing.T) { + var got url.Values + server := jsonServer(t, func(r *http.Request) { got = r.URL.Query() }, map[string]interface{}{ + "results": []interface{}{map[string]interface{}{"id": "c-1", "email": "ap@acme.test"}}, "totalRecords": 1, + }) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).ListCompanies(context.Background(), &ListCompaniesOptions{ + SortBy: strPtr("contactCount"), SortDirection: strPtr("desc"), + }) + + require.NoError(t, err) + assert.Equal(t, url.Values{"sortBy": {"contactCount"}, "sortDirection": {"desc"}}, got) + assert.Equal(t, "ap@acme.test", *result.Results[0].Email) +} + +func TestQuoteClient_ListContacts_SeveralCompaniesAndSort(t *testing.T) { + var got url.Values + server := jsonServer(t, func(r *http.Request) { got = r.URL.Query() }, + map[string]interface{}{"results": []interface{}{}, "totalRecords": 0}) + defer server.Close() + + _, err := newTestQuoteClient(t, server.URL).ListContacts(context.Background(), &ListContactsOptions{ + CompanyIDs: []string{"c-1", "c-2"}, SortBy: strPtr("name"), SortDirection: strPtr("asc"), + }) + + require.NoError(t, err) + assert.Equal(t, url.Values{"companyIds": {"c-1", "c-2"}, "sortBy": {"name"}, "sortDirection": {"asc"}}, got) +} + +func TestQuoteClient_ListCompanyContacts_SortAndContactCount(t *testing.T) { + var got url.Values + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "/v1/companies/c-1/contacts", r.URL.Path) + got = r.URL.Query() + }, map[string]interface{}{ + "results": []interface{}{}, "totalRecords": 0, "company": map[string]interface{}{"id": "c-1", "contactCount": 1}, + }) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).ListCompanyContacts(context.Background(), "c-1", &ListCompanyContactsOptions{ + SortBy: strPtr("createdOn"), SortDirection: strPtr("desc"), + }) + + require.NoError(t, err) + assert.Equal(t, url.Values{"sortBy": {"createdOn"}, "sortDirection": {"desc"}}, got) + require.NotNil(t, result.Company) + assert.Equal(t, 1, *result.Company.ContactCount) +} + +func TestQuoteClient_ListTypes_Sort(t *testing.T) { + var got url.Values + server := jsonServer(t, func(r *http.Request) { got = r.URL.Query() }, + map[string]interface{}{"results": []interface{}{}, "totalRecords": 0}) + defer server.Close() + + _, err := newTestQuoteClient(t, server.URL).ListTypes(context.Background(), &ListTypesOptions{ + CategoryType: strPtr("product_category"), SortBy: strPtr("usageCount"), SortDirection: strPtr("desc"), + }) + + require.NoError(t, err) + assert.Equal(t, url.Values{ + "categoryType": {"product_category"}, "sortBy": {"usageCount"}, "sortDirection": {"desc"}, + }, got) +} + +func TestQuoteClient_GetQuoteSummary(t *testing.T) { + var got url.Values + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "/v1/quotes/summary", r.URL.Path) + got = r.URL.Query() + }, map[string]interface{}{"summary": map[string]interface{}{ + "currency": "EUR", "openPipeline": map[string]interface{}{"total": 10, "count": 1}, + }}) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).GetQuoteSummary(context.Background(), &QuoteSummaryOptions{ + Currencies: []string{"EUR", "USD"}, SummaryCurrency: strPtr("EUR"), + }) + + require.NoError(t, err) + assert.Equal(t, url.Values{"currencies": {"EUR", "USD"}, "summaryCurrency": {"EUR"}}, got) + assert.Equal(t, "EUR", result.CurrencyCode) + assert.Equal(t, 1, result.OpenPipeline.Count) +} + +func TestQuoteClient_ListQuoteCreators(t *testing.T) { + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "/v1/quotes/creators", r.URL.Path) + }, map[string]interface{}{"results": []interface{}{ + map[string]interface{}{"id": "u-1", "name": "Ana Lee", "email": "ana@acme.test", "isAPIKey": false, "apiKeyDeleted": false}, + map[string]interface{}{"id": "u-2", "name": "Zapier key", "email": nil, "isAPIKey": true, "apiKeyDeleted": true}, + }}) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).ListQuoteCreators(context.Background()) + + require.NoError(t, err) + require.Len(t, result, 2) + assert.Equal(t, "Ana Lee", result[0].Name) + assert.Equal(t, "ana@acme.test", *result[0].Email) + assert.True(t, result[1].IsAPIKey) + assert.Nil(t, result[1].Email) + assert.True(t, result[1].APIKeyDeleted) +} + +func TestQuoteClient_GetNextQuoteNumber(t *testing.T) { + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "/v1/quotes/number-config/next", r.URL.Path) + }, map[string]interface{}{"results": map[string]interface{}{"nextNumber": "Q-2026-0042", "sequence": 42, "canEdit": true}}) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).GetNextQuoteNumber(context.Background()) + + require.NoError(t, err) + assert.Equal(t, &NextQuoteNumber{NextNumber: "Q-2026-0042", Sequence: 42, CanEdit: true}, result) +} + +func TestQuoteClient_OpenQuotes(t *testing.T) { + body := map[string]interface{}{ + "results": []interface{}{map[string]interface{}{ + "id": "q-1", "quoteNumber": "Q-1", "name": "Renewal", "status": "sent", "updatedOn": "2026-10-01", + }}, + "totalRecords": 1, + } + t.Run("company", func(t *testing.T) { + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "/v1/companies/c-1/open-quotes", r.URL.Path) + }, body) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).GetCompanyOpenQuotes(context.Background(), "c-1") + + require.NoError(t, err) + assert.Equal(t, 1, result.TotalRecords) + assert.Equal(t, QuoteStatusSent, result.Results[0].Status) + }) + t.Run("contact", func(t *testing.T) { + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "/v1/contacts/ct-1/open-quotes", r.URL.Path) + }, body) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).GetContactOpenQuotes(context.Background(), "ct-1") + + require.NoError(t, err) + assert.Equal(t, "Q-1", result.Results[0].QuoteNumber) + }) +} + +func TestQuoteClient_GetProductQuoteImages(t *testing.T) { + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/v1/products/quote-images", r.URL.Path) + var body map[string][]string + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, []string{"p-1", "p-2"}, body["productIds"]) + }, map[string]interface{}{"results": map[string]interface{}{ + "p-1": []interface{}{map[string]interface{}{"id": "i-1", "productId": "p-1", "displayOrder": 0}}, + "p-2": []interface{}{}, + }}) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).GetProductQuoteImages(context.Background(), []string{"p-1", "p-2"}) + + require.NoError(t, err) + assert.Len(t, result["p-1"], 1) + assert.Len(t, result["p-2"], 0) +} + +func TestQuoteClient_LineItemImages(t *testing.T) { + t.Run("GetQuoteLineItemImages keys images by line item", func(t *testing.T) { + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "/v1/quotes/q-1/items/images", r.URL.Path) + }, map[string]interface{}{"results": map[string]interface{}{ + "li-1": []interface{}{map[string]interface{}{"id": "img-1", "lineItemId": "li-1", "displayOrder": 0}}, + }}) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).GetQuoteLineItemImages(context.Background(), "q-1") + + require.NoError(t, err) + assert.Equal(t, "img-1", result["li-1"][0].ID) + }) + + t.Run("GetLineItemImages returns one item's images", func(t *testing.T) { + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "/v1/quotes/q-1/items/li-1/images", r.URL.Path) + }, map[string]interface{}{"results": []interface{}{map[string]interface{}{"id": "img-1", "lineItemId": "li-1"}}}) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).GetLineItemImages(context.Background(), "q-1", "li-1") + + require.NoError(t, err) + assert.Equal(t, "img-1", result[0].ID) + }) + + t.Run("AddLineItemImages uploads 'images' parts with detected types and no data part", func(t *testing.T) { + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/v1/quotes/q-1/items/li-1/images", r.URL.Path) + require.NoError(t, r.ParseMultipartForm(10<<20)) + _, hasData := r.MultipartForm.Value["data"] + assert.False(t, hasData) + files := r.MultipartForm.File["images"] + require.Len(t, files, 2) + assert.Equal(t, "image/png", files[0].Header.Get("Content-Type")) + assert.Equal(t, "image/jpeg", files[1].Header.Get("Content-Type")) + f, _ := files[0].Open() + data, _ := io.ReadAll(f) + assert.Equal(t, testPNG, data) + }, map[string]interface{}{"results": []interface{}{map[string]interface{}{"id": "img-1"}, map[string]interface{}{"id": "img-2"}}}) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).AddLineItemImages(context.Background(), "q-1", "li-1", []ProductImageInput{ + {Data: testPNG, FileName: "a.png"}, + {Data: testJPEG, FileName: "b.jpg"}, + }) + + require.NoError(t, err) + assert.Len(t, result, 2) + }) + + t.Run("ReorderLineItemImages puts the ids first to last", func(t *testing.T) { + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "PUT", r.Method) + assert.Equal(t, "/v1/quotes/q-1/items/li-1/images/order", r.URL.Path) + var body map[string][]string + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, []string{"img-2", "img-1"}, body["imageIds"]) + }, map[string]interface{}{"results": []interface{}{map[string]interface{}{"id": "img-2"}, map[string]interface{}{"id": "img-1"}}}) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).ReorderLineItemImages(context.Background(), "q-1", "li-1", []string{"img-2", "img-1"}) + + require.NoError(t, err) + assert.Equal(t, "img-2", result[0].ID) + }) + + t.Run("RemoveLineItemImage returns the images left", func(t *testing.T) { + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + assert.Equal(t, "/v1/quotes/q-1/items/li-1/images/img-1", r.URL.Path) + }, map[string]interface{}{"results": []interface{}{map[string]interface{}{"id": "img-2"}}}) + defer server.Close() + + result, err := newTestQuoteClient(t, server.URL).RemoveLineItemImage(context.Background(), "q-1", "li-1", "img-1") + + require.NoError(t, err) + assert.Equal(t, "img-2", result[0].ID) + }) +} + +func TestQuoteClient_DuplicatesTakeAName(t *testing.T) { + cases := []struct { + name string + path string + call func(c *QuoteClient, opts ...DuplicateOptions) (string, error) + }{ + {"DuplicateQuote", "/v1/quotes/x-1/duplicate", func(c *QuoteClient, opts ...DuplicateOptions) (string, error) { + r, err := c.DuplicateQuote(context.Background(), "x-1", opts...) + if err != nil { + return "", err + } + return r.ID, nil + }}, + {"DuplicateProduct", "/v1/products/x-1/duplicate", func(c *QuoteClient, opts ...DuplicateOptions) (string, error) { + r, err := c.DuplicateProduct(context.Background(), "x-1", opts...) + if err != nil { + return "", err + } + return r.ID, nil + }}, + {"DuplicatePriceBook", "/v1/pricebooks/x-1/duplicate", func(c *QuoteClient, opts ...DuplicateOptions) (string, error) { + r, err := c.DuplicatePriceBook(context.Background(), "x-1", opts...) + if err != nil { + return "", err + } + return r.ID, nil + }}, + {"DuplicateBundle", "/v1/bundles/x-1/duplicate", func(c *QuoteClient, opts ...DuplicateOptions) (string, error) { + r, err := c.DuplicateBundle(context.Background(), "x-1", opts...) + if err != nil { + return "", err + } + return r.ID, nil + }}, + } + for _, tc := range cases { + t.Run(tc.name+" sends the name", func(t *testing.T) { + var body []byte + server := jsonServer(t, func(r *http.Request) { + assert.Equal(t, tc.path, r.URL.Path) + body, _ = io.ReadAll(r.Body) + }, map[string]interface{}{"result": map[string]interface{}{"id": "x-2"}}) + defer server.Close() + + id, err := tc.call(newTestQuoteClient(t, server.URL), DuplicateOptions{Name: "Q4 copy"}) + + require.NoError(t, err) + assert.Equal(t, "x-2", id) + assert.JSONEq(t, `{"name":"Q4 copy"}`, string(body)) + }) + t.Run(tc.name+" without a name sends no body", func(t *testing.T) { + var body []byte + server := jsonServer(t, func(r *http.Request) { body, _ = io.ReadAll(r.Body) }, + map[string]interface{}{"result": map[string]interface{}{"id": "x-2"}}) + defer server.Close() + + _, err := tc.call(newTestQuoteClient(t, server.URL)) + + require.NoError(t, err) + assert.Empty(t, body) + }) + } +} + +func TestQuoteClient_CompanyEmailAndPriceBookReset(t *testing.T) { + t.Run("CreateCompany sends the email", func(t *testing.T) { + var body map[string]interface{} + server := jsonServer(t, func(r *http.Request) { json.NewDecoder(r.Body).Decode(&body) }, + map[string]interface{}{"result": map[string]interface{}{"id": "c-1"}}) + defer server.Close() + + _, err := newTestQuoteClient(t, server.URL).CreateCompany(context.Background(), &CreateCompanyRequest{ + Name: "Acme", Email: strPtr("ap@acme.test"), + Contacts: []CreateCompanyContactInput{{Name: "Ana", Email: "ana@acme.test"}}, + }) + + require.NoError(t, err) + assert.Equal(t, "ap@acme.test", body["email"]) + }) + + t.Run("UpdateCompany ClearEmail sends an explicit null", func(t *testing.T) { + var body []byte + server := jsonServer(t, func(r *http.Request) { body, _ = io.ReadAll(r.Body) }, + map[string]interface{}{"result": map[string]interface{}{"id": "c-1"}}) + defer server.Close() + + req := &UpdateCompanyRequest{} + req.ClearEmail() + _, err := newTestQuoteClient(t, server.URL).UpdateCompany(context.Background(), "c-1", req) + + require.NoError(t, err) + assert.JSONEq(t, `{"email":null}`, string(body)) + }) + + t.Run("UpdatePriceBook sends resetProductPricing", func(t *testing.T) { + var body []byte + server := jsonServer(t, func(r *http.Request) { body, _ = io.ReadAll(r.Body) }, + map[string]interface{}{"result": map[string]interface{}{"id": "pb-1"}}) + defer server.Close() + + yes := true + _, err := newTestQuoteClient(t, server.URL).UpdatePriceBook(context.Background(), "pb-1", &UpdatePriceBookRequest{ + ResetProductPricing: &yes, + }) + + require.NoError(t, err) + assert.JSONEq(t, `{"resetProductPricing":true}`, string(body)) + }) +} + +func TestQuoteClient_DeleteSentQuoteIsAConflict(t *testing.T) { + message := "A sent quote can't be deleted. Void it instead." + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": message, + "error": map[string]interface{}{"message": message, "code": "CANNOT_DELETE_SENT_QUOTE"}, + "data": map[string]interface{}{}, + }) + })) + defer server.Close() + + _, err := newTestQuoteClient(t, server.URL).DeleteQuote(context.Background(), "q-1") + + var conflict *ConflictError + require.ErrorAs(t, err, &conflict) + assert.Equal(t, 409, conflict.StatusCode) + assert.Equal(t, "CANNOT_DELETE_SENT_QUOTE", conflict.Code) + assert.Equal(t, message, conflict.Message) +} diff --git a/packages/go-sdk/turboquote_test.go b/packages/go-sdk/turboquote_test.go index 6844a7b8..397da71d 100644 --- a/packages/go-sdk/turboquote_test.go +++ b/packages/go-sdk/turboquote_test.go @@ -130,9 +130,13 @@ func TestNewQuoteClient(t *testing.T) { // QuoteStatus Constants Tests // ============================================= -func TestQuoteStatusPendingApproval(t *testing.T) { - t.Run("QuoteStatusPendingApproval constant exists and equals pending_approval", func(t *testing.T) { - assert.Equal(t, QuoteStatus("pending_approval"), QuoteStatusPendingApproval) +func TestQuoteStatusConstants(t *testing.T) { + t.Run("defines the five released statuses", func(t *testing.T) { + assert.Equal(t, QuoteStatus("draft"), QuoteStatusDraft) + assert.Equal(t, QuoteStatus("sent"), QuoteStatusSent) + assert.Equal(t, QuoteStatus("accepted"), QuoteStatusAccepted) + assert.Equal(t, QuoteStatus("declined"), QuoteStatusDeclined) + assert.Equal(t, QuoteStatus("voided"), QuoteStatusVoided) }) } diff --git a/packages/go-sdk/turboquote_types.go b/packages/go-sdk/turboquote_types.go index 13760be1..3c675f05 100644 --- a/packages/go-sdk/turboquote_types.go +++ b/packages/go-sdk/turboquote_types.go @@ -10,12 +10,11 @@ import "encoding/json" type QuoteStatus string const ( - QuoteStatusDraft QuoteStatus = "draft" - QuoteStatusPendingApproval QuoteStatus = "pending_approval" - QuoteStatusSent QuoteStatus = "sent" - QuoteStatusAccepted QuoteStatus = "accepted" - QuoteStatusDeclined QuoteStatus = "declined" - QuoteStatusVoided QuoteStatus = "voided" + QuoteStatusDraft QuoteStatus = "draft" + QuoteStatusSent QuoteStatus = "sent" + QuoteStatusAccepted QuoteStatus = "accepted" + QuoteStatusDeclined QuoteStatus = "declined" + QuoteStatusVoided QuoteStatus = "voided" ) // BillingFrequency represents a billing interval @@ -58,6 +57,29 @@ const ( CurrencyINR Currency = "INR" ) +// List sorting and quote list filter values (the options take them as plain strings) +const ( + // SortAsc / SortDesc are the SortDirection values for every list + SortAsc = "asc" + SortDesc = "desc" + + // QuoteListStatusExpired is the "expired" quote list status filter: a draft or sent quote + // past its Valid until day (UTC), or whose signature expired. It is not a stored status. + QuoteListStatusExpired = "expired" + + // ListQuotes SortBy values (default QuoteSortByCreatedOn, newest first) + QuoteSortByUpdatedOn = "updatedOn" + QuoteSortByCreatedOn = "createdOn" + QuoteSortByTotal = "total" + QuoteSortByValidUntil = "validUntil" + QuoteSortByQuoteNumber = "quoteNumber" + + // ListQuotes ValidUntilWindow values, in UTC days + ValidUntilThisWeek = "thisWeek" // today through today + 6 + ValidUntilThisMonth = "thisMonth" // the current calendar month + ValidUntilPast = "past" // before today +) + // CategoryType represents a category classification type CategoryType string @@ -138,10 +160,20 @@ type QuoteStatusInfo struct { } // QuoteCreator represents the creator of a quote +// +// The same type is returned by ListQuoteCreators, which fills Name, Email, IsAPIKey and +// APIKeyDeleted instead of FirstName / LastName. type QuoteCreator struct { ID string `json:"id"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + // Name is a person's name (else email), or the API key's name (ListQuoteCreators) + Name string `json:"name,omitempty"` + // Email is nil for an API key + Email *string `json:"email,omitempty"` + IsAPIKey bool `json:"isAPIKey,omitempty"` + // APIKeyDeleted marks an API key that has since been deleted (its quotes stay) + APIKeyDeleted bool `json:"apiKeyDeleted,omitempty"` } // QuotePreparedBy is the resolved "Prepared by" identity shown on the quote PDF and preview, @@ -193,6 +225,13 @@ type Quote struct { StatusInfo *QuoteStatusInfo `json:"statusInfo,omitempty"` // PreparedBy is folded on by GetQuote from the response's sibling "preparedBy". PreparedBy *QuotePreparedBy `json:"preparedBy,omitempty"` + // LineItemCount is set by ListQuotes: how many line items the quote has + LineItemCount *int `json:"lineItemCount,omitempty"` + // IsSignatureExpired is set by ListQuotes: the quote's signature request has expired + IsSignatureExpired *bool `json:"isSignatureExpired,omitempty"` + // IsExpired is set by ListQuotes: a draft or sent quote past its Valid until day (UTC), or + // whose signature expired. The same rule as the "expired" status filter. + IsExpired *bool `json:"isExpired,omitempty"` } // ============================================ @@ -276,14 +315,56 @@ func (r UpdateQuoteRequest) MarshalJSON() ([]byte, error) { } // ListQuotesOptions holds the filter/pagination options for listing quotes +// +// Statuses also takes "expired" (QuoteListStatusExpired): a draft or sent quote past its Valid +// until day (UTC), or whose signature expired. SortBy is one of the QuoteSortBy* values +// (default createdOn, newest first); SortDirection is SortAsc or SortDesc. type ListQuotesOptions struct { - Limit *int `json:"limit,omitempty"` - Offset *int `json:"offset,omitempty"` - Query *string `json:"query,omitempty"` - Statuses []string `json:"statuses,omitempty"` - CompanyID *string `json:"companyId,omitempty"` - ContactID *string `json:"contactId,omitempty"` - CurrencyCode *string `json:"currency,omitempty"` + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` + Query *string `json:"query,omitempty"` + Statuses []string `json:"statuses,omitempty"` + CompanyID *string `json:"companyId,omitempty"` + CompanyIDs []string `json:"companyIds,omitempty"` + ContactID *string `json:"contactId,omitempty"` + CurrencyCode *string `json:"currency,omitempty"` + Currencies []string `json:"currencies,omitempty"` + // CreatedBy holds the ids of the users who created the quotes (see ListQuoteCreators) + CreatedBy []string `json:"createdBy,omitempty"` + // CreatedByAPIKeys picks quotes created by any API key (ORed with CreatedBy) + CreatedByAPIKeys *bool `json:"createdByApiKeys,omitempty"` + // ValidUntilWindow is one of the ValidUntil* window values + ValidUntilWindow *string `json:"validUntilWindow,omitempty"` + // ValidUntilFrom / ValidUntilTo are inclusive YYYY-MM-DD bounds + ValidUntilFrom *string `json:"validUntilFrom,omitempty"` + ValidUntilTo *string `json:"validUntilTo,omitempty"` + SortBy *string `json:"sortBy,omitempty"` + SortDirection *string `json:"sortDirection,omitempty"` + SummaryCurrency *string `json:"summaryCurrency,omitempty"` +} + +// QuoteSummaryOptions holds the filters for GetQuoteSummary (the ListQuotes filters; the status +// filter is ignored) +type QuoteSummaryOptions struct { + Query *string `json:"query,omitempty"` + CompanyID *string `json:"companyId,omitempty"` + CompanyIDs []string `json:"companyIds,omitempty"` + ContactID *string `json:"contactId,omitempty"` + CurrencyCode *string `json:"currency,omitempty"` + Currencies []string `json:"currencies,omitempty"` + CreatedBy []string `json:"createdBy,omitempty"` + CreatedByAPIKeys *bool `json:"createdByApiKeys,omitempty"` + ValidUntilWindow *string `json:"validUntilWindow,omitempty"` + ValidUntilFrom *string `json:"validUntilFrom,omitempty"` + ValidUntilTo *string `json:"validUntilTo,omitempty"` + // SummaryCurrency is the currency of the summary (default: the one filtered currency, else USD) + SummaryCurrency *string `json:"summaryCurrency,omitempty"` +} + +// DuplicateOptions names the copy made by a Duplicate* call. Left out (or empty), the copy is +// called "Copy of ". +type DuplicateOptions struct { + Name string } // SendQuoteRequest is the request to send a quote. @@ -389,8 +470,101 @@ type QuoteListResponse struct { Results []Quote `json:"results"` TotalRecords int `json:"totalRecords"` Stats QuoteListStats `json:"stats"` + // StatusCounts holds the quotes per list tab (every filter except the status filter) + StatusCounts *QuoteStatusCounts `json:"statusCounts,omitempty"` + // Summary holds the list's summary cards, in one currency + Summary *QuoteSummary `json:"summary,omitempty"` +} + +// QuoteStatusCounts holds the quotes per list tab. Expired overlaps Draft and Sent. +type QuoteStatusCounts struct { + All int `json:"all"` + Draft int `json:"draft"` + Sent int `json:"sent"` + Accepted int `json:"accepted"` + Declined int `json:"declined"` + Voided int `json:"voided"` + Expired int `json:"expired"` +} + +// QuoteSummaryAmount is a total with how many quotes make it up +type QuoteSummaryAmount struct { + Total float64 `json:"total"` + Count int `json:"count"` +} + +// QuoteSummaryCount is a count of quotes +type QuoteSummaryCount struct { + Count int `json:"count"` +} + +// QuoteWinRate is accepted / (accepted + declined) over the last WindowDays. +// Percent is nil when no quote was decided. +type QuoteWinRate struct { + Percent *float64 `json:"percent"` + Accepted int `json:"accepted"` + Declined int `json:"declined"` + WindowDays int `json:"windowDays"` +} + +// QuoteSummary holds the quote list's summary cards, in one currency +type QuoteSummary struct { + CurrencyCode string `json:"currency"` + // OpenPipeline: draft and sent quotes that aren't expired + OpenPipeline QuoteSummaryAmount `json:"openPipeline"` + Completed struct { + Accepted int `json:"accepted"` + Declined int `json:"declined"` + } `json:"completed"` + CreatedThisMonth QuoteSummaryCount `json:"createdThisMonth"` + // WaitingToSign: sent quotes that aren't expired + WaitingToSign QuoteSummaryCount `json:"waitingToSign"` + WonThisMonth QuoteSummaryCount `json:"wonThisMonth"` + // Currencies lists every currency the org's quotes are in, A to Z + Currencies []string `json:"currencies"` + WinRate QuoteWinRate `json:"winRate"` + // MonthlyRecurring: the monthly subtotal of accepted quotes + MonthlyRecurring QuoteSummaryAmount `json:"monthlyRecurring"` +} + +// NextQuoteNumber is the number the next new quote will get (read only: the counter doesn't move) +type NextQuoteNumber struct { + NextNumber string `json:"nextNumber"` + Sequence int `json:"sequence"` + // CanEdit reports whether the caller can change numbering (administrators) + CanEdit bool `json:"canEdit"` +} + +// OpenQuoteSummary is an open quote (draft or sent) for a company or contact +type OpenQuoteSummary struct { + ID string `json:"id"` + QuoteNumber string `json:"quoteNumber"` + Name *string `json:"name"` + Status QuoteStatus `json:"status"` + UpdatedOn string `json:"updatedOn"` +} + +// OpenQuotesResponse holds the open quotes that block deleting a company or contact: the newest +// 50, and how many in all +type OpenQuotesResponse struct { + Results []OpenQuoteSummary `json:"results"` + TotalRecords int `json:"totalRecords"` +} + +// LineItemImage is an image on a custom line item (a line with no catalog product) +type LineItemImage struct { + ID string `json:"id"` + LineItemID string `json:"lineItemId"` + FileID string `json:"fileId"` + DisplayOrder int `json:"displayOrder"` + FileName string `json:"fileName"` + FileType string `json:"fileType"` + ImageData string `json:"imageData,omitempty"` } +// QuoteLineItemImagesResponse maps line item IDs to their images (up to three, in order) +type QuoteLineItemImagesResponse map[string][]LineItemImage + // SendQuoteResponse is the response from sending a quote type SendQuoteResponse struct { QuoteResult Quote `json:"quote"` @@ -719,14 +893,22 @@ func (r UpdateProductRequest) MarshalJSON() ([]byte, error) { } // ListProductsOptions holds the filter/pagination options for listing products +// +// BillingFrequencies and CurrencyCodes filter by several values (sent as repeated +// billingFrequency / currency params, alongside the single BillingFrequency / CurrencyCode). +// SortBy is "name", "listPrice" or "createdOn" (default createdOn, newest first). type ListProductsOptions struct { - Limit *int `json:"limit,omitempty"` - Offset *int `json:"offset,omitempty"` - Query *string `json:"query,omitempty"` - CategoryIDs []string `json:"categoryIds,omitempty"` - BillingFrequency *string `json:"billingFrequency,omitempty"` - CurrencyCode *string `json:"currency,omitempty"` - ShowInCatalog *bool `json:"showInCatalog,omitempty"` + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` + Query *string `json:"query,omitempty"` + CategoryIDs []string `json:"categoryIds,omitempty"` + BillingFrequency *string `json:"billingFrequency,omitempty"` + BillingFrequencies []string `json:"-" query:"billingFrequency"` + CurrencyCode *string `json:"currency,omitempty"` + CurrencyCodes []string `json:"-" query:"currency"` + ShowInCatalog *bool `json:"showInCatalog,omitempty"` + SortBy *string `json:"sortBy,omitempty"` + SortDirection *string `json:"sortDirection,omitempty"` } // ProductListResponse is the response from listing products @@ -742,6 +924,10 @@ type ProductListResponse struct { // ProductPrimaryImagesResponse maps product IDs to their primary image (or null) type ProductPrimaryImagesResponse map[string]*ProductImage +// ProductQuoteImagesResponse maps product IDs to the images they show on a quote (up to three, +// in display order; empty when none) +type ProductQuoteImagesResponse map[string][]ProductImage + // ============================================ // PriceBook Types // ============================================ @@ -787,6 +973,8 @@ type PriceBook struct { ProductPricing []PriceBookProductPricing `json:"productPricing,omitempty"` PriceBookType *PriceBookType `json:"priceBookType,omitempty"` ProductCount *int `json:"productCount,omitempty"` + // OverrideCount is set by ListPriceBooks: products with their own price in this book + OverrideCount *int `json:"overrideCount,omitempty"` } // PriceBookProductPricingInput represents a product pricing entry for create/update @@ -822,7 +1010,10 @@ type UpdatePriceBookRequest struct { IsDefault *bool `json:"isDefault,omitempty"` ShowInQuoteBuilder *bool `json:"showInQuoteBuilder,omitempty"` ProductPricing []PriceBookProductPricingInput `json:"productPricing,omitempty"` - nullFields map[string]bool `json:"-"` + // ResetProductPricing puts every product back on the book's discount (before any + // ProductPricing in the same request) + ResetProductPricing *bool `json:"resetProductPricing,omitempty"` + nullFields map[string]bool `json:"-"` } // ClearDescription explicitly sets description to null in the JSON payload. @@ -854,12 +1045,17 @@ func (r UpdatePriceBookRequest) MarshalJSON() ([]byte, error) { } // ListPriceBooksOptions holds the filter/pagination options for listing price books +// +// SortBy is "name", "discountPercent", "overrideCount", "validFrom", "validTo" or "createdOn" +// (default createdOn, newest first). type ListPriceBooksOptions struct { Limit *int `json:"limit,omitempty"` Offset *int `json:"offset,omitempty"` Query *string `json:"query,omitempty"` PriceBookTypeIDs []string `json:"priceBookTypeIds,omitempty"` ShowInQuoteBuilder *bool `json:"showInQuoteBuilder,omitempty"` + SortBy *string `json:"sortBy,omitempty"` + SortDirection *string `json:"sortDirection,omitempty"` } // ListPriceBookProductsOptions holds the filter/pagination options for listing price book products @@ -939,6 +1135,10 @@ type Bundle struct { UpdatedOn string `json:"updatedOn"` Items []BundleItem `json:"items,omitempty"` Category *BundleCategory `json:"category,omitempty"` + // ItemCount is set by ListBundles: the bundle's active items + ItemCount *int `json:"itemCount,omitempty"` + // BillingTotals is set by ListBundles: the bundle price per billing frequency + BillingTotals map[string]float64 `json:"billingTotals,omitempty"` } // BundleItemInput represents a bundle item entry for create/update @@ -1023,13 +1223,19 @@ func (r UpdateBundleRequest) MarshalJSON() ([]byte, error) { } // ListBundlesOptions holds the filter/pagination options for listing bundles +// +// CurrencyCodes filters by several currencies (repeated currency params). SortBy is "name", +// "itemCount", "totalListPrice", "totalFinalPrice" or "createdOn" (default createdOn, newest first). type ListBundlesOptions struct { Limit *int `json:"limit,omitempty"` Offset *int `json:"offset,omitempty"` Query *string `json:"query,omitempty"` CategoryIDs []string `json:"categoryIds,omitempty"` CurrencyCode *string `json:"currency,omitempty"` + CurrencyCodes []string `json:"-" query:"currency"` ShowInCatalog *bool `json:"showInCatalog,omitempty"` + SortBy *string `json:"sortBy,omitempty"` + SortDirection *string `json:"sortDirection,omitempty"` } // BundleListResponse is the response from listing bundles @@ -1051,6 +1257,7 @@ type Company struct { ID string `json:"id"` OrgID string `json:"orgId"` Name string `json:"name"` + Email *string `json:"email"` Phone *string `json:"phone"` City *string `json:"city"` State *string `json:"state"` @@ -1077,6 +1284,7 @@ type CreateCompanyContactInput struct { type CreateCompanyRequest struct { Name string `json:"name"` Contacts []CreateCompanyContactInput `json:"contacts"` + Email *string `json:"email,omitempty"` Phone *string `json:"phone,omitempty"` City *string `json:"city,omitempty"` State *string `json:"state,omitempty"` @@ -1087,6 +1295,7 @@ type CreateCompanyRequest struct { // UpdateCompanyRequest is the request to update a company type UpdateCompanyRequest struct { Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` Phone *string `json:"phone,omitempty"` City *string `json:"city,omitempty"` State *string `json:"state,omitempty"` @@ -1095,6 +1304,13 @@ type UpdateCompanyRequest struct { nullFields map[string]bool `json:"-"` } +// ClearEmail explicitly sets email to null in the JSON payload. +func (r *UpdateCompanyRequest) ClearEmail() { + if r.nullFields == nil { r.nullFields = make(map[string]bool) } + r.nullFields["email"] = true + r.Email = nil +} + // ClearPhone explicitly sets phone to null in the JSON payload. func (r *UpdateCompanyRequest) ClearPhone() { if r.nullFields == nil { r.nullFields = make(map[string]bool) } @@ -1145,11 +1361,15 @@ func (r UpdateCompanyRequest) MarshalJSON() ([]byte, error) { } // ListCompaniesOptions holds the filter/pagination options for listing companies +// +// SortBy is "name", "contactCount" or "lastActivityDate" (default lastActivityDate, most recent first). type ListCompaniesOptions struct { - Limit *int `json:"limit,omitempty"` - Offset *int `json:"offset,omitempty"` - Query *string `json:"query,omitempty"` - IndustryIDs []string `json:"industryIds,omitempty"` + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` + Query *string `json:"query,omitempty"` + IndustryIDs []string `json:"industryIds,omitempty"` + SortBy *string `json:"sortBy,omitempty"` + SortDirection *string `json:"sortDirection,omitempty"` } // CompanyListResponse is the response from listing companies @@ -1232,17 +1452,35 @@ func (r UpdateContactRequest) MarshalJSON() ([]byte, error) { } // ListContactsOptions holds the filter/pagination options for listing contacts +// +// SortBy is "name" or "createdOn" (default createdOn, newest first). type ListContactsOptions struct { - Limit *int `json:"limit,omitempty"` - Offset *int `json:"offset,omitempty"` - Query *string `json:"query,omitempty"` - CompanyID *string `json:"companyId,omitempty"` + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` + Query *string `json:"query,omitempty"` + CompanyID *string `json:"companyId,omitempty"` + CompanyIDs []string `json:"companyIds,omitempty"` + SortBy *string `json:"sortBy,omitempty"` + SortDirection *string `json:"sortDirection,omitempty"` +} + +// ListCompanyContactsOptions holds the search, sort and pagination options for one company's contacts. +// SortBy is "name" or "createdOn" (default createdOn, newest first). +type ListCompanyContactsOptions struct { + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` + Query *string `json:"query,omitempty"` + SortBy *string `json:"sortBy,omitempty"` + SortDirection *string `json:"sortDirection,omitempty"` } // ContactListResponse is the response from listing contacts type ContactListResponse struct { Results []Contact `json:"results"` TotalRecords int `json:"totalRecords"` + // Company is set when the list is for one company; its ContactCount is the company's active + // contacts whatever the search (a company can't lose its last contact) + Company *Company `json:"company,omitempty"` } // ============================================ @@ -1404,12 +1642,16 @@ type UpdateQuoteTypeRequest struct { } // ListTypesOptions holds the filter/pagination options for listing types +// +// SortBy is "name" or "usageCount" (usage needs a CategoryType); default name, A to Z. type ListTypesOptions struct { - Limit *int `json:"limit,omitempty"` - Offset *int `json:"offset,omitempty"` - Query *string `json:"query,omitempty"` - CategoryType *string `json:"categoryType,omitempty"` - IncludeUsage *bool `json:"includeUsage,omitempty"` + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` + Query *string `json:"query,omitempty"` + CategoryType *string `json:"categoryType,omitempty"` + IncludeUsage *bool `json:"includeUsage,omitempty"` + SortBy *string `json:"sortBy,omitempty"` + SortDirection *string `json:"sortDirection,omitempty"` } // QuoteTypeListResponse is the response from listing types diff --git a/packages/java-sdk/README.md b/packages/java-sdk/README.md index 5099e90a..f1db019d 100644 --- a/packages/java-sdk/README.md +++ b/packages/java-sdk/README.md @@ -1105,6 +1105,63 @@ byte[] pdf = tq.downloadQuotePdf(quoteId); Files.write(Paths.get("quote.pdf"), pdf); ``` +### Sorting, filters and the list summary + +Every list takes `sortBy` / `sortDirection` (`asc` | `desc`); leave them out for the order the +list always had. Sort columns: quotes `updatedOn` · `createdOn` · `total` · `validUntil` · `quoteNumber`; +products `name` · `listPrice` · `createdOn`; price books `name` · `discountPercent` · `overrideCount` · +`validFrom` · `validTo` · `createdOn`; bundles `name` · `itemCount` · `totalListPrice` · `totalFinalPrice` · +`createdOn`; companies `name` · `contactCount` · `lastActivityDate`; contacts `name` · `createdOn`; +types `name` · `usageCount`. + +The quote list also filters by several companies (`companyIds`) and currencies (`currencies`), by +`expired` in `statuses` (a draft or sent quote past its Valid until day, UTC, or whose signature +expired), by creator (`createdBy`, ids from the creators call; `createdByApiKeys` for API-key quotes) +and by Valid until (`validUntilWindow` = `thisWeek` | `thisMonth` | `past`, `validUntilFrom` / +`validUntilTo`). Each quote carries `isExpired` and `lineItemCount`; the response adds +`statusCounts` (per tab) and `summary` (pipeline, win rate, monthly recurring, in `summaryCurrency`). + +```java +ListQuotesOptions options = new ListQuotesOptions(); +options.setStatuses(Arrays.asList("sent", "expired")); +options.setCurrencies(Arrays.asList("USD", "EUR")); +options.setValidUntilWindow("thisMonth"); +options.setSortBy("validUntil"); +options.setSortDirection("asc"); +QuoteListResponse page = tq.listQuotes(options); +System.out.println(page.getStatusCounts().getExpired() + " " + page.getSummary().getWinRate().getPercent()); + +List creators = tq.listQuoteCreators(); // ids for createdBy +NextQuoteNumber next = tq.getNextQuoteNumber(); // read only +Quote copy = tq.duplicateQuote(quote.getId(), "Q4 renewal"); // name is optional +``` + +`duplicateQuote`, `duplicateProduct`, `duplicatePriceBook` and `duplicateBundle` have a `(id, name)` +overload (otherwise "Copy of …"). `ListProductsOptions` / `ListBundlesOptions` take several values with +`setBillingFrequencies` / `setCurrencies`; `listCompanyContacts` takes `ListCompanyContactsOptions` for +the sort. Companies take an optional `email` (`setEmail(null)` clears it on update); +`UpdatePriceBookRequest.setResetProductPricing(true)` puts every product back on the book's discount. + +### Custom line item images + +A custom line item (one with no catalog product) can show up to three images on the quote (PNG or +JPEG, 2 MB each; draft quotes only). Catalog products use `getProductQuoteImages(productIds)`. + +```java +List images = tq.addLineItemImages(quote.getId(), itemId, + new byte[][]{Files.readAllBytes(Paths.get("diagram.png"))}); +tq.reorderLineItemImages(quote.getId(), itemId, Arrays.asList(images.get(1).getId(), images.get(0).getId())); +tq.removeLineItemImage(quote.getId(), itemId, images.get(0).getId()); +Map> byItem = tq.getQuoteLineItemImages(quote.getId()); +``` + +**Deletes the API refuses.** `deleteQuote` on a **sent** quote is a 409 `ConflictException` with code +`CANNOT_DELETE_SENT_QUOTE` — void it instead; an accepted quote is a 409 `CANNOT_DELETE_ACCEPTED_QUOTE`. +`deleteCompany` is a 409 `CompanyHasOpenQuotes` while the company has open quotes (draft or +sent), and `deleteContact` is a 409 `LastContact` (the company's only contact) or `ContactHasOpenQuotes`. +`deletePriceBook` on the org's default price book is a 400 `ValidationException` `PriceBookIsDefault`. Branch on +`e.getCode()`; `getCompanyOpenQuotes` / `getContactOpenQuotes` list the open quotes that block a delete. + ### Sender identity — "Prepared by" The quote's **"Prepared by"** name and email are resolved by the server, not by whoever @@ -1134,14 +1191,14 @@ if (quote.getPreparedBy() != null) { | Group | Methods | |:------|:--------| -| **Quotes** | `listQuotes`, `createQuote`, `getQuote`, `updateQuote`, `deleteQuote`, `duplicateQuote`, `applyPriceBook`, `removePriceBook`, `downloadQuotePdf` | +| **Quotes** | `listQuotes`, `createQuote`, `getQuote`, `updateQuote`, `deleteQuote`, `duplicateQuote`, `applyPriceBook`, `removePriceBook`, `downloadQuotePdf`, `getQuoteSummary`, `listQuoteCreators`, `getNextQuoteNumber` | | **Quote status** | `sendQuote`, `sendQuoteWithDeliverable`, `declineQuote`, `voidQuote`, `handleExpiredQuote` | -| **Line items** | `listLineItems`, `addLineItems`, `addBundleLineItems`, `updateLineItem`, `removeLineItem` | -| **Products** | `listProducts`, `createProduct`, `getProduct`, `updateProduct`, `deleteProduct`, `duplicateProduct`, `getProductPrimaryImages` | +| **Line items** | `listLineItems`, `addLineItems`, `addBundleLineItems`, `updateLineItem`, `removeLineItem`, `getQuoteLineItemImages`, `getLineItemImages`, `addLineItemImages`, `reorderLineItemImages`, `removeLineItemImage` | +| **Products** | `listProducts`, `createProduct`, `getProduct`, `updateProduct`, `deleteProduct`, `duplicateProduct`, `getProductPrimaryImages`, `getProductQuoteImages` | | **Pricebooks** | `listPriceBooks`, `createPriceBook`, `getPriceBook`, `updatePriceBook`, `deletePriceBook`, `duplicatePriceBook`, `listPriceBookProducts` | | **Bundles** | `listBundles`, `createBundle`, `getBundle`, `updateBundle`, `deleteBundle`, `duplicateBundle` | -| **Companies** | `listCompanies`, `createCompany`, `getCompany`, `updateCompany`, `deleteCompany`, `listCompanyContacts` | -| **Contacts** | `listContacts`, `createContact`, `updateContact`, `deleteContact` | +| **Companies** | `listCompanies`, `createCompany`, `getCompany`, `updateCompany`, `deleteCompany`, `listCompanyContacts`, `getCompanyOpenQuotes` | +| **Contacts** | `listContacts`, `createContact`, `updateContact`, `deleteContact`, `getContactOpenQuotes` | | **Templates** | `listTemplates`, `getTemplate`, `getTemplateById`, `createTemplate`, `updateTemplate`, `deleteTemplate` | | **Types** | `listTypes`, `createType`, `updateType`, `deleteType` | | **Convenience** | `createAndSend` | diff --git a/packages/java-sdk/src/main/java/com/turbodocx/HttpClient.java b/packages/java-sdk/src/main/java/com/turbodocx/HttpClient.java index e6ad4166..7083c5a2 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/HttpClient.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/HttpClient.java @@ -310,6 +310,30 @@ public T postRawJson(String path, String jsonBody, Type responseType) throws return execute(request, responseType); } + public T put(String path, Object body, Type responseType) throws IOException { + RequestBody requestBody = body != null + ? RequestBody.create(gson.toJson(body), JSON) + : RequestBody.create("{}", JSON); + + Request request = new Request.Builder() + .url(baseUrl + path) + .headers(buildHeaders()) + .put(requestBody) + .build(); + + return execute(request, responseType); + } + + public T delete(String path, Type responseType) throws IOException { + Request request = new Request.Builder() + .url(baseUrl + path) + .headers(buildHeaders()) + .delete() + .build(); + + return execute(request, responseType); + } + public T delete(String path, Class responseClass) throws IOException { Request request = new Request.Builder() .url(baseUrl + path) diff --git a/packages/java-sdk/src/main/java/com/turbodocx/TurboQuote.java b/packages/java-sdk/src/main/java/com/turbodocx/TurboQuote.java index fbc83158..936639e7 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/TurboQuote.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/TurboQuote.java @@ -33,6 +33,7 @@ public final class TurboQuote { // (explicit nulls = fields the caller cleared). Other nulls are dropped earlier by {@code gson} // (no serializeNulls) when the tree is built. Gson is thread-safe, so one shared instance. private static final Gson NULL_PRESERVING_GSON = new GsonBuilder().serializeNulls().create(); + private static final Type LINE_ITEM_IMAGES = new TypeToken>>(){}.getType(); public TurboQuote(HttpClient httpClient) { this.httpClient = httpClient; @@ -58,6 +59,32 @@ public QuoteListResponse listQuotes() throws IOException { return listQuotes(null); } + /** + * The quote list's summary cards (pipeline, win rate, monthly recurring…) for a set of + * filters, in one currency ({@code summaryCurrency}). The status filter is ignored. + */ + public QuoteSummary getQuoteSummary(QuoteSummaryOptions options) throws IOException { + Map params = options != null ? options.toQueryParams() : null; + JsonObject response = httpClient.get("/v1/quotes/summary", params, JsonObject.class); + return gson.fromJson(response.get("summary"), QuoteSummary.class); + } + + /** + * The quote list's summary cards, without filters (USD unless the org's quotes say otherwise). + */ + public QuoteSummary getQuoteSummary() throws IOException { + return getQuoteSummary(null); + } + + /** + * The people and API keys who created the org's quotes — the ids for the {@code createdBy} filter. + */ + public List listQuoteCreators() throws IOException { + Type type = new TypeToken>>(){}.getType(); + ValueEnvelope> envelope = httpClient.get("/v1/quotes/creators", type); + return envelope.getResults(); + } + /** * Create a new quote. */ @@ -98,18 +125,27 @@ public Quote updateQuote(String id, UpdateQuoteRequest request) throws IOExcepti } /** - * Delete a quote. + * Delete a quote. A sent quote is refused with a 409 ({@code ConflictException}, code + * {@code CANNOT_DELETE_SENT_QUOTE}) — void it instead — and so is an accepted one + * ({@code CANNOT_DELETE_ACCEPTED_QUOTE}). */ public SuccessResponse deleteQuote(String id) throws IOException { return httpClient.delete("/v1/quotes/" + id, SuccessResponse.class); } /** - * Duplicate a quote. + * Duplicate a quote. The copy is called "Copy of <name>". */ public Quote duplicateQuote(String id) throws IOException { + return duplicateQuote(id, null); + } + + /** + * Duplicate a quote and name the copy (null or blank: "Copy of <name>"). + */ + public Quote duplicateQuote(String id, String name) throws IOException { Type type = new TypeToken>(){}.getType(); - ResultEnvelope envelope = httpClient.post("/v1/quotes/" + id + "/duplicate", null, type); + ResultEnvelope envelope = httpClient.post("/v1/quotes/" + id + "/duplicate", duplicateBody(name), type); return envelope.getResult(); } @@ -308,6 +344,65 @@ public SuccessResponse removeLineItem(String quoteId, String itemId) throws IOEx return httpClient.delete("/v1/quotes/" + quoteId + "/items/" + itemId, SuccessResponse.class); } + // ============================================ + // CUSTOM LINE ITEM IMAGES + // Up to three per custom item (a line with no catalog product), PNG or JPEG, 2MB each. + // Changes are allowed on draft quotes only. + // ============================================ + + /** + * Every custom line item's images (up to three, in order), keyed by line item ID. + */ + public Map> getQuoteLineItemImages(String quoteId) throws IOException { + Type type = new TypeToken>>>(){}.getType(); + ValueEnvelope>> envelope = + httpClient.get("/v1/quotes/" + quoteId + "/items/images", type); + return envelope.getResults(); + } + + /** + * One custom line item's images, in order. + */ + public List getLineItemImages(String quoteId, String itemId) throws IOException { + ValueEnvelope> envelope = + httpClient.get("/v1/quotes/" + quoteId + "/items/" + itemId + "/images", LINE_ITEM_IMAGES); + return envelope.getResults(); + } + + /** + * Add images (PNG or JPEG bytes) to a custom line item. Returns all of its images. + */ + public List addLineItemImages(String quoteId, String itemId, byte[][] images) throws IOException { + MultipartBody.Builder builder = new MultipartBody.Builder().setType(MultipartBody.FORM); + for (byte[] image : images) { + String mimeType = detectImageMimeType(image); + builder.addFormDataPart("images", getImageFilename(mimeType), RequestBody.create(image, MediaType.parse(mimeType))); + } + ValueEnvelope> envelope = httpClient.postFormData( + "/v1/quotes/" + quoteId + "/items/" + itemId + "/images", builder.build(), LINE_ITEM_IMAGES); + return envelope.getResults(); + } + + /** + * Set the order of a custom line item's images, first to last. + */ + public List reorderLineItemImages(String quoteId, String itemId, List imageIds) throws IOException { + Map body = new LinkedHashMap<>(); + body.put("imageIds", imageIds); + ValueEnvelope> envelope = + httpClient.put("/v1/quotes/" + quoteId + "/items/" + itemId + "/images/order", body, LINE_ITEM_IMAGES); + return envelope.getResults(); + } + + /** + * Remove one image from a custom line item. Returns the images left. + */ + public List removeLineItemImage(String quoteId, String itemId, String imageId) throws IOException { + ValueEnvelope> envelope = httpClient.delete( + "/v1/quotes/" + quoteId + "/items/" + itemId + "/images/" + imageId, LINE_ITEM_IMAGES); + return envelope.getResults(); + } + // ============================================ // PRODUCTS // ============================================ @@ -382,11 +477,18 @@ public SuccessResponse deleteProduct(String id) throws IOException { } /** - * Duplicate a product. + * Duplicate a product. The copy is called "Copy of <name>". */ public Product duplicateProduct(String id) throws IOException { + return duplicateProduct(id, null); + } + + /** + * Duplicate a product and name the copy (null or blank: "Copy of <name>"). + */ + public Product duplicateProduct(String id, String name) throws IOException { Type type = new TypeToken>(){}.getType(); - ResultEnvelope envelope = httpClient.post("/v1/products/" + id + "/duplicate", null, type); + ResultEnvelope envelope = httpClient.post("/v1/products/" + id + "/duplicate", duplicateBody(name), type); return envelope.getResult(); } @@ -413,6 +515,17 @@ public Map getProductPrimaryImages(List productIds return map; } + /** + * The images each product shows on a quote: up to three, in display order (empty when none). + */ + public Map> getProductQuoteImages(List productIds) throws IOException { + Map body = new HashMap<>(); + body.put("productIds", productIds); + Type type = new TypeToken>>>(){}.getType(); + ValueEnvelope>> envelope = httpClient.post("/v1/products/quote-images", body, type); + return envelope.getResults(); + } + // ============================================ // PRICE BOOKS // ============================================ @@ -475,18 +588,26 @@ public PriceBook updatePriceBook(String id, UpdatePriceBookRequest request) thro } /** - * Delete a price book. + * Delete a price book. The org's default price book is refused (400 ValidationException, + * code {@code PriceBookIsDefault}). */ public SuccessResponse deletePriceBook(String id) throws IOException { return httpClient.delete("/v1/pricebooks/" + id, SuccessResponse.class); } /** - * Duplicate a price book. + * Duplicate a price book. The copy is called "Copy of <name>". */ public PriceBook duplicatePriceBook(String id) throws IOException { + return duplicatePriceBook(id, null); + } + + /** + * Duplicate a price book and name the copy (null or blank: "Copy of <name>"). + */ + public PriceBook duplicatePriceBook(String id, String name) throws IOException { Type type = new TypeToken>(){}.getType(); - ResultEnvelope envelope = httpClient.post("/v1/pricebooks/" + id + "/duplicate", null, type); + ResultEnvelope envelope = httpClient.post("/v1/pricebooks/" + id + "/duplicate", duplicateBody(name), type); return envelope.getResult(); } @@ -567,11 +688,18 @@ public SuccessResponse deleteBundle(String id) throws IOException { } /** - * Duplicate a bundle. + * Duplicate a bundle. The copy is called "Copy of <name>". */ public Bundle duplicateBundle(String id) throws IOException { + return duplicateBundle(id, null); + } + + /** + * Duplicate a bundle and name the copy (null or blank: "Copy of <name>"). + */ + public Bundle duplicateBundle(String id, String name) throws IOException { Type type = new TypeToken>(){}.getType(); - ResultEnvelope envelope = httpClient.post("/v1/bundles/" + id + "/duplicate", null, type); + ResultEnvelope envelope = httpClient.post("/v1/bundles/" + id + "/duplicate", duplicateBody(name), type); return envelope.getResult(); } @@ -631,12 +759,21 @@ public Company updateCompany(String id, UpdateCompanyRequest request) throws IOE } /** - * Delete a company. + * Delete a company and its contacts. Refused with a 409 ({@code ConflictException}, code + * {@code CompanyHasOpenQuotes}) while it has open quotes (draft or sent) — + * see {@link #getCompanyOpenQuotes(String)}. */ public SuccessResponse deleteCompany(String id) throws IOException { return httpClient.delete("/v1/companies/" + id, SuccessResponse.class); } + /** + * The open quotes (draft or sent) for a company: what blocks deleting it. + */ + public OpenQuotesResponse getCompanyOpenQuotes(String companyId) throws IOException { + return httpClient.get("/v1/companies/" + companyId + "/open-quotes", OpenQuotesResponse.class); + } + /** * List contacts for a specific company. */ @@ -699,12 +836,21 @@ public Contact updateContact(String id, UpdateContactRequest request) throws IOE } /** - * Delete a contact. + * Delete a contact. Refused with a 409 ({@code ConflictException}) when they are the + * company's only contact ({@code LastContact}) or the contact on open quotes + * ({@code ContactHasOpenQuotes}) — see {@link #getContactOpenQuotes(String)}. */ public SuccessResponse deleteContact(String id) throws IOException { return httpClient.delete("/v1/contacts/" + id, SuccessResponse.class); } + /** + * The open quotes (draft or sent) made out to a contact: what blocks deleting them. + */ + public OpenQuotesResponse getContactOpenQuotes(String contactId) throws IOException { + return httpClient.get("/v1/contacts/" + contactId + "/open-quotes", OpenQuotesResponse.class); + } + // ============================================ // TEMPLATES // ============================================ @@ -885,10 +1031,35 @@ public QuoteNumberConfig updateQuoteNumberConfig(QuoteNumberFormat format) throw return envelope.getResults(); } + /** + * The number the next new quote will get. Read only — the counter doesn't move, so a quote + * created by someone else in between takes it first. + */ + public NextQuoteNumber getNextQuoteNumber() throws IOException { + Type type = new TypeToken>(){}.getType(); + ValueEnvelope envelope = httpClient.get("/v1/quotes/number-config/next", type); + return envelope.getResults(); + } + // ============================================ // PRIVATE HELPERS // ============================================ + /** Unwraps { results: T } where results is one value (an object, a map or a list). */ + private static final class ValueEnvelope { + private T results; + + T getResults() { return results; } + } + + /** A duplicate's body: the copy's name when given, else none (the API names it "Copy of …"). */ + private static Map duplicateBody(String name) { + if (name == null) return null; + Map body = new LinkedHashMap<>(); + body.put("name", name); + return body; + } + /** * Flattens a {@link SignatureSchedule} onto the send body as eight top-level keys. * diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/Bundle.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/Bundle.java index ebaa2d8a..0f27561c 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/Bundle.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/Bundle.java @@ -1,6 +1,7 @@ package com.turbodocx.models.quote; import java.util.List; +import java.util.Map; /** * Bundle domain entity. @@ -26,6 +27,10 @@ public class Bundle { private String updatedOn; private List items; private BundleCategory category; + /** listBundles only: the bundle's active items */ + private Integer itemCount; + /** listBundles only: the bundle price per billing frequency */ + private Map billingTotals; public String getId() { return id; } public String getOrgId() { return orgId; } @@ -47,6 +52,8 @@ public class Bundle { public String getUpdatedOn() { return updatedOn; } public List getItems() { return items; } public BundleCategory getCategory() { return category; } + public Integer getItemCount() { return itemCount; } + public Map getBillingTotals() { return billingTotals; } /** * Nested bundle category reference. diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/Company.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/Company.java index 170b55f7..5967b576 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/Company.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/Company.java @@ -7,6 +7,8 @@ public class Company { private String id; private String orgId; private String name; + /** The company's own email (null for companies created without one) */ + private String email; private String phone; private String city; private String state; @@ -23,6 +25,7 @@ public class Company { public String getId() { return id; } public String getOrgId() { return orgId; } public String getName() { return name; } + public String getEmail() { return email; } public String getPhone() { return phone; } public String getCity() { return city; } public String getState() { return state; } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ContactListResponse.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ContactListResponse.java index b08c4783..a652c714 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ContactListResponse.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ContactListResponse.java @@ -8,7 +8,13 @@ public class ContactListResponse { private List results; private Integer totalRecords; + /** + * The company, when the list is for one company. Its contactCount is the company's active + * contacts whatever the search (a company can't lose its last contact). + */ + private Company company; public List getResults() { return results; } public Integer getTotalRecords() { return totalRecords; } + public Company getCompany() { return company; } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/CreateCompanyRequest.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/CreateCompanyRequest.java index 8c0fa3d6..fff7ddc6 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/CreateCompanyRequest.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/CreateCompanyRequest.java @@ -13,6 +13,8 @@ public class CreateCompanyRequest { private String state; private String country; private String industryId; + /** The company's own email (optional through the API; the TurboDocx app requires it) */ + private String email; public String getName() { return name; } public void setName(String name) { this.name = name; } @@ -28,4 +30,6 @@ public class CreateCompanyRequest { public void setCountry(String country) { this.country = country; } public String getIndustryId() { return industryId; } public void setIndustryId(String industryId) { this.industryId = industryId; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/LineItemImage.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/LineItemImage.java new file mode 100644 index 00000000..6db76e82 --- /dev/null +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/LineItemImage.java @@ -0,0 +1,23 @@ +package com.turbodocx.models.quote; + +/** + * An image on a custom line item (a quote line with no catalog product). + */ +public class LineItemImage { + private String id; + private String lineItemId; + private String fileId; + private Integer displayOrder; + private String fileName; + private String fileType; + /** Base64 data URL: "data:image/png;base64,..." */ + private String imageData; + + public String getId() { return id; } + public String getLineItemId() { return lineItemId; } + public String getFileId() { return fileId; } + public Integer getDisplayOrder() { return displayOrder; } + public String getFileName() { return fileName; } + public String getFileType() { return fileType; } + public String getImageData() { return imageData; } +} diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListBundlesOptions.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListBundlesOptions.java index 28e40b64..92d1dc9d 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListBundlesOptions.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListBundlesOptions.java @@ -14,6 +14,12 @@ public class ListBundlesOptions { private List categoryIds; private String currency; private Boolean showInCatalog; + /** Several currencies, sent as repeated "currency" params (merged with currency) */ + private List currencies; + /** Sort column: "name", "itemCount", "totalListPrice", "totalFinalPrice" or "createdOn" (default createdOn, newest first) */ + private String sortBy; + /** "asc" or "desc" */ + private String sortDirection; public Integer getLimit() { return limit; } public void setLimit(Integer limit) { this.limit = limit; } @@ -28,14 +34,24 @@ public class ListBundlesOptions { public Boolean getShowInCatalog() { return showInCatalog; } public void setShowInCatalog(Boolean showInCatalog) { this.showInCatalog = showInCatalog; } + public List getCurrencies() { return currencies; } + public void setCurrencies(List currencies) { this.currencies = currencies; } + public String getSortBy() { return sortBy; } + public void setSortBy(String sortBy) { this.sortBy = sortBy; } + public String getSortDirection() { return sortDirection; } + public void setSortDirection(String sortDirection) { this.sortDirection = sortDirection; } + public Map toQueryParams() { Map params = new LinkedHashMap<>(); if (limit != null) params.put("limit", limit.toString()); if (offset != null) params.put("offset", offset.toString()); if (query != null) params.put("query", query); if (categoryIds != null) params.put("categoryIds", categoryIds); - if (currency != null) params.put("currency", currency); + List currencyList = QueryParams.oneOrMany(currency, currencies); + if (currencyList != null) params.put("currency", currencyList); if (showInCatalog != null) params.put("showInCatalog", showInCatalog.toString()); + if (sortBy != null) params.put("sortBy", sortBy); + if (sortDirection != null) params.put("sortDirection", sortDirection); return params.isEmpty() ? null : params; } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListCompaniesOptions.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListCompaniesOptions.java index 58695f6b..4b442e75 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListCompaniesOptions.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListCompaniesOptions.java @@ -12,6 +12,10 @@ public class ListCompaniesOptions { private Integer offset; private String query; private List industryIds; + /** Sort column: "name", "contactCount" or "lastActivityDate" (default lastActivityDate, most recent first) */ + private String sortBy; + /** "asc" or "desc" */ + private String sortDirection; public Integer getLimit() { return limit; } public void setLimit(Integer limit) { this.limit = limit; } @@ -22,12 +26,19 @@ public class ListCompaniesOptions { public List getIndustryIds() { return industryIds; } public void setIndustryIds(List industryIds) { this.industryIds = industryIds; } + public String getSortBy() { return sortBy; } + public void setSortBy(String sortBy) { this.sortBy = sortBy; } + public String getSortDirection() { return sortDirection; } + public void setSortDirection(String sortDirection) { this.sortDirection = sortDirection; } + public Map toQueryParams() { Map params = new LinkedHashMap<>(); if (limit != null) params.put("limit", limit.toString()); if (offset != null) params.put("offset", offset.toString()); if (query != null) params.put("query", query); if (industryIds != null) params.put("industryIds", industryIds); + if (sortBy != null) params.put("sortBy", sortBy); + if (sortDirection != null) params.put("sortDirection", sortDirection); return params.isEmpty() ? null : params; } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListCompanyContactsOptions.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListCompanyContactsOptions.java new file mode 100644 index 00000000..f5e6b059 --- /dev/null +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListCompanyContactsOptions.java @@ -0,0 +1,28 @@ +package com.turbodocx.models.quote; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Search, sort and pagination for one company's contacts (TurboQuote#listCompanyContacts). + */ +public class ListCompanyContactsOptions extends PaginationParams { + /** "name" or "createdOn" (default createdOn, newest first) */ + private String sortBy; + /** "asc" or "desc" */ + private String sortDirection; + + public String getSortBy() { return sortBy; } + public void setSortBy(String sortBy) { this.sortBy = sortBy; } + public String getSortDirection() { return sortDirection; } + public void setSortDirection(String sortDirection) { this.sortDirection = sortDirection; } + + @Override + public Map toQueryParams() { + Map base = super.toQueryParams(); + Map params = base != null ? base : new LinkedHashMap<>(); + if (sortBy != null) params.put("sortBy", sortBy); + if (sortDirection != null) params.put("sortDirection", sortDirection); + return params.isEmpty() ? null : params; + } +} diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListContactsOptions.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListContactsOptions.java index 647cf355..92b93d87 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListContactsOptions.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListContactsOptions.java @@ -1,6 +1,7 @@ package com.turbodocx.models.quote; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -11,6 +12,12 @@ public class ListContactsOptions { private Integer offset; private String query; private String companyId; + /** Several companies */ + private List companyIds; + /** Sort column: "name" or "createdOn" (default createdOn, newest first) */ + private String sortBy; + /** "asc" or "desc" */ + private String sortDirection; public Integer getLimit() { return limit; } public void setLimit(Integer limit) { this.limit = limit; } @@ -21,12 +28,22 @@ public class ListContactsOptions { public String getCompanyId() { return companyId; } public void setCompanyId(String companyId) { this.companyId = companyId; } + public List getCompanyIds() { return companyIds; } + public void setCompanyIds(List companyIds) { this.companyIds = companyIds; } + public String getSortBy() { return sortBy; } + public void setSortBy(String sortBy) { this.sortBy = sortBy; } + public String getSortDirection() { return sortDirection; } + public void setSortDirection(String sortDirection) { this.sortDirection = sortDirection; } + public Map toQueryParams() { Map params = new LinkedHashMap<>(); if (limit != null) params.put("limit", limit.toString()); if (offset != null) params.put("offset", offset.toString()); if (query != null) params.put("query", query); if (companyId != null) params.put("companyId", companyId); + if (companyIds != null) params.put("companyIds", companyIds); + if (sortBy != null) params.put("sortBy", sortBy); + if (sortDirection != null) params.put("sortDirection", sortDirection); return params.isEmpty() ? null : params; } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListPriceBooksOptions.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListPriceBooksOptions.java index 69c38eff..ac8eba27 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListPriceBooksOptions.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListPriceBooksOptions.java @@ -13,6 +13,10 @@ public class ListPriceBooksOptions { private String query; private List priceBookTypeIds; private Boolean showInQuoteBuilder; + /** Sort column: "name", "discountPercent", "overrideCount", "validFrom", "validTo" or "createdOn" (default createdOn, newest first) */ + private String sortBy; + /** "asc" or "desc" */ + private String sortDirection; public Integer getLimit() { return limit; } public void setLimit(Integer limit) { this.limit = limit; } @@ -25,6 +29,11 @@ public class ListPriceBooksOptions { public Boolean getShowInQuoteBuilder() { return showInQuoteBuilder; } public void setShowInQuoteBuilder(Boolean showInQuoteBuilder) { this.showInQuoteBuilder = showInQuoteBuilder; } + public String getSortBy() { return sortBy; } + public void setSortBy(String sortBy) { this.sortBy = sortBy; } + public String getSortDirection() { return sortDirection; } + public void setSortDirection(String sortDirection) { this.sortDirection = sortDirection; } + public Map toQueryParams() { Map params = new LinkedHashMap<>(); if (limit != null) params.put("limit", limit.toString()); @@ -32,6 +41,8 @@ public Map toQueryParams() { if (query != null) params.put("query", query); if (priceBookTypeIds != null) params.put("priceBookTypeIds", priceBookTypeIds); if (showInQuoteBuilder != null) params.put("showInQuoteBuilder", showInQuoteBuilder.toString()); + if (sortBy != null) params.put("sortBy", sortBy); + if (sortDirection != null) params.put("sortDirection", sortDirection); return params.isEmpty() ? null : params; } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListProductsOptions.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListProductsOptions.java index 5203e036..25624ff1 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListProductsOptions.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListProductsOptions.java @@ -15,6 +15,14 @@ public class ListProductsOptions { private String billingFrequency; private String currency; private Boolean showInCatalog; + /** Several billing frequencies, sent as repeated "billingFrequency" params (merged with billingFrequency) */ + private List billingFrequencies; + /** Several currencies, sent as repeated "currency" params (merged with currency) */ + private List currencies; + /** Sort column: "name", "listPrice" or "createdOn" (default createdOn, newest first) */ + private String sortBy; + /** "asc" or "desc" */ + private String sortDirection; public Integer getLimit() { return limit; } public void setLimit(Integer limit) { this.limit = limit; } @@ -31,15 +39,28 @@ public class ListProductsOptions { public Boolean getShowInCatalog() { return showInCatalog; } public void setShowInCatalog(Boolean showInCatalog) { this.showInCatalog = showInCatalog; } + public List getBillingFrequencies() { return billingFrequencies; } + public void setBillingFrequencies(List billingFrequencies) { this.billingFrequencies = billingFrequencies; } + public List getCurrencies() { return currencies; } + public void setCurrencies(List currencies) { this.currencies = currencies; } + public String getSortBy() { return sortBy; } + public void setSortBy(String sortBy) { this.sortBy = sortBy; } + public String getSortDirection() { return sortDirection; } + public void setSortDirection(String sortDirection) { this.sortDirection = sortDirection; } + public Map toQueryParams() { Map params = new LinkedHashMap<>(); if (limit != null) params.put("limit", limit.toString()); if (offset != null) params.put("offset", offset.toString()); if (query != null) params.put("query", query); if (categoryIds != null) params.put("categoryIds", categoryIds); - if (billingFrequency != null) params.put("billingFrequency", billingFrequency); - if (currency != null) params.put("currency", currency); + List frequencies = QueryParams.oneOrMany(billingFrequency, billingFrequencies); + if (frequencies != null) params.put("billingFrequency", frequencies); + List currencyList = QueryParams.oneOrMany(currency, currencies); + if (currencyList != null) params.put("currency", currencyList); if (showInCatalog != null) params.put("showInCatalog", showInCatalog.toString()); + if (sortBy != null) params.put("sortBy", sortBy); + if (sortDirection != null) params.put("sortDirection", sortDirection); return params.isEmpty() ? null : params; } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListQuotesOptions.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListQuotesOptions.java index aaa07168..cd7f5611 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListQuotesOptions.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListQuotesOptions.java @@ -5,7 +5,11 @@ import java.util.Map; /** - * Options for listing quotes with pagination and filters. + * Options for listing quotes with pagination, filters and sorting. + * + *

{@code statuses} also takes {@code "expired"}: a draft or sent quote past its Valid until + * day (UTC), or whose signature expired. {@code companyId} / {@code currency} are the original + * single filters; they merge with {@code companyIds} / {@code currencies}.

*/ public class ListQuotesOptions { private Integer limit; @@ -15,6 +19,23 @@ public class ListQuotesOptions { private String companyId; private String contactId; private String currency; + private List companyIds; + private List currencies; + /** User ids of the quotes' creators (see TurboQuote#listQuoteCreators) */ + private List createdBy; + /** Quotes created by any API key (ORed with createdBy) */ + private Boolean createdByApiKeys; + /** "thisWeek", "thisMonth" or "past" (UTC days) */ + private String validUntilWindow; + /** Inclusive YYYY-MM-DD bounds on Valid until */ + private String validUntilFrom; + private String validUntilTo; + /** "updatedOn", "createdOn", "total", "validUntil" or "quoteNumber" (default createdOn, newest first) */ + private String sortBy; + /** "asc" or "desc" */ + private String sortDirection; + /** Currency of the returned summary (default: the one filtered currency, else USD) */ + private String summaryCurrency; public Integer getLimit() { return limit; } public void setLimit(Integer limit) { this.limit = limit; } @@ -30,6 +51,26 @@ public class ListQuotesOptions { public void setContactId(String contactId) { this.contactId = contactId; } public String getCurrency() { return currency; } public void setCurrency(String currency) { this.currency = currency; } + public List getCompanyIds() { return companyIds; } + public void setCompanyIds(List companyIds) { this.companyIds = companyIds; } + public List getCurrencies() { return currencies; } + public void setCurrencies(List currencies) { this.currencies = currencies; } + public List getCreatedBy() { return createdBy; } + public void setCreatedBy(List createdBy) { this.createdBy = createdBy; } + public Boolean getCreatedByApiKeys() { return createdByApiKeys; } + public void setCreatedByApiKeys(Boolean createdByApiKeys) { this.createdByApiKeys = createdByApiKeys; } + public String getValidUntilWindow() { return validUntilWindow; } + public void setValidUntilWindow(String validUntilWindow) { this.validUntilWindow = validUntilWindow; } + public String getValidUntilFrom() { return validUntilFrom; } + public void setValidUntilFrom(String validUntilFrom) { this.validUntilFrom = validUntilFrom; } + public String getValidUntilTo() { return validUntilTo; } + public void setValidUntilTo(String validUntilTo) { this.validUntilTo = validUntilTo; } + public String getSortBy() { return sortBy; } + public void setSortBy(String sortBy) { this.sortBy = sortBy; } + public String getSortDirection() { return sortDirection; } + public void setSortDirection(String sortDirection) { this.sortDirection = sortDirection; } + public String getSummaryCurrency() { return summaryCurrency; } + public void setSummaryCurrency(String summaryCurrency) { this.summaryCurrency = summaryCurrency; } /** * Convert to query parameter map for HTTP requests. @@ -38,11 +79,11 @@ public Map toQueryParams() { Map params = new LinkedHashMap<>(); if (limit != null) params.put("limit", limit.toString()); if (offset != null) params.put("offset", offset.toString()); - if (query != null) params.put("query", query); if (statuses != null) params.put("statuses", statuses); - if (companyId != null) params.put("companyId", companyId); - if (contactId != null) params.put("contactId", contactId); - if (currency != null) params.put("currency", currency); + QueryParams.putQuoteFilters(params, query, companyId, companyIds, contactId, currency, currencies, + createdBy, createdByApiKeys, validUntilWindow, validUntilFrom, validUntilTo, summaryCurrency); + if (sortBy != null) params.put("sortBy", sortBy); + if (sortDirection != null) params.put("sortDirection", sortDirection); return params.isEmpty() ? null : params; } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListTypesOptions.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListTypesOptions.java index 3f36c3cd..7ed28b5e 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListTypesOptions.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/ListTypesOptions.java @@ -12,6 +12,10 @@ public class ListTypesOptions { private String query; private CategoryType categoryType; private Boolean includeUsage; + /** Sort column: "name" or "usageCount" (usage needs a categoryType; default name, A to Z) */ + private String sortBy; + /** "asc" or "desc" */ + private String sortDirection; public Integer getLimit() { return limit; } public void setLimit(Integer limit) { this.limit = limit; } @@ -24,6 +28,11 @@ public class ListTypesOptions { public Boolean getIncludeUsage() { return includeUsage; } public void setIncludeUsage(Boolean includeUsage) { this.includeUsage = includeUsage; } + public String getSortBy() { return sortBy; } + public void setSortBy(String sortBy) { this.sortBy = sortBy; } + public String getSortDirection() { return sortDirection; } + public void setSortDirection(String sortDirection) { this.sortDirection = sortDirection; } + public Map toQueryParams() { Map params = new LinkedHashMap<>(); if (limit != null) params.put("limit", limit.toString()); @@ -31,6 +40,8 @@ public Map toQueryParams() { if (query != null) params.put("query", query); if (categoryType != null) params.put("categoryType", categoryType.getValue()); if (includeUsage != null) params.put("includeUsage", includeUsage.toString()); + if (sortBy != null) params.put("sortBy", sortBy); + if (sortDirection != null) params.put("sortDirection", sortDirection); return params.isEmpty() ? null : params; } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/NextQuoteNumber.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/NextQuoteNumber.java new file mode 100644 index 00000000..8329bb0a --- /dev/null +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/NextQuoteNumber.java @@ -0,0 +1,16 @@ +package com.turbodocx.models.quote; + +/** + * The number the next new quote will get (TurboQuote#getNextQuoteNumber). Read only: the counter + * doesn't move, so a quote created by someone else in between takes it first. + */ +public class NextQuoteNumber { + private String nextNumber; + private Integer sequence; + /** Whether the caller can change numbering (administrators) */ + private Boolean canEdit; + + public String getNextNumber() { return nextNumber; } + public Integer getSequence() { return sequence; } + public Boolean getCanEdit() { return canEdit; } +} diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/OpenQuoteSummary.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/OpenQuoteSummary.java new file mode 100644 index 00000000..02eb5337 --- /dev/null +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/OpenQuoteSummary.java @@ -0,0 +1,18 @@ +package com.turbodocx.models.quote; + +/** + * An open quote (draft or sent) for a company or contact. + */ +public class OpenQuoteSummary { + private String id; + private String quoteNumber; + private String name; + private String status; + private String updatedOn; + + public String getId() { return id; } + public String getQuoteNumber() { return quoteNumber; } + public String getName() { return name; } + public String getStatus() { return status; } + public String getUpdatedOn() { return updatedOn; } +} diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/OpenQuotesResponse.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/OpenQuotesResponse.java new file mode 100644 index 00000000..7e4fe271 --- /dev/null +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/OpenQuotesResponse.java @@ -0,0 +1,14 @@ +package com.turbodocx.models.quote; + +import java.util.List; + +/** + * The open quotes that block deleting a company or contact: the newest 50, and how many in all. + */ +public class OpenQuotesResponse { + private List results; + private Integer totalRecords; + + public List getResults() { return results; } + public Integer getTotalRecords() { return totalRecords; } +} diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/PriceBook.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/PriceBook.java index ef610a90..36a2bf5f 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/PriceBook.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/PriceBook.java @@ -23,6 +23,8 @@ public class PriceBook { private List productPricing; private PriceBookType priceBookType; private Integer productCount; + /** listPriceBooks only: products with their own price in this book */ + private Integer overrideCount; public String getId() { return id; } public String getOrgId() { return orgId; } @@ -41,6 +43,7 @@ public class PriceBook { public List getProductPricing() { return productPricing; } public PriceBookType getPriceBookType() { return priceBookType; } public Integer getProductCount() { return productCount; } + public Integer getOverrideCount() { return overrideCount; } /** * Nested price book type reference. diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QueryParams.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QueryParams.java new file mode 100644 index 00000000..28e750cf --- /dev/null +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QueryParams.java @@ -0,0 +1,45 @@ +package com.turbodocx.models.quote; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Helpers shared by the list option classes. + */ +final class QueryParams { + private QueryParams() {} + + /** + * One value and several values for the same query param, merged into one list (sent as + * repeated keys). Null when neither is set. + */ + static List oneOrMany(String one, List many) { + if (one == null && (many == null || many.isEmpty())) return null; + List values = new ArrayList<>(); + if (one != null) values.add(one); + if (many != null) values.addAll(many); + return values; + } + + /** + * Puts the quote list filters shared by listQuotes and getQuoteSummary. + */ + static void putQuoteFilters(Map params, String query, String companyId, List companyIds, + String contactId, String currency, List currencies, List createdBy, + Boolean createdByApiKeys, String validUntilWindow, String validUntilFrom, + String validUntilTo, String summaryCurrency) { + if (query != null) params.put("query", query); + if (companyId != null) params.put("companyId", companyId); + if (companyIds != null) params.put("companyIds", companyIds); + if (contactId != null) params.put("contactId", contactId); + if (currency != null) params.put("currency", currency); + if (currencies != null) params.put("currencies", currencies); + if (createdBy != null) params.put("createdBy", createdBy); + if (createdByApiKeys != null) params.put("createdByApiKeys", createdByApiKeys.toString()); + if (validUntilWindow != null) params.put("validUntilWindow", validUntilWindow); + if (validUntilFrom != null) params.put("validUntilFrom", validUntilFrom); + if (validUntilTo != null) params.put("validUntilTo", validUntilTo); + if (summaryCurrency != null) params.put("summaryCurrency", summaryCurrency); + } +} diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/Quote.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/Quote.java index 8dbd214b..114b6ad3 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/Quote.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/Quote.java @@ -37,6 +37,15 @@ public class Quote { private QuoteCreator creator; private QuoteStatusInfo statusInfo; private QuotePreparedBy preparedBy; + /** listQuotes only: how many line items the quote has */ + private Integer lineItemCount; + /** listQuotes only: the quote's signature request has expired */ + private Boolean isSignatureExpired; + /** + * listQuotes only: a draft or sent quote past its Valid until day (UTC), or whose signature + * expired. The same rule as the "expired" status filter. + */ + private Boolean isExpired; public String getId() { return id; } public String getOrgId() { return orgId; } @@ -81,17 +90,34 @@ public class Quote { public void setPreparedBy(QuotePreparedBy preparedBy) { this.preparedBy = preparedBy; } + public Integer getLineItemCount() { return lineItemCount; } + public Boolean getIsSignatureExpired() { return isSignatureExpired; } + public Boolean getIsExpired() { return isExpired; } + /** - * Nested creator type. + * Nested creator type. Also what TurboQuote#listQuoteCreators returns: there {@code name}, + * {@code email}, {@code isAPIKey} and {@code apiKeyDeleted} are set instead of the first and + * last name, and {@code id} is the value for the {@code createdBy} filter. */ public static class QuoteCreator { private String id; private String firstName; private String lastName; + /** A person's name (else email), or the API key's name */ + private String name; + /** Null for an API key */ + private String email; + private Boolean isAPIKey; + /** An API key that has since been deleted (its quotes stay) */ + private Boolean apiKeyDeleted; public String getId() { return id; } public String getFirstName() { return firstName; } public String getLastName() { return lastName; } + public String getName() { return name; } + public String getEmail() { return email; } + public Boolean getIsAPIKey() { return isAPIKey; } + public Boolean getApiKeyDeleted() { return apiKeyDeleted; } } /** diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteListResponse.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteListResponse.java index a62072fa..abc6aae9 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteListResponse.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteListResponse.java @@ -9,8 +9,14 @@ public class QuoteListResponse { private List results; private Integer totalRecords; private QuoteListStats stats; + /** Quotes per list tab (every filter except the status filter) */ + private QuoteStatusCounts statusCounts; + /** The list's summary cards, in one currency */ + private QuoteSummary summary; public List getResults() { return results; } public Integer getTotalRecords() { return totalRecords; } public QuoteListStats getStats() { return stats; } + public QuoteStatusCounts getStatusCounts() { return statusCounts; } + public QuoteSummary getSummary() { return summary; } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteStatusCounts.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteStatusCounts.java new file mode 100644 index 00000000..bc7d8b6f --- /dev/null +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteStatusCounts.java @@ -0,0 +1,24 @@ +package com.turbodocx.models.quote; + + +/** + * Quotes per quote list tab. Every list filter applies except the status filter, so each count is + * what that tab would hold. {@code expired} overlaps {@code draft} and {@code sent}. + */ +public class QuoteStatusCounts { + private int all; + private int draft; + private int sent; + private int accepted; + private int declined; + private int voided; + private int expired; + + public int getAll() { return all; } + public int getDraft() { return draft; } + public int getSent() { return sent; } + public int getAccepted() { return accepted; } + public int getDeclined() { return declined; } + public int getVoided() { return voided; } + public int getExpired() { return expired; } +} diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteSummary.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteSummary.java new file mode 100644 index 00000000..18f74723 --- /dev/null +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteSummary.java @@ -0,0 +1,73 @@ +package com.turbodocx.models.quote; + +import java.util.List; + +/** + * The quote list's summary cards, in one currency. Search, company, creator and Valid until + * filters apply; the status filter and the currency list don't. + */ +public class QuoteSummary { + private String currency; + /** Draft and sent quotes that aren't expired */ + private Amount openPipeline; + /** Every accepted and every declined quote */ + private Completed completed; + /** Quotes created since the 1st of this month (UTC) */ + private Count createdThisMonth; + /** Sent quotes that aren't expired */ + private Count waitingToSign; + /** Quotes accepted since the 1st of this month (UTC) */ + private Count wonThisMonth; + /** Every currency the org's quotes are in, A to Z */ + private List currencies; + private WinRate winRate; + /** Monthly subtotal of accepted quotes */ + private Amount monthlyRecurring; + + public String getCurrency() { return currency; } + public Amount getOpenPipeline() { return openPipeline; } + public Completed getCompleted() { return completed; } + public Count getCreatedThisMonth() { return createdThisMonth; } + public Count getWaitingToSign() { return waitingToSign; } + public Count getWonThisMonth() { return wonThisMonth; } + public List getCurrencies() { return currencies; } + public WinRate getWinRate() { return winRate; } + public Amount getMonthlyRecurring() { return monthlyRecurring; } + + /** A total with how many quotes make it up */ + public static class Amount { + private double total; + private int count; + + public double getTotal() { return total; } + public int getCount() { return count; } + } + + /** A count of quotes */ + public static class Count { + private int count; + + public int getCount() { return count; } + } + + public static class Completed { + private int accepted; + private int declined; + + public int getAccepted() { return accepted; } + public int getDeclined() { return declined; } + } + + /** accepted / (accepted + declined) over the last windowDays; percent is null when none were decided */ + public static class WinRate { + private Double percent; + private int accepted; + private int declined; + private int windowDays; + + public Double getPercent() { return percent; } + public int getAccepted() { return accepted; } + public int getDeclined() { return declined; } + public int getWindowDays() { return windowDays; } + } +} diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteSummaryOptions.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteSummaryOptions.java new file mode 100644 index 00000000..3f37419b --- /dev/null +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/QuoteSummaryOptions.java @@ -0,0 +1,56 @@ +package com.turbodocx.models.quote; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Filters for TurboQuote#getQuoteSummary — the same filters as listing quotes (the status filter + * is ignored by the summary), plus the currency the summary is in. + */ +public class QuoteSummaryOptions { + private String query; + private String companyId; + private List companyIds; + private String contactId; + private String currency; + private List currencies; + private List createdBy; + private Boolean createdByApiKeys; + private String validUntilWindow; + private String validUntilFrom; + private String validUntilTo; + private String summaryCurrency; + + public String getQuery() { return query; } + public void setQuery(String query) { this.query = query; } + public String getCompanyId() { return companyId; } + public void setCompanyId(String companyId) { this.companyId = companyId; } + public List getCompanyIds() { return companyIds; } + public void setCompanyIds(List companyIds) { this.companyIds = companyIds; } + public String getContactId() { return contactId; } + public void setContactId(String contactId) { this.contactId = contactId; } + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + public List getCurrencies() { return currencies; } + public void setCurrencies(List currencies) { this.currencies = currencies; } + public List getCreatedBy() { return createdBy; } + public void setCreatedBy(List createdBy) { this.createdBy = createdBy; } + public Boolean getCreatedByApiKeys() { return createdByApiKeys; } + public void setCreatedByApiKeys(Boolean createdByApiKeys) { this.createdByApiKeys = createdByApiKeys; } + public String getValidUntilWindow() { return validUntilWindow; } + public void setValidUntilWindow(String validUntilWindow) { this.validUntilWindow = validUntilWindow; } + public String getValidUntilFrom() { return validUntilFrom; } + public void setValidUntilFrom(String validUntilFrom) { this.validUntilFrom = validUntilFrom; } + public String getValidUntilTo() { return validUntilTo; } + public void setValidUntilTo(String validUntilTo) { this.validUntilTo = validUntilTo; } + public String getSummaryCurrency() { return summaryCurrency; } + public void setSummaryCurrency(String summaryCurrency) { this.summaryCurrency = summaryCurrency; } + + public Map toQueryParams() { + Map params = new LinkedHashMap<>(); + QueryParams.putQuoteFilters(params, query, companyId, companyIds, contactId, currency, currencies, + createdBy, createdByApiKeys, validUntilWindow, validUntilFrom, validUntilTo, summaryCurrency); + return params.isEmpty() ? null : params; + } +} diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/UpdateCompanyRequest.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/UpdateCompanyRequest.java index e2bbd657..47fe77eb 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/UpdateCompanyRequest.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/UpdateCompanyRequest.java @@ -10,6 +10,7 @@ public class UpdateCompanyRequest extends TrackableRequest { private String state; private String country; private String industryId; + private String email; public String getName() { return name; } public void setName(String name) { this.name = name; markFieldSet("name"); } @@ -23,4 +24,7 @@ public class UpdateCompanyRequest extends TrackableRequest { public void setCountry(String country) { this.country = country; markFieldSet("country"); } public String getIndustryId() { return industryId; } public void setIndustryId(String industryId) { this.industryId = industryId; markFieldSet("industryId"); } + public String getEmail() { return email; } + /** Set to null to clear the company's email. */ + public void setEmail(String email) { this.email = email; markFieldSet("email"); } } diff --git a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/UpdatePriceBookRequest.java b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/UpdatePriceBookRequest.java index 2ffcad28..2c42d49a 100644 --- a/packages/java-sdk/src/main/java/com/turbodocx/models/quote/UpdatePriceBookRequest.java +++ b/packages/java-sdk/src/main/java/com/turbodocx/models/quote/UpdatePriceBookRequest.java @@ -15,6 +15,8 @@ public class UpdatePriceBookRequest extends TrackableRequest { private Boolean isDefault; private Boolean showInQuoteBuilder; private List productPricing; + /** Put every product back on the book's discount (before any productPricing in the request) */ + private Boolean resetProductPricing; public String getName() { return name; } public void setName(String name) { this.name = name; markFieldSet("name"); } @@ -34,4 +36,6 @@ public class UpdatePriceBookRequest extends TrackableRequest { public void setShowInQuoteBuilder(Boolean showInQuoteBuilder) { this.showInQuoteBuilder = showInQuoteBuilder; markFieldSet("showInQuoteBuilder"); } public List getProductPricing() { return productPricing; } public void setProductPricing(List productPricing) { this.productPricing = productPricing; markFieldSet("productPricing"); } + public Boolean getResetProductPricing() { return resetProductPricing; } + public void setResetProductPricing(Boolean resetProductPricing) { this.resetProductPricing = resetProductPricing; markFieldSet("resetProductPricing"); } } diff --git a/packages/java-sdk/src/test/java/com/turbodocx/TurboQuoteListsAndImagesTest.java b/packages/java-sdk/src/test/java/com/turbodocx/TurboQuoteListsAndImagesTest.java new file mode 100644 index 00000000..3249c966 --- /dev/null +++ b/packages/java-sdk/src/test/java/com/turbodocx/TurboQuoteListsAndImagesTest.java @@ -0,0 +1,495 @@ +package com.turbodocx; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.turbodocx.models.quote.*; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.*; + +import java.io.IOException; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * TurboQuote: list sorting and filters, the quote summary / creators / next number reads, + * open quotes, product quote images, custom line item images, named duplicates, company + * email and price book reset. + */ +class TurboQuoteListsAndImagesTest { + + private static final byte[] PNG = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00}; + private static final byte[] JPEG = {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0, 0x00}; + + private MockWebServer server; + private TurboQuote quote; + private final Gson gson = new Gson(); + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + quote = new TurboQuoteClient.Builder() + .apiKey("test-api-key") + .orgId("test-org-id") + .baseUrl(server.url("/").toString()) + .build() + .turboQuote(); + } + + @AfterEach + void tearDown() throws IOException { + server.shutdown(); + } + + private void respond(Object body) { + JsonObject wrapper = new JsonObject(); + wrapper.add("data", gson.toJsonTree(body)); + server.enqueue(new MockResponse().setBody(gson.toJson(wrapper)).addHeader("Content-Type", "application/json")); + } + + private static Map map(Object... keyValues) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + m.put((String) keyValues[i], keyValues[i + 1]); + } + return m; + } + + private static Map emptyList() { + return map("results", new ArrayList<>(), "totalRecords", 0); + } + + private static Map> query(RecordedRequest request) { + HttpUrl url = request.getRequestUrl(); + Map> params = new LinkedHashMap<>(); + for (String name : url.queryParameterNames()) { + params.put(name, url.queryParameterValues(name)); + } + return params; + } + + // ============================================ + // SORTING AND FILTERS + // ============================================ + + @Test + void listQuotesSendsSortNewFiltersAndSummaryCurrency() throws Exception { + respond(emptyList()); + + ListQuotesOptions options = new ListQuotesOptions(); + options.setSortBy("total"); + options.setSortDirection("asc"); + options.setStatuses(Arrays.asList("sent", "expired")); + options.setCompanyIds(Arrays.asList("c-1", "c-2")); + options.setCurrencies(Arrays.asList("USD", "EUR")); + options.setCreatedBy(Collections.singletonList("u-1")); + options.setCreatedByApiKeys(true); + options.setValidUntilWindow("thisMonth"); + options.setValidUntilFrom("2026-10-01"); + options.setValidUntilTo("2026-10-31"); + options.setSummaryCurrency("EUR"); + quote.listQuotes(options); + + RecordedRequest request = server.takeRequest(); + assertEquals("/v1/quotes", request.getRequestUrl().encodedPath()); + Map> params = query(request); + assertEquals(Arrays.asList("total"), params.get("sortBy")); + assertEquals(Arrays.asList("asc"), params.get("sortDirection")); + assertEquals(Arrays.asList("sent", "expired"), params.get("statuses")); + assertEquals(Arrays.asList("c-1", "c-2"), params.get("companyIds")); + assertEquals(Arrays.asList("USD", "EUR"), params.get("currencies")); + assertEquals(Arrays.asList("u-1"), params.get("createdBy")); + assertEquals(Arrays.asList("true"), params.get("createdByApiKeys")); + assertEquals(Arrays.asList("thisMonth"), params.get("validUntilWindow")); + assertEquals(Arrays.asList("2026-10-01"), params.get("validUntilFrom")); + assertEquals(Arrays.asList("2026-10-31"), params.get("validUntilTo")); + assertEquals(Arrays.asList("EUR"), params.get("summaryCurrency")); + assertEquals(11, params.size()); + } + + @Test + void listQuotesReturnsCountsSummaryAndExpiry() throws Exception { + respond(map( + "results", Collections.singletonList(map("id", "q-1", "status", "sent", "isExpired", true, + "isSignatureExpired", false, "lineItemCount", 3)), + "totalRecords", 1, + "statusCounts", map("all", 1, "sent", 1, "expired", 1), + "summary", map("currency", "USD", + "openPipeline", map("total", 1200.5, "count", 2), + "winRate", map("percent", null, "accepted", 0, "declined", 0, "windowDays", 90)))); + + QuoteListResponse result = quote.listQuotes(); + + assertEquals(1, result.getStatusCounts().getExpired()); + assertEquals(0, result.getStatusCounts().getDraft()); + assertNull(result.getSummary().getWinRate().getPercent()); + assertEquals(90, result.getSummary().getWinRate().getWindowDays()); + assertEquals(1200.5, result.getSummary().getOpenPipeline().getTotal()); + assertTrue(result.getResults().get(0).getIsExpired()); + assertFalse(result.getResults().get(0).getIsSignatureExpired()); + assertEquals(3, result.getResults().get(0).getLineItemCount()); + } + + @Test + void listProductsSendsSeveralValuesAndSort() throws Exception { + respond(emptyList()); + + ListProductsOptions options = new ListProductsOptions(); + options.setBillingFrequencies(Arrays.asList("monthly", "annual")); + options.setCurrencies(Arrays.asList("USD", "INR")); + options.setSortBy("listPrice"); + options.setSortDirection("desc"); + quote.listProducts(options); + + Map> params = query(server.takeRequest()); + assertEquals(Arrays.asList("monthly", "annual"), params.get("billingFrequency")); + assertEquals(Arrays.asList("USD", "INR"), params.get("currency")); + assertEquals(Arrays.asList("listPrice"), params.get("sortBy")); + assertEquals(Arrays.asList("desc"), params.get("sortDirection")); + } + + @Test + void listPriceBooksSendsSort() throws Exception { + respond(map("results", Collections.singletonList(map("id", "pb-1", "overrideCount", 2)), "totalRecords", 1)); + + ListPriceBooksOptions options = new ListPriceBooksOptions(); + options.setSortBy("overrideCount"); + options.setSortDirection("desc"); + PriceBookListResponse result = quote.listPriceBooks(options); + + Map> params = query(server.takeRequest()); + assertEquals(Arrays.asList("overrideCount"), params.get("sortBy")); + assertEquals(Arrays.asList("desc"), params.get("sortDirection")); + assertEquals(2, result.getResults().get(0).getOverrideCount()); + } + + @Test + void listBundlesSendsSeveralCurrenciesAndSort() throws Exception { + respond(map("results", Collections.singletonList(map("id", "b-1", "itemCount", 2, + "billingTotals", map("monthly", 100, "one-time", 500))), "totalRecords", 1)); + + ListBundlesOptions options = new ListBundlesOptions(); + options.setCurrencies(Arrays.asList("USD", "EUR")); + options.setSortBy("itemCount"); + options.setSortDirection("asc"); + BundleListResponse result = quote.listBundles(options); + + Map> params = query(server.takeRequest()); + assertEquals(Arrays.asList("USD", "EUR"), params.get("currency")); + assertEquals(Arrays.asList("itemCount"), params.get("sortBy")); + assertEquals(2, result.getResults().get(0).getItemCount()); + assertEquals(500.0, result.getResults().get(0).getBillingTotals().get("one-time")); + } + + @Test + void listCompaniesSendsSort() throws Exception { + respond(map("results", Collections.singletonList(map("id", "c-1", "email", "ap@acme.test")), "totalRecords", 1)); + + ListCompaniesOptions options = new ListCompaniesOptions(); + options.setSortBy("contactCount"); + options.setSortDirection("desc"); + CompanyListResponse result = quote.listCompanies(options); + + Map> params = query(server.takeRequest()); + assertEquals(Arrays.asList("contactCount"), params.get("sortBy")); + assertEquals("ap@acme.test", result.getResults().get(0).getEmail()); + } + + @Test + void listContactsSendsSeveralCompaniesAndSort() throws Exception { + respond(emptyList()); + + ListContactsOptions options = new ListContactsOptions(); + options.setCompanyIds(Arrays.asList("c-1", "c-2")); + options.setSortBy("name"); + options.setSortDirection("asc"); + quote.listContacts(options); + + Map> params = query(server.takeRequest()); + assertEquals(Arrays.asList("c-1", "c-2"), params.get("companyIds")); + assertEquals(Arrays.asList("name"), params.get("sortBy")); + assertEquals(Arrays.asList("asc"), params.get("sortDirection")); + } + + @Test + void listCompanyContactsSendsSortAndReturnsContactCount() throws Exception { + respond(map("results", new ArrayList<>(), "totalRecords", 0, + "company", map("id", "c-1", "name", "Acme", "contactCount", 1))); + + ListCompanyContactsOptions options = new ListCompanyContactsOptions(); + options.setSortBy("createdOn"); + options.setSortDirection("desc"); + ContactListResponse result = quote.listCompanyContacts("c-1", options); + + RecordedRequest request = server.takeRequest(); + assertEquals("/v1/companies/c-1/contacts", request.getRequestUrl().encodedPath()); + assertEquals(Arrays.asList("createdOn"), query(request).get("sortBy")); + assertEquals(1, result.getCompany().getContactCount()); + } + + @Test + void listTypesSendsSort() throws Exception { + respond(emptyList()); + + ListTypesOptions options = new ListTypesOptions(); + options.setCategoryType(CategoryType.PRODUCT_CATEGORY); + options.setSortBy("usageCount"); + options.setSortDirection("desc"); + quote.listTypes(options); + + Map> params = query(server.takeRequest()); + assertEquals(Arrays.asList("usageCount"), params.get("sortBy")); + assertEquals(Arrays.asList("desc"), params.get("sortDirection")); + } + + // ============================================ + // QUOTE READS + // ============================================ + + @Test + void getQuoteSummary() throws Exception { + respond(map("summary", map("currency", "EUR", "openPipeline", map("total", 10, "count", 1)))); + + QuoteSummaryOptions options = new QuoteSummaryOptions(); + options.setCurrencies(Arrays.asList("EUR", "USD")); + options.setSummaryCurrency("EUR"); + QuoteSummary result = quote.getQuoteSummary(options); + + RecordedRequest request = server.takeRequest(); + assertEquals("/v1/quotes/summary", request.getRequestUrl().encodedPath()); + assertEquals(Arrays.asList("EUR", "USD"), query(request).get("currencies")); + assertEquals("EUR", result.getCurrency()); + assertEquals(1, result.getOpenPipeline().getCount()); + } + + @Test + void listQuoteCreators() throws Exception { + respond(map("results", Arrays.asList( + map("id", "u-1", "name", "Ana Lee", "email", "ana@acme.test", "isAPIKey", false, "apiKeyDeleted", false), + map("id", "u-2", "name", "Zapier key", "email", null, "isAPIKey", true, "apiKeyDeleted", true)))); + + List result = quote.listQuoteCreators(); + + assertTrue(server.takeRequest().getPath().endsWith("/v1/quotes/creators")); + assertEquals(2, result.size()); + assertEquals("Ana Lee", result.get(0).getName()); + assertNull(result.get(1).getEmail()); + assertTrue(result.get(1).getIsAPIKey()); + assertTrue(result.get(1).getApiKeyDeleted()); + } + + @Test + void getNextQuoteNumber() throws Exception { + respond(map("results", map("nextNumber", "Q-2026-0042", "sequence", 42, "canEdit", true))); + + NextQuoteNumber result = quote.getNextQuoteNumber(); + + assertTrue(server.takeRequest().getPath().endsWith("/v1/quotes/number-config/next")); + assertEquals("Q-2026-0042", result.getNextNumber()); + assertEquals(42, result.getSequence()); + assertTrue(result.getCanEdit()); + } + + // ============================================ + // OPEN QUOTES + // ============================================ + + @Test + void getCompanyOpenQuotes() throws Exception { + respond(map("results", Collections.singletonList(map("id", "q-1", "quoteNumber", "Q-1", "name", "Renewal", + "status", "sent", "updatedOn", "2026-10-01")), "totalRecords", 1)); + + OpenQuotesResponse result = quote.getCompanyOpenQuotes("c-1"); + + assertTrue(server.takeRequest().getPath().endsWith("/v1/companies/c-1/open-quotes")); + assertEquals(1, result.getTotalRecords()); + assertEquals("Q-1", result.getResults().get(0).getQuoteNumber()); + assertEquals("sent", result.getResults().get(0).getStatus()); + } + + @Test + void getContactOpenQuotes() throws Exception { + respond(emptyList()); + + OpenQuotesResponse result = quote.getContactOpenQuotes("ct-1"); + + assertTrue(server.takeRequest().getPath().endsWith("/v1/contacts/ct-1/open-quotes")); + assertEquals(0, result.getTotalRecords()); + } + + // ============================================ + // IMAGES + // ============================================ + + @Test + void getProductQuoteImages() throws Exception { + respond(map("results", map( + "p-1", Collections.singletonList(map("id", "i-1", "productId", "p-1", "displayOrder", 0)), + "p-2", new ArrayList<>()))); + + Map> result = quote.getProductQuoteImages(Arrays.asList("p-1", "p-2")); + + RecordedRequest request = server.takeRequest(); + assertTrue(request.getPath().endsWith("/v1/products/quote-images")); + JsonObject body = JsonParser.parseString(request.getBody().readUtf8()).getAsJsonObject(); + assertEquals(2, body.getAsJsonArray("productIds").size()); + assertEquals("i-1", result.get("p-1").get(0).getId()); + assertTrue(result.get("p-2").isEmpty()); + } + + @Test + void getQuoteLineItemImages() throws Exception { + respond(map("results", map("li-1", Collections.singletonList(map("id", "img-1", "lineItemId", "li-1", "displayOrder", 0))))); + + Map> result = quote.getQuoteLineItemImages("q-1"); + + assertTrue(server.takeRequest().getPath().endsWith("/v1/quotes/q-1/items/images")); + assertEquals("img-1", result.get("li-1").get(0).getId()); + } + + @Test + void getLineItemImages() throws Exception { + respond(map("results", Collections.singletonList(map("id", "img-1", "lineItemId", "li-1")))); + + List result = quote.getLineItemImages("q-1", "li-1"); + + assertTrue(server.takeRequest().getPath().endsWith("/v1/quotes/q-1/items/li-1/images")); + assertEquals("img-1", result.get(0).getId()); + } + + @Test + void addLineItemImagesUploadsImagesPartsWithDetectedTypes() throws Exception { + respond(map("results", Arrays.asList(map("id", "img-1"), map("id", "img-2")), "message", "Images added")); + + List result = quote.addLineItemImages("q-1", "li-1", new byte[][]{PNG, JPEG}); + + RecordedRequest request = server.takeRequest(); + assertEquals("POST", request.getMethod()); + assertTrue(request.getPath().endsWith("/v1/quotes/q-1/items/li-1/images")); + String body = request.getBody().readUtf8(); + assertTrue(body.contains("name=\"images\"; filename=\"image.png\"")); + assertTrue(body.contains("Content-Type: image/png")); + assertTrue(body.contains("Content-Type: image/jpeg")); + assertFalse(body.contains("name=\"data\"")); + assertEquals(2, result.size()); + } + + @Test + void reorderLineItemImages() throws Exception { + respond(map("results", Arrays.asList(map("id", "img-2"), map("id", "img-1")))); + + List result = quote.reorderLineItemImages("q-1", "li-1", Arrays.asList("img-2", "img-1")); + + RecordedRequest request = server.takeRequest(); + assertEquals("PUT", request.getMethod()); + assertTrue(request.getPath().endsWith("/v1/quotes/q-1/items/li-1/images/order")); + assertEquals("{\"imageIds\":[\"img-2\",\"img-1\"]}", request.getBody().readUtf8()); + assertEquals("img-2", result.get(0).getId()); + } + + @Test + void removeLineItemImage() throws Exception { + respond(map("results", Collections.singletonList(map("id", "img-2")), "message", "Image removed")); + + List result = quote.removeLineItemImage("q-1", "li-1", "img-1"); + + RecordedRequest request = server.takeRequest(); + assertEquals("DELETE", request.getMethod()); + assertTrue(request.getPath().endsWith("/v1/quotes/q-1/items/li-1/images/img-1")); + assertEquals("img-2", result.get(0).getId()); + } + + // ============================================ + // NAMED DUPLICATES + // ============================================ + + @Test + void duplicatesSendTheNameForTheCopy() throws Exception { + for (int i = 0; i < 4; i++) { + respond(map("result", map("id", "x-2", "name", "Q4 copy"))); + } + + assertEquals("x-2", quote.duplicateQuote("x-1", "Q4 copy").getId()); + assertEquals("x-2", quote.duplicateProduct("x-1", "Q4 copy").getId()); + assertEquals("x-2", quote.duplicatePriceBook("x-1", "Q4 copy").getId()); + assertEquals("x-2", quote.duplicateBundle("x-1", "Q4 copy").getId()); + + for (String path : Arrays.asList("/v1/quotes/x-1/duplicate", "/v1/products/x-1/duplicate", + "/v1/pricebooks/x-1/duplicate", "/v1/bundles/x-1/duplicate")) { + RecordedRequest request = server.takeRequest(); + assertTrue(request.getPath().endsWith(path)); + assertEquals("{\"name\":\"Q4 copy\"}", request.getBody().readUtf8()); + } + } + + @Test + void duplicateWithoutANameSendsAnEmptyBody() throws Exception { + respond(map("result", map("id", "x-2"))); + + quote.duplicateQuote("x-1"); + + assertEquals("{}", server.takeRequest().getBody().readUtf8()); + } + + // ============================================ + // REQUEST FIELDS + // ============================================ + + @Test + void createCompanySendsTheEmail() throws Exception { + respond(map("result", map("id", "c-1"))); + + CreateCompanyRequest request = new CreateCompanyRequest(); + request.setName("Acme"); + request.setEmail("ap@acme.test"); + CreateCompanyContactInput contact = new CreateCompanyContactInput(); + contact.setName("Ana"); + contact.setEmail("ana@acme.test"); + request.setContacts(Collections.singletonList(contact)); + quote.createCompany(request); + + JsonObject body = JsonParser.parseString(server.takeRequest().getBody().readUtf8()).getAsJsonObject(); + assertEquals("ap@acme.test", body.get("email").getAsString()); + } + + @Test + void updateCompanySendsAnExplicitNullEmail() throws Exception { + respond(map("result", map("id", "c-1"))); + + UpdateCompanyRequest request = new UpdateCompanyRequest(); + request.setEmail(null); + quote.updateCompany("c-1", request); + + assertEquals("{\"email\":null}", server.takeRequest().getBody().readUtf8()); + } + + @Test + void updatePriceBookSendsResetProductPricing() throws Exception { + respond(map("result", map("id", "pb-1"))); + + UpdatePriceBookRequest request = new UpdatePriceBookRequest(); + request.setResetProductPricing(true); + quote.updatePriceBook("pb-1", request); + + assertEquals("{\"resetProductPricing\":true}", server.takeRequest().getBody().readUtf8()); + } + + @Test + void deletingASentQuoteIsAConflict() { + String message = "A sent quote can't be deleted. Void it instead."; + server.enqueue(new MockResponse().setResponseCode(409).addHeader("Content-Type", "application/json") + .setBody(gson.toJson(map("message", message, + "error", map("message", message, "code", "CANNOT_DELETE_SENT_QUOTE"), "data", map())))); + + TurboDocxException.ConflictException error = assertThrows(TurboDocxException.ConflictException.class, + () -> quote.deleteQuote("q-1")); + assertEquals(409, error.getStatusCode()); + assertEquals("CANNOT_DELETE_SENT_QUOTE", error.getCode()); + assertEquals(message, error.getMessage()); + } +} diff --git a/packages/java-sdk/src/test/java/com/turbodocx/WebhookSignatureVerifierTest.java b/packages/java-sdk/src/test/java/com/turbodocx/WebhookSignatureVerifierTest.java index c5c09784..9f49b591 100644 --- a/packages/java-sdk/src/test/java/com/turbodocx/WebhookSignatureVerifierTest.java +++ b/packages/java-sdk/src/test/java/com/turbodocx/WebhookSignatureVerifierTest.java @@ -127,12 +127,10 @@ void rejectsLengthMismatchedSignature() throws Exception { @Test void stringBodyOverloadUsesDefaultTolerance() throws Exception { - String sig = sign(BODY, String.valueOf(System.currentTimeMillis() / 1000), SECRET); - // Pass current time so the default 300s window is within tolerance. - assertTrue(WebhookSignatureVerifier.verify( - BODY, - sig, - String.valueOf(System.currentTimeMillis() / 1000), - SECRET)); + // Read the clock once: signing and verifying with timestamps a second apart would not match. + String timestamp = String.valueOf(System.currentTimeMillis() / 1000); + String sig = sign(BODY, timestamp, SECRET); + // The current time is within the default 300s window. + assertTrue(WebhookSignatureVerifier.verify(BODY, sig, timestamp, SECRET)); } } diff --git a/packages/js-sdk/README.md b/packages/js-sdk/README.md index 3e9d7e0b..79cba541 100644 --- a/packages/js-sdk/README.md +++ b/packages/js-sdk/README.md @@ -828,17 +828,17 @@ TurboQuote.configure({ | Group | Methods | |---|---| | **Configuration** | `configure()` | -| **Quotes** | `createQuote()`, `getQuote()`, `listQuotes()`, `updateQuote()`, `deleteQuote()` | +| **Quotes** | `createQuote()`, `getQuote()`, `listQuotes()`, `updateQuote()`, `deleteQuote()`, `getQuoteSummary()`, `listQuoteCreators()`, `getNextQuoteNumber()` | | **Quote status transitions** | `sendQuote()`, `sendQuoteWithDeliverable()`, `declineQuote()`, `voidQuote()`, `handleExpiredQuote()`, `duplicateQuote()` | | **Quote downloads** | `downloadQuotePdf()` | -| **Line items** | `addLineItems()`, `addBundleLineItems()`, `listLineItems()`, `updateLineItem()`, `removeLineItem()` | -| **Products** | `createProduct()`, `getProduct()`, `listProducts()`, `updateProduct()`, `deleteProduct()`, `duplicateProduct()`, `getProductPrimaryImages()` | +| **Line items** | `addLineItems()`, `addBundleLineItems()`, `listLineItems()`, `updateLineItem()`, `removeLineItem()`, `getQuoteLineItemImages()`, `getLineItemImages()`, `addLineItemImages()`, `reorderLineItemImages()`, `removeLineItemImage()` | +| **Products** | `createProduct()`, `getProduct()`, `listProducts()`, `updateProduct()`, `deleteProduct()`, `duplicateProduct()`, `getProductPrimaryImages()`, `getProductQuoteImages()` | | **Bundles** | `createBundle()`, `getBundle()`, `listBundles()`, `updateBundle()`, `deleteBundle()`, `duplicateBundle()` | | **Price books** | `createPriceBook()`, `getPriceBook()`, `listPriceBooks()`, `updatePriceBook()`, `deletePriceBook()`, `duplicatePriceBook()`, `applyPriceBook()`, `removePriceBook()`, `listPriceBookProducts()` | | **Quote template** | `getTemplate()`, `getTemplateById()`, `listTemplates()`, `createTemplate()`, `updateTemplate()`, `deleteTemplate()` | | **Types / categories** | `createType()`, `listTypes()`, `updateType()`, `deleteType()` | -| **Companies** | `createCompany()`, `getCompany()`, `listCompanies()`, `updateCompany()`, `deleteCompany()` | -| **Contacts** | `createContact()`, `listCompanyContacts()`, `updateContact()`, `deleteContact()` | +| **Companies** | `createCompany()`, `getCompany()`, `listCompanies()`, `updateCompany()`, `deleteCompany()`, `getCompanyOpenQuotes()` | +| **Contacts** | `createContact()`, `listCompanyContacts()`, `updateContact()`, `deleteContact()`, `getContactOpenQuotes()` | #### Create a quote, add line items, and send @@ -906,6 +906,64 @@ const pdf = await TurboQuote.downloadQuotePdf(quote.id); // returns ArrayBuffer writeFileSync('quote.pdf', Buffer.from(pdf)); ``` +#### Sorting, filters and the list summary + +Every list takes `sortBy` / `sortDirection` (`asc` | `desc`); leave them out for the order the +list always had. Sort columns: quotes `updatedOn` · `createdOn` · `total` · `validUntil` · `quoteNumber`; +products `name` · `listPrice` · `createdOn`; price books `name` · `discountPercent` · `overrideCount` · +`validFrom` · `validTo` · `createdOn`; bundles `name` · `itemCount` · `totalListPrice` · `totalFinalPrice` · +`createdOn`; companies `name` · `contactCount` · `lastActivityDate`; contacts `name` · `createdOn`; +types `name` · `usageCount`. + +The quote list also filters by several companies (`companyIds`) and currencies (`currencies`), by +`expired` in `statuses` (a draft or sent quote past its Valid until day, UTC, or whose signature +expired), by creator (`createdBy`, ids from the creators call; `createdByApiKeys` for API-key quotes) +and by Valid until (`validUntilWindow` = `thisWeek` | `thisMonth` | `past`, `validUntilFrom` / +`validUntilTo`). Each quote carries `isExpired` and `lineItemCount`; the response adds +`statusCounts` (per tab) and `summary` (pipeline, win rate, monthly recurring, in `summaryCurrency`). + +```typescript +const page = await TurboQuote.listQuotes({ + statuses: ['sent', 'expired'], + currencies: ['USD', 'EUR'], + validUntilWindow: 'thisMonth', + sortBy: 'validUntil', + sortDirection: 'asc', + summaryCurrency: 'USD', +}); +console.log(page.statusCounts?.expired, page.summary?.winRate.percent); + +const creators = await TurboQuote.listQuoteCreators(); // ids for createdBy +const summary = await TurboQuote.getQuoteSummary({ createdBy: [creators[0].id] }); +const { nextNumber } = await TurboQuote.getNextQuoteNumber(); // read only + +const products = await TurboQuote.listProducts({ billingFrequency: ['monthly', 'annual'], sortBy: 'listPrice' }); +const copy = await TurboQuote.duplicateQuote(quote.id, { name: 'Q4 renewal' }); // name is optional +``` + +`duplicateQuote`, `duplicateProduct`, `duplicatePriceBook` and `duplicateBundle` take an optional +`{ name }` (otherwise "Copy of …"). Companies take an optional `email`; `updatePriceBook({ resetProductPricing: true })` +puts every product back on the book's discount. + +#### Custom line item images + +A custom line item (one with no catalog product) can show up to three images on the quote (PNG or +JPEG, 2 MB each; draft quotes only). Catalog products use `getProductQuoteImages(productIds)`. + +```typescript +const images = await TurboQuote.addLineItemImages(quote.id, itemId, ['./diagram.png']); // paths, Buffers or Files +await TurboQuote.reorderLineItemImages(quote.id, itemId, images.map(i => i.id).reverse()); +await TurboQuote.removeLineItemImage(quote.id, itemId, images[0].id); +const byItem = await TurboQuote.getQuoteLineItemImages(quote.id); // { [lineItemId]: LineItemImage[] } +``` + +**Deletes the API refuses.** `deleteQuote` on a **sent** quote is a 409 `ConflictError` with code +`CANNOT_DELETE_SENT_QUOTE` — void it instead; an accepted quote is a 409 `CANNOT_DELETE_ACCEPTED_QUOTE`. +`deleteCompany` is a 409 `CompanyHasOpenQuotes` while the company has open quotes (draft or +sent), and `deleteContact` is a 409 `LastContact` (the company's only contact) or `ContactHasOpenQuotes`. +`deletePriceBook` on the org's default price book is a 400 `ValidationError` `PriceBookIsDefault`. Branch on +`err.code`; `getCompanyOpenQuotes` / `getContactOpenQuotes` list the open quotes that block a delete. + #### Sender identity — "Prepared by" The quote's **"Prepared by"** name and email are resolved by the server, not by whoever diff --git a/packages/js-sdk/src/http.ts b/packages/js-sdk/src/http.ts index fd4a2b8c..1b123e66 100644 --- a/packages/js-sdk/src/http.ts +++ b/packages/js-sdk/src/http.ts @@ -475,6 +475,10 @@ export class HttpClient { return this.request('PATCH', path, data, options); } + async put(path: string, data?: any, options?: RequestInit): Promise { + return this.request('PUT', path, data, options); + } + async delete(path: string, options?: RequestInit): Promise { return this.request('DELETE', path, undefined, options); } diff --git a/packages/js-sdk/src/modules/quote.ts b/packages/js-sdk/src/modules/quote.ts index 0f895606..5d7fded5 100644 --- a/packages/js-sdk/src/modules/quote.ts +++ b/packages/js-sdk/src/modules/quote.ts @@ -27,6 +27,14 @@ import type { ApplyPriceBookResponse, QuoteNumberConfig, QuoteNumberFormat, + QuoteSummary, + QuoteSummaryOptions, + QuoteCreator, + NextQuoteNumber, + DuplicateOptions, + OpenQuotesResponse, + LineItemImage, + QuoteLineItemImagesResponse, } from '../types/quote'; import type { LineItem, @@ -43,6 +51,7 @@ import type { ListProductsOptions, ProductListResponse, ProductPrimaryImagesResponse, + ProductQuoteImagesResponse, } from '../types/product'; import type { PriceBook, @@ -72,6 +81,7 @@ import type { CreateContactRequest, UpdateContactRequest, ListContactsOptions, + ListCompanyContactsOptions, ContactListResponse, } from '../types/contact'; import type { @@ -107,6 +117,25 @@ function toQueryParams(request?: Record): Record 0 ? params : undefined; } +// Each image is an "images" part: a path is read, a Buffer's type comes from its magic bytes, +// a File goes as is. +function appendImages(formData: FormData, images: Array): void { + for (const image of images) { + if (typeof image === 'string') { + const fileBuffer = fs.readFileSync(image); + const detected = detectFileType(fileBuffer); + const blob = new Blob([toBlobPart(fileBuffer)], { type: detected.mimetype }); + formData.append('images', blob, nodePath.basename(image)); + } else if (image instanceof Buffer) { + const detected = detectFileType(image); + const blob = new Blob([toBlobPart(image)], { type: detected.mimetype }); + formData.append('images', blob, `image.${detected.extension}`); + } else { + formData.append('images', image as File, (image as File).name); + } + } +} + function buildProductFormData(request: Record): FormData { const formData = new FormData(); const { images, ...dataFields } = request; @@ -114,25 +143,14 @@ function buildProductFormData(request: Record): FormData { formData.append('data', JSON.stringify(dataFields)); if (images) { - for (const image of images as Array) { - if (typeof image === 'string') { - const fileBuffer = fs.readFileSync(image); - const detected = detectFileType(fileBuffer); - const blob = new Blob([toBlobPart(fileBuffer)], { type: detected.mimetype }); - formData.append('images', blob, nodePath.basename(image)); - } else if (image instanceof Buffer) { - const detected = detectFileType(image); - const blob = new Blob([toBlobPart(image)], { type: detected.mimetype }); - formData.append('images', blob, `image.${detected.extension}`); - } else { - formData.append('images', image as File, (image as File).name); - } - } + appendImages(formData, images as Array); } return formData; } + + export class TurboQuote { private static client: HttpClient; @@ -156,6 +174,17 @@ export class TurboQuote { return response.result; } + // Shared POST for the four duplicate endpoints: the copy's name when given, else no body (the + // API names the copy "Copy of …"). + private static async postDuplicate(path: string, options?: DuplicateOptions): Promise { + const client = this.getClient(); + const response = + options?.name !== undefined + ? await client.post<{ result: T }>(path, { name: options.name }) + : await client.post<{ result: T }>(path); + return response.result; + } + // Shared POST for the six bulk-create endpoints. Body is { rows }; response is // { results: BulkImportResult } — partial success (a failed row does not roll // back the rest), max 500 rows per request, admin/contributor only. @@ -181,6 +210,16 @@ export class TurboQuote { return response.results; } + /** + * The number the next new quote will get. Read only — the counter doesn't move, so a quote + * created by someone else in between takes it first. Any role that can create quotes. + */ + static async getNextQuoteNumber(): Promise { + const client = this.getClient(); + const response = await client.get<{ results: NextQuoteNumber }>('/v1/quotes/number-config/next'); + return response.results; + } + // ============================================ // QUOTES — CRUD // ============================================ @@ -190,6 +229,23 @@ export class TurboQuote { return client.get('/v1/quotes', toQueryParams(options)); } + /** + * The quote list's summary cards (pipeline, win rate, monthly recurring…) for a set of + * filters, in one currency (`summaryCurrency`). The status filter is ignored. + */ + static async getQuoteSummary(options?: QuoteSummaryOptions): Promise { + const client = this.getClient(); + const response = await client.get<{ summary: QuoteSummary }>('/v1/quotes/summary', toQueryParams(options)); + return response.summary; + } + + /** The people and API keys who created the org's quotes — the ids for the `createdBy` filter. */ + static async listQuoteCreators(): Promise { + const client = this.getClient(); + const response = await client.get<{ results: QuoteCreator[] }>('/v1/quotes/creators'); + return response.results; + } + static async createQuote(request: CreateQuoteRequest): Promise { const client = this.getClient(); return this.unwrap(await client.post<{ result: Quote }>('/v1/quotes', request)); @@ -220,14 +276,17 @@ export class TurboQuote { return this.unwrap(await client.patch<{ result: Quote }>(`/v1/quotes/${id}`, request)); } + /** + * Delete a quote. A sent quote is refused with a 409 (`CANNOT_DELETE_SENT_QUOTE`) — void it + * instead — and so is an accepted one (`CANNOT_DELETE_ACCEPTED_QUOTE`). + */ static async deleteQuote(id: string): Promise { const client = this.getClient(); return client.delete(`/v1/quotes/${id}`); } - static async duplicateQuote(id: string): Promise { - const client = this.getClient(); - return this.unwrap(await client.post<{ result: Quote }>(`/v1/quotes/${id}/duplicate`)); + static async duplicateQuote(id: string, options?: DuplicateOptions): Promise { + return this.postDuplicate(`/v1/quotes/${id}/duplicate`, options); } static async applyPriceBook(quoteId: string, priceBookId: string): Promise { @@ -322,6 +381,61 @@ export class TurboQuote { return client.delete(`/v1/quotes/${quoteId}/items/${itemId}`); } + // ============================================ + // CUSTOM LINE ITEM IMAGES + // Up to three per custom item (a line with no catalog product), PNG or JPEG, 2MB each. + // Changes are allowed on draft quotes only. + // ============================================ + + /** Every custom line item's images (up to three, in order), keyed by line item id. */ + static async getQuoteLineItemImages(quoteId: string): Promise { + const client = this.getClient(); + const response = await client.get<{ results: QuoteLineItemImagesResponse }>(`/v1/quotes/${quoteId}/items/images`); + return response.results; + } + + /** One custom line item's images, in order. */ + static async getLineItemImages(quoteId: string, itemId: string): Promise { + const client = this.getClient(); + const response = await client.get<{ results: LineItemImage[] }>(`/v1/quotes/${quoteId}/items/${itemId}/images`); + return response.results; + } + + /** Add images to a custom line item (file paths, Buffers or Files). Returns all of its images. */ + static async addLineItemImages( + quoteId: string, + itemId: string, + images: Array + ): Promise { + const client = this.getClient(); + const formData = new FormData(); + appendImages(formData, images); + const response = await client.postFormData<{ results: LineItemImage[] }>( + `/v1/quotes/${quoteId}/items/${itemId}/images`, + formData + ); + return response.results; + } + + /** Set the order of a custom line item's images, first to last. */ + static async reorderLineItemImages(quoteId: string, itemId: string, imageIds: string[]): Promise { + const client = this.getClient(); + const response = await client.put<{ results: LineItemImage[] }>( + `/v1/quotes/${quoteId}/items/${itemId}/images/order`, + { imageIds } + ); + return response.results; + } + + /** Remove one image from a custom line item. Returns the images left. */ + static async removeLineItemImage(quoteId: string, itemId: string, imageId: string): Promise { + const client = this.getClient(); + const response = await client.delete<{ results: LineItemImage[] }>( + `/v1/quotes/${quoteId}/items/${itemId}/images/${imageId}` + ); + return response.results; + } + // ============================================ // PRODUCTS // ============================================ @@ -363,9 +477,8 @@ export class TurboQuote { return client.delete(`/v1/products/${id}`); } - static async duplicateProduct(id: string): Promise { - const client = this.getClient(); - return this.unwrap(await client.post<{ result: Product }>(`/v1/products/${id}/duplicate`)); + static async duplicateProduct(id: string, options?: DuplicateOptions): Promise { + return this.postDuplicate(`/v1/products/${id}/duplicate`, options); } static async getProductPrimaryImages(productIds: string[]): Promise { @@ -374,6 +487,13 @@ export class TurboQuote { return response.results; } + /** The images each product shows on a quote: up to three, in display order (1–100 product ids). */ + static async getProductQuoteImages(productIds: string[]): Promise { + const client = this.getClient(); + const response = await client.post<{ results: ProductQuoteImagesResponse }>('/v1/products/quote-images', { productIds }); + return response.results; + } + // ============================================ // PRICE BOOKS // ============================================ @@ -406,14 +526,14 @@ export class TurboQuote { return this.unwrap(await client.patch<{ result: PriceBook }>(`/v1/pricebooks/${id}`, request)); } + /** Delete a price book. The org's default price book is refused (400 `PriceBookIsDefault`). */ static async deletePriceBook(id: string): Promise { const client = this.getClient(); return client.delete(`/v1/pricebooks/${id}`); } - static async duplicatePriceBook(id: string): Promise { - const client = this.getClient(); - return this.unwrap(await client.post<{ result: PriceBook }>(`/v1/pricebooks/${id}/duplicate`)); + static async duplicatePriceBook(id: string, options?: DuplicateOptions): Promise { + return this.postDuplicate(`/v1/pricebooks/${id}/duplicate`, options); } static async listPriceBookProducts(id: string, options?: ListPriceBookProductsOptions): Promise { @@ -454,9 +574,8 @@ export class TurboQuote { return client.delete(`/v1/bundles/${id}`); } - static async duplicateBundle(id: string): Promise { - const client = this.getClient(); - return this.unwrap(await client.post<{ result: Bundle }>(`/v1/bundles/${id}/duplicate`)); + static async duplicateBundle(id: string, options?: DuplicateOptions): Promise { + return this.postDuplicate(`/v1/bundles/${id}/duplicate`, options); } // ============================================ @@ -487,12 +606,22 @@ export class TurboQuote { return this.unwrap(await client.patch<{ result: Company }>(`/v1/companies/${id}`, request)); } + /** + * Delete a company and its contacts. Refused with a 409 (`CompanyHasOpenQuotes`) while it has + * open quotes (draft or sent) — see `getCompanyOpenQuotes`. + */ static async deleteCompany(id: string): Promise { const client = this.getClient(); return client.delete(`/v1/companies/${id}`); } - static async listCompanyContacts(companyId: string, options?: PaginationParams): Promise { + /** The open quotes (draft or sent) for a company: what blocks deleting it. */ + static async getCompanyOpenQuotes(companyId: string): Promise { + const client = this.getClient(); + return client.get(`/v1/companies/${companyId}/open-quotes`); + } + + static async listCompanyContacts(companyId: string, options?: ListCompanyContactsOptions): Promise { const client = this.getClient(); return client.get(`/v1/companies/${companyId}/contacts`, toQueryParams(options)); } @@ -520,11 +649,21 @@ export class TurboQuote { return this.unwrap(await client.patch<{ result: Contact }>(`/v1/contacts/${id}`, request)); } + /** + * Delete a contact. Refused with a 409 when they are the company's only contact (`LastContact`) + * or the contact on open quotes (`ContactHasOpenQuotes`) — see `getContactOpenQuotes`. + */ static async deleteContact(id: string): Promise { const client = this.getClient(); return client.delete(`/v1/contacts/${id}`); } + /** The open quotes (draft or sent) made out to a contact: what blocks deleting them. */ + static async getContactOpenQuotes(contactId: string): Promise { + const client = this.getClient(); + return client.get(`/v1/contacts/${contactId}/open-quotes`); + } + // ============================================ // TEMPLATES // ============================================ diff --git a/packages/js-sdk/src/types/bundle.ts b/packages/js-sdk/src/types/bundle.ts index 9955d4d9..0a2db8e7 100644 --- a/packages/js-sdk/src/types/bundle.ts +++ b/packages/js-sdk/src/types/bundle.ts @@ -2,7 +2,15 @@ * TypeScript types for TurboQuote — Bundle entity and request/response types */ -import type { BillingFrequency, Currency, DiscountType, PaginationParams, PaginatedResponse } from './quote-shared'; +import type { + BillingFrequency, + Currency, + DiscountType, + OneOrMany, + PaginationParams, + PaginatedResponse, + SortDirection, +} from './quote-shared'; import type { Product } from './product'; // ============================================ @@ -55,6 +63,13 @@ export interface Bundle { updatedOn: string; items?: BundleItem[]; category?: { id: string; name: string; categoryType: string }; + /** listBundles only: the bundle's active items. */ + itemCount?: number; + /** + * listBundles only: the bundle price per billing frequency, with the bundle discount shared in + * proportion. A bundle mixing billing frequencies has one entry per frequency. + */ + billingTotals?: Partial>; } // ============================================ @@ -103,10 +118,16 @@ export interface UpdateBundleRequest { syncWithProducts?: boolean; } +/** `name` | `itemCount` | `totalListPrice` | `totalFinalPrice` | `createdOn` */ +export type BundleSortBy = 'name' | 'itemCount' | 'totalListPrice' | 'totalFinalPrice' | 'createdOn'; + export interface ListBundlesOptions extends PaginationParams { categoryIds?: string | string[]; - currency?: Currency; + currency?: OneOrMany; showInCatalog?: boolean; + /** Default `createdOn` newest first. */ + sortBy?: BundleSortBy; + sortDirection?: SortDirection; } // ============================================ diff --git a/packages/js-sdk/src/types/company.ts b/packages/js-sdk/src/types/company.ts index 625d6191..d477dd31 100644 --- a/packages/js-sdk/src/types/company.ts +++ b/packages/js-sdk/src/types/company.ts @@ -2,7 +2,7 @@ * TypeScript types for TurboQuote — Company entity and request/response types */ -import type { PaginationParams, PaginatedResponse } from './quote-shared'; +import type { PaginationParams, PaginatedResponse, SortDirection } from './quote-shared'; import type { QuoteType } from './quote-type'; // ============================================ @@ -13,6 +13,8 @@ export interface Company { id: string; orgId: string; name: string; + /** The company's own email. Null for companies created without one. */ + email: string | null; phone: string | null; city: string | null; state: string | null; @@ -41,6 +43,8 @@ export interface CreateCompanyContactInput { export interface CreateCompanyRequest { name: string; contacts: CreateCompanyContactInput[]; + /** The company's own email (optional through the API; the TurboDocx app requires it). */ + email?: string | null; phone?: string | null; city?: string | null; state?: string | null; @@ -50,6 +54,7 @@ export interface CreateCompanyRequest { export interface UpdateCompanyRequest { name?: string; + email?: string | null; phone?: string | null; city?: string | null; state?: string | null; @@ -57,8 +62,14 @@ export interface UpdateCompanyRequest { industryId?: string | null; } +/** `name` | `contactCount` | `lastActivityDate` */ +export type CompanySortBy = 'name' | 'contactCount' | 'lastActivityDate'; + export interface ListCompaniesOptions extends PaginationParams { industryIds?: string | string[]; + /** Default `lastActivityDate` most recent first. */ + sortBy?: CompanySortBy; + sortDirection?: SortDirection; } // ============================================ diff --git a/packages/js-sdk/src/types/contact.ts b/packages/js-sdk/src/types/contact.ts index 24a62a14..f2b3ad3e 100644 --- a/packages/js-sdk/src/types/contact.ts +++ b/packages/js-sdk/src/types/contact.ts @@ -2,7 +2,7 @@ * TypeScript types for TurboQuote — Contact entity and request/response types */ -import type { PaginationParams, PaginatedResponse } from './quote-shared'; +import type { OneOrMany, PaginationParams, PaginatedResponse, SortDirection } from './quote-shared'; import type { Company } from './company'; // ============================================ @@ -43,12 +43,30 @@ export interface UpdateContactRequest { title?: string | null; } -export interface ListContactsOptions extends PaginationParams { +/** `name` | `createdOn` */ +export type ContactSortBy = 'name' | 'createdOn'; + +/** Options for `listCompanyContacts`. */ +export interface ListCompanyContactsOptions extends PaginationParams { + /** Default `createdOn` newest first. */ + sortBy?: ContactSortBy; + sortDirection?: SortDirection; +} + +export interface ListContactsOptions extends ListCompanyContactsOptions { companyId?: string; + /** Several companies. */ + companyIds?: OneOrMany; } // ============================================ // RESPONSE TYPES // ============================================ -export type ContactListResponse = PaginatedResponse; +export interface ContactListResponse extends PaginatedResponse { + /** + * The company, when the list is for one company (`listCompanyContacts`, or `companyId`). + * `contactCount` is its active contacts whatever the search — a company can't lose its last one. + */ + company?: Company & { contactCount?: number }; +} diff --git a/packages/js-sdk/src/types/pricebook.ts b/packages/js-sdk/src/types/pricebook.ts index e6783c1c..22bd02bc 100644 --- a/packages/js-sdk/src/types/pricebook.ts +++ b/packages/js-sdk/src/types/pricebook.ts @@ -2,7 +2,7 @@ * TypeScript types for TurboQuote — PriceBook entity and request/response types */ -import type { DiscountType, PaginationParams, PaginatedResponse } from './quote-shared'; +import type { DiscountType, PaginationParams, PaginatedResponse, SortDirection } from './quote-shared'; import type { Product } from './product'; // ============================================ @@ -41,6 +41,11 @@ export interface PriceBook { productPricing?: PriceBookProductPricing[]; priceBookType?: { id: string; name: string; categoryType: string }; productCount?: number; + /** + * listPriceBooks only: products with their own price in this book (an amount discount, or a + * percent different from the book's discount). + */ + overrideCount?: number; } // ============================================ @@ -77,11 +82,22 @@ export interface UpdatePriceBookRequest { isDefault?: boolean; showInQuoteBuilder?: boolean; productPricing?: PriceBookProductPricingInput[]; + /** + * Put every product of the book back on the book's own discount. Applied before any + * `productPricing` in the same request. + */ + resetProductPricing?: boolean; } +/** `name` | `discountPercent` | `overrideCount` | `validFrom` | `validTo` | `createdOn` */ +export type PriceBookSortBy = 'name' | 'discountPercent' | 'overrideCount' | 'validFrom' | 'validTo' | 'createdOn'; + export interface ListPriceBooksOptions extends PaginationParams { priceBookTypeIds?: string | string[]; showInQuoteBuilder?: boolean; + /** Default `createdOn` newest first. */ + sortBy?: PriceBookSortBy; + sortDirection?: SortDirection; } export interface ListPriceBookProductsOptions extends PaginationParams { diff --git a/packages/js-sdk/src/types/product.ts b/packages/js-sdk/src/types/product.ts index e0cf2322..4c5d6b10 100644 --- a/packages/js-sdk/src/types/product.ts +++ b/packages/js-sdk/src/types/product.ts @@ -2,7 +2,7 @@ * TypeScript types for TurboQuote — Product entity and request/response types */ -import type { BillingFrequency, Currency, PaginationParams, PaginatedResponse } from './quote-shared'; +import type { BillingFrequency, Currency, OneOrMany, PaginationParams, PaginatedResponse, SortDirection } from './quote-shared'; // ============================================ // DOMAIN TYPES @@ -79,11 +79,17 @@ export interface UpdateProductRequest { imageOrder?: string[]; } +/** `name` | `listPrice` | `createdOn` */ +export type ProductSortBy = 'name' | 'listPrice' | 'createdOn'; + export interface ListProductsOptions extends PaginationParams { categoryIds?: string | string[]; - billingFrequency?: BillingFrequency; - currency?: Currency; + billingFrequency?: OneOrMany; + currency?: OneOrMany; showInCatalog?: boolean; + /** Default `createdOn` newest first. */ + sortBy?: ProductSortBy; + sortDirection?: SortDirection; } // ============================================ @@ -100,3 +106,8 @@ export interface ProductListResponse extends PaginatedResponse { export interface ProductPrimaryImagesResponse { [productId: string]: ProductImage | null; } + +/** The images each product shows on a quote: up to three, in display order (empty when none). */ +export interface ProductQuoteImagesResponse { + [productId: string]: ProductImage[]; +} diff --git a/packages/js-sdk/src/types/quote-shared.ts b/packages/js-sdk/src/types/quote-shared.ts index 9ca1a742..d2e550d1 100644 --- a/packages/js-sdk/src/types/quote-shared.ts +++ b/packages/js-sdk/src/types/quote-shared.ts @@ -2,7 +2,7 @@ * Shared types for the TurboQuote module */ -export type QuoteStatus = 'draft' | 'pending_approval' | 'sent' | 'accepted' | 'declined' | 'voided'; +export type QuoteStatus = 'draft' | 'sent' | 'accepted' | 'declined' | 'voided'; export type BillingFrequency = 'monthly' | 'quarterly' | 'annual' | 'one-time'; @@ -16,6 +16,14 @@ export type CategoryType = 'product_category' | 'pricebook_type' | 'company_indu export type DiscountType = 'percent' | 'amount'; +/** Direction for every list's `sortBy`. */ +export type SortDirection = 'asc' | 'desc'; + +/** + * One value, or several (sent as a repeated query param: `?currency=USD¤cy=EUR`). + */ +export type OneOrMany = T | T[]; + export interface PaginationParams { limit?: number; offset?: number; diff --git a/packages/js-sdk/src/types/quote-type.ts b/packages/js-sdk/src/types/quote-type.ts index 63100318..160c43e1 100644 --- a/packages/js-sdk/src/types/quote-type.ts +++ b/packages/js-sdk/src/types/quote-type.ts @@ -2,7 +2,7 @@ * TypeScript types for TurboQuote — Type/Category entity and request/response types */ -import type { CategoryType, PaginationParams, PaginatedResponse } from './quote-shared'; +import type { CategoryType, PaginationParams, PaginatedResponse, SortDirection } from './quote-shared'; // ============================================ // DOMAIN TYPES @@ -38,9 +38,15 @@ export interface UpdateQuoteTypeRequest { name?: string; } +/** `name` | `usageCount` (usage needs a `categoryType`; without one it sorts by name) */ +export type QuoteTypeSortBy = 'name' | 'usageCount'; + export interface ListTypesOptions extends PaginationParams { categoryType?: CategoryType; includeUsage?: boolean; + /** Default `name` A to Z. */ + sortBy?: QuoteTypeSortBy; + sortDirection?: SortDirection; } // ============================================ diff --git a/packages/js-sdk/src/types/quote.ts b/packages/js-sdk/src/types/quote.ts index 9f7eaaba..14f290a2 100644 --- a/packages/js-sdk/src/types/quote.ts +++ b/packages/js-sdk/src/types/quote.ts @@ -9,6 +9,8 @@ import type { Currency, PaginationParams, PaginatedResponse, + SortDirection, + OneOrMany, } from './quote-shared'; import type { AddLineItemRequest, AddBundleLineItemRequest, LineItem } from './quote-line-item'; import type { Company } from './company'; @@ -75,10 +77,19 @@ export interface Quote { contact?: Contact; lineItems?: LineItem[]; priceBook?: PriceBook; - creator?: { id: string; firstName: string; lastName: string }; + creator?: { id: string; firstName: string; lastName: string; email?: string | null; isAPIKey?: boolean }; statusInfo?: QuoteStatusInfo; // Folded on by getQuote from the response's sibling `preparedBy`. See QuotePreparedBy. preparedBy?: QuotePreparedBy; + /** listQuotes only: how many line items the quote has. */ + lineItemCount?: number; + /** listQuotes only: the quote's signature request has expired (expiration was on and its deadline passed). */ + isSignatureExpired?: boolean; + /** + * listQuotes only: a draft or sent quote whose Valid until day has passed (UTC), or whose + * signature has expired. The same rule as the `expired` status filter. + */ + isExpired?: boolean; } // ============================================ @@ -113,13 +124,55 @@ export interface UpdateQuoteRequest { priceBookId?: string | null; } -export interface ListQuotesOptions extends PaginationParams { - statuses?: QuoteStatus | QuoteStatus[]; +/** + * A quote list status filter: a stored status, or `expired` — a draft or sent quote whose Valid + * until day has passed (UTC) or whose signature has expired. `expired` is not a stored status. + */ +export type QuoteListStatusFilter = QuoteStatus | 'expired'; + +/** `updatedOn` | `createdOn` | `total` (grand total) | `validUntil` | `quoteNumber` */ +export type QuoteSortBy = 'updatedOn' | 'createdOn' | 'total' | 'validUntil' | 'quoteNumber'; + +/** + * Valid until windows, in UTC days: `thisWeek` = today through today + 6, `thisMonth` = the + * current calendar month, `past` = before today. + */ +export type QuoteValidUntilWindow = 'thisWeek' | 'thisMonth' | 'past'; + +/** The filters shared by `listQuotes` and `getQuoteSummary`. */ +export interface QuoteListFilters { + /** Matches quote number, quote name, company name, contact name and contact email. */ + query?: string; + statuses?: OneOrMany; + /** One company. Merged with `companyIds`. */ companyId?: string; + companyIds?: OneOrMany; contactId?: string; - currency?: Currency; + /** One currency or several. Merged with `currencies`. */ + currency?: OneOrMany; + currencies?: OneOrMany; + /** Ids of the users (or API keys) who created the quotes — see `listQuoteCreators`. */ + createdBy?: OneOrMany; + /** Quotes created by any API key. ORed with `createdBy`. */ + createdByApiKeys?: boolean; + validUntilWindow?: QuoteValidUntilWindow; + /** Inclusive YYYY-MM-DD bounds on Valid until, combined with the window. */ + validUntilFrom?: string; + validUntilTo?: string; + /** Currency of the returned `summary` (default: the one filtered currency, else USD). */ + summaryCurrency?: Currency; +} + +export interface ListQuotesOptions extends QuoteListFilters { + limit?: number; + offset?: number; + /** Default `createdOn` newest first. */ + sortBy?: QuoteSortBy; + sortDirection?: SortDirection; } +export type QuoteSummaryOptions = QuoteListFilters; + export interface SendQuoteRequest { ccEmails?: string[]; validUntil?: string; @@ -228,8 +281,104 @@ export interface QuoteListStats { quotesThisMonth: number; } +/** + * Quotes per list tab. Every filter applies except `statuses`, so each count is what that tab + * would hold. `expired` overlaps `draft` and `sent` (an expired quote keeps its stored status). + */ +export interface QuoteStatusCounts { + all: number; + draft: number; + sent: number; + accepted: number; + declined: number; + voided: number; + expired: number; +} + +/** + * The quote list's summary cards, in one currency. Search, company, creator and Valid until + * filters apply; the status filter and the currency list don't. + */ +export interface QuoteSummary { + currency: Currency; + /** Draft and sent quotes that aren't expired. */ + openPipeline: { total: number; count: number }; + /** Every accepted and every declined quote. */ + completed: { accepted: number; declined: number }; + /** Quotes created since the 1st of this month (UTC). */ + createdThisMonth: { count: number }; + /** Sent quotes that aren't expired. */ + waitingToSign: { count: number }; + /** Quotes accepted since the 1st of this month (UTC). */ + wonThisMonth: { count: number }; + /** Every currency the org's quotes are in, A to Z. */ + currencies: Currency[]; + /** accepted / (accepted + declined) over the last `windowDays`; `percent` is null when none were decided. */ + winRate: { percent: number | null; accepted: number; declined: number; windowDays: number }; + /** Monthly subtotal of accepted quotes; `count` = accepted quotes with a monthly amount. */ + monthlyRecurring: { total: number; count: number }; +} + export interface QuoteListResponse extends PaginatedResponse { stats: QuoteListStats; + statusCounts?: QuoteStatusCounts; + summary?: QuoteSummary; +} + +/** Someone who created quotes in the org: a person, or an API key. */ +export interface QuoteCreator { + /** The user id — the value for the `createdBy` filter. */ + id: string; + /** A person's name (else email), or the API key's name. */ + name: string; + /** Null for an API key. */ + email: string | null; + isAPIKey: boolean; + /** An API key that has since been deleted (its quotes stay). */ + apiKeyDeleted: boolean; +} + +/** The number the next new quote will get. Read only: the counter doesn't move. */ +export interface NextQuoteNumber { + nextNumber: string; + sequence: number; + /** Whether the caller can change the numbering (administrators). */ + canEdit: boolean; +} + +/** Optional settings for a duplicate. */ +export interface DuplicateOptions { + /** The copy's name. Left out (or blank), the copy is called "Copy of ". */ + name?: string; +} + +/** An open quote (draft or sent) for a company or contact. */ +export interface OpenQuoteSummary { + id: string; + quoteNumber: string; + name: string | null; + status: QuoteStatus; + updatedOn: string; +} + +/** The open quotes that block deleting a company or contact: the newest 50, and how many in all. */ +export type OpenQuotesResponse = PaginatedResponse; + +/** An image on a custom line item (a line with no catalog product). */ +export interface LineItemImage { + id: string; + lineItemId: string; + fileId: string; + displayOrder: number; + fileName: string; + fileType: string; + /** Base64 data URL: "data:image/png;base64,..." */ + imageData?: string; +} + +/** Each custom line item's images (up to three, in order), keyed by line item id. */ +export interface QuoteLineItemImagesResponse { + [lineItemId: string]: LineItemImage[]; } diff --git a/packages/js-sdk/tests/http-put.test.ts b/packages/js-sdk/tests/http-put.test.ts new file mode 100644 index 00000000..8e4b8adc --- /dev/null +++ b/packages/js-sdk/tests/http-put.test.ts @@ -0,0 +1,62 @@ +/** + * HttpClient.put — a JSON PUT (used by reorderLineItemImages), unwrapped like every other call, + * and the 409 a refused quote delete comes back as. + */ + +import { HttpClient } from '../src/http'; +import { ConflictError } from '../src/utils/errors'; + +const ORIGINAL_FETCH = global.fetch; + +function makeClient(): HttpClient { + return new HttpClient({ apiKey: 'TDX-test-key', orgId: 'org-test', skipSenderValidation: true }); +} + +describe('HttpClient.put', () => { + afterEach(() => { + global.fetch = ORIGINAL_FETCH; + }); + + it('sends a JSON PUT and unwraps { data }', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: { results: [{ id: 'img-2' }], message: 'Image order saved' } }), + } as unknown as Response); + global.fetch = fetchMock; + + const result = await makeClient().put<{ results: Array<{ id: string }> }>( + '/v1/quotes/q-1/items/li-1/images/order', + { imageIds: ['img-2'] } + ); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toMatch(/\/v1\/quotes\/q-1\/items\/li-1\/images\/order$/); + expect(init.method).toBe('PUT'); + expect(JSON.parse(init.body)).toEqual({ imageIds: ['img-2'] }); + expect(result.results).toEqual([{ id: 'img-2' }]); + }); + + it('maps a refused delete (409 with a top-level message) to ConflictError with its code', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 409, + statusText: 'Conflict', + json: async () => ({ + message: "A sent quote can't be deleted. Void it instead.", + error: { message: "A sent quote can't be deleted. Void it instead.", code: 'CANNOT_DELETE_SENT_QUOTE' }, + data: {}, + }), + } as unknown as Response); + + const promise = makeClient().delete('/v1/quotes/q-1'); + + await expect(promise).rejects.toBeInstanceOf(ConflictError); + await expect(promise).rejects.toMatchObject({ + statusCode: 409, + code: 'CANNOT_DELETE_SENT_QUOTE', + message: "A sent quote can't be deleted. Void it instead.", + }); + }); +}); diff --git a/packages/js-sdk/tests/turboquote-lists-and-images.test.ts b/packages/js-sdk/tests/turboquote-lists-and-images.test.ts new file mode 100644 index 00000000..6742bfde --- /dev/null +++ b/packages/js-sdk/tests/turboquote-lists-and-images.test.ts @@ -0,0 +1,424 @@ +/** + * TurboQuote: list sorting and filters, the quote summary / creators / next number reads, + * open quotes, product quote images, custom line item images, named duplicates, company + * email and price book reset. + */ + +import { TurboQuote } from "../src/modules/quote"; +import { HttpClient } from "../src/http"; + +jest.mock("../src/http", () => { + const actual = jest.requireActual("../src/http"); + return { + ...actual, + HttpClient: jest.fn(), + }; +}); + +const MockedHttpClient = HttpClient as jest.MockedClass; + +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]); +const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]); + +describe("TurboQuote lists, reads and images", () => { + let mockClient: { + get: jest.Mock; + post: jest.Mock; + put: jest.Mock; + patch: jest.Mock; + delete: jest.Mock; + postFormData: jest.Mock; + }; + + beforeEach(() => { + jest.clearAllMocks(); + (TurboQuote as any).client = undefined; + mockClient = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + postFormData: jest.fn(), + }; + MockedHttpClient.mockImplementation(() => mockClient as any); + TurboQuote.configure({ apiKey: "test-key", orgId: "org-1" }); + }); + + // ============================================ + // SORTING AND FILTERS + // ============================================ + + describe("listQuotes", () => { + it("sends the sort, the new filters and the summary currency as query params", async () => { + mockClient.get.mockResolvedValue({ results: [], totalRecords: 0 }); + + await TurboQuote.listQuotes({ + sortBy: "total", + sortDirection: "asc", + statuses: ["sent", "expired"], + companyIds: ["c-1", "c-2"], + currencies: ["USD", "EUR"], + createdBy: ["u-1"], + createdByApiKeys: true, + validUntilWindow: "thisMonth", + validUntilFrom: "2026-10-01", + validUntilTo: "2026-10-31", + summaryCurrency: "EUR", + }); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/quotes", { + sortBy: "total", + sortDirection: "asc", + statuses: ["sent", "expired"], + companyIds: ["c-1", "c-2"], + currencies: ["USD", "EUR"], + createdBy: ["u-1"], + createdByApiKeys: "true", + validUntilWindow: "thisMonth", + validUntilFrom: "2026-10-01", + validUntilTo: "2026-10-31", + summaryCurrency: "EUR", + }); + }); + + it("still takes one currency through the original currency param", async () => { + mockClient.get.mockResolvedValue({ results: [], totalRecords: 0 }); + + await TurboQuote.listQuotes({ currency: "GBP", companyId: "c-1" }); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/quotes", { currency: "GBP", companyId: "c-1" }); + }); + + it("returns the tab counts, the summary and each quote's expiry flag", async () => { + const response = { + results: [{ id: "q-1", status: "sent", isExpired: true, isSignatureExpired: false, lineItemCount: 3 }], + totalRecords: 1, + stats: { total: 1 }, + statusCounts: { all: 1, draft: 0, sent: 1, accepted: 0, declined: 0, voided: 0, expired: 1 }, + summary: { + currency: "USD", + openPipeline: { total: 0, count: 0 }, + completed: { accepted: 0, declined: 0 }, + createdThisMonth: { count: 1 }, + waitingToSign: { count: 0 }, + wonThisMonth: { count: 0 }, + currencies: ["USD"], + winRate: { percent: null, accepted: 0, declined: 0, windowDays: 90 }, + monthlyRecurring: { total: 0, count: 0 }, + }, + }; + mockClient.get.mockResolvedValue(response); + + const result = await TurboQuote.listQuotes(); + + expect(result.statusCounts?.expired).toBe(1); + expect(result.summary?.winRate.percent).toBeNull(); + expect(result.results[0].isExpired).toBe(true); + expect(result.results[0].lineItemCount).toBe(3); + }); + }); + + it("listProducts sends several billing frequencies and currencies, and the sort", async () => { + mockClient.get.mockResolvedValue({ results: [], totalRecords: 0 }); + + await TurboQuote.listProducts({ + billingFrequency: ["monthly", "annual"], + currency: ["USD", "INR"], + sortBy: "listPrice", + sortDirection: "desc", + }); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/products", { + billingFrequency: ["monthly", "annual"], + currency: ["USD", "INR"], + sortBy: "listPrice", + sortDirection: "desc", + }); + }); + + it("listPriceBooks sends the sort", async () => { + mockClient.get.mockResolvedValue({ results: [{ id: "pb-1", overrideCount: 2 }], totalRecords: 1 }); + + const result = await TurboQuote.listPriceBooks({ sortBy: "overrideCount", sortDirection: "desc" }); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/pricebooks", { sortBy: "overrideCount", sortDirection: "desc" }); + expect(result.results[0].overrideCount).toBe(2); + }); + + it("listBundles sends several currencies and the sort", async () => { + mockClient.get.mockResolvedValue({ + results: [{ id: "b-1", itemCount: 2, billingTotals: { monthly: 100, "one-time": 500 } }], + totalRecords: 1, + }); + + const result = await TurboQuote.listBundles({ currency: ["USD", "EUR"], sortBy: "itemCount", sortDirection: "asc" }); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/bundles", { + currency: ["USD", "EUR"], + sortBy: "itemCount", + sortDirection: "asc", + }); + expect(result.results[0].billingTotals?.["one-time"]).toBe(500); + }); + + it("listCompanies sends the sort", async () => { + mockClient.get.mockResolvedValue({ results: [{ id: "c-1", email: "ap@acme.test" }], totalRecords: 1 }); + + const result = await TurboQuote.listCompanies({ sortBy: "contactCount", sortDirection: "desc" }); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/companies", { sortBy: "contactCount", sortDirection: "desc" }); + expect(result.results[0].email).toBe("ap@acme.test"); + }); + + it("listContacts sends several companies and the sort", async () => { + mockClient.get.mockResolvedValue({ results: [], totalRecords: 0 }); + + await TurboQuote.listContacts({ companyIds: ["c-1", "c-2"], sortBy: "name", sortDirection: "asc" }); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/contacts", { + companyIds: ["c-1", "c-2"], + sortBy: "name", + sortDirection: "asc", + }); + }); + + it("listCompanyContacts sends the sort and returns the company's contact count", async () => { + mockClient.get.mockResolvedValue({ results: [], totalRecords: 0, company: { id: "c-1", contactCount: 1 } }); + + const result = await TurboQuote.listCompanyContacts("c-1", { sortBy: "createdOn", sortDirection: "desc" }); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/companies/c-1/contacts", { + sortBy: "createdOn", + sortDirection: "desc", + }); + expect(result.company?.contactCount).toBe(1); + }); + + it("listTypes sends the sort", async () => { + mockClient.get.mockResolvedValue({ results: [], totalRecords: 0 }); + + await TurboQuote.listTypes({ categoryType: "product_category", sortBy: "usageCount", sortDirection: "desc" }); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/types", { + categoryType: "product_category", + sortBy: "usageCount", + sortDirection: "desc", + }); + }); + + // ============================================ + // QUOTE READS + // ============================================ + + describe("getQuoteSummary", () => { + it("gets the summary for the filters and returns it unwrapped", async () => { + const summary = { currency: "EUR", openPipeline: { total: 10, count: 1 } }; + mockClient.get.mockResolvedValue({ summary }); + + const result = await TurboQuote.getQuoteSummary({ currencies: ["EUR", "USD"], summaryCurrency: "EUR" }); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/quotes/summary", { + currencies: ["EUR", "USD"], + summaryCurrency: "EUR", + }); + expect(result).toEqual(summary); + }); + + it("sends no params when called without options", async () => { + mockClient.get.mockResolvedValue({ summary: { currency: "USD" } }); + + await TurboQuote.getQuoteSummary(); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/quotes/summary", undefined); + }); + }); + + it("listQuoteCreators returns the people and API keys who created quotes", async () => { + const creators = [ + { id: "u-1", name: "Ana Lee", email: "ana@acme.test", isAPIKey: false, apiKeyDeleted: false }, + { id: "u-2", name: "Zapier key", email: null, isAPIKey: true, apiKeyDeleted: true }, + ]; + mockClient.get.mockResolvedValue({ results: creators }); + + const result = await TurboQuote.listQuoteCreators(); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/quotes/creators"); + expect(result).toEqual(creators); + }); + + it("getNextQuoteNumber returns the next number without moving the counter", async () => { + mockClient.get.mockResolvedValue({ results: { nextNumber: "Q-2026-0042", sequence: 42, canEdit: true } }); + + const result = await TurboQuote.getNextQuoteNumber(); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/quotes/number-config/next"); + expect(result).toEqual({ nextNumber: "Q-2026-0042", sequence: 42, canEdit: true }); + }); + + // ============================================ + // OPEN QUOTES + // ============================================ + + it("getCompanyOpenQuotes lists the open quotes for a company", async () => { + const response = { + results: [{ id: "q-1", quoteNumber: "Q-1", name: "Renewal", status: "sent", updatedOn: "2026-10-01" }], + totalRecords: 1, + }; + mockClient.get.mockResolvedValue(response); + + const result = await TurboQuote.getCompanyOpenQuotes("c-1"); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/companies/c-1/open-quotes"); + expect(result).toEqual(response); + }); + + it("getContactOpenQuotes lists the open quotes made out to a contact", async () => { + mockClient.get.mockResolvedValue({ results: [], totalRecords: 0 }); + + const result = await TurboQuote.getContactOpenQuotes("ct-1"); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/contacts/ct-1/open-quotes"); + expect(result.totalRecords).toBe(0); + }); + + // ============================================ + // IMAGES + // ============================================ + + it("getProductQuoteImages posts the product ids and returns up to three images each", async () => { + const images = { "p-1": [{ id: "i-1", productId: "p-1", displayOrder: 0 }], "p-2": [] }; + mockClient.post.mockResolvedValue({ results: images }); + + const result = await TurboQuote.getProductQuoteImages(["p-1", "p-2"]); + + expect(mockClient.post).toHaveBeenCalledWith("/v1/products/quote-images", { productIds: ["p-1", "p-2"] }); + expect(result).toEqual(images); + }); + + describe("custom line item images", () => { + it("getQuoteLineItemImages returns every custom item's images keyed by line item id", async () => { + const byItem = { "li-1": [{ id: "img-1", lineItemId: "li-1", displayOrder: 0 }] }; + mockClient.get.mockResolvedValue({ results: byItem }); + + const result = await TurboQuote.getQuoteLineItemImages("q-1"); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/quotes/q-1/items/images"); + expect(result).toEqual(byItem); + }); + + it("getLineItemImages returns one item's images in order", async () => { + const images = [{ id: "img-1", lineItemId: "li-1", displayOrder: 0 }]; + mockClient.get.mockResolvedValue({ results: images }); + + const result = await TurboQuote.getLineItemImages("q-1", "li-1"); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/quotes/q-1/items/li-1/images"); + expect(result).toEqual(images); + }); + + it("addLineItemImages uploads each image as an 'images' part with its detected type", async () => { + mockClient.postFormData.mockResolvedValue({ results: [{ id: "img-1" }, { id: "img-2" }], message: "Images added" }); + + const result = await TurboQuote.addLineItemImages("q-1", "li-1", [PNG, JPEG]); + + expect(mockClient.postFormData).toHaveBeenCalledWith("/v1/quotes/q-1/items/li-1/images", expect.any(FormData)); + const formData = mockClient.postFormData.mock.calls[0][1] as FormData; + const parts = formData.getAll("images") as File[]; + expect(parts).toHaveLength(2); + expect(parts[0].type).toBe("image/png"); + expect(parts[1].type).toBe("image/jpeg"); + // Images only: no JSON "data" part + expect(formData.get("data")).toBeNull(); + expect(result).toEqual([{ id: "img-1" }, { id: "img-2" }]); + }); + + it("reorderLineItemImages puts the image ids first to last", async () => { + mockClient.put.mockResolvedValue({ results: [{ id: "img-2" }, { id: "img-1" }], message: "Image order saved" }); + + const result = await TurboQuote.reorderLineItemImages("q-1", "li-1", ["img-2", "img-1"]); + + expect(mockClient.put).toHaveBeenCalledWith("/v1/quotes/q-1/items/li-1/images/order", { + imageIds: ["img-2", "img-1"], + }); + expect(result).toEqual([{ id: "img-2" }, { id: "img-1" }]); + }); + + it("removeLineItemImage deletes one image and returns the ones left", async () => { + mockClient.delete.mockResolvedValue({ results: [{ id: "img-2" }], message: "Image removed" }); + + const result = await TurboQuote.removeLineItemImage("q-1", "li-1", "img-1"); + + expect(mockClient.delete).toHaveBeenCalledWith("/v1/quotes/q-1/items/li-1/images/img-1"); + expect(result).toEqual([{ id: "img-2" }]); + }); + }); + + // ============================================ + // NAMED DUPLICATES + // ============================================ + + describe("duplicates take an optional name", () => { + it.each([ + ["duplicateQuote", "/v1/quotes/x-1/duplicate"], + ["duplicateProduct", "/v1/products/x-1/duplicate"], + ["duplicatePriceBook", "/v1/pricebooks/x-1/duplicate"], + ["duplicateBundle", "/v1/bundles/x-1/duplicate"], + ] as const)("%s sends the name for the copy", async (method, path) => { + mockClient.post.mockResolvedValue({ result: { id: "x-2", name: "Q4 copy" } }); + + const result = await (TurboQuote as any)[method]("x-1", { name: "Q4 copy" }); + + expect(mockClient.post).toHaveBeenCalledWith(path, { name: "Q4 copy" }); + expect(result).toEqual({ id: "x-2", name: "Q4 copy" }); + }); + + it.each([ + ["duplicateQuote", "/v1/quotes/x-1/duplicate"], + ["duplicateProduct", "/v1/products/x-1/duplicate"], + ["duplicatePriceBook", "/v1/pricebooks/x-1/duplicate"], + ["duplicateBundle", "/v1/bundles/x-1/duplicate"], + ] as const)("%s without a name sends no body (the API names it \"Copy of …\")", async (method, path) => { + mockClient.post.mockResolvedValue({ result: { id: "x-2" } }); + + await (TurboQuote as any)[method]("x-1"); + + expect(mockClient.post).toHaveBeenCalledWith(path); + }); + }); + + // ============================================ + // REQUEST FIELDS + // ============================================ + + it("createCompany sends the company email", async () => { + mockClient.post.mockResolvedValue({ result: { id: "c-1" } }); + + await TurboQuote.createCompany({ + name: "Acme", + email: "ap@acme.test", + contacts: [{ name: "Ana", email: "ana@acme.test" }], + }); + + expect(mockClient.post).toHaveBeenCalledWith( + "/v1/companies", + expect.objectContaining({ email: "ap@acme.test" }) + ); + }); + + it("updateCompany sends an explicit null email to clear it", async () => { + mockClient.patch.mockResolvedValue({ result: { id: "c-1" } }); + + await TurboQuote.updateCompany("c-1", { email: null }); + + expect(mockClient.patch).toHaveBeenCalledWith("/v1/companies/c-1", { email: null }); + }); + + it("updatePriceBook sends resetProductPricing", async () => { + mockClient.patch.mockResolvedValue({ result: { id: "pb-1" } }); + + await TurboQuote.updatePriceBook("pb-1", { resetProductPricing: true }); + + expect(mockClient.patch).toHaveBeenCalledWith("/v1/pricebooks/pb-1", { resetProductPricing: true }); + }); +}); diff --git a/packages/js-sdk/tests/turboquote.test.ts b/packages/js-sdk/tests/turboquote.test.ts index 1789a93b..04be31cc 100644 --- a/packages/js-sdk/tests/turboquote.test.ts +++ b/packages/js-sdk/tests/turboquote.test.ts @@ -18,6 +18,7 @@ import { TurboQuote } from "../src/modules/quote"; import { HttpClient } from "../src/http"; import { DeclineQuoteRequest, VoidQuoteRequest } from "../src/types/quote"; +import type { QuoteStatus } from "../src/types/quote-shared"; jest.mock("../src/http", () => { const actual = jest.requireActual("../src/http"); @@ -185,31 +186,28 @@ describe("TurboQuote Module", () => { ); }); - it("should accept pending_approval as a valid quote status filter", async () => { - const mockResponse = { - results: [{ id: "q-1", name: "Pending Quote", status: "pending_approval" }], - totalRecords: 1, - }; + it("should send a multi-status array filter", async () => { + const mockResponse = { results: [], totalRecords: 0 }; mockClient.get.mockResolvedValue(mockResponse); - await TurboQuote.listQuotes({ statuses: "pending_approval" }); + await TurboQuote.listQuotes({ statuses: ["draft", "sent", "accepted"] }); expect(mockClient.get).toHaveBeenCalledWith( "/v1/quotes", - expect.objectContaining({ statuses: "pending_approval" }) + expect.objectContaining({ statuses: ["draft", "sent", "accepted"] }) ); }); - it("should accept pending_approval in a multi-status array filter", async () => { - const mockResponse = { results: [], totalRecords: 0 }; - mockClient.get.mockResolvedValue(mockResponse); - - await TurboQuote.listQuotes({ statuses: ["draft", "pending_approval", "sent"] }); - - expect(mockClient.get).toHaveBeenCalledWith( - "/v1/quotes", - expect.objectContaining({ statuses: ["draft", "pending_approval", "sent"] }) - ); + it("should offer exactly the five quote statuses", () => { + // A Record over QuoteStatus fails to compile if a status is missing here or added to the type + const statuses: Record = { + draft: true, + sent: true, + accepted: true, + declined: true, + voided: true, + }; + expect(Object.keys(statuses)).toEqual(["draft", "sent", "accepted", "declined", "voided"]); }); it("should create a quote and unwrap result", async () => { diff --git a/packages/php-sdk/CHANGELOG.md b/packages/php-sdk/CHANGELOG.md index 606983fd..6c1f1fbe 100644 --- a/packages/php-sdk/CHANGELOG.md +++ b/packages/php-sdk/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- TurboQuote list sorting: `sortBy` / `sortDirection` on `ListQuotesRequest`, `ListProductsRequest`, `ListPriceBooksRequest`, `ListBundlesRequest`, `ListCompaniesRequest`, `ListContactsRequest` (also used by `listCompanyContacts()`) and `ListTypesRequest`. Left out, each list keeps its old order. +- Quote list filters on `ListQuotesRequest`: `companyIds`, `currencies` (`currency` now takes one value or an array too), the `expired` status, `createdBy`, `createdByApiKeys`, `validUntilWindow`, `validUntilFrom`, `validUntilTo`, `summaryCurrency`. `QuoteListResponse` gains `statusCounts` (`QuoteStatusCounts`) and `summary` (`QuoteSummary`); each `Quote` from the list carries `isExpired`, `isSignatureExpired` and `lineItemCount`. +- `ListProductsRequest` `billingFrequency` / `currency` and `ListBundlesRequest` `currency` take one value or an array; `ListContactsRequest` takes `companyIds`. +- `TurboQuote::getQuoteSummary(?QuoteSummaryRequest)`, `listQuoteCreators()` (`QuoteCreator[]`), `getNextQuoteNumber()` (`NextQuoteNumber`), `getCompanyOpenQuotes()` / `getContactOpenQuotes()` (`OpenQuotesResponse`), `getProductQuoteImages()`, and custom line item images: `getQuoteLineItemImages()`, `getLineItemImages()`, `addLineItemImages()`, `reorderLineItemImages()`, `removeLineItemImage()` (`LineItemImage`). +- `duplicateQuote()`, `duplicateProduct()`, `duplicatePriceBook()` and `duplicateBundle()` take an optional name for the copy. +- Companies: `email` on `Company`, `CreateCompanyRequest` and `UpdateCompanyRequest` (`includeEmail: true` + `email: null` clears it). `UpdatePriceBookRequest` takes `resetProductPricing`. `PriceBook` gains `overrideCount`; `Bundle` gains `itemCount` and `billingTotals`; `ContactListResponse` gains `company`. +- `HttpClient::put()`. `FileTypeDetector` recognises PNG and JPEG. + +### Changed +- **API behaviour, all SDK versions:** deleting a sent quote is refused with a 409 (`ConflictException`, code `CANNOT_DELETE_SENT_QUOTE`) — void it instead. Deleting a company or contact with open quotes, or a company's last contact, is a 409 (`CompanyHasOpenQuotes`, `ContactHasOpenQuotes`, `LastContact`); deleting the default price book is a 400 `PriceBookIsDefault`. + +### Removed +- `QuoteStatus` lists only the released quote statuses: `DRAFT`, `SENT`, `ACCEPTED`, `DECLINED`, `VOIDED`. + ## [0.9.0] - 2026-10-20 ### Added diff --git a/packages/php-sdk/README.md b/packages/php-sdk/README.md index 8d7001f4..512fcbb6 100644 --- a/packages/php-sdk/README.md +++ b/packages/php-sdk/README.md @@ -1462,7 +1462,10 @@ TURBODOCX_ORG_ID=your-org-id | | `getQuote(id)` | Get quote by ID (includes `statusInfo` and `preparedBy`) | | | `updateQuote(id, UpdateQuoteRequest)` | Update quote fields | | | `deleteQuote(id)` | Delete a quote | -| | `duplicateQuote(id)` | Duplicate a quote | +| | `duplicateQuote(id, ?name)` | Duplicate a quote (optional name for the copy) | +| | `getQuoteSummary(?QuoteSummaryRequest)` | The list's summary cards (pipeline, win rate…) for a set of filters | +| | `listQuoteCreators()` | People and API keys who created quotes (ids for the `createdBy` filter) | +| | `getNextQuoteNumber()` | The number the next quote will get (read only) | | | `downloadQuotePdf(id)` | Download quote as PDF (raw bytes) | | **Quote Actions** | `sendQuote(id, ?SendQuoteRequest)` | Send a quote to the contact | | | `sendQuoteWithDeliverable(id, SendQuoteWithDeliverableRequest)` | Send with a TurboDocx-generated document attached | @@ -1476,36 +1479,44 @@ TURBODOCX_ORG_ID=your-org-id | | `addBundleLineItems(quoteId, AddBundleLineItemRequest\|array)` | Add one or more bundle line items | | | `updateLineItem(quoteId, itemId, UpdateLineItemRequest)` | Update a line item | | | `removeLineItem(quoteId, itemId)` | Remove a line item | +| | `getQuoteLineItemImages(quoteId)` | Every custom line item's images, keyed by line item ID | +| | `getLineItemImages(quoteId, itemId)` | One custom line item's images | +| | `addLineItemImages(quoteId, itemId, images[])` | Add images (paths or bytes) to a custom line item | +| | `reorderLineItemImages(quoteId, itemId, imageIds[])` | Set the image order | +| | `removeLineItemImage(quoteId, itemId, imageId)` | Remove one image | | **Products** | `listProducts(?ListProductsRequest)` | List catalog products | | | `createProduct(CreateProductRequest)` | Create a product (supports image upload) | | | `getProduct(id)` | Get product by ID | | | `updateProduct(id, UpdateProductRequest)` | Update a product | | | `deleteProduct(id)` | Delete a product | -| | `duplicateProduct(id)` | Duplicate a product | +| | `duplicateProduct(id, ?name)` | Duplicate a product (optional name for the copy) | | | `getProductPrimaryImages(productIds[])` | Batch-fetch primary images for product IDs | +| | `getProductQuoteImages(productIds[])` | The images each product shows on a quote (up to three) | | **Price Books** | `listPriceBooks(?ListPriceBooksRequest)` | List price books | | | `createPriceBook(CreatePriceBookRequest)` | Create a price book | | | `getPriceBook(id)` | Get price book by ID | | | `updatePriceBook(id, UpdatePriceBookRequest)` | Update a price book | | | `deletePriceBook(id)` | Delete a price book | -| | `duplicatePriceBook(id)` | Duplicate a price book | +| | `duplicatePriceBook(id, ?name)` | Duplicate a price book (optional name for the copy) | | | `listPriceBookProducts(id, ?ListPriceBookProductsRequest)` | List products with custom pricing in a price book | | **Bundles** | `listBundles(?ListBundlesRequest)` | List catalog bundles | | | `createBundle(CreateBundleRequest)` | Create a bundle | | | `getBundle(id)` | Get bundle by ID | | | `updateBundle(id, UpdateBundleRequest)` | Update a bundle | | | `deleteBundle(id)` | Delete a bundle | -| | `duplicateBundle(id)` | Duplicate a bundle | +| | `duplicateBundle(id, ?name)` | Duplicate a bundle (optional name for the copy) | | **Companies** | `listCompanies(?ListCompaniesRequest)` | List companies | | | `createCompany(CreateCompanyRequest)` | Create a company | | | `getCompany(id)` | Get company by ID | | | `updateCompany(id, UpdateCompanyRequest)` | Update a company | -| | `deleteCompany(id)` | Delete a company | +| | `deleteCompany(id)` | Delete a company (409 while it has open quotes) | +| | `getCompanyOpenQuotes(companyId)` | The open quotes that block deleting a company | | | `listCompanyContacts(companyId, ?ListContactsRequest)` | List contacts for a company | | **Contacts** | `listContacts(?ListContactsRequest)` | List contacts | | | `createContact(CreateContactRequest)` | Create a contact | | | `updateContact(id, UpdateContactRequest)` | Update a contact | -| | `deleteContact(id)` | Delete a contact | +| | `deleteContact(id)` | Delete a contact (409 for the last contact or one on open quotes) | +| | `getContactOpenQuotes(contactId)` | The open quotes that block deleting a contact | | **Templates** | `listTemplates(?ListTemplatesRequest)` | List quote templates | | | `getTemplate()` | Get the org's active quote template | | | `getTemplateById(id)` | Get a template by ID | @@ -1607,6 +1618,67 @@ $result = TurboQuote::sendQuote($quote->id, new SendQuoteRequest( )); ``` +### Sorting, Filters and the List Summary + +Every list takes `sortBy` / `sortDirection` (`asc` | `desc`); leave them out for the order the +list always had. Sort columns: quotes `updatedOn` · `createdOn` · `total` · `validUntil` · `quoteNumber`; +products `name` · `listPrice` · `createdOn`; price books `name` · `discountPercent` · `overrideCount` · +`validFrom` · `validTo` · `createdOn`; bundles `name` · `itemCount` · `totalListPrice` · `totalFinalPrice` · +`createdOn`; companies `name` · `contactCount` · `lastActivityDate`; contacts `name` · `createdOn`; +types `name` · `usageCount`. + +The quote list also filters by several companies (`companyIds`) and currencies (`currencies`), by +`expired` in `statuses` (a draft or sent quote past its Valid until day, UTC, or whose signature +expired), by creator (`createdBy`, ids from the creators call; `createdByApiKeys` for API-key quotes) +and by Valid until (`validUntilWindow` = `thisWeek` | `thisMonth` | `past`, `validUntilFrom` / +`validUntilTo`). Each quote carries `isExpired` and `lineItemCount`; the response adds +`statusCounts` (per tab) and `summary` (pipeline, win rate, monthly recurring, in `summaryCurrency`). + +```php +use TurboDocx\Types\Requests\Quote\ListQuotesRequest; +use TurboDocx\Types\Requests\Quote\QuoteSummaryRequest; + +$page = TurboQuote::listQuotes(new ListQuotesRequest( + statuses: ['sent', 'expired'], + currencies: ['USD', 'EUR'], + validUntilWindow: 'thisMonth', + sortBy: 'validUntil', + sortDirection: 'asc', + summaryCurrency: 'USD', +)); +echo $page->statusCounts?->expired, ' ', $page->summary?->winRate['percent']; + +$creators = TurboQuote::listQuoteCreators(); // ids for createdBy +$summary = TurboQuote::getQuoteSummary(new QuoteSummaryRequest(createdBy: [$creators[0]->id])); +$next = TurboQuote::getNextQuoteNumber(); // read only + +$copy = TurboQuote::duplicateQuote($quote->id, 'Q4 renewal'); // name is optional +``` + +`duplicateQuote()`, `duplicateProduct()`, `duplicatePriceBook()` and `duplicateBundle()` take an optional +name (otherwise "Copy of …"). `ListProductsRequest` / `ListBundlesRequest` take one value or an array for +`billingFrequency` / `currency`. Companies take an optional `email` (`includeEmail: true` + `email: null` +clears it); `UpdatePriceBookRequest(resetProductPricing: true)` puts every product back on the book's discount. + +### Custom Line Item Images + +A custom line item (one with no catalog product) can show up to three images on the quote (PNG or +JPEG, 2 MB each; draft quotes only). Catalog products use `getProductQuoteImages($productIds)`. + +```php +$images = TurboQuote::addLineItemImages($quote->id, $itemId, ['/path/to/diagram.png']); // paths or raw bytes +TurboQuote::reorderLineItemImages($quote->id, $itemId, [$images[1]->id, $images[0]->id]); +TurboQuote::removeLineItemImage($quote->id, $itemId, $images[0]->id); +$byItem = TurboQuote::getQuoteLineItemImages($quote->id); // [lineItemId => LineItemImage[]] +``` + +**Deletes the API refuses.** `deleteQuote()` on a **sent** quote is a 409 `ConflictException` with code +`CANNOT_DELETE_SENT_QUOTE` — void it instead; an accepted quote is a 409 `CANNOT_DELETE_ACCEPTED_QUOTE`. +`deleteCompany()` is a 409 `CompanyHasOpenQuotes` while the company has open quotes (draft or +sent), and `deleteContact()` is a 409 `LastContact` (the company's only contact) or `ContactHasOpenQuotes`. +`deletePriceBook()` on the org's default price book is a 400 `ValidationException` `PriceBookIsDefault`. Branch on +`$e->errorCode`; `getCompanyOpenQuotes()` / `getContactOpenQuotes()` list the open quotes that block a delete. + ### Sender Identity — "Prepared by" The quote's **"Prepared by"** name and email are resolved by the server, not by whoever diff --git a/packages/php-sdk/src/HttpClient.php b/packages/php-sdk/src/HttpClient.php index cff4f0be..53570feb 100644 --- a/packages/php-sdk/src/HttpClient.php +++ b/packages/php-sdk/src/HttpClient.php @@ -151,6 +151,28 @@ public function patch(string $path, ?array $data = null): mixed } } + /** + * Generic PUT request (JSON body) + * + * @param string $path + * @param array|null $data + * @return mixed + */ + public function put(string $path, ?array $data = null): mixed + { + try { + $response = $this->client->put($path, [ + 'json' => $data, + ]); + + return ResponseNormalizer::normalizeResponse( + $this->smartUnwrap($this->parseResponse($response)) + ); + } catch (GuzzleException $e) { + $this->handleException($e); + } + } + /** * GET request returning raw binary response (for file downloads) * diff --git a/packages/php-sdk/src/TurboQuote.php b/packages/php-sdk/src/TurboQuote.php index cba69bec..b2fe4755 100644 --- a/packages/php-sdk/src/TurboQuote.php +++ b/packages/php-sdk/src/TurboQuote.php @@ -5,16 +5,21 @@ namespace TurboDocx; use TurboDocx\Config\QuoteClientConfig; +use TurboDocx\Utils\FileTypeDetector; use TurboDocx\Types\Quote\BulkImportResult; use TurboDocx\Types\Quote\Bundle; use TurboDocx\Types\Quote\Company; use TurboDocx\Types\Quote\Contact; use TurboDocx\Types\Quote\LineItem; +use TurboDocx\Types\Quote\LineItemImage; +use TurboDocx\Types\Quote\NextQuoteNumber; use TurboDocx\Types\Quote\PriceBook; use TurboDocx\Types\Quote\Product; use TurboDocx\Types\Quote\Quote; use TurboDocx\Types\Quote\QuoteNumberConfig; +use TurboDocx\Types\Quote\QuoteCreator; use TurboDocx\Types\Quote\QuoteNumberFormat; +use TurboDocx\Types\Quote\QuoteSummary; use TurboDocx\Types\Quote\QuoteTemplate; use TurboDocx\Types\Quote\QuoteType; use TurboDocx\Types\Requests\Quote\AddBundleLineItemRequest; @@ -40,6 +45,7 @@ use TurboDocx\Types\Requests\Quote\ListQuotesRequest; use TurboDocx\Types\Requests\Quote\ListTemplatesRequest; use TurboDocx\Types\Requests\Quote\ListTypesRequest; +use TurboDocx\Types\Requests\Quote\QuoteSummaryRequest; use TurboDocx\Types\Requests\Quote\SendQuoteRequest; use TurboDocx\Types\Requests\Quote\SendQuoteWithDeliverableRequest; use TurboDocx\Types\Requests\Quote\UpdateBundleRequest; @@ -59,6 +65,7 @@ use TurboDocx\Types\Responses\Quote\CreateAndSendResponse; use TurboDocx\Types\Responses\Quote\LineItemListResponse; use TurboDocx\Types\Responses\Quote\MessageResponse; +use TurboDocx\Types\Responses\Quote\OpenQuotesResponse; use TurboDocx\Types\Responses\Quote\PriceBookListResponse; use TurboDocx\Types\Responses\Quote\PriceBookProductListResponse; use TurboDocx\Types\Responses\Quote\ProductListResponse; @@ -178,6 +185,46 @@ private static function buildProductFormData(array $request): array return $multipart; } + /** + * One "images" multipart part per image: a file path is read, raw bytes are sent as they + * are. The part's type and file extension come from the bytes (PNG / JPEG). + * + * @param string[] $images File paths or raw image bytes + * @return array}> + */ + private static function buildImageParts(array $images): array + { + $parts = []; + foreach ($images as $image) { + $isPath = strlen($image) < 4096 && !str_contains($image, "\0") && is_file($image); + $contents = $isPath ? file_get_contents($image) : $image; + if ($contents === false) { + throw new Exceptions\ValidationException("Failed to read image file: {$image}"); + } + $detected = FileTypeDetector::detect($contents); + $parts[] = [ + 'name' => 'images', + 'contents' => $contents, + 'filename' => $isPath ? basename($image) : "image.{$detected['extension']}", + 'headers' => ['Content-Type' => $detected['mimetype']], + ]; + } + return $parts; + } + + /** + * POST to a duplicate endpoint: the copy's name when given, else no body (the API names the + * copy "Copy of …"). Returns the unwrapped copy. + * + * @return array + */ + private static function postDuplicate(string $path, ?string $name): array + { + $client = self::getClient(); + $response = $name !== null ? $client->post($path, ['name' => $name]) : $client->post($path); + return self::unwrap($response); + } + /** * POST rows to a bulk-create endpoint and unwrap the results envelope. * @@ -214,6 +261,32 @@ public static function listQuotes(?ListQuotesRequest $request = null): QuoteList return QuoteListResponse::fromArray($response); } + /** + * The quote list's summary cards (pipeline, win rate, monthly recurring…) for a set of + * filters, in one currency (`summaryCurrency`). The status filter is ignored. + * + * @param QuoteSummaryRequest|null $request + * @return QuoteSummary + */ + public static function getQuoteSummary(?QuoteSummaryRequest $request = null): QuoteSummary + { + $client = self::getClient(); + $response = $client->get('/v1/quotes/summary', $request?->toQueryParams() ?? []); + return QuoteSummary::fromArray($response['summary'] ?? []); + } + + /** + * The people and API keys who created the org's quotes — the ids for the `createdBy` filter. + * + * @return QuoteCreator[] + */ + public static function listQuoteCreators(): array + { + $client = self::getClient(); + $response = $client->get('/v1/quotes/creators'); + return array_map(fn(array $c) => QuoteCreator::fromArray($c), $response['results'] ?? []); + } + /** * Create a new quote. * @@ -267,6 +340,9 @@ public static function updateQuote(string $id, UpdateQuoteRequest $request): Quo /** * Delete a quote. * + * A sent quote is refused with a 409 (ConflictException, code CANNOT_DELETE_SENT_QUOTE) — + * void it instead — and so is an accepted one (CANNOT_DELETE_ACCEPTED_QUOTE). + * * @param string $id * @return MessageResponse */ @@ -277,15 +353,15 @@ public static function deleteQuote(string $id): MessageResponse } /** - * Duplicate a quote. + * Duplicate a quote. Pass $name to name the copy; without it the copy is called "Copy of ". * * @param string $id + * @param string|null $name * @return Quote */ - public static function duplicateQuote(string $id): Quote + public static function duplicateQuote(string $id, ?string $name = null): Quote { - $client = self::getClient(); - return Quote::fromArray(self::unwrap($client->post("/v1/quotes/{$id}/duplicate"))); + return Quote::fromArray(self::postDuplicate("/v1/quotes/{$id}/duplicate", $name)); } /** @@ -476,6 +552,84 @@ public static function removeLineItem(string $quoteId, string $itemId): MessageR return MessageResponse::fromArray($client->delete("/v1/quotes/{$quoteId}/items/{$itemId}")); } + // ============================================ + // CUSTOM LINE ITEM IMAGES + // Up to three per custom item (a line with no catalog product), PNG or JPEG, 2MB each. + // Changes are allowed on draft quotes only. + // ============================================ + + /** + * Every custom line item's images (up to three, in order), keyed by line item ID. + * + * @param string $quoteId + * @return array + */ + public static function getQuoteLineItemImages(string $quoteId): array + { + $client = self::getClient(); + $response = $client->get("/v1/quotes/{$quoteId}/items/images"); + return array_map(fn(array $images) => LineItemImage::listFromArray($images), $response['results'] ?? []); + } + + /** + * One custom line item's images, in order. + * + * @param string $quoteId + * @param string $itemId + * @return LineItemImage[] + */ + public static function getLineItemImages(string $quoteId, string $itemId): array + { + $client = self::getClient(); + $response = $client->get("/v1/quotes/{$quoteId}/items/{$itemId}/images"); + return LineItemImage::listFromArray($response['results'] ?? []); + } + + /** + * Add images to a custom line item. Returns all of its images. + * + * @param string $quoteId + * @param string $itemId + * @param string[] $images File paths or raw image bytes (PNG or JPEG) + * @return LineItemImage[] + */ + public static function addLineItemImages(string $quoteId, string $itemId, array $images): array + { + $client = self::getClient(); + $response = $client->postFormData("/v1/quotes/{$quoteId}/items/{$itemId}/images", self::buildImageParts($images)); + return LineItemImage::listFromArray($response['results'] ?? []); + } + + /** + * Set the order of a custom line item's images, first to last. + * + * @param string $quoteId + * @param string $itemId + * @param string[] $imageIds + * @return LineItemImage[] + */ + public static function reorderLineItemImages(string $quoteId, string $itemId, array $imageIds): array + { + $client = self::getClient(); + $response = $client->put("/v1/quotes/{$quoteId}/items/{$itemId}/images/order", ['imageIds' => $imageIds]); + return LineItemImage::listFromArray($response['results'] ?? []); + } + + /** + * Remove one image from a custom line item. Returns the images left. + * + * @param string $quoteId + * @param string $itemId + * @param string $imageId + * @return LineItemImage[] + */ + public static function removeLineItemImage(string $quoteId, string $itemId, string $imageId): array + { + $client = self::getClient(); + $response = $client->delete("/v1/quotes/{$quoteId}/items/{$itemId}/images/{$imageId}"); + return LineItemImage::listFromArray($response['results'] ?? []); + } + // ============================================ // PRODUCTS // ============================================ @@ -565,15 +719,15 @@ public static function deleteProduct(string $id): MessageResponse } /** - * Duplicate a product. + * Duplicate a product. Pass $name to name the copy; without it the copy is called "Copy of ". * * @param string $id + * @param string|null $name * @return Product */ - public static function duplicateProduct(string $id): Product + public static function duplicateProduct(string $id, ?string $name = null): Product { - $client = self::getClient(); - return Product::fromArray(self::unwrap($client->post("/v1/products/{$id}/duplicate"))); + return Product::fromArray(self::postDuplicate("/v1/products/{$id}/duplicate", $name)); } /** @@ -589,6 +743,19 @@ public static function getProductPrimaryImages(array $productIds): array return $response['results']; } + /** + * The images each product shows on a quote: up to three, in display order (empty when none). + * + * @param string[] $productIds 1–100 product IDs + * @return array>> + */ + public static function getProductQuoteImages(array $productIds): array + { + $client = self::getClient(); + $response = $client->post('/v1/products/quote-images', ['productIds' => $productIds]); + return $response['results']; + } + // ============================================ // PRICE BOOKS // ============================================ @@ -659,7 +826,8 @@ public static function updatePriceBook(string $id, UpdatePriceBookRequest $reque } /** - * Delete a price book. + * Delete a price book. The org's default price book is refused (400 ValidationException, + * code PriceBookIsDefault). * * @param string $id * @return MessageResponse @@ -671,15 +839,15 @@ public static function deletePriceBook(string $id): MessageResponse } /** - * Duplicate a price book. + * Duplicate a price book. Pass $name to name the copy; without it the copy is called "Copy of ". * * @param string $id + * @param string|null $name * @return PriceBook */ - public static function duplicatePriceBook(string $id): PriceBook + public static function duplicatePriceBook(string $id, ?string $name = null): PriceBook { - $client = self::getClient(); - return PriceBook::fromArray(self::unwrap($client->post("/v1/pricebooks/{$id}/duplicate"))); + return PriceBook::fromArray(self::postDuplicate("/v1/pricebooks/{$id}/duplicate", $name)); } /** @@ -776,15 +944,15 @@ public static function deleteBundle(string $id): MessageResponse } /** - * Duplicate a bundle. + * Duplicate a bundle. Pass $name to name the copy; without it the copy is called "Copy of ". * * @param string $id + * @param string|null $name * @return Bundle */ - public static function duplicateBundle(string $id): Bundle + public static function duplicateBundle(string $id, ?string $name = null): Bundle { - $client = self::getClient(); - return Bundle::fromArray(self::unwrap($client->post("/v1/bundles/{$id}/duplicate"))); + return Bundle::fromArray(self::postDuplicate("/v1/bundles/{$id}/duplicate", $name)); } // ============================================ @@ -855,7 +1023,9 @@ public static function updateCompany(string $id, UpdateCompanyRequest $request): } /** - * Delete a company. + * Delete a company and its contacts. Refused with a 409 (ConflictException, code + * CompanyHasOpenQuotes) while it has open quotes (draft or sent) — + * see getCompanyOpenQuotes(). * * @param string $id * @return MessageResponse @@ -866,6 +1036,18 @@ public static function deleteCompany(string $id): MessageResponse return MessageResponse::fromArray($client->delete("/v1/companies/{$id}")); } + /** + * The open quotes (draft or sent) for a company: what blocks deleting it. + * + * @param string $companyId + * @return OpenQuotesResponse + */ + public static function getCompanyOpenQuotes(string $companyId): OpenQuotesResponse + { + $client = self::getClient(); + return OpenQuotesResponse::fromArray($client->get("/v1/companies/{$companyId}/open-quotes")); + } + /** * List contacts for a company. * @@ -937,7 +1119,9 @@ public static function updateContact(string $id, UpdateContactRequest $request): } /** - * Delete a contact. + * Delete a contact. Refused with a 409 (ConflictException) when they are the company's only + * contact (LastContact) or the contact on open quotes (ContactHasOpenQuotes) — see + * getContactOpenQuotes(). * * @param string $id * @return MessageResponse @@ -948,6 +1132,18 @@ public static function deleteContact(string $id): MessageResponse return MessageResponse::fromArray($client->delete("/v1/contacts/{$id}")); } + /** + * The open quotes (draft or sent) made out to a contact: what blocks deleting them. + * + * @param string $contactId + * @return OpenQuotesResponse + */ + public static function getContactOpenQuotes(string $contactId): OpenQuotesResponse + { + $client = self::getClient(); + return OpenQuotesResponse::fromArray($client->get("/v1/contacts/{$contactId}/open-quotes")); + } + // ============================================ // TEMPLATES // ============================================ @@ -1121,6 +1317,19 @@ public static function updateQuoteNumberConfig(QuoteNumberFormat $format): Quote return QuoteNumberConfig::fromArray($response['results']); } + /** + * The number the next new quote will get. Read only — the counter doesn't move, so a quote + * created by someone else in between takes it first. + * + * @return NextQuoteNumber + */ + public static function getNextQuoteNumber(): NextQuoteNumber + { + $client = self::getClient(); + $response = $client->get('/v1/quotes/number-config/next'); + return NextQuoteNumber::fromArray($response['results'] ?? []); + } + // ============================================ // CONVENIENCE // ============================================ diff --git a/packages/php-sdk/src/Types/Enums/QuoteStatus.php b/packages/php-sdk/src/Types/Enums/QuoteStatus.php index 9d44bed3..e752b1fc 100644 --- a/packages/php-sdk/src/Types/Enums/QuoteStatus.php +++ b/packages/php-sdk/src/Types/Enums/QuoteStatus.php @@ -10,7 +10,6 @@ enum QuoteStatus: string { case DRAFT = 'draft'; - case PENDING_APPROVAL = 'pending_approval'; case SENT = 'sent'; case ACCEPTED = 'accepted'; case DECLINED = 'declined'; diff --git a/packages/php-sdk/src/Types/Quote/Bundle.php b/packages/php-sdk/src/Types/Quote/Bundle.php index 662911cf..1f65db8d 100644 --- a/packages/php-sdk/src/Types/Quote/Bundle.php +++ b/packages/php-sdk/src/Types/Quote/Bundle.php @@ -12,6 +12,7 @@ final class Bundle implements \JsonSerializable /** * @param array|null $items * @param array{id: string, name: string, categoryType: string}|null $category + * @param array|null $billingTotals */ public function __construct( public readonly string $id, @@ -34,6 +35,10 @@ public function __construct( public readonly ?string $updatedOn = null, public readonly ?array $items = null, public readonly ?array $category = null, + // listBundles only: the bundle's active items + public readonly ?int $itemCount = null, + // listBundles only: the bundle price per billing frequency + public readonly ?array $billingTotals = null, ) {} /** @@ -66,6 +71,10 @@ public static function fromArray(array $data): self $data['items'] ) : null, category: $data['category'] ?? null, + itemCount: isset($data['itemCount']) ? (int) $data['itemCount'] : null, + billingTotals: isset($data['billingTotals']) && is_array($data['billingTotals']) + ? array_map(fn($total) => (float) $total, $data['billingTotals']) + : null, ); } @@ -101,6 +110,12 @@ public function toArray(): array if ($this->category !== null) { $data['category'] = $this->category; } + if ($this->itemCount !== null) { + $data['itemCount'] = $this->itemCount; + } + if ($this->billingTotals !== null) { + $data['billingTotals'] = $this->billingTotals; + } return $data; } diff --git a/packages/php-sdk/src/Types/Quote/Company.php b/packages/php-sdk/src/Types/Quote/Company.php index a22d9520..fb53ef27 100644 --- a/packages/php-sdk/src/Types/Quote/Company.php +++ b/packages/php-sdk/src/Types/Quote/Company.php @@ -25,6 +25,8 @@ public function __construct( public readonly ?string $updatedOn = null, public readonly ?int $contactCount = null, public readonly ?QuoteType $industry = null, + // The company's own email (null for companies created without one) + public readonly ?string $email = null, ) {} /** @@ -49,6 +51,7 @@ public static function fromArray(array $data): self updatedOn: $data['updatedOn'] ?? null, contactCount: isset($data['contactCount']) ? (int) $data['contactCount'] : null, industry: isset($data['industry']) ? QuoteType::fromArray($data['industry']) : null, + email: $data['email'] ?? null, ); } @@ -61,6 +64,7 @@ public function toArray(): array 'id' => $this->id, 'orgId' => $this->orgId, 'name' => $this->name, + 'email' => $this->email, 'phone' => $this->phone, 'city' => $this->city, 'state' => $this->state, diff --git a/packages/php-sdk/src/Types/Quote/LineItemImage.php b/packages/php-sdk/src/Types/Quote/LineItemImage.php new file mode 100644 index 00000000..8d1683cd --- /dev/null +++ b/packages/php-sdk/src/Types/Quote/LineItemImage.php @@ -0,0 +1,75 @@ + $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + id: (string) ($data['id'] ?? ''), + lineItemId: (string) ($data['lineItemId'] ?? ''), + fileId: (string) ($data['fileId'] ?? ''), + displayOrder: (int) ($data['displayOrder'] ?? 0), + fileName: (string) ($data['fileName'] ?? ''), + fileType: (string) ($data['fileType'] ?? ''), + imageData: isset($data['imageData']) ? (string) $data['imageData'] : null, + ); + } + + /** + * @param array> $list + * @return LineItemImage[] + */ + public static function listFromArray(array $list): array + { + return array_map(fn(array $image) => self::fromArray($image), $list); + } + + /** + * @return array + */ + public function toArray(): array + { + $data = [ + 'id' => $this->id, + 'lineItemId' => $this->lineItemId, + 'fileId' => $this->fileId, + 'displayOrder' => $this->displayOrder, + 'fileName' => $this->fileName, + 'fileType' => $this->fileType, + ]; + if ($this->imageData !== null) { + $data['imageData'] = $this->imageData; + } + return $data; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/packages/php-sdk/src/Types/Quote/NextQuoteNumber.php b/packages/php-sdk/src/Types/Quote/NextQuoteNumber.php new file mode 100644 index 00000000..b47a972f --- /dev/null +++ b/packages/php-sdk/src/Types/Quote/NextQuoteNumber.php @@ -0,0 +1,52 @@ + $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + nextNumber: (string) ($data['nextNumber'] ?? ''), + sequence: (int) ($data['sequence'] ?? 0), + canEdit: (bool) ($data['canEdit'] ?? false), + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'nextNumber' => $this->nextNumber, + 'sequence' => $this->sequence, + 'canEdit' => $this->canEdit, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/packages/php-sdk/src/Types/Quote/OpenQuoteSummary.php b/packages/php-sdk/src/Types/Quote/OpenQuoteSummary.php new file mode 100644 index 00000000..7ca33eea --- /dev/null +++ b/packages/php-sdk/src/Types/Quote/OpenQuoteSummary.php @@ -0,0 +1,56 @@ + $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + id: (string) ($data['id'] ?? ''), + quoteNumber: (string) ($data['quoteNumber'] ?? ''), + name: isset($data['name']) ? (string) $data['name'] : null, + status: (string) ($data['status'] ?? ''), + updatedOn: isset($data['updatedOn']) ? (string) $data['updatedOn'] : null, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'quoteNumber' => $this->quoteNumber, + 'name' => $this->name, + 'status' => $this->status, + 'updatedOn' => $this->updatedOn, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/packages/php-sdk/src/Types/Quote/PriceBook.php b/packages/php-sdk/src/Types/Quote/PriceBook.php index ca6e7ac9..907665dd 100644 --- a/packages/php-sdk/src/Types/Quote/PriceBook.php +++ b/packages/php-sdk/src/Types/Quote/PriceBook.php @@ -31,6 +31,8 @@ public function __construct( public readonly ?array $productPricing = null, public readonly ?array $priceBookType = null, public readonly ?int $productCount = null, + // listPriceBooks only: products with their own price in this book + public readonly ?int $overrideCount = null, ) {} /** @@ -60,6 +62,7 @@ public static function fromArray(array $data): self ) : null, priceBookType: $data['priceBookType'] ?? null, productCount: isset($data['productCount']) ? (int) $data['productCount'] : null, + overrideCount: isset($data['overrideCount']) ? (int) $data['overrideCount'] : null, ); } @@ -94,6 +97,9 @@ public function toArray(): array if ($this->productCount !== null) { $data['productCount'] = $this->productCount; } + if ($this->overrideCount !== null) { + $data['overrideCount'] = $this->overrideCount; + } return $data; } diff --git a/packages/php-sdk/src/Types/Quote/Quote.php b/packages/php-sdk/src/Types/Quote/Quote.php index 4e582e4c..9c4f1199 100644 --- a/packages/php-sdk/src/Types/Quote/Quote.php +++ b/packages/php-sdk/src/Types/Quote/Quote.php @@ -52,6 +52,13 @@ public function __construct( * @var array|null */ public readonly ?array $preparedBy = null, + // listQuotes only: how many line items the quote has + public readonly ?int $lineItemCount = null, + // listQuotes only: the quote's signature request has expired + public readonly ?bool $isSignatureExpired = null, + // listQuotes only: a draft or sent quote past its Valid until day (UTC), or whose + // signature expired. The same rule as the "expired" status filter. + public readonly ?bool $isExpired = null, ) {} /** @@ -95,6 +102,9 @@ public static function fromArray(array $data): self creator: $data['creator'] ?? null, statusInfo: isset($data['statusInfo']) ? QuoteStatusInfo::fromArray($data['statusInfo']) : null, preparedBy: $data['preparedBy'] ?? null, + lineItemCount: isset($data['lineItemCount']) ? (int) $data['lineItemCount'] : null, + isSignatureExpired: isset($data['isSignatureExpired']) ? (bool) $data['isSignatureExpired'] : null, + isExpired: isset($data['isExpired']) ? (bool) $data['isExpired'] : null, ); } @@ -151,6 +161,15 @@ public function toArray(): array if ($this->preparedBy !== null) { $data['preparedBy'] = $this->preparedBy; } + if ($this->lineItemCount !== null) { + $data['lineItemCount'] = $this->lineItemCount; + } + if ($this->isSignatureExpired !== null) { + $data['isSignatureExpired'] = $this->isSignatureExpired; + } + if ($this->isExpired !== null) { + $data['isExpired'] = $this->isExpired; + } return $data; } diff --git a/packages/php-sdk/src/Types/Quote/QuoteCreator.php b/packages/php-sdk/src/Types/Quote/QuoteCreator.php new file mode 100644 index 00000000..e0720157 --- /dev/null +++ b/packages/php-sdk/src/Types/Quote/QuoteCreator.php @@ -0,0 +1,60 @@ + $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + id: (string) ($data['id'] ?? ''), + name: (string) ($data['name'] ?? ''), + email: isset($data['email']) ? (string) $data['email'] : null, + isAPIKey: (bool) ($data['isAPIKey'] ?? false), + apiKeyDeleted: (bool) ($data['apiKeyDeleted'] ?? false), + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'isAPIKey' => $this->isAPIKey, + 'apiKeyDeleted' => $this->apiKeyDeleted, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/packages/php-sdk/src/Types/Quote/QuoteStatusCounts.php b/packages/php-sdk/src/Types/Quote/QuoteStatusCounts.php new file mode 100644 index 00000000..e756d21d --- /dev/null +++ b/packages/php-sdk/src/Types/Quote/QuoteStatusCounts.php @@ -0,0 +1,63 @@ + $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + all: (int) ($data['all'] ?? 0), + draft: (int) ($data['draft'] ?? 0), + sent: (int) ($data['sent'] ?? 0), + accepted: (int) ($data['accepted'] ?? 0), + declined: (int) ($data['declined'] ?? 0), + voided: (int) ($data['voided'] ?? 0), + expired: (int) ($data['expired'] ?? 0), + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'all' => $this->all, + 'draft' => $this->draft, + 'sent' => $this->sent, + 'accepted' => $this->accepted, + 'declined' => $this->declined, + 'voided' => $this->voided, + 'expired' => $this->expired, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/packages/php-sdk/src/Types/Quote/QuoteSummary.php b/packages/php-sdk/src/Types/Quote/QuoteSummary.php new file mode 100644 index 00000000..05e22d05 --- /dev/null +++ b/packages/php-sdk/src/Types/Quote/QuoteSummary.php @@ -0,0 +1,96 @@ + 0.0, 'count' => 0], + public readonly array $completed = ['accepted' => 0, 'declined' => 0], + public readonly array $createdThisMonth = ['count' => 0], + public readonly array $waitingToSign = ['count' => 0], + public readonly array $wonThisMonth = ['count' => 0], + public readonly array $currencies = [], + public readonly array $winRate = ['percent' => null, 'accepted' => 0, 'declined' => 0, 'windowDays' => 0], + public readonly array $monthlyRecurring = ['total' => 0.0, 'count' => 0], + ) {} + + /** + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + $amount = fn(mixed $value): array => [ + 'total' => (float) (is_array($value) ? ($value['total'] ?? 0) : 0), + 'count' => (int) (is_array($value) ? ($value['count'] ?? 0) : 0), + ]; + $count = fn(mixed $value): array => ['count' => (int) (is_array($value) ? ($value['count'] ?? 0) : 0)]; + $completed = is_array($data['completed'] ?? null) ? $data['completed'] : []; + $winRate = is_array($data['winRate'] ?? null) ? $data['winRate'] : []; + + return new self( + currency: (string) ($data['currency'] ?? ''), + openPipeline: $amount($data['openPipeline'] ?? null), + completed: [ + 'accepted' => (int) ($completed['accepted'] ?? 0), + 'declined' => (int) ($completed['declined'] ?? 0), + ], + createdThisMonth: $count($data['createdThisMonth'] ?? null), + waitingToSign: $count($data['waitingToSign'] ?? null), + wonThisMonth: $count($data['wonThisMonth'] ?? null), + currencies: array_values(array_map('strval', is_array($data['currencies'] ?? null) ? $data['currencies'] : [])), + winRate: [ + 'percent' => isset($winRate['percent']) ? (float) $winRate['percent'] : null, + 'accepted' => (int) ($winRate['accepted'] ?? 0), + 'declined' => (int) ($winRate['declined'] ?? 0), + 'windowDays' => (int) ($winRate['windowDays'] ?? 0), + ], + monthlyRecurring: $amount($data['monthlyRecurring'] ?? null), + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'currency' => $this->currency, + 'openPipeline' => $this->openPipeline, + 'completed' => $this->completed, + 'createdThisMonth' => $this->createdThisMonth, + 'waitingToSign' => $this->waitingToSign, + 'wonThisMonth' => $this->wonThisMonth, + 'currencies' => $this->currencies, + 'winRate' => $this->winRate, + 'monthlyRecurring' => $this->monthlyRecurring, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/packages/php-sdk/src/Types/Requests/Quote/CreateCompanyRequest.php b/packages/php-sdk/src/Types/Requests/Quote/CreateCompanyRequest.php index 6aa5b00f..d094c8bc 100644 --- a/packages/php-sdk/src/Types/Requests/Quote/CreateCompanyRequest.php +++ b/packages/php-sdk/src/Types/Requests/Quote/CreateCompanyRequest.php @@ -20,6 +20,8 @@ public function __construct( public readonly ?string $state = null, public readonly ?string $country = null, public readonly ?string $industryId = null, + // The company's own email (optional through the API; the TurboDocx app requires it) + public readonly ?string $email = null, ) {} /** @@ -32,6 +34,9 @@ public function toArray(): array 'contacts' => $this->contacts, ]; + if ($this->email !== null) { + $data['email'] = $this->email; + } if ($this->phone !== null) { $data['phone'] = $this->phone; } diff --git a/packages/php-sdk/src/Types/Requests/Quote/ListBundlesRequest.php b/packages/php-sdk/src/Types/Requests/Quote/ListBundlesRequest.php index 46443383..238b7903 100644 --- a/packages/php-sdk/src/Types/Requests/Quote/ListBundlesRequest.php +++ b/packages/php-sdk/src/Types/Requests/Quote/ListBundlesRequest.php @@ -11,14 +11,20 @@ final class ListBundlesRequest { /** * @param string|string[]|null $categoryIds + * @param string|string[]|null $currency */ public function __construct( public readonly ?int $limit = null, public readonly ?int $offset = null, public readonly ?string $query = null, public readonly string|array|null $categoryIds = null, - public readonly ?string $currency = null, + // One currency or several + public readonly string|array|null $currency = null, public readonly ?bool $showInCatalog = null, + // Sort column: 'name' | 'itemCount' | 'totalListPrice' | 'totalFinalPrice' | 'createdOn' (default createdOn, newest first) + public readonly ?string $sortBy = null, + // 'asc' | 'desc' + public readonly ?string $sortDirection = null, ) {} /** @@ -51,6 +57,13 @@ public function toQueryParams(): array $params['showInCatalog'] = $this->showInCatalog ? 'true' : 'false'; } + if ($this->sortBy !== null) { + $params['sortBy'] = $this->sortBy; + } + if ($this->sortDirection !== null) { + $params['sortDirection'] = $this->sortDirection; + } + return $params; } } diff --git a/packages/php-sdk/src/Types/Requests/Quote/ListCompaniesRequest.php b/packages/php-sdk/src/Types/Requests/Quote/ListCompaniesRequest.php index 16ec1bb6..075c8ced 100644 --- a/packages/php-sdk/src/Types/Requests/Quote/ListCompaniesRequest.php +++ b/packages/php-sdk/src/Types/Requests/Quote/ListCompaniesRequest.php @@ -17,6 +17,10 @@ public function __construct( public readonly ?int $offset = null, public readonly ?string $query = null, public readonly string|array|null $industryIds = null, + // Sort column: 'name' | 'contactCount' | 'lastActivityDate' (default lastActivityDate, most recent first) + public readonly ?string $sortBy = null, + // 'asc' | 'desc' + public readonly ?string $sortDirection = null, ) {} /** @@ -43,6 +47,13 @@ public function toQueryParams(): array } } + if ($this->sortBy !== null) { + $params['sortBy'] = $this->sortBy; + } + if ($this->sortDirection !== null) { + $params['sortDirection'] = $this->sortDirection; + } + return $params; } } diff --git a/packages/php-sdk/src/Types/Requests/Quote/ListContactsRequest.php b/packages/php-sdk/src/Types/Requests/Quote/ListContactsRequest.php index a3476b8a..b5da7e6d 100644 --- a/packages/php-sdk/src/Types/Requests/Quote/ListContactsRequest.php +++ b/packages/php-sdk/src/Types/Requests/Quote/ListContactsRequest.php @@ -9,15 +9,24 @@ */ final class ListContactsRequest { + /** + * @param string|string[]|null $companyIds + */ public function __construct( public readonly ?int $limit = null, public readonly ?int $offset = null, public readonly ?string $query = null, public readonly ?string $companyId = null, + /** Several companies (listContacts only) */ + public readonly string|array|null $companyIds = null, + // Sort column: 'name' | 'createdOn' (default createdOn, newest first) + public readonly ?string $sortBy = null, + // 'asc' | 'desc' + public readonly ?string $sortDirection = null, ) {} /** - * @return array + * @return array */ public function toQueryParams(): array { @@ -36,6 +45,16 @@ public function toQueryParams(): array $params['companyId'] = $this->companyId; } + if ($this->companyIds !== null) { + $params['companyIds'] = $this->companyIds; + } + if ($this->sortBy !== null) { + $params['sortBy'] = $this->sortBy; + } + if ($this->sortDirection !== null) { + $params['sortDirection'] = $this->sortDirection; + } + return $params; } } diff --git a/packages/php-sdk/src/Types/Requests/Quote/ListPriceBooksRequest.php b/packages/php-sdk/src/Types/Requests/Quote/ListPriceBooksRequest.php index 5c7937f6..c8c11770 100644 --- a/packages/php-sdk/src/Types/Requests/Quote/ListPriceBooksRequest.php +++ b/packages/php-sdk/src/Types/Requests/Quote/ListPriceBooksRequest.php @@ -18,6 +18,10 @@ public function __construct( public readonly ?string $query = null, public readonly string|array|null $priceBookTypeIds = null, public readonly ?bool $showInQuoteBuilder = null, + // Sort column: 'name' | 'discountPercent' | 'overrideCount' | 'validFrom' | 'validTo' | 'createdOn' (default createdOn, newest first) + public readonly ?string $sortBy = null, + // 'asc' | 'desc' + public readonly ?string $sortDirection = null, ) {} /** @@ -47,6 +51,13 @@ public function toQueryParams(): array $params['showInQuoteBuilder'] = $this->showInQuoteBuilder ? 'true' : 'false'; } + if ($this->sortBy !== null) { + $params['sortBy'] = $this->sortBy; + } + if ($this->sortDirection !== null) { + $params['sortDirection'] = $this->sortDirection; + } + return $params; } } diff --git a/packages/php-sdk/src/Types/Requests/Quote/ListProductsRequest.php b/packages/php-sdk/src/Types/Requests/Quote/ListProductsRequest.php index 5aaddc6f..16eb7c4b 100644 --- a/packages/php-sdk/src/Types/Requests/Quote/ListProductsRequest.php +++ b/packages/php-sdk/src/Types/Requests/Quote/ListProductsRequest.php @@ -11,15 +11,22 @@ final class ListProductsRequest { /** * @param string[]|null $categoryIds + * @param string|string[]|null $billingFrequency + * @param string|string[]|null $currency */ public function __construct( public readonly ?int $limit = null, public readonly ?int $offset = null, public readonly ?string $query = null, public readonly ?array $categoryIds = null, - public readonly ?string $billingFrequency = null, - public readonly ?string $currency = null, + // One billing frequency / currency, or several + public readonly string|array|null $billingFrequency = null, + public readonly string|array|null $currency = null, public readonly ?bool $showInCatalog = null, + // Sort column: 'name' | 'listPrice' | 'createdOn' (default createdOn, newest first) + public readonly ?string $sortBy = null, + // 'asc' | 'desc' + public readonly ?string $sortDirection = null, ) {} /** @@ -51,6 +58,13 @@ public function toQueryParams(): array $params['showInCatalog'] = $this->showInCatalog ? 'true' : 'false'; } + if ($this->sortBy !== null) { + $params['sortBy'] = $this->sortBy; + } + if ($this->sortDirection !== null) { + $params['sortDirection'] = $this->sortDirection; + } + return $params; } } diff --git a/packages/php-sdk/src/Types/Requests/Quote/ListQuotesRequest.php b/packages/php-sdk/src/Types/Requests/Quote/ListQuotesRequest.php index d38da079..16e7ced6 100644 --- a/packages/php-sdk/src/Types/Requests/Quote/ListQuotesRequest.php +++ b/packages/php-sdk/src/Types/Requests/Quote/ListQuotesRequest.php @@ -5,12 +5,21 @@ namespace TurboDocx\Types\Requests\Quote; /** - * Request for listing quotes with pagination and filters + * Request for listing quotes with pagination, filters and sorting + * + * Multi-value filters take one value or several (sent as repeated query params). + * `statuses` also takes "expired": a draft or sent quote past its Valid until day (UTC), or + * whose signature expired. `companyId` / `currency` are the original single filters; they merge + * with `companyIds` / `currencies`. */ final class ListQuotesRequest { /** * @param string|string[]|null $statuses + * @param string|string[]|null $currency + * @param string|string[]|null $companyIds + * @param string|string[]|null $currencies + * @param string|string[]|null $createdBy */ public function __construct( public readonly ?int $limit = null, @@ -19,7 +28,24 @@ public function __construct( public readonly string|array|null $statuses = null, public readonly ?string $companyId = null, public readonly ?string $contactId = null, - public readonly ?string $currency = null, + public readonly string|array|null $currency = null, + public readonly string|array|null $companyIds = null, + public readonly string|array|null $currencies = null, + // User ids of the quotes' creators (see TurboQuote::listQuoteCreators) + public readonly string|array|null $createdBy = null, + // Quotes created by any API key (ORed with createdBy) + public readonly ?bool $createdByApiKeys = null, + // 'thisWeek' | 'thisMonth' | 'past' (UTC days) + public readonly ?string $validUntilWindow = null, + // Inclusive YYYY-MM-DD bounds on Valid until + public readonly ?string $validUntilFrom = null, + public readonly ?string $validUntilTo = null, + // 'updatedOn' | 'createdOn' | 'total' | 'validUntil' | 'quoteNumber' (default createdOn, newest first) + public readonly ?string $sortBy = null, + // 'asc' | 'desc' + public readonly ?string $sortDirection = null, + // Currency of the returned summary (default: the one filtered currency, else USD) + public readonly ?string $summaryCurrency = null, ) {} /** @@ -35,26 +61,23 @@ public function toQueryParams(): array if ($this->offset !== null) { $params['offset'] = (string) $this->offset; } - if ($this->query !== null) { - $params['query'] = $this->query; - } - if ($this->statuses !== null) { - if (is_array($this->statuses)) { - $params['statuses'] = $this->statuses; - } else { - $params['statuses'] = $this->statuses; - } - } - if ($this->companyId !== null) { - $params['companyId'] = $this->companyId; - } - if ($this->contactId !== null) { - $params['contactId'] = $this->contactId; - } - if ($this->currency !== null) { - $params['currency'] = $this->currency; - } - return $params; + return $params + QuoteSummaryRequest::filterParams([ + 'query' => $this->query, + 'statuses' => $this->statuses, + 'companyId' => $this->companyId, + 'companyIds' => $this->companyIds, + 'contactId' => $this->contactId, + 'currency' => $this->currency, + 'currencies' => $this->currencies, + 'createdBy' => $this->createdBy, + 'createdByApiKeys' => $this->createdByApiKeys, + 'validUntilWindow' => $this->validUntilWindow, + 'validUntilFrom' => $this->validUntilFrom, + 'validUntilTo' => $this->validUntilTo, + 'sortBy' => $this->sortBy, + 'sortDirection' => $this->sortDirection, + 'summaryCurrency' => $this->summaryCurrency, + ]); } } diff --git a/packages/php-sdk/src/Types/Requests/Quote/ListTypesRequest.php b/packages/php-sdk/src/Types/Requests/Quote/ListTypesRequest.php index d55e86ac..1bb94555 100644 --- a/packages/php-sdk/src/Types/Requests/Quote/ListTypesRequest.php +++ b/packages/php-sdk/src/Types/Requests/Quote/ListTypesRequest.php @@ -15,6 +15,10 @@ public function __construct( public readonly ?string $query = null, public readonly ?string $categoryType = null, public readonly ?bool $includeUsage = null, + // Sort column: 'name' | 'usageCount' (usage needs a categoryType; default name, A to Z) + public readonly ?string $sortBy = null, + // 'asc' | 'desc' + public readonly ?string $sortDirection = null, ) {} /** @@ -40,6 +44,13 @@ public function toQueryParams(): array $params['includeUsage'] = $this->includeUsage ? 'true' : 'false'; } + if ($this->sortBy !== null) { + $params['sortBy'] = $this->sortBy; + } + if ($this->sortDirection !== null) { + $params['sortDirection'] = $this->sortDirection; + } + return $params; } } diff --git a/packages/php-sdk/src/Types/Requests/Quote/QuoteSummaryRequest.php b/packages/php-sdk/src/Types/Requests/Quote/QuoteSummaryRequest.php new file mode 100644 index 00000000..ac847e3d --- /dev/null +++ b/packages/php-sdk/src/Types/Requests/Quote/QuoteSummaryRequest.php @@ -0,0 +1,73 @@ + + */ + public function toQueryParams(): array + { + return self::filterParams([ + 'query' => $this->query, + 'companyId' => $this->companyId, + 'companyIds' => $this->companyIds, + 'contactId' => $this->contactId, + 'currency' => $this->currency, + 'currencies' => $this->currencies, + 'createdBy' => $this->createdBy, + 'createdByApiKeys' => $this->createdByApiKeys, + 'validUntilWindow' => $this->validUntilWindow, + 'validUntilFrom' => $this->validUntilFrom, + 'validUntilTo' => $this->validUntilTo, + 'summaryCurrency' => $this->summaryCurrency, + ]); + } + + /** + * Query params from the set filters: null values are left out, booleans become + * "true" / "false", arrays stay arrays (repeated params). + * + * @param array $filters + * @return array + */ + public static function filterParams(array $filters): array + { + $params = []; + foreach ($filters as $key => $value) { + if ($value === null) { + continue; + } + $params[$key] = is_bool($value) ? ($value ? 'true' : 'false') : $value; + } + return $params; + } +} diff --git a/packages/php-sdk/src/Types/Requests/Quote/UpdateCompanyRequest.php b/packages/php-sdk/src/Types/Requests/Quote/UpdateCompanyRequest.php index e44d95d9..34e1c5d8 100644 --- a/packages/php-sdk/src/Types/Requests/Quote/UpdateCompanyRequest.php +++ b/packages/php-sdk/src/Types/Requests/Quote/UpdateCompanyRequest.php @@ -7,9 +7,9 @@ /** * Request for updating a company * - * `industryId` supports null-clear semantics: pass `null` + `includeIndustryId: true` - * to explicitly clear the field on the server. Omitting `industryId` (or not setting - * `includeIndustryId`) leaves the server value unchanged. + * `industryId` and `email` support null-clear semantics: pass `null` + `includeIndustryId: true` + * (or `includeEmail: true`) to explicitly clear the field on the server. Omitting the field (or + * not setting its include flag) leaves the server value unchanged. */ final class UpdateCompanyRequest { @@ -23,6 +23,8 @@ public function __construct( // explicitly send null (clears the value on the server). public readonly ?string $industryId = null, public readonly bool $includeIndustryId = false, + public readonly ?string $email = null, + public readonly bool $includeEmail = false, ) {} /** @@ -35,6 +37,9 @@ public function toArray(): array if ($this->name !== null) { $data['name'] = $this->name; } + if ($this->includeEmail || $this->email !== null) { + $data['email'] = $this->email; + } if ($this->phone !== null) { $data['phone'] = $this->phone; } diff --git a/packages/php-sdk/src/Types/Requests/Quote/UpdatePriceBookRequest.php b/packages/php-sdk/src/Types/Requests/Quote/UpdatePriceBookRequest.php index e52965cf..6de68466 100644 --- a/packages/php-sdk/src/Types/Requests/Quote/UpdatePriceBookRequest.php +++ b/packages/php-sdk/src/Types/Requests/Quote/UpdatePriceBookRequest.php @@ -29,6 +29,8 @@ public function __construct( public readonly ?bool $showInQuoteBuilder = null, public readonly ?array $productPricing = null, public readonly bool $includeValidTo = false, + // Put every product back on the book's discount (before any productPricing in the request) + public readonly ?bool $resetProductPricing = null, ) {} /** @@ -67,6 +69,9 @@ public function toArray(): array if ($this->productPricing !== null) { $data['productPricing'] = $this->productPricing; } + if ($this->resetProductPricing !== null) { + $data['resetProductPricing'] = $this->resetProductPricing; + } return $data; } diff --git a/packages/php-sdk/src/Types/Responses/Quote/ContactListResponse.php b/packages/php-sdk/src/Types/Responses/Quote/ContactListResponse.php index 32a6c6df..ae8d3165 100644 --- a/packages/php-sdk/src/Types/Responses/Quote/ContactListResponse.php +++ b/packages/php-sdk/src/Types/Responses/Quote/ContactListResponse.php @@ -4,6 +4,7 @@ namespace TurboDocx\Types\Responses\Quote; +use TurboDocx\Types\Quote\Company; use TurboDocx\Types\Quote\Contact; /** @@ -17,6 +18,9 @@ final class ContactListResponse implements \JsonSerializable public function __construct( public readonly array $results, public readonly int $totalRecords, + // The company, when the list is for one company. Its contactCount is the company's active + // contacts whatever the search (a company can't lose its last contact). + public readonly ?Company $company = null, ) {} /** @@ -31,6 +35,7 @@ public static function fromArray(array $data): self $data['results'] ?? [] ), totalRecords: (int) ($data['totalRecords'] ?? 0), + company: isset($data['company']) && is_array($data['company']) ? Company::fromArray($data['company']) : null, ); } @@ -39,10 +44,14 @@ public static function fromArray(array $data): self */ public function toArray(): array { - return [ + $data = [ 'results' => array_map(fn(Contact $c) => $c->toArray(), $this->results), 'totalRecords' => $this->totalRecords, ]; + if ($this->company !== null) { + $data['company'] = $this->company->toArray(); + } + return $data; } /** diff --git a/packages/php-sdk/src/Types/Responses/Quote/OpenQuotesResponse.php b/packages/php-sdk/src/Types/Responses/Quote/OpenQuotesResponse.php new file mode 100644 index 00000000..0f952875 --- /dev/null +++ b/packages/php-sdk/src/Types/Responses/Quote/OpenQuotesResponse.php @@ -0,0 +1,55 @@ + $results + */ + public function __construct( + public readonly array $results, + public readonly int $totalRecords, + ) {} + + /** + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + results: array_map( + fn(array $q) => OpenQuoteSummary::fromArray($q), + $data['results'] ?? [] + ), + totalRecords: (int) ($data['totalRecords'] ?? 0), + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'results' => array_map(fn(OpenQuoteSummary $q) => $q->toArray(), $this->results), + 'totalRecords' => $this->totalRecords, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/packages/php-sdk/src/Types/Responses/Quote/QuoteListResponse.php b/packages/php-sdk/src/Types/Responses/Quote/QuoteListResponse.php index 275a8c8c..cd0b5e25 100644 --- a/packages/php-sdk/src/Types/Responses/Quote/QuoteListResponse.php +++ b/packages/php-sdk/src/Types/Responses/Quote/QuoteListResponse.php @@ -6,6 +6,8 @@ use TurboDocx\Types\Quote\Quote; use TurboDocx\Types\Quote\QuoteListStats; +use TurboDocx\Types\Quote\QuoteStatusCounts; +use TurboDocx\Types\Quote\QuoteSummary; /** * Response from listing quotes @@ -19,6 +21,10 @@ public function __construct( public readonly array $results, public readonly int $totalRecords, public readonly ?QuoteListStats $stats = null, + // Quotes per list tab (every filter except the status filter) + public readonly ?QuoteStatusCounts $statusCounts = null, + // The list's summary cards, in one currency + public readonly ?QuoteSummary $summary = null, ) {} /** @@ -34,6 +40,8 @@ public static function fromArray(array $data): self ), totalRecords: (int) ($data['totalRecords'] ?? 0), stats: isset($data['stats']) ? QuoteListStats::fromArray($data['stats']) : null, + statusCounts: isset($data['statusCounts']) ? QuoteStatusCounts::fromArray($data['statusCounts']) : null, + summary: isset($data['summary']) ? QuoteSummary::fromArray($data['summary']) : null, ); } @@ -50,6 +58,12 @@ public function toArray(): array if ($this->stats !== null) { $data['stats'] = $this->stats->toArray(); } + if ($this->statusCounts !== null) { + $data['statusCounts'] = $this->statusCounts->toArray(); + } + if ($this->summary !== null) { + $data['summary'] = $this->summary->toArray(); + } return $data; } diff --git a/packages/php-sdk/src/Utils/FileTypeDetector.php b/packages/php-sdk/src/Utils/FileTypeDetector.php index 2798a5f4..711583ae 100644 --- a/packages/php-sdk/src/Utils/FileTypeDetector.php +++ b/packages/php-sdk/src/Utils/FileTypeDetector.php @@ -53,6 +53,22 @@ public static function detect(string $content): array ]; } + // JPEG: FF D8 FF + if (str_starts_with($content, "\xFF\xD8\xFF")) { + return [ + 'mimetype' => 'image/jpeg', + 'extension' => 'jpg', + ]; + } + + // PNG: 89 50 4E 47 + if (str_starts_with($content, "\x89PNG")) { + return [ + 'mimetype' => 'image/png', + 'extension' => 'png', + ]; + } + // Unknown file type return [ 'mimetype' => 'application/octet-stream', diff --git a/packages/php-sdk/tests/Unit/HttpClientPutTest.php b/packages/php-sdk/tests/Unit/HttpClientPutTest.php new file mode 100644 index 00000000..c139a335 --- /dev/null +++ b/packages/php-sdk/tests/Unit/HttpClientPutTest.php @@ -0,0 +1,72 @@ +> */ + private array $history = []; + + private function clientWith(Response $response): HttpClient + { + $stack = HandlerStack::create(new MockHandler([$response])); + $stack->push(Middleware::history($this->history)); + + $httpClient = new HttpClient(new QuoteClientConfig(apiKey: 'test-key')); + $prop = (new ReflectionClass($httpClient))->getProperty('client'); + $prop->setValue($httpClient, new Client(['handler' => $stack])); + return $httpClient; + } + + public function testPutSendsJsonAndUnwrapsData(): void + { + $client = $this->clientWith(new Response(200, ['Content-Type' => 'application/json'], json_encode( + ['data' => ['results' => [['id' => 'img-2']], 'message' => 'Image order saved']], + JSON_THROW_ON_ERROR + ))); + + $result = $client->put('/v1/quotes/q-1/items/li-1/images/order', ['imageIds' => ['img-2']]); + + $request = $this->history[0]['request']; + $this->assertSame('PUT', $request->getMethod()); + $this->assertStringEndsWith('/v1/quotes/q-1/items/li-1/images/order', (string) $request->getUri()); + $this->assertSame(['imageIds' => ['img-2']], json_decode((string) $request->getBody(), true)); + $this->assertSame([['id' => 'img-2']], $result['results']); + } + + public function testRefusedQuoteDeleteIsAConflictWithItsCode(): void + { + $message = "A sent quote can't be deleted. Void it instead."; + $client = $this->clientWith(new Response(409, ['Content-Type' => 'application/json'], json_encode([ + 'message' => $message, + 'error' => ['message' => $message, 'code' => 'CANNOT_DELETE_SENT_QUOTE'], + 'data' => [], + ], JSON_THROW_ON_ERROR))); + + try { + $client->delete('/v1/quotes/q-1'); + $this->fail('Expected a ConflictException'); + } catch (ConflictException $e) { + $this->assertSame(409, $e->statusCode); + $this->assertSame('CANNOT_DELETE_SENT_QUOTE', $e->errorCode); + $this->assertSame($message, $e->getMessage()); + } + } +} diff --git a/packages/php-sdk/tests/Unit/TurboQuoteListsAndImagesTest.php b/packages/php-sdk/tests/Unit/TurboQuoteListsAndImagesTest.php new file mode 100644 index 00000000..87e4d8f9 --- /dev/null +++ b/packages/php-sdk/tests/Unit/TurboQuoteListsAndImagesTest.php @@ -0,0 +1,448 @@ +mockClient = new MockHttpClient(); + $ref = new \ReflectionClass(TurboQuote::class); + $ref->getProperty('client')->setValue(null, $this->mockClient); + } + + // ============================================ + // SORTING AND FILTERS + // ============================================ + + public function testListQuotesSendsSortNewFiltersAndSummaryCurrency(): void + { + $this->mockClient->setGetReturn(['results' => [], 'totalRecords' => 0]); + + TurboQuote::listQuotes(new ListQuotesRequest( + statuses: ['sent', 'expired'], + companyIds: ['c-1', 'c-2'], + currencies: ['USD', 'EUR'], + createdBy: ['u-1'], + createdByApiKeys: true, + validUntilWindow: 'thisMonth', + validUntilFrom: '2026-10-01', + validUntilTo: '2026-10-31', + sortBy: 'total', + sortDirection: 'asc', + summaryCurrency: 'EUR', + )); + + $this->assertSame('/v1/quotes', $this->mockClient->lastGetPath); + $this->assertSame([ + 'statuses' => ['sent', 'expired'], + 'companyIds' => ['c-1', 'c-2'], + 'currencies' => ['USD', 'EUR'], + 'createdBy' => ['u-1'], + 'createdByApiKeys' => 'true', + 'validUntilWindow' => 'thisMonth', + 'validUntilFrom' => '2026-10-01', + 'validUntilTo' => '2026-10-31', + 'sortBy' => 'total', + 'sortDirection' => 'asc', + 'summaryCurrency' => 'EUR', + ], $this->mockClient->lastGetParams); + } + + public function testListQuotesStillTakesOneCurrency(): void + { + $this->mockClient->setGetReturn(['results' => [], 'totalRecords' => 0]); + + TurboQuote::listQuotes(new ListQuotesRequest(companyId: 'c-1', currency: 'GBP')); + + $this->assertSame(['companyId' => 'c-1', 'currency' => 'GBP'], $this->mockClient->lastGetParams); + } + + public function testListQuotesReturnsCountsSummaryAndExpiry(): void + { + $this->mockClient->setGetReturn([ + 'results' => [['id' => 'q-1', 'status' => 'sent', 'isExpired' => true, 'isSignatureExpired' => false, 'lineItemCount' => 3]], + 'totalRecords' => 1, + 'statusCounts' => ['all' => 1, 'sent' => 1, 'expired' => 1], + 'summary' => [ + 'currency' => 'USD', + 'openPipeline' => ['total' => 1200.5, 'count' => 2], + 'winRate' => ['percent' => null, 'accepted' => 0, 'declined' => 0, 'windowDays' => 90], + ], + ]); + + $result = TurboQuote::listQuotes(); + + $this->assertNotNull($result->statusCounts); + $this->assertSame(1, $result->statusCounts->expired); + $this->assertSame(0, $result->statusCounts->draft); + $this->assertInstanceOf(QuoteSummary::class, $result->summary); + $this->assertNull($result->summary->winRate['percent']); + $this->assertSame(1200.5, $result->summary->openPipeline['total']); + $this->assertTrue($result->results[0]->isExpired); + $this->assertFalse($result->results[0]->isSignatureExpired); + $this->assertSame(3, $result->results[0]->lineItemCount); + } + + public function testListProductsSendsSeveralValuesAndSort(): void + { + $this->mockClient->setGetReturn(['results' => [], 'totalRecords' => 0]); + + TurboQuote::listProducts(new ListProductsRequest( + billingFrequency: ['monthly', 'annual'], + currency: ['USD', 'INR'], + sortBy: 'listPrice', + sortDirection: 'desc', + )); + + $this->assertSame([ + 'billingFrequency' => ['monthly', 'annual'], + 'currency' => ['USD', 'INR'], + 'sortBy' => 'listPrice', + 'sortDirection' => 'desc', + ], $this->mockClient->lastGetParams); + } + + public function testListPriceBooksSendsSort(): void + { + $this->mockClient->setGetReturn(['results' => [['id' => 'pb-1', 'overrideCount' => 2]], 'totalRecords' => 1]); + + $result = TurboQuote::listPriceBooks(new ListPriceBooksRequest(sortBy: 'overrideCount', sortDirection: 'desc')); + + $this->assertSame(['sortBy' => 'overrideCount', 'sortDirection' => 'desc'], $this->mockClient->lastGetParams); + $this->assertSame(2, $result->results[0]->overrideCount); + } + + public function testListBundlesSendsSeveralCurrenciesAndSort(): void + { + $this->mockClient->setGetReturn([ + 'results' => [['id' => 'b-1', 'itemCount' => 2, 'billingTotals' => ['monthly' => 100, 'one-time' => 500]]], + 'totalRecords' => 1, + ]); + + $result = TurboQuote::listBundles(new ListBundlesRequest(currency: ['USD', 'EUR'], sortBy: 'itemCount', sortDirection: 'asc')); + + $this->assertSame(['currency' => ['USD', 'EUR'], 'sortBy' => 'itemCount', 'sortDirection' => 'asc'], $this->mockClient->lastGetParams); + $this->assertSame(2, $result->results[0]->itemCount); + $this->assertSame(['monthly' => 100.0, 'one-time' => 500.0], $result->results[0]->billingTotals); + } + + public function testListCompaniesSendsSort(): void + { + $this->mockClient->setGetReturn(['results' => [['id' => 'c-1', 'email' => 'ap@acme.test']], 'totalRecords' => 1]); + + $result = TurboQuote::listCompanies(new ListCompaniesRequest(sortBy: 'contactCount', sortDirection: 'desc')); + + $this->assertSame(['sortBy' => 'contactCount', 'sortDirection' => 'desc'], $this->mockClient->lastGetParams); + $this->assertSame('ap@acme.test', $result->results[0]->email); + } + + public function testListContactsSendsSeveralCompaniesAndSort(): void + { + $this->mockClient->setGetReturn(['results' => [], 'totalRecords' => 0]); + + TurboQuote::listContacts(new ListContactsRequest(companyIds: ['c-1', 'c-2'], sortBy: 'name', sortDirection: 'asc')); + + $this->assertSame(['companyIds' => ['c-1', 'c-2'], 'sortBy' => 'name', 'sortDirection' => 'asc'], $this->mockClient->lastGetParams); + } + + public function testListCompanyContactsSendsSortAndReturnsContactCount(): void + { + $this->mockClient->setGetReturn([ + 'results' => [], 'totalRecords' => 0, 'company' => ['id' => 'c-1', 'name' => 'Acme', 'contactCount' => 1], + ]); + + $result = TurboQuote::listCompanyContacts('c-1', new ListContactsRequest(sortBy: 'createdOn', sortDirection: 'desc')); + + $this->assertSame('/v1/companies/c-1/contacts', $this->mockClient->lastGetPath); + $this->assertSame(['sortBy' => 'createdOn', 'sortDirection' => 'desc'], $this->mockClient->lastGetParams); + $this->assertNotNull($result->company); + $this->assertSame(1, $result->company->contactCount); + } + + public function testListTypesSendsSort(): void + { + $this->mockClient->setGetReturn(['results' => [], 'totalRecords' => 0]); + + TurboQuote::listTypes(new ListTypesRequest(categoryType: 'product_category', sortBy: 'usageCount', sortDirection: 'desc')); + + $this->assertSame( + ['categoryType' => 'product_category', 'sortBy' => 'usageCount', 'sortDirection' => 'desc'], + $this->mockClient->lastGetParams + ); + } + + // ============================================ + // QUOTE READS + // ============================================ + + public function testGetQuoteSummary(): void + { + $this->mockClient->setGetReturn(['summary' => ['currency' => 'EUR', 'openPipeline' => ['total' => 10, 'count' => 1]]]); + + $result = TurboQuote::getQuoteSummary(new QuoteSummaryRequest(currencies: ['EUR', 'USD'], summaryCurrency: 'EUR')); + + $this->assertSame('/v1/quotes/summary', $this->mockClient->lastGetPath); + $this->assertSame(['currencies' => ['EUR', 'USD'], 'summaryCurrency' => 'EUR'], $this->mockClient->lastGetParams); + $this->assertSame('EUR', $result->currency); + $this->assertSame(1, $result->openPipeline['count']); + } + + public function testGetQuoteSummaryWithoutOptionsSendsNoParams(): void + { + $this->mockClient->setGetReturn(['summary' => ['currency' => 'USD']]); + + TurboQuote::getQuoteSummary(); + + $this->assertSame([], $this->mockClient->lastGetParams); + } + + public function testListQuoteCreators(): void + { + $this->mockClient->setGetReturn(['results' => [ + ['id' => 'u-1', 'name' => 'Ana Lee', 'email' => 'ana@acme.test', 'isAPIKey' => false, 'apiKeyDeleted' => false], + ['id' => 'u-2', 'name' => 'Zapier key', 'email' => null, 'isAPIKey' => true, 'apiKeyDeleted' => true], + ]]); + + $result = TurboQuote::listQuoteCreators(); + + $this->assertSame('/v1/quotes/creators', $this->mockClient->lastGetPath); + $this->assertCount(2, $result); + $this->assertInstanceOf(QuoteCreator::class, $result[0]); + $this->assertSame('Ana Lee', $result[0]->name); + $this->assertNull($result[1]->email); + $this->assertTrue($result[1]->isAPIKey); + $this->assertTrue($result[1]->apiKeyDeleted); + } + + public function testGetNextQuoteNumber(): void + { + $this->mockClient->setGetReturn(['results' => ['nextNumber' => 'Q-2026-0042', 'sequence' => 42, 'canEdit' => true]]); + + $result = TurboQuote::getNextQuoteNumber(); + + $this->assertSame('/v1/quotes/number-config/next', $this->mockClient->lastGetPath); + $this->assertInstanceOf(NextQuoteNumber::class, $result); + $this->assertSame('Q-2026-0042', $result->nextNumber); + $this->assertSame(42, $result->sequence); + $this->assertTrue($result->canEdit); + } + + // ============================================ + // OPEN QUOTES + // ============================================ + + public function testGetCompanyOpenQuotes(): void + { + $this->mockClient->setGetReturn([ + 'results' => [['id' => 'q-1', 'quoteNumber' => 'Q-1', 'name' => 'Renewal', 'status' => 'sent', 'updatedOn' => '2026-10-01']], + 'totalRecords' => 1, + ]); + + $result = TurboQuote::getCompanyOpenQuotes('c-1'); + + $this->assertSame('/v1/companies/c-1/open-quotes', $this->mockClient->lastGetPath); + $this->assertInstanceOf(OpenQuotesResponse::class, $result); + $this->assertSame(1, $result->totalRecords); + $this->assertSame('Q-1', $result->results[0]->quoteNumber); + $this->assertSame('sent', $result->results[0]->status); + } + + public function testGetContactOpenQuotes(): void + { + $this->mockClient->setGetReturn(['results' => [], 'totalRecords' => 0]); + + $result = TurboQuote::getContactOpenQuotes('ct-1'); + + $this->assertSame('/v1/contacts/ct-1/open-quotes', $this->mockClient->lastGetPath); + $this->assertSame(0, $result->totalRecords); + } + + // ============================================ + // IMAGES + // ============================================ + + public function testGetProductQuoteImages(): void + { + $images = ['p-1' => [['id' => 'i-1', 'productId' => 'p-1', 'displayOrder' => 0]], 'p-2' => []]; + $this->mockClient->setPostReturn(['results' => $images]); + + $result = TurboQuote::getProductQuoteImages(['p-1', 'p-2']); + + $this->assertSame('/v1/products/quote-images', $this->mockClient->lastPostPath); + $this->assertSame(['productIds' => ['p-1', 'p-2']], $this->mockClient->lastPostData); + $this->assertSame($images, $result); + } + + public function testGetQuoteLineItemImages(): void + { + $this->mockClient->setGetReturn(['results' => ['li-1' => [['id' => 'img-1', 'lineItemId' => 'li-1', 'displayOrder' => 0]]]]); + + $result = TurboQuote::getQuoteLineItemImages('q-1'); + + $this->assertSame('/v1/quotes/q-1/items/images', $this->mockClient->lastGetPath); + $this->assertInstanceOf(LineItemImage::class, $result['li-1'][0]); + $this->assertSame('img-1', $result['li-1'][0]->id); + } + + public function testGetLineItemImages(): void + { + $this->mockClient->setGetReturn(['results' => [['id' => 'img-1', 'lineItemId' => 'li-1', 'displayOrder' => 0]]]); + + $result = TurboQuote::getLineItemImages('q-1', 'li-1'); + + $this->assertSame('/v1/quotes/q-1/items/li-1/images', $this->mockClient->lastGetPath); + $this->assertSame('img-1', $result[0]->id); + } + + public function testAddLineItemImagesUploadsImagesPartsWithDetectedTypes(): void + { + $this->mockClient->setPostFormDataReturn(['results' => [['id' => 'img-1'], ['id' => 'img-2']], 'message' => 'Images added']); + + $result = TurboQuote::addLineItemImages('q-1', 'li-1', [self::PNG, self::JPEG]); + + $this->assertSame('/v1/quotes/q-1/items/li-1/images', $this->mockClient->lastPostFormDataPath); + $parts = $this->mockClient->lastPostFormDataMultipart; + $this->assertSame(['images', 'images'], array_column($parts, 'name')); + $this->assertSame('image/png', $parts[0]['headers']['Content-Type']); + $this->assertSame('image.png', $parts[0]['filename']); + $this->assertSame('image/jpeg', $parts[1]['headers']['Content-Type']); + $this->assertSame(self::PNG, $parts[0]['contents']); + $this->assertCount(2, $result); + $this->assertSame('img-2', $result[1]->id); + } + + public function testReorderLineItemImages(): void + { + $this->mockClient->setPutReturn(['results' => [['id' => 'img-2'], ['id' => 'img-1']]]); + + $result = TurboQuote::reorderLineItemImages('q-1', 'li-1', ['img-2', 'img-1']); + + $this->assertSame('/v1/quotes/q-1/items/li-1/images/order', $this->mockClient->lastPutPath); + $this->assertSame(['imageIds' => ['img-2', 'img-1']], $this->mockClient->lastPutData); + $this->assertSame('img-2', $result[0]->id); + } + + public function testRemoveLineItemImage(): void + { + $this->mockClient->setDeleteReturn(['results' => [['id' => 'img-2']], 'message' => 'Image removed']); + + $result = TurboQuote::removeLineItemImage('q-1', 'li-1', 'img-1'); + + $this->assertSame('/v1/quotes/q-1/items/li-1/images/img-1', $this->mockClient->lastDeletePath); + $this->assertSame('img-2', $result[0]->id); + } + + // ============================================ + // NAMED DUPLICATES + // ============================================ + + /** + * @return array + */ + public static function duplicateProvider(): array + { + return [ + 'quote' => ['duplicateQuote', '/v1/quotes/x-1/duplicate'], + 'product' => ['duplicateProduct', '/v1/products/x-1/duplicate'], + 'price book' => ['duplicatePriceBook', '/v1/pricebooks/x-1/duplicate'], + 'bundle' => ['duplicateBundle', '/v1/bundles/x-1/duplicate'], + ]; + } + + /** + * @dataProvider duplicateProvider + */ + public function testDuplicateSendsTheNameForTheCopy(string $method, string $path): void + { + $this->mockClient->setPostReturn(['result' => ['id' => 'x-2', 'name' => 'Q4 copy']]); + + $result = TurboQuote::$method('x-1', 'Q4 copy'); + + $this->assertSame($path, $this->mockClient->lastPostPath); + $this->assertSame(['name' => 'Q4 copy'], $this->mockClient->lastPostData); + $this->assertSame('x-2', $result->id); + } + + /** + * @dataProvider duplicateProvider + */ + public function testDuplicateWithoutANameSendsNoBody(string $method, string $path): void + { + $this->mockClient->setPostReturn(['result' => ['id' => 'x-2']]); + + TurboQuote::$method('x-1'); + + $this->assertSame($path, $this->mockClient->lastPostPath); + $this->assertNull($this->mockClient->lastPostData); + } + + // ============================================ + // REQUEST FIELDS + // ============================================ + + public function testCreateCompanySendsTheEmail(): void + { + $this->mockClient->setPostReturn(['result' => ['id' => 'c-1']]); + + TurboQuote::createCompany(new CreateCompanyRequest( + name: 'Acme', + contacts: [['name' => 'Ana', 'email' => 'ana@acme.test']], + email: 'ap@acme.test', + )); + + $this->assertSame('ap@acme.test', $this->mockClient->lastPostData['email']); + } + + public function testUpdateCompanySendsAnExplicitNullEmail(): void + { + $this->mockClient->setPatchReturn(['result' => ['id' => 'c-1']]); + + TurboQuote::updateCompany('c-1', new UpdateCompanyRequest(email: null, includeEmail: true)); + + $this->assertSame(['email' => null], $this->mockClient->lastPatchData); + } + + public function testUpdatePriceBookSendsResetProductPricing(): void + { + $this->mockClient->setPatchReturn(['result' => ['id' => 'pb-1']]); + + TurboQuote::updatePriceBook('pb-1', new UpdatePriceBookRequest(resetProductPricing: true)); + + $this->assertSame(['resetProductPricing' => true], $this->mockClient->lastPatchData); + } +} diff --git a/packages/php-sdk/tests/Unit/TurboQuoteTest.php b/packages/php-sdk/tests/Unit/TurboQuoteTest.php index 12024043..a03faf2f 100644 --- a/packages/php-sdk/tests/Unit/TurboQuoteTest.php +++ b/packages/php-sdk/tests/Unit/TurboQuoteTest.php @@ -1693,22 +1693,15 @@ public function testCreateAndSendWithBundleItems(): void // ENUMS // ============================================ - public function testQuoteStatusPendingApprovalExists(): void - { - $status = QuoteStatus::PENDING_APPROVAL; - $this->assertSame('pending_approval', $status->value); - } - public function testQuoteStatusEnumHasAllValues(): void { $cases = array_map(fn(QuoteStatus $s) => $s->value, QuoteStatus::cases()); $this->assertContains('draft', $cases); - $this->assertContains('pending_approval', $cases); $this->assertContains('sent', $cases); $this->assertContains('accepted', $cases); $this->assertContains('declined', $cases); $this->assertContains('voided', $cases); - $this->assertCount(6, $cases); + $this->assertCount(5, $cases); } public function testBundleItemStatusEnumValues(): void @@ -2204,6 +2197,8 @@ public function __construct() public ?string $lastPatchPath = null; public mixed $lastPatchData = null; public ?string $lastDeletePath = null; + public ?string $lastPutPath = null; + public mixed $lastPutData = null; public ?string $lastGetRawPath = null; public ?string $lastPostFormDataPath = null; /** @var array> */ @@ -2220,6 +2215,7 @@ public function __construct() private mixed $postReturn = null; private mixed $patchReturn = null; private mixed $deleteReturn = null; + private mixed $putReturn = null; private string $getRawReturn = ''; private mixed $postFormDataReturn = null; private mixed $patchFormDataReturn = null; @@ -2258,6 +2254,11 @@ public function setDeleteReturn(mixed $return): void $this->deleteReturn = $return; } + public function setPutReturn(mixed $return): void + { + $this->putReturn = $return; + } + public function setGetRawReturn(string $return): void { $this->getRawReturn = $return; @@ -2324,6 +2325,13 @@ public function delete(string $path): mixed return $this->deleteReturn; } + public function put(string $path, ?array $data = null): mixed + { + $this->lastPutPath = $path; + $this->lastPutData = $data; + return $this->putReturn; + } + public function getRaw(string $path): string { $this->lastGetRawPath = $path; diff --git a/packages/py-sdk/README.md b/packages/py-sdk/README.md index 45c9f643..e2417570 100644 --- a/packages/py-sdk/README.md +++ b/packages/py-sdk/README.md @@ -829,6 +829,64 @@ with open("quote.pdf", "wb") as f: f.write(pdf_bytes) ``` +#### Sorting, filters and the list summary + +Every list takes `sortBy` / `sortDirection` (`asc` | `desc`); leave them out for the order the +list always had. Sort columns: quotes `updatedOn` · `createdOn` · `total` · `validUntil` · `quoteNumber`; +products `name` · `listPrice` · `createdOn`; price books `name` · `discountPercent` · `overrideCount` · +`validFrom` · `validTo` · `createdOn`; bundles `name` · `itemCount` · `totalListPrice` · `totalFinalPrice` · +`createdOn`; companies `name` · `contactCount` · `lastActivityDate`; contacts `name` · `createdOn`; +types `name` · `usageCount`. + +The quote list also filters by several companies (`companyIds`) and currencies (`currencies`), by +`expired` in `statuses` (a draft or sent quote past its Valid until day, UTC, or whose signature +expired), by creator (`createdBy`, ids from the creators call; `createdByApiKeys` for API-key quotes) +and by Valid until (`validUntilWindow` = `thisWeek` | `thisMonth` | `past`, `validUntilFrom` / +`validUntilTo`). Each quote carries `isExpired` and `lineItemCount`; the response adds +`statusCounts` (per tab) and `summary` (pipeline, win rate, monthly recurring, in `summaryCurrency`). + +```python +page = await TurboQuote.list_quotes({ + "statuses": ["sent", "expired"], + "currencies": ["USD", "EUR"], + "validUntilWindow": "thisMonth", + "sortBy": "validUntil", + "sortDirection": "asc", + "summaryCurrency": "USD", +}) +print(page["statusCounts"]["expired"], page["summary"]["winRate"]["percent"]) + +creators = await TurboQuote.list_quote_creators() # ids for createdBy +summary = await TurboQuote.get_quote_summary({"createdBy": [creators[0]["id"]]}) +next_number = await TurboQuote.get_next_quote_number() # read only + +products = await TurboQuote.list_products({"billingFrequency": ["monthly", "annual"], "sortBy": "listPrice"}) +copy = await TurboQuote.duplicate_quote(quote["id"], name="Q4 renewal") # name is optional +``` + +`duplicate_quote`, `duplicate_product`, `duplicate_price_book` and `duplicate_bundle` take an optional +`name=` (otherwise "Copy of …"). Companies take an optional `"email"`; `update_price_book(id, {"resetProductPricing": True})` +puts every product back on the book's discount. + +#### Custom line item images + +A custom line item (one with no catalog product) can show up to three images on the quote (PNG or +JPEG, 2 MB each; draft quotes only). Catalog products use `get_product_quote_images(product_ids)`. + +```python +images = await TurboQuote.add_line_item_images(quote["id"], item_id, ["./diagram.png"]) # paths, bytes or files +await TurboQuote.reorder_line_item_images(quote["id"], item_id, [i["id"] for i in reversed(images)]) +await TurboQuote.remove_line_item_image(quote["id"], item_id, images[0]["id"]) +by_item = await TurboQuote.get_quote_line_item_images(quote["id"]) # {line_item_id: [image, ...]} +``` + +**Deletes the API refuses.** `delete_quote` on a **sent** quote is a 409 `ConflictError` with code +`CANNOT_DELETE_SENT_QUOTE` — void it instead; an accepted quote is a 409 `CANNOT_DELETE_ACCEPTED_QUOTE`. +`delete_company` is a 409 `CompanyHasOpenQuotes` while the company has open quotes (draft or +sent), and `delete_contact` is a 409 `LastContact` (the company's only contact) or `ContactHasOpenQuotes`. +`delete_price_book` on the org's default price book is a 400 `ValidationError` `PriceBookIsDefault`. Branch on +`err.code`; `get_company_open_quotes` / `get_contact_open_quotes` list the open quotes that block a delete. + #### Sender identity — "Prepared by" The quote's **"Prepared by"** name and email are resolved by the server, not by whoever @@ -857,15 +915,15 @@ print(prepared.get("email")) # may be absent for an API-created quote — rende | Group | Methods | |:------|:--------| -| **Quotes** | `list_quotes()`, `create_quote()`, `get_quote()`, `update_quote()`, `delete_quote()`, `duplicate_quote()` | +| **Quotes** | `list_quotes()`, `create_quote()`, `get_quote()`, `update_quote()`, `delete_quote()`, `duplicate_quote()`, `get_quote_summary()`, `list_quote_creators()` | | **Quote status** | `send_quote()`, `send_quote_with_deliverable()`, `decline_quote()`, `void_quote()`, `handle_expired_quote()` | | **Price book ops** | `apply_price_book()`, `remove_price_book()`, `download_quote_pdf()` | -| **Line items** | `list_line_items()`, `add_line_items()`, `add_bundle_line_items()`, `update_line_item()`, `remove_line_item()` | -| **Products** | `list_products()`, `create_product()`, `get_product()`, `update_product()`, `delete_product()`, `duplicate_product()`, `get_product_primary_images()` | +| **Line items** | `list_line_items()`, `add_line_items()`, `add_bundle_line_items()`, `update_line_item()`, `remove_line_item()`, `get_quote_line_item_images()`, `get_line_item_images()`, `add_line_item_images()`, `reorder_line_item_images()`, `remove_line_item_image()` | +| **Products** | `list_products()`, `create_product()`, `get_product()`, `update_product()`, `delete_product()`, `duplicate_product()`, `get_product_primary_images()`, `get_product_quote_images()` | | **Price books** | `list_price_books()`, `create_price_book()`, `get_price_book()`, `update_price_book()`, `delete_price_book()`, `duplicate_price_book()`, `list_price_book_products()` | | **Bundles** | `list_bundles()`, `create_bundle()`, `get_bundle()`, `update_bundle()`, `delete_bundle()`, `duplicate_bundle()` | -| **Companies** | `list_companies()`, `create_company()`, `get_company()`, `update_company()`, `delete_company()`, `list_company_contacts()` | -| **Contacts** | `list_contacts()`, `create_contact()`, `update_contact()`, `delete_contact()` | +| **Companies** | `list_companies()`, `create_company()`, `get_company()`, `update_company()`, `delete_company()`, `list_company_contacts()`, `get_company_open_quotes()` | +| **Contacts** | `list_contacts()`, `create_contact()`, `update_contact()`, `delete_contact()`, `get_contact_open_quotes()` | | **Templates** | `list_templates()`, `get_template()`, `get_template_by_id()`, `create_template()`, `update_template()`, `delete_template()` | | **Types** | `list_types()`, `create_type()`, `update_type()`, `delete_type()` | | **Convenience** | `create_and_send()` | diff --git a/packages/py-sdk/src/turbodocx_sdk/http.py b/packages/py-sdk/src/turbodocx_sdk/http.py index e858f913..e6cef4a5 100644 --- a/packages/py-sdk/src/turbodocx_sdk/http.py +++ b/packages/py-sdk/src/turbodocx_sdk/http.py @@ -423,6 +423,37 @@ async def patch(self, path: str, data: Optional[Dict[str, Any]] = None) -> Any: except Exception as e: raise NetworkError(f"Request failed: {str(e) or 'Unknown error'}") from e + async def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Any: + """ + Make PUT request to API + + Args: + path: API endpoint path + data: Request body data (will be sent as JSON) + + Returns: + Response data + """ + url = f"{self.base_url}{path}" + headers = self._get_headers() + + async with httpx.AsyncClient(timeout=120.0) as client: + try: + response = await client.put(url, headers=headers, json=data) + + if not response.is_success: + await self._handle_error_response(response) + + return normalize_response(self._smart_unwrap(response.json())) + except httpx.TimeoutException as e: + raise NetworkError(f"Request timed out after 120 seconds: {str(e) or 'Timeout'}") from e + except httpx.NetworkError as e: + raise NetworkError(f"Network request failed: {str(e) or 'Connection error'}") from e + except TurboDocxError: + raise + except Exception as e: + raise NetworkError(f"Request failed: {str(e) or 'Unknown error'}") from e + async def delete(self, path: str) -> Any: """ Make DELETE request to API diff --git a/packages/py-sdk/src/turbodocx_sdk/modules/quote.py b/packages/py-sdk/src/turbodocx_sdk/modules/quote.py index fd8bbe9f..3960de62 100644 --- a/packages/py-sdk/src/turbodocx_sdk/modules/quote.py +++ b/packages/py-sdk/src/turbodocx_sdk/modules/quote.py @@ -2,15 +2,15 @@ TurboQuote Module -- Quoting operations Provides quote lifecycle management: -- Quotes CRUD (list, create, get, update, delete, duplicate) +- Quotes CRUD (list, create, get, update, delete, duplicate, summary, creators) - Quote status transitions (send, decline, void, handle expired) -- Quote number config (get / update org-wide quote numbering format) -- Line items (list, add product items, add bundle items, update, remove) -- Products (list, create, get, update, delete, duplicate, primary images) +- Quote number config (get / update org-wide quote numbering format, next number) +- Line items (list, add product items, add bundle items, update, remove, custom item images) +- Products (list, create, get, update, delete, duplicate, primary images, quote images) - Price books (list, create, get, update, delete, duplicate, list products) - Bundles (list, create, get, update, delete, duplicate) -- Companies (list, create, get, update, delete, list contacts) -- Contacts (list, create, update, delete) +- Companies (list, create, get, update, delete, list contacts, open quotes) +- Contacts (list, create, update, delete, open quotes) - Templates (list, get, get by ID, create, update, delete) - Types / categories (list, create, update, delete) - Convenience (createAndSend) @@ -50,6 +50,29 @@ def _to_query_params(request: Optional[Dict[str, Any]] = None) -> Optional[Dict[ return params if params else None +def _image_files(images: List[Any]) -> List[Any]: + """ + One ("images", (name, bytes, mime)) tuple per image: a path is read, bytes get their + type from the magic bytes, a file-like object is read. + """ + files: List[Any] = [] + for image in images: + if isinstance(image, str): + with open(image, "rb") as f: + file_bytes = f.read() + file_name = os.path.basename(image) + mime_type, _ = detect_file_type(file_bytes) + files.append(("images", (file_name, file_bytes, mime_type))) + elif isinstance(image, bytes): + mime_type, ext = detect_file_type(image) + files.append(("images", (f"image.{ext}", image, mime_type))) + else: + file_bytes = image.read() if hasattr(image, "read") else image + mime_type, ext = detect_file_type(file_bytes if isinstance(file_bytes, bytes) else b"") + files.append(("images", (getattr(image, "name", f"image.{ext}"), file_bytes, mime_type))) + return files + + def _build_product_form_data(request: Dict[str, Any]): """ Build multipart form data for product create/update with images. @@ -65,22 +88,7 @@ def _build_product_form_data(request: Dict[str, Any]): data_json = json.dumps(data) data = {"data": data_json} - files: List[Any] = [] - if images: - for image in images: - if isinstance(image, str): - with open(image, "rb") as f: - file_bytes = f.read() - file_name = os.path.basename(image) - mime_type, _ = detect_file_type(file_bytes) - files.append(("images", (file_name, file_bytes, mime_type))) - elif isinstance(image, bytes): - mime_type, ext = detect_file_type(image) - files.append(("images", (f"image.{ext}", image, mime_type))) - else: - file_bytes = image.read() if hasattr(image, "read") else image - mime_type, ext = detect_file_type(file_bytes if isinstance(file_bytes, bytes) else b"") - files.append(("images", (getattr(image, "name", f"image.{ext}"), file_bytes, mime_type))) + files: List[Any] = _image_files(images) if images else [] return data, files @@ -144,6 +152,17 @@ def _unwrap(cls, response: Dict[str, Any]) -> Any: """ return response["result"] + @classmethod + async def _post_duplicate(cls, path: str, name: Optional[str]) -> Dict[str, Any]: + """ + Shared duplicate helper: sends the copy's name when given, else no body + (the API names the copy "Copy of ..."). + """ + client = cls._get_client() + if name is not None: + return cls._unwrap(await client.post(path, {"name": name})) + return cls._unwrap(await client.post(path)) + @classmethod async def _bulk_import(cls, path: str, rows: List[Dict[str, Any]]) -> Dict[str, Any]: """ @@ -194,17 +213,39 @@ async def update_quote(cls, id: str, request: Dict[str, Any]) -> Dict[str, Any]: client = cls._get_client() return cls._unwrap(await client.patch(f"/v1/quotes/{id}", request)) + @classmethod + async def get_quote_summary(cls, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + The quote list's summary cards (pipeline, win rate, monthly recurring...) for a set of + filters, in one currency (``summaryCurrency``). Takes the list's filters; the status + filter is ignored. + """ + client = cls._get_client() + response = await client.get("/v1/quotes/summary", _to_query_params(options)) + return response["summary"] + + @classmethod + async def list_quote_creators(cls) -> List[Dict[str, Any]]: + """The people and API keys who created the org's quotes -- the ids for the ``createdBy`` filter.""" + client = cls._get_client() + response = await client.get("/v1/quotes/creators") + return response["results"] + @classmethod async def delete_quote(cls, id: str) -> Dict[str, Any]: - """Delete a quote.""" + """ + Delete a quote. + + A sent quote is refused with a 409 (``CANNOT_DELETE_SENT_QUOTE``) -- void it instead -- + and so is an accepted one (``CANNOT_DELETE_ACCEPTED_QUOTE``). + """ client = cls._get_client() return await client.delete(f"/v1/quotes/{id}") @classmethod - async def duplicate_quote(cls, id: str) -> Dict[str, Any]: - """Duplicate a quote.""" - client = cls._get_client() - return cls._unwrap(await client.post(f"/v1/quotes/{id}/duplicate")) + async def duplicate_quote(cls, id: str, name: Optional[str] = None) -> Dict[str, Any]: + """Duplicate a quote. ``name`` names the copy; left out, it is "Copy of ".""" + return await cls._post_duplicate(f"/v1/quotes/{id}/duplicate", name) @classmethod async def apply_price_book(cls, quote_id: str, price_book_id: str) -> Dict[str, Any]: @@ -270,6 +311,17 @@ async def update_quote_number_config(cls, format: Dict[str, Any]) -> Dict[str, A response = await client.patch("/v1/quotes/number-config", format) return response["results"] + @classmethod + async def get_next_quote_number(cls) -> Dict[str, Any]: + """ + The number the next new quote will get: ``nextNumber``, ``sequence`` and ``canEdit`` + (whether the caller can change numbering). Read only -- the counter doesn't move, so a + quote created by someone else in between takes it first. + """ + client = cls._get_client() + response = await client.get("/v1/quotes/number-config/next") + return response["results"] + # ============================================ # QUOTES -- STATUS TRANSITIONS # ============================================ @@ -448,6 +500,53 @@ async def remove_line_item(cls, quote_id: str, item_id: str) -> Dict[str, Any]: client = cls._get_client() return await client.delete(f"/v1/quotes/{quote_id}/items/{item_id}") + # ============================================ + # CUSTOM LINE ITEM IMAGES + # Up to three per custom item (a line with no catalog product), PNG or JPEG, + # 2MB each. Changes are allowed on draft quotes only. + # ============================================ + + @classmethod + async def get_quote_line_item_images(cls, quote_id: str) -> Dict[str, Any]: + """Every custom line item's images (up to three, in order), keyed by line item id.""" + client = cls._get_client() + response = await client.get(f"/v1/quotes/{quote_id}/items/images") + return response["results"] + + @classmethod + async def get_line_item_images(cls, quote_id: str, item_id: str) -> List[Dict[str, Any]]: + """One custom line item's images, in order.""" + client = cls._get_client() + response = await client.get(f"/v1/quotes/{quote_id}/items/{item_id}/images") + return response["results"] + + @classmethod + async def add_line_item_images(cls, quote_id: str, item_id: str, images: List[Any]) -> List[Dict[str, Any]]: + """ + Add images to a custom line item (file paths, bytes or file-like objects). + + Returns all of the item's images. + """ + client = cls._get_client() + response = await client.post_form_data( + f"/v1/quotes/{quote_id}/items/{item_id}/images", {}, _image_files(images) + ) + return response["results"] + + @classmethod + async def reorder_line_item_images(cls, quote_id: str, item_id: str, image_ids: List[str]) -> List[Dict[str, Any]]: + """Set the order of a custom line item's images, first to last.""" + client = cls._get_client() + response = await client.put(f"/v1/quotes/{quote_id}/items/{item_id}/images/order", {"imageIds": image_ids}) + return response["results"] + + @classmethod + async def remove_line_item_image(cls, quote_id: str, item_id: str, image_id: str) -> List[Dict[str, Any]]: + """Remove one image from a custom line item. Returns the images left.""" + client = cls._get_client() + response = await client.delete(f"/v1/quotes/{quote_id}/items/{item_id}/images/{image_id}") + return response["results"] + # ============================================ # PRODUCTS # ============================================ @@ -513,10 +612,9 @@ async def delete_product(cls, id: str) -> Dict[str, Any]: return await client.delete(f"/v1/products/{id}") @classmethod - async def duplicate_product(cls, id: str) -> Dict[str, Any]: - """Duplicate a product.""" - client = cls._get_client() - return cls._unwrap(await client.post(f"/v1/products/{id}/duplicate")) + async def duplicate_product(cls, id: str, name: Optional[str] = None) -> Dict[str, Any]: + """Duplicate a product. ``name`` names the copy; left out, it is "Copy of ".""" + return await cls._post_duplicate(f"/v1/products/{id}/duplicate", name) @classmethod async def get_product_primary_images(cls, product_ids: List[str]) -> Dict[str, Any]: @@ -529,6 +627,17 @@ async def get_product_primary_images(cls, product_ids: List[str]) -> Dict[str, A response = await client.post("/v1/products/primary-images", {"productIds": product_ids}) return response["results"] + @classmethod + async def get_product_quote_images(cls, product_ids: List[str]) -> Dict[str, Any]: + """ + The images each product shows on a quote: up to three, in display order. + + Returns a dict mapping product ID -> list of ProductImage (empty when none). + """ + client = cls._get_client() + response = await client.post("/v1/products/quote-images", {"productIds": product_ids}) + return response["results"] + # ============================================ # PRICE BOOKS # ============================================ @@ -574,15 +683,14 @@ async def update_price_book(cls, id: str, request: Dict[str, Any]) -> Dict[str, @classmethod async def delete_price_book(cls, id: str) -> Dict[str, Any]: - """Delete a price book.""" + """Delete a price book. The org's default price book is refused (400 ``PriceBookIsDefault``).""" client = cls._get_client() return await client.delete(f"/v1/pricebooks/{id}") @classmethod - async def duplicate_price_book(cls, id: str) -> Dict[str, Any]: - """Duplicate a price book.""" - client = cls._get_client() - return cls._unwrap(await client.post(f"/v1/pricebooks/{id}/duplicate")) + async def duplicate_price_book(cls, id: str, name: Optional[str] = None) -> Dict[str, Any]: + """Duplicate a price book. ``name`` names the copy; left out, it is "Copy of ".""" + return await cls._post_duplicate(f"/v1/pricebooks/{id}/duplicate", name) @classmethod async def list_price_book_products(cls, id: str, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: @@ -636,10 +744,9 @@ async def delete_bundle(cls, id: str) -> Dict[str, Any]: return await client.delete(f"/v1/bundles/{id}") @classmethod - async def duplicate_bundle(cls, id: str) -> Dict[str, Any]: - """Duplicate a bundle.""" - client = cls._get_client() - return cls._unwrap(await client.post(f"/v1/bundles/{id}/duplicate")) + async def duplicate_bundle(cls, id: str, name: Optional[str] = None) -> Dict[str, Any]: + """Duplicate a bundle. ``name`` names the copy; left out, it is "Copy of ".""" + return await cls._post_duplicate(f"/v1/bundles/{id}/duplicate", name) # ============================================ # COMPANIES @@ -683,10 +790,21 @@ async def update_company(cls, id: str, request: Dict[str, Any]) -> Dict[str, Any @classmethod async def delete_company(cls, id: str) -> Dict[str, Any]: - """Delete a company.""" + """ + Delete a company and its contacts. + + Refused with a 409 (``CompanyHasOpenQuotes``) while it has open quotes (draft or + sent) -- see ``get_company_open_quotes``. + """ client = cls._get_client() return await client.delete(f"/v1/companies/{id}") + @classmethod + async def get_company_open_quotes(cls, company_id: str) -> Dict[str, Any]: + """The open quotes (draft or sent) for a company: what blocks deleting it.""" + client = cls._get_client() + return await client.get(f"/v1/companies/{company_id}/open-quotes") + @classmethod async def list_company_contacts(cls, company_id: str, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """List contacts for a company.""" @@ -728,10 +846,21 @@ async def update_contact(cls, id: str, request: Dict[str, Any]) -> Dict[str, Any @classmethod async def delete_contact(cls, id: str) -> Dict[str, Any]: - """Delete a contact.""" + """ + Delete a contact. + + Refused with a 409 when they are the company's only contact (``LastContact``) or the + contact on open quotes (``ContactHasOpenQuotes``) -- see ``get_contact_open_quotes``. + """ client = cls._get_client() return await client.delete(f"/v1/contacts/{id}") + @classmethod + async def get_contact_open_quotes(cls, contact_id: str) -> Dict[str, Any]: + """The open quotes (draft or sent) made out to a contact: what blocks deleting them.""" + client = cls._get_client() + return await client.get(f"/v1/contacts/{contact_id}/open-quotes") + # ============================================ # TEMPLATES # ============================================ diff --git a/packages/py-sdk/src/turbodocx_sdk/types/__init__.py b/packages/py-sdk/src/turbodocx_sdk/types/__init__.py index f0827b9a..c56c4466 100644 --- a/packages/py-sdk/src/turbodocx_sdk/types/__init__.py +++ b/packages/py-sdk/src/turbodocx_sdk/types/__init__.py @@ -7,6 +7,7 @@ Currency, CategoryType, DiscountType, + SortDirection, PaginationParams, PaginatedResponse, SuccessResponse, @@ -34,6 +35,18 @@ QuoteListResponse, QuoteNumberFormat, QuoteNumberConfig, + QuoteListStatusFilter, + QuoteSortBy, + QuoteValidUntilWindow, + QuoteSummaryOptions, + QuoteStatusCounts, + QuoteSummary, + QuoteCreator, + NextQuoteNumber, + OpenQuoteSummary, + OpenQuotesResponse, + LineItemImage, + QuoteLineItemImagesResponse, ) from .quote_line_item import ( LineItem, @@ -51,6 +64,7 @@ ListProductsOptions, ProductListResponse, ProductPrimaryImagesResponse, + ProductQuoteImagesResponse, ) from .pricebook import ( PriceBookProductPricing, @@ -86,6 +100,7 @@ CreateContactRequest, UpdateContactRequest, ListContactsOptions, + ListCompanyContactsOptions, ContactListResponse, ) from .quote_template import ( @@ -132,6 +147,7 @@ "Currency", "CategoryType", "DiscountType", + "SortDirection", "PaginationParams", "PaginatedResponse", "SuccessResponse", @@ -158,6 +174,18 @@ "QuoteListResponse", "QuoteNumberFormat", "QuoteNumberConfig", + "QuoteListStatusFilter", + "QuoteSortBy", + "QuoteValidUntilWindow", + "QuoteSummaryOptions", + "QuoteStatusCounts", + "QuoteSummary", + "QuoteCreator", + "NextQuoteNumber", + "OpenQuoteSummary", + "OpenQuotesResponse", + "LineItemImage", + "QuoteLineItemImagesResponse", # Line Item "LineItem", "AddLineItemRequest", @@ -173,6 +201,7 @@ "ListProductsOptions", "ProductListResponse", "ProductPrimaryImagesResponse", + "ProductQuoteImagesResponse", # PriceBook "PriceBookProductPricing", "PriceBook", @@ -204,6 +233,7 @@ "CreateContactRequest", "UpdateContactRequest", "ListContactsOptions", + "ListCompanyContactsOptions", "ContactListResponse", # Template "QuoteTemplate", diff --git a/packages/py-sdk/src/turbodocx_sdk/types/bundle.py b/packages/py-sdk/src/turbodocx_sdk/types/bundle.py index 04ca5a10..c57d6101 100644 --- a/packages/py-sdk/src/turbodocx_sdk/types/bundle.py +++ b/packages/py-sdk/src/turbodocx_sdk/types/bundle.py @@ -53,6 +53,9 @@ class Bundle(TypedDict, total=False): updatedOn: str items: List[BundleItem] category: Dict[str, Any] + # list_bundles only: active items, and the price per billing frequency + itemCount: int + billingTotals: Dict[str, float] class _BundleItemInputRequired(TypedDict): @@ -108,8 +111,11 @@ class ListBundlesOptions(TypedDict, total=False): offset: int query: str categoryIds: Any # string | string[] - currency: str # Currency literal + currency: Any # Currency | List[Currency] showInCatalog: bool + # "name" | "itemCount" | "totalListPrice" | "totalFinalPrice" | "createdOn" + sortBy: str + sortDirection: str # "asc" | "desc" class BundleListResponse(TypedDict, total=False): diff --git a/packages/py-sdk/src/turbodocx_sdk/types/company.py b/packages/py-sdk/src/turbodocx_sdk/types/company.py index bcc56054..567d1f44 100644 --- a/packages/py-sdk/src/turbodocx_sdk/types/company.py +++ b/packages/py-sdk/src/turbodocx_sdk/types/company.py @@ -14,6 +14,7 @@ class Company(TypedDict, total=False): id: str orgId: str name: str + email: Optional[str] phone: Optional[str] city: Optional[str] state: Optional[str] @@ -41,6 +42,7 @@ class _CreateCompanyRequestRequired(TypedDict): class CreateCompanyRequest(_CreateCompanyRequestRequired, total=False): + email: Optional[str] # optional through the API; the TurboDocx app requires it phone: Optional[str] city: Optional[str] state: Optional[str] @@ -50,6 +52,7 @@ class CreateCompanyRequest(_CreateCompanyRequestRequired, total=False): class UpdateCompanyRequest(TypedDict, total=False): name: str + email: Optional[str] phone: Optional[str] city: Optional[str] state: Optional[str] @@ -62,6 +65,8 @@ class ListCompaniesOptions(TypedDict, total=False): offset: int query: str industryIds: Any # string | string[] + sortBy: str # "name" | "contactCount" | "lastActivityDate"; default lastActivityDate + sortDirection: str # "asc" | "desc" class CompanyListResponse(TypedDict): diff --git a/packages/py-sdk/src/turbodocx_sdk/types/contact.py b/packages/py-sdk/src/turbodocx_sdk/types/contact.py index fdcf5ca7..35a15de6 100644 --- a/packages/py-sdk/src/turbodocx_sdk/types/contact.py +++ b/packages/py-sdk/src/turbodocx_sdk/types/contact.py @@ -43,13 +43,21 @@ class UpdateContactRequest(TypedDict, total=False): title: Optional[str] -class ListContactsOptions(TypedDict, total=False): +class ListCompanyContactsOptions(TypedDict, total=False): limit: int offset: int query: str + sortBy: str # "name" | "createdOn"; default createdOn newest first + sortDirection: str # "asc" | "desc" + + +class ListContactsOptions(ListCompanyContactsOptions, total=False): companyId: str + companyIds: Any # str | List[str] -class ContactListResponse(TypedDict): +class ContactListResponse(TypedDict, total=False): results: List[Contact] totalRecords: int + # The company, when the list is for one company; contactCount = its active contacts + company: Dict[str, Any] diff --git a/packages/py-sdk/src/turbodocx_sdk/types/pricebook.py b/packages/py-sdk/src/turbodocx_sdk/types/pricebook.py index f53ca75e..825937f4 100644 --- a/packages/py-sdk/src/turbodocx_sdk/types/pricebook.py +++ b/packages/py-sdk/src/turbodocx_sdk/types/pricebook.py @@ -43,6 +43,8 @@ class PriceBook(TypedDict, total=False): productPricing: List[PriceBookProductPricing] priceBookType: Dict[str, Any] productCount: int + # list_price_books only: products with their own price in this book + overrideCount: int class PriceBookProductPricingInput(TypedDict, total=False): @@ -78,6 +80,8 @@ class UpdatePriceBookRequest(TypedDict, total=False): isDefault: bool showInQuoteBuilder: bool productPricing: List[PriceBookProductPricingInput] + # Put every product back on the book's discount (before any productPricing in the request) + resetProductPricing: bool class ListPriceBooksOptions(TypedDict, total=False): @@ -86,6 +90,9 @@ class ListPriceBooksOptions(TypedDict, total=False): query: str priceBookTypeIds: Any # string | string[] showInQuoteBuilder: bool + # "name" | "discountPercent" | "overrideCount" | "validFrom" | "validTo" | "createdOn" + sortBy: str + sortDirection: str # "asc" | "desc" class ListPriceBookProductsOptions(TypedDict, total=False): diff --git a/packages/py-sdk/src/turbodocx_sdk/types/product.py b/packages/py-sdk/src/turbodocx_sdk/types/product.py index dce3afa5..a3480c01 100644 --- a/packages/py-sdk/src/turbodocx_sdk/types/product.py +++ b/packages/py-sdk/src/turbodocx_sdk/types/product.py @@ -83,9 +83,11 @@ class ListProductsOptions(TypedDict, total=False): offset: int query: str categoryIds: List[str] - billingFrequency: str # BillingFrequency literal - currency: str # Currency literal + billingFrequency: Any # BillingFrequency | List[BillingFrequency] + currency: Any # Currency | List[Currency] showInCatalog: bool + sortBy: str # "name" | "listPrice" | "createdOn"; default createdOn newest first + sortDirection: str # "asc" | "desc" class ProductListResponse(TypedDict, total=False): @@ -99,3 +101,6 @@ class ProductListResponse(TypedDict, total=False): # Dict mapping productId -> ProductImage | None ProductPrimaryImagesResponse = Dict[str, Any] + +# Dict mapping productId -> List[ProductImage] (up to three, in display order) +ProductQuoteImagesResponse = Dict[str, List[ProductImage]] diff --git a/packages/py-sdk/src/turbodocx_sdk/types/quote.py b/packages/py-sdk/src/turbodocx_sdk/types/quote.py index 2116e3dc..bbca19af 100644 --- a/packages/py-sdk/src/turbodocx_sdk/types/quote.py +++ b/packages/py-sdk/src/turbodocx_sdk/types/quote.py @@ -72,6 +72,13 @@ class Quote(TypedDict, total=False): statusInfo: QuoteStatusInfo # Folded on by get_quote from the response's sibling "preparedBy". See QuotePreparedBy. preparedBy: QuotePreparedBy + # list_quotes only: how many line items the quote has + lineItemCount: int + # list_quotes only: the quote's signature request has expired + isSignatureExpired: bool + # list_quotes only: a draft or sent quote past its Valid until day (UTC), or whose + # signature expired. The same rule as the "expired" status filter. + isExpired: bool # ============================================ @@ -105,14 +112,39 @@ class UpdateQuoteRequest(TypedDict, total=False): priceBookId: Optional[str] -class ListQuotesOptions(TypedDict, total=False): - limit: int - offset: int +# A quote list status filter: a stored status, or "expired" (a draft or sent quote past its +# Valid until day, or whose signature expired). "expired" is not a stored status. +QuoteListStatusFilter = Literal["draft", "sent", "accepted", "declined", "voided", "expired"] + +QuoteSortBy = Literal["updatedOn", "createdOn", "total", "validUntil", "quoteNumber"] + +# thisWeek = today through today + 6, thisMonth = the current calendar month, past = before today (UTC) +QuoteValidUntilWindow = Literal["thisWeek", "thisMonth", "past"] + + +class QuoteSummaryOptions(TypedDict, total=False): + """The filters shared by list_quotes and get_quote_summary. Multi-value keys take a str or a list.""" + query: str - statuses: Any # QuoteStatus | List[QuoteStatus] + statuses: Any # QuoteListStatusFilter | List[QuoteListStatusFilter] companyId: str + companyIds: Any # str | List[str] contactId: str - currency: str + currency: Any # Currency | List[Currency] + currencies: Any # Currency | List[Currency] + createdBy: Any # user id(s) -- see list_quote_creators + createdByApiKeys: bool + validUntilWindow: str # QuoteValidUntilWindow literal + validUntilFrom: str # YYYY-MM-DD, inclusive + validUntilTo: str # YYYY-MM-DD, inclusive + summaryCurrency: str # Currency literal + + +class ListQuotesOptions(QuoteSummaryOptions, total=False): + limit: int + offset: int + sortBy: str # QuoteSortBy literal; default createdOn newest first + sortDirection: str # SortDirection literal class SendQuoteRequest(TypedDict, total=False): @@ -208,10 +240,85 @@ class QuoteListStats(TypedDict, total=False): quotesThisMonth: int +class QuoteStatusCounts(TypedDict): + """Quotes per list tab: every filter applies except statuses. expired overlaps draft and sent.""" + + all: int + draft: int + sent: int + accepted: int + declined: int + voided: int + expired: int + + +class QuoteSummary(TypedDict, total=False): + """The quote list's summary cards, in one currency.""" + + currency: str + openPipeline: Dict[str, Any] # {total, count}: draft + sent quotes that aren't expired + completed: Dict[str, Any] # {accepted, declined} + createdThisMonth: Dict[str, Any] # {count} + waitingToSign: Dict[str, Any] # {count}: sent quotes that aren't expired + wonThisMonth: Dict[str, Any] # {count} + currencies: List[str] + winRate: Dict[str, Any] # {percent (None when none decided), accepted, declined, windowDays} + monthlyRecurring: Dict[str, Any] # {total, count} + + +class QuoteCreator(TypedDict): + """Someone who created quotes in the org: a person, or an API key.""" + + id: str # the value for the createdBy filter + name: str + email: Optional[str] # None for an API key + isAPIKey: bool + apiKeyDeleted: bool + + +class NextQuoteNumber(TypedDict): + nextNumber: str + sequence: int + canEdit: bool # whether the caller can change numbering (administrators) + + +class OpenQuoteSummary(TypedDict): + id: str + quoteNumber: str + name: Optional[str] + status: str + updatedOn: str + + +class OpenQuotesResponse(TypedDict): + """The open quotes that block deleting a company or contact: the newest 50, and how many in all.""" + + results: List[OpenQuoteSummary] + totalRecords: int + + +class LineItemImage(TypedDict, total=False): + """An image on a custom line item (a line with no catalog product).""" + + id: str + lineItemId: str + fileId: str + displayOrder: int + fileName: str + fileType: str + imageData: str # base64 data URL + + +# Dict mapping lineItemId -> List[LineItemImage] +QuoteLineItemImagesResponse = Dict[str, List[LineItemImage]] + + class QuoteListResponse(TypedDict, total=False): results: List[Quote] totalRecords: int stats: QuoteListStats + statusCounts: QuoteStatusCounts + summary: QuoteSummary # ============================================ diff --git a/packages/py-sdk/src/turbodocx_sdk/types/quote_shared.py b/packages/py-sdk/src/turbodocx_sdk/types/quote_shared.py index 9144321d..f0ec3827 100644 --- a/packages/py-sdk/src/turbodocx_sdk/types/quote_shared.py +++ b/packages/py-sdk/src/turbodocx_sdk/types/quote_shared.py @@ -10,7 +10,7 @@ from typing_extensions import Literal, TypedDict # Literal union types (used as type hints, not runtime values) -QuoteStatus = Literal["draft", "pending_approval", "sent", "accepted", "declined", "voided"] +QuoteStatus = Literal["draft", "sent", "accepted", "declined", "voided"] BillingFrequency = Literal["monthly", "quarterly", "annual", "one-time"] @@ -24,6 +24,9 @@ DiscountType = Literal["percent", "amount"] +# Direction for every list's sortBy +SortDirection = Literal["asc", "desc"] + class PaginationParams(TypedDict, total=False): limit: int diff --git a/packages/py-sdk/src/turbodocx_sdk/types/quote_type.py b/packages/py-sdk/src/turbodocx_sdk/types/quote_type.py index 0397fdbf..4a134d1a 100644 --- a/packages/py-sdk/src/turbodocx_sdk/types/quote_type.py +++ b/packages/py-sdk/src/turbodocx_sdk/types/quote_type.py @@ -46,6 +46,8 @@ class ListTypesOptions(TypedDict, total=False): query: str categoryType: str # CategoryType literal includeUsage: bool + sortBy: str # "name" | "usageCount" (usage needs categoryType); default name A to Z + sortDirection: str # "asc" | "desc" class QuoteTypeListResponse(TypedDict): diff --git a/packages/py-sdk/tests/test_http_put.py b/packages/py-sdk/tests/test_http_put.py new file mode 100644 index 00000000..a0972231 --- /dev/null +++ b/packages/py-sdk/tests/test_http_put.py @@ -0,0 +1,67 @@ +""" +HttpClient.put — a JSON PUT (used by reorder_line_item_images), unwrapped like every other call, +and the 409 a refused quote delete comes back as. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from turbodocx_sdk import ConflictError +from turbodocx_sdk.http import HttpClient + + +def _make_client() -> HttpClient: + return HttpClient(api_key="TDX-test-key", org_id="org-test", skip_sender_validation=True) + + +def _patch_async_client(method: str, response: MagicMock): + mock_http_client = AsyncMock() + setattr(mock_http_client, method, AsyncMock(return_value=response)) + patcher = patch("httpx.AsyncClient") + mock_httpx = patcher.start() + mock_httpx.return_value.__aenter__.return_value = mock_http_client + return patcher, mock_http_client + + +@pytest.mark.asyncio +async def test_put_sends_json_and_unwraps_data(): + response = MagicMock(spec=httpx.Response) + response.status_code = 200 + response.is_success = True + response.json.return_value = {"data": {"results": [{"id": "img-2"}], "message": "Image order saved"}} + patcher, http = _patch_async_client("put", response) + try: + result = await _make_client().put("/v1/quotes/q-1/items/li-1/images/order", {"imageIds": ["img-2"]}) + + url = http.put.call_args[0][0] + assert url.endswith("/v1/quotes/q-1/items/li-1/images/order") + assert http.put.call_args[1]["json"] == {"imageIds": ["img-2"]} + assert result["results"] == [{"id": "img-2"}] + finally: + patcher.stop() + + +@pytest.mark.asyncio +async def test_refused_quote_delete_maps_to_conflict_error_with_its_code(): + message = "A sent quote can't be deleted. Void it instead." + response = MagicMock(spec=httpx.Response) + response.status_code = 409 + response.reason_phrase = "Conflict" + response.is_success = False + response.headers = {"content-type": "application/json"} + response.json.return_value = { + "message": message, + "error": {"message": message, "code": "CANNOT_DELETE_SENT_QUOTE"}, + "data": {}, + } + patcher, _ = _patch_async_client("delete", response) + try: + with pytest.raises(ConflictError) as exc_info: + await _make_client().delete("/v1/quotes/q-1") + assert exc_info.value.status_code == 409 + assert exc_info.value.code == "CANNOT_DELETE_SENT_QUOTE" + assert message in str(exc_info.value) + finally: + patcher.stop() diff --git a/packages/py-sdk/tests/test_quote_status.py b/packages/py-sdk/tests/test_quote_status.py index b3c48606..144bac96 100644 --- a/packages/py-sdk/tests/test_quote_status.py +++ b/packages/py-sdk/tests/test_quote_status.py @@ -9,19 +9,12 @@ from turbodocx_sdk.types.quote_shared import QuoteStatus -EXPECTED_STATUSES = {"draft", "pending_approval", "sent", "accepted", "declined", "voided"} +EXPECTED_STATUSES = {"draft", "sent", "accepted", "declined", "voided"} class TestQuoteStatus: """Verify QuoteStatus Literal includes all backend statuses""" - def test_pending_approval_is_valid_status(self): - """pending_approval must be a member of QuoteStatus""" - actual = set(get_args(QuoteStatus)) - assert "pending_approval" in actual, ( - f"'pending_approval' missing from QuoteStatus. Got: {actual}" - ) - def test_all_backend_statuses_present(self): """QuoteStatus must include every status the backend supports""" actual = set(get_args(QuoteStatus)) diff --git a/packages/py-sdk/tests/test_turboquote_lists_and_images.py b/packages/py-sdk/tests/test_turboquote_lists_and_images.py new file mode 100644 index 00000000..0842aea2 --- /dev/null +++ b/packages/py-sdk/tests/test_turboquote_lists_and_images.py @@ -0,0 +1,378 @@ +""" +TurboQuote: list sorting and filters, the quote summary / creators / next number reads, +open quotes, product quote images, custom line item images, named duplicates, company +email and price book reset. +""" + +import pytest +from unittest.mock import AsyncMock + +from turbodocx_sdk import TurboQuote + +PNG = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]) +JPEG = bytes([0xFF, 0xD8, 0xFF, 0xE0, 0x00]) + + +class TestListsReadsAndImages: + @pytest.fixture(autouse=True) + def setup(self): + TurboQuote._client = None + self.mock_client = AsyncMock() + TurboQuote._client = self.mock_client + + # ============================================ + # SORTING AND FILTERS + # ============================================ + + @pytest.mark.asyncio + async def test_list_quotes_sends_sort_new_filters_and_summary_currency(self): + self.mock_client.get = AsyncMock(return_value={"results": [], "totalRecords": 0}) + + await TurboQuote.list_quotes({ + "sortBy": "total", + "sortDirection": "asc", + "statuses": ["sent", "expired"], + "companyIds": ["c-1", "c-2"], + "currencies": ["USD", "EUR"], + "createdBy": ["u-1"], + "createdByApiKeys": True, + "validUntilWindow": "thisMonth", + "validUntilFrom": "2026-10-01", + "validUntilTo": "2026-10-31", + "summaryCurrency": "EUR", + }) + + self.mock_client.get.assert_called_once_with("/v1/quotes", { + "sortBy": "total", + "sortDirection": "asc", + "statuses": ["sent", "expired"], + "companyIds": ["c-1", "c-2"], + "currencies": ["USD", "EUR"], + "createdBy": ["u-1"], + "createdByApiKeys": "true", + "validUntilWindow": "thisMonth", + "validUntilFrom": "2026-10-01", + "validUntilTo": "2026-10-31", + "summaryCurrency": "EUR", + }) + + @pytest.mark.asyncio + async def test_list_quotes_still_takes_one_currency(self): + self.mock_client.get = AsyncMock(return_value={"results": [], "totalRecords": 0}) + + await TurboQuote.list_quotes({"currency": "GBP", "companyId": "c-1"}) + + self.mock_client.get.assert_called_once_with("/v1/quotes", {"currency": "GBP", "companyId": "c-1"}) + + @pytest.mark.asyncio + async def test_list_quotes_returns_tab_counts_summary_and_expiry_flag(self): + self.mock_client.get = AsyncMock(return_value={ + "results": [{"id": "q-1", "status": "sent", "isExpired": True, "lineItemCount": 3}], + "totalRecords": 1, + "statusCounts": {"all": 1, "sent": 1, "expired": 1}, + "summary": {"currency": "USD", "winRate": {"percent": None, "accepted": 0, "declined": 0, "windowDays": 90}}, + }) + + result = await TurboQuote.list_quotes() + + assert result["statusCounts"]["expired"] == 1 + assert result["summary"]["winRate"]["percent"] is None + assert result["results"][0]["isExpired"] is True + assert result["results"][0]["lineItemCount"] == 3 + + @pytest.mark.asyncio + async def test_list_products_sends_several_billing_frequencies_currencies_and_sort(self): + self.mock_client.get = AsyncMock(return_value={"results": [], "totalRecords": 0}) + + await TurboQuote.list_products({ + "billingFrequency": ["monthly", "annual"], + "currency": ["USD", "INR"], + "sortBy": "listPrice", + "sortDirection": "desc", + }) + + self.mock_client.get.assert_called_once_with("/v1/products", { + "billingFrequency": ["monthly", "annual"], + "currency": ["USD", "INR"], + "sortBy": "listPrice", + "sortDirection": "desc", + }) + + @pytest.mark.asyncio + async def test_list_price_books_sends_sort(self): + self.mock_client.get = AsyncMock(return_value={"results": [{"id": "pb-1", "overrideCount": 2}], "totalRecords": 1}) + + result = await TurboQuote.list_price_books({"sortBy": "overrideCount", "sortDirection": "desc"}) + + self.mock_client.get.assert_called_once_with("/v1/pricebooks", {"sortBy": "overrideCount", "sortDirection": "desc"}) + assert result["results"][0]["overrideCount"] == 2 + + @pytest.mark.asyncio + async def test_list_bundles_sends_several_currencies_and_sort(self): + self.mock_client.get = AsyncMock(return_value={ + "results": [{"id": "b-1", "itemCount": 2, "billingTotals": {"monthly": 100, "one-time": 500}}], + "totalRecords": 1, + }) + + result = await TurboQuote.list_bundles({"currency": ["USD", "EUR"], "sortBy": "itemCount", "sortDirection": "asc"}) + + self.mock_client.get.assert_called_once_with("/v1/bundles", { + "currency": ["USD", "EUR"], + "sortBy": "itemCount", + "sortDirection": "asc", + }) + assert result["results"][0]["billingTotals"]["one-time"] == 500 + + @pytest.mark.asyncio + async def test_list_companies_sends_sort(self): + self.mock_client.get = AsyncMock(return_value={"results": [{"id": "c-1", "email": "ap@acme.test"}], "totalRecords": 1}) + + result = await TurboQuote.list_companies({"sortBy": "contactCount", "sortDirection": "desc"}) + + self.mock_client.get.assert_called_once_with("/v1/companies", {"sortBy": "contactCount", "sortDirection": "desc"}) + assert result["results"][0]["email"] == "ap@acme.test" + + @pytest.mark.asyncio + async def test_list_contacts_sends_several_companies_and_sort(self): + self.mock_client.get = AsyncMock(return_value={"results": [], "totalRecords": 0}) + + await TurboQuote.list_contacts({"companyIds": ["c-1", "c-2"], "sortBy": "name", "sortDirection": "asc"}) + + self.mock_client.get.assert_called_once_with("/v1/contacts", { + "companyIds": ["c-1", "c-2"], + "sortBy": "name", + "sortDirection": "asc", + }) + + @pytest.mark.asyncio + async def test_list_company_contacts_sends_sort_and_returns_contact_count(self): + self.mock_client.get = AsyncMock(return_value={ + "results": [], "totalRecords": 0, "company": {"id": "c-1", "contactCount": 1}, + }) + + result = await TurboQuote.list_company_contacts("c-1", {"sortBy": "createdOn", "sortDirection": "desc"}) + + self.mock_client.get.assert_called_once_with( + "/v1/companies/c-1/contacts", {"sortBy": "createdOn", "sortDirection": "desc"} + ) + assert result["company"]["contactCount"] == 1 + + @pytest.mark.asyncio + async def test_list_types_sends_sort(self): + self.mock_client.get = AsyncMock(return_value={"results": [], "totalRecords": 0}) + + await TurboQuote.list_types({"categoryType": "product_category", "sortBy": "usageCount", "sortDirection": "desc"}) + + self.mock_client.get.assert_called_once_with("/v1/types", { + "categoryType": "product_category", + "sortBy": "usageCount", + "sortDirection": "desc", + }) + + # ============================================ + # QUOTE READS + # ============================================ + + @pytest.mark.asyncio + async def test_get_quote_summary_unwraps_the_summary(self): + summary = {"currency": "EUR", "openPipeline": {"total": 10, "count": 1}} + self.mock_client.get = AsyncMock(return_value={"summary": summary}) + + result = await TurboQuote.get_quote_summary({"currencies": ["EUR", "USD"], "summaryCurrency": "EUR"}) + + self.mock_client.get.assert_called_once_with( + "/v1/quotes/summary", {"currencies": ["EUR", "USD"], "summaryCurrency": "EUR"} + ) + assert result == summary + + @pytest.mark.asyncio + async def test_get_quote_summary_without_options_sends_no_params(self): + self.mock_client.get = AsyncMock(return_value={"summary": {"currency": "USD"}}) + + await TurboQuote.get_quote_summary() + + self.mock_client.get.assert_called_once_with("/v1/quotes/summary", None) + + @pytest.mark.asyncio + async def test_list_quote_creators(self): + creators = [ + {"id": "u-1", "name": "Ana Lee", "email": "ana@acme.test", "isAPIKey": False, "apiKeyDeleted": False}, + {"id": "u-2", "name": "Zapier key", "email": None, "isAPIKey": True, "apiKeyDeleted": True}, + ] + self.mock_client.get = AsyncMock(return_value={"results": creators}) + + result = await TurboQuote.list_quote_creators() + + self.mock_client.get.assert_called_once_with("/v1/quotes/creators") + assert result == creators + + @pytest.mark.asyncio + async def test_get_next_quote_number(self): + self.mock_client.get = AsyncMock( + return_value={"results": {"nextNumber": "Q-2026-0042", "sequence": 42, "canEdit": True}} + ) + + result = await TurboQuote.get_next_quote_number() + + self.mock_client.get.assert_called_once_with("/v1/quotes/number-config/next") + assert result == {"nextNumber": "Q-2026-0042", "sequence": 42, "canEdit": True} + + # ============================================ + # OPEN QUOTES + # ============================================ + + @pytest.mark.asyncio + async def test_get_company_open_quotes(self): + response = { + "results": [{"id": "q-1", "quoteNumber": "Q-1", "name": "Renewal", "status": "sent", "updatedOn": "2026-10-01"}], + "totalRecords": 1, + } + self.mock_client.get = AsyncMock(return_value=response) + + result = await TurboQuote.get_company_open_quotes("c-1") + + self.mock_client.get.assert_called_once_with("/v1/companies/c-1/open-quotes") + assert result == response + + @pytest.mark.asyncio + async def test_get_contact_open_quotes(self): + self.mock_client.get = AsyncMock(return_value={"results": [], "totalRecords": 0}) + + result = await TurboQuote.get_contact_open_quotes("ct-1") + + self.mock_client.get.assert_called_once_with("/v1/contacts/ct-1/open-quotes") + assert result["totalRecords"] == 0 + + # ============================================ + # IMAGES + # ============================================ + + @pytest.mark.asyncio + async def test_get_product_quote_images(self): + images = {"p-1": [{"id": "i-1", "productId": "p-1", "displayOrder": 0}], "p-2": []} + self.mock_client.post = AsyncMock(return_value={"results": images}) + + result = await TurboQuote.get_product_quote_images(["p-1", "p-2"]) + + self.mock_client.post.assert_called_once_with("/v1/products/quote-images", {"productIds": ["p-1", "p-2"]}) + assert result == images + + @pytest.mark.asyncio + async def test_get_quote_line_item_images(self): + by_item = {"li-1": [{"id": "img-1", "lineItemId": "li-1", "displayOrder": 0}]} + self.mock_client.get = AsyncMock(return_value={"results": by_item}) + + result = await TurboQuote.get_quote_line_item_images("q-1") + + self.mock_client.get.assert_called_once_with("/v1/quotes/q-1/items/images") + assert result == by_item + + @pytest.mark.asyncio + async def test_get_line_item_images(self): + images = [{"id": "img-1", "lineItemId": "li-1", "displayOrder": 0}] + self.mock_client.get = AsyncMock(return_value={"results": images}) + + result = await TurboQuote.get_line_item_images("q-1", "li-1") + + self.mock_client.get.assert_called_once_with("/v1/quotes/q-1/items/li-1/images") + assert result == images + + @pytest.mark.asyncio + async def test_add_line_item_images_uploads_images_parts_with_detected_types(self): + self.mock_client.post_form_data = AsyncMock( + return_value={"results": [{"id": "img-1"}, {"id": "img-2"}], "message": "Images added"} + ) + + result = await TurboQuote.add_line_item_images("q-1", "li-1", [PNG, JPEG]) + + path, data, files = self.mock_client.post_form_data.call_args[0] + assert path == "/v1/quotes/q-1/items/li-1/images" + # Images only: no JSON "data" part + assert data == {} + assert [field for field, _ in files] == ["images", "images"] + assert files[0][1][2] == "image/png" + assert files[1][1][2] == "image/jpeg" + assert result == [{"id": "img-1"}, {"id": "img-2"}] + + @pytest.mark.asyncio + async def test_reorder_line_item_images(self): + self.mock_client.put = AsyncMock(return_value={"results": [{"id": "img-2"}, {"id": "img-1"}]}) + + result = await TurboQuote.reorder_line_item_images("q-1", "li-1", ["img-2", "img-1"]) + + self.mock_client.put.assert_called_once_with( + "/v1/quotes/q-1/items/li-1/images/order", {"imageIds": ["img-2", "img-1"]} + ) + assert result == [{"id": "img-2"}, {"id": "img-1"}] + + @pytest.mark.asyncio + async def test_remove_line_item_image(self): + self.mock_client.delete = AsyncMock(return_value={"results": [{"id": "img-2"}], "message": "Image removed"}) + + result = await TurboQuote.remove_line_item_image("q-1", "li-1", "img-1") + + self.mock_client.delete.assert_called_once_with("/v1/quotes/q-1/items/li-1/images/img-1") + assert result == [{"id": "img-2"}] + + # ============================================ + # NAMED DUPLICATES + # ============================================ + + DUPLICATES = [ + ("duplicate_quote", "/v1/quotes/x-1/duplicate"), + ("duplicate_product", "/v1/products/x-1/duplicate"), + ("duplicate_price_book", "/v1/pricebooks/x-1/duplicate"), + ("duplicate_bundle", "/v1/bundles/x-1/duplicate"), + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("method,path", DUPLICATES) + async def test_duplicate_sends_the_name_for_the_copy(self, method, path): + self.mock_client.post = AsyncMock(return_value={"result": {"id": "x-2", "name": "Q4 copy"}}) + + result = await getattr(TurboQuote, method)("x-1", name="Q4 copy") + + self.mock_client.post.assert_called_once_with(path, {"name": "Q4 copy"}) + assert result == {"id": "x-2", "name": "Q4 copy"} + + @pytest.mark.asyncio + @pytest.mark.parametrize("method,path", DUPLICATES) + async def test_duplicate_without_a_name_sends_no_body(self, method, path): + self.mock_client.post = AsyncMock(return_value={"result": {"id": "x-2"}}) + + await getattr(TurboQuote, method)("x-1") + + self.mock_client.post.assert_called_once_with(path) + + # ============================================ + # REQUEST FIELDS + # ============================================ + + @pytest.mark.asyncio + async def test_create_company_sends_the_company_email(self): + self.mock_client.post = AsyncMock(return_value={"result": {"id": "c-1"}}) + + await TurboQuote.create_company({ + "name": "Acme", + "email": "ap@acme.test", + "contacts": [{"name": "Ana", "email": "ana@acme.test"}], + }) + + body = self.mock_client.post.call_args[0][1] + assert body["email"] == "ap@acme.test" + + @pytest.mark.asyncio + async def test_update_company_sends_an_explicit_null_email(self): + self.mock_client.patch = AsyncMock(return_value={"result": {"id": "c-1"}}) + + await TurboQuote.update_company("c-1", {"email": None}) + + self.mock_client.patch.assert_called_once_with("/v1/companies/c-1", {"email": None}) + + @pytest.mark.asyncio + async def test_update_price_book_sends_reset_product_pricing(self): + self.mock_client.patch = AsyncMock(return_value={"result": {"id": "pb-1"}}) + + await TurboQuote.update_price_book("pb-1", {"resetProductPricing": True}) + + self.mock_client.patch.assert_called_once_with("/v1/pricebooks/pb-1", {"resetProductPricing": True}) diff --git a/packages/ruby-sdk/README.md b/packages/ruby-sdk/README.md index 945660c4..23db549b 100644 --- a/packages/ruby-sdk/README.md +++ b/packages/ruby-sdk/README.md @@ -929,23 +929,23 @@ TurboDocxSdk::TurboQuote.configure( `TurboQuote` does **not** require `sender_email` — use `api_key` + `org_id` only. -#### All 68 Methods +#### All 79 Methods | Group | Methods | |---|---| | **Configuration** | `configure` | -| **Quotes** | `create_quote`, `get_quote`, `list_quotes`, `update_quote`, `delete_quote`, `duplicate_quote` | +| **Quotes** | `create_quote`, `get_quote`, `list_quotes`, `update_quote`, `delete_quote`, `duplicate_quote`, `get_quote_summary`, `list_quote_creators` | | **Quote status transitions** | `send_quote`, `decline_quote`, `void_quote`, `handle_expired_quote` | | **Quote extras** | `send_quote_with_deliverable`, `apply_price_book`, `remove_price_book`, `download_quote_pdf` | -| **Line items** | `add_line_items`, `add_bundle_line_items`, `list_line_items`, `update_line_item`, `remove_line_item` | -| **Products** | `create_product`, `bulk_create_products`, `get_product`, `list_products`, `update_product`, `delete_product`, `duplicate_product`, `get_product_primary_images` | +| **Line items** | `add_line_items`, `add_bundle_line_items`, `list_line_items`, `update_line_item`, `remove_line_item`, `get_quote_line_item_images`, `get_line_item_images`, `add_line_item_images`, `reorder_line_item_images`, `remove_line_item_image` | +| **Products** | `create_product`, `bulk_create_products`, `get_product`, `list_products`, `update_product`, `delete_product`, `duplicate_product`, `get_product_primary_images`, `get_product_quote_images` | | **Bundles** | `create_bundle`, `bulk_create_bundles`, `get_bundle`, `list_bundles`, `update_bundle`, `delete_bundle`, `duplicate_bundle` | | **Price books** | `create_price_book`, `bulk_create_price_books`, `get_price_book`, `list_price_books`, `update_price_book`, `delete_price_book`, `duplicate_price_book`, `list_price_book_products` | | **Quote templates** | `get_template`, `get_template_by_id`, `list_templates`, `create_template`, `update_template`, `delete_template` | | **Types / categories** | `create_type`, `bulk_create_types`, `list_types`, `update_type`, `delete_type` | -| **Companies** | `create_company`, `bulk_create_companies`, `get_company`, `list_companies`, `update_company`, `delete_company`, `list_company_contacts` | -| **Contacts** | `create_contact`, `bulk_create_contacts`, `list_contacts`, `update_contact`, `delete_contact` | -| **Quote number config (admin)** | `get_quote_number_config`, `update_quote_number_config` | +| **Companies** | `create_company`, `bulk_create_companies`, `get_company`, `list_companies`, `update_company`, `delete_company`, `list_company_contacts`, `get_company_open_quotes` | +| **Contacts** | `create_contact`, `bulk_create_contacts`, `list_contacts`, `update_contact`, `delete_contact`, `get_contact_open_quotes` | +| **Quote number config (admin)** | `get_quote_number_config`, `update_quote_number_config`, `get_next_quote_number` (any role) | | **Convenience** | `create_and_send` | #### Create a quote, add line items, and send @@ -1017,6 +1017,63 @@ TurboDocxSdk::TurboQuote.send_quote(quote["id"], ) ``` +#### Sorting, filters and the list summary + +Every list takes `sortBy` / `sortDirection` (`asc` | `desc`); leave them out for the order the +list always had. Sort columns: quotes `updatedOn` · `createdOn` · `total` · `validUntil` · `quoteNumber`; +products `name` · `listPrice` · `createdOn`; price books `name` · `discountPercent` · `overrideCount` · +`validFrom` · `validTo` · `createdOn`; bundles `name` · `itemCount` · `totalListPrice` · `totalFinalPrice` · +`createdOn`; companies `name` · `contactCount` · `lastActivityDate`; contacts `name` · `createdOn`; +types `name` · `usageCount`. + +The quote list also filters by several companies (`companyIds`) and currencies (`currencies`), by +`expired` in `statuses` (a draft or sent quote past its Valid until day, UTC, or whose signature +expired), by creator (`createdBy`, ids from the creators call; `createdByApiKeys` for API-key quotes) +and by Valid until (`validUntilWindow` = `thisWeek` | `thisMonth` | `past`, `validUntilFrom` / +`validUntilTo`). Each quote carries `isExpired` and `lineItemCount`; the response adds +`statusCounts` (per tab) and `summary` (pipeline, win rate, monthly recurring, in `summaryCurrency`). + +```ruby +page = TurboDocxSdk::TurboQuote.list_quotes( + "statuses" => %w[sent expired], + "currencies" => %w[USD EUR], + "validUntilWindow" => "thisMonth", + "sortBy" => "validUntil", + "sortDirection" => "asc", + "summaryCurrency" => "USD" +) +puts page["statusCounts"]["expired"], page["summary"]["winRate"]["percent"] + +creators = TurboDocxSdk::TurboQuote.list_quote_creators # ids for createdBy +summary = TurboDocxSdk::TurboQuote.get_quote_summary("createdBy" => [creators.first["id"]]) +next_number = TurboDocxSdk::TurboQuote.get_next_quote_number # read only + +copy = TurboDocxSdk::TurboQuote.duplicate_quote(quote["id"], name: "Q4 renewal") # name is optional +``` + +`duplicate_quote`, `duplicate_product`, `duplicate_price_book` and `duplicate_bundle` take an optional +`name:` (otherwise "Copy of …"). Companies take an optional `"email"`; `update_price_book(id, "resetProductPricing" => true)` +puts every product back on the book's discount. + +#### Custom line item images + +A custom line item (one with no catalog product) can show up to three images on the quote (PNG or +JPEG, 2 MB each; draft quotes only). Catalog products use `get_product_quote_images(product_ids)`. + +```ruby +images = TurboDocxSdk::TurboQuote.add_line_item_images(quote["id"], item_id, ["./diagram.png"]) # paths or IO objects +TurboDocxSdk::TurboQuote.reorder_line_item_images(quote["id"], item_id, images.map { |i| i["id"] }.reverse) +TurboDocxSdk::TurboQuote.remove_line_item_image(quote["id"], item_id, images.first["id"]) +by_item = TurboDocxSdk::TurboQuote.get_quote_line_item_images(quote["id"]) # { line_item_id => [image, ...] } +``` + +**Deletes the API refuses.** `delete_quote` on a **sent** quote is a 409 `ConflictError` with code +`CANNOT_DELETE_SENT_QUOTE` — void it instead; an accepted quote is a 409 `CANNOT_DELETE_ACCEPTED_QUOTE`. +`delete_company` is a 409 `CompanyHasOpenQuotes` while the company has open quotes (draft or +sent), and `delete_contact` is a 409 `LastContact` (the company's only contact) or `ContactHasOpenQuotes`. +`delete_price_book` on the org's default price book is a 400 `ValidationError` `PriceBookIsDefault`. Branch on +`e.code`; `get_company_open_quotes` / `get_contact_open_quotes` list the open quotes that block a delete. + #### Sender identity — "Prepared by" The quote's **"Prepared by"** name and email are resolved by the server, not by whoever diff --git a/packages/ruby-sdk/lib/turbodocx_sdk/constants.rb b/packages/ruby-sdk/lib/turbodocx_sdk/constants.rb index a11f931c..0657b367 100644 --- a/packages/ruby-sdk/lib/turbodocx_sdk/constants.rb +++ b/packages/ruby-sdk/lib/turbodocx_sdk/constants.rb @@ -3,12 +3,11 @@ module TurboDocxSdk module QuoteStatus DRAFT = "draft" - PENDING_APPROVAL = "pending_approval" SENT = "sent" ACCEPTED = "accepted" DECLINED = "declined" VOIDED = "voided" - ALL = [DRAFT, PENDING_APPROVAL, SENT, ACCEPTED, DECLINED, VOIDED].freeze + ALL = [DRAFT, SENT, ACCEPTED, DECLINED, VOIDED].freeze end module BillingFrequency diff --git a/packages/ruby-sdk/lib/turbodocx_sdk/http_client.rb b/packages/ruby-sdk/lib/turbodocx_sdk/http_client.rb index ca638793..62143091 100644 --- a/packages/ruby-sdk/lib/turbodocx_sdk/http_client.rb +++ b/packages/ruby-sdk/lib/turbodocx_sdk/http_client.rb @@ -115,6 +115,23 @@ def patch(path, data = nil) execute(request, url) end + # PUT request with JSON body. + # @param path [String] + # @param data [Hash, nil] request body + # @return [Object] parsed, unwrapped, normalized response + # @raise [ValidationError] on 400 response + # @raise [AuthenticationError] on 401 response + # @raise [NotFoundError] on 404 response + # @raise [RateLimitError] on 429 response + # @raise [NetworkError] on connection failure + def put(path, data = nil) + url = build_url(path) + request = Net::HTTP::Put.new(url) + apply_headers(request) + request.body = JSON.generate(data) if data + execute(request, url) + end + # DELETE request. # @param path [String] # @return [Object] parsed, unwrapped, normalized response diff --git a/packages/ruby-sdk/lib/turbodocx_sdk/turbo_quote.rb b/packages/ruby-sdk/lib/turbodocx_sdk/turbo_quote.rb index 568770fe..c22519d0 100644 --- a/packages/ruby-sdk/lib/turbodocx_sdk/turbo_quote.rb +++ b/packages/ruby-sdk/lib/turbodocx_sdk/turbo_quote.rb @@ -47,6 +47,29 @@ def list_quotes(options = nil) client.get("/v1/quotes", to_query_params(options)) end + # The quote list's summary cards (pipeline, win rate, monthly recurring...) for a set of + # filters, in one currency (+summaryCurrency+). Takes the list_quotes filters; the status + # filter is ignored. + # + # @param options [Hash, nil] filters (camelCase keys, as for list_quotes) + # @return [Hash] the summary ("currency", "openPipeline", "winRate", ...) + # @raise [AuthenticationError] on invalid credentials + # @raise [NetworkError] on connection failure + def get_quote_summary(options = nil) + client = get_client + client.get("/v1/quotes/summary", to_query_params(options))["summary"] + end + + # The people and API keys who created the org's quotes -- the ids for the +createdBy+ filter. + # + # @return [Array] each { "id", "name", "email" (nil for an API key), "isAPIKey", "apiKeyDeleted" } + # @raise [AuthenticationError] on invalid credentials + # @raise [NetworkError] on connection failure + def list_quote_creators + client = get_client + client.get("/v1/quotes/creators")["results"] + end + # Create a new quote. # # @param request [Hash] :name (required), :companyId (required), :contactId (required), @@ -99,10 +122,12 @@ def update_quote(id, request) unwrap(client.patch("/v1/quotes/#{id}", request)) end - # Delete a quote. + # Delete a quote. A sent quote is refused (void it instead), and so is an accepted one. # # @param id [String] # @return [Hash] { "message" => "..." } + # @raise [ConflictError] for a sent (CANNOT_DELETE_SENT_QUOTE) or accepted + # (CANNOT_DELETE_ACCEPTED_QUOTE) quote # @raise [NotFoundError] if the quote does not exist # @raise [AuthenticationError] on invalid credentials # @raise [NetworkError] on connection failure @@ -111,16 +136,19 @@ def delete_quote(id) client.delete("/v1/quotes/#{id}") end - # Duplicate a quote. + # Duplicate a bundle. +name+ names the copy; left out, it is "Copy of ". # # @param id [String] + # @param name [String, nil] the copy's name + # @param name [String, nil] the copy's name + # @param name [String, nil] the copy's name + # @param name [String, nil] the copy's name # @return [Hash] the duplicated quote # @raise [NotFoundError] if the quote does not exist # @raise [AuthenticationError] on invalid credentials # @raise [NetworkError] on connection failure - def duplicate_quote(id) - client = get_client - unwrap(client.post("/v1/quotes/#{id}/duplicate")) + def duplicate_quote(id, name: nil) + post_duplicate("/v1/quotes/#{id}/duplicate", name) end # Apply a price book to a quote. @@ -373,6 +401,77 @@ def remove_line_item(quote_id, item_id) client.delete("/v1/quotes/#{quote_id}/items/#{item_id}") end + # ============================================ + # CUSTOM LINE ITEM IMAGES + # Up to three per custom item (a line with no catalog product), PNG or JPEG, + # 2MB each. Changes are allowed on draft quotes only. + # ============================================ + + # Every custom line item's images (up to three, in order), keyed by line item id. + # + # @param quote_id [String] + # @return [Hash] line_item_id => Array + # @raise [AuthenticationError] on invalid credentials + # @raise [NetworkError] on connection failure + def get_quote_line_item_images(quote_id) + client = get_client + client.get("/v1/quotes/#{quote_id}/items/images")["results"] + end + + # One custom line item's images, in order. + # + # @param quote_id [String] + # @param item_id [String] + # @return [Array] + # @raise [NotFoundError] if the quote or line item does not exist + # @raise [NetworkError] on connection failure + def get_line_item_images(quote_id, item_id) + client = get_client + client.get("/v1/quotes/#{quote_id}/items/#{item_id}/images")["results"] + end + + # Add images to a custom line item. Returns all of its images. + # + # @param quote_id [String] + # @param item_id [String] + # @param images [Array] file paths or IO objects (PNG or JPEG) + # @return [Array] + # @raise [ValidationError] on a wrong type, a too-large image, more than three, or a sent quote + # @raise [NetworkError] on connection failure + def add_line_item_images(quote_id, item_id, images) + client = get_client + response = client.post_form_data( + "/v1/quotes/#{quote_id}/items/#{item_id}/images", { "images" => image_parts(images) } + ) + response["results"] + end + + # Set the order of a custom line item's images, first to last. + # + # @param quote_id [String] + # @param item_id [String] + # @param image_ids [Array] + # @return [Array] the images in their new order + # @raise [ValidationError] on invalid ids or a sent quote + # @raise [NetworkError] on connection failure + def reorder_line_item_images(quote_id, item_id, image_ids) + client = get_client + client.put("/v1/quotes/#{quote_id}/items/#{item_id}/images/order", { "imageIds" => image_ids })["results"] + end + + # Remove one image from a custom line item. Returns the images left. + # + # @param quote_id [String] + # @param item_id [String] + # @param image_id [String] + # @return [Array] + # @raise [NotFoundError] if the image does not exist + # @raise [NetworkError] on connection failure + def remove_line_item_image(quote_id, item_id, image_id) + client = get_client + client.delete("/v1/quotes/#{quote_id}/items/#{item_id}/images/#{image_id}")["results"] + end + # ============================================ # PRODUCTS # ============================================ @@ -477,9 +576,8 @@ def delete_product(id) # @raise [NotFoundError] if the product does not exist # @raise [AuthenticationError] on invalid credentials # @raise [NetworkError] on connection failure - def duplicate_product(id) - client = get_client - unwrap(client.post("/v1/products/#{id}/duplicate")) + def duplicate_product(id, name: nil) + post_duplicate("/v1/products/#{id}/duplicate", name) end # Get primary images for multiple products. @@ -494,6 +592,18 @@ def get_product_primary_images(product_ids) response["results"] end + # The images each product shows on a quote: up to three, in display order. + # + # @param product_ids [Array] 1-100 product ids + # @return [Hash] product_id => Array (empty when none) + # @raise [AuthenticationError] on invalid credentials + # @raise [NetworkError] on connection failure + def get_product_quote_images(product_ids) + client = get_client + response = client.post("/v1/products/quote-images", { "productIds" => product_ids }) + response["results"] + end + # ============================================ # PRICE BOOKS # ============================================ @@ -600,9 +710,8 @@ def delete_price_book(id) # @raise [NotFoundError] if the price book does not exist # @raise [AuthenticationError] on invalid credentials # @raise [NetworkError] on connection failure - def duplicate_price_book(id) - client = get_client - unwrap(client.post("/v1/pricebooks/#{id}/duplicate")) + def duplicate_price_book(id, name: nil) + post_duplicate("/v1/pricebooks/#{id}/duplicate", name) end # List products in a price book. @@ -718,9 +827,8 @@ def delete_bundle(id) # @raise [NotFoundError] if the bundle does not exist # @raise [AuthenticationError] on invalid credentials # @raise [NetworkError] on connection failure - def duplicate_bundle(id) - client = get_client - unwrap(client.post("/v1/bundles/#{id}/duplicate")) + def duplicate_bundle(id, name: nil) + post_duplicate("/v1/bundles/#{id}/duplicate", name) end # ============================================ @@ -792,10 +900,12 @@ def update_company(id, request) unwrap(client.patch("/v1/companies/#{id}", request)) end - # Delete a company. + # Delete a company and its contacts. Refused while it has open quotes (draft or + # sent) -- see +get_company_open_quotes+. # # @param id [String] # @return [Hash] { "message" => "..." } + # @raise [ConflictError] with code CompanyHasOpenQuotes # @raise [NotFoundError] if the company does not exist # @raise [AuthenticationError] on invalid credentials # @raise [NetworkError] on connection failure @@ -804,6 +914,17 @@ def delete_company(id) client.delete("/v1/companies/#{id}") end + # The open quotes (draft or sent) for a company: what blocks deleting it. + # + # @param company_id [String] + # @return [Hash] { "results" => [{ "id", "quoteNumber", "name", "status", "updatedOn" }], "totalRecords" => N } + # @raise [AuthenticationError] on invalid credentials + # @raise [NetworkError] on connection failure + def get_company_open_quotes(company_id) + client = get_client + client.get("/v1/companies/#{company_id}/open-quotes") + end + # List contacts for a company. # # @param company_id [String] @@ -874,10 +995,12 @@ def update_contact(id, request) unwrap(client.patch("/v1/contacts/#{id}", request)) end - # Delete a contact. + # Delete a contact. Refused when they are the company's only contact, or the contact on + # open quotes -- see +get_contact_open_quotes+. # # @param id [String] # @return [Hash] { "message" => "..." } + # @raise [ConflictError] with code LastContact or ContactHasOpenQuotes # @raise [NotFoundError] if the contact does not exist # @raise [AuthenticationError] on invalid credentials # @raise [NetworkError] on connection failure @@ -886,6 +1009,17 @@ def delete_contact(id) client.delete("/v1/contacts/#{id}") end + # The open quotes (draft or sent) made out to a contact: what blocks deleting them. + # + # @param contact_id [String] + # @return [Hash] { "results" => [...], "totalRecords" => N } + # @raise [AuthenticationError] on invalid credentials + # @raise [NetworkError] on connection failure + def get_contact_open_quotes(contact_id) + client = get_client + client.get("/v1/contacts/#{contact_id}/open-quotes") + end + # ============================================ # TEMPLATES # ============================================ @@ -1067,6 +1201,17 @@ def update_quote_number_config(format) response["results"] end + # The number the next new quote will get. Read only -- the counter doesn't move, so a quote + # created by someone else in between takes it first. + # + # @return [Hash] { "nextNumber" => String, "sequence" => Integer, "canEdit" => Boolean } + # @raise [AuthenticationError] on invalid credentials + # @raise [NetworkError] on connection failure + def get_next_quote_number + client = get_client + client.get("/v1/quotes/number-config/next")["results"] + end + # ============================================ # CONVENIENCE # ============================================ @@ -1137,32 +1282,37 @@ def to_query_params(request) params.empty? ? nil : params end + # POST to a duplicate endpoint: the copy's name when given, else no body (the API names + # the copy "Copy of ..."). + def post_duplicate(path, name) + client = get_client + unwrap(name.nil? ? client.post(path) : client.post(path, { "name" => name })) + end + # Build form data hash for product creation/update with images. def build_product_form_data(data_fields, images) form_data = {} form_data["data"] = JSON.generate(data_fields) - file_parts = [] - images.each do |image| + file_parts = image_parts(images, io_filename: "image.bin") + form_data["images"] = file_parts unless file_parts.empty? + form_data + end + + # One multipart part per image: a path keeps its basename, an IO is named + # +io_filename+ (default image.). The type comes from the bytes. + def image_parts(images, io_filename: nil) + images.filter_map do |image| if image.is_a?(String) - # File path content = File.binread(image) - file_parts << { - io: StringIO.new(content), - filename: File.basename(image), - content_type: detect_image_type(content) - } + { io: StringIO.new(content), filename: File.basename(image), content_type: detect_image_type(content) } elsif image.respond_to?(:read) content = image.read - file_parts << { - io: StringIO.new(content), - filename: "image.bin", - content_type: detect_image_type(content) - } + type = detect_image_type(content) + ext = { "image/png" => "png", "image/jpeg" => "jpg" }.fetch(type, "bin") + { io: StringIO.new(content), filename: io_filename || "image.#{ext}", content_type: type } end end - form_data["images"] = file_parts unless file_parts.empty? - form_data end # Detect MIME type for product images from magic bytes. diff --git a/packages/ruby-sdk/spec/constants_spec.rb b/packages/ruby-sdk/spec/constants_spec.rb index 2ae13e4f..a8b556be 100644 --- a/packages/ruby-sdk/spec/constants_spec.rb +++ b/packages/ruby-sdk/spec/constants_spec.rb @@ -3,9 +3,8 @@ RSpec.describe "TurboDocxSdk Constants" do describe "QuoteStatus" do - it "defines all 6 status values" do + it "defines all 5 status values" do expect(TurboDocxSdk::QuoteStatus::DRAFT).to eq("draft") - expect(TurboDocxSdk::QuoteStatus::PENDING_APPROVAL).to eq("pending_approval") expect(TurboDocxSdk::QuoteStatus::SENT).to eq("sent") expect(TurboDocxSdk::QuoteStatus::ACCEPTED).to eq("accepted") expect(TurboDocxSdk::QuoteStatus::DECLINED).to eq("declined") @@ -14,7 +13,7 @@ it "provides ALL constant" do expect(TurboDocxSdk::QuoteStatus::ALL).to contain_exactly( - "draft", "pending_approval", "sent", "accepted", "declined", "voided" + "draft", "sent", "accepted", "declined", "voided" ) end end diff --git a/packages/ruby-sdk/spec/http_client_put_spec.rb b/packages/ruby-sdk/spec/http_client_put_spec.rb new file mode 100644 index 00000000..3be2f8d0 --- /dev/null +++ b/packages/ruby-sdk/spec/http_client_put_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "spec_helper" + +# HttpClient#put — a JSON PUT (used by reorder_line_item_images), unwrapped like every other call, +# and the 409 a refused quote delete comes back as. Only the network send is stubbed. +RSpec.describe TurboDocxSdk::HttpClient do + let(:client) { described_class.new(api_key: "test-key", skip_sender_validation: true) } + + it "sends a JSON PUT and unwraps { data }" do + sent = nil + ok = Net::HTTPOK.new("1.1", "200", "OK") + ok["content-type"] = "application/json" + allow(ok).to receive(:body).and_return(JSON.generate({ "data" => { "results" => [{ "id" => "img-2" }] } })) + allow(client).to receive(:http_request) do |request, _url| + sent = request + ok + end + + result = client.put("/v1/quotes/q-1/items/li-1/images/order", { "imageIds" => ["img-2"] }) + + expect(sent).to be_a(Net::HTTP::Put) + expect(sent.path).to end_with("/v1/quotes/q-1/items/li-1/images/order") + expect(JSON.parse(sent.body)).to eq({ "imageIds" => ["img-2"] }) + expect(result["results"]).to eq([{ "id" => "img-2" }]) + end + + it "maps a refused quote delete (409) to ConflictError with its code" do + message = "A sent quote can't be deleted. Void it instead." + response = double(code: "409", message: "Conflict", body: JSON.generate({ + "message" => message, + "error" => { "message" => message, "code" => "CANNOT_DELETE_SENT_QUOTE" }, + "data" => {} + })) + + expect { client.send(:handle_error_response, response) }.to raise_error(TurboDocxSdk::ConflictError) { |error| + expect(error.code).to eq("CANNOT_DELETE_SENT_QUOTE") + expect(error.message).to eq(message) + } + end +end diff --git a/packages/ruby-sdk/spec/turbo_quote_lists_and_images_spec.rb b/packages/ruby-sdk/spec/turbo_quote_lists_and_images_spec.rb new file mode 100644 index 00000000..55355bef --- /dev/null +++ b/packages/ruby-sdk/spec/turbo_quote_lists_and_images_spec.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +require "spec_helper" + +# TurboQuote: list sorting and filters, the quote summary / creators / next number reads, +# open quotes, product quote images, custom line item images, named duplicates, company +# email and price book reset. +RSpec.describe TurboDocxSdk::TurboQuote do + let(:mock_client) { instance_double(TurboDocxSdk::HttpClient) } + let(:png) { "\x89PNG\r\n\x1a\n\x00".b } + let(:jpeg) { "\xFF\xD8\xFF\xE0\x00".b } + + before do + described_class.instance_variable_set(:@client, nil) + allow(TurboDocxSdk::HttpClient).to receive(:new).and_return(mock_client) + end + + # ============================================ + # SORTING AND FILTERS + # ============================================ + + describe ".list_quotes" do + it "sends the sort, the new filters and the summary currency" do + allow(mock_client).to receive(:get).and_return({ "results" => [], "totalRecords" => 0 }) + + described_class.list_quotes( + "sortBy" => "total", + "sortDirection" => "asc", + "statuses" => %w[sent expired], + "companyIds" => %w[c-1 c-2], + "currencies" => %w[USD EUR], + "createdBy" => ["u-1"], + "createdByApiKeys" => true, + "validUntilWindow" => "thisMonth", + "validUntilFrom" => "2026-10-01", + "validUntilTo" => "2026-10-31", + "summaryCurrency" => "EUR" + ) + + expect(mock_client).to have_received(:get).with("/v1/quotes", { + "sortBy" => "total", + "sortDirection" => "asc", + "statuses" => %w[sent expired], + "companyIds" => %w[c-1 c-2], + "currencies" => %w[USD EUR], + "createdBy" => ["u-1"], + "createdByApiKeys" => "true", + "validUntilWindow" => "thisMonth", + "validUntilFrom" => "2026-10-01", + "validUntilTo" => "2026-10-31", + "summaryCurrency" => "EUR" + }) + end + + it "returns the tab counts, the summary and each quote's expiry flag" do + allow(mock_client).to receive(:get).and_return({ + "results" => [{ "id" => "q-1", "isExpired" => true, "lineItemCount" => 3 }], + "totalRecords" => 1, + "statusCounts" => { "expired" => 1 }, + "summary" => { "currency" => "USD", "winRate" => { "percent" => nil } } + }) + + result = described_class.list_quotes + + expect(result["statusCounts"]["expired"]).to eq(1) + expect(result["summary"]["winRate"]["percent"]).to be_nil + expect(result["results"][0]["isExpired"]).to be(true) + expect(result["results"][0]["lineItemCount"]).to eq(3) + end + end + + it "list_products sends several billing frequencies and currencies, and the sort" do + allow(mock_client).to receive(:get).and_return({ "results" => [], "totalRecords" => 0 }) + + described_class.list_products( + "billingFrequency" => %w[monthly annual], "currency" => %w[USD INR], + "sortBy" => "listPrice", "sortDirection" => "desc" + ) + + expect(mock_client).to have_received(:get).with("/v1/products", { + "billingFrequency" => %w[monthly annual], "currency" => %w[USD INR], + "sortBy" => "listPrice", "sortDirection" => "desc" + }) + end + + it "list_price_books, list_bundles, list_companies, list_types send the sort" do + allow(mock_client).to receive(:get).and_return({ "results" => [], "totalRecords" => 0 }) + + described_class.list_price_books("sortBy" => "overrideCount", "sortDirection" => "desc") + described_class.list_bundles("currency" => %w[USD EUR], "sortBy" => "itemCount", "sortDirection" => "asc") + described_class.list_companies("sortBy" => "contactCount", "sortDirection" => "desc") + described_class.list_types("categoryType" => "product_category", "sortBy" => "usageCount", "sortDirection" => "desc") + + expect(mock_client).to have_received(:get).with("/v1/pricebooks", { "sortBy" => "overrideCount", "sortDirection" => "desc" }) + expect(mock_client).to have_received(:get).with("/v1/bundles", { "currency" => %w[USD EUR], "sortBy" => "itemCount", "sortDirection" => "asc" }) + expect(mock_client).to have_received(:get).with("/v1/companies", { "sortBy" => "contactCount", "sortDirection" => "desc" }) + expect(mock_client).to have_received(:get).with("/v1/types", { + "categoryType" => "product_category", "sortBy" => "usageCount", "sortDirection" => "desc" + }) + end + + it "list_contacts sends several companies and the sort" do + allow(mock_client).to receive(:get).and_return({ "results" => [], "totalRecords" => 0 }) + + described_class.list_contacts("companyIds" => %w[c-1 c-2], "sortBy" => "name", "sortDirection" => "asc") + + expect(mock_client).to have_received(:get).with("/v1/contacts", { + "companyIds" => %w[c-1 c-2], "sortBy" => "name", "sortDirection" => "asc" + }) + end + + it "list_company_contacts sends the sort and returns the company's contact count" do + allow(mock_client).to receive(:get).and_return({ "results" => [], "totalRecords" => 0, "company" => { "contactCount" => 1 } }) + + result = described_class.list_company_contacts("c-1", "sortBy" => "createdOn", "sortDirection" => "desc") + + expect(mock_client).to have_received(:get).with("/v1/companies/c-1/contacts", { "sortBy" => "createdOn", "sortDirection" => "desc" }) + expect(result["company"]["contactCount"]).to eq(1) + end + + # ============================================ + # QUOTE READS + # ============================================ + + describe ".get_quote_summary" do + it "gets the summary for the filters and unwraps it" do + summary = { "currency" => "EUR", "openPipeline" => { "total" => 10, "count" => 1 } } + allow(mock_client).to receive(:get).and_return({ "summary" => summary }) + + result = described_class.get_quote_summary("currencies" => %w[EUR USD], "summaryCurrency" => "EUR") + + expect(mock_client).to have_received(:get).with("/v1/quotes/summary", { "currencies" => %w[EUR USD], "summaryCurrency" => "EUR" }) + expect(result).to eq(summary) + end + + it "sends no params without options" do + allow(mock_client).to receive(:get).and_return({ "summary" => {} }) + + described_class.get_quote_summary + + expect(mock_client).to have_received(:get).with("/v1/quotes/summary", nil) + end + end + + it "list_quote_creators returns the people and API keys who created quotes" do + creators = [ + { "id" => "u-1", "name" => "Ana Lee", "email" => "ana@acme.test", "isAPIKey" => false, "apiKeyDeleted" => false }, + { "id" => "u-2", "name" => "Zapier key", "email" => nil, "isAPIKey" => true, "apiKeyDeleted" => true } + ] + allow(mock_client).to receive(:get).and_return({ "results" => creators }) + + expect(described_class.list_quote_creators).to eq(creators) + expect(mock_client).to have_received(:get).with("/v1/quotes/creators") + end + + it "get_next_quote_number returns the next number" do + next_number = { "nextNumber" => "Q-2026-0042", "sequence" => 42, "canEdit" => true } + allow(mock_client).to receive(:get).and_return({ "results" => next_number }) + + expect(described_class.get_next_quote_number).to eq(next_number) + expect(mock_client).to have_received(:get).with("/v1/quotes/number-config/next") + end + + # ============================================ + # OPEN QUOTES + # ============================================ + + it "get_company_open_quotes and get_contact_open_quotes list what blocks a delete" do + response = { "results" => [{ "id" => "q-1", "quoteNumber" => "Q-1", "status" => "sent" }], "totalRecords" => 1 } + allow(mock_client).to receive(:get).and_return(response) + + expect(described_class.get_company_open_quotes("c-1")).to eq(response) + expect(described_class.get_contact_open_quotes("ct-1")).to eq(response) + expect(mock_client).to have_received(:get).with("/v1/companies/c-1/open-quotes") + expect(mock_client).to have_received(:get).with("/v1/contacts/ct-1/open-quotes") + end + + # ============================================ + # IMAGES + # ============================================ + + it "get_product_quote_images posts the product ids" do + images = { "p-1" => [{ "id" => "i-1" }], "p-2" => [] } + allow(mock_client).to receive(:post).and_return({ "results" => images }) + + expect(described_class.get_product_quote_images(%w[p-1 p-2])).to eq(images) + expect(mock_client).to have_received(:post).with("/v1/products/quote-images", { "productIds" => %w[p-1 p-2] }) + end + + describe "custom line item images" do + it "get_quote_line_item_images returns images keyed by line item" do + by_item = { "li-1" => [{ "id" => "img-1" }] } + allow(mock_client).to receive(:get).and_return({ "results" => by_item }) + + expect(described_class.get_quote_line_item_images("q-1")).to eq(by_item) + expect(mock_client).to have_received(:get).with("/v1/quotes/q-1/items/images") + end + + it "get_line_item_images returns one item's images" do + allow(mock_client).to receive(:get).and_return({ "results" => [{ "id" => "img-1" }] }) + + expect(described_class.get_line_item_images("q-1", "li-1")).to eq([{ "id" => "img-1" }]) + expect(mock_client).to have_received(:get).with("/v1/quotes/q-1/items/li-1/images") + end + + it "add_line_item_images uploads 'images' parts with detected types and no data part" do + captured = nil + allow(mock_client).to receive(:post_form_data) do |path, form| + captured = [path, form] + { "results" => [{ "id" => "img-1" }, { "id" => "img-2" }] } + end + + result = described_class.add_line_item_images("q-1", "li-1", [StringIO.new(png), StringIO.new(jpeg)]) + + path, form = captured + expect(path).to eq("/v1/quotes/q-1/items/li-1/images") + expect(form.keys).to eq(["images"]) + expect(form["images"].map { |part| part[:content_type] }).to eq(%w[image/png image/jpeg]) + expect(form["images"].map { |part| part[:filename] }).to eq(%w[image.png image.jpg]) + expect(result.size).to eq(2) + end + + it "reorder_line_item_images puts the ids first to last" do + allow(mock_client).to receive(:put).and_return({ "results" => [{ "id" => "img-2" }, { "id" => "img-1" }] }) + + result = described_class.reorder_line_item_images("q-1", "li-1", %w[img-2 img-1]) + + expect(mock_client).to have_received(:put).with("/v1/quotes/q-1/items/li-1/images/order", { "imageIds" => %w[img-2 img-1] }) + expect(result.first["id"]).to eq("img-2") + end + + it "remove_line_item_image returns the images left" do + allow(mock_client).to receive(:delete).and_return({ "results" => [{ "id" => "img-2" }] }) + + expect(described_class.remove_line_item_image("q-1", "li-1", "img-1")).to eq([{ "id" => "img-2" }]) + expect(mock_client).to have_received(:delete).with("/v1/quotes/q-1/items/li-1/images/img-1") + end + end + + # ============================================ + # NAMED DUPLICATES + # ============================================ + + { + duplicate_quote: "/v1/quotes/x-1/duplicate", + duplicate_product: "/v1/products/x-1/duplicate", + duplicate_price_book: "/v1/pricebooks/x-1/duplicate", + duplicate_bundle: "/v1/bundles/x-1/duplicate" + }.each do |method, path| + it "#{method} sends the name for the copy" do + allow(mock_client).to receive(:post).and_return({ "result" => { "id" => "x-2" } }) + + expect(described_class.public_send(method, "x-1", name: "Q4 copy")).to eq({ "id" => "x-2" }) + expect(mock_client).to have_received(:post).with(path, { "name" => "Q4 copy" }) + end + + it "#{method} without a name sends no body" do + allow(mock_client).to receive(:post).and_return({ "result" => { "id" => "x-2" } }) + + described_class.public_send(method, "x-1") + + expect(mock_client).to have_received(:post).with(path) + end + end + + # ============================================ + # REQUEST FIELDS + # ============================================ + + it "create_company sends the company email" do + allow(mock_client).to receive(:post).and_return({ "result" => { "id" => "c-1" } }) + + described_class.create_company( + "name" => "Acme", "email" => "ap@acme.test", "contacts" => [{ "name" => "Ana", "email" => "ana@acme.test" }] + ) + + expect(mock_client).to have_received(:post).with("/v1/companies", hash_including("email" => "ap@acme.test")) + end + + it "update_company sends an explicit null email" do + allow(mock_client).to receive(:patch).and_return({ "result" => { "id" => "c-1" } }) + + described_class.update_company("c-1", { "email" => nil }) + + expect(mock_client).to have_received(:patch).with("/v1/companies/c-1", { "email" => nil }) + end + + it "update_price_book sends resetProductPricing" do + allow(mock_client).to receive(:patch).and_return({ "result" => { "id" => "pb-1" } }) + + described_class.update_price_book("pb-1", { "resetProductPricing" => true }) + + expect(mock_client).to have_received(:patch).with("/v1/pricebooks/pb-1", { "resetProductPricing" => true }) + end +end