@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 +90 -15
- package/dist/api-client.js +2 -2
- package/dist/cli-trace.js +6 -1
- package/dist/commands.js +164 -22
- package/dist/index.js +134 -29
- package/dist/load-env.js +1 -1
- package/dist/parse-mutation-payload.js +22 -9
- package/package.json +2 -2
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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` (
|
|
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
|
|
package/dist/api-client.js
CHANGED
|
@@ -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
|
|
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: `${
|
|
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
|
|
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
|
|
315
|
+
const preview = buildSensitiveWorkflowPreview(input);
|
|
296
316
|
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
297
|
-
throw new Error(`${
|
|
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(`${
|
|
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(
|
|
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
|
|
713
|
-
// that an ambiguously successful
|
|
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.
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
|
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: `
|
|
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: "
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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-
|
|
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 --
|
|
313
|
-
list-api-keys [--userId <userId>] (
|
|
314
|
-
revoke-api-key <apiKeyId> [--userId <userId>] (
|
|
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-
|
|
1074
|
-
const
|
|
1075
|
-
if (!
|
|
1076
|
-
throw new Error("list-customer-
|
|
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
|
-
|
|
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") ??
|
|
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 (!
|
|
1605
|
-
throw new Error("create-api-key requires --
|
|
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
|
@@ -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
|
-
|
|
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.
|
|
4
|
-
"description": "Budget Builder CLI for AI agents — manage budgets, bills,
|
|
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": {
|