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