---
name: quote-to-cash
description: Build the product catalog, assemble quotes (line items + associations to deals), and track invoices and subscriptions through to revenue.
triggers:
  - "create product"
  - "product catalog"
  - "build a quote"
  - "create quote"
  - "line items"
  - "associate line items"
  - "outstanding invoices"
  - "overdue invoices"
  - "active subscriptions"
  - "quote to cash"
---

## Resources

| File | When to use |
|---|---|
| `resources/q2c-essentials.md` | Six-field cheat sheet, association directions, portal caveats for invoices/subscriptions/orders/carts. |

## Foundations

Read `bulk-operations/SKILL.md` first — JSONL piping, batch read, pagination, and the dry-run/digest/confirm flow for destructive ops live there. Reshape recipes (read → write payload) are in `bulk-operations/resources/json-patterns.md`.

`hubspot <command> --help` is the source of truth. Object types are plural (`products`, `line_items`, `quotes`, `invoices`, `subscriptions`). Never hardcode property tables — `hubspot properties list --type <type>` is one call away. Verify any enum value the agent is about to write with `hubspot properties options-list --type <type> <property> | jq -r '.value'`.

Portal note: `invoices`, `subscriptions`, `orders`, `carts` show an empty `objectTypeId` in `hubspot objects types`. They work through `objects search`/`list` when the token has the matching scope (`invoices-read`, `subscriptions-read`, etc.) and 403 otherwise. CLI-created quotes are always `DRAFT`; approval routing, share links, PDF generation, and invoice creation usually require the HubSpot UI.

## 1. Create a product

```bash
hubspot objects create --type products \
  --property name="Enterprise License" \
  --property price=12000 \
  --property hs_sku=ENT-001
```

For a recurring product set `recurringbillingfrequency`; check the API enum values first with `hubspot properties options-list --type products recurringbillingfrequency | jq -r '.value'`. Bulk-import a catalog by piping JSONL of `{"properties":{...}}` to `hubspot objects create --type products --dry-run`.

## 2. Build a quote: line items → quote → associations

`objects create` emits one result line per stdin line, in input order. That lets you build line items, capture their IDs, and associate them to the new quote in three pipes — no per-record shell loop.

```bash
DEAL_ID=12345

# 1. Create the line items. items.jsonl holds {"name":..,"qty":..,"price":..,"product_id":..} per line.
jq -c '{properties:{
    name:.name, quantity:(.qty|tostring), price:(.price|tostring),
    hs_product_id:.product_id, hs_line_item_currency_code:"USD"
  }}' items.jsonl \
| hubspot objects create --type line_items > /tmp/lineitems.jsonl

# 2. Create the quote.
QUOTE_ID=$(hubspot objects create --type quotes \
  --property hs_title="Acme Corp - 2026" \
  --property hs_expiration_date=2026-06-30 \
  --property hs_currency=USD \
  --format json | jq -r '.data.id // .id')

# 3. Associate every new line item to the quote in one pipe.
jq -r '.id' /tmp/lineitems.jsonl \
| jq -cR --arg q "$QUOTE_ID" '{from:("quotes:" + $q), to:("line_items:" + .)}' \
| hubspot associations create

# 4. Link the quote to the deal.
hubspot associations create --from "deals:$DEAL_ID" --to "quotes:$QUOTE_ID"
```

Discount handling — `discount` is the writable percentage (`10` = 10% off). `hs_total_discount` is HubSpot-computed; do not set it. Verify with `hubspot properties get --type line_items hs_total_discount` (property name is positional; look for `modificationMetadata.readOnlyValue:true`) before relying on this in a portal you don't own.

Promote a quote out of `DRAFT` when ready to share. `objects update` is irreversible — dry-run first, then re-run with the digest and `--confirm <quote_id>`:

```bash
hubspot objects update --type quotes <quote_id> --property hs_status=APPROVAL_NOT_NEEDED --dry-run
hubspot objects update --type quotes <quote_id> --property hs_status=APPROVAL_NOT_NEEDED --digest <hash> --confirm <quote_id>
```

Verify `hs_status` enum values for your portal: `hubspot properties options-list --type quotes hs_status | jq -r '.value'`.

## 3. Track invoices

The CLI reads invoice data and updates status; creation usually needs HubSpot Commerce + UI. Filter by `hs_invoice_status` and date.

```bash
# All outstanding invoices
hubspot objects search --type invoices \
  --filter "hs_invoice_status=OUTSTANDING" \
  --properties hs_number,hs_amount_billed,hs_balance,hs_due_date

# Past-due (overdue) invoices, dynamic date
hubspot objects search --type invoices \
  --filter "hs_due_date<$(date +%Y-%m-%d) AND hs_invoice_status!=PAID" \
  --properties hs_number,hs_due_date,hs_balance

# Invoices billed in the last 30 days
hubspot objects search --type invoices \
  --filter "hs_invoice_date>=$(date -v-30d +%Y-%m-%d 2>/dev/null || date -d '30 days ago' +%Y-%m-%d)" \
  --properties hs_number,hs_amount_billed,hs_invoice_date
```

Verify the status enum the same way: `hubspot properties options-list --type invoices hs_invoice_status | jq -r '.value'`.

## 4. Track subscriptions

Same shape, filter on `hs_subscription_status`. Verify the enum values before writing the filter — do not hardcode `ACTIVE`/`CANCELLED`/`PAST_DUE`:

```bash
hubspot properties options-list --type subscriptions hs_subscription_status | jq -r '.value'

# Then filter (case matters)
hubspot objects search --type subscriptions \
  --filter "hs_subscription_status=<value-from-above>" \
  --properties hs_mrr,hs_arr,hs_subscription_status

# Sum MRR across active subs
hubspot objects search --type subscriptions \
  --filter "hs_subscription_status=<active-value>" --format json \
  | jq '[.data[].properties.hs_mrr | select(. != null and . != "") | tonumber] | add // 0'
```

## Known constraints

- `invoices`, `subscriptions`, `orders`, `carts`, `payments` need the matching read scope on the active token; 403 means the user OAuth login or private-app token is missing the scope.
- `objects delete` on products/quotes/line_items works under both user OAuth (`hubspot auth login`, with the object's write scope) and a service key (`export HUBSPOT_ACCESS_TOKEN=<token>`); a 403 means the active token is missing that write scope. The exception is the `--gdpr` permanent purge, which requires a service key — the GDPR endpoint does not accept user OAuth tokens. See `bulk-operations/SKILL.md` for the dry-run → digest → confirm flow before bulk-deleting catalog records.
- Quote share links, PDF generation, approval routing, and from-scratch invoice creation are UI-only — the CLI updates records but cannot send a quote to a customer.
- `hs_total_discount` on line items is read-only — set `discount` (percentage) instead.
