@go-labs-sg/bb 2.33.0 → 2.33.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -232,6 +232,36 @@ Every canonical command is classified by effect: `state-change`, `email`, `exter
232
232
 
233
233
  **Draft-only bills:** Use `bb bill create --draft-only --payload '<bill-json>' --allow-state-change --allow-financial-write` when the user authorizes a draft only. The equivalent payload field is `draftOnly: true`. This creates `DRAFT` for every role, without auto-checking, auto-approval, approval requests, emails, or QuickBooks writes. It cannot be combined with `alreadyPaid: true`; conflicting flag and payload values are rejected. Draft-only creation uses the dedicated `bill.createDraft` API route and fails on older servers without falling back to ordinary creation. Deploy backend support before releasing the updated CLI. Reconciled projects must be moved to Won separately before draft-only creation, because reopening a project can trigger external integrations. Completed projects retain their existing creation restriction.
234
234
 
235
+ ### Bill amounts and GST (create and update)
236
+
237
+ **All bill amount fields below exclude GST.** This applies to ordinary creation, draft-only creation, and updates. The CLI does not convert a GST-inclusive invoice total into a subtotal. BB calculates applicable GST separately using the supplier's GST registration and each line's out-of-scope setting.
238
+
239
+ | Payload field | Meaning |
240
+ | --- | --- |
241
+ | `amount` | Amount being billed now, **before GST**. For a deposit or partial bill, use only the portion being billed. |
242
+ | `extractedAmount` | Full supplier invoice amount **before GST**, as shown on the attachment. For a partial bill, this can exceed `amount`. |
243
+ | `lineAmounts[].amount` | This bill's allocation to each selected budget item, **before GST**. The entries must sum to `amount`. |
244
+ | `quotationAllocations[].amount` | Quotation coverage for each bill line, **before GST**. Use the same tax basis as `lineAmounts`. |
245
+
246
+ For an invoice showing **S$1,800 subtotal + S$162 GST = S$1,962 total**, a full bill uses the following amount fields (fragment only; include the other required bill fields):
247
+
248
+ ```json
249
+ {
250
+ "amount": 1800,
251
+ "extractedAmount": 1800,
252
+ "lineAmounts": [{ "budgetItemId": "item-a", "amount": 1800 }],
253
+ "quotationAllocations": [
254
+ { "budgetItemId": "item-a", "quotationId": "quote-a", "amount": 1800 }
255
+ ]
256
+ }
257
+ ```
258
+
259
+ Do **not** put `1962` in either `amount` or `extractedAmount` for this full bill: BB would treat it as a before-GST value. A 50% partial bill against the same invoice uses `amount: 900`, `extractedAmount: 1800`, and line/quotation allocations totaling `900`; with 9% GST applicable to that line, its total is S$981.
260
+
261
+ Use the invoice's actual subtotal and tax breakdown. Do not blindly divide every invoice total by 1.09: non-GST-registered suppliers, out-of-scope lines, and mixed-tax invoices need their actual breakdown. If the attachment does not establish the before-GST amount, clarify it before creating or updating the bill.
262
+
263
+ After a mutation, re-read the bill and verify `amount`, `extractedAmount`, line amounts, and quotation allocations against the attachment. Report the subtotal, GST, and total separately; do not describe the stored `amount` as the GST-inclusive total. When correcting a gross/net input mistake, also check `extractedAmount` so the full invoice is not accidentally represented as a partial bill.
264
+
235
265
  **Quotation-first supplier bills:** Use `quotationIds` when approved quotations cover disjoint bill lines. When two quotations fund the same bill line, pass `quotationAllocations` entries with `{ budgetItemId, quotationId, amount }` and an exact `lineAmounts` entry for every selected budget item; `lineAmounts` must sum to the bill amount. Canonical allocation rows are the stored source of every bill-line quotation relationship. A required zero-value bill line needs one or more allocations with `amount: 0`, which record its approved source(s) without consuming quotation capacity. Every quotation must belong to the bill supplier and project and cover the allocated budget item. Legacy `quotationId` remains valid as single-quotation input and maps to the current selection. Supplier bills that qualify for an existing exemption must state that intent with `--quotation-exempt`. Claims share budget allowance but never consume or link quotations, so quotation source fields are rejected for claims.
236
266
 
237
267
  Every selected bill or claim line must belong to an active budget in `ESTIMATE_ACCEPTED` or `ESTIMATE_CLOSED`, including lines drawn from additional budgets in the project. The server checks this during selection, creation, editing, and approval. An unaccepted budget never qualifies a line for a quotation exemption. Existing pending bills expose budget and source-link blockers in `commitmentReview.blockers`.
@@ -256,6 +286,48 @@ Bill creation determines its required permissions from the parsed input, for bot
256
286
 
257
287
  **Contact-person estimate email:** `bb budget estimate send --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 durably queues worker delivery of the QuickBooks PDF, standard terms, and Budget Builder budget attachments, and returns the outbound-email and operation IDs immediately. Worker retries reuse one provider idempotency key; the budget becomes `ESTIMATE_SENT` only after Resend accepts the email. The command requires interactive `CONFIRM`; inspect the budget, recipients, and HTML first.
258
288
 
289
+ ### Quotation amounts and line allocations
290
+
291
+ Quotation create and update payloads use `amountWithoutGst` for the supplier subtotal and `gstAmount` for GST. Keep GST separate; do not add it to `amountWithoutGst`. When a quotation covers more than one budget item, `lineAmounts` is required and must contain each selected item exactly once. The line amounts must sum to `amountWithoutGst`.
292
+
293
+ Use the actual `budgetItemIds` returned by `bb budget get <budget-id>` or the relevant budget-item read. Do not invent IDs, use item names, or reuse IDs from another project or budget. `quotationAllocations` belongs to bill creation/update as the bill's source-quotation allocation; it is not a quotation create/update field.
294
+
295
+ Upload exactly one quotation document first. Pass the single attachment object returned by `bb quotation attachment upload <project-id> <file-path>` in the quotation payload's one-element `attachments` array. The server accepts one original quotation file and normalizes it to PDF when needed.
296
+
297
+ Replace the example attachment size `12345` with the actual byte count from the upload response.
298
+
299
+ For a quotation split across two real budget lines at S$750 and S$450, with no GST, the complete CLI payload shape is:
300
+
301
+ ```bash
302
+ bb quotation attachment upload "<project-id>" "<quotation-file.pdf>"
303
+
304
+ # Replace every <...> placeholder with the matching value from the reads and
305
+ # the upload response. The attachment array must contain that one upload object.
306
+ bb quotation create --payload '{
307
+ "projectId": "<project-id>",
308
+ "budgetId": "<budget-id>",
309
+ "supplierId": "<supplier-id>",
310
+ "budgetItemIds": ["<budget-item-id-for-750>", "<budget-item-id-for-450>"],
311
+ "lineAmounts": [
312
+ {"budgetItemId": "<budget-item-id-for-750>", "amount": 750},
313
+ {"budgetItemId": "<budget-item-id-for-450>", "amount": 450}
314
+ ],
315
+ "amountWithoutGst": 1200,
316
+ "gstAmount": 0,
317
+ "attachments": [
318
+ {
319
+ "id": "<attachment-id-from-upload>",
320
+ "name": "<attachment-name-from-upload>",
321
+ "key": "<attachment-key-from-upload>",
322
+ "size": 12345
323
+ }
324
+ ]
325
+ }'
326
+ ```
327
+
328
+ Use the same fields for `bb quotation update --payload`, adding the existing quotation `"id": "<quotation-id>"`. Read the quotation and its budget before updating so the IDs and current status are confirmed. The example's subtotal is S$1,200, GST is S$0, and total is S$1,200; those values are illustrative only and contain no live resource IDs.
329
+
330
+
259
331
  ### Link an existing QuickBooks invoice
260
332
 
261
333
  **Release status:** these CLI commands are pending release. The repository package version remains `2.31.0`; that version number alone does not establish that a published CLI contains them. Check `bb help` for `customer-invoice preview-qbo` and `customer-invoice import-qbo` after the normal CLI release. This code delivery does not publish the CLI, deploy the API, or perform a production import. The backend must expose `customerInvoice.previewExistingInvoice` and `customerInvoice.linkExistingInvoice` (implemented in PR #1067).
@@ -327,8 +399,8 @@ Bill, claim, and quotation mutations accept only suppliers whose approval status
327
399
  | Area | Legacy handler labels (reference only; non-exhaustive) |
328
400
  | --- | --- |
329
401
  | **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`; cloned unavailable lines require `unavailableItemReviewAcknowledged: true`), `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`, `replace-budget-item`, `approve-unavailable-item-exception` (Lead/Admin), `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`) |
330
- | **Bills / claims** | `list-bills` (`--isClaimable false` for bills, `--isClaimable true` for claims, omit for both), `list-claims` (claims only), `validate-bill-selection` (`--payload`, plus `--quotationIds q1,q2` or legacy `--quotationId q1`; validates the approved supplier and quotation coverage), `link-bill-quotations` (`<billId> --quotationIds q1,q2`; Admin source-link repair for eligible supplier bills), `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 and provide approved `quotationIds`, or use `--quotation-exempt` for an eligible server-validated exemption; set `isClaimable=true` for a claim without quotations; 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), `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` |
331
- | **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`) |
402
+ | **Bills / claims** | `list-bills` (`--isClaimable false` for bills, `--isClaimable true` for claims, omit for both), `list-claims` (claims only), `validate-bill-selection` (`--payload`, plus `--quotationIds q1,q2` or legacy `--quotationId q1`; validates the approved supplier and quotation coverage), `link-bill-quotations` (`<billId> --quotationIds q1,q2`; Admin source-link repair for eligible supplier bills), `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 and provide approved `quotationIds`, or use `--quotation-exempt` for an eligible server-validated exemption; set `isClaimable=true` for a claim without quotations; supplier bills require positive **before-GST** `extractedAmount` (full invoice subtotal) and `amount` (portion billed), with `amount <= extractedAmount`; line and quotation allocation amounts also exclude GST; 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), `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` |
403
+ | **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`, `gstAmount`, and `lineAmounts` entries `{ budgetItemId, amount }` for multiple selected items), `update-quotation` (`--payload`; updates a `DRAFT` or `REJECTED` quotation), `delete-quotation`, `submit-quotation`, `approve-quotation`, `reject-quotation`, `download-quotation-pdf` (`original`, `staff`, or `final`) |
332
404
  | **Customer invoices** | `check-customer-invoice-readiness`, `list-eligible-customer-invoice-budgets`, `list-customer-invoices` (global/project/budget filters plus summary metrics; `--sortBy totalInvoiceAmount` sorts provider-confirmed totals including GST, with unknown amounts last), `get-customer-invoice`, `get-customer-invoice-email-context`, `preview-qbo-customer-invoice`, `import-qbo-customer-invoice` (BB-only link with reviewed token), `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`, `download-customer-invoice-pdf` (non-admins cannot download while approval is pending), `sync-customer-invoice` |
333
405
  | **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` |
334
406
  | **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), `get-project-hub-status`, `setup-project-hub`, `sync-project-hub-commercial-documents`, `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`) |
@@ -2063,7 +2063,7 @@
2063
2063
  {
2064
2064
  "path": ["bill", "create"],
2065
2065
  "legacyAliases": ["create-bill", "create_bill"],
2066
- "summary": "Create a bill or claim with an approved supplier. --draft-only guarantees DRAFT without email or external writes; otherwise unpaid creation skips email permission but keeps external-write permission.",
2066
+ "summary": "Create a bill or claim with an approved supplier. All amount fields exclude GST: amount is the portion billed; extractedAmount is the full invoice subtotal; lineAmounts and quotationAllocations also exclude GST. Example: 1800 + 162 GST = 1962 total requires amount=1800 and extractedAmount=1800 for a full bill. The CLI does not strip GST from input. --draft-only guarantees DRAFT without email or external writes; otherwise unpaid creation skips email permission but keeps external-write permission.",
2067
2067
  "globalOptions": [
2068
2068
  {
2069
2069
  "name": "--help",
@@ -2237,7 +2237,7 @@
2237
2237
  {
2238
2238
  "path": ["bill", "update"],
2239
2239
  "legacyAliases": ["update-bill", "update_bill"],
2240
- "summary": "Update Bill.",
2240
+ "summary": "Update a bill or claim; payload must include id. All amount fields exclude GST: amount is the portion billed; extractedAmount is the full invoice subtotal; lineAmounts and quotationAllocations also exclude GST. Example: 1800 + 162 GST = 1962 total requires amount=1800 and extractedAmount=1800 for a full bill. The CLI does not strip GST from input. Re-read and verify both amount fields and allocations after correcting a gross/net mistake.",
2241
2241
  "globalOptions": [
2242
2242
  {
2243
2243
  "name": "--help",
@@ -3107,7 +3107,7 @@
3107
3107
  {
3108
3108
  "path": ["quotation", "create"],
3109
3109
  "legacyAliases": ["create-quotation", "create_quotation"],
3110
- "summary": "Create a quotation with an approved supplier.",
3110
+ "summary": "Create a quotation with an approved supplier using --payload JSON. Include projectId, budgetId, supplierId, budgetItemIds, amountWithoutGst, gstAmount, and one uploaded attachment in attachments. Multiple budget items require lineAmounts: [{\"budgetItemId\":\"<first-id>\",\"amount\":750},{\"budgetItemId\":\"<second-id>\",\"amount\":450}], covering every selected budget-item ID exactly once and summing to amountWithoutGst. Amounts exclude GST; use gstAmount: 0 for no GST. Example: amountWithoutGst: 1200 with line amounts 750 and 450.",
3111
3111
  "globalOptions": [
3112
3112
  {
3113
3113
  "name": "--help",
@@ -3148,6 +3148,11 @@
3148
3148
  {
3149
3149
  "name": "--allow-financial-write",
3150
3150
  "description": "Allow financial-record changes in non-interactive use."
3151
+ },
3152
+ {
3153
+ "name": "--payload",
3154
+ "description": "Quotation JSON including lineAmounts for multiple selected budget items; see the payload format above.",
3155
+ "required": true
3151
3156
  }
3152
3157
  ],
3153
3158
  "argumentMode": "legacy-passthrough",
@@ -3157,7 +3162,7 @@
3157
3162
  {
3158
3163
  "path": ["quotation", "update"],
3159
3164
  "legacyAliases": ["update-quotation", "update_quotation"],
3160
- "summary": "Update a quotation with an approved supplier.",
3165
+ "summary": "Update a DRAFT or REJECTED quotation using --payload JSON with id and the complete creation payload. Multiple budget items require lineAmounts: [{\"budgetItemId\":\"<first-id>\",\"amount\":750},{\"budgetItemId\":\"<second-id>\",\"amount\":450}], covering every budgetItemIds entry exactly once and summing to amountWithoutGst. Amounts exclude GST; use gstAmount: 0 for no GST.",
3161
3166
  "globalOptions": [
3162
3167
  {
3163
3168
  "name": "--help",
@@ -3198,6 +3203,11 @@
3198
3203
  {
3199
3204
  "name": "--allow-financial-write",
3200
3205
  "description": "Allow financial-record changes in non-interactive use."
3206
+ },
3207
+ {
3208
+ "name": "--payload",
3209
+ "description": "Complete quotation JSON plus id, including lineAmounts for multiple selected budget items.",
3210
+ "required": true
3201
3211
  }
3202
3212
  ],
3203
3213
  "argumentMode": "legacy-passthrough",
@@ -347,7 +347,7 @@ Effects: state-change, email.
347
347
 
348
348
  ## `bb bill create`
349
349
 
350
- Create a bill or claim with an approved supplier. --draft-only guarantees DRAFT without email or external writes; otherwise unpaid creation skips email permission but keeps external-write permission.
350
+ Create a bill or claim with an approved supplier. All amount fields exclude GST: amount is the portion billed; extractedAmount is the full invoice subtotal; lineAmounts and quotationAllocations also exclude GST. Example: 1800 + 162 GST = 1962 total requires amount=1800 and extractedAmount=1800 for a full bill. The CLI does not strip GST from input. --draft-only guarantees DRAFT without email or external writes; otherwise unpaid creation skips email permission but keeps external-write permission.
351
351
 
352
352
  Legacy aliases: `create-bill`, `create_bill`.
353
353
 
@@ -371,7 +371,7 @@ Effects: state-change, financial-write.
371
371
 
372
372
  ## `bb bill update`
373
373
 
374
- Update Bill.
374
+ Update a bill or claim; payload must include id. All amount fields exclude GST: amount is the portion billed; extractedAmount is the full invoice subtotal; lineAmounts and quotationAllocations also exclude GST. Example: 1800 + 162 GST = 1962 total requires amount=1800 and extractedAmount=1800 for a full bill. The CLI does not strip GST from input. Re-read and verify both amount fields and allocations after correcting a gross/net mistake.
375
375
 
376
376
  Legacy aliases: `update-bill`, `update_bill`.
377
377
 
@@ -507,7 +507,7 @@ Effects: state-change, delete.
507
507
 
508
508
  ## `bb quotation create`
509
509
 
510
- Create a quotation with an approved supplier.
510
+ Create a quotation with an approved supplier using --payload JSON. Include projectId, budgetId, supplierId, budgetItemIds, amountWithoutGst, gstAmount, and one uploaded attachment in attachments. Multiple budget items require lineAmounts: [{"budgetItemId":"<first-id>","amount":750},{"budgetItemId":"<second-id>","amount":450}], covering every selected budget-item ID exactly once and summing to amountWithoutGst. Amounts exclude GST; use gstAmount: 0 for no GST. Example: amountWithoutGst: 1200 with line amounts 750 and 450.
511
511
 
512
512
  Legacy aliases: `create-quotation`, `create_quotation`.
513
513
 
@@ -515,7 +515,7 @@ Effects: state-change.
515
515
 
516
516
  ## `bb quotation update`
517
517
 
518
- Update a quotation with an approved supplier.
518
+ Update a DRAFT or REJECTED quotation using --payload JSON with id and the complete creation payload. Multiple budget items require lineAmounts: [{"budgetItemId":"<first-id>","amount":750},{"budgetItemId":"<second-id>","amount":450}], covering every budgetItemIds entry exactly once and summing to amountWithoutGst. Amounts exclude GST; use gstAmount: 0 for no GST.
519
519
 
520
520
  Legacy aliases: `update-quotation`, `update_quotation`.
521
521
 
package/dist/index.js CHANGED
@@ -19478,14 +19478,6 @@ var logoutCliSession = async ({
19478
19478
  // src/cli.ts
19479
19479
  import { readFileSync } from "fs";
19480
19480
 
19481
- // src/api-client.ts
19482
- import {
19483
- createTRPCProxyClient,
19484
- httpBatchLink,
19485
- loggerLink
19486
- } from "@trpc/client";
19487
- import SuperJSON from "superjson";
19488
-
19489
19481
  // src/cli-trace.ts
19490
19482
  var cliQuietSession = false;
19491
19483
  var setCliQuiet = (quiet) => {
@@ -19570,6 +19562,12 @@ var normalizeApiBaseUrl = (value) => {
19570
19562
  };
19571
19563
 
19572
19564
  // src/api-client.ts
19565
+ import {
19566
+ createTRPCProxyClient,
19567
+ httpBatchLink,
19568
+ loggerLink
19569
+ } from "@trpc/client";
19570
+ import SuperJSON from "superjson";
19573
19571
  var BUDGET_BUILDER_API_BASE_URL = DEFAULT_BUDGET_BUILDER_API_BASE_URL;
19574
19572
  var interactiveAccessToken;
19575
19573
  function getAuthHeader() {
@@ -20130,6 +20128,138 @@ var normalizePlainTextRichTextField = ({
20130
20128
  };
20131
20129
  };
20132
20130
 
20131
+ // src/runtime/error.ts
20132
+ var invocationErrorCodes = new Set([
20133
+ "USAGE",
20134
+ "VALIDATION",
20135
+ "CONFIRMATION_REQUIRED"
20136
+ ]);
20137
+ var trpcCodeMap = {
20138
+ BAD_REQUEST: "VALIDATION",
20139
+ CONFLICT: "CONFLICT",
20140
+ FORBIDDEN: "AUTHORIZATION",
20141
+ INTERNAL_SERVER_ERROR: "API",
20142
+ METHOD_NOT_SUPPORTED: "API",
20143
+ NOT_FOUND: "NOT_FOUND",
20144
+ PARSE_ERROR: "VALIDATION",
20145
+ PAYLOAD_TOO_LARGE: "VALIDATION",
20146
+ PRECONDITION_FAILED: "CONFLICT",
20147
+ TIMEOUT: "NETWORK",
20148
+ TOO_MANY_REQUESTS: "API",
20149
+ UNAUTHORIZED: "AUTHENTICATION",
20150
+ UNPROCESSABLE_CONTENT: "VALIDATION"
20151
+ };
20152
+ var cliErrorCodes = new Set([
20153
+ "USAGE",
20154
+ "VALIDATION",
20155
+ "CONFIRMATION_REQUIRED",
20156
+ "AUTHENTICATION",
20157
+ "AUTHORIZATION",
20158
+ "NOT_FOUND",
20159
+ "CONFLICT",
20160
+ "API",
20161
+ "NETWORK",
20162
+ "ABORTED",
20163
+ "INTERRUPTED",
20164
+ "INTERNAL"
20165
+ ]);
20166
+
20167
+ class CliRuntimeError extends Error {
20168
+ code;
20169
+ details;
20170
+ requestId;
20171
+ constructor(code, message, options = {}) {
20172
+ super(message);
20173
+ this.name = "CliRuntimeError";
20174
+ this.code = code;
20175
+ this.details = options.details;
20176
+ this.requestId = options.requestId;
20177
+ }
20178
+ }
20179
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
20180
+ var asNonEmptyString = (value) => typeof value === "string" && value.trim() !== "" ? value : undefined;
20181
+ var asDetails = (value) => {
20182
+ if (!isRecord(value))
20183
+ return;
20184
+ const details = {};
20185
+ for (const [key, nestedValue] of Object.entries(value)) {
20186
+ if (isJsonValue(nestedValue))
20187
+ details[key] = nestedValue;
20188
+ }
20189
+ return Object.keys(details).length > 0 ? details : undefined;
20190
+ };
20191
+ var isJsonValue = (value) => {
20192
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") {
20193
+ return true;
20194
+ }
20195
+ if (Array.isArray(value))
20196
+ return value.every(isJsonValue);
20197
+ return isRecord(value) && Object.values(value).every(isJsonValue);
20198
+ };
20199
+ var mapErrorCode = (value) => {
20200
+ if (typeof value !== "string")
20201
+ return;
20202
+ if (cliErrorCodes.has(value))
20203
+ return value;
20204
+ if (value in trpcCodeMap)
20205
+ return trpcCodeMap[value];
20206
+ return;
20207
+ };
20208
+ var networkMessagePattern = /\b(fetch failed|network error|unable to connect|econnrefused|econnreset|enotfound|etimedout|socket hang up)\b/i;
20209
+ var isNetworkError = (error61, depth = 0) => {
20210
+ if (depth > 3 || !isRecord(error61))
20211
+ return false;
20212
+ const message = asNonEmptyString(error61.message);
20213
+ if (message !== undefined && networkMessagePattern.test(message))
20214
+ return true;
20215
+ const code = asNonEmptyString(error61.code);
20216
+ if (code !== undefined && networkMessagePattern.test(code))
20217
+ return true;
20218
+ return isNetworkError(error61.cause, depth + 1);
20219
+ };
20220
+ var normalizeCliError = (error61) => {
20221
+ if (error61 instanceof CliRuntimeError) {
20222
+ return {
20223
+ code: error61.code,
20224
+ message: error61.message,
20225
+ ...error61.details === undefined ? {} : { details: error61.details },
20226
+ ...error61.requestId === undefined ? {} : { requestId: error61.requestId }
20227
+ };
20228
+ }
20229
+ if (isNetworkError(error61)) {
20230
+ return {
20231
+ code: "NETWORK",
20232
+ message: error61 instanceof Error ? error61.message : "The Budget Builder API could not be reached."
20233
+ };
20234
+ }
20235
+ if (isRecord(error61)) {
20236
+ const data = isRecord(error61.data) ? error61.data : undefined;
20237
+ const transportCode = mapErrorCode(data?.code ?? error61.code);
20238
+ if (error61 instanceof Error && data === undefined && transportCode === undefined) {
20239
+ return { code: "INTERNAL", message: error61.message };
20240
+ }
20241
+ const code = transportCode ?? "API";
20242
+ const message = asNonEmptyString(error61.message) ?? "The Budget Builder API request failed.";
20243
+ const requestId = asNonEmptyString(data?.requestId) ?? asNonEmptyString(data?.requestID) ?? asNonEmptyString(error61.requestId);
20244
+ const details = asDetails(data?.details ?? error61.details);
20245
+ return {
20246
+ code,
20247
+ message,
20248
+ ...details === undefined ? {} : { details },
20249
+ ...requestId === undefined ? {} : { requestId }
20250
+ };
20251
+ }
20252
+ if (error61 instanceof Error) {
20253
+ return { code: "INTERNAL", message: error61.message };
20254
+ }
20255
+ return { code: "INTERNAL", message: "An unexpected CLI error occurred." };
20256
+ };
20257
+ var exitCodeForCliError = (error61) => {
20258
+ if (error61.code === "INTERRUPTED")
20259
+ return 130;
20260
+ return invocationErrorCodes.has(error61.code) ? 2 : 1;
20261
+ };
20262
+
20133
20263
  // src/parse-mutation-payload.ts
20134
20264
  function requireObject(raw, label) {
20135
20265
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
@@ -20547,12 +20677,42 @@ function parseUpdateBillPaymentEvidencePayload(raw) {
20547
20677
  requireObject(raw, "update-bill-payment-evidence payload");
20548
20678
  return raw;
20549
20679
  }
20680
+ var validateQuotationAllocations = (raw, command) => {
20681
+ const input2 = requireObject(raw, `${command} payload`);
20682
+ const ids = input2.budgetItemIds;
20683
+ if (!Array.isArray(ids) || ids.length === 0 || ids.some((id) => typeof id !== "string" || id.length === 0) || new Set(ids).size !== ids.length) {
20684
+ throw new CliRuntimeError("VALIDATION", `${command} payload.budgetItemIds must contain each selected budget-item ID exactly once.`);
20685
+ }
20686
+ const isCurrency = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 99999999.99 && Math.abs(value * 100 - Math.round(value * 100)) < 0.00000001;
20687
+ if (!isCurrency(input2.amountWithoutGst) || input2.amountWithoutGst === 0 || !isCurrency(input2.gstAmount)) {
20688
+ throw new CliRuntimeError("VALIDATION", `${command} requires a positive payload.amountWithoutGst and a non-negative payload.gstAmount, in currency units with at most two decimal places. Use gstAmount: 0 for no GST.`);
20689
+ }
20690
+ const guidance = `${command} requires payload.lineAmounts as [{"budgetItemId":"<first-id>","amount":750},{"budgetItemId":"<second-id>","amount":450}] for multiple budget items. Include each payload.budgetItemIds entry exactly once, with non-negative amounts before GST summing to payload.amountWithoutGst. For a 1200 quotation with no GST, use line amounts 750 and 450, amountWithoutGst: 1200, gstAmount: 0.`;
20691
+ if (input2.lineAmounts === undefined && ids.length === 1)
20692
+ return;
20693
+ if (!Array.isArray(input2.lineAmounts) || input2.lineAmounts.length !== ids.length) {
20694
+ throw new CliRuntimeError("VALIDATION", guidance);
20695
+ }
20696
+ const seen = new Set;
20697
+ let totalCents = 0;
20698
+ for (const rawLine of input2.lineAmounts) {
20699
+ const line = requireObject(rawLine, `${command} payload.lineAmounts entry`);
20700
+ if (typeof line.budgetItemId !== "string" || !ids.includes(line.budgetItemId) || seen.has(line.budgetItemId) || !isCurrency(line.amount)) {
20701
+ throw new CliRuntimeError("VALIDATION", guidance);
20702
+ }
20703
+ seen.add(line.budgetItemId);
20704
+ totalCents += Math.round(line.amount * 100);
20705
+ }
20706
+ if (totalCents !== Math.round(input2.amountWithoutGst * 100)) {
20707
+ throw new CliRuntimeError("VALIDATION", guidance);
20708
+ }
20709
+ };
20550
20710
  function parseCreateQuotationPayload(raw) {
20551
- requireObject(raw, "create-quotation payload");
20711
+ validateQuotationAllocations(raw, "create-quotation");
20552
20712
  return raw;
20553
20713
  }
20554
20714
  function parseUpdateQuotationPayload(raw) {
20555
- requireObject(raw, "update-quotation payload");
20715
+ validateQuotationAllocations(raw, "update-quotation");
20556
20716
  return raw;
20557
20717
  }
20558
20718
  function parseCreateCustomerInvoicePayload(raw) {
@@ -20663,138 +20823,6 @@ function parseCompanyUpdatePayload(raw) {
20663
20823
  return raw;
20664
20824
  }
20665
20825
 
20666
- // src/runtime/error.ts
20667
- var invocationErrorCodes = new Set([
20668
- "USAGE",
20669
- "VALIDATION",
20670
- "CONFIRMATION_REQUIRED"
20671
- ]);
20672
- var trpcCodeMap = {
20673
- BAD_REQUEST: "VALIDATION",
20674
- CONFLICT: "CONFLICT",
20675
- FORBIDDEN: "AUTHORIZATION",
20676
- INTERNAL_SERVER_ERROR: "API",
20677
- METHOD_NOT_SUPPORTED: "API",
20678
- NOT_FOUND: "NOT_FOUND",
20679
- PARSE_ERROR: "VALIDATION",
20680
- PAYLOAD_TOO_LARGE: "VALIDATION",
20681
- PRECONDITION_FAILED: "CONFLICT",
20682
- TIMEOUT: "NETWORK",
20683
- TOO_MANY_REQUESTS: "API",
20684
- UNAUTHORIZED: "AUTHENTICATION",
20685
- UNPROCESSABLE_CONTENT: "VALIDATION"
20686
- };
20687
- var cliErrorCodes = new Set([
20688
- "USAGE",
20689
- "VALIDATION",
20690
- "CONFIRMATION_REQUIRED",
20691
- "AUTHENTICATION",
20692
- "AUTHORIZATION",
20693
- "NOT_FOUND",
20694
- "CONFLICT",
20695
- "API",
20696
- "NETWORK",
20697
- "ABORTED",
20698
- "INTERRUPTED",
20699
- "INTERNAL"
20700
- ]);
20701
-
20702
- class CliRuntimeError extends Error {
20703
- code;
20704
- details;
20705
- requestId;
20706
- constructor(code, message, options = {}) {
20707
- super(message);
20708
- this.name = "CliRuntimeError";
20709
- this.code = code;
20710
- this.details = options.details;
20711
- this.requestId = options.requestId;
20712
- }
20713
- }
20714
- var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
20715
- var asNonEmptyString = (value) => typeof value === "string" && value.trim() !== "" ? value : undefined;
20716
- var asDetails = (value) => {
20717
- if (!isRecord(value))
20718
- return;
20719
- const details = {};
20720
- for (const [key, nestedValue] of Object.entries(value)) {
20721
- if (isJsonValue(nestedValue))
20722
- details[key] = nestedValue;
20723
- }
20724
- return Object.keys(details).length > 0 ? details : undefined;
20725
- };
20726
- var isJsonValue = (value) => {
20727
- if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") {
20728
- return true;
20729
- }
20730
- if (Array.isArray(value))
20731
- return value.every(isJsonValue);
20732
- return isRecord(value) && Object.values(value).every(isJsonValue);
20733
- };
20734
- var mapErrorCode = (value) => {
20735
- if (typeof value !== "string")
20736
- return;
20737
- if (cliErrorCodes.has(value))
20738
- return value;
20739
- if (value in trpcCodeMap)
20740
- return trpcCodeMap[value];
20741
- return;
20742
- };
20743
- var networkMessagePattern = /\b(fetch failed|network error|unable to connect|econnrefused|econnreset|enotfound|etimedout|socket hang up)\b/i;
20744
- var isNetworkError = (error61, depth = 0) => {
20745
- if (depth > 3 || !isRecord(error61))
20746
- return false;
20747
- const message = asNonEmptyString(error61.message);
20748
- if (message !== undefined && networkMessagePattern.test(message))
20749
- return true;
20750
- const code = asNonEmptyString(error61.code);
20751
- if (code !== undefined && networkMessagePattern.test(code))
20752
- return true;
20753
- return isNetworkError(error61.cause, depth + 1);
20754
- };
20755
- var normalizeCliError = (error61) => {
20756
- if (error61 instanceof CliRuntimeError) {
20757
- return {
20758
- code: error61.code,
20759
- message: error61.message,
20760
- ...error61.details === undefined ? {} : { details: error61.details },
20761
- ...error61.requestId === undefined ? {} : { requestId: error61.requestId }
20762
- };
20763
- }
20764
- if (isNetworkError(error61)) {
20765
- return {
20766
- code: "NETWORK",
20767
- message: error61 instanceof Error ? error61.message : "The Budget Builder API could not be reached."
20768
- };
20769
- }
20770
- if (isRecord(error61)) {
20771
- const data = isRecord(error61.data) ? error61.data : undefined;
20772
- const transportCode = mapErrorCode(data?.code ?? error61.code);
20773
- if (error61 instanceof Error && data === undefined && transportCode === undefined) {
20774
- return { code: "INTERNAL", message: error61.message };
20775
- }
20776
- const code = transportCode ?? "API";
20777
- const message = asNonEmptyString(error61.message) ?? "The Budget Builder API request failed.";
20778
- const requestId = asNonEmptyString(data?.requestId) ?? asNonEmptyString(data?.requestID) ?? asNonEmptyString(error61.requestId);
20779
- const details = asDetails(data?.details ?? error61.details);
20780
- return {
20781
- code,
20782
- message,
20783
- ...details === undefined ? {} : { details },
20784
- ...requestId === undefined ? {} : { requestId }
20785
- };
20786
- }
20787
- if (error61 instanceof Error) {
20788
- return { code: "INTERNAL", message: error61.message };
20789
- }
20790
- return { code: "INTERNAL", message: "An unexpected CLI error occurred." };
20791
- };
20792
- var exitCodeForCliError = (error61) => {
20793
- if (error61.code === "INTERRUPTED")
20794
- return 130;
20795
- return invocationErrorCodes.has(error61.code) ? 2 : 1;
20796
- };
20797
-
20798
20826
  // src/runtime/confirmation.ts
20799
20827
  var effectLabels = {
20800
20828
  "state-change": "changes Budget Builder state",
@@ -23444,16 +23472,19 @@ var summaryFor = (legacyTarget) => {
23444
23472
  if (legacyTarget === "whoami")
23445
23473
  return "Show the active API-key identity.";
23446
23474
  if (legacyTarget === "create-bill") {
23447
- return "Create a bill or claim with an approved supplier. --draft-only guarantees DRAFT without email or external writes; otherwise unpaid creation skips email permission but keeps external-write permission.";
23475
+ return "Create a bill or claim with an approved supplier. All amount fields exclude GST: amount is the portion billed; extractedAmount is the full invoice subtotal; lineAmounts and quotationAllocations also exclude GST. Example: 1800 + 162 GST = 1962 total requires amount=1800 and extractedAmount=1800 for a full bill. The CLI does not strip GST from input. --draft-only guarantees DRAFT without email or external writes; otherwise unpaid creation skips email permission but keeps external-write permission.";
23476
+ }
23477
+ if (legacyTarget === "update-bill") {
23478
+ return "Update a bill or claim; payload must include id. All amount fields exclude GST: amount is the portion billed; extractedAmount is the full invoice subtotal; lineAmounts and quotationAllocations also exclude GST. Example: 1800 + 162 GST = 1962 total requires amount=1800 and extractedAmount=1800 for a full bill. The CLI does not strip GST from input. Re-read and verify both amount fields and allocations after correcting a gross/net mistake.";
23448
23479
  }
23449
23480
  if (legacyTarget === "validate-bill-selection") {
23450
23481
  return "Validate the approved supplier and selected bill line items.";
23451
23482
  }
23452
23483
  if (legacyTarget === "create-quotation") {
23453
- return "Create a quotation with an approved supplier.";
23484
+ return 'Create a quotation with an approved supplier using --payload JSON. Include projectId, budgetId, supplierId, budgetItemIds, amountWithoutGst, gstAmount, and one uploaded attachment in attachments. Multiple budget items require lineAmounts: [{"budgetItemId":"<first-id>","amount":750},{"budgetItemId":"<second-id>","amount":450}], covering every selected budget-item ID exactly once and summing to amountWithoutGst. Amounts exclude GST; use gstAmount: 0 for no GST. Example: amountWithoutGst: 1200 with line amounts 750 and 450.';
23454
23485
  }
23455
23486
  if (legacyTarget === "update-quotation") {
23456
- return "Update a quotation with an approved supplier.";
23487
+ return 'Update a DRAFT or REJECTED quotation using --payload JSON with id and the complete creation payload. Multiple budget items require lineAmounts: [{"budgetItemId":"<first-id>","amount":750},{"budgetItemId":"<second-id>","amount":450}], covering every budgetItemIds entry exactly once and summing to amountWithoutGst. Amounts exclude GST; use gstAmount: 0 for no GST.';
23457
23488
  }
23458
23489
  if (legacyTarget === "download-customer-invoice-pdf") {
23459
23490
  return "Download a customer invoice PDF when permitted.";
@@ -23625,8 +23656,20 @@ var registry2 = [
23625
23656
  "staged",
23626
23657
  "cleanup"
23627
23658
  ]),
23628
- legacyCommand("create-quotation", ["quotation", "create"]),
23629
- legacyCommand("update-quotation", ["quotation", "update"]),
23659
+ legacyCommand("create-quotation", ["quotation", "create"], [], [
23660
+ {
23661
+ name: "--payload",
23662
+ description: "Quotation JSON including lineAmounts for multiple selected budget items; see the payload format above.",
23663
+ required: true
23664
+ }
23665
+ ]),
23666
+ legacyCommand("update-quotation", ["quotation", "update"], [], [
23667
+ {
23668
+ name: "--payload",
23669
+ description: "Complete quotation JSON plus id, including lineAmounts for multiple selected budget items.",
23670
+ required: true
23671
+ }
23672
+ ]),
23630
23673
  legacyCommand("delete-quotation", ["quotation", "delete"]),
23631
23674
  legacyCommand("submit-quotation", ["quotation", "submit"]),
23632
23675
  legacyCommand("approve-quotation", ["quotation", "approve"]),
@@ -23874,12 +23917,28 @@ var resolveCommand = (argv) => {
23874
23917
  isLegacyAlias: true
23875
23918
  };
23876
23919
  };
23877
- var createHumanHelp = () => {
23878
- const lines = commandRegistry.map((command) => {
23879
- const canonical = `bb ${command.path.join(" ")}`;
23880
- const aliases = command.legacyAliases.filter((alias) => alias.includes("-")).map((alias) => `bb ${alias}`).join(", ");
23920
+ var createHumanHelp = (command) => {
23921
+ if (command) {
23922
+ return [
23923
+ `Usage: bb ${command.path.join(" ")} [options] [args]`,
23924
+ "",
23925
+ command.summary,
23926
+ "",
23927
+ `Legacy aliases: ${command.legacyAliases.join(", ")}`,
23928
+ "",
23929
+ "Options",
23930
+ ...command.options.map((option) => ` ${option.name}
23931
+ ${option.description}`),
23932
+ "",
23933
+ "Use `bb help --legacy` for additional command arguments."
23934
+ ].join(`
23935
+ `);
23936
+ }
23937
+ const lines = commandRegistry.map((command2) => {
23938
+ const canonical = `bb ${command2.path.join(" ")}`;
23939
+ const aliases = command2.legacyAliases.filter((alias) => alias.includes("-")).map((alias) => `bb ${alias}`).join(", ");
23881
23940
  return ` ${canonical}
23882
- ${command.summary}${aliases ? ` Legacy: ${aliases}.` : ""}`;
23941
+ ${command2.summary}${aliases ? ` Legacy: ${aliases}.` : ""}`;
23883
23942
  });
23884
23943
  return [
23885
23944
  "bb \u2014 Budget Builder CLI",
@@ -24134,6 +24193,8 @@ Bills
24134
24193
  list-claims same flags as list-bills; only reimbursable claims (ignores --isClaimable)
24135
24194
  isClaimable differentiates the shared bill/claim records: false = bill, true = claim.
24136
24195
  Create supplier bills from approved quotations from the same supplier and project. Use quotationAllocations plus lineAmounts when quotations split one bill line. Eligible exemptions require explicit --quotation-exempt intent; claims do not consume or link quotations.
24196
+ Amounts for create/update (including drafts) exclude GST: amount = portion billed; extractedAmount = full invoice subtotal; lineAmounts[].amount and quotationAllocations[].amount also exclude GST. The CLI does not strip GST from input.
24197
+ Example: invoice subtotal 1800 + GST 162 = total 1962 -> amount: 1800, extractedAmount: 1800, allocations totaling 1800. BB calculates applicable GST separately. Re-read and verify both amount fields after writing.
24137
24198
  create-bill-approval <billId> (also queues approval request emails)
24138
24199
  create-bill --payload '<json>' [--draft-only] [--quotation-exempt] (bill.create; supplier bills use payload.quotationAllocations for split sources or quotationIds for disjoint coverage; explicit allocations require lineAmounts; claims reject quotation sources)
24139
24200
  validate-bill-selection --payload '<json>' [--quotationIds <csv> | --quotationId <id>] Validate an approved supplier and approved quotation coverage for each supplier-bill line. --quotationId is legacy single-quotation syntax.
@@ -24162,8 +24223,8 @@ Quotations
24162
24223
  get-quotation <quotationId>
24163
24224
  upload-quotation-attachment <projectId> <filePath> Upload one PDF, JPEG, or PNG up to 20MB; returns attachment JSON for a quotation payload.
24164
24225
  cleanup-staged-quotation-attachments <projectId> --keys <csv> Delete unattached staged quotation uploads.
24165
- create-quotation --payload '<json>' (quotation.createDraft; requires amountWithoutGst and gstAmount)
24166
- update-quotation --payload '<json>' Update a DRAFT or REJECTED quotation; same payload plus id.
24226
+ create-quotation --payload '<json>' (quotation.createDraft; requires amountWithoutGst, gstAmount, and lineAmounts [{budgetItemId, amount}] for multiple budgetItemIds; amounts before GST must sum to amountWithoutGst)
24227
+ update-quotation --payload '<json>' Update a DRAFT or REJECTED quotation; same allocation payload plus id; use lineAmounts [{budgetItemId, amount}] for every selected item.
24167
24228
  delete-quotation <quotationId>
24168
24229
  submit-quotation <quotationId> [--overBudgetOverrideReason <text>]
24169
24230
  approve-quotation <quotationId> [--overBudgetOverrideReason <text>]
@@ -24438,7 +24499,9 @@ async function runCli(argv = process.argv, runtime2 = createProcessRuntime(), cr
24438
24499
  if (commandArgv.includes("--legacy")) {
24439
24500
  printLegacyHelp(runtime2);
24440
24501
  } else {
24441
- runtime2.stdout.write(`${createHumanHelp()}
24502
+ const helpArgv = commandArgv.filter((argument, index) => argument !== "-h" && argument !== "--help" && !argument.startsWith("--help=") && !(commandArgv[index - 1] === "--help" && booleanFlagValues.has(argument)));
24503
+ const helpCommand = resolveCommand(helpArgv[0] === "help" ? helpArgv.slice(1) : helpArgv)?.command;
24504
+ runtime2.stdout.write(`${createHumanHelp(helpCommand)}
24442
24505
  `);
24443
24506
  }
24444
24507
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@go-labs-sg/bb",
3
- "version": "2.33.0",
3
+ "version": "2.33.2",
4
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",
@@ -134,6 +134,58 @@ The following distinctions are especially important:
134
134
  - Ordinary unpaid creation does not require `--allow-email`, but remains role-dependent: Accounting auto-checks and Admin auto-approves. It still requires `--allow-external-write`, including for possible placeholder adjustments. Already-paid creation retains the email gate.
135
135
  - Staged attachments belong to the user in the database. Do not reuse, share, or manually construct staged keys.
136
136
 
137
+ ## Creating and updating quotations
138
+
139
+ Quotation payloads keep the supplier subtotal and GST in separate fields: `amountWithoutGst` is the subtotal and `gstAmount` is GST. For a multi-line quotation, `lineAmounts` is required and must list every selected budget item exactly once; its amounts must add up to `amountWithoutGst`. Read the budget first and copy the actual `budgetItemIds` returned by Budget Builder. Never substitute item names, invented IDs, or IDs from another budget or project.
140
+
141
+ `quotationAllocations` is a bill-side source allocation. Use it when creating or updating a supplier bill to record which approved quotations fund bill lines. Do not put `quotationAllocations` in a quotation create/update payload.
142
+
143
+ Upload one quotation file with `bb quotation attachment upload <project-id> <file-path>`, then pass the one returned attachment object in the quotation payload's one-element `attachments` array. The create and update procedures require that single original quotation attachment.
144
+
145
+ Replace the example attachment size `12345` with the actual byte count from the upload response.
146
+
147
+ Use this complete placeholder shape for a quotation with S$750 on one real budget line and S$450 on another (S$1,200 subtotal, S$0 GST):
148
+
149
+ ```bash
150
+ bb quotation attachment upload "<project-id>" "<quotation-file.pdf>"
151
+
152
+ # Replace every <...> placeholder with values from the budget read or upload response.
153
+ bb quotation create --payload '{
154
+ "projectId": "<project-id>",
155
+ "budgetId": "<budget-id>",
156
+ "supplierId": "<supplier-id>",
157
+ "budgetItemIds": ["<budget-item-id-for-750>", "<budget-item-id-for-450>"],
158
+ "lineAmounts": [
159
+ {"budgetItemId": "<budget-item-id-for-750>", "amount": 750},
160
+ {"budgetItemId": "<budget-item-id-for-450>", "amount": 450}
161
+ ],
162
+ "amountWithoutGst": 1200,
163
+ "gstAmount": 0,
164
+ "attachments": [
165
+ {
166
+ "id": "<attachment-id-from-upload>",
167
+ "name": "<attachment-name-from-upload>",
168
+ "key": "<attachment-key-from-upload>",
169
+ "size": 12345
170
+ }
171
+ ]
172
+ }'
173
+ ```
174
+
175
+ For an update, use the same shape with `"id": "<quotation-id>"` and the existing quotation's editable status. The numeric example is documentation-only; it contains no live resource IDs. Report the quotation subtotal, GST, and total separately after reading the result.
176
+
177
+ ## Bill amounts: always identify the subtotal before writing
178
+
179
+ For bill creation (including `--draft-only`) and updates, `amount`, `extractedAmount`, `lineAmounts[].amount`, and `quotationAllocations[].amount` all **exclude GST**. The CLI does not strip GST from a supplied total. BB calculates applicable GST separately from the supplier and line settings.
180
+
181
+ - `amount` is the portion being billed now; `extractedAmount` is the full invoice subtotal before GST. They are equal for a full bill and may differ for a deposit or partial bill.
182
+ - Example: an invoice for **S$1,800 + S$162 GST = S$1,962** requires `amount: 1800` and `extractedAmount: 1800` for a full bill, with line/quotation allocations totaling `1800`. Entering `1962` as the bill amount charges GST again.
183
+ - Read the attachment's actual subtotal and tax breakdown. Do not assume every invoice includes 9% GST or blindly divide all totals by 1.09. Clarify missing or ambiguous tax information before writing.
184
+ - After creation or correction, re-read the bill and verify both amount fields and the line/quotation allocations. Correcting only `amount` can leave an incorrect `extractedAmount` suggesting a partial bill.
185
+ - Report **subtotal, GST, and total separately**. Keep the original invoice attachment as evidence; recording GST only in a comment does not correct the structured amount fields.
186
+
187
+ See [Bill amounts and GST](README.md#bill-amounts-and-gst-create-and-update) for payload and partial-bill examples.
188
+
137
189
  ## Handling authorization failures
138
190
 
139
191
  On `FORBIDDEN`: