From 798626f9cbd1dad461f3f0950ced2caa8f0b9e0c Mon Sep 17 00:00:00 2001 From: Santi Date: Sat, 26 Sep 2026 19:08:49 -0400 Subject: [PATCH] feat(quotes): add quote commands Add dualentry quotes: list with quote filters, get by number or QT- ID with totals and signing order, create, partial update with --display-options, submit, send with or without a PDF, and a JSON template. Co-authored-by: Cursor --- CHANGELOG.md | 5 + README.md | 18 ++- src/dualentry_cli/commands/quotes.py | 208 +++++++++++++++++++++++++++ src/dualentry_cli/main.py | 2 + src/dualentry_cli/output.py | 116 +++++++++++++++ tests/test_quotes.py | 206 ++++++++++++++++++++++++++ 6 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 src/dualentry_cli/commands/quotes.py create mode 100644 tests/test_quotes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e0634a3..4e075d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [Unreleased] + +- Added `dualentry quotes` for CPQ quotes: `list` (search, status, approval status, company, customer, valid-until range, ordering), `get` by number or `QT-` ID, `create`, `update` (partial, with `--display-options`), `submit`, `send` (with `--pdf` or `--no-pdf`), and `template`. +- `quotes get` shows lines with billing frequency and service period, totals (one-time, recurring per cadence, MRR, ARR, first invoice, TCV or Ongoing, per year), and the signing order. + ## [0.1.18] - 2026-09-01 diff --git a/README.md b/README.md index b6a4d4e..33153f3 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,22 @@ dualentry invoices list dualentry invoices create --file invoice.json ``` +### Quote, submit, and send + +Quotes need the CPQ subscription. On recurring lines, `rate` is the price per billing period. + +```bash +dualentry quotes template --output quote.json # edit it, then: +dualentry quotes create --file quote.json # "draft" saves; "posted" submits for approval +dualentry quotes update 12 --display-options '{"headline_total": "annual", "year_by_year": true}' +dualentry quotes submit 12 +dualentry quotes list --approval-status approved +dualentry quotes get QT-45 # lines, recipients, totals (MRR, ARR, TCV, per year) +dualentry quotes send 12 --pdf quote-12.pdf # or --no-pdf +``` + +Approvers approve or reject quotes in DualEntry. The API does not render quote PDFs, so `send` attaches a PDF you downloaded from DualEntry, or sends without one. + ### Export transactions for a date range ```bash @@ -76,7 +92,7 @@ dualentry bills list --status posted --format json | Category | Resources | |----------|-----------| -| **Receivables** | Invoices, Sales Orders, Customer Payments, Credits, Deposits | +| **Receivables** | Quotes, Invoices, Sales Orders, Customer Payments, Credits, Deposits | | **Payables** | Bills, Purchase Orders, Vendor Payments, Credits, Refunds | | **Accounting** | Journal Entries, Bank Transfers, Fixed Assets, Depreciation | | **Master Data** | Customers, Vendors, Items, Accounts, Classifications | diff --git a/src/dualentry_cli/commands/quotes.py b/src/dualentry_cli/commands/quotes.py new file mode 100644 index 0000000..b17f448 --- /dev/null +++ b/src/dualentry_cli/commands/quotes.py @@ -0,0 +1,208 @@ +"""Quote (CPQ) commands.""" + +from __future__ import annotations + +import base64 +import json +import re +from pathlib import Path + +import typer + +from dualentry_cli.commands import AllPages, Format, Limit, Offset, Search, Status +from dualentry_cli.commands.actions import load_json_file, make_action_app, run_list +from dualentry_cli.output import format_output + +app = make_action_app("Manage quotes (requires the CPQ subscription). Approvers approve or reject quotes in DualEntry; the API does not render quote PDFs or payment schedules.") + +_REFERENCE_RE = re.compile(r"^QT-?(\d+)$", re.IGNORECASE) +_DISPLAY_OPTION_KEYS = ("headline_total", "total_rows", "year_by_year") + +QUOTE_TEMPLATE = { + "company_id": 1, + "customer_id": 1, + "email": "buyer@example.com", + "currency_iso_4217_code": "USD", + "close_date": "2026-01-15", + "contract_start_date": "2026-02-01", + "contract_end_type": "after", + "number_of_months": 12, + "record_status": "draft", + "items": [ + {"item_id": 1, "quantity": 1, "rate": 5000, "position": 1, "memo": "Implementation"}, + {"item_id": 2, "quantity": 10, "rate": 50, "position": 2, "billing_frequency": "monthly", "memo": "Seats, per month"}, + ], + "recipients": [ + {"side": "buyer", "name": "Ana Diaz", "email": "buyer@example.com", "position": 1}, + {"side": "company", "name": "Sam Lee", "email": "sales@example.com", "position": 1}, + ], +} + +Quote = typer.Argument(help="Quote number (e.g. 12) or the QT- ID shown in DualEntry (e.g. QT-45)") +DisplayOptions = typer.Option( + None, + "--display-options", + help='Quote document settings as JSON, e.g. \'{"headline_total": "annual", "total_rows": ["one_time", "tcv"], "year_by_year": true}\'', +) + + +def _client(): + from dualentry_cli.main import get_client + + return get_client() + + +def _quote_number(client, reference: str) -> str: + """Resolve a quote number or QT- reference to the quote number the API routes on.""" + raw = reference.strip() + if match := _REFERENCE_RE.match(raw): + from dualentry_cli.client import APIError + + items = client.get("/quotes/", params={"id": int(match.group(1)), "limit": 1}).get("items", []) + if not items: + raise APIError(404, f"Quote {raw} not found.") + return str(items[0]["number"]) + if raw.isdigit(): + return raw + typer.secho(f"Error: '{reference}' is not a quote number or QT- ID.", fg=typer.colors.RED, err=True) + raise typer.Exit(code=2) + + +def _display_options(value: str | None) -> dict: + if not value: + return {} + try: + options = json.loads(value) + except json.JSONDecodeError as e: + typer.secho(f"Error: --display-options is not valid JSON: {e.msg}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=2) from None + if not isinstance(options, dict) or set(options) - set(_DISPLAY_OPTION_KEYS): + typer.secho(f"Error: --display-options accepts {', '.join(_DISPLAY_OPTION_KEYS)}.", fg=typer.colors.RED, err=True) + raise typer.Exit(code=2) + return {f"document_{key}": option for key, option in options.items()} + + +@app.command("list") +def list_quotes( + limit: int = Limit, + offset: int = Offset, + all_pages: bool = AllPages, + search: str | None = Search, + status: str | None = Status, + approval_status: str | None = typer.Option(None, "--approval-status", help="draft, pending_approval, approved or rejected"), + company: str | None = typer.Option(None, "--company", "-c", help="Filter by company ID"), + customer: str | None = typer.Option(None, "--customer", help="Filter by customer ID"), + valid_from: str | None = typer.Option(None, "--valid-from", help="Valid until on or after (YYYY-MM-DD)"), + valid_to: str | None = typer.Option(None, "--valid-to", help="Valid until on or before (YYYY-MM-DD)"), + ordering: str | None = typer.Option(None, "--ordering", help="Sort field, e.g. -amount, valid_until, customer__name"), + output: str = Format, +): + """List quotes.""" + run_list( + "quotes", + resource="quote", + limit=limit, + offset=offset, + all_pages=all_pages, + output=output, + search=search, + status=status, + approval_status=approval_status, + company_id=company, + customer_id=customer, + valid_until_from=valid_from, + valid_until_to=valid_to, + ordering=ordering, + ) + + +@app.command("get") +def get_quote(quote: str = Quote, output: str = Format): + """Get a quote with its lines, recipients, approval status, and totals.""" + client = _client() + format_output(client.get(f"/quotes/{_quote_number(client, quote)}/"), resource="quote", fmt=output) + + +@app.command("create") +def create_quote( + file: Path = typer.Option(..., "--file", "-f", help="JSON file with the quote (see `dualentry quotes template`)"), + display_options: str | None = DisplayOptions, + output: str = Format, +): + """ + Create a quote from a JSON file. + + record_status "posted" (the default) submits the quote for approval; "draft" saves it. + On recurring lines, rate is the price per billing period. + """ + payload = load_json_file(file) | _display_options(display_options) + format_output(_client().post("/quotes/", json=payload), resource="quote", fmt=output) + + +@app.command("update") +def update_quote( + quote: str = Quote, + file: Path | None = typer.Option(None, "--file", "-f", help="JSON file with the fields to change"), + display_options: str | None = DisplayOptions, + output: str = Format, +): + """ + Update a quote. Only the fields you send change. + + Sending "items" or "recipients" replaces the saved list: include each one to keep, with its id. + A line sent without billing fields becomes a one-time line. + """ + payload = (load_json_file(file) if file else {}) | _display_options(display_options) + if not payload: + typer.secho("Error: provide --file and/or --display-options.", fg=typer.colors.RED, err=True) + raise typer.Exit(code=2) + client = _client() + format_output(client.patch(f"/quotes/{_quote_number(client, quote)}/", json=payload), resource="quote", fmt=output) + + +@app.command("submit") +def submit_quote(quote: str = Quote, output: str = Format): + """Submit a draft or rejected quote for approval.""" + client = _client() + format_output(client.patch(f"/quotes/{_quote_number(client, quote)}/", json={"record_status": "posted"}), resource="quote", fmt=output) + + +@app.command("send") +def send_quote( + quote: str = Quote, + to: list[str] = typer.Option([], "--to", help="Recipient; repeat for more. Defaults to the quote's buyers"), + cc: list[str] = typer.Option([], "--cc", help="CC address; repeat for more"), + bcc: list[str] = typer.Option([], "--bcc", help="BCC address; repeat for more"), + subject: str | None = typer.Option(None, "--subject", help="Defaults to the organization's quote email subject"), + message: str | None = typer.Option(None, "--message", help="Defaults to the organization's quote email message"), + reply_to: str | None = typer.Option(None, "--reply-to", help="Defaults to the organization's reply-to address"), + pdf: Path | None = typer.Option(None, "--pdf", help="Quote PDF to attach (download it from DualEntry)"), + no_pdf: bool = typer.Option(False, "--no-pdf", help="Send without attaching the PDF"), +): + """Email an approved quote to its buyers and mark it sent.""" + if pdf and no_pdf: + typer.secho("Error: use --pdf or --no-pdf, not both.", fg=typer.colors.RED, err=True) + raise typer.Exit(code=2) + body: dict = {"to_emails": to, "cc_emails": cc, "bcc_emails": bcc, "subject": subject, "message": message, "reply_to": reply_to} + if pdf: + if not pdf.exists(): + typer.secho(f"Error: File not found: {pdf}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + body |= {"attach_pdf": True, "pdf_content": base64.b64encode(pdf.read_bytes()).decode()} + elif no_pdf: + body["attach_pdf"] = False + client = _client() + number = _quote_number(client, quote) + client.post(f"/quotes/{number}/send/", json={key: value for key, value in body.items() if value not in (None, [])}) + typer.secho(f"Quote {number} sent.", fg=typer.colors.GREEN) + + +@app.command("template") +def template(output_file: Path | None = typer.Option(None, "--output", "-o", help="Write the template to a file instead of stdout")): + """Output a sample quote JSON file.""" + content = json.dumps(QUOTE_TEMPLATE, indent=2) + if output_file: + output_file.write_text(content + "\n") + typer.secho(f"Template written to {output_file}", fg=typer.colors.GREEN) + else: + typer.echo(content) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 5036fd0..154d670 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -10,6 +10,7 @@ from dualentry_cli.commands.accounts import app as accounts_app from dualentry_cli.commands.bank_match import app as bank_match_app from dualentry_cli.commands.ije_extras import IJE_CHECKS, IJE_ONLINE_EXTRA_CHECKS, IJE_TEMPLATE +from dualentry_cli.commands.quotes import app as quotes_app from dualentry_cli.config import Config app = typer.Typer(name="dualentry", help="DualEntry accounting CLI", no_args_is_help=True, cls=HelpfulGroup) @@ -31,6 +32,7 @@ app.add_typer(accounts_app, name="accounts") # Accounts has custom filtering (no status/date filters) # Money-in +app.add_typer(quotes_app, name="quotes") # Quotes have their own filters and actions (submit, send) app.add_typer(make_resource_app("sales orders", "sales-order", "sales-orders", has_number=True, filters=TXN_CUSTOMER), name="sales-orders") app.add_typer(make_resource_app("customer payments", "customer-payment", "customer-payments", has_number=True, filters=TXN_CUSTOMER), name="customer-payments") app.add_typer(make_resource_app("customer credits", "customer-credit", "customer-credits", has_number=True, filters=TXN_CUSTOMER), name="customer-credits") diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index c9c254d..ab56393 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -34,6 +34,7 @@ "intercompany-journal-entry": "IJE", "bank-transfer": "BT", "fixed-asset": "FA", + "quote": "QT", } @@ -767,6 +768,121 @@ def _fixed_asset_detail(r): _register("fixed-asset", _fixed_asset_list, _fixed_asset_detail) +# ── Quote ──────────────────────────────────────────────────────────── + +_APPROVAL_COLORS = {"draft": "yellow", "pending_approval": "cyan", "approved": "green", "rejected": "red"} +_CADENCE_LABELS = { + "one_time": "One-time", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "quarterly": "Quarterly", + "semi_annually": "Semi-annually", + "annually": "Annually", +} + + +def _approval_badge(status: str | None) -> str: + if not status: + return "-" + color = _APPROVAL_COLORS.get(status, "white") + return f"[{color}]{status.replace('_', ' ')}[/{color}]" + + +def _cadence(frequency: str | None, interval: int | None) -> str: + label = _CADENCE_LABELS.get(frequency or "", frequency or "-") + return f"{label} x{interval}" if interval and interval > 1 else label + + +def _quote_list(items): + table = Table(title="Quotes", show_lines=False) + table.add_column("ID", style="dim") + table.add_column("#", style="bold", justify="right") + table.add_column("Customer", min_width=20) + table.add_column("Contract Start", justify="center") + table.add_column("Valid Until", justify="center") + table.add_column("Amount", justify="right", style="bold") + table.add_column("Approval") + + for r in items: + table.add_row( + _fmt_id(r.get("id"), "quote"), + str(r.get("number", "-")), + r.get("customer_name") or r.get("name") or "-", + r.get("contract_start_date", "-"), + r.get("valid_until", "-"), + _money(r.get("amount"), r.get("currency_iso_4217_code", "")), + _approval_badge(r.get("approval_status")), + ) + + console.print(table) + + +def _quote_detail(r): + currency = r.get("currency_iso_4217_code", "") + header = Text() + header.append("QUOTE", style="bold") + header.append(f" #{r.get('number', '')} {_fmt_id(r.get('id'), 'quote')}", style="bold cyan") + status = r.get("approval_status") or "" + header.append(f" {status.replace('_', ' ').upper()}", style=_APPROVAL_COLORS.get(status, "white")) + if r.get("sent_at"): + header.append(" SENT", style="green") + console.print(Panel(header, expand=False)) + + end = {"after": f"after {r.get('number_of_months')} months ({r.get('contract_end_date')})", "date": r.get("contract_end_date"), "ongoing": "ongoing"} + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", min_width=16) + details.add_column() + details.add_row("Customer:", r.get("customer_name") or r.get("name") or "-") + details.add_row("Company:", r.get("company_name", "-")) + details.add_row("Contract:", f"{r.get('contract_start_date', '-')} to {end.get(r.get('contract_end_type'), '-')}") + details.add_row("Valid Until:", r.get("valid_until", "-")) + if r.get("memo"): + details.add_row("Memo:", r["memo"]) + console.print(details) + + lines = Table(title="Lines", show_lines=False) + lines.add_column("Item", min_width=18) + lines.add_column("Billing") + lines.add_column("Service Period", justify="center") + lines.add_column("Qty", justify="right") + lines.add_column("Rate", justify="right") + lines.add_column("Line TCV", justify="right", style="bold") + for line in r.get("items") or []: + period = f"{line.get('billing_start_date') or '-'} to {line.get('billing_end_date') or 'ongoing'}" + item = line.get("item_name", "-") + (" (ramp)" if line.get("ramp_group_id") else "") + lines.add_row( + item, + _cadence(line.get("billing_frequency"), line.get("billing_interval")), + period, + _fmt_decimal(line.get("quantity")), + _money(line.get("rate"), currency), + _money(line.get("line_tcv"), currency) if line.get("line_tcv") is not None else "ongoing", + ) + console.print(lines) + + totals = r.get("totals") or {} + summary = Table.grid(padding=(0, 2)) + summary.add_column(style="dim", min_width=16) + summary.add_column(justify="right") + summary.add_row("One-time:", _money((totals.get("one_time") or {}).get("total"), currency)) + for bucket in totals.get("recurring") or []: + summary.add_row(f"{_cadence(bucket.get('billing_frequency'), bucket.get('billing_interval'))}:", _money(bucket.get("total"), currency)) + summary.add_row("MRR / ARR:", f"{_money(totals.get('mrr'), currency)} / {_money(totals.get('arr'), currency)}") + summary.add_row("First Invoice:", _money(totals.get("first_invoice_amount"), currency)) + summary.add_row("TCV:", _money(totals.get("tcv"), currency) if totals.get("tcv") is not None else "Ongoing") + for year in totals.get("years") or []: + summary.add_row(f"Year {year.get('year')}:", _money(year.get("total"), currency)) + console.print(summary) + + signers = sorted((p for p in r.get("recipients") or [] if p.get("signing_order")), key=lambda p: p["signing_order"]) + if signers: + console.print("[dim]Signing order:[/dim] " + ", ".join(f"{p['signing_order']}. {p.get('name')} ({p.get('side')})" for p in signers)) + + +_register("quote", _quote_list, _quote_detail) + + # ── Customer ───────────────────────────────────────────────────────── diff --git a/tests/test_quotes.py b/tests/test_quotes.py new file mode 100644 index 0000000..cc3983d --- /dev/null +++ b/tests/test_quotes.py @@ -0,0 +1,206 @@ +import base64 +import json +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from dualentry_cli.client import APIError +from dualentry_cli.main import app + +runner = CliRunner() + +QUOTE = { + "id": 45, + "number": 12, + "approval_status": "approved", + "customer_name": "Acme Example", + "company_name": "Example Co", + "currency_iso_4217_code": "USD", + "contract_start_date": "2026-01-01", + "contract_end_type": "after", + "number_of_months": 12, + "contract_end_date": "2026-12-31", + "valid_until": "2025-12-31", + "sent_at": None, + "items": [ + { + "item_name": "Setup", + "billing_frequency": "one_time", + "billing_interval": 1, + "billing_start_date": "2026-01-01", + "billing_end_date": "2026-01-01", + "quantity": "1", + "rate": "5000", + "line_tcv": "5000.00", + }, + { + "item_name": "Seats", + "billing_frequency": "monthly", + "billing_interval": 1, + "billing_start_date": "2026-01-01", + "billing_end_date": "2026-12-31", + "quantity": "10", + "rate": "50", + "line_tcv": "6000.00", + }, + ], + "recipients": [{"name": "Ana Diaz", "side": "buyer", "signing_order": 1}], + "totals": { + "one_time": {"total": "5000.00"}, + "recurring": [{"billing_frequency": "monthly", "billing_interval": 1, "total": "500.00"}], + "mrr": "500.00", + "arr": "6000.00", + "first_invoice_amount": "5500.00", + "tcv": "11000.00", + "years": [{"year": 1, "total": "11000.00"}], + }, +} + + +def _invoke(args, client): + with patch("dualentry_cli.main.get_client", return_value=client): + return runner.invoke(app, args) + + +def test_list_passes_quote_filters(): + client = MagicMock() + client.get.return_value = {"items": [QUOTE], "count": 1} + + result = _invoke(["quotes", "list", "--approval-status", "approved", "--customer", "7", "--valid-from", "2026-01-01", "--ordering", "-amount"], client) + + assert result.exit_code == 0, result.output + client.get.assert_called_once_with( + "/quotes/", + params={"approval_status": "approved", "customer_id": "7", "valid_until_from": "2026-01-01", "ordering": "-amount", "limit": 20, "offset": 0}, + ) + assert "QT-45" in result.output + + +def test_get_by_number_shows_totals_and_signing_order(): + client = MagicMock() + client.get.return_value = QUOTE + + result = _invoke(["quotes", "get", "12"], client) + + assert result.exit_code == 0, result.output + client.get.assert_called_once_with("/quotes/12/") + for text in ("MRR / ARR", "$11,000.00", "Year 1", "1. Ana Diaz"): + assert text in result.output + + +def test_get_ongoing_quote_shows_ongoing_tcv(): + client = MagicMock() + client.get.return_value = QUOTE | {"contract_end_type": "ongoing", "totals": QUOTE["totals"] | {"tcv": None, "years": []}} + + result = _invoke(["quotes", "get", "12"], client) + + assert result.exit_code == 0, result.output + assert "Ongoing" in result.output + + +def test_get_by_qt_id_resolves_the_number(): + client = MagicMock() + client.get.side_effect = [{"items": [QUOTE], "count": 1}, QUOTE] + + result = _invoke(["quotes", "get", "QT-45", "-o", "json"], client) + + assert result.exit_code == 0, result.output + assert [c.args[0] for c in client.get.call_args_list] == ["/quotes/", "/quotes/12/"] + assert client.get.call_args_list[0].kwargs["params"] == {"id": 45, "limit": 1} + + +def test_get_rejects_other_references(): + result = _invoke(["quotes", "get", "IN-12"], MagicMock()) + + assert result.exit_code == 2 + assert "not a quote number" in result.output + + +def test_create_merges_display_options(tmp_path): + file = tmp_path / "quote.json" + file.write_text(json.dumps({"company_id": 1})) + client = MagicMock() + client.post.return_value = QUOTE + + result = _invoke(["quotes", "create", "-f", str(file), "--display-options", '{"headline_total": "annual", "year_by_year": true}', "-o", "json"], client) + + assert result.exit_code == 0, result.output + client.post.assert_called_once_with("/quotes/", json={"company_id": 1, "document_headline_total": "annual", "document_year_by_year": True}) + + +def test_display_options_reject_unknown_keys(tmp_path): + file = tmp_path / "quote.json" + file.write_text("{}") + + result = _invoke(["quotes", "create", "-f", str(file), "--display-options", '{"colour": "red"}'], MagicMock()) + + assert result.exit_code == 2 + + +def test_update_patches_only_sent_fields(tmp_path): + file = tmp_path / "changes.json" + file.write_text(json.dumps({"memo": "Renewal"})) + client = MagicMock() + client.patch.return_value = QUOTE + + result = _invoke(["quotes", "update", "12", "-f", str(file), "-o", "json"], client) + + assert result.exit_code == 0, result.output + client.patch.assert_called_once_with("/quotes/12/", json={"memo": "Renewal"}) + + +def test_update_needs_something_to_send(): + result = _invoke(["quotes", "update", "12"], MagicMock()) + + assert result.exit_code == 2 + + +def test_submit_posts_the_quote(): + client = MagicMock() + client.patch.return_value = QUOTE | {"approval_status": "pending_approval"} + + result = _invoke(["quotes", "submit", "12"], client) + + assert result.exit_code == 0, result.output + client.patch.assert_called_once_with("/quotes/12/", json={"record_status": "posted"}) + + +def test_send_attaches_the_pdf(tmp_path): + pdf = tmp_path / "quote.pdf" + pdf.write_bytes(b"%PDF-1.4") + client = MagicMock() + + result = _invoke(["quotes", "send", "12", "--pdf", str(pdf), "--cc", "cc@example.com"], client) + + assert result.exit_code == 0, result.output + client.post.assert_called_once_with( + "/quotes/12/send/", + json={"cc_emails": ["cc@example.com"], "attach_pdf": True, "pdf_content": base64.b64encode(b"%PDF-1.4").decode()}, + ) + + +def test_send_without_pdf(): + client = MagicMock() + + result = _invoke(["quotes", "send", "12", "--no-pdf"], client) + + assert result.exit_code == 0, result.output + client.post.assert_called_once_with("/quotes/12/send/", json={"attach_pdf": False}) + + +def test_missing_subscription_is_a_clear_error(): + client = MagicMock() + client.get.side_effect = APIError(402, "Subscription required for this feature") + + result = _invoke(["quotes", "list"], client) + + assert result.exit_code != 0 + assert isinstance(result.exception, APIError) + assert result.exception.status_code == 402 + + +def test_template_is_valid_json(): + result = runner.invoke(app, ["quotes", "template"]) + + assert result.exit_code == 0 + assert json.loads(result.output)["items"][1]["billing_frequency"] == "monthly"