@go-labs-sg/bb 1.20.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, 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. 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,9 +132,11 @@ 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`.
136
+
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.
72
138
 
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`.
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`.
74
140
 
75
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
 
@@ -82,20 +148,29 @@ Global options and flags use `--key=value` or `--key value` (see `bb help`).
82
148
 
83
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.
84
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
+
85
160
  ### Command overview
86
161
 
87
162
  | Area | Commands (non-exhaustive) |
88
163
  | --- | --- |
89
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`) |
90
- | **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` (`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` |
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` |
91
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`) |
92
- | **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` |
93
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` |
94
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`) |
95
170
  | **Contacts** | `list-contacts`, `create-contact-person` (`--payload`), `update-contact-person` (`--payload`) |
96
- | **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) |
97
- | **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` |
98
- | **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` |
99
174
  | **Automation (admin)** | `list-integration-operations`, `retry-integration-operation` |
100
175
  | **Historical / benchmarks** | `get-approved-budgets`, `get-budget-category-benchmarks`, `get-item-pricing-history`, `get-supplier-pricing-history` |
101
176
 
@@ -1,7 +1,7 @@
1
1
  import { createTRPCProxyClient, httpBatchLink, loggerLink } from "@trpc/client";
2
2
  import SuperJSON from "superjson";
3
3
  import { sanitizeValueForTrace, shouldLogCliActions } from "./cli-trace.js";
4
- const BASE_URL = "https://budget-builder.getout.events";
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())
@@ -31,7 +31,7 @@ export const api = createTRPCProxyClient({
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
@@ -29,8 +29,12 @@ const SENSITIVE_TRACE_FIELD = new Set([
29
29
  "paymentproofattachments",
30
30
  "paymentproofpath",
31
31
  "paymentreference",
32
+ "proof",
32
33
  "receipt",
33
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));
34
38
  export const sanitizeValueForTrace = (value, seen = new WeakSet()) => {
35
39
  if (typeof value !== "object" || value === null)
36
40
  return value;
@@ -45,7 +49,8 @@ export const sanitizeValueForTrace = (value, seen = new WeakSet()) => {
45
49
  return value;
46
50
  return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => [
47
51
  key,
48
- SENSITIVE_TRACE_FIELD.has(key.toLowerCase())
52
+ SENSITIVE_TRACE_FIELD.has(key.toLowerCase()) ||
53
+ isPresignedUrl(nestedValue)
49
54
  ? "[redacted]"
50
55
  : sanitizeValueForTrace(nestedValue, seen),
51
56
  ]));
package/dist/commands.js CHANGED
@@ -6,7 +6,7 @@ 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, parseSendEstimateToContactPersonPayload, parseSupplierCreatePayload, parseSupplierUpdatePayload, parseUpdateBillPayload, parseUpdateBillPaymentEvidencePayload, parseUpdateBudgetCommissionPayload, parseUpdateBudgetPayload, parseUpdateProjectPayload, parseUpdateQuotationPayload, parseValidateBillSelectionPayload, } from "./parse-mutation-payload.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
10
  import { BillStatus, BudgetStatus, ProjectStatus } from "./prisma-enums.js";
11
11
  import { createRichTextFromPlainText } from "./rich-text.js";
12
12
  const BUDGET = "BUDGET";
@@ -263,7 +263,18 @@ const billAttachmentContentTypeForFileName = (fileName) => {
263
263
  return resolvedContentType;
264
264
  };
265
265
  const PAYMENT_PROOF_MAX_SIZE = 20 * 1024 * 1024;
266
+ const CUSTOMER_INVOICE_PAYMENT_PROOF_MAX_SIZE = 20 * 1024 * 1024;
266
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
+ };
267
278
  const quotationAttachmentContentTypeForFileName = (fileName) => {
268
279
  const resolvedContentType = contentTypeHeaderForFileName(fileName);
269
280
  if (resolvedContentType === "application/pdf" ||
@@ -290,18 +301,27 @@ const lockedBudgetStatusLabels = {
290
301
  const lockedBudgetStatusLabel = (status) => status in lockedBudgetStatusLabels
291
302
  ? lockedBudgetStatusLabels[status]
292
303
  : status;
293
- 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) => {
294
314
  const expected = "CONFIRM";
295
- const detailText = details ? ` ${details}` : "";
315
+ const preview = buildSensitiveWorkflowPreview(input);
296
316
  if (!process.stdin.isTTY || !process.stderr.isTTY) {
297
- 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.`);
298
318
  }
299
319
  const rl = createInterface({
300
320
  input: process.stdin,
301
321
  output: process.stderr,
302
322
  });
303
323
  try {
304
- 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: `);
305
325
  if (answer.trim() !== expected) {
306
326
  throw new Error("Aborted.");
307
327
  }
@@ -392,6 +412,9 @@ export async function updateBudgetStatus(budgetId, status, opts) {
392
412
  ...(opts?.projectStatusOnCommercialRejection !== undefined && {
393
413
  projectStatusOnCommercialRejection: opts.projectStatusOnCommercialRejection,
394
414
  }),
415
+ ...(opts?.projectInvoiceSettings !== undefined && {
416
+ projectInvoiceSettings: opts.projectInvoiceSettings,
417
+ }),
395
418
  });
396
419
  out(result);
397
420
  }
@@ -503,7 +526,9 @@ export async function approveBill(billId) {
503
526
  }
504
527
  let email;
505
528
  try {
506
- await api.email.sendReplyBillApprovalEmail.mutate(result.updatedApproval);
529
+ await api.email.sendReplyBillApprovalEmail.mutate({
530
+ id: result.updatedApproval.id,
531
+ });
507
532
  email = { sent: true };
508
533
  }
509
534
  catch (error) {
@@ -709,8 +734,8 @@ export async function updateBillStatus(opts) {
709
734
  catch (error) {
710
735
  await rethrowAfterStagedPaymentProofCleanup(error, bill && paymentProof
711
736
  ? async () => {
712
- // The cleanup endpoint checks Attachment rows and refuses to delete a key
713
- // that an ambiguously successful status update already attached.
737
+ // The cleanup endpoint checks the staged-upload record and refuses to
738
+ // delete a key that an ambiguously successful update already consumed.
714
739
  await deleteStagedPaymentProof(bill.projectId, paymentProof.key);
715
740
  }
716
741
  : undefined);
@@ -867,6 +892,8 @@ export async function uploadQuotationAttachmentFromPath(projectId, filePath) {
867
892
  const key = `quotations/${projectId}/${randomUUID()}.${ext || "pdf"}`;
868
893
  const uploadUrl = await api.attachment.getPresignedUrlToUpload.mutate({
869
894
  key,
895
+ size: buf.byteLength,
896
+ contentType: attachmentContentType,
870
897
  });
871
898
  const res = await fetch(uploadUrl, {
872
899
  method: "PUT",
@@ -958,6 +985,9 @@ export async function uploadBudgetWinProofFromPath(budgetId, filePath, opts) {
958
985
  ...(opts?.markProjectWon !== undefined && {
959
986
  markProjectWon: opts.markProjectWon,
960
987
  }),
988
+ ...(opts?.projectInvoiceSettings !== undefined && {
989
+ projectInvoiceSettings: opts.projectInvoiceSettings,
990
+ }),
961
991
  });
962
992
  out(result);
963
993
  }
@@ -1278,19 +1308,31 @@ export const checkCustomerInvoiceReadiness = async (budgetId) => {
1278
1308
  out(result);
1279
1309
  };
1280
1310
  export const listCustomerInvoices = async (input) => {
1281
- 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
+ });
1282
1318
  out(result);
1283
1319
  };
1284
1320
  export const getCustomerInvoice = async (batchId) => {
1285
1321
  const result = await api.customerInvoice.getInvoiceDetail.query({ batchId });
1286
1322
  out(result);
1287
1323
  };
1324
+ export const getCustomerInvoiceEmailContext = async (batchId) => {
1325
+ const result = await api.customerInvoice.getInvoiceEmailContext.query({
1326
+ batchId,
1327
+ });
1328
+ out(result);
1329
+ };
1288
1330
  export const createCustomerInvoice = async (raw) => {
1289
1331
  const input = parseCreateCustomerInvoicePayload(raw);
1290
1332
  await assertSensitiveWorkflowConfirmed({
1291
1333
  action: "Create customer invoice",
1292
1334
  entity: `budget ${input.budgetId}`,
1293
- 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",
1294
1336
  });
1295
1337
  const result = await api.customerInvoice.createInvoiceBatch.mutate(input);
1296
1338
  out(result);
@@ -1299,6 +1341,7 @@ export const discardCreatingCustomerInvoice = async (batchId) => {
1299
1341
  await assertSensitiveWorkflowConfirmed({
1300
1342
  action: "Discard unfinished customer invoice",
1301
1343
  entity: `invoice batch ${batchId}`,
1344
+ details: "removes only a reserved CREATING batch that has no invoice created in QuickBooks",
1302
1345
  });
1303
1346
  const result = await api.customerInvoice.discardCreatingInvoiceBatch.mutate({
1304
1347
  batchId,
@@ -1309,7 +1352,7 @@ export const deleteCustomerInvoice = async (batchId) => {
1309
1352
  await assertSensitiveWorkflowConfirmed({
1310
1353
  action: "Delete customer invoice",
1311
1354
  entity: `invoice batch ${batchId}`,
1312
- 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",
1313
1356
  });
1314
1357
  const result = await api.customerInvoice.deleteInvoiceBatch.mutate({
1315
1358
  batchId,
@@ -1320,7 +1363,7 @@ export const voidCustomerInvoice = async (batchId) => {
1320
1363
  await assertSensitiveWorkflowConfirmed({
1321
1364
  action: "Void customer invoice",
1322
1365
  entity: `invoice batch ${batchId}`,
1323
- 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",
1324
1367
  });
1325
1368
  const result = await api.customerInvoice.voidInvoiceBatch.mutate({ batchId });
1326
1369
  out(result);
@@ -1329,7 +1372,7 @@ export const approveCustomerInvoice = async (batchId) => {
1329
1372
  await assertSensitiveWorkflowConfirmed({
1330
1373
  action: "Approve customer invoice",
1331
1374
  entity: `invoice batch ${batchId}`,
1332
- 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",
1333
1376
  });
1334
1377
  const result = await api.customerInvoice.approveInvoiceBatch.mutate({
1335
1378
  batchId,
@@ -1340,7 +1383,7 @@ export const rejectCustomerInvoice = async (batchId, rejectionReason) => {
1340
1383
  await assertSensitiveWorkflowConfirmed({
1341
1384
  action: "Reject customer invoice",
1342
1385
  entity: `invoice batch ${batchId}`,
1343
- 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`,
1344
1387
  });
1345
1388
  const result = await api.customerInvoice.rejectInvoiceBatch.mutate({
1346
1389
  batchId,
@@ -1364,13 +1407,104 @@ export const syncCustomerInvoice = async (invoiceId) => {
1364
1407
  await assertSensitiveWorkflowConfirmed({
1365
1408
  action: "Sync customer invoice from QuickBooks",
1366
1409
  entity: `invoice ${invoiceId}`,
1367
- 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",
1368
1411
  });
1369
1412
  const result = await api.customerInvoice.syncInvoiceStatus.mutate({
1370
1413
  invoiceId,
1371
1414
  });
1372
1415
  out(result);
1373
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
+ };
1374
1508
  export async function updateBillFromPayload(raw) {
1375
1509
  const input = parseUpdateBillPayload(raw);
1376
1510
  const result = await api.bill.update.mutate(input);
@@ -1539,7 +1673,9 @@ export async function approveBudget(budgetId) {
1539
1673
  });
1540
1674
  let email;
1541
1675
  try {
1542
- await api.email.sendReplyApprovalEmail.mutate(result.updatedApproval);
1676
+ await api.email.sendReplyApprovalEmail.mutate({
1677
+ id: result.updatedApproval.id,
1678
+ });
1543
1679
  email = { sent: true };
1544
1680
  }
1545
1681
  catch (error) {
@@ -1576,7 +1712,9 @@ export async function rejectBudget(budgetId, reason) {
1576
1712
  });
1577
1713
  let email;
1578
1714
  try {
1579
- await api.email.sendReplyApprovalEmail.mutate(result.updatedApproval);
1715
+ await api.email.sendReplyApprovalEmail.mutate({
1716
+ id: result.updatedApproval.id,
1717
+ });
1580
1718
  email = { sent: true };
1581
1719
  }
1582
1720
  catch (error) {
@@ -1673,8 +1811,7 @@ export async function approveSupplier(supplierId) {
1673
1811
  let email;
1674
1812
  try {
1675
1813
  await api.email.sendReplySupplierApprovalEmail.mutate({
1676
- ...result.updatedApproval,
1677
- rejectionReason: result.updatedApproval.rejectionReason ?? undefined,
1814
+ id: result.updatedApproval.id,
1678
1815
  });
1679
1816
  email = { sent: true };
1680
1817
  }
@@ -1712,8 +1849,7 @@ export async function rejectSupplier(supplierId, reason) {
1712
1849
  let email;
1713
1850
  try {
1714
1851
  await api.email.sendReplySupplierApprovalEmail.mutate({
1715
- ...result.updatedApproval,
1716
- rejectionReason: result.updatedApproval.rejectionReason ?? undefined,
1852
+ id: result.updatedApproval.id,
1717
1853
  });
1718
1854
  email = { sent: true };
1719
1855
  }
@@ -1754,7 +1890,9 @@ export async function rejectBill(billId, reason) {
1754
1890
  }
1755
1891
  let email;
1756
1892
  try {
1757
- await api.email.sendReplyBillApprovalEmail.mutate(result.updatedApproval);
1893
+ await api.email.sendReplyBillApprovalEmail.mutate({
1894
+ id: result.updatedApproval.id,
1895
+ });
1758
1896
  email = { sent: true };
1759
1897
  }
1760
1898
  catch (error) {
@@ -1995,6 +2133,10 @@ export async function listUsers() {
1995
2133
  const users = await api.user.getAllUsers.query();
1996
2134
  out(users);
1997
2135
  }
2136
+ export async function whoAmI() {
2137
+ const user = await api.user.getCurrentUser.query();
2138
+ out(user);
2139
+ }
1998
2140
  export async function getUserPerformance(userId) {
1999
2141
  const result = await api.dashboard.getUserPerformance.query({ userId });
2000
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, 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, } 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,9 +145,18 @@ 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
162
  estimate emails, customer invoices, integration retries, mark-budget-won,
@@ -170,7 +180,7 @@ 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>
@@ -235,16 +245,23 @@ Quotations
235
245
 
236
246
  Customer invoices
237
247
  check-customer-invoice-readiness <budgetId>
238
- 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.
239
251
  get-customer-invoice <batchId>
252
+ get-customer-invoice-email-context <batchId> Contact, required CC, budget, and project context for composing an invoice email.
240
253
  create-customer-invoice --payload '<json>' budgetId + one split with label, percentage, and dueDate.
241
254
  discard-customer-invoice <batchId> Discard a reserved CREATING batch with no created QBO invoices.
242
255
  delete-customer-invoice <batchId> Delete QBO invoices and the local batch.
243
256
  void-customer-invoice <batchId> Void the invoice batch in QuickBooks.
244
- 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.
245
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>]
246
263
  download-customer-invoice-pdf <invoiceId> [--output <path>]
247
- 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.
248
265
 
249
266
  Approvals
250
267
  list-approvals | get-pending-approvals [--type budget|supplier|bill|quotation|customer_invoice|all]
@@ -298,30 +315,31 @@ Suppliers & items
298
315
  update-item --payload '<json>' (item.updateItem; must include id)
299
316
  delete-item <id>
300
317
  get-supplier-details <supplierId>
301
- get-supplier-analytics [--name] [--page] [--perPage] [--timeFrame ALL|LAST_YEAR|…]
318
+ get-supplier-analytics [--name] [--page] [--perPage] [--timeFrame ALL|LAST_YEAR|…] (admin)
302
319
  list-items [--name] [--page] [--perPage]
303
320
  get-item <id>
304
321
  list-item-categories [--page] [--perPage]
305
- create-item-category --name
306
- update-item-category --id --name
322
+ create-item-category --name (admin)
323
+ update-item-category --id --name (admin)
307
324
  delete-item-categories --ids <csv> (itemCategory.deleteItemCategories; admin; empty categories only)
308
325
 
309
326
  Dashboard & users
327
+ whoami Show the user identity and role for the active API key.
310
328
  list-users
311
329
  create-user --email <email> [--name <name>] [--role ${userRolesForHelp.join("|")}] (admin; API-only service identity with no Google sign-in)
312
- create-api-key --userId <userId> --name <label> (admin; raw key is shown once)
313
- list-api-keys [--userId <userId>] (admin; defaults to the caller)
314
- revoke-api-key <apiKeyId> [--userId <userId>] (admin; --userId is required for another user's key)
315
- get-user-performance [--userId]
316
- get-dashboard [--userId] [--role BD|CREATOR|INSIDE_SALES|ALL] [--deals ALL|SUCCESSFUL|LOST] [--timeFrame] [--startDate] [--endDate]
317
- get-monthly-metrics [same optional flags as get-dashboard] (dashboard.getMonthlyMetrics)
318
- get-system-overview [same optional flags as get-dashboard] (dashboard.getSystemOverview)
319
- get-estimate-performance [same optional flags as get-dashboard] (dashboard.getEstimatePerformance)
320
- 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)
321
339
 
322
340
  Errors
323
- get-recent-errors [--page] [--perPage] [--severity] [--status]
324
- get-error-metrics
341
+ get-recent-errors [--page] [--perPage] [--severity] [--status] (admin)
342
+ get-error-metrics (admin)
325
343
 
326
344
  Automation (admin)
327
345
  list-integration-operations [--destination] [--status PENDING|PROCESSING|FAILED|COMPLETED] [--page] [--perPage]
@@ -345,6 +363,20 @@ Not covered vs MCP get_budget: "get-budget" also fetches line items in one call.
345
363
  `.trim();
346
364
  console.log(help);
347
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
+ };
348
380
  /** MCP registers tools with snake_case; CLI commands are kebab-case. */
349
381
  function normalizeCliCommand(raw) {
350
382
  return raw.replace(/_/g, "-");
@@ -353,6 +385,10 @@ async function main() {
353
385
  const { command, positional, flags } = parseArgs(process.argv);
354
386
  setCliQuiet(consumeCliQuietFlags(flags));
355
387
  const cmd = normalizeCliCommand(command ?? "");
388
+ if (cmd === "version" || flags.version === true) {
389
+ printVersion();
390
+ process.exit(0);
391
+ }
356
392
  if (!cmd || cmd === "help" || flags.help === true) {
357
393
  printHelp();
358
394
  process.exit(0);
@@ -370,6 +406,10 @@ async function main() {
370
406
  }
371
407
  try {
372
408
  switch (cmd) {
409
+ case "whoami": {
410
+ await whoAmI();
411
+ break;
412
+ }
373
413
  case "list-budgets": {
374
414
  const statusStr = getFlag(flags, "status");
375
415
  const statuses = parseCommaSeparatedBudgetStatuses(statusStr !== undefined ? String(statusStr) : undefined);
@@ -415,6 +455,9 @@ async function main() {
415
455
  ? String(getFlag(flags, "projectManagerId"))
416
456
  : undefined,
417
457
  markProjectWon: parseOptionalBoolFlag(flags, "markProjectWon"),
458
+ projectInvoiceSettings: getFlag(flags, "invoiceSettings") !== undefined
459
+ ? parseJsonFlag(String(getFlag(flags, "invoiceSettings")), "--invoiceSettings")
460
+ : undefined,
418
461
  });
419
462
  break;
420
463
  }
@@ -474,6 +517,9 @@ async function main() {
474
517
  : undefined,
475
518
  markProjectWon: parseOptionalBoolFlag(flags, "markProjectWon"),
476
519
  projectStatusOnCommercialRejection: parseCommercialRejectionProjectStatus(flags),
520
+ projectInvoiceSettings: getFlag(flags, "invoiceSettings") !== undefined
521
+ ? parseJsonFlag(String(getFlag(flags, "invoiceSettings")), "--invoiceSettings")
522
+ : undefined,
477
523
  });
478
524
  break;
479
525
  }
@@ -1070,19 +1116,39 @@ async function main() {
1070
1116
  await checkCustomerInvoiceReadiness(budgetId);
1071
1117
  break;
1072
1118
  }
1073
- case "list-customer-invoices": {
1074
- const budgetId = positional[0];
1075
- if (!budgetId) {
1076
- 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>");
1077
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
+ ];
1078
1138
  const statusRaw = getFlag(flags, "status");
1079
1139
  const sortDir = parseOptionalBillListSortDir(getFlag(flags, "sortDir"));
1080
1140
  await listCustomerInvoices({
1081
- budgetId,
1141
+ scope: projectId
1142
+ ? { type: "project", projectId }
1143
+ : { type: "global" },
1082
1144
  page: parsePositiveIntFlag(getFlag(flags, "page"), "--page") ?? 1,
1083
- perPage: parsePositiveIntFlag(getFlag(flags, "perPage"), "--perPage") ?? 12,
1145
+ perPage: parsePositiveIntFlag(getFlag(flags, "perPage"), "--perPage") ?? 20,
1084
1146
  q: getFlag(flags, "search") ?? "",
1085
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")),
1086
1152
  sortBy: parseCustomerInvoiceSortBy(getFlag(flags, "sortBy")),
1087
1153
  sortDir: sortDir ?? "desc",
1088
1154
  });
@@ -1096,6 +1162,14 @@ async function main() {
1096
1162
  await getCustomerInvoice(batchId);
1097
1163
  break;
1098
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
+ }
1099
1173
  case "create-customer-invoice": {
1100
1174
  const payloadRaw = getFlag(flags, "payload");
1101
1175
  if (!payloadRaw) {
@@ -1145,6 +1219,37 @@ async function main() {
1145
1219
  await rejectCustomerInvoice(batchId, String(reason));
1146
1220
  break;
1147
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
+ }
1148
1253
  case "download-customer-invoice-pdf": {
1149
1254
  const invoiceId = positional[0];
1150
1255
  if (!invoiceId) {
@@ -1601,8 +1706,8 @@ async function main() {
1601
1706
  case "create-api-key": {
1602
1707
  const userId = getFlag(flags, "userId");
1603
1708
  const name = getFlag(flags, "name");
1604
- if (!userId || !name) {
1605
- 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>]");
1606
1711
  }
1607
1712
  await createApiKeyForUser({ userId, name });
1608
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 });
@@ -42,24 +42,37 @@ const requiredEmail = (value, fieldLabel) => {
42
42
  }
43
43
  return email;
44
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
+ };
45
58
  /** Matches the web estimate composer (`email.sendEstimateToContactPerson`). */
46
59
  export const parseSendEstimateToContactPersonPayload = (raw) => {
47
60
  const input = requireObject(raw, "send-estimate-to-contact-person payload");
48
- if (!Array.isArray(input.cc)) {
49
- throw new Error("send-estimate-to-contact-person payload.cc must be an email array.");
50
- }
51
61
  return {
52
- to: requiredEmail(input.to, "send-estimate-to-contact-person payload.to"),
53
- cc: input.cc.map((email, index) => requiredEmail(email, `send-estimate-to-contact-person payload.cc[${index}]`)),
54
- replyTo: requiredEmail(input.replyTo, "send-estimate-to-contact-person payload.replyTo"),
55
- subject: requiredString(input.subject, "send-estimate-to-contact-person payload.subject"),
56
- content: requiredString(input.content, "send-estimate-to-contact-person payload.content"),
57
- signature: requiredString(input.signature, "send-estimate-to-contact-person payload.signature"),
62
+ ...parseContactPersonEmailPayload(input, "send-estimate-to-contact-person"),
58
63
  estimateId: requiredString(input.estimateId, "send-estimate-to-contact-person payload.estimateId"),
59
64
  estimateDocNumber: optionalString(input.estimateDocNumber),
60
65
  budgetId: requiredString(input.budgetId, "send-estimate-to-contact-person payload.budgetId"),
61
66
  };
62
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
+ };
63
76
  /** Matches budget.createBudget — optional pipedriveDealId; Asana deal is project.asanaTaskId. */
64
77
  export function parseCreateBudgetPayload(raw) {
65
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.20.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": {