@go-labs-sg/bb 1.19.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,14 +6,17 @@ For AI agents: bills and claims are stored as the same underlying bill records.
6
6
 
7
7
  For AI agents using this CLI: always check whether a newer `@go-labs-sg/bb` version is available before running Budget Builder commands, then update the CLI if needed so command flags and API behavior stay current.
8
8
 
9
- For AI agents using this CLI: do not execute approval decisions, approval requests, status updates, customer-invoice changes, integration retries, `mark-budget-won`, `submit-quotation`, or supplier approval request side effects until the user explicitly confirms the exact entity, target state, and side effects. Inspect the entity first, summarize what will change and whether emails/integrations will run, then wait for a clear confirmation from the user. These sensitive workflow commands also require an interactive `CONFIRM` prompt at runtime and abort in non-interactive shells.
9
+ For AI agents using this CLI: run `bb whoami` before reading or changing Budget Builder data. The CLI displays every command regardless of the authenticated user's role, but the API authorizes each request using the API-key owner's role, resource ownership or project assignment, current workflow state, and approval assignment. See the [role-aware agent guide](./role-aware-agent-guide.md) before choosing commands.
10
+
11
+ For AI agents using this CLI: do not execute approval decisions, approval requests, status updates, estimate emails, customer-invoice changes, integration retries, `mark-budget-won`, `submit-quotation`, or supplier approval request side effects until the user explicitly confirms the exact entity, target state, and side effects. Inspect the entity first, summarize what will change and whether emails/integrations will run, then wait for a clear confirmation from the user. Sensitive commands print a workflow preview with separate **Action**, **Target**, and **Effects** fields before an interactive `CONFIRM` prompt, and abort in non-interactive shells.
10
12
 
11
13
  **Registry:** [`@go-labs-sg/bb`](https://www.npmjs.com/package/@go-labs-sg/bb)
12
14
 
13
15
  ## Requirements
14
16
 
15
17
  - **Node.js** 18+ (ESM; relative imports in `dist` use `.js` extensions)
16
- - A **Budget Builder API key** (same key used for the MCP server)
18
+ - A Budget Builder account with any active user role
19
+ - A **Budget Builder API key** (the same key is used for the CLI and MCP server)
17
20
 
18
21
  ## Install
19
22
 
@@ -29,13 +32,33 @@ npx @go-labs-sg/bb <command>
29
32
 
30
33
  ## Authentication
31
34
 
32
- Set your API key before running commands:
35
+ Sign in to Budget Builder and create your first key under **API Keys**. Every
36
+ active user can manage their own keys; an admin is only required to manage a key
37
+ for someone else.
38
+
39
+ Load the key from your shell's secret manager or environment before running
40
+ commands:
41
+
42
+ ```bash
43
+ export BB_API_KEY="<your-key>"
44
+ ```
45
+
46
+ Do not put the raw key in a command argument, commit it to a repository, or paste
47
+ it into logs. The CLI redacts API-key values from its request tracing. If you use
48
+ a local `.env`, keep it out of version control and restrict its file permissions.
49
+
50
+ Once authenticated, any user can rotate or manage their own keys from the CLI:
33
51
 
34
52
  ```bash
35
- export BB_API_KEY=<your-key>
53
+ bb whoami
54
+ bb create-api-key --name "My CLI"
55
+ bb list-api-keys
56
+ bb revoke-api-key <api-key-id>
36
57
  ```
37
58
 
38
- Create or revoke keys in the web app: **Goracle API Keys** (MCP API keys).
59
+ The raw key is returned only by `create-api-key`; copy it immediately. Listing
60
+ keys returns metadata only. Revoking the key currently stored in `BB_API_KEY`
61
+ will cause subsequent commands to fail until it is replaced.
39
62
 
40
63
  Admins can provision API-only service identities and manage their keys from the
41
64
  CLI. These identities are not human web accounts: `create-user` does not create
@@ -48,15 +71,56 @@ bb list-api-keys --userId <user-id>
48
71
  bb revoke-api-key <api-key-id> --userId <user-id>
49
72
  ```
50
73
 
51
- The raw key is returned only by `create-api-key`; copy it immediately. Listing
52
- keys returns metadata only.
53
-
54
74
  The CLI talks to the production API: `https://budget-builder.getout.events`.
55
75
 
76
+ ## Role-aware agent startup
77
+
78
+ Every agent session that uses an API key must begin with:
79
+
80
+ ```bash
81
+ bb version
82
+ bb whoami
83
+ ```
84
+
85
+ `whoami` returns the identity and database role attached to the active key:
86
+
87
+ ```json
88
+ {
89
+ "id": "...",
90
+ "name": "...",
91
+ "email": "...",
92
+ "role": "USER"
93
+ }
94
+ ```
95
+
96
+ Record that identity in the agent's working context before selecting commands. Do not infer the role from the key name, email address, task description, or commands shown by `bb help`. Do not switch to another key to bypass a permission failure.
97
+
98
+ The following is a practical guide, not a client-side allowlist. The API remains authoritative.
99
+
100
+ | `whoami.role` | What an agent may normally do | Important limits |
101
+ | --- | --- | --- |
102
+ | `USER` | Manage its own API keys; use shared authenticated reads and catalog/CRM operations; work with resources it created or projects/budgets where its user ID is assigned; create its own bills, claims, and quotation drafts; request approval. | No admin dashboards, user provisioning, integration recovery, final finance actions, or approval decisions unless the user is the recorded approver. |
103
+ | `INSIDE_SALES` | All applicable base-user operations, assigned project/budget work, and the broader supplier workflow exposed to Inside Sales. | Budget and supplier approval requests still follow the Lead/Admin workflow. Assignment, ownership, state, and recorded-approver checks still apply. |
104
+ | `LEAD` | All applicable base-user operations plus Lead-stage budget and supplier approval work; supplier creation is auto-approved for this role. | Lead is not Admin. Do not attempt admin dashboards, user administration, integration recovery, final bill payment, or admin-only customer-invoice actions. |
105
+ | `ACCOUNTING_TEAM` | All applicable authenticated operations plus finance-stage bill work, including checking bills and correcting allowed payment or invoice-number metadata. Act on bill approvals only when the API lists the user as an approver. | Direct `APPROVED` and `PAID` bill status changes, customer-invoice payment, user administration, and system operations remain Admin-only. |
106
+ | `ADMIN` | Cross-user API-key and service-identity administration, global dashboard/error/integration operations, broad resource access, final bill approval/payment, and admin customer-invoice workflows. | Admin access does not bypass valid state transitions, required evidence, resource consistency, external-operation locks, or the agent's duty to obtain explicit confirmation for sensitive commands. |
107
+
108
+ Permissions are also resource- and state-dependent:
109
+
110
+ - Budget/project mutations generally require Admin, resource ownership, or assignment as Business Development, Inside Sales, or Project Manager.
111
+ - Bill and quotation edits/deletes are commonly limited to their creator or Admin, and only in supported statuses.
112
+ - Approval commands require the authenticated user to be the recorded pending approver; having a generally elevated role is not sufficient by itself.
113
+ - Own-key commands are available to every active role. Passing `--userId` to manage another user's keys requires Admin.
114
+ - `bb help` is a command catalog, not proof that the current identity is authorized.
115
+
116
+ If the API returns `FORBIDDEN`, stop. Report the command, target resource, `whoami` identity/role, and the missing ownership, assignment, approver, or role condition when known. Do not retry through a lower-level command or another credential unless the user explicitly changes the operating identity.
117
+
56
118
  ## Usage
57
119
 
58
120
  ```bash
59
121
  bb help
122
+ bb version
123
+ bb whoami
60
124
  bb list-budgets
61
125
  bb get-budget <budget-id>
62
126
  bb update-budget-status <budget-id> <status> [--markProjectWon true] [--projectStatusOnCommercialRejection PITCH|LOST]
@@ -68,11 +132,13 @@ bb list-suppliers
68
132
 
69
133
  Global options and flags use `--key=value` or `--key value` (see `bb help`).
70
134
 
71
- **Authoritative command list:** run `bb help` it includes every command, positional args, and flags. MCP exposes a subset of the same tRPC surface; the CLI additionally includes a few procedures mainly used by the web UI (e.g. `reorder-budget-items`, `update-budget-item-supplier`). You can also use MCP-style `snake_case` (e.g. `bb list_bills`); it is normalized to kebab-case.
135
+ Run `bb version` before agent-driven work to record the installed package version and production API target. This is read-only and does not require `BB_API_KEY`.
72
136
 
73
- **Budget status automation:** Setting a budget to `ESTIMATE_ACCEPTED` requires a confirmed win-proof attachment. If the parent project is `PITCH` or `LOST`, the API now marks it `WON` automatically and, when the `asana-tasks` feature flag is enabled, creates an Asana Event Ops section from Prompt 5 tasks. Prompt 5 skips quantity-zero items, combines matching non-GO-internal suppliers, keeps each GO internal item separate, removes generated work that repeats another line item or group, and schedules applicable subtasks relative to the project start date. When rejecting the only accepted/closed budget on a commercial project, pass `--projectStatusOnCommercialRejection PITCH|LOST`.
137
+ **Authoritative command list:** run `bb help` it includes every command, positional args, and flags, but does not filter the list by the current `whoami` role. MCP exposes a subset of the same tRPC surface; the CLI additionally includes a few procedures mainly used by the web UI (e.g. `reorder-budget-items`, `update-budget-item-supplier`). You can also use MCP-style `snake_case` (e.g. `bb list_bills`); it is normalized to kebab-case.
74
138
 
75
- **Sensitive workflow changes:** Approval decisions/requests, entity status updates, customer-invoice changes, integration retries, `mark-budget-won`, `submit-quotation`, and supplier approval request side effects require an interactive `CONFIRM` prompt. Non-interactive runs abort before the guarded workflow mutation. Agents must get user confirmation in chat before attempting the command; the prompt is a final runtime guard, not a replacement for user approval.
139
+ **Budget status automation:** Setting a budget to `ESTIMATE_ACCEPTED` requires a confirmed win-proof attachment. The first accepted estimate also requires `--invoiceSettings '<json>'` while customer invoicing is enabled. Provide `depositPercentage`, `balancePercentage`, `deliveryMethod`, and the matching `billingEmail` or HTTPS `uploadUrl`; revenue at or below S$5,000 requires `100/0`. If the parent project is `PITCH` or `LOST`, the API marks it `WON` automatically and runs enabled Asana automation. When rejecting the only accepted/closed budget on a commercial project, pass `--projectStatusOnCommercialRejection PITCH|LOST`.
140
+
141
+ **Sensitive workflow changes:** Approval decisions/requests, entity status updates, estimate emails, customer-invoice changes, integration retries, `mark-budget-won`, `submit-quotation`, and supplier approval request side effects require an interactive `CONFIRM` prompt. Non-interactive runs abort before the guarded workflow mutation. Agents must get user confirmation in chat before attempting the command; the prompt is a final runtime guard, not a replacement for user approval.
76
142
 
77
143
  **Approval email exclusions:** Approval requests create pending database records for every eligible approver, including configured non-recipient admin accounts. Automated approval-request emails skip those accounts.
78
144
 
@@ -80,20 +146,31 @@ Global options and flags use `--key=value` or `--key value` (see `bb help`).
80
146
 
81
147
  **Mutations with `--payload`:** Commands such as `create-budget`, `create-bill`, `update-supplier`, etc. take a single JSON object (`--payload '<json>'`) matching the corresponding tRPC procedure input. Use ISO strings for date/datetime fields; the CLI coerces them where needed. The API still validates the full shape. Plain `description` fields for item create/update are converted to `descriptionRichText`; pass `descriptionRichText` directly when formatted Tiptap JSON is required. For **`update-project`**, the project window is `dateRange.from` and `dateRange.to` (optional end); there are no separate event-date fields on the project payload. **`create-budget` / `update-budget`** do not accept `asanaTaskId`; configure the deal card on the project (`update-project` / project settings).
82
148
 
149
+ **Contact-person estimate email:** `send-estimate-to-contact-person --payload '<json>'` calls the same `email.sendEstimateToContactPerson` procedure as the web composer. The payload requires `budgetId`, `estimateId`, optional `estimateDocNumber`, `to`, `cc`, `replyTo`, `subject`, HTML `content`, and HTML `signature`. It sends the raw PDF returned by QuickBooks together with the standard terms and Budget Builder budget attachments, then marks the budget `ESTIMATE_SENT`. The command requires interactive `CONFIRM`; inspect the budget, recipients, and HTML first, and do not retry blindly after an ambiguous delivery failure.
150
+
151
+ **Customer-invoice workflow parity:** `list-customer-invoices` now uses the same global/project list procedure and metrics as the web pages; omit filters for the global list, use `--projectId` for project scope, or pass a positional budget ID as the legacy shortcut. `send-customer-invoice-to-contact-person` uses the same protected email workflow as the web composer and marks a successful invoice `SENT`. `mark-customer-invoice-paid` requires an admin, a payment date, and a local PDF/GIF/JPEG/PNG proof up to 20MB; it uploads the proof, creates a QuickBooks Payment for the live outstanding balance when necessary, attempts to attach the proof in QuickBooks, and records the result in Budget Builder. Invoice approval refuses voided invoices and closes the estimate only when approved invoice coverage totals 100%; deletion, voiding, rejection, and QBO synchronization use the same estimate-reopening and active-payment guards as the web app.
152
+
153
+ For agent-driven invoice work, use this read-before-write sequence:
154
+
155
+ 1. Run `get-customer-invoice <batchId>` and, when composing email, `get-customer-invoice-email-context <batchId>`.
156
+ 2. For creation, run `list-eligible-customer-invoice-budgets <projectId>` or `check-customer-invoice-readiness <budgetId>` first.
157
+ 3. State the exact invoice or batch, current status, intended mutation, QuickBooks effect, email recipients, proof file, and estimate-closing/reopening effect to the user.
158
+ 4. Wait for explicit confirmation, then run the sensitive command interactively and verify its JSON result. Do not treat the runtime `CONFIRM` prompt as user authorization.
159
+
83
160
  ### Command overview
84
161
 
85
162
  | Area | Commands (non-exhaustive) |
86
163
  | --- | --- |
87
- | **Budgets** | `list-budgets` (full payload by default; `--summary` or `--includeDetails false` for slim list), `get-budget`, `get-budget-items`, `get-budget-details`, `get-budget-categories`, `get-budget-versions`, `rename-budget-version`, `restore-budget-version`, `update-budget-status` (`ESTIMATE_ACCEPTED` requires win proof, auto-marks `PITCH` or `LOST` projects `WON`, and auto-creates an Asana Event Ops section from Prompt 5 tasks when enabled; Prompt 5 skips quantity-zero items, combines matching non-GO-internal suppliers, keeps each GO internal item separate, removes generated work that repeats another line item or group, and schedules applicable subtasks relative to the project start date; `--projectStatusOnCommercialRejection PITCH\|LOST` is required when rejecting the only accepted/closed budget on a commercial project), `mark-budget-won` (`<budgetId>` + proof file path; `PITCH` or `LOST` projects become `WON` automatically), `create-budget` / `update-budget` (`--payload`), `delete-budget`, `create-budget-approval` (also sends approval request emails), `add-budget-items`, `update-budget-item`, `remove-budget-item`, `reorder-budget-items`, `update-budget-item-supplier`, `mark-budget-items-not-utilized`, `restore-budget-item`, `create-placeholder-bill` (admin recovery), `create-budget-category`, `update-budget-category`, `delete-budget-category`, `update-budget-commission`, `delete-budget-commission`, `update-budget-discount`, `delete-budget-discount` (`--payload` where noted), `upload-budget-attachment` (`<budgetId>` + local file path; uses `attachment.requestBudgetAttachmentUpload` + PUT + `attachment.confirmBudgetAttachment`) |
88
- | **Bills / claims** | `list-bills` (`--isClaimable false` for bills, `--isClaimable true` for claims, omit for both), `list-claims` (claims only), `validate-bill-selection` (`--payload`; runs the same supplier, line-item, and quotation-coverage checks as the web flow), `stage-bill-attachment` (securely uploads invoice/payment-proof files before creation and returns attachment JSON), `cleanup-staged-bill-attachments`, `create-bill` (`--payload`; set `isClaimable=false` for a bill, `isClaimable=true` for a claim; supplier bills require positive `extractedAmount` and `amount`, with `amount <= extractedAmount`; an already-paid supplier bill sets `alreadyPaid=true` and requires `paymentReference` plus a staged PDF in `paymentProofAttachments`, and is created pending finance approval; non-legacy supplier bills from 1 Jul 2026 00:00 SGT also require approved quotation coverage), `update-bill` (`--payload`), `update-bill-payment-evidence` (`--payload`; replaces the payment reference and payment-proof PDFs for an already-paid bill), `delete-bill`, `create-bill-approval` (also sends approval request emails), `update-bill-status` (moving a bill or claim to `PAID` sends creator and supplier emails, CC `m@getout.sg` and BCC `noah@getout.sg`; a claim sends one email when both recipient addresses match), `patch-bill-payment` (PAID bills: `--paymentTrackingUrl`, `--paymentReference`, `--quickbooksBillId`, `--paymentDate` ISO; clearing a paid bill's QuickBooks link is not allowed), `patch-bill-invoice-number`, `get-bill-attachments`, `upload-bill-attachment` (`<billId>` + local path), `get-bill-details` |
164
+ | **Budgets** | `list-budgets` (full payload by default; `--summary` or `--includeDetails false` for slim list), `get-budget`, `get-budget-items`, `get-budget-details`, `get-budget-categories`, `get-budget-versions`, `rename-budget-version`, `restore-budget-version`, `update-budget-status` (`ESTIMATE_ACCEPTED` requires win proof, auto-marks `PITCH` or `LOST` projects `WON`, and auto-creates an Asana Event Ops section from Prompt 5 tasks when enabled; Prompt 5 skips quantity-zero items, combines matching non-GO-internal suppliers, keeps each GO internal item separate, removes generated work that repeats another line item or group, and schedules applicable subtasks relative to the project start date; `--projectStatusOnCommercialRejection PITCH\|LOST` is required when rejecting the only accepted/closed budget on a commercial project), `mark-budget-won` (`<budgetId>` + proof file path; `PITCH` or `LOST` projects become `WON` automatically), `create-budget` / `update-budget` (`--payload`), `delete-budget`, `create-budget-approval` (also sends approval request emails), `create-estimate`, `send-estimate-to-contact-person` (same contact-person email workflow as web), `add-budget-items`, `update-budget-item`, `remove-budget-item`, `reorder-budget-items`, `update-budget-item-supplier`, `mark-budget-items-not-utilized`, `restore-budget-item`, `create-placeholder-bill` (admin recovery), `create-budget-category`, `update-budget-category`, `delete-budget-category`, `update-budget-commission`, `delete-budget-commission`, `update-budget-discount`, `delete-budget-discount` (`--payload` where noted), `upload-budget-attachment` (`<budgetId>` + local file path; uses `attachment.requestBudgetAttachmentUpload` + PUT + `attachment.confirmBudgetAttachment`) |
165
+ | **Bills / claims** | `list-bills` (`--isClaimable false` for bills, `--isClaimable true` for claims, omit for both), `list-claims` (claims only), `validate-bill-selection` (`--payload`; runs the same supplier, line-item, and quotation-coverage checks as the web flow), `stage-bill-attachment` (securely uploads invoice/payment-proof files before creation and returns attachment JSON; ownership and one-hour expiry are enforced by a server-side staged-upload record rather than encoded in the object key), `cleanup-staged-bill-attachments`, `create-bill` (`--payload`; set `isClaimable=false` for a bill, `isClaimable=true` for a claim; supplier bills require positive `extractedAmount` and `amount`, with `amount <= extractedAmount`; an already-paid supplier bill sets `alreadyPaid=true` and requires `paymentReference` plus a staged PDF in `paymentProofAttachments`; admin creation automatically queues QBO finalization while other roles remain pending approval; non-legacy supplier bills from 1 Jul 2026 00:00 SGT also require approved quotation coverage), `update-bill` (`--payload`), `update-bill-payment-evidence` (`--payload`; replaces the payment reference and payment-proof PDFs for an already-paid bill), `delete-bill`, `create-bill-approval` (also sends approval request emails), `update-bill-status` (`PAID` requires `--paymentReference`; pass `--paymentProof <receipt.pdf>` to stage and submit a PDF up to 20MB atomically, or omit it only when BB already has payment proof; moving to `PAID` runs the server's paid-bill notification workflow), `patch-bill-payment` (PAID bills: `--paymentTrackingUrl`, `--paymentReference`, `--quickbooksBillId`, `--paymentDate` ISO; clearing a paid bill's QuickBooks link is not allowed), `patch-bill-invoice-number`, `get-bill-attachments`, `upload-bill-attachment` (`<billId>` + local path), `get-bill-details` |
89
166
  | **Quotations** | `list-quotations` (supports project, budget, supplier, status, requester, and text-search filters), `get-quotation`, `upload-quotation-attachment` (`<projectId>` + PDF/JPEG/PNG path up to 20MB; returns attachment JSON for payload use), `cleanup-staged-quotation-attachments`, `create-quotation` (`--payload` for `quotation.createDraft`; include `amountWithoutGst` and `gstAmount`), `update-quotation` (`--payload`; updates a `DRAFT` or `REJECTED` quotation), `delete-quotation`, `submit-quotation`, `approve-quotation`, `reject-quotation`, `download-quotation-pdf` (`original`, `staff`, or `final`) |
90
- | **Customer invoices** | `check-customer-invoice-readiness`, `list-customer-invoices`, `get-customer-invoice`, `create-customer-invoice`, `discard-customer-invoice`, `delete-customer-invoice`, `void-customer-invoice`, `approve-customer-invoice`, `reject-customer-invoice`, `download-customer-invoice-pdf`, `sync-customer-invoice` |
167
+ | **Customer invoices** | `check-customer-invoice-readiness`, `list-eligible-customer-invoice-budgets`, `list-customer-invoices` (global/project/budget filters plus summary metrics), `get-customer-invoice`, `get-customer-invoice-email-context`, `create-customer-invoice`, `discard-customer-invoice`, `delete-customer-invoice`, `void-customer-invoice`, `approve-customer-invoice` (admin), `reject-customer-invoice` (admin), `send-customer-invoice-to-contact-person`, `mark-customer-invoice-paid` (admin; high-level proof upload + QBO Payment workflow), `download-customer-invoice-payment-proof`, `download-customer-invoice-pdf`, `sync-customer-invoice` |
91
168
  | **Approvals** | `list-approvals` / `get-pending-approvals` (`--type budget\|supplier\|bill\|quotation\|customer_invoice\|all`), `approve-bill` / `reject-bill` (send reply email), `approve-budget` / `reject-budget` (send reply email), `approve-supplier` / `reject-supplier` (send reply email), `approve-quotation` / `reject-quotation`, `approve-customer-invoice` / `reject-customer-invoice` |
92
169
  | **Companies & projects** | `list-companies`, `get-company`, `create-company`, `update-company` (`--payload`), `delete-company`, `list-projects`, `get-project` (its budget overview returns `totalRevenue`, calculated only from Estimate Accepted and Estimate Closed budgets), `create-project` (required: `--name`, `--companyId`, `--contactPersonId`, `--insideSalesId`, `--businessDevelopmentId`, `--venue`, `--startDate` as ISO datetime for project/window start; when `--asanaTaskId` is omitted, the CLI searches open Asana lead tasks in the Deals project, prompts for one of the top five matches, and resolves Slack channel fields from the selected deal card; optional `--asanaSearch`, `--pax`, `--endDate`, `--description`, `--projectManagerId`; always requests QBO project import like the web app), `update-project` (`--payload` with `dateRange.from` / `dateRange.to` for the project window; optional `projectManagerId` and `requestQboAccountantNotification` in JSON), `delete-project`, `check-project-reconciliation <id>` (runs the web app's live checks without changing status), `reconcile-project <id>` (reruns checks transactionally and marks an eligible project `RECONCILED`), `complete-project <id>` (reruns validation, marks a reconciled project `COMPLETED`, and queues QuickBooks placeholder cleanup), `import-qbo-project`, `update-project-status` (`<id>` `<status>`: `PITCH` \| `WON` \| `COMPLETED` \| `RECONCILED` \| `LOST`; close-out follows `WON` → `RECONCILED` → `COMPLETED`, and reconciliation requires every accepted/closed budget line to have an Approved/Paid bill or claim or be explicitly Not Utilized; marking `COMPLETED` queues deletion of every remaining project placeholder bill from QuickBooks; for `PITCH` → `WON` also pass a Budget Builder user `--projectManagerId` or `--projectManagerEmail`; when marking `WON` without an accepted/closed budget or proof, pass `--wonOverrideReason`) |
93
170
  | **Contacts** | `list-contacts`, `create-contact-person` (`--payload`), `update-contact-person` (`--payload`) |
94
- | **Suppliers & items** | `list-suppliers` (defaults to active suppliers, `--perPage 10`, sorted by `createdAt` desc; supports `--name`, `--sortBy` for scalar supplier fields, `--sortDir`, `--createdBy`, `--gstRegistered`, `--status`, `--supplierTags`, `--active false` for archived suppliers), `create-supplier` (`--payload`; when supplier status is `PENDING_APPROVAL`, also runs `supplier.createSupplierApproval` and `email.sendSupplierApprovalRequestEmail`), `update-supplier` (`--payload`; when supplier status is `PENDING_APPROVAL`, also runs `supplier.createSupplierApproval` and `email.sendSupplierApprovalRequestEmail`), `delete-suppliers` (`--ids` CSV; admin; archives/deactivates related items), `reactivate-suppliers` (`--ids` CSV; admin; reactivates related items), `create-certification` / `create-payment-method` / `create-supplier-role` / `create-supplier-tag` (`--name`), `get-supplier-details` (includes `supplierApprovalSummary` for pending approvers, superseded approvers, and the actual responder/respondedAt metadata), `get-supplier-analytics`, `list-items`, `create-item` (`--payload`), `update-item` (`--payload`), `delete-item`, `get-item`, `list-item-categories`, `create-item-category`, `update-item-category`, `delete-item-categories` (`--ids` CSV; admin) |
95
- | **Dashboard & users** | `list-users`, `create-user` (admin; provisions an API-only service identity with no Google sign-in; `--email`, optional `--name`, optional `--role` defaulting to `USER`), `create-api-key` (admin; `--userId`, `--name`; raw key shown once), `list-api-keys` (admin; optional `--userId`), `revoke-api-key` (admin; key ID plus `--userId` for another user's key), `get-user-performance`, `get-dashboard`, `get-monthly-metrics`, `get-system-overview`, `get-estimate-performance`, `get-financial-overview` |
96
- | **Errors** | `get-recent-errors`, `get-error-metrics` |
171
+ | **Suppliers & items** | `list-suppliers` (defaults to active suppliers, `--perPage 10`, sorted by `createdAt` desc; supports `--name`, `--sortBy` for scalar supplier fields, `--sortDir`, `--createdBy`, `--gstRegistered`, `--status`, `--supplierTags`, `--active false` for archived suppliers), `create-supplier` (`--payload`; when supplier status is `PENDING_APPROVAL`, also runs `supplier.createSupplierApproval` and `email.sendSupplierApprovalRequestEmail`), `update-supplier` (`--payload`; when supplier status is `PENDING_APPROVAL`, also runs `supplier.createSupplierApproval` and `email.sendSupplierApprovalRequestEmail`), `delete-suppliers` (`--ids` CSV; admin; archives/deactivates related items), `reactivate-suppliers` (`--ids` CSV; admin; reactivates related items), `create-certification` / `create-payment-method` / `create-supplier-role` / `create-supplier-tag` (`--name`), `get-supplier-details` (includes `supplierApprovalSummary` for pending approvers, superseded approvers, and the actual responder/respondedAt metadata), `get-supplier-analytics` (admin), `list-items`, `create-item` (`--payload`), `update-item` (`--payload`), `delete-item`, `get-item`, `list-item-categories`, `create-item-category` / `update-item-category` / `delete-item-categories` (admin; `--ids` CSV for delete) |
172
+ | **Dashboard & users** | `whoami` (current API-key owner identity and role), `list-users`, `create-user` (admin; provisions an API-only service identity with no Google sign-in; `--email`, optional `--name`, optional `--role` defaulting to `USER`), `create-api-key` (`--name`; defaults to the caller; admin-only `--userId` for another user; raw key shown once), `list-api-keys` (defaults to the caller; admin-only `--userId` for another user), `revoke-api-key` (key ID; admin-only `--userId` for another user), `get-user-performance`, `get-dashboard`, `get-monthly-metrics`, `get-system-overview`, `get-estimate-performance`, `get-financial-overview` (performance/dashboard commands are admin-only) |
173
+ | **Errors (admin)** | `get-recent-errors`, `get-error-metrics` |
97
174
  | **Automation (admin)** | `list-integration-operations`, `retry-integration-operation` |
98
175
  | **Historical / benchmarks** | `get-approved-budgets`, `get-budget-category-benchmarks`, `get-item-pricing-history`, `get-supplier-pricing-history` |
99
176
 
@@ -1,7 +1,7 @@
1
1
  import { createTRPCProxyClient, httpBatchLink, loggerLink } from "@trpc/client";
2
2
  import SuperJSON from "superjson";
3
- import { shouldLogCliActions } from "./cli-trace.js";
4
- const BASE_URL = "https://budget-builder.getout.events";
3
+ import { sanitizeValueForTrace, shouldLogCliActions } from "./cli-trace.js";
4
+ export const BUDGET_BUILDER_API_BASE_URL = "https://budget-builder.getout.events";
5
5
  function getAuthHeader() {
6
6
  const key = process.env.BB_API_KEY;
7
7
  if (!key?.trim())
@@ -26,12 +26,12 @@ export const api = createTRPCProxyClient({
26
26
  withContext: false,
27
27
  // tRPC defaults use console.log for requests; stderr keeps stdout JSON-safe for pipes.
28
28
  console: {
29
- log: (...args) => console.error(...args),
30
- error: (...args) => console.error(...args),
29
+ log: (...args) => console.error(...args.map((arg) => sanitizeValueForTrace(arg))),
30
+ error: (...args) => console.error(...args.map((arg) => sanitizeValueForTrace(arg))),
31
31
  },
32
32
  }),
33
33
  httpBatchLink({
34
- url: `${BASE_URL}/api/trpc`,
34
+ url: `${BUDGET_BUILDER_API_BASE_URL}/api/trpc`,
35
35
  transformer: SuperJSON,
36
36
  headers: () => {
37
37
  const headers = {
package/dist/cli-trace.js CHANGED
@@ -17,9 +17,44 @@ const SENSITIVE_FLAG = new Set([
17
17
  "bb_api_key",
18
18
  "token",
19
19
  "password",
20
+ "payment-proof",
21
+ "paymentproof",
22
+ "paymentreference",
23
+ "receipt",
20
24
  "secret",
21
25
  "authorization",
22
26
  ]);
27
+ const SENSITIVE_TRACE_FIELD = new Set([
28
+ "paymentproof",
29
+ "paymentproofattachments",
30
+ "paymentproofpath",
31
+ "paymentreference",
32
+ "proof",
33
+ "receipt",
34
+ ]);
35
+ const isPresignedUrl = (value) => typeof value === "string" &&
36
+ (/[?&]X-Amz-(?:Algorithm|Credential|Signature)=/i.test(value) ||
37
+ /[?&]X-Goog-(?:Algorithm|Credential|Signature)=/i.test(value));
38
+ export const sanitizeValueForTrace = (value, seen = new WeakSet()) => {
39
+ if (typeof value !== "object" || value === null)
40
+ return value;
41
+ if (seen.has(value))
42
+ return "[circular]";
43
+ seen.add(value);
44
+ if (Array.isArray(value)) {
45
+ return value.map((item) => sanitizeValueForTrace(item, seen));
46
+ }
47
+ const prototype = Object.getPrototypeOf(value);
48
+ if (prototype !== Object.prototype && prototype !== null)
49
+ return value;
50
+ return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => [
51
+ key,
52
+ SENSITIVE_TRACE_FIELD.has(key.toLowerCase()) ||
53
+ isPresignedUrl(nestedValue)
54
+ ? "[redacted]"
55
+ : sanitizeValueForTrace(nestedValue, seen),
56
+ ]));
57
+ };
23
58
  export const sanitizeFlagsForTrace = (flags) => {
24
59
  const out = {};
25
60
  for (const [k, v] of Object.entries(flags)) {
package/dist/commands.js CHANGED
@@ -6,8 +6,8 @@ import { contentType } from "mime-types";
6
6
  import { api } from "./api-client.js";
7
7
  import { BudgetRole, Deals, ExtendedApprovalStatus, ExtendedApprovalType, ExtendedBudgetStatus, TimeFrame, } from "./filter-enums.js";
8
8
  import { billStatusesForApi, } from "./parse-cli-enums.js";
9
- import { parseBudgetDiscountPayload, parseCompanyUpdatePayload, parseContactCreatePayload, parseContactUpdatePayload, parseCreateBillPayload, parseCreateBudgetPayload, parseCreateCustomerInvoicePayload, parseCreateQuotationPayload, parseItemCreatePayload, parseItemUpdatePayload, parseSupplierCreatePayload, parseSupplierUpdatePayload, parseUpdateBillPayload, parseUpdateBillPaymentEvidencePayload, parseUpdateBudgetCommissionPayload, parseUpdateBudgetPayload, parseUpdateProjectPayload, parseUpdateQuotationPayload, parseValidateBillSelectionPayload, } from "./parse-mutation-payload.js";
10
- import { BudgetStatus, ProjectStatus, } from "./prisma-enums.js";
9
+ import { parseBudgetDiscountPayload, parseCompanyUpdatePayload, parseContactCreatePayload, parseContactUpdatePayload, parseCreateBillPayload, parseCreateBudgetPayload, parseCreateCustomerInvoicePayload, parseCreateQuotationPayload, parseItemCreatePayload, parseItemUpdatePayload, parseSendCustomerInvoiceToContactPersonPayload, parseSendEstimateToContactPersonPayload, parseSupplierCreatePayload, parseSupplierUpdatePayload, parseUpdateBillPayload, parseUpdateBillPaymentEvidencePayload, parseUpdateBudgetCommissionPayload, parseUpdateBudgetPayload, parseUpdateProjectPayload, parseUpdateQuotationPayload, parseValidateBillSelectionPayload, } from "./parse-mutation-payload.js";
10
+ import { BillStatus, BudgetStatus, ProjectStatus } from "./prisma-enums.js";
11
11
  import { createRichTextFromPlainText } from "./rich-text.js";
12
12
  const BUDGET = "BUDGET";
13
13
  const BILL = "BILL";
@@ -262,7 +262,19 @@ const billAttachmentContentTypeForFileName = (fileName) => {
262
262
  }
263
263
  return resolvedContentType;
264
264
  };
265
+ const PAYMENT_PROOF_MAX_SIZE = 20 * 1024 * 1024;
266
+ const CUSTOMER_INVOICE_PAYMENT_PROOF_MAX_SIZE = 20 * 1024 * 1024;
265
267
  const QUOTATION_ATTACHMENT_MAX_SIZE = 20 * 1024 * 1024;
268
+ const customerInvoicePaymentProofContentTypeForFileName = (fileName) => {
269
+ const resolvedContentType = contentTypeHeaderForFileName(fileName);
270
+ if (resolvedContentType === "application/pdf" ||
271
+ resolvedContentType === "image/gif" ||
272
+ resolvedContentType === "image/jpeg" ||
273
+ resolvedContentType === "image/png") {
274
+ return resolvedContentType;
275
+ }
276
+ throw new Error(`Unsupported customer invoice payment-proof type: ${fileName}. Use PDF, GIF, JPEG, or PNG.`);
277
+ };
266
278
  const quotationAttachmentContentTypeForFileName = (fileName) => {
267
279
  const resolvedContentType = contentTypeHeaderForFileName(fileName);
268
280
  if (resolvedContentType === "application/pdf" ||
@@ -289,18 +301,27 @@ const lockedBudgetStatusLabels = {
289
301
  const lockedBudgetStatusLabel = (status) => status in lockedBudgetStatusLabels
290
302
  ? lockedBudgetStatusLabels[status]
291
303
  : status;
292
- const assertSensitiveWorkflowConfirmed = async ({ action, entity, details, }) => {
304
+ export const buildSensitiveWorkflowPreview = ({ action, entity, details, }) => {
305
+ const effects = details ?? "may change workflow state or send notifications";
306
+ return [
307
+ "Budget Builder workflow preview",
308
+ `Action: ${action}`,
309
+ `Target: ${entity}`,
310
+ `Effects: ${effects}`,
311
+ ].join("\n");
312
+ };
313
+ const assertSensitiveWorkflowConfirmed = async (input) => {
293
314
  const expected = "CONFIRM";
294
- const detailText = details ? ` ${details}` : "";
315
+ const preview = buildSensitiveWorkflowPreview(input);
295
316
  if (!process.stdin.isTTY || !process.stderr.isTTY) {
296
- throw new Error(`${action} on ${entity}${detailText} is a sensitive Budget Builder workflow change. Run this command in an interactive terminal after the user explicitly confirms the exact action, then type "${expected}" to continue.`);
317
+ throw new Error(`${preview}\nThis is a sensitive workflow change. Run it in an interactive terminal only after the user explicitly confirms this exact action, then type "${expected}" to continue.`);
297
318
  }
298
319
  const rl = createInterface({
299
320
  input: process.stdin,
300
321
  output: process.stderr,
301
322
  });
302
323
  try {
303
- const answer = await rl.question(`${action} on ${entity}${detailText} may change workflow status, approvals, or send notification emails. Confirm the user explicitly approved this exact action, then type "${expected}" to continue: `);
324
+ const answer = await rl.question(`${preview}\nConfirm the user explicitly approved this exact action, then type "${expected}" to continue: `);
304
325
  if (answer.trim() !== expected) {
305
326
  throw new Error("Aborted.");
306
327
  }
@@ -391,6 +412,9 @@ export async function updateBudgetStatus(budgetId, status, opts) {
391
412
  ...(opts?.projectStatusOnCommercialRejection !== undefined && {
392
413
  projectStatusOnCommercialRejection: opts.projectStatusOnCommercialRejection,
393
414
  }),
415
+ ...(opts?.projectInvoiceSettings !== undefined && {
416
+ projectInvoiceSettings: opts.projectInvoiceSettings,
417
+ }),
394
418
  });
395
419
  out(result);
396
420
  }
@@ -502,7 +526,9 @@ export async function approveBill(billId) {
502
526
  }
503
527
  let email;
504
528
  try {
505
- await api.email.sendReplyBillApprovalEmail.mutate(result.updatedApproval);
529
+ await api.email.sendReplyBillApprovalEmail.mutate({
530
+ id: result.updatedApproval.id,
531
+ });
506
532
  email = { sent: true };
507
533
  }
508
534
  catch (error) {
@@ -608,20 +634,112 @@ export async function createBillApproval(billId) {
608
634
  email,
609
635
  });
610
636
  }
637
+ export const validateBillStatusUpdateOptions = (opts) => {
638
+ if (opts.status === BillStatus.PAID && !opts.paymentReference?.trim()) {
639
+ throw new Error("update-bill-status requires --paymentReference when status is PAID.");
640
+ }
641
+ if (opts.status !== BillStatus.PAID && opts.paymentProofPath !== undefined) {
642
+ throw new Error("--paymentProof can only be used when status is PAID.");
643
+ }
644
+ if (opts.paymentProofPath !== undefined && !opts.paymentProofPath.trim()) {
645
+ throw new Error("--paymentProof requires a PDF file path.");
646
+ }
647
+ };
648
+ const deleteStagedPaymentProof = async (projectId, key) => {
649
+ const cleanup = await api.attachment.deleteStagedBillAttachments.mutate({
650
+ projectId,
651
+ keys: [key],
652
+ });
653
+ if (cleanup.failedKeys.includes(key)) {
654
+ throw new Error(`Failed to delete staged payment proof ${key}.`);
655
+ }
656
+ };
657
+ export const rethrowAfterStagedPaymentProofCleanup = async (error, cleanup) => {
658
+ if (cleanup) {
659
+ try {
660
+ await cleanup();
661
+ }
662
+ catch (cleanupError) {
663
+ throw new AggregateError([error, cleanupError], "Bill status update failed and its staged payment proof could not be cleaned up.");
664
+ }
665
+ }
666
+ throw error;
667
+ };
668
+ const stageBillPaymentProof = async (projectId, filePath) => {
669
+ const buffer = await readFile(filePath);
670
+ const fileName = basename(filePath);
671
+ const size = buffer.byteLength;
672
+ const contentType = billAttachmentContentTypeForFileName(fileName);
673
+ if (!fileName.toLowerCase().endsWith(".pdf") ||
674
+ contentType !== "application/pdf") {
675
+ throw new Error("Payment proof must be a PDF.");
676
+ }
677
+ if (size === 0) {
678
+ throw new Error("Payment proof must not be empty.");
679
+ }
680
+ if (size > PAYMENT_PROOF_MAX_SIZE) {
681
+ throw new Error("Payment proof must be 20MB or smaller.");
682
+ }
683
+ const { uploadUrl, key } = await api.attachment.requestStagedBillAttachmentUpload.mutate({
684
+ projectId,
685
+ fileName,
686
+ size,
687
+ contentType,
688
+ });
689
+ try {
690
+ const response = await fetch(uploadUrl, {
691
+ method: "PUT",
692
+ body: buffer,
693
+ headers: { "Content-Type": contentType },
694
+ });
695
+ if (!response.ok) {
696
+ throw new Error(`S3 upload failed: HTTP ${response.status} ${(await response.text()).slice(0, 500)}`);
697
+ }
698
+ }
699
+ catch (uploadError) {
700
+ try {
701
+ await deleteStagedPaymentProof(projectId, key);
702
+ }
703
+ catch (cleanupError) {
704
+ throw new AggregateError([uploadError, cleanupError], "Payment-proof upload failed and its staged object could not be cleaned up.");
705
+ }
706
+ throw uploadError;
707
+ }
708
+ return { id: randomUUID(), key, name: fileName, size };
709
+ };
611
710
  export async function updateBillStatus(opts) {
711
+ validateBillStatusUpdateOptions(opts);
612
712
  await assertSensitiveWorkflowConfirmed({
613
713
  action: "Update bill status",
614
714
  entity: `bill ${opts.id}`,
615
715
  details: `to ${opts.status}`,
616
716
  });
617
- const result = await api.bill.updateStatus.mutate({
618
- id: opts.id,
619
- status: opts.status,
620
- rejectionReason: opts.rejectionReason,
621
- paymentTrackingUrl: opts.paymentTrackingUrl,
622
- paymentReference: opts.paymentReference,
623
- });
624
- out(result);
717
+ const bill = opts.paymentProofPath
718
+ ? await api.bill.getById.query({ id: opts.id })
719
+ : null;
720
+ const paymentProof = bill && opts.paymentProofPath
721
+ ? await stageBillPaymentProof(bill.projectId, opts.paymentProofPath)
722
+ : undefined;
723
+ try {
724
+ const result = await api.bill.updateStatus.mutate({
725
+ id: opts.id,
726
+ status: opts.status,
727
+ rejectionReason: opts.rejectionReason,
728
+ paymentTrackingUrl: opts.paymentTrackingUrl,
729
+ paymentReference: opts.paymentReference,
730
+ paymentProofAttachments: paymentProof ? [paymentProof] : undefined,
731
+ });
732
+ out(result);
733
+ }
734
+ catch (error) {
735
+ await rethrowAfterStagedPaymentProofCleanup(error, bill && paymentProof
736
+ ? async () => {
737
+ // The cleanup endpoint checks the staged-upload record and refuses to
738
+ // delete a key that an ambiguously successful update already consumed.
739
+ await deleteStagedPaymentProof(bill.projectId, paymentProof.key);
740
+ }
741
+ : undefined);
742
+ }
625
743
  }
626
744
  export async function patchBillPayment(opts) {
627
745
  const input = { id: opts.id };
@@ -774,6 +892,8 @@ export async function uploadQuotationAttachmentFromPath(projectId, filePath) {
774
892
  const key = `quotations/${projectId}/${randomUUID()}.${ext || "pdf"}`;
775
893
  const uploadUrl = await api.attachment.getPresignedUrlToUpload.mutate({
776
894
  key,
895
+ size: buf.byteLength,
896
+ contentType: attachmentContentType,
777
897
  });
778
898
  const res = await fetch(uploadUrl, {
779
899
  method: "PUT",
@@ -865,6 +985,9 @@ export async function uploadBudgetWinProofFromPath(budgetId, filePath, opts) {
865
985
  ...(opts?.markProjectWon !== undefined && {
866
986
  markProjectWon: opts.markProjectWon,
867
987
  }),
988
+ ...(opts?.projectInvoiceSettings !== undefined && {
989
+ projectInvoiceSettings: opts.projectInvoiceSettings,
990
+ }),
868
991
  });
869
992
  out(result);
870
993
  }
@@ -961,6 +1084,17 @@ export async function createEstimate(budgetId, preflight) {
961
1084
  const result = await api.quickbooks.createEstimate.mutate({ budgetId });
962
1085
  out(result);
963
1086
  }
1087
+ export const describeEstimateEmailConfirmation = (input) => `to ${input.to}; CC ${input.cc.length > 0 ? input.cc.join(", ") : "none"}; reply-to ${input.replyTo}; subject "${input.subject}"; attach the QBO estimate PDF, terms and conditions, and Budget Builder attachments, then mark the estimate sent`;
1088
+ export const sendEstimateToContactPersonFromPayload = async (raw) => {
1089
+ const input = parseSendEstimateToContactPersonPayload(raw);
1090
+ await assertSensitiveWorkflowConfirmed({
1091
+ action: "Send estimate email",
1092
+ entity: `budget ${input.budgetId}`,
1093
+ details: describeEstimateEmailConfirmation(input),
1094
+ });
1095
+ const result = await api.email.sendEstimateToContactPerson.mutate(input);
1096
+ out(result);
1097
+ };
964
1098
  export async function getBudgetDetails(budgetId) {
965
1099
  const detail = await api.budget.getBudgetDetail.query({ id: budgetId });
966
1100
  out(detail);
@@ -1174,19 +1308,31 @@ export const checkCustomerInvoiceReadiness = async (budgetId) => {
1174
1308
  out(result);
1175
1309
  };
1176
1310
  export const listCustomerInvoices = async (input) => {
1177
- const result = await api.customerInvoice.listByBudget.query(input);
1311
+ const result = await api.customerInvoice.list.query(input);
1312
+ out(result);
1313
+ };
1314
+ export const listEligibleCustomerInvoiceBudgets = async (projectId) => {
1315
+ const result = await api.customerInvoice.getEligibleBudgets.query({
1316
+ projectId,
1317
+ });
1178
1318
  out(result);
1179
1319
  };
1180
1320
  export const getCustomerInvoice = async (batchId) => {
1181
1321
  const result = await api.customerInvoice.getInvoiceDetail.query({ batchId });
1182
1322
  out(result);
1183
1323
  };
1324
+ export const getCustomerInvoiceEmailContext = async (batchId) => {
1325
+ const result = await api.customerInvoice.getInvoiceEmailContext.query({
1326
+ batchId,
1327
+ });
1328
+ out(result);
1329
+ };
1184
1330
  export const createCustomerInvoice = async (raw) => {
1185
1331
  const input = parseCreateCustomerInvoicePayload(raw);
1186
1332
  await assertSensitiveWorkflowConfirmed({
1187
1333
  action: "Create customer invoice",
1188
1334
  entity: `budget ${input.budgetId}`,
1189
- details: "create the invoice in QuickBooks and either approve it as admin or send approval-request emails",
1335
+ details: "creates one invoice in QuickBooks; admin-created invoices are approved immediately, while other invoices request approval; approved cumulative coverage of 100% closes the QuickBooks estimate",
1190
1336
  });
1191
1337
  const result = await api.customerInvoice.createInvoiceBatch.mutate(input);
1192
1338
  out(result);
@@ -1195,6 +1341,7 @@ export const discardCreatingCustomerInvoice = async (batchId) => {
1195
1341
  await assertSensitiveWorkflowConfirmed({
1196
1342
  action: "Discard unfinished customer invoice",
1197
1343
  entity: `invoice batch ${batchId}`,
1344
+ details: "removes only a reserved CREATING batch that has no invoice created in QuickBooks",
1198
1345
  });
1199
1346
  const result = await api.customerInvoice.discardCreatingInvoiceBatch.mutate({
1200
1347
  batchId,
@@ -1205,7 +1352,7 @@ export const deleteCustomerInvoice = async (batchId) => {
1205
1352
  await assertSensitiveWorkflowConfirmed({
1206
1353
  action: "Delete customer invoice",
1207
1354
  entity: `invoice batch ${batchId}`,
1208
- details: "delete its invoices from QuickBooks and remove the local batch",
1355
+ details: "deletes its invoices from QuickBooks and removes the local batch; paid invoices are blocked until their QBO payments are reversed; removing approved coverage may reopen the estimate",
1209
1356
  });
1210
1357
  const result = await api.customerInvoice.deleteInvoiceBatch.mutate({
1211
1358
  batchId,
@@ -1216,7 +1363,7 @@ export const voidCustomerInvoice = async (batchId) => {
1216
1363
  await assertSensitiveWorkflowConfirmed({
1217
1364
  action: "Void customer invoice",
1218
1365
  entity: `invoice batch ${batchId}`,
1219
- details: "void its invoices in QuickBooks",
1366
+ details: "voids its invoices in QuickBooks and updates Budget Builder; paid invoices are blocked until their QBO payments are reversed; removing approved coverage may reopen the estimate",
1220
1367
  });
1221
1368
  const result = await api.customerInvoice.voidInvoiceBatch.mutate({ batchId });
1222
1369
  out(result);
@@ -1225,7 +1372,7 @@ export const approveCustomerInvoice = async (batchId) => {
1225
1372
  await assertSensitiveWorkflowConfirmed({
1226
1373
  action: "Approve customer invoice",
1227
1374
  entity: `invoice batch ${batchId}`,
1228
- details: "and notify its creator",
1375
+ details: "approves the batch and notifies its creator; voided invoices cannot be approved; cumulative approved coverage of 100% closes the QuickBooks estimate",
1229
1376
  });
1230
1377
  const result = await api.customerInvoice.approveInvoiceBatch.mutate({
1231
1378
  batchId,
@@ -1236,7 +1383,7 @@ export const rejectCustomerInvoice = async (batchId, rejectionReason) => {
1236
1383
  await assertSensitiveWorkflowConfirmed({
1237
1384
  action: "Reject customer invoice",
1238
1385
  entity: `invoice batch ${batchId}`,
1239
- details: `with reason "${rejectionReason}", void it in QuickBooks, and notify its creator`,
1386
+ details: `records the rejection reason, voids it in QuickBooks, notifies its creator, and reopens a prematurely closed estimate when applicable`,
1240
1387
  });
1241
1388
  const result = await api.customerInvoice.rejectInvoiceBatch.mutate({
1242
1389
  batchId,
@@ -1260,13 +1407,104 @@ export const syncCustomerInvoice = async (invoiceId) => {
1260
1407
  await assertSensitiveWorkflowConfirmed({
1261
1408
  action: "Sync customer invoice from QuickBooks",
1262
1409
  entity: `invoice ${invoiceId}`,
1263
- details: "and update its local status, QuickBooks metadata, balance, and history",
1410
+ details: "refreshes local status, QuickBooks metadata, balance, and history; a QBO-voided invoice becomes VOIDED locally and may reopen its estimate",
1264
1411
  });
1265
1412
  const result = await api.customerInvoice.syncInvoiceStatus.mutate({
1266
1413
  invoiceId,
1267
1414
  });
1268
1415
  out(result);
1269
1416
  };
1417
+ export const describeCustomerInvoiceEmailConfirmation = (input) => `emails the QBO invoice PDF to ${input.to}; requested CC ${input.cc.length > 0 ? input.cc.join(", ") : "none"}; reply-to ${input.replyTo}; subject "${input.subject}"; server-required admin, creator, business-development, and inside-sales CC recipients are added; success marks the invoice SENT; do not retry blindly after an ambiguous delivery failure`;
1418
+ export const sendCustomerInvoiceToContactPersonFromPayload = async (raw) => {
1419
+ const input = parseSendCustomerInvoiceToContactPersonPayload(raw);
1420
+ await assertSensitiveWorkflowConfirmed({
1421
+ action: "Send customer invoice email",
1422
+ entity: `invoice ${input.invoiceId}`,
1423
+ details: describeCustomerInvoiceEmailConfirmation(input),
1424
+ });
1425
+ const result = await api.customerInvoice.sendInvoiceToContactPerson.mutate(input);
1426
+ out(result);
1427
+ };
1428
+ export const describeCustomerInvoicePaymentConfirmation = ({ invoiceId, paymentDate, paymentReference, proofFileName, proofSize, }) => `for invoice ${invoiceId}, records payment date ${paymentDate}${paymentReference
1429
+ ? " with a payment reference"
1430
+ : " without a payment reference"}; uploads ${proofFileName} (${proofSize} bytes); creates a QuickBooks Payment for the live outstanding balance when non-zero, attaches the proof to QBO when possible, and records the payment in Budget Builder`;
1431
+ const assertPaymentDate = (paymentDate) => {
1432
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(paymentDate)) {
1433
+ throw new Error("--paymentDate must use YYYY-MM-DD.");
1434
+ }
1435
+ const parsed = new Date(`${paymentDate}T00:00:00.000Z`);
1436
+ if (Number.isNaN(parsed.getTime()) ||
1437
+ parsed.toISOString().slice(0, 10) !== paymentDate) {
1438
+ throw new Error("--paymentDate must be a valid calendar date.");
1439
+ }
1440
+ return paymentDate;
1441
+ };
1442
+ export const markCustomerInvoicePaidFromPath = async ({ invoiceId, paymentDate, paymentProofPath, paymentReference, }) => {
1443
+ const normalizedPaymentDate = assertPaymentDate(paymentDate);
1444
+ const proofContents = await readFile(paymentProofPath);
1445
+ const proofFileName = basename(paymentProofPath);
1446
+ if (proofContents.byteLength === 0) {
1447
+ throw new Error("Customer invoice payment proof must not be empty.");
1448
+ }
1449
+ if (proofContents.byteLength > CUSTOMER_INVOICE_PAYMENT_PROOF_MAX_SIZE) {
1450
+ throw new Error("Customer invoice payment proof must be 20MB or smaller.");
1451
+ }
1452
+ const proofContentType = customerInvoicePaymentProofContentTypeForFileName(proofFileName);
1453
+ await assertSensitiveWorkflowConfirmed({
1454
+ action: "Mark customer invoice paid",
1455
+ entity: `invoice ${invoiceId}`,
1456
+ details: describeCustomerInvoicePaymentConfirmation({
1457
+ invoiceId,
1458
+ paymentDate: normalizedPaymentDate,
1459
+ paymentReference,
1460
+ proofFileName,
1461
+ proofSize: proofContents.byteLength,
1462
+ }),
1463
+ });
1464
+ const proofUpload = await api.customerInvoice.requestPaymentProofUpload.mutate({
1465
+ invoiceId,
1466
+ fileName: proofFileName,
1467
+ size: proofContents.byteLength,
1468
+ contentType: proofContentType,
1469
+ });
1470
+ const uploadResponse = await fetch(proofUpload.uploadUrl, {
1471
+ method: "PUT",
1472
+ body: proofContents,
1473
+ headers: { "Content-Type": proofUpload.contentType },
1474
+ });
1475
+ if (!uploadResponse.ok) {
1476
+ throw new Error(`Payment-proof upload failed: HTTP ${uploadResponse.status} ${(await uploadResponse.text()).slice(0, 500)}`);
1477
+ }
1478
+ const result = await api.customerInvoice.markPaid.mutate({
1479
+ invoiceId,
1480
+ paymentDate: normalizedPaymentDate,
1481
+ paymentReference,
1482
+ proof: {
1483
+ key: proofUpload.key,
1484
+ name: proofUpload.name,
1485
+ size: proofContents.byteLength,
1486
+ contentType: proofUpload.contentType,
1487
+ },
1488
+ });
1489
+ out(result);
1490
+ };
1491
+ export const downloadCustomerInvoicePaymentProof = async (invoiceId, outputPath) => {
1492
+ const proof = await api.customerInvoice.getPaymentProofDownloadUrl.mutate({
1493
+ invoiceId,
1494
+ });
1495
+ const response = await fetch(proof.url);
1496
+ if (!response.ok) {
1497
+ throw new Error(`Payment-proof download failed: HTTP ${response.status} ${(await response.text()).slice(0, 500)}`);
1498
+ }
1499
+ const contents = Buffer.from(await response.arrayBuffer());
1500
+ const resolvedOutputPath = outputPath?.trim() || basename(proof.fileName);
1501
+ await writeFile(resolvedOutputPath, contents);
1502
+ out({
1503
+ fileName: proof.fileName,
1504
+ outputPath: resolvedOutputPath,
1505
+ size: contents.byteLength,
1506
+ });
1507
+ };
1270
1508
  export async function updateBillFromPayload(raw) {
1271
1509
  const input = parseUpdateBillPayload(raw);
1272
1510
  const result = await api.bill.update.mutate(input);
@@ -1435,7 +1673,9 @@ export async function approveBudget(budgetId) {
1435
1673
  });
1436
1674
  let email;
1437
1675
  try {
1438
- await api.email.sendReplyApprovalEmail.mutate(result.updatedApproval);
1676
+ await api.email.sendReplyApprovalEmail.mutate({
1677
+ id: result.updatedApproval.id,
1678
+ });
1439
1679
  email = { sent: true };
1440
1680
  }
1441
1681
  catch (error) {
@@ -1472,7 +1712,9 @@ export async function rejectBudget(budgetId, reason) {
1472
1712
  });
1473
1713
  let email;
1474
1714
  try {
1475
- await api.email.sendReplyApprovalEmail.mutate(result.updatedApproval);
1715
+ await api.email.sendReplyApprovalEmail.mutate({
1716
+ id: result.updatedApproval.id,
1717
+ });
1476
1718
  email = { sent: true };
1477
1719
  }
1478
1720
  catch (error) {
@@ -1569,8 +1811,7 @@ export async function approveSupplier(supplierId) {
1569
1811
  let email;
1570
1812
  try {
1571
1813
  await api.email.sendReplySupplierApprovalEmail.mutate({
1572
- ...result.updatedApproval,
1573
- rejectionReason: result.updatedApproval.rejectionReason ?? undefined,
1814
+ id: result.updatedApproval.id,
1574
1815
  });
1575
1816
  email = { sent: true };
1576
1817
  }
@@ -1608,8 +1849,7 @@ export async function rejectSupplier(supplierId, reason) {
1608
1849
  let email;
1609
1850
  try {
1610
1851
  await api.email.sendReplySupplierApprovalEmail.mutate({
1611
- ...result.updatedApproval,
1612
- rejectionReason: result.updatedApproval.rejectionReason ?? undefined,
1852
+ id: result.updatedApproval.id,
1613
1853
  });
1614
1854
  email = { sent: true };
1615
1855
  }
@@ -1650,7 +1890,9 @@ export async function rejectBill(billId, reason) {
1650
1890
  }
1651
1891
  let email;
1652
1892
  try {
1653
- await api.email.sendReplyBillApprovalEmail.mutate(result.updatedApproval);
1893
+ await api.email.sendReplyBillApprovalEmail.mutate({
1894
+ id: result.updatedApproval.id,
1895
+ });
1654
1896
  email = { sent: true };
1655
1897
  }
1656
1898
  catch (error) {
@@ -1891,6 +2133,10 @@ export async function listUsers() {
1891
2133
  const users = await api.user.getAllUsers.query();
1892
2134
  out(users);
1893
2135
  }
2136
+ export async function whoAmI() {
2137
+ const user = await api.user.getCurrentUser.query();
2138
+ out(user);
2139
+ }
1894
2140
  export async function getUserPerformance(userId) {
1895
2141
  const result = await api.dashboard.getUserPerformance.query({ userId });
1896
2142
  out(result);
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import "./load-env.js";
3
- import { requireApiKey } from "./api-client.js";
3
+ import { readFileSync } from "node:fs";
4
+ import { BUDGET_BUILDER_API_BASE_URL, requireApiKey } from "./api-client.js";
4
5
  import { consumeCliQuietFlags, logCliAction, sanitizeFlagsForTrace, setCliQuiet, shouldLogCliActions, } from "./cli-trace.js";
5
- import { addBudgetItems, approveBill, approveBudget, approveCustomerInvoice, approveQuotation, approveSupplier, checkCustomerInvoiceReadiness, checkProjectReconciliation, cleanupStagedBillAttachments, cleanupStagedQuotationAttachments, completeProject, createApiKeyForUser, createBillApproval, createBillFromPayload, createBudgetApproval, createBudgetCategory, createBudgetFromPayload, createCompany, createContactPersonFromPayload, createCustomerInvoice, createEstimate, createItemCategory, createItemFromPayload, createPlaceholderBillForBudgetItem, createProject, createQuotationFromPayload, createSupplierCertification, createSupplierFromPayload, createSupplierPaymentMethod, createSupplierRoleOption, createSupplierTagOption, createUser, deleteBillById, deleteBudgetById, deleteBudgetCategory, deleteBudgetCommission, deleteBudgetDiscount, deleteCompanyById, deleteCustomerInvoice, deleteItemById, deleteItemCategoriesByIds, deleteProjectById, deleteQuotationById, deleteSuppliersByIds, discardCreatingCustomerInvoice, downloadCustomerInvoicePdf, downloadQuotationPdf, getApprovedBudgets, getBillAttachments, getBillDetails, getBudget, getBudgetCategories, getBudgetCategoryBenchmarks, getBudgetDetails, getBudgetItemsOnly, getBudgetVersions, getCompany, getCustomerInvoice, getDashboard, getErrorMetrics, getEstimatePerformance, getFinancialOverview, getItem, getItemPricingHistory, getMonthlyMetrics, getProject, getQuotationDetails, getRecentErrors, getSupplierAnalytics, getSupplierDetails, getSupplierPricingHistory, getSystemOverview, getUserPerformance, importQuickBooksProjectId, listApiKeysForUser, listApprovals, listBills, listBudgets, listCompanies, listContacts, listCustomerInvoices, listIntegrationOperations, listItemCategories, listItems, listProjects, listQuotations, listSuppliers, listUsers, markBudgetWonWithProof, patchBillInvoiceNumber, patchBillPayment, reactivateSuppliersByIds, reconcileProject, rejectBill, rejectBudget, rejectCustomerInvoice, rejectQuotation, rejectSupplier, removeBudgetItem, renameBudgetVersion, reorderBudgetItemsCli, restoreBudgetVersion, retryIntegrationOperation, revokeApiKeyForUser, setBudgetItemsNotUtilized, stageBillAttachmentsFromPaths, submitQuotation, syncCustomerInvoice, updateBillFromPayload, updateBillPaymentEvidenceFromPayload, updateBillStatus, updateBudgetCategory, updateBudgetCommissionFromPayload, updateBudgetDiscountFromPayload, updateBudgetFromPayload, updateBudgetItem, updateBudgetItemSupplierCli, updateBudgetStatus, updateCompanyFromPayload, updateContactPersonFromPayload, updateItemCategory, updateItemFromPayload, updateProjectFromPayload, updateProjectStatus, updateQuotationFromPayload, updateSupplierFromPayload, uploadBillAttachmentFromPath, uploadBillAttachmentsFromPaths, uploadBillDocumentsFromPaths, uploadBudgetAttachmentFromPath, uploadQuotationAttachmentFromPath, validateBillSelectionFromPayload, voidCustomerInvoice, } from "./commands.js";
6
+ import { addBudgetItems, approveBill, approveBudget, approveCustomerInvoice, approveQuotation, approveSupplier, checkCustomerInvoiceReadiness, checkProjectReconciliation, cleanupStagedBillAttachments, cleanupStagedQuotationAttachments, completeProject, createApiKeyForUser, createBillApproval, createBillFromPayload, createBudgetApproval, createBudgetCategory, createBudgetFromPayload, createCompany, createContactPersonFromPayload, createCustomerInvoice, createEstimate, createItemCategory, createItemFromPayload, createPlaceholderBillForBudgetItem, createProject, createQuotationFromPayload, createSupplierCertification, createSupplierFromPayload, createSupplierPaymentMethod, createSupplierRoleOption, createSupplierTagOption, createUser, deleteBillById, deleteBudgetById, deleteBudgetCategory, deleteBudgetCommission, deleteBudgetDiscount, deleteCompanyById, deleteCustomerInvoice, deleteItemById, deleteItemCategoriesByIds, deleteProjectById, deleteQuotationById, deleteSuppliersByIds, discardCreatingCustomerInvoice, downloadCustomerInvoicePaymentProof, downloadCustomerInvoicePdf, downloadQuotationPdf, getApprovedBudgets, getBillAttachments, getBillDetails, getBudget, getBudgetCategories, getBudgetCategoryBenchmarks, getBudgetDetails, getBudgetItemsOnly, getBudgetVersions, getCompany, getCustomerInvoice, getCustomerInvoiceEmailContext, getDashboard, getErrorMetrics, getEstimatePerformance, getFinancialOverview, getItem, getItemPricingHistory, getMonthlyMetrics, getProject, getQuotationDetails, getRecentErrors, getSupplierAnalytics, getSupplierDetails, getSupplierPricingHistory, getSystemOverview, getUserPerformance, importQuickBooksProjectId, listApiKeysForUser, listApprovals, listBills, listBudgets, listCompanies, listContacts, listCustomerInvoices, listEligibleCustomerInvoiceBudgets, listIntegrationOperations, listItemCategories, listItems, listProjects, listQuotations, listSuppliers, listUsers, markBudgetWonWithProof, markCustomerInvoicePaidFromPath, patchBillInvoiceNumber, patchBillPayment, reactivateSuppliersByIds, reconcileProject, rejectBill, rejectBudget, rejectCustomerInvoice, rejectQuotation, rejectSupplier, removeBudgetItem, renameBudgetVersion, reorderBudgetItemsCli, restoreBudgetVersion, retryIntegrationOperation, revokeApiKeyForUser, sendCustomerInvoiceToContactPersonFromPayload, sendEstimateToContactPersonFromPayload, setBudgetItemsNotUtilized, stageBillAttachmentsFromPaths, submitQuotation, syncCustomerInvoice, updateBillFromPayload, updateBillPaymentEvidenceFromPayload, updateBillStatus, updateBudgetCategory, updateBudgetCommissionFromPayload, updateBudgetDiscountFromPayload, updateBudgetFromPayload, updateBudgetItem, updateBudgetItemSupplierCli, updateBudgetStatus, updateCompanyFromPayload, updateContactPersonFromPayload, updateItemCategory, updateItemFromPayload, updateProjectFromPayload, updateProjectStatus, updateQuotationFromPayload, updateSupplierFromPayload, uploadBillAttachmentFromPath, uploadBillAttachmentsFromPaths, uploadBillDocumentsFromPaths, uploadBudgetAttachmentFromPath, uploadQuotationAttachmentFromPath, validateBillSelectionFromPayload, voidCustomerInvoice, whoAmI, } from "./commands.js";
6
7
  import { getFlag, parseArgs } from "./parse-args.js";
7
8
  import { billStatusesForUpdateHelp, budgetStatusesForHelp, isBudgetStatusUpdate, parseApprovalTypeFlag, parseBillStatusForUpdate, parseCommaSeparatedBillStatuses, parseCommaSeparatedBudgetStatuses, parseCommaSeparatedCustomerInvoiceStatuses, parseCommaSeparatedIds, parseCommaSeparatedQuotationStatuses, parseCommaSeparatedSupplierStatuses, parseOptionalBillListSortBy, parseOptionalBillListSortDir, parseOptionalDashboardRole, parseOptionalDeals, parseOptionalErrorSeverity, parseOptionalErrorStatus, parseOptionalExtendedProjectStatus, parseOptionalFinancialRole, parseOptionalSupplierAnalyticsTimeFrame, parseOptionalTimeFrame, parseProjectStatusForUpdate, parseUserRole, projectStatusesForHelp, userRolesForHelp, } from "./parse-cli-enums.js";
8
9
  import { parseJsonFlag, parseOptionalNumber as parseOptNum, } from "./parse-json-flag.js";
@@ -144,14 +145,23 @@ bb — Budget Builder CLI for AI agents (parity with MCP tools)
144
145
 
145
146
  Usage: bb <command> [options] [args]
146
147
 
148
+ Identity: bb version — print the installed CLI package version and API target as JSON.
149
+
147
150
  Global: --quiet | -q | BB_CLI_QUIET=1 — hide action logs (default: log command + each tRPC call to stderr; stdout JSON unchanged).
148
151
 
149
- Auth: Set BB_API_KEY in the environment (same API key as MCP / Goracle).
152
+ Auth: Create a key under API Keys in Budget Builder and set BB_API_KEY in the environment.
153
+
154
+ Authorization: Run bb whoami before choosing commands. bb help lists the full
155
+ catalog for every role; it does not prove the active key may call a command.
156
+ The API evaluates whoami.role (USER, INSIDE_SALES, LEAD, ACCOUNTING_TEAM, or
157
+ ADMIN) together with resource ownership/project assignment, current state, and
158
+ pending-approver assignment. On FORBIDDEN, stop and report the identity, role,
159
+ command, and target; do not switch keys or try a lower-level procedure.
150
160
 
151
161
  Sensitive workflow changes: approval decisions/requests, entity status updates,
152
- customer invoices, integration retries, mark-budget-won, submit-quotation, and
153
- supplier approval request side effects require an interactive CONFIRM prompt
154
- and abort in non-interactive shells.
162
+ estimate emails, customer invoices, integration retries, mark-budget-won,
163
+ submit-quotation, and supplier approval request side effects require an
164
+ interactive CONFIRM prompt and abort in non-interactive shells.
155
165
  AI agents must inspect the entity first, summarize the exact entity, target
156
166
  state, and side effects, then wait for explicit user confirmation before running
157
167
  the sensitive command.
@@ -170,12 +180,13 @@ Budgets
170
180
  ESTIMATE_ACCEPTED requires win proof, auto-marks PITCH or LOST projects WON, requires --projectManagerId when the project has no PM, and auto-creates the Asana project/section when enabled.
171
181
  ESTIMATE_REJECTED requires --projectStatusOnCommercialRejection when rejecting the only accepted/closed budget on a commercial project.
172
182
  status: ${budgetStatusesForHelp.join(", ")}
173
- mark-budget-won <budgetId> <filePath> [--projectManagerId <id>] [--markProjectWon true|false] Upload signed quote/PO proof and set status to ESTIMATE_ACCEPTED; PITCH or LOST projects become WON automatically and require PM when none is assigned
183
+ mark-budget-won <budgetId> <filePath> [--projectManagerId <id>] [--invoiceSettings <json>] Upload signed quote/PO proof and set status to ESTIMATE_ACCEPTED; first acceptance requires invoice settings when customer invoicing is enabled
174
184
  create-budget --payload '<json>' (budget.createBudget; Asana deal card is on the project)
175
185
  update-budget --payload '<json>' (budget.updateBudget; must include id)
176
186
  delete-budget <budgetId>
177
187
  create-budget-approval <budgetId> (also sends approval request emails)
178
188
  create-estimate <budgetId> (creates QuickBooks estimate; supports preflight updates)
189
+ send-estimate-to-contact-person --payload '<json>' Send using the web contact-person workflow; payload includes recipient fields plus HTML content/signature.
179
190
  add-budget-items --budgetId --items '[{"itemId":"…","quantity":1,"markup":30},…]'
180
191
  update-budget-item --id <budgetItemId> [--description plain-text] [--note plain-text] [--quantity] [--markup] [--cost] [--unitPrice] [--isFreeOfCharge true|false] [--gstInclusive true|false] [--gstOutOfScope true|false]
181
192
  remove-budget-item <budgetItemId>
@@ -206,9 +217,10 @@ Bills
206
217
  update-bill --payload '<json>' (bill.update; must include id)
207
218
  update-bill-payment-evidence --payload '<json>' Replace paymentReference/paymentProofAttachments for an already-paid bill.
208
219
  delete-bill <billId>
209
- update-bill-status <id> <status> [--rejectionReason] [--paymentTrackingUrl] [--paymentReference]
220
+ update-bill-status <id> <status> [--rejectionReason] [--paymentTrackingUrl] [--paymentReference] [--paymentProof <receipt.pdf>]
210
221
  status (${billStatusesForUpdateHelp.join(", ")}) — not PENDING_APPROVAL; use create-bill-approval
211
- Moving a bill or claim to PAID sends creator and supplier emails, CC m@getout.sg and BCC noah@getout.sg; duplicate claim recipients receive one email.
222
+ PAID requires --paymentReference and a payment-proof PDF. Use --paymentProof to stage and submit the PDF atomically, or omit it only when BB already has a BILL_PAYMENT_PROOF attachment.
223
+ Moving a bill or claim to PAID runs the server's paid-bill notification workflow.
212
224
  patch-bill-payment <billId> [--paymentTrackingUrl] [--paymentReference] [--quickbooksBillId] [--paymentDate <ISO>]
213
225
  Clear a field: --clearPaymentTrackingUrl true | --clearPaymentReference true | --clearQuickbooksBillId true | --clearPaymentDate true
214
226
  patch-bill-invoice-number <billId> <invoiceNumber>
@@ -233,16 +245,23 @@ Quotations
233
245
 
234
246
  Customer invoices
235
247
  check-customer-invoice-readiness <budgetId>
236
- list-customer-invoices <budgetId> [--status CSV] [--search] [--sortBy createdAt|status|totalNetAmount|approvalExpiresAt] [--sortDir asc|desc] [--page] [--perPage]
248
+ list-eligible-customer-invoice-budgets <projectId> Accepted budgets that are ready and still have invoice coverage remaining.
249
+ list-customer-invoices [budgetId] [--projectId <id>] [--companyIds CSV] [--projectIds CSV] [--budgetIds CSV] [--createdByIds CSV] [--status CSV] [--search] [--sortBy createdAt|status|totalNetAmount|approvalExpiresAt] [--sortDir asc|desc] [--page] [--perPage]
250
+ No scope flag lists invoices globally. --projectId uses the project scope. A positional budgetId is a backward-compatible shortcut for --budgetIds.
237
251
  get-customer-invoice <batchId>
252
+ get-customer-invoice-email-context <batchId> Contact, required CC, budget, and project context for composing an invoice email.
238
253
  create-customer-invoice --payload '<json>' budgetId + one split with label, percentage, and dueDate.
239
254
  discard-customer-invoice <batchId> Discard a reserved CREATING batch with no created QBO invoices.
240
255
  delete-customer-invoice <batchId> Delete QBO invoices and the local batch.
241
256
  void-customer-invoice <batchId> Void the invoice batch in QuickBooks.
242
- approve-customer-invoice <batchId> Admin approval; notifies the creator.
257
+ approve-customer-invoice <batchId> Admin approval; rejects voided invoices, notifies the creator, and closes the estimate at cumulative 100% coverage.
243
258
  reject-customer-invoice <batchId> --reason <text> Admin rejection; voids QBO invoices and notifies the creator.
259
+ send-customer-invoice-to-contact-person --payload '<json>' Same composer workflow as web; requires invoiceId, to, cc, replyTo, subject, HTML content, and HTML signature.
260
+ mark-customer-invoice-paid <invoiceId> --paymentDate YYYY-MM-DD --paymentProof <path> [--paymentReference <text>]
261
+ Admin-only. Uploads proof, creates a QBO Payment for the live balance when needed, attaches proof in QBO when possible, and records payment history in BB.
262
+ download-customer-invoice-payment-proof <invoiceId> [--output <path>]
244
263
  download-customer-invoice-pdf <invoiceId> [--output <path>]
245
- sync-customer-invoice <invoiceId> Refresh local status and balance from QuickBooks.
264
+ sync-customer-invoice <invoiceId> Refresh local status and balance from QuickBooks; QBO voids can reopen a closed estimate.
246
265
 
247
266
  Approvals
248
267
  list-approvals | get-pending-approvals [--type budget|supplier|bill|quotation|customer_invoice|all]
@@ -296,30 +315,31 @@ Suppliers & items
296
315
  update-item --payload '<json>' (item.updateItem; must include id)
297
316
  delete-item <id>
298
317
  get-supplier-details <supplierId>
299
- get-supplier-analytics [--name] [--page] [--perPage] [--timeFrame ALL|LAST_YEAR|…]
318
+ get-supplier-analytics [--name] [--page] [--perPage] [--timeFrame ALL|LAST_YEAR|…] (admin)
300
319
  list-items [--name] [--page] [--perPage]
301
320
  get-item <id>
302
321
  list-item-categories [--page] [--perPage]
303
- create-item-category --name
304
- update-item-category --id --name
322
+ create-item-category --name (admin)
323
+ update-item-category --id --name (admin)
305
324
  delete-item-categories --ids <csv> (itemCategory.deleteItemCategories; admin; empty categories only)
306
325
 
307
326
  Dashboard & users
327
+ whoami Show the user identity and role for the active API key.
308
328
  list-users
309
329
  create-user --email <email> [--name <name>] [--role ${userRolesForHelp.join("|")}] (admin; API-only service identity with no Google sign-in)
310
- create-api-key --userId <userId> --name <label> (admin; raw key is shown once)
311
- list-api-keys [--userId <userId>] (admin; defaults to the caller)
312
- revoke-api-key <apiKeyId> [--userId <userId>] (admin; --userId is required for another user's key)
313
- get-user-performance [--userId]
314
- get-dashboard [--userId] [--role BD|CREATOR|INSIDE_SALES|ALL] [--deals ALL|SUCCESSFUL|LOST] [--timeFrame] [--startDate] [--endDate]
315
- get-monthly-metrics [same optional flags as get-dashboard] (dashboard.getMonthlyMetrics)
316
- get-system-overview [same optional flags as get-dashboard] (dashboard.getSystemOverview)
317
- get-estimate-performance [same optional flags as get-dashboard] (dashboard.getEstimatePerformance)
318
- get-financial-overview [same optional flags as dashboard; role uses BudgetRole enum]
330
+ create-api-key --name <label> [--userId <userId>] (defaults to the caller; admin required for another user; raw key is shown once)
331
+ list-api-keys [--userId <userId>] (defaults to the caller; admin required for another user)
332
+ revoke-api-key <apiKeyId> [--userId <userId>] (defaults to the caller; admin required for another user)
333
+ get-user-performance [--userId] (admin)
334
+ get-dashboard [--userId] [--role BD|CREATOR|INSIDE_SALES|ALL] [--deals ALL|SUCCESSFUL|LOST] [--timeFrame] [--startDate] [--endDate] (admin)
335
+ get-monthly-metrics [same optional flags as get-dashboard] (admin; dashboard.getMonthlyMetrics)
336
+ get-system-overview [same optional flags as get-dashboard] (admin; dashboard.getSystemOverview)
337
+ get-estimate-performance [same optional flags as get-dashboard] (admin; dashboard.getEstimatePerformance)
338
+ get-financial-overview [same optional flags as dashboard; role uses BudgetRole enum] (admin)
319
339
 
320
340
  Errors
321
- get-recent-errors [--page] [--perPage] [--severity] [--status]
322
- get-error-metrics
341
+ get-recent-errors [--page] [--perPage] [--severity] [--status] (admin)
342
+ get-error-metrics (admin)
323
343
 
324
344
  Automation (admin)
325
345
  list-integration-operations [--destination] [--status PENDING|PROCESSING|FAILED|COMPLETED] [--page] [--perPage]
@@ -343,6 +363,20 @@ Not covered vs MCP get_budget: "get-budget" also fetches line items in one call.
343
363
  `.trim();
344
364
  console.log(help);
345
365
  }
366
+ const getCliPackageMetadata = () => {
367
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
368
+ if (!packageJson.name || !packageJson.version) {
369
+ throw new Error("Could not read Budget Builder CLI package metadata.");
370
+ }
371
+ return { name: packageJson.name, version: packageJson.version };
372
+ };
373
+ const printVersion = () => {
374
+ const metadata = getCliPackageMetadata();
375
+ console.log(JSON.stringify({
376
+ ...metadata,
377
+ apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
378
+ }, null, 2));
379
+ };
346
380
  /** MCP registers tools with snake_case; CLI commands are kebab-case. */
347
381
  function normalizeCliCommand(raw) {
348
382
  return raw.replace(/_/g, "-");
@@ -351,6 +385,10 @@ async function main() {
351
385
  const { command, positional, flags } = parseArgs(process.argv);
352
386
  setCliQuiet(consumeCliQuietFlags(flags));
353
387
  const cmd = normalizeCliCommand(command ?? "");
388
+ if (cmd === "version" || flags.version === true) {
389
+ printVersion();
390
+ process.exit(0);
391
+ }
354
392
  if (!cmd || cmd === "help" || flags.help === true) {
355
393
  printHelp();
356
394
  process.exit(0);
@@ -368,6 +406,10 @@ async function main() {
368
406
  }
369
407
  try {
370
408
  switch (cmd) {
409
+ case "whoami": {
410
+ await whoAmI();
411
+ break;
412
+ }
371
413
  case "list-budgets": {
372
414
  const statusStr = getFlag(flags, "status");
373
415
  const statuses = parseCommaSeparatedBudgetStatuses(statusStr !== undefined ? String(statusStr) : undefined);
@@ -413,6 +455,9 @@ async function main() {
413
455
  ? String(getFlag(flags, "projectManagerId"))
414
456
  : undefined,
415
457
  markProjectWon: parseOptionalBoolFlag(flags, "markProjectWon"),
458
+ projectInvoiceSettings: getFlag(flags, "invoiceSettings") !== undefined
459
+ ? parseJsonFlag(String(getFlag(flags, "invoiceSettings")), "--invoiceSettings")
460
+ : undefined,
416
461
  });
417
462
  break;
418
463
  }
@@ -472,6 +517,9 @@ async function main() {
472
517
  : undefined,
473
518
  markProjectWon: parseOptionalBoolFlag(flags, "markProjectWon"),
474
519
  projectStatusOnCommercialRejection: parseCommercialRejectionProjectStatus(flags),
520
+ projectInvoiceSettings: getFlag(flags, "invoiceSettings") !== undefined
521
+ ? parseJsonFlag(String(getFlag(flags, "invoiceSettings")), "--invoiceSettings")
522
+ : undefined,
475
523
  });
476
524
  break;
477
525
  }
@@ -515,6 +563,16 @@ async function main() {
515
563
  });
516
564
  break;
517
565
  }
566
+ case "send-estimate-email":
567
+ case "send-estimate-to-contact-person": {
568
+ const payloadRaw = getFlag(flags, "payload");
569
+ if (!payloadRaw) {
570
+ throw new Error("send-estimate-to-contact-person requires --payload '<json>'");
571
+ }
572
+ const raw = parseJsonFlag(String(payloadRaw), "--payload");
573
+ await sendEstimateToContactPersonFromPayload(raw);
574
+ break;
575
+ }
518
576
  case "create-budget": {
519
577
  const payloadRaw = getFlag(flags, "payload");
520
578
  if (!payloadRaw) {
@@ -745,7 +803,11 @@ async function main() {
745
803
  case "update-bill-status": {
746
804
  const [id, statusRaw] = positional;
747
805
  if (!id || !statusRaw) {
748
- throw new Error("update-bill-status requires <billId> <status> [--rejectionReason] [--paymentTrackingUrl] [--paymentReference]");
806
+ throw new Error("update-bill-status requires <billId> <status> [--rejectionReason] [--paymentTrackingUrl] [--paymentReference] [--paymentProof <receipt.pdf>]");
807
+ }
808
+ const paymentProofFlag = flags.paymentProof ?? flags["payment-proof"] ?? flags.receipt;
809
+ if (paymentProofFlag === true) {
810
+ throw new Error("--paymentProof requires a PDF file path.");
749
811
  }
750
812
  const status = parseBillStatusForUpdate(statusRaw);
751
813
  await updateBillStatus({
@@ -754,6 +816,7 @@ async function main() {
754
816
  rejectionReason: getFlag(flags, "rejectionReason"),
755
817
  paymentTrackingUrl: getFlag(flags, "paymentTrackingUrl"),
756
818
  paymentReference: getFlag(flags, "paymentReference"),
819
+ paymentProofPath: paymentProofFlag,
757
820
  });
758
821
  break;
759
822
  }
@@ -1053,19 +1116,39 @@ async function main() {
1053
1116
  await checkCustomerInvoiceReadiness(budgetId);
1054
1117
  break;
1055
1118
  }
1056
- case "list-customer-invoices": {
1057
- const budgetId = positional[0];
1058
- if (!budgetId) {
1059
- throw new Error("list-customer-invoices requires <budgetId>");
1119
+ case "list-eligible-customer-invoice-budgets": {
1120
+ const projectId = positional[0];
1121
+ if (!projectId) {
1122
+ throw new Error("list-eligible-customer-invoice-budgets requires <projectId>");
1060
1123
  }
1124
+ await listEligibleCustomerInvoiceBudgets(projectId);
1125
+ break;
1126
+ }
1127
+ case "list-customer-invoices": {
1128
+ const positionalBudgetId = positional[0];
1129
+ const projectId = getFlag(flags, "projectId");
1130
+ const projectIds = parseCommaSeparatedIds(getFlag(flags, "projectIds"));
1131
+ if (projectId && projectIds) {
1132
+ throw new Error("Use either --projectId for project scope or --projectIds for a global filter, not both.");
1133
+ }
1134
+ const budgetIds = [
1135
+ ...(positionalBudgetId ? [positionalBudgetId] : []),
1136
+ ...(parseCommaSeparatedIds(getFlag(flags, "budgetIds")) ?? []),
1137
+ ];
1061
1138
  const statusRaw = getFlag(flags, "status");
1062
1139
  const sortDir = parseOptionalBillListSortDir(getFlag(flags, "sortDir"));
1063
1140
  await listCustomerInvoices({
1064
- budgetId,
1141
+ scope: projectId
1142
+ ? { type: "project", projectId }
1143
+ : { type: "global" },
1065
1144
  page: parsePositiveIntFlag(getFlag(flags, "page"), "--page") ?? 1,
1066
- perPage: parsePositiveIntFlag(getFlag(flags, "perPage"), "--perPage") ?? 12,
1145
+ perPage: parsePositiveIntFlag(getFlag(flags, "perPage"), "--perPage") ?? 20,
1067
1146
  q: getFlag(flags, "search") ?? "",
1068
1147
  statuses: parseCommaSeparatedCustomerInvoiceStatuses(statusRaw !== undefined ? String(statusRaw) : undefined),
1148
+ companyIds: parseCommaSeparatedIds(getFlag(flags, "companyIds")),
1149
+ projectIds,
1150
+ budgetIds: budgetIds.length > 0 ? [...new Set(budgetIds)] : undefined,
1151
+ createdByIds: parseCommaSeparatedIds(getFlag(flags, "createdByIds")),
1069
1152
  sortBy: parseCustomerInvoiceSortBy(getFlag(flags, "sortBy")),
1070
1153
  sortDir: sortDir ?? "desc",
1071
1154
  });
@@ -1079,6 +1162,14 @@ async function main() {
1079
1162
  await getCustomerInvoice(batchId);
1080
1163
  break;
1081
1164
  }
1165
+ case "get-customer-invoice-email-context": {
1166
+ const batchId = positional[0];
1167
+ if (!batchId) {
1168
+ throw new Error("get-customer-invoice-email-context requires <batchId>");
1169
+ }
1170
+ await getCustomerInvoiceEmailContext(batchId);
1171
+ break;
1172
+ }
1082
1173
  case "create-customer-invoice": {
1083
1174
  const payloadRaw = getFlag(flags, "payload");
1084
1175
  if (!payloadRaw) {
@@ -1128,6 +1219,37 @@ async function main() {
1128
1219
  await rejectCustomerInvoice(batchId, String(reason));
1129
1220
  break;
1130
1221
  }
1222
+ case "send-customer-invoice-to-contact-person": {
1223
+ const payloadRaw = getFlag(flags, "payload");
1224
+ if (!payloadRaw) {
1225
+ throw new Error("send-customer-invoice-to-contact-person requires --payload '<json>'");
1226
+ }
1227
+ await sendCustomerInvoiceToContactPersonFromPayload(parseJsonFlag(String(payloadRaw), "--payload"));
1228
+ break;
1229
+ }
1230
+ case "mark-customer-invoice-paid": {
1231
+ const invoiceId = positional[0];
1232
+ const paymentDate = getFlag(flags, "paymentDate");
1233
+ const paymentProofPath = getFlag(flags, "paymentProof");
1234
+ if (!invoiceId || !paymentDate || !paymentProofPath) {
1235
+ throw new Error("mark-customer-invoice-paid requires <invoiceId> --paymentDate YYYY-MM-DD --paymentProof <path> [--paymentReference <text>]");
1236
+ }
1237
+ await markCustomerInvoicePaidFromPath({
1238
+ invoiceId,
1239
+ paymentDate,
1240
+ paymentProofPath,
1241
+ paymentReference: getFlag(flags, "paymentReference"),
1242
+ });
1243
+ break;
1244
+ }
1245
+ case "download-customer-invoice-payment-proof": {
1246
+ const invoiceId = positional[0];
1247
+ if (!invoiceId) {
1248
+ throw new Error("download-customer-invoice-payment-proof requires <invoiceId> [--output <path>]");
1249
+ }
1250
+ await downloadCustomerInvoicePaymentProof(invoiceId, getFlag(flags, "output"));
1251
+ break;
1252
+ }
1131
1253
  case "download-customer-invoice-pdf": {
1132
1254
  const invoiceId = positional[0];
1133
1255
  if (!invoiceId) {
@@ -1584,8 +1706,8 @@ async function main() {
1584
1706
  case "create-api-key": {
1585
1707
  const userId = getFlag(flags, "userId");
1586
1708
  const name = getFlag(flags, "name");
1587
- if (!userId || !name) {
1588
- throw new Error("create-api-key requires --userId <userId> --name <label>");
1709
+ if (!name) {
1710
+ throw new Error("create-api-key requires --name <label> [--userId <userId>]");
1589
1711
  }
1590
1712
  await createApiKeyForUser({ userId, name });
1591
1713
  break;
package/dist/load-env.js CHANGED
@@ -3,4 +3,4 @@
3
3
  */
4
4
  import path from "node:path";
5
5
  import { config } from "dotenv";
6
- config({ path: path.resolve(process.cwd(), ".env") });
6
+ config({ path: path.resolve(process.cwd(), ".env"), quiet: true });
@@ -34,6 +34,45 @@ function requiredString(value, fieldLabel) {
34
34
  }
35
35
  return t;
36
36
  }
37
+ const EMAIL_ADDRESS_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
38
+ const requiredEmail = (value, fieldLabel) => {
39
+ const email = requiredString(value, fieldLabel);
40
+ if (!EMAIL_ADDRESS_PATTERN.test(email)) {
41
+ throw new Error(`${fieldLabel} must be a valid email address.`);
42
+ }
43
+ return email;
44
+ };
45
+ const parseContactPersonEmailPayload = (input, commandName) => {
46
+ if (!Array.isArray(input.cc)) {
47
+ throw new Error(`${commandName} payload.cc must be an email array.`);
48
+ }
49
+ return {
50
+ to: requiredEmail(input.to, `${commandName} payload.to`),
51
+ cc: input.cc.map((email, index) => requiredEmail(email, `${commandName} payload.cc[${index}]`)),
52
+ replyTo: requiredEmail(input.replyTo, `${commandName} payload.replyTo`),
53
+ subject: requiredString(input.subject, `${commandName} payload.subject`),
54
+ content: requiredString(input.content, `${commandName} payload.content`),
55
+ signature: requiredString(input.signature, `${commandName} payload.signature`),
56
+ };
57
+ };
58
+ /** Matches the web estimate composer (`email.sendEstimateToContactPerson`). */
59
+ export const parseSendEstimateToContactPersonPayload = (raw) => {
60
+ const input = requireObject(raw, "send-estimate-to-contact-person payload");
61
+ return {
62
+ ...parseContactPersonEmailPayload(input, "send-estimate-to-contact-person"),
63
+ estimateId: requiredString(input.estimateId, "send-estimate-to-contact-person payload.estimateId"),
64
+ estimateDocNumber: optionalString(input.estimateDocNumber),
65
+ budgetId: requiredString(input.budgetId, "send-estimate-to-contact-person payload.budgetId"),
66
+ };
67
+ };
68
+ /** Matches the web customer-invoice email composer. */
69
+ export const parseSendCustomerInvoiceToContactPersonPayload = (raw) => {
70
+ const input = requireObject(raw, "send-customer-invoice-to-contact-person payload");
71
+ return {
72
+ ...parseContactPersonEmailPayload(input, "send-customer-invoice-to-contact-person"),
73
+ invoiceId: requiredString(input.invoiceId, "send-customer-invoice-to-contact-person payload.invoiceId"),
74
+ };
75
+ };
37
76
  /** Matches budget.createBudget — optional pipedriveDealId; Asana deal is project.asanaTaskId. */
38
77
  export function parseCreateBudgetPayload(raw) {
39
78
  const o = requireObject(raw, "create-budget payload");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@go-labs-sg/bb",
3
- "version": "1.19.0",
4
- "description": "Budget Builder CLI for AI agents — manage budgets, bills, and claims; bill records use isClaimable=false for bills and isClaimable=true for claims.",
3
+ "version": "1.21.0",
4
+ "description": "Budget Builder CLI for AI agents — manage budgets, bills, claims, quotations, and customer invoices with explicit workflow previews for sensitive changes.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "bin": {