@go-labs-sg/bb 2.31.1 → 2.33.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. Canonical allocation rows are the stored source of every bill-line quotation relationship. A required zero-value bill line needs one or more allocations with `amount: 0`, which record its approved source(s) without consuming quotation capacity. Every quotation must belong to the bill supplier and project and cover the allocated budget item. Legacy `quotationId` remains valid as single-quotation input and maps to the current selection. Supplier bills that qualify for an existing exemption must state that intent with `--quotation-exempt`. Claims share budget allowance but never consume or link quotations, so quotation source fields are rejected for claims.
236
+
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,6 @@ 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 canonical bill quotation
407
+ allocation rows; apply the updated database schema and deploy the API before
408
+ releasing the CLI. Unresolved historical overlaps 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,123 @@ 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 non-negative 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
+ if (cents(allocation.amount) === 0 && lineAmountById.get(allocation.budgetItemId) !== 0) {
20265
+ throw new Error(`${commandName} zero quotation allocations are only valid for zero-value bill lines.`);
20266
+ }
20267
+ }
20268
+ for (const [budgetItemId, allocated] of allocatedById) {
20269
+ if (allocated > (lineAmountById.get(budgetItemId) ?? 0)) {
20270
+ throw new Error(`${commandName} quotation allocations exceed the line amount for ${budgetItemId}.`);
20271
+ }
20272
+ }
20273
+ };
20157
20274
  function requiredString(value, fieldLabel) {
20158
20275
  if (value === undefined || value === null) {
20159
20276
  throw new Error(`${fieldLabel} is required.`);
@@ -20259,7 +20376,7 @@ var billAttachment = (x, i, field = "attachments") => {
20259
20376
  size: Number(a.size)
20260
20377
  };
20261
20378
  };
20262
- function parseCreateBillPayload(raw, draftOnlyFlag) {
20379
+ function parseCreateBillPayload(raw, draftOnlyFlag, quotationExempt = false, requireSupplierQuotation = true) {
20263
20380
  const o = requireObject(raw, "create-bill payload");
20264
20381
  const isClaimable = o.isClaimable;
20265
20382
  if (typeof isClaimable !== "boolean") {
@@ -20315,13 +20432,36 @@ function parseCreateBillPayload(raw, draftOnlyFlag) {
20315
20432
  if (!Number.isFinite(amount) || amount < 0) {
20316
20433
  throw new Error("create-bill payload.amount must be a number >= 0.");
20317
20434
  }
20435
+ const quotationReferences = parseQuotationReferences(o, "create-bill");
20436
+ const parsedBudgetItemIds = budgetItemIds.map((id, i) => requiredString(id, `create-bill payload.budgetItemIds[${i}]`));
20437
+ const lineAmounts = parseBillLineAmounts(o, "create-bill");
20438
+ validateExplicitBillAllocations({
20439
+ amount,
20440
+ budgetItemIds: parsedBudgetItemIds,
20441
+ commandName: "create-bill",
20442
+ lineAmounts,
20443
+ quotationAllocations: quotationReferences.quotationAllocations
20444
+ });
20445
+ if (quotationExempt && isClaimable) {
20446
+ throw new Error("--quotation-exempt is only valid for supplier bills.");
20447
+ }
20448
+ if (quotationExempt && (quotationReferences.quotationId || (quotationReferences.quotationIds?.length ?? 0) > 0 || (quotationReferences.quotationAllocations?.length ?? 0) > 0)) {
20449
+ throw new Error("--quotation-exempt cannot be combined with payload quotation IDs.");
20450
+ }
20451
+ if (isClaimable && (quotationReferences.quotationId || (quotationReferences.quotationIds?.length ?? 0) > 0 || (quotationReferences.quotationAllocations?.length ?? 0) > 0)) {
20452
+ throw new Error("Employee claims cannot include quotation source inputs.");
20453
+ }
20454
+ if (requireSupplierQuotation && !quotationExempt && !isClaimable && !quotationReferences.quotationId && (quotationReferences.quotationIds?.length ?? 0) === 0 && (quotationReferences.quotationAllocations?.length ?? 0) === 0) {
20455
+ throw new Error("create-bill supplier bills require payload.quotationAllocations, payload.quotationIds, or legacy payload.quotationId.");
20456
+ }
20318
20457
  return {
20319
20458
  budgetId: requiredString(o.budgetId, "create-bill payload.budgetId"),
20320
20459
  projectId: requiredString(o.projectId, "create-bill payload.projectId"),
20321
20460
  supplierId: requiredString(o.supplierId, "create-bill payload.supplierId"),
20322
- quotationId: optionalString(o.quotationId),
20461
+ ...quotationReferences,
20323
20462
  amount,
20324
- budgetItemIds: budgetItemIds.map((id, i) => requiredString(id, `create-bill payload.budgetItemIds[${i}]`)),
20463
+ budgetItemIds: parsedBudgetItemIds,
20464
+ ...lineAmounts ? { lineAmounts } : {},
20325
20465
  comment: optionalString(o.comment),
20326
20466
  isClaimable,
20327
20467
  attachments,
@@ -20339,7 +20479,7 @@ function parseCreateBillPayload(raw, draftOnlyFlag) {
20339
20479
  }
20340
20480
  function parseUpdateBillPayload(raw) {
20341
20481
  const o = requireObject(raw, "update-bill payload");
20342
- const { draftOnly, ...input2 } = parseCreateBillPayload(raw);
20482
+ const { draftOnly, ...input2 } = parseCreateBillPayload(raw, undefined, false, false);
20343
20483
  if (draftOnly !== undefined) {
20344
20484
  throw new Error("draftOnly is only supported when creating a bill.");
20345
20485
  }
@@ -20348,9 +20488,60 @@ function parseUpdateBillPayload(raw) {
20348
20488
  id: requiredString(o.id, "update-bill payload.id")
20349
20489
  };
20350
20490
  }
20351
- function parseValidateBillSelectionPayload(raw) {
20352
- requireObject(raw, "validate-bill-selection payload");
20353
- return raw;
20491
+ function parseValidateBillSelectionPayload(raw, quotationFlags = {}) {
20492
+ const input2 = requireObject(raw, "validate-bill-selection payload");
20493
+ const payloadReferences = parseQuotationReferences(input2, "validate-bill-selection");
20494
+ const flagReferences = parseQuotationReferences({ ...quotationFlags }, "validate-bill-selection flags");
20495
+ const payloadIds = payloadReferences.quotationIds ?? (payloadReferences.quotationId ? [payloadReferences.quotationId] : undefined);
20496
+ const flagIds = flagReferences.quotationIds ?? (flagReferences.quotationId ? [flagReferences.quotationId] : undefined);
20497
+ if (payloadIds && flagIds && payloadIds.join("\x00") !== flagIds.join("\x00")) {
20498
+ throw new Error("validate-bill-selection quotation flags conflict with the payload quotation IDs.");
20499
+ }
20500
+ const isClaimable = input2.isClaimable;
20501
+ if (isClaimable !== undefined && typeof isClaimable !== "boolean") {
20502
+ throw new Error("validate-bill-selection payload.isClaimable must be boolean.");
20503
+ }
20504
+ if (isClaimable === true && (payloadReferences.quotationId || (payloadReferences.quotationIds?.length ?? 0) > 0 || (payloadReferences.quotationAllocations?.length ?? 0) > 0 || !!flagReferences.quotationId || (flagReferences.quotationIds?.length ?? 0) > 0)) {
20505
+ throw new Error("Employee claims cannot include quotation source inputs.");
20506
+ }
20507
+ const lineAmounts = parseBillLineAmounts(input2, "validate-bill-selection");
20508
+ const budgetItemIds = Array.isArray(input2.budgetItemIds) ? input2.budgetItemIds.map((id, index) => requiredString(id, `validate-bill-selection payload.budgetItemIds[${index}]`)) : [];
20509
+ const amount = input2.amount === undefined || input2.amount === null ? undefined : Number(input2.amount);
20510
+ if (lineAmounts || payloadReferences.quotationAllocations) {
20511
+ if (amount === undefined || !Number.isFinite(amount) || budgetItemIds.length === 0) {
20512
+ throw new Error("validate-bill-selection explicit allocations require payload.amount and payload.budgetItemIds.");
20513
+ }
20514
+ validateExplicitBillAllocations({
20515
+ amount,
20516
+ budgetItemIds,
20517
+ commandName: "validate-bill-selection",
20518
+ lineAmounts,
20519
+ quotationAllocations: payloadReferences.quotationAllocations
20520
+ });
20521
+ }
20522
+ return {
20523
+ ...input2,
20524
+ ...amount !== undefined ? { amount } : {},
20525
+ ...isClaimable !== undefined ? { isClaimable } : {},
20526
+ ...lineAmounts ? { lineAmounts } : {},
20527
+ ...payloadReferences,
20528
+ ...flagReferences
20529
+ };
20530
+ }
20531
+ function parseLinkBillQuotationsPayload(billId, raw) {
20532
+ const input2 = requireObject(raw, "link-bill-quotations payload");
20533
+ const references = parseQuotationReferences(input2, "link-bill-quotations");
20534
+ if (references.quotationId) {
20535
+ throw new Error("link-bill-quotations payload uses quotationIds or quotationAllocations; legacy quotationId is not supported.");
20536
+ }
20537
+ if ((references.quotationIds?.length ?? 0) === 0 && (references.quotationAllocations?.length ?? 0) === 0) {
20538
+ throw new Error("link-bill-quotations payload requires quotationIds or quotationAllocations.");
20539
+ }
20540
+ return {
20541
+ billId: requiredString(billId, "billId"),
20542
+ ...references.quotationIds ? { quotationIds: references.quotationIds } : {},
20543
+ ...references.quotationAllocations ? { quotationAllocations: references.quotationAllocations } : {}
20544
+ };
20354
20545
  }
20355
20546
  function parseUpdateBillPaymentEvidencePayload(raw) {
20356
20547
  requireObject(raw, "update-bill-payment-evidence payload");
@@ -22048,8 +22239,8 @@ async function deleteItemCategoriesByIds(ids) {
22048
22239
  const result = await api2.itemCategory.deleteItemCategories.mutate({ ids });
22049
22240
  out(result);
22050
22241
  }
22051
- async function createBillFromPayload(raw, draftOnlyFlag) {
22052
- const input2 = parseCreateBillPayload(raw, draftOnlyFlag);
22242
+ async function createBillFromPayload(raw, draftOnlyFlag, quotationExempt = false) {
22243
+ const input2 = parseCreateBillPayload(raw, draftOnlyFlag, quotationExempt);
22053
22244
  await confirmCurrentCommand({
22054
22245
  action: input2.draftOnly ? "bill create (draft only)" : "bill create",
22055
22246
  target: `project ${input2.projectId}, supplier ${input2.supplierId}`,
@@ -22250,11 +22441,16 @@ async function updateBillFromPayload(raw) {
22250
22441
  const result = await api2.bill.update.mutate(input2);
22251
22442
  out(result);
22252
22443
  }
22253
- var validateBillSelectionFromPayload = async (raw) => {
22254
- const input2 = parseValidateBillSelectionPayload(raw);
22444
+ var validateBillSelectionFromPayload = async (raw, quotationFlags) => {
22445
+ const input2 = parseValidateBillSelectionPayload(raw, quotationFlags);
22255
22446
  const result = await api2.bill.validateSelection.mutate(input2);
22256
22447
  out(result);
22257
22448
  };
22449
+ var linkBillQuotations = async (billId, raw) => {
22450
+ const input2 = parseLinkBillQuotationsPayload(billId, raw);
22451
+ const result = await api2.bill.linkQuotations.mutate(input2);
22452
+ out(result);
22453
+ };
22258
22454
  var updateBillPaymentEvidenceFromPayload = async (raw) => {
22259
22455
  const input2 = parseUpdateBillPaymentEvidencePayload(raw);
22260
22456
  const result = await api2.bill.updatePaymentEvidence.mutate(input2);
@@ -23092,6 +23288,7 @@ var mutationPrefixes = [
23092
23288
  "delete-",
23093
23289
  "discard-",
23094
23290
  "import-",
23291
+ "link-",
23095
23292
  "mark-",
23096
23293
  "patch-",
23097
23294
  "reactivate-",
@@ -23180,6 +23377,7 @@ var externalWriteTargets = new Set([
23180
23377
  ]);
23181
23378
  var financialWriteTargets = new Set([
23182
23379
  "import-qbo-customer-invoice",
23380
+ "link-bill-quotations",
23183
23381
  "approve-bill",
23184
23382
  "approve-customer-invoice",
23185
23383
  "complete-project",
@@ -23358,9 +23556,32 @@ var registry2 = [
23358
23556
  {
23359
23557
  name: "--draft-only",
23360
23558
  description: "Create a draft for every role; no approval, email, or QuickBooks writes. Conflicts with alreadyPaid: true."
23559
+ },
23560
+ {
23561
+ name: "--quotation-exempt",
23562
+ description: "Explicitly request an eligible server-validated supplier-bill quotation exemption. Cannot be combined with quotation IDs."
23563
+ }
23564
+ ]),
23565
+ legacyCommand("validate-bill-selection", ["bill", "selection", "validate"], [], [
23566
+ {
23567
+ name: "--quotationIds <csv>",
23568
+ description: "Approved quotation IDs for a supplier bill. Each selected budget item must be covered once."
23569
+ },
23570
+ {
23571
+ name: "--quotationId <id>",
23572
+ description: "Legacy syntax for selecting one approved quotation."
23573
+ }
23574
+ ]),
23575
+ legacyCommand("link-bill-quotations", ["bill", "quotation", "link"], ["billId"], [
23576
+ {
23577
+ name: "--payload <json>",
23578
+ description: "Explicit quotationAllocations for split source coverage on the bill's stored line amounts."
23579
+ },
23580
+ {
23581
+ name: "--quotationIds <csv>",
23582
+ description: "Approved quotation IDs to link across the bill's disjoint line coverage."
23361
23583
  }
23362
23584
  ]),
23363
- legacyCommand("validate-bill-selection", ["bill", "selection", "validate"]),
23364
23585
  legacyCommand("update-bill", ["bill", "update"]),
23365
23586
  legacyCommand("update-bill-payment-evidence", [
23366
23587
  "bill",
@@ -23912,10 +24133,11 @@ Bills
23912
24133
  list-bills [--projectId] [--budgetId] [--status CSV] [--search <text>] [--isClaimable true|false] [--createdByIds <csv>] [--sortBy createdAt|amount|status] [--sortDir asc|desc] [--page] [--pageSize]
23913
24134
  list-claims same flags as list-bills; only reimbursable claims (ignores --isClaimable)
23914
24135
  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.
24136
+ 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
24137
  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.
24138
+ create-bill --payload '<json>' [--draft-only] [--quotation-exempt] (bill.create; supplier bills use payload.quotationAllocations for split sources or quotationIds for disjoint coverage; explicit allocations require lineAmounts; claims reject quotation sources)
24139
+ 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.
24140
+ link-bill-quotations <billId> (--payload '<json>' | --quotationIds <csv>) Admin-only source-link repair; payload.quotationAllocations supports same-line splits.
23919
24141
  stage-bill-attachment <projectId> <filePath...> [--file <path>] [--files <csv>] Upload files before create-bill; returns attachment JSON for attachments/paymentProofAttachments
23920
24142
  cleanup-staged-bill-attachments <projectId> --keys <csv> Delete unattached staged bill/claim uploads.
23921
24143
  update-bill --payload '<json>' (bill.update; must include id)
@@ -24769,7 +24991,11 @@ Waiting for approval\u2026
24769
24991
  }
24770
24992
  const raw = parseJsonFlag(String(payloadRaw), "--payload");
24771
24993
  const draftOnly = flags["draft-only"] === true ? true : parseOptionalBoolFlag(flags, "draft-only");
24772
- await createBillFromPayload(raw, draftOnly);
24994
+ const quotationExempt = flags["quotation-exempt"] === true;
24995
+ if (flags["quotation-exempt"] !== undefined && flags["quotation-exempt"] !== true) {
24996
+ throw new Error("--quotation-exempt does not take a value.");
24997
+ }
24998
+ await createBillFromPayload(raw, draftOnly, quotationExempt);
24773
24999
  break;
24774
25000
  }
24775
25001
  case "validate-bill-selection": {
@@ -24778,7 +25004,37 @@ Waiting for approval\u2026
24778
25004
  throw new Error("validate-bill-selection requires --payload '<json>'");
24779
25005
  }
24780
25006
  const raw = parseJsonFlag(String(payloadRaw), "--payload");
24781
- await validateBillSelectionFromPayload(raw);
25007
+ if (flags.quotationIds === true || flags.quotationId === true) {
25008
+ throw new Error("--quotationIds requires a CSV value and --quotationId requires an ID value.");
25009
+ }
25010
+ const quotationIds = parseCommaSeparatedIds(getFlag(flags, "quotationIds"));
25011
+ const quotationId = getFlag(flags, "quotationId")?.trim() || undefined;
25012
+ if (quotationId && quotationIds && (quotationIds.length !== 1 || quotationIds[0] !== quotationId)) {
25013
+ throw new Error("--quotationId conflicts with --quotationIds.");
25014
+ }
25015
+ if (quotationIds && new Set(quotationIds).size !== quotationIds.length) {
25016
+ throw new Error("--quotationIds must not contain duplicates.");
25017
+ }
25018
+ await validateBillSelectionFromPayload(raw, {
25019
+ ...quotationId ? { quotationId } : {},
25020
+ ...quotationIds ? { quotationIds } : {}
25021
+ });
25022
+ break;
25023
+ }
25024
+ case "link-bill-quotations": {
25025
+ const billId = positional[0];
25026
+ const payloadRaw = getFlag(flags, "payload");
25027
+ const quotationIds = parseCommaSeparatedIds(getFlag(flags, "quotationIds"));
25028
+ if (!billId || !payloadRaw && (!quotationIds || quotationIds.length === 0)) {
25029
+ throw new Error("link-bill-quotations requires <billId> and either --payload '<json>' or --quotationIds <csv>");
25030
+ }
25031
+ if (payloadRaw && quotationIds) {
25032
+ throw new Error("--payload cannot be combined with --quotationIds.");
25033
+ }
25034
+ if (quotationIds && new Set(quotationIds).size !== quotationIds.length) {
25035
+ throw new Error("--quotationIds must not contain duplicates.");
25036
+ }
25037
+ await linkBillQuotations(billId, payloadRaw ? parseJsonFlag(String(payloadRaw), "--payload") : { quotationIds });
24782
25038
  break;
24783
25039
  }
24784
25040
  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.33.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. A required zero-value bill line needs one or more `quotationAllocations` entries with `amount: 0` so its approved source(s) are retained without consuming quotation capacity. 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.