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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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


Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
208 changes: 208 additions & 0 deletions src/dualentry_cli/commands/quotes.py
Original file line number Diff line number Diff line change
@@ -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-<id> 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)
2 changes: 2 additions & 0 deletions src/dualentry_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down
116 changes: 116 additions & 0 deletions src/dualentry_cli/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"intercompany-journal-entry": "IJE",
"bank-transfer": "BT",
"fixed-asset": "FA",
"quote": "QT",
}


Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────


Expand Down
Loading
Loading