@go-labs-sg/bb 2.31.0 → 2.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -232,6 +232,18 @@ 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
+ **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. Every quotation must belong to the bill supplier and project and cover the allocated budget item. Legacy `quotationId` remains valid for one quotation. 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
+
237
+ 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`.
238
+
239
+ Admins can repair missing source links on eligible draft, pending-approval, or rejected supplier bills with `bb bill quotation link <billId> --quotationIds q1,q2 --allow-state-change --allow-financial-write`. For same-line splits, use `--payload '{"quotationAllocations":[{"budgetItemId":"line_1","quotationId":"quote_1","amount":400},{"budgetItemId":"line_1","quotationId":"quote_2","amount":350}]}'`. The repair uses stored bill-line amounts and changes source linkage only.
240
+
241
+ ```sh
242
+ bb bill create --payload '{"projectId":"project_1","budgetId":"budget_1","supplierId":"supplier_1","budgetItemIds":["line_1","line_2"],"quotationIds":["quote_1","quote_2"],"amount":1200,"isClaimable":false}'
243
+
244
+ bb bill create --payload '{"projectId":"project_1","budgetId":"budget_1","supplierId":"supplier_1","budgetItemIds":["line_1"],"amount":750,"lineAmounts":[{"budgetItemId":"line_1","amount":750}],"quotationAllocations":[{"budgetItemId":"line_1","quotationId":"quote_1","amount":400},{"budgetItemId":"line_1","quotationId":"quote_2","amount":350}],"isClaimable":false}'
245
+ ```
246
+
235
247
  Bill creation determines its required permissions from the parsed input, for both canonical and legacy commands. Ordinary unpaid creation requires state-change, financial-write, and external-write permissions, but no email permission. Already-paid creation also requires email permission. Without draft-only mode, existing role behavior remains: ordinary users create drafts, Accounting creates checked bills, and Admin creates approved bills and writes to QuickBooks. Ordinary creation can also adjust existing QuickBooks placeholder bills. Explicit drafts defer those adjustments until the later approval/QuickBooks workflow. When later authorized to submit the draft, use `bb bill approval create <billId>` with its state-change and email permissions; Admin-created drafts also enter finance review through this command. The static command catalog lists the union of possible effects.
236
248
 
237
249
  **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.
@@ -244,6 +256,38 @@ Bill creation determines its required permissions from the parsed input, for bot
244
256
 
245
257
  **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.
246
258
 
259
+ ### Link an existing QuickBooks invoice
260
+
261
+ **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).
262
+
263
+ Authenticate and run `bb auth whoami` first. Preview and import use the same project management permission as the web flow: with user access checks enabled, Admins or the project's creator, business-development owner, inside-sales owner, or project manager can act. A finance/Lead role alone does not grant access to an unrelated project. The server applies its current access-check configuration and rechecks permission during confirmation.
264
+
265
+ Use the **BB budget ID** whose existing estimate should receive the invoice, and the numeric **QBO invoice transaction ID**, not the displayed invoice number or an estimate number. Accepted and closed estimates are supported; linking does not reopen them. Fully invoiced means all estimate coverage has been billed; it does not mean the invoices are fully paid.
266
+
267
+ ```bash
268
+ bb customer-invoice preview-qbo <budgetId> <qboInvoiceId>
269
+ bb customer-invoice import-qbo <budgetId> <qboInvoiceId> --token <previewToken>
270
+ ```
271
+
272
+ Preview performs read-only QBO access and returns JSON under `data`, including `token`, project/estimate, invoice number/identity, currency, dates, total, outstanding `balance`, allocation `percentage`, lines, `alreadyLinked`, and other unlinked invoices. Review all of it before confirming. Import requires the exact `data.token` from that preview; it does not silently generate a fresh token. In an interactive terminal, confirm the named invoice and budget when prompted. For an explicitly authorized non-interactive import:
273
+
274
+ ```bash
275
+ bb customer-invoice import-qbo <budgetId> <qboInvoiceId> \
276
+ --token <previewToken> --allow-state-change --allow-financial-write
277
+ ```
278
+
279
+ Import writes BB financial records only and needs neither `--allow-external-write` nor `--allow-email`. It calls the same server validation as the UI: project/customer/currency/estimate/allocation checks, sibling invoice coverage, and financial drift checks. It preserves the original invoice evidence and does not create, update, void, delete, send, or match anything in QBO. A successful response returns `batchId`, `invoiceId` (the new BB invoice ID), and `alreadyLinked: false`. Repeating the same link returns the original IDs with `alreadyLinked: true`, provided upstream financial evidence still agrees. A conflicting budget, ambiguous allocation, or financial mismatch fails; a stale preview requires another reviewed preview. Do not retry a conflict with a different budget to bypass it.
280
+
281
+ To refresh payments, use the returned **BB invoice ID**:
282
+
283
+ ```bash
284
+ bb customer-invoice sync <invoiceId>
285
+ ```
286
+
287
+ For `QBO_IMPORT` invoices, this existing command reads QBO and updates BB balance, payment status, and sync evidence only. Settlement becomes `PAID`; a reversal restores `APPROVED`; original invoice evidence is retained. Financial drift is reported separately as `qboFinancialDrift` and blocks further billing. The shared sync command also supports native invoices, so its generic automation permissions remain `--allow-state-change --allow-financial-write --allow-external-write`; imported records still take the server's BB-only path. Native creation continues to use `bb customer-invoice create --payload '<json>'` and needs no import token or fields.
288
+
289
+ The Seatrium example must be previewed against live QBO again before an operator imports it. Previously observed invoice numbers and paid/open balances are not authorization or current-state evidence.
290
+
247
291
  **Customer-invoice workflow parity:** `bb customer-invoice list` uses the same global/project list procedure and metrics as the web pages; omit filters for the global list or use `--projectId` for project scope. `bb customer-invoice send --payload '<json>'` uses the same protected email workflow as the web composer, sends to the payload's `to` address, saves that address as the project's configured billing email after a successful send, and marks the invoice `SENT`. `bb customer-invoice approve` and `bb customer-invoice reject` are Admin-only batch operations: callers select a batch ID, and the API resolves its internal pending Admin approval record. `bb customer-invoice sync` follows QuickBooks' paid state and zero balance, restoring `SENT` or `APPROVED` if that payment is reversed. Invoice approval refuses voided invoices and closes the estimate only when approved invoice coverage totals 100%; deletion, voiding, rejection, expiry, and QBO synchronization use the same estimate-reopening and live-payment guards as the web app.
248
292
 
249
293
  For agent-driven invoice work, use this read-before-write sequence:
@@ -283,9 +327,9 @@ Bill, claim, and quotation mutations accept only suppliers whose approval status
283
327
  | Area | Legacy handler labels (reference only; non-exhaustive) |
284
328
  | --- | --- |
285
329
  | **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`) |
286
- | **Bills / claims** | `list-bills` (`--isClaimable false` for bills, `--isClaimable true` for claims, omit for both), `list-claims` (claims only), `validate-bill-selection` (`--payload`; validates the project and supplier separately, then checks approved quotation links for each selected budget line item independent of the bill supplier; `alreadyPaid` never bypasses the checks), `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 require approved quotation coverage linked to every selected line item, independent of the bill supplier, even when already paid), `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` |
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` |
287
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`) |
288
- | **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`, `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` |
332
+ | **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` |
289
333
  | **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` |
290
334
  | **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`) |
291
335
  | **Contacts** | `list-contacts`, `create-contact-person` (`--payload`), `update-contact-person` (`--payload`) |
@@ -359,5 +403,7 @@ relationships or allocations block approval and require review. Multi-line quota
359
403
  create/update payloads must include `lineAmounts` with `{ budgetItemId, amount }`
360
404
  for every selected line, summing to `amountWithoutGst`. The server revalidates
361
405
  existing quotation links, reviewed usage, and per-line allowance inside the
362
- approval transaction. No new database fields or relationship declarations are
363
- required; unresolved historical overlaps continue to block approval.
406
+ approval transaction. Multiple source quotations use `BillLineItem.quotationId`;
407
+ apply the updated database schema and deploy the API before releasing the CLI.
408
+ Legacy bill-wide links remain readable, and unresolved historical overlaps
409
+ continue to block approval.
@@ -2108,6 +2108,10 @@
2108
2108
  {
2109
2109
  "name": "--draft-only",
2110
2110
  "description": "Create a draft for every role; no approval, email, or QuickBooks writes. Conflicts with alreadyPaid: true."
2111
+ },
2112
+ {
2113
+ "name": "--quotation-exempt",
2114
+ "description": "Explicitly request an eligible server-validated supplier-bill quotation exemption. Cannot be combined with quotation IDs."
2111
2115
  }
2112
2116
  ],
2113
2117
  "argumentMode": "legacy-passthrough",
@@ -2158,12 +2162,78 @@
2158
2162
  {
2159
2163
  "name": "--allow-financial-write",
2160
2164
  "description": "Allow financial-record changes in non-interactive use."
2165
+ },
2166
+ {
2167
+ "name": "--quotationIds <csv>",
2168
+ "description": "Approved quotation IDs for a supplier bill. Each selected budget item must be covered once."
2169
+ },
2170
+ {
2171
+ "name": "--quotationId <id>",
2172
+ "description": "Legacy syntax for selecting one approved quotation."
2161
2173
  }
2162
2174
  ],
2163
2175
  "argumentMode": "legacy-passthrough",
2164
2176
  "effects": [],
2165
2177
  "legacyTarget": "validate-bill-selection"
2166
2178
  },
2179
+ {
2180
+ "path": ["bill", "quotation", "link"],
2181
+ "legacyAliases": ["link-bill-quotations", "link_bill_quotations", "billId"],
2182
+ "summary": "Link Bill Quotations.",
2183
+ "globalOptions": [
2184
+ {
2185
+ "name": "--help",
2186
+ "description": "Show help for this command."
2187
+ },
2188
+ {
2189
+ "name": "--quiet",
2190
+ "description": "Suppress non-error diagnostics."
2191
+ },
2192
+ {
2193
+ "name": "--debug",
2194
+ "description": "Emit sanitized diagnostic traces."
2195
+ },
2196
+ {
2197
+ "name": "--api-url",
2198
+ "description": "Override the Budget Builder API base URL."
2199
+ },
2200
+ {
2201
+ "name": "--no-browser",
2202
+ "description": "Print the authorization URL without opening a browser."
2203
+ },
2204
+ {
2205
+ "name": "--allow-state-change",
2206
+ "description": "Allow a Budget Builder state change in non-interactive use."
2207
+ },
2208
+ {
2209
+ "name": "--allow-email",
2210
+ "description": "Allow sending email in non-interactive use."
2211
+ },
2212
+ {
2213
+ "name": "--allow-external-write",
2214
+ "description": "Allow writes to external systems in non-interactive use."
2215
+ },
2216
+ {
2217
+ "name": "--allow-delete",
2218
+ "description": "Allow deleting data in non-interactive use."
2219
+ },
2220
+ {
2221
+ "name": "--allow-financial-write",
2222
+ "description": "Allow financial-record changes in non-interactive use."
2223
+ },
2224
+ {
2225
+ "name": "--payload <json>",
2226
+ "description": "Explicit quotationAllocations for split source coverage on the bill's stored line amounts."
2227
+ },
2228
+ {
2229
+ "name": "--quotationIds <csv>",
2230
+ "description": "Approved quotation IDs to link across the bill's disjoint line coverage."
2231
+ }
2232
+ ],
2233
+ "argumentMode": "legacy-passthrough",
2234
+ "effects": ["state-change", "financial-write"],
2235
+ "legacyTarget": "link-bill-quotations"
2236
+ },
2167
2237
  {
2168
2238
  "path": ["bill", "update"],
2169
2239
  "legacyAliases": ["update-bill", "update_bill"],
@@ -3643,6 +3713,112 @@
3643
3713
  "effects": [],
3644
3714
  "legacyTarget": "get-customer-invoice-email-context"
3645
3715
  },
3716
+ {
3717
+ "path": ["customer-invoice", "preview-qbo"],
3718
+ "legacyAliases": [
3719
+ "preview-qbo-customer-invoice",
3720
+ "preview_qbo_customer_invoice"
3721
+ ],
3722
+ "summary": "Preview an existing QuickBooks invoice for a BB budget using read-only QBO access. Arguments: <budgetId> <qboInvoiceId>.",
3723
+ "globalOptions": [
3724
+ {
3725
+ "name": "--help",
3726
+ "description": "Show help for this command."
3727
+ },
3728
+ {
3729
+ "name": "--quiet",
3730
+ "description": "Suppress non-error diagnostics."
3731
+ },
3732
+ {
3733
+ "name": "--debug",
3734
+ "description": "Emit sanitized diagnostic traces."
3735
+ },
3736
+ {
3737
+ "name": "--api-url",
3738
+ "description": "Override the Budget Builder API base URL."
3739
+ },
3740
+ {
3741
+ "name": "--no-browser",
3742
+ "description": "Print the authorization URL without opening a browser."
3743
+ },
3744
+ {
3745
+ "name": "--allow-state-change",
3746
+ "description": "Allow a Budget Builder state change in non-interactive use."
3747
+ },
3748
+ {
3749
+ "name": "--allow-email",
3750
+ "description": "Allow sending email in non-interactive use."
3751
+ },
3752
+ {
3753
+ "name": "--allow-external-write",
3754
+ "description": "Allow writes to external systems in non-interactive use."
3755
+ },
3756
+ {
3757
+ "name": "--allow-delete",
3758
+ "description": "Allow deleting data in non-interactive use."
3759
+ },
3760
+ {
3761
+ "name": "--allow-financial-write",
3762
+ "description": "Allow financial-record changes in non-interactive use."
3763
+ }
3764
+ ],
3765
+ "argumentMode": "legacy-passthrough",
3766
+ "effects": [],
3767
+ "legacyTarget": "preview-qbo-customer-invoice"
3768
+ },
3769
+ {
3770
+ "path": ["customer-invoice", "import-qbo"],
3771
+ "legacyAliases": [
3772
+ "import-qbo-customer-invoice",
3773
+ "import_qbo_customer_invoice"
3774
+ ],
3775
+ "summary": "Link an existing QuickBooks invoice in BB only after reviewing preview-qbo. Arguments: <budgetId> <qboInvoiceId> --token <previewToken>. Supports closed estimates; no QBO writes or emails.",
3776
+ "globalOptions": [
3777
+ {
3778
+ "name": "--help",
3779
+ "description": "Show help for this command."
3780
+ },
3781
+ {
3782
+ "name": "--quiet",
3783
+ "description": "Suppress non-error diagnostics."
3784
+ },
3785
+ {
3786
+ "name": "--debug",
3787
+ "description": "Emit sanitized diagnostic traces."
3788
+ },
3789
+ {
3790
+ "name": "--api-url",
3791
+ "description": "Override the Budget Builder API base URL."
3792
+ },
3793
+ {
3794
+ "name": "--no-browser",
3795
+ "description": "Print the authorization URL without opening a browser."
3796
+ },
3797
+ {
3798
+ "name": "--allow-state-change",
3799
+ "description": "Allow a Budget Builder state change in non-interactive use."
3800
+ },
3801
+ {
3802
+ "name": "--allow-email",
3803
+ "description": "Allow sending email in non-interactive use."
3804
+ },
3805
+ {
3806
+ "name": "--allow-external-write",
3807
+ "description": "Allow writes to external systems in non-interactive use."
3808
+ },
3809
+ {
3810
+ "name": "--allow-delete",
3811
+ "description": "Allow deleting data in non-interactive use."
3812
+ },
3813
+ {
3814
+ "name": "--allow-financial-write",
3815
+ "description": "Allow financial-record changes in non-interactive use."
3816
+ }
3817
+ ],
3818
+ "argumentMode": "legacy-passthrough",
3819
+ "effects": ["state-change", "financial-write"],
3820
+ "legacyTarget": "import-qbo-customer-invoice"
3821
+ },
3646
3822
  {
3647
3823
  "path": ["customer-invoice", "create"],
3648
3824
  "legacyAliases": ["create-customer-invoice", "create_customer_invoice"],
@@ -361,6 +361,14 @@ Legacy aliases: `validate-bill-selection`, `validate_bill_selection`.
361
361
 
362
362
  Effects: none.
363
363
 
364
+ ## `bb bill quotation link`
365
+
366
+ Link Bill Quotations.
367
+
368
+ Legacy aliases: `link-bill-quotations`, `link_bill_quotations`, `billId`.
369
+
370
+ Effects: state-change, financial-write.
371
+
364
372
  ## `bb bill update`
365
373
 
366
374
  Update Bill.
@@ -593,6 +601,22 @@ Legacy aliases: `get-customer-invoice-email-context`, `get_customer_invoice_emai
593
601
 
594
602
  Effects: none.
595
603
 
604
+ ## `bb customer-invoice preview-qbo`
605
+
606
+ Preview an existing QuickBooks invoice for a BB budget using read-only QBO access. Arguments: <budgetId> <qboInvoiceId>.
607
+
608
+ Legacy aliases: `preview-qbo-customer-invoice`, `preview_qbo_customer_invoice`.
609
+
610
+ Effects: none.
611
+
612
+ ## `bb customer-invoice import-qbo`
613
+
614
+ Link an existing QuickBooks invoice in BB only after reviewing preview-qbo. Arguments: <budgetId> <qboInvoiceId> --token <previewToken>. Supports closed estimates; no QBO writes or emails.
615
+
616
+ Legacy aliases: `import-qbo-customer-invoice`, `import_qbo_customer_invoice`.
617
+
618
+ Effects: state-change, financial-write.
619
+
596
620
  ## `bb customer-invoice create`
597
621
 
598
622
  Create Customer Invoice.
package/dist/index.js CHANGED
@@ -20154,6 +20154,120 @@ function optionalString(v) {
20154
20154
  const s = String(v);
20155
20155
  return s === "" ? undefined : s;
20156
20156
  }
20157
+ var parseQuotationReferences = (input2, commandName) => {
20158
+ const quotationId = optionalString(input2.quotationId)?.trim();
20159
+ const rawAllocations = input2.quotationAllocations;
20160
+ let quotationAllocations;
20161
+ if (rawAllocations !== undefined && rawAllocations !== null) {
20162
+ if (!Array.isArray(rawAllocations) || rawAllocations.length === 0) {
20163
+ throw new Error(`${commandName} payload.quotationAllocations must be a non-empty array.`);
20164
+ }
20165
+ quotationAllocations = rawAllocations.map((raw, index) => {
20166
+ const label = `${commandName} payload.quotationAllocations[${index}]`;
20167
+ const allocation = requireObject(raw, label);
20168
+ const amount = Number(allocation.amount);
20169
+ if (!Number.isFinite(amount) || amount <= 0 || !Number.isInteger(amount * 100)) {
20170
+ throw new Error(`${label}.amount must be a positive cent value.`);
20171
+ }
20172
+ return {
20173
+ budgetItemId: requiredString(allocation.budgetItemId, `${label}.budgetItemId`),
20174
+ quotationId: requiredString(allocation.quotationId, `${label}.quotationId`),
20175
+ amount
20176
+ };
20177
+ });
20178
+ const keys = quotationAllocations.map((allocation) => `${allocation.budgetItemId}\x00${allocation.quotationId}`);
20179
+ if (new Set(keys).size !== keys.length) {
20180
+ throw new Error(`${commandName} payload.quotationAllocations must not repeat a budget-item/quotation pair.`);
20181
+ }
20182
+ }
20183
+ const rawQuotationIds = input2.quotationIds;
20184
+ let quotationIds;
20185
+ if (rawQuotationIds !== undefined && rawQuotationIds !== null) {
20186
+ if (!Array.isArray(rawQuotationIds)) {
20187
+ throw new Error(`${commandName} payload.quotationIds must be a string array.`);
20188
+ }
20189
+ quotationIds = rawQuotationIds.map((id, index) => requiredString(id, `${commandName} payload.quotationIds[${index}]`));
20190
+ if (new Set(quotationIds).size !== quotationIds.length) {
20191
+ throw new Error(`${commandName} payload.quotationIds must not contain duplicates.`);
20192
+ }
20193
+ }
20194
+ if (quotationId && quotationIds && (quotationIds.length !== 1 || quotationIds[0] !== quotationId)) {
20195
+ throw new Error(`${commandName} payload.quotationId conflicts with payload.quotationIds.`);
20196
+ }
20197
+ if (quotationAllocations && quotationIds) {
20198
+ const allocationIds = [
20199
+ ...new Set(quotationAllocations.map((allocation) => allocation.quotationId))
20200
+ ].sort();
20201
+ const selectedIds = [...quotationIds].sort();
20202
+ if (allocationIds.join("\x00") !== selectedIds.join("\x00")) {
20203
+ throw new Error(`${commandName} payload.quotationIds conflicts with payload.quotationAllocations.`);
20204
+ }
20205
+ }
20206
+ return {
20207
+ ...quotationId ? { quotationId } : {},
20208
+ ...quotationIds ? { quotationIds } : {},
20209
+ ...quotationAllocations ? { quotationAllocations } : {}
20210
+ };
20211
+ };
20212
+ var parseBillLineAmounts = (input2, commandName) => {
20213
+ if (input2.lineAmounts === undefined || input2.lineAmounts === null)
20214
+ return;
20215
+ if (!Array.isArray(input2.lineAmounts) || input2.lineAmounts.length === 0) {
20216
+ throw new Error(`${commandName} payload.lineAmounts must be a non-empty array.`);
20217
+ }
20218
+ const lineAmounts = input2.lineAmounts.map((raw, index) => {
20219
+ const label = `${commandName} payload.lineAmounts[${index}]`;
20220
+ const line = requireObject(raw, label);
20221
+ const amount = Number(line.amount);
20222
+ if (!Number.isFinite(amount) || amount < 0 || !Number.isInteger(amount * 100)) {
20223
+ throw new Error(`${label}.amount must be a non-negative cent value.`);
20224
+ }
20225
+ return {
20226
+ budgetItemId: requiredString(line.budgetItemId, `${label}.budgetItemId`),
20227
+ amount
20228
+ };
20229
+ });
20230
+ if (new Set(lineAmounts.map((line) => line.budgetItemId)).size !== lineAmounts.length) {
20231
+ throw new Error(`${commandName} payload.lineAmounts must contain each budget item once.`);
20232
+ }
20233
+ return lineAmounts;
20234
+ };
20235
+ var cents = (amount) => Math.round(amount * 100);
20236
+ var validateExplicitBillAllocations = ({
20237
+ amount,
20238
+ budgetItemIds,
20239
+ commandName,
20240
+ lineAmounts,
20241
+ quotationAllocations
20242
+ }) => {
20243
+ if (!lineAmounts && !quotationAllocations)
20244
+ return;
20245
+ if (!lineAmounts) {
20246
+ throw new Error(`${commandName} payload.lineAmounts is required with quotationAllocations.`);
20247
+ }
20248
+ const selected = new Set(budgetItemIds);
20249
+ if (lineAmounts.length !== selected.size || lineAmounts.some((line) => !selected.has(line.budgetItemId))) {
20250
+ throw new Error(`${commandName} payload.lineAmounts must contain each selected budget item exactly once.`);
20251
+ }
20252
+ if (lineAmounts.reduce((sum, line) => sum + cents(line.amount), 0) !== cents(amount)) {
20253
+ throw new Error(`${commandName} payload.lineAmounts must sum to payload.amount.`);
20254
+ }
20255
+ if (!quotationAllocations)
20256
+ return;
20257
+ const lineAmountById = new Map(lineAmounts.map((line) => [line.budgetItemId, cents(line.amount)]));
20258
+ const allocatedById = new Map;
20259
+ for (const allocation of quotationAllocations) {
20260
+ if (!selected.has(allocation.budgetItemId)) {
20261
+ throw new Error(`${commandName} payload.quotationAllocations references an unselected budget item.`);
20262
+ }
20263
+ allocatedById.set(allocation.budgetItemId, (allocatedById.get(allocation.budgetItemId) ?? 0) + cents(allocation.amount));
20264
+ }
20265
+ for (const [budgetItemId, allocated] of allocatedById) {
20266
+ if (allocated > (lineAmountById.get(budgetItemId) ?? 0)) {
20267
+ throw new Error(`${commandName} quotation allocations exceed the line amount for ${budgetItemId}.`);
20268
+ }
20269
+ }
20270
+ };
20157
20271
  function requiredString(value, fieldLabel) {
20158
20272
  if (value === undefined || value === null) {
20159
20273
  throw new Error(`${fieldLabel} is required.`);
@@ -20259,7 +20373,7 @@ var billAttachment = (x, i, field = "attachments") => {
20259
20373
  size: Number(a.size)
20260
20374
  };
20261
20375
  };
20262
- function parseCreateBillPayload(raw, draftOnlyFlag) {
20376
+ function parseCreateBillPayload(raw, draftOnlyFlag, quotationExempt = false, requireSupplierQuotation = true) {
20263
20377
  const o = requireObject(raw, "create-bill payload");
20264
20378
  const isClaimable = o.isClaimable;
20265
20379
  if (typeof isClaimable !== "boolean") {
@@ -20315,13 +20429,36 @@ function parseCreateBillPayload(raw, draftOnlyFlag) {
20315
20429
  if (!Number.isFinite(amount) || amount < 0) {
20316
20430
  throw new Error("create-bill payload.amount must be a number >= 0.");
20317
20431
  }
20432
+ const quotationReferences = parseQuotationReferences(o, "create-bill");
20433
+ const parsedBudgetItemIds = budgetItemIds.map((id, i) => requiredString(id, `create-bill payload.budgetItemIds[${i}]`));
20434
+ const lineAmounts = parseBillLineAmounts(o, "create-bill");
20435
+ validateExplicitBillAllocations({
20436
+ amount,
20437
+ budgetItemIds: parsedBudgetItemIds,
20438
+ commandName: "create-bill",
20439
+ lineAmounts,
20440
+ quotationAllocations: quotationReferences.quotationAllocations
20441
+ });
20442
+ if (quotationExempt && isClaimable) {
20443
+ throw new Error("--quotation-exempt is only valid for supplier bills.");
20444
+ }
20445
+ if (quotationExempt && (quotationReferences.quotationId || (quotationReferences.quotationIds?.length ?? 0) > 0 || (quotationReferences.quotationAllocations?.length ?? 0) > 0)) {
20446
+ throw new Error("--quotation-exempt cannot be combined with payload quotation IDs.");
20447
+ }
20448
+ if (isClaimable && (quotationReferences.quotationId || (quotationReferences.quotationIds?.length ?? 0) > 0 || (quotationReferences.quotationAllocations?.length ?? 0) > 0)) {
20449
+ throw new Error("Employee claims cannot include quotation source inputs.");
20450
+ }
20451
+ if (requireSupplierQuotation && !quotationExempt && !isClaimable && !quotationReferences.quotationId && (quotationReferences.quotationIds?.length ?? 0) === 0 && (quotationReferences.quotationAllocations?.length ?? 0) === 0) {
20452
+ throw new Error("create-bill supplier bills require payload.quotationAllocations, payload.quotationIds, or legacy payload.quotationId.");
20453
+ }
20318
20454
  return {
20319
20455
  budgetId: requiredString(o.budgetId, "create-bill payload.budgetId"),
20320
20456
  projectId: requiredString(o.projectId, "create-bill payload.projectId"),
20321
20457
  supplierId: requiredString(o.supplierId, "create-bill payload.supplierId"),
20322
- quotationId: optionalString(o.quotationId),
20458
+ ...quotationReferences,
20323
20459
  amount,
20324
- budgetItemIds: budgetItemIds.map((id, i) => requiredString(id, `create-bill payload.budgetItemIds[${i}]`)),
20460
+ budgetItemIds: parsedBudgetItemIds,
20461
+ ...lineAmounts ? { lineAmounts } : {},
20325
20462
  comment: optionalString(o.comment),
20326
20463
  isClaimable,
20327
20464
  attachments,
@@ -20339,7 +20476,7 @@ function parseCreateBillPayload(raw, draftOnlyFlag) {
20339
20476
  }
20340
20477
  function parseUpdateBillPayload(raw) {
20341
20478
  const o = requireObject(raw, "update-bill payload");
20342
- const { draftOnly, ...input2 } = parseCreateBillPayload(raw);
20479
+ const { draftOnly, ...input2 } = parseCreateBillPayload(raw, undefined, false, false);
20343
20480
  if (draftOnly !== undefined) {
20344
20481
  throw new Error("draftOnly is only supported when creating a bill.");
20345
20482
  }
@@ -20348,9 +20485,60 @@ function parseUpdateBillPayload(raw) {
20348
20485
  id: requiredString(o.id, "update-bill payload.id")
20349
20486
  };
20350
20487
  }
20351
- function parseValidateBillSelectionPayload(raw) {
20352
- requireObject(raw, "validate-bill-selection payload");
20353
- return raw;
20488
+ function parseValidateBillSelectionPayload(raw, quotationFlags = {}) {
20489
+ const input2 = requireObject(raw, "validate-bill-selection payload");
20490
+ const payloadReferences = parseQuotationReferences(input2, "validate-bill-selection");
20491
+ const flagReferences = parseQuotationReferences({ ...quotationFlags }, "validate-bill-selection flags");
20492
+ const payloadIds = payloadReferences.quotationIds ?? (payloadReferences.quotationId ? [payloadReferences.quotationId] : undefined);
20493
+ const flagIds = flagReferences.quotationIds ?? (flagReferences.quotationId ? [flagReferences.quotationId] : undefined);
20494
+ if (payloadIds && flagIds && payloadIds.join("\x00") !== flagIds.join("\x00")) {
20495
+ throw new Error("validate-bill-selection quotation flags conflict with the payload quotation IDs.");
20496
+ }
20497
+ const isClaimable = input2.isClaimable;
20498
+ if (isClaimable !== undefined && typeof isClaimable !== "boolean") {
20499
+ throw new Error("validate-bill-selection payload.isClaimable must be boolean.");
20500
+ }
20501
+ if (isClaimable === true && (payloadReferences.quotationId || (payloadReferences.quotationIds?.length ?? 0) > 0 || (payloadReferences.quotationAllocations?.length ?? 0) > 0 || !!flagReferences.quotationId || (flagReferences.quotationIds?.length ?? 0) > 0)) {
20502
+ throw new Error("Employee claims cannot include quotation source inputs.");
20503
+ }
20504
+ const lineAmounts = parseBillLineAmounts(input2, "validate-bill-selection");
20505
+ const budgetItemIds = Array.isArray(input2.budgetItemIds) ? input2.budgetItemIds.map((id, index) => requiredString(id, `validate-bill-selection payload.budgetItemIds[${index}]`)) : [];
20506
+ const amount = input2.amount === undefined || input2.amount === null ? undefined : Number(input2.amount);
20507
+ if (lineAmounts || payloadReferences.quotationAllocations) {
20508
+ if (amount === undefined || !Number.isFinite(amount) || budgetItemIds.length === 0) {
20509
+ throw new Error("validate-bill-selection explicit allocations require payload.amount and payload.budgetItemIds.");
20510
+ }
20511
+ validateExplicitBillAllocations({
20512
+ amount,
20513
+ budgetItemIds,
20514
+ commandName: "validate-bill-selection",
20515
+ lineAmounts,
20516
+ quotationAllocations: payloadReferences.quotationAllocations
20517
+ });
20518
+ }
20519
+ return {
20520
+ ...input2,
20521
+ ...amount !== undefined ? { amount } : {},
20522
+ ...isClaimable !== undefined ? { isClaimable } : {},
20523
+ ...lineAmounts ? { lineAmounts } : {},
20524
+ ...payloadReferences,
20525
+ ...flagReferences
20526
+ };
20527
+ }
20528
+ function parseLinkBillQuotationsPayload(billId, raw) {
20529
+ const input2 = requireObject(raw, "link-bill-quotations payload");
20530
+ const references = parseQuotationReferences(input2, "link-bill-quotations");
20531
+ if (references.quotationId) {
20532
+ throw new Error("link-bill-quotations payload uses quotationIds or quotationAllocations; legacy quotationId is not supported.");
20533
+ }
20534
+ if ((references.quotationIds?.length ?? 0) === 0 && (references.quotationAllocations?.length ?? 0) === 0) {
20535
+ throw new Error("link-bill-quotations payload requires quotationIds or quotationAllocations.");
20536
+ }
20537
+ return {
20538
+ billId: requiredString(billId, "billId"),
20539
+ ...references.quotationIds ? { quotationIds: references.quotationIds } : {},
20540
+ ...references.quotationAllocations ? { quotationAllocations: references.quotationAllocations } : {}
20541
+ };
20354
20542
  }
20355
20543
  function parseUpdateBillPaymentEvidencePayload(raw) {
20356
20544
  requireObject(raw, "update-bill-payment-evidence payload");
@@ -22048,8 +22236,8 @@ async function deleteItemCategoriesByIds(ids) {
22048
22236
  const result = await api2.itemCategory.deleteItemCategories.mutate({ ids });
22049
22237
  out(result);
22050
22238
  }
22051
- async function createBillFromPayload(raw, draftOnlyFlag) {
22052
- const input2 = parseCreateBillPayload(raw, draftOnlyFlag);
22239
+ async function createBillFromPayload(raw, draftOnlyFlag, quotationExempt = false) {
22240
+ const input2 = parseCreateBillPayload(raw, draftOnlyFlag, quotationExempt);
22053
22241
  await confirmCurrentCommand({
22054
22242
  action: input2.draftOnly ? "bill create (draft only)" : "bill create",
22055
22243
  target: `project ${input2.projectId}, supplier ${input2.supplierId}`,
@@ -22134,6 +22322,19 @@ var getCustomerInvoiceEmailContext = async (batchId) => {
22134
22322
  });
22135
22323
  out(result);
22136
22324
  };
22325
+ var previewQboCustomerInvoice = async (input2) => {
22326
+ const result = await api2.customerInvoice.previewExistingInvoice.mutate(input2);
22327
+ out(result);
22328
+ };
22329
+ var importQboCustomerInvoice = async (input2) => {
22330
+ await confirmCurrentCommand({
22331
+ action: "Link existing QuickBooks invoice",
22332
+ target: `QuickBooks invoice ${input2.qboInvoiceId} to budget ${input2.budgetId}`,
22333
+ details: "records the reviewed invoice in Budget Builder only; QuickBooks access is read-only; preserves original evidence and revalidates the preview token"
22334
+ });
22335
+ const result = await api2.customerInvoice.linkExistingInvoice.mutate(input2);
22336
+ out(result);
22337
+ };
22137
22338
  var createCustomerInvoice = async (raw) => {
22138
22339
  const input2 = parseCreateCustomerInvoicePayload(raw);
22139
22340
  await assertSensitiveWorkflowConfirmed({
@@ -22237,11 +22438,16 @@ async function updateBillFromPayload(raw) {
22237
22438
  const result = await api2.bill.update.mutate(input2);
22238
22439
  out(result);
22239
22440
  }
22240
- var validateBillSelectionFromPayload = async (raw) => {
22241
- const input2 = parseValidateBillSelectionPayload(raw);
22441
+ var validateBillSelectionFromPayload = async (raw, quotationFlags) => {
22442
+ const input2 = parseValidateBillSelectionPayload(raw, quotationFlags);
22242
22443
  const result = await api2.bill.validateSelection.mutate(input2);
22243
22444
  out(result);
22244
22445
  };
22446
+ var linkBillQuotations = async (billId, raw) => {
22447
+ const input2 = parseLinkBillQuotationsPayload(billId, raw);
22448
+ const result = await api2.bill.linkQuotations.mutate(input2);
22449
+ out(result);
22450
+ };
22245
22451
  var updateBillPaymentEvidenceFromPayload = async (raw) => {
22246
22452
  const input2 = parseUpdateBillPaymentEvidencePayload(raw);
22247
22453
  const result = await api2.bill.updatePaymentEvidence.mutate(input2);
@@ -23079,6 +23285,7 @@ var mutationPrefixes = [
23079
23285
  "delete-",
23080
23286
  "discard-",
23081
23287
  "import-",
23288
+ "link-",
23082
23289
  "mark-",
23083
23290
  "patch-",
23084
23291
  "reactivate-",
@@ -23166,6 +23373,8 @@ var externalWriteTargets = new Set([
23166
23373
  "void-customer-invoice"
23167
23374
  ]);
23168
23375
  var financialWriteTargets = new Set([
23376
+ "import-qbo-customer-invoice",
23377
+ "link-bill-quotations",
23169
23378
  "approve-bill",
23170
23379
  "approve-customer-invoice",
23171
23380
  "complete-project",
@@ -23223,6 +23432,12 @@ var effectsFor = (legacyTarget) => {
23223
23432
  };
23224
23433
  var titleCase = (value) => value.split("-").map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`).join(" ");
23225
23434
  var summaryFor = (legacyTarget) => {
23435
+ if (legacyTarget === "preview-qbo-customer-invoice") {
23436
+ return "Preview an existing QuickBooks invoice for a BB budget using read-only QBO access. Arguments: <budgetId> <qboInvoiceId>.";
23437
+ }
23438
+ if (legacyTarget === "import-qbo-customer-invoice") {
23439
+ return "Link an existing QuickBooks invoice in BB only after reviewing preview-qbo. Arguments: <budgetId> <qboInvoiceId> --token <previewToken>. Supports closed estimates; no QBO writes or emails.";
23440
+ }
23226
23441
  if (legacyTarget === "whoami")
23227
23442
  return "Show the active API-key identity.";
23228
23443
  if (legacyTarget === "create-bill") {
@@ -23338,9 +23553,32 @@ var registry2 = [
23338
23553
  {
23339
23554
  name: "--draft-only",
23340
23555
  description: "Create a draft for every role; no approval, email, or QuickBooks writes. Conflicts with alreadyPaid: true."
23556
+ },
23557
+ {
23558
+ name: "--quotation-exempt",
23559
+ description: "Explicitly request an eligible server-validated supplier-bill quotation exemption. Cannot be combined with quotation IDs."
23560
+ }
23561
+ ]),
23562
+ legacyCommand("validate-bill-selection", ["bill", "selection", "validate"], [], [
23563
+ {
23564
+ name: "--quotationIds <csv>",
23565
+ description: "Approved quotation IDs for a supplier bill. Each selected budget item must be covered once."
23566
+ },
23567
+ {
23568
+ name: "--quotationId <id>",
23569
+ description: "Legacy syntax for selecting one approved quotation."
23570
+ }
23571
+ ]),
23572
+ legacyCommand("link-bill-quotations", ["bill", "quotation", "link"], ["billId"], [
23573
+ {
23574
+ name: "--payload <json>",
23575
+ description: "Explicit quotationAllocations for split source coverage on the bill's stored line amounts."
23576
+ },
23577
+ {
23578
+ name: "--quotationIds <csv>",
23579
+ description: "Approved quotation IDs to link across the bill's disjoint line coverage."
23341
23580
  }
23342
23581
  ]),
23343
- legacyCommand("validate-bill-selection", ["bill", "selection", "validate"]),
23344
23582
  legacyCommand("update-bill", ["bill", "update"]),
23345
23583
  legacyCommand("update-bill-payment-evidence", [
23346
23584
  "bill",
@@ -23408,6 +23646,14 @@ var registry2 = [
23408
23646
  "email-context",
23409
23647
  "get"
23410
23648
  ]),
23649
+ legacyCommand("preview-qbo-customer-invoice", [
23650
+ "customer-invoice",
23651
+ "preview-qbo"
23652
+ ]),
23653
+ legacyCommand("import-qbo-customer-invoice", [
23654
+ "customer-invoice",
23655
+ "import-qbo"
23656
+ ]),
23411
23657
  legacyCommand("create-customer-invoice", ["customer-invoice", "create"]),
23412
23658
  legacyCommand("discard-customer-invoice", ["customer-invoice", "discard"]),
23413
23659
  legacyCommand("delete-customer-invoice", ["customer-invoice", "delete"]),
@@ -23884,10 +24130,11 @@ Bills
23884
24130
  list-bills [--projectId] [--budgetId] [--status CSV] [--search <text>] [--isClaimable true|false] [--createdByIds <csv>] [--sortBy createdAt|amount|status] [--sortDir asc|desc] [--page] [--pageSize]
23885
24131
  list-claims same flags as list-bills; only reimbursable claims (ignores --isClaimable)
23886
24132
  isClaimable differentiates the shared bill/claim records: false = bill, true = claim.
23887
- Non-legacy supplier bills from 1 Jul 2026 00:00 SGT require approved quotation coverage linked to every selected line item, independent of the bill supplier, including already-paid bills.
24133
+ 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.
23888
24134
  create-bill-approval <billId> (also queues approval request emails)
23889
- create-bill --payload '<json>' [--draft-only] (bill.create; payload.isClaimable false = bill, true = claim; supplier must be APPROVED)
23890
- validate-bill-selection --payload '<json>' Require an APPROVED supplier, then verify approved quotation links for each budgetItemId; alreadyPaid never bypasses quotation checks.
24135
+ 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)
24136
+ 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.
24137
+ link-bill-quotations <billId> (--payload '<json>' | --quotationIds <csv>) Admin-only source-link repair; payload.quotationAllocations supports same-line splits.
23891
24138
  stage-bill-attachment <projectId> <filePath...> [--file <path>] [--files <csv>] Upload files before create-bill; returns attachment JSON for attachments/paymentProofAttachments
23892
24139
  cleanup-staged-bill-attachments <projectId> --keys <csv> Delete unattached staged bill/claim uploads.
23893
24140
  update-bill --payload '<json>' (bill.update; must include id)
@@ -23927,6 +24174,8 @@ Customer invoices
23927
24174
  No scope flag lists invoices globally. --projectId uses the project scope. A positional budgetId is a backward-compatible shortcut for --budgetIds.
23928
24175
  get-customer-invoice <batchId>
23929
24176
  get-customer-invoice-email-context <batchId> Contact, required CC, budget, and project context for composing an invoice email.
24177
+ preview-qbo-customer-invoice <budgetId> <qboInvoiceId> Preview linking an existing QBO transaction (numeric ID, not invoice number); works on accepted/closed estimates.
24178
+ import-qbo-customer-invoice <budgetId> <qboInvoiceId> --token <previewToken> Confirm the reviewed BB-only link; requires --allow-state-change --allow-financial-write in automation.
23930
24179
  create-customer-invoice --payload '<json>' budgetId + one split with label, percentage, and dueDate.
23931
24180
  discard-customer-invoice <batchId> Discard a reserved CREATING batch with no created QBO invoices.
23932
24181
  delete-customer-invoice <batchId> Delete QBO invoices and the local batch.
@@ -24371,7 +24620,7 @@ Waiting for approval\u2026
24371
24620
  }
24372
24621
  configureAccessToken(accessToken);
24373
24622
  }
24374
- if (!resolvedCommand.isLegacyAlias && cmd !== "create-bill" && resolvedCommand.command.effects.length > 0) {
24623
+ if (!resolvedCommand.isLegacyAlias && cmd !== "create-bill" && cmd !== "import-qbo-customer-invoice" && resolvedCommand.command.effects.length > 0) {
24375
24624
  await confirmCurrentCommand({
24376
24625
  action: resolvedCommand.command.path.join(" "),
24377
24626
  target: commandConfirmationTarget(resolvedCommand),
@@ -24739,7 +24988,11 @@ Waiting for approval\u2026
24739
24988
  }
24740
24989
  const raw = parseJsonFlag(String(payloadRaw), "--payload");
24741
24990
  const draftOnly = flags["draft-only"] === true ? true : parseOptionalBoolFlag(flags, "draft-only");
24742
- await createBillFromPayload(raw, draftOnly);
24991
+ const quotationExempt = flags["quotation-exempt"] === true;
24992
+ if (flags["quotation-exempt"] !== undefined && flags["quotation-exempt"] !== true) {
24993
+ throw new Error("--quotation-exempt does not take a value.");
24994
+ }
24995
+ await createBillFromPayload(raw, draftOnly, quotationExempt);
24743
24996
  break;
24744
24997
  }
24745
24998
  case "validate-bill-selection": {
@@ -24748,7 +25001,37 @@ Waiting for approval\u2026
24748
25001
  throw new Error("validate-bill-selection requires --payload '<json>'");
24749
25002
  }
24750
25003
  const raw = parseJsonFlag(String(payloadRaw), "--payload");
24751
- await validateBillSelectionFromPayload(raw);
25004
+ if (flags.quotationIds === true || flags.quotationId === true) {
25005
+ throw new Error("--quotationIds requires a CSV value and --quotationId requires an ID value.");
25006
+ }
25007
+ const quotationIds = parseCommaSeparatedIds(getFlag(flags, "quotationIds"));
25008
+ const quotationId = getFlag(flags, "quotationId")?.trim() || undefined;
25009
+ if (quotationId && quotationIds && (quotationIds.length !== 1 || quotationIds[0] !== quotationId)) {
25010
+ throw new Error("--quotationId conflicts with --quotationIds.");
25011
+ }
25012
+ if (quotationIds && new Set(quotationIds).size !== quotationIds.length) {
25013
+ throw new Error("--quotationIds must not contain duplicates.");
25014
+ }
25015
+ await validateBillSelectionFromPayload(raw, {
25016
+ ...quotationId ? { quotationId } : {},
25017
+ ...quotationIds ? { quotationIds } : {}
25018
+ });
25019
+ break;
25020
+ }
25021
+ case "link-bill-quotations": {
25022
+ const billId = positional[0];
25023
+ const payloadRaw = getFlag(flags, "payload");
25024
+ const quotationIds = parseCommaSeparatedIds(getFlag(flags, "quotationIds"));
25025
+ if (!billId || !payloadRaw && (!quotationIds || quotationIds.length === 0)) {
25026
+ throw new Error("link-bill-quotations requires <billId> and either --payload '<json>' or --quotationIds <csv>");
25027
+ }
25028
+ if (payloadRaw && quotationIds) {
25029
+ throw new Error("--payload cannot be combined with --quotationIds.");
25030
+ }
25031
+ if (quotationIds && new Set(quotationIds).size !== quotationIds.length) {
25032
+ throw new Error("--quotationIds must not contain duplicates.");
25033
+ }
25034
+ await linkBillQuotations(billId, payloadRaw ? parseJsonFlag(String(payloadRaw), "--payload") : { quotationIds });
24752
25035
  break;
24753
25036
  }
24754
25037
  case "update-bill": {
@@ -25123,6 +25406,26 @@ Waiting for approval\u2026
25123
25406
  await getCustomerInvoiceEmailContext(batchId);
25124
25407
  break;
25125
25408
  }
25409
+ case "preview-qbo-customer-invoice":
25410
+ case "import-qbo-customer-invoice": {
25411
+ const [budgetId, qboInvoiceId] = positional;
25412
+ if (!budgetId?.trim() || !qboInvoiceId || positional.length !== 2) {
25413
+ throw new Error(`${cmd} requires <budgetId> <qboInvoiceId>`);
25414
+ }
25415
+ if (!/^\d+$/.test(qboInvoiceId)) {
25416
+ throw new Error("qboInvoiceId must be the numeric QuickBooks transaction ID, not the invoice number");
25417
+ }
25418
+ if (cmd === "preview-qbo-customer-invoice") {
25419
+ await previewQboCustomerInvoice({ budgetId, qboInvoiceId });
25420
+ } else {
25421
+ const token = getFlag(flags, "token");
25422
+ if (!token || !/^[a-f0-9]{64}$/.test(token)) {
25423
+ throw new Error("import-qbo-customer-invoice requires --token <64-character preview token> from a reviewed preview-qbo result");
25424
+ }
25425
+ await importQboCustomerInvoice({ budgetId, qboInvoiceId, token });
25426
+ }
25427
+ break;
25428
+ }
25126
25429
  case "create-customer-invoice": {
25127
25430
  const payloadRaw = getFlag(flags, "payload");
25128
25431
  if (!payloadRaw) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@go-labs-sg/bb",
3
- "version": "2.31.0",
3
+ "version": "2.32.0",
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",
@@ -129,6 +129,7 @@ The following distinctions are especially important:
129
129
  - Project Hub setup and sync are asynchronous external writes. Read `bb project hub status <project-id>` first, obtain explicit approval, and request the mutation once instead of repeatedly queueing it. Setup readiness remains visible through `status`; commercial sync returns an outbox operation ID for Admin diagnostics.
130
130
  - Bill/claim status commands have separate role rules for `CHECKED`, `APPROVED`, and `PAID`.
131
131
  - `bb bill create --payload` uses `isClaimable=false`; `isClaimable=true` creates a claim. Claims intentionally permit some base-user flows that supplier bills do not.
132
+ - Create supplier bills from approved quotations by passing `quotationIds` for disjoint coverage, or `quotationAllocations` plus exact `lineAmounts` when quotations split a bill line. Use `--quotation-exempt` only for an existing server-validated exemption. Claims share budget allowance but never consume or link quotations, and reject quotation source fields.
132
133
  - For a draft-only bill or claim, use `bb bill create --draft-only --payload` (or `draftOnly: true` in the payload). This forces `DRAFT` for every role and requires only `--allow-state-change --allow-financial-write`. It sends no approval/email and performs no QuickBooks writes. Do not combine it with `alreadyPaid: true`. An older API without `bill.createDraft` rejects the command; never retry with ordinary creation to bypass that error.
133
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.
134
135
  - Staged attachments belong to the user in the database. Do not reuse, share, or manually construct staged keys.