@go-labs-sg/bb 2.31.1 → 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.
@@ -315,7 +327,7 @@ Bill, claim, and quotation mutations accept only suppliers whose approval status
315
327
  | Area | Legacy handler labels (reference only; non-exhaustive) |
316
328
  | --- | --- |
317
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`) |
318
- | **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` |
319
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`) |
320
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` |
321
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` |
@@ -391,5 +403,7 @@ relationships or allocations block approval and require review. Multi-line quota
391
403
  create/update payloads must include `lineAmounts` with `{ budgetItemId, amount }`
392
404
  for every selected line, summing to `amountWithoutGst`. The server revalidates
393
405
  existing quotation links, reviewed usage, and per-line allowance inside the
394
- approval transaction. No new database fields or relationship declarations are
395
- 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"],
@@ -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.
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}`,
@@ -22250,11 +22438,16 @@ async function updateBillFromPayload(raw) {
22250
22438
  const result = await api2.bill.update.mutate(input2);
22251
22439
  out(result);
22252
22440
  }
22253
- var validateBillSelectionFromPayload = async (raw) => {
22254
- const input2 = parseValidateBillSelectionPayload(raw);
22441
+ var validateBillSelectionFromPayload = async (raw, quotationFlags) => {
22442
+ const input2 = parseValidateBillSelectionPayload(raw, quotationFlags);
22255
22443
  const result = await api2.bill.validateSelection.mutate(input2);
22256
22444
  out(result);
22257
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
+ };
22258
22451
  var updateBillPaymentEvidenceFromPayload = async (raw) => {
22259
22452
  const input2 = parseUpdateBillPaymentEvidencePayload(raw);
22260
22453
  const result = await api2.bill.updatePaymentEvidence.mutate(input2);
@@ -23092,6 +23285,7 @@ var mutationPrefixes = [
23092
23285
  "delete-",
23093
23286
  "discard-",
23094
23287
  "import-",
23288
+ "link-",
23095
23289
  "mark-",
23096
23290
  "patch-",
23097
23291
  "reactivate-",
@@ -23180,6 +23374,7 @@ var externalWriteTargets = new Set([
23180
23374
  ]);
23181
23375
  var financialWriteTargets = new Set([
23182
23376
  "import-qbo-customer-invoice",
23377
+ "link-bill-quotations",
23183
23378
  "approve-bill",
23184
23379
  "approve-customer-invoice",
23185
23380
  "complete-project",
@@ -23358,9 +23553,32 @@ var registry2 = [
23358
23553
  {
23359
23554
  name: "--draft-only",
23360
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."
23361
23580
  }
23362
23581
  ]),
23363
- legacyCommand("validate-bill-selection", ["bill", "selection", "validate"]),
23364
23582
  legacyCommand("update-bill", ["bill", "update"]),
23365
23583
  legacyCommand("update-bill-payment-evidence", [
23366
23584
  "bill",
@@ -23912,10 +24130,11 @@ Bills
23912
24130
  list-bills [--projectId] [--budgetId] [--status CSV] [--search <text>] [--isClaimable true|false] [--createdByIds <csv>] [--sortBy createdAt|amount|status] [--sortDir asc|desc] [--page] [--pageSize]
23913
24131
  list-claims same flags as list-bills; only reimbursable claims (ignores --isClaimable)
23914
24132
  isClaimable differentiates the shared bill/claim records: false = bill, true = claim.
23915
- 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.
23916
24134
  create-bill-approval <billId> (also queues approval request emails)
23917
- create-bill --payload '<json>' [--draft-only] (bill.create; payload.isClaimable false = bill, true = claim; supplier must be APPROVED)
23918
- 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.
23919
24138
  stage-bill-attachment <projectId> <filePath...> [--file <path>] [--files <csv>] Upload files before create-bill; returns attachment JSON for attachments/paymentProofAttachments
23920
24139
  cleanup-staged-bill-attachments <projectId> --keys <csv> Delete unattached staged bill/claim uploads.
23921
24140
  update-bill --payload '<json>' (bill.update; must include id)
@@ -24769,7 +24988,11 @@ Waiting for approval\u2026
24769
24988
  }
24770
24989
  const raw = parseJsonFlag(String(payloadRaw), "--payload");
24771
24990
  const draftOnly = flags["draft-only"] === true ? true : parseOptionalBoolFlag(flags, "draft-only");
24772
- 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);
24773
24996
  break;
24774
24997
  }
24775
24998
  case "validate-bill-selection": {
@@ -24778,7 +25001,37 @@ Waiting for approval\u2026
24778
25001
  throw new Error("validate-bill-selection requires --payload '<json>'");
24779
25002
  }
24780
25003
  const raw = parseJsonFlag(String(payloadRaw), "--payload");
24781
- 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 });
24782
25035
  break;
24783
25036
  }
24784
25037
  case "update-bill": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@go-labs-sg/bb",
3
- "version": "2.31.1",
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.