@go-labs-sg/bb 1.12.0 → 1.14.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
@@ -56,15 +56,16 @@ Global options and flags use `--key=value` or `--key value` (see `bb help`).
56
56
 
57
57
  **Budget status automation:** Setting a budget to `ESTIMATE_ACCEPTED` requires a confirmed win-proof attachment. If the parent project is `PITCH` or `LOST`, the API now marks it `WON` automatically and, when the `asana-tasks` feature flag is enabled, creates the Asana project/section/tasks automatically. When rejecting the only accepted/closed budget on a commercial project, pass `--projectStatusOnCommercialRejection PITCH|LOST`.
58
58
 
59
- **Mutations with `--payload`:** Commands such as `create-budget`, `create-bill`, `update-supplier`, etc. take a single JSON object (`--payload '<json>'`) matching the corresponding tRPC procedure input. Use ISO strings for date/datetime fields; the CLI coerces them where needed. The API still validates the full shape. For **`update-project`**, the project window is `dateRange.from` and `dateRange.to` (optional end); there are no separate event-date fields on the project payload. **`create-budget` / `update-budget`** do not accept `asanaTaskId`; configure the deal card on the project (`update-project` / project settings).
59
+ **Mutations with `--payload`:** Commands such as `create-budget`, `create-bill`, `update-supplier`, etc. take a single JSON object (`--payload '<json>'`) matching the corresponding tRPC procedure input. Use ISO strings for date/datetime fields; the CLI coerces them where needed. The API still validates the full shape. Plain `description` fields for item create/update are converted to `descriptionRichText`; pass `descriptionRichText` directly when formatted Tiptap JSON is required. For **`update-project`**, the project window is `dateRange.from` and `dateRange.to` (optional end); there are no separate event-date fields on the project payload. **`create-budget` / `update-budget`** do not accept `asanaTaskId`; configure the deal card on the project (`update-project` / project settings).
60
60
 
61
61
  ### Command overview
62
62
 
63
63
  | Area | Commands (non-exhaustive) |
64
64
  | --- | --- |
65
65
  | **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`, `update-budget-status` (`ESTIMATE_ACCEPTED` requires win proof, auto-marks `PITCH` or `LOST` projects `WON`, and auto-creates Asana project/section/tasks when enabled; `--projectStatusOnCommercialRejection PITCH\|LOST` is required when rejecting the only accepted/closed budget on a commercial project), `mark-budget-won` (`<budgetId>` + proof file path; `PITCH` or `LOST` projects become `WON` automatically), `create-budget` / `update-budget` (`--payload`), `delete-budget`, `create-budget-approval` (also sends approval request emails), `add-budget-items`, `update-budget-item`, `remove-budget-item`, `reorder-budget-items`, `update-budget-item-supplier`, `create-budget-category`, `update-budget-category`, `delete-budget-category`, `update-budget-commission`, `update-budget-discount` (`--payload` where noted), `upload-budget-attachment` (`<budgetId>` + local file path; uses `attachment.requestBudgetAttachmentUpload` + PUT + `attachment.confirmBudgetAttachment`) |
66
- | **Bills / claims** | `list-bills` (`--isClaimable false` for bills, `--isClaimable true` for claims, omit for both), `list-claims` (claims only), `create-bill` (`--payload`; set `isClaimable=false` for a bill or `isClaimable=true` for a claim), `update-bill` (`--payload`), `delete-bill`, `create-bill-approval` (also sends approval request emails), `update-bill-status`, `patch-bill-payment` (PAID bills: `--paymentTrackingUrl`, `--paymentReference`, `--quickbooksBillId`, `--paymentDate` ISO; clear with `--clearPaymentTrackingUrl` / `--clearPaymentReference` / `--clearQuickbooksBillId` / `--clearPaymentDate` `true`), `patch-bill-invoice-number`, `get-bill-attachments`, `upload-bill-attachment` (`<billId>` + local path), `get-bill-details` |
67
- | **Approvals** | `list-approvals` / `get-pending-approvals`, `approve-bill` / `reject-bill` (send reply email), `approve-budget` / `reject-budget` (send reply email), `approve-supplier` / `reject-supplier` (send reply email) |
66
+ | **Bills / claims** | `list-bills` (`--isClaimable false` for bills, `--isClaimable true` for claims, omit for both), `list-claims` (claims only), `create-bill` (`--payload`; set `isClaimable=false` for a bill or `isClaimable=true` for a claim; non-legacy supplier bills from 1 Jul 2026 00:00 SGT require approved quotation coverage), `update-bill` (`--payload`), `delete-bill`, `create-bill-approval` (also sends approval request emails), `update-bill-status`, `patch-bill-payment` (PAID bills: `--paymentTrackingUrl`, `--paymentReference`, `--quickbooksBillId`, `--paymentDate` ISO; clear with `--clearPaymentTrackingUrl` / `--clearPaymentReference` / `--clearQuickbooksBillId` / `--clearPaymentDate` `true`), `patch-bill-invoice-number`, `get-bill-attachments`, `upload-bill-attachment` (`<billId>` + local path), `get-bill-details` |
67
+ | **Quotations** | `list-quotations`, `get-quotation`, `upload-quotation-attachment` (`<projectId>` + local PDF path; returns attachment JSON for payload use), `create-quotation` (`--payload` for `quotation.createDraft`), `submit-quotation`, `approve-quotation`, `reject-quotation`, `download-quotation-pdf` (`original`, `staff`, or `final`) |
68
+ | **Approvals** | `list-approvals` / `get-pending-approvals` (`--type budget\|supplier\|bill\|quotation\|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` |
68
69
  | **Companies & projects** | `list-companies`, `get-company`, `create-company`, `update-company` (`--payload`), `delete-company`, `list-projects`, `get-project`, `create-project` (required: `--name`, `--companyId`, `--contactPersonId`, `--insideSalesId`, `--businessDevelopmentId`, `--venue`, `--asanaTaskId`, `--slackChannelId`, `--slackChannelUrl`, `--slackChannelName`, `--startDate` as ISO datetime for project/window start; optional `--pax`; optional `--endDate`; optional `--description`; optional `--requestQboAccountantNotification false` to skip QBO accountant emails), `update-project` (`--payload` with `dateRange.from` / `dateRange.to` for the project window; optional `requestQboAccountantNotification` in JSON), `delete-project`, `update-project-status` (`<id>` `<status>`: `PITCH` \| `WON` \| `COMPLETED` \| `LOST`; for `PITCH` → `WON` also pass `--projectManagerId` or `--projectManagerName`; when marking `WON` without an accepted/closed budget or proof, pass `--wonOverrideReason`) |
69
70
  | **Contacts** | `list-contacts`, `create-contact-person` (`--payload`), `update-contact-person` (`--payload`) |
70
71
  | **Suppliers & items** | `list-suppliers` (defaults to active suppliers, `--perPage 10`, sorted by `createdAt` desc; supports `--name`, `--sortBy` for scalar supplier fields, `--sortDir`, `--createdBy`, `--gstRegistered`, `--status`, `--supplierTags`, `--active false` for archived suppliers), `create-supplier` (`--payload`; when supplier status is `PENDING_APPROVAL`, also runs `supplier.createSupplierApproval` and `email.sendSupplierApprovalRequestEmail`), `update-supplier` (`--payload`; when supplier status is `PENDING_APPROVAL`, also runs `supplier.createSupplierApproval` and `email.sendSupplierApprovalRequestEmail`), `delete-suppliers` (`--ids` CSV; admin; archives/deactivates related items), `reactivate-suppliers` (`--ids` CSV; admin; reactivates related items), `create-certification` / `create-payment-method` / `create-supplier-role` / `create-supplier-tag` (`--name`), `get-supplier-details` (includes `supplierApprovalSummary` for pending approvers, superseded approvers, and the actual responder/respondedAt metadata), `get-supplier-analytics`, `list-items`, `create-item` (`--payload`), `update-item` (`--payload`), `delete-item`, `get-item`, `list-item-categories`, `create-item-category`, `update-item-category`, `delete-item-categories` (`--ids` CSV; admin) |
package/dist/commands.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { basename } from "node:path";
3
4
  import { contentType } from "mime-types";
@@ -5,9 +6,11 @@ import { api } from "./api-client.js";
5
6
  import { BudgetRole, Deals, ExtendedApprovalStatus, ExtendedApprovalType, ExtendedBudgetStatus, TimeFrame, } from "./filter-enums.js";
6
7
  import { billStatusesForApi, } from "./parse-cli-enums.js";
7
8
  import { parseBudgetDiscountPayload, parseCompanyUpdatePayload, parseContactCreatePayload, parseContactUpdatePayload, parseCreateBillPayload, parseCreateBudgetPayload, parseItemCreatePayload, parseItemUpdatePayload, parseSupplierCreatePayload, parseSupplierUpdatePayload, parseUpdateBillPayload, parseUpdateBudgetCommissionPayload, parseUpdateBudgetPayload, parseUpdateProjectPayload, } from "./parse-mutation-payload.js";
9
+ import { createRichTextFromPlainText } from "./rich-text.js";
8
10
  const BUDGET = "BUDGET";
9
11
  const BILL = "BILL";
10
12
  const SUPPLIER = "SUPPLIER";
13
+ const QUOTATION = "QUOTATION";
11
14
  function isPendingRequest(n) {
12
15
  return n.notificationType === "request" && n.status === "PENDING_APPROVAL";
13
16
  }
@@ -198,6 +201,10 @@ export async function listApprovals(opts) {
198
201
  id = n.supplier.id;
199
202
  name = n.supplier.name;
200
203
  }
204
+ else if (n.type === QUOTATION && n.supplierQuotation) {
205
+ id = n.supplierQuotation.id;
206
+ name = `Quotation ${n.supplierQuotation.supplier.name}`;
207
+ }
201
208
  else {
202
209
  id = n.id;
203
210
  name = "Unknown";
@@ -322,6 +329,33 @@ export async function uploadBillAttachmentFromPath(billId, filePath) {
322
329
  });
323
330
  out(confirmed);
324
331
  }
332
+ export async function uploadQuotationAttachmentFromPath(projectId, filePath) {
333
+ const buf = await readFile(filePath);
334
+ const fileName = basename(filePath);
335
+ const ext = fileName
336
+ .split(".")
337
+ .pop()
338
+ ?.toLowerCase()
339
+ .replace(/[^a-z0-9]/g, "");
340
+ const key = `quotations/${projectId}/${randomUUID()}.${ext || "pdf"}`;
341
+ const uploadUrl = await api.attachment.getPresignedUrlToUpload.mutate({
342
+ key,
343
+ });
344
+ const res = await fetch(uploadUrl, {
345
+ method: "PUT",
346
+ body: buf,
347
+ headers: { "Content-Type": contentTypeHeaderForFileName(fileName) },
348
+ });
349
+ if (!res.ok) {
350
+ throw new Error(`S3 upload failed: HTTP ${res.status} ${(await res.text()).slice(0, 500)}`);
351
+ }
352
+ out({
353
+ id: randomUUID(),
354
+ name: fileName,
355
+ key,
356
+ size: buf.byteLength,
357
+ });
358
+ }
325
359
  export async function uploadBudgetAttachmentFromPath(budgetId, filePath) {
326
360
  const buf = await readFile(filePath);
327
361
  const fileName = basename(filePath);
@@ -396,7 +430,17 @@ export async function addBudgetItems(opts) {
396
430
  out(result);
397
431
  }
398
432
  export async function updateBudgetItem(input) {
399
- const result = await api.budgetItem.updateBudgetItem.mutate(input);
433
+ const result = await api.budgetItem.updateBudgetItem.mutate({
434
+ ...input,
435
+ ...(input.description !== undefined &&
436
+ input.descriptionRichText === undefined && {
437
+ descriptionRichText: createRichTextFromPlainText(input.description),
438
+ }),
439
+ ...(input.note !== undefined &&
440
+ input.noteRichText === undefined && {
441
+ noteRichText: createRichTextFromPlainText(input.note),
442
+ }),
443
+ });
400
444
  out(result);
401
445
  }
402
446
  export async function removeBudgetItem(budgetItemId) {
@@ -557,6 +601,29 @@ export async function createBillFromPayload(raw) {
557
601
  const result = await api.bill.create.mutate(input);
558
602
  out(result);
559
603
  }
604
+ export async function listQuotations(opts) {
605
+ const result = await api.quotation.list.query(opts);
606
+ out(result);
607
+ }
608
+ export async function getQuotationDetails(id) {
609
+ const result = await api.quotation.getById.query({ id });
610
+ out(result);
611
+ }
612
+ export async function createQuotationFromPayload(raw) {
613
+ const result = await api.quotation.createDraft.mutate(raw);
614
+ out(result);
615
+ }
616
+ export async function submitQuotation(id) {
617
+ const result = await api.quotation.submitForApproval.mutate({ id });
618
+ out(result);
619
+ }
620
+ export async function downloadQuotationPdf(id, fileRole) {
621
+ const result = await api.quotation.getSignedDownloadUrl.query({
622
+ id,
623
+ fileRole,
624
+ });
625
+ out(result);
626
+ }
560
627
  export async function updateBillFromPayload(raw) {
561
628
  const input = parseUpdateBillPayload(raw);
562
629
  const result = await api.bill.update.mutate(input);
@@ -913,6 +980,37 @@ export async function rejectBill(billId, reason) {
913
980
  email,
914
981
  });
915
982
  }
983
+ export async function approveQuotation(quotationId) {
984
+ const notifications = await api.budget.getNotifications.query();
985
+ const pending = notifications.filter((n) => isPendingRequest(n) &&
986
+ n.type === QUOTATION &&
987
+ (n.supplierQuotationId === quotationId ||
988
+ n.supplierQuotation?.id === quotationId));
989
+ const approval = pending[0];
990
+ if (!approval) {
991
+ throw new Error("No pending quotation approval found.");
992
+ }
993
+ const result = await api.quotation.approve.mutate({
994
+ quotationApprovalId: approval.id,
995
+ });
996
+ out(result);
997
+ }
998
+ export async function rejectQuotation(quotationId, reason) {
999
+ const notifications = await api.budget.getNotifications.query();
1000
+ const pending = notifications.filter((n) => isPendingRequest(n) &&
1001
+ n.type === QUOTATION &&
1002
+ (n.supplierQuotationId === quotationId ||
1003
+ n.supplierQuotation?.id === quotationId));
1004
+ const approval = pending[0];
1005
+ if (!approval) {
1006
+ throw new Error("No pending quotation approval found.");
1007
+ }
1008
+ const result = await api.quotation.reject.mutate({
1009
+ quotationApprovalId: approval.id,
1010
+ rejectionReason: reason,
1011
+ });
1012
+ out(result);
1013
+ }
916
1014
  // --- Companies & projects ---
917
1015
  export async function listCompanies(opts) {
918
1016
  const result = await api.company.getCompanies.query({
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import "./load-env.js";
3
3
  import { requireApiKey } from "./api-client.js";
4
4
  import { consumeCliQuietFlags, logCliAction, sanitizeFlagsForTrace, setCliQuiet, shouldLogCliActions, } from "./cli-trace.js";
5
- import { addBudgetItems, approveBill, approveBudget, approveSupplier, createBillApproval, createBillFromPayload, createBudgetApproval, createBudgetCategory, createBudgetFromPayload, createCompany, createContactPersonFromPayload, createEstimate, createItemCategory, createItemFromPayload, createProject, createSupplierCertification, createSupplierFromPayload, createSupplierPaymentMethod, createSupplierRoleOption, createSupplierTagOption, deleteBillById, deleteBudgetById, deleteBudgetCategory, deleteCompanyById, deleteItemById, deleteItemCategoriesByIds, deleteProjectById, deleteSuppliersByIds, getApprovedBudgets, getBillAttachments, getBillDetails, getBudget, getBudgetCategories, getBudgetCategoryBenchmarks, getBudgetDetails, getBudgetItemsOnly, getBudgetVersions, getCompany, getDashboard, getErrorMetrics, getEstimatePerformance, getFinancialOverview, getItem, getItemPricingHistory, getMonthlyMetrics, getProject, getRecentErrors, getSupplierAnalytics, getSupplierDetails, getSupplierPricingHistory, getSystemOverview, getUserPerformance, listApprovals, listBills, listBudgets, listCompanies, listContacts, listItemCategories, listItems, listProjects, listSuppliers, listUsers, markBudgetWonWithProof, patchBillInvoiceNumber, patchBillPayment, reactivateSuppliersByIds, rejectBill, rejectBudget, rejectSupplier, removeBudgetItem, reorderBudgetItemsCli, updateBillFromPayload, updateBillStatus, updateBudgetCategory, updateBudgetCommissionFromPayload, updateBudgetDiscountFromPayload, updateBudgetFromPayload, updateBudgetItem, updateBudgetItemSupplierCli, updateBudgetStatus, updateCompanyFromPayload, updateContactPersonFromPayload, updateItemCategory, updateItemFromPayload, updateProjectFromPayload, updateProjectStatus, updateSupplierFromPayload, uploadBillAttachmentFromPath, uploadBudgetAttachmentFromPath, } from "./commands.js";
5
+ import { addBudgetItems, approveBill, approveBudget, approveQuotation, approveSupplier, createBillApproval, createBillFromPayload, createBudgetApproval, createBudgetCategory, createBudgetFromPayload, createCompany, createContactPersonFromPayload, createEstimate, createItemCategory, createItemFromPayload, createProject, createQuotationFromPayload, createSupplierCertification, createSupplierFromPayload, createSupplierPaymentMethod, createSupplierRoleOption, createSupplierTagOption, deleteBillById, deleteBudgetById, deleteBudgetCategory, deleteCompanyById, deleteItemById, deleteItemCategoriesByIds, deleteProjectById, deleteSuppliersByIds, downloadQuotationPdf, getApprovedBudgets, getBillAttachments, getBillDetails, getBudget, getBudgetCategories, getBudgetCategoryBenchmarks, getBudgetDetails, getBudgetItemsOnly, getBudgetVersions, getCompany, getDashboard, getErrorMetrics, getEstimatePerformance, getFinancialOverview, getItem, getItemPricingHistory, getMonthlyMetrics, getProject, getQuotationDetails, getRecentErrors, getSupplierAnalytics, getSupplierDetails, getSupplierPricingHistory, getSystemOverview, getUserPerformance, listApprovals, listBills, listBudgets, listCompanies, listContacts, listItemCategories, listItems, listProjects, listQuotations, listSuppliers, listUsers, markBudgetWonWithProof, patchBillInvoiceNumber, patchBillPayment, reactivateSuppliersByIds, rejectBill, rejectBudget, rejectQuotation, rejectSupplier, removeBudgetItem, reorderBudgetItemsCli, submitQuotation, updateBillFromPayload, updateBillStatus, updateBudgetCategory, updateBudgetCommissionFromPayload, updateBudgetDiscountFromPayload, updateBudgetFromPayload, updateBudgetItem, updateBudgetItemSupplierCli, updateBudgetStatus, updateCompanyFromPayload, updateContactPersonFromPayload, updateItemCategory, updateItemFromPayload, updateProjectFromPayload, updateProjectStatus, updateSupplierFromPayload, uploadBillAttachmentFromPath, uploadBudgetAttachmentFromPath, uploadQuotationAttachmentFromPath, } from "./commands.js";
6
6
  import { getFlag, parseArgs } from "./parse-args.js";
7
7
  import { billStatusesForUpdateHelp, budgetStatusesForHelp, isBudgetStatusUpdate, parseApprovalTypeFlag, parseBillStatusForUpdate, parseCommaSeparatedBillStatuses, parseCommaSeparatedBudgetStatuses, parseCommaSeparatedIds, parseCommaSeparatedSupplierStatuses, parseOptionalBillListSortBy, parseOptionalBillListSortDir, parseOptionalDashboardRole, parseOptionalDeals, parseOptionalErrorSeverity, parseOptionalErrorStatus, parseOptionalExtendedProjectStatus, parseOptionalFinancialRole, parseOptionalSupplierAnalyticsTimeFrame, parseOptionalTimeFrame, parseProjectStatusForUpdate, projectStatusesForHelp, } from "./parse-cli-enums.js";
8
8
  import { parseJsonFlag, parseOptionalNumber as parseOptNum, } from "./parse-json-flag.js";
@@ -137,7 +137,7 @@ Budgets
137
137
  create-budget-approval <budgetId> (also sends approval request emails)
138
138
  create-estimate <budgetId> (creates QuickBooks estimate; supports preflight updates)
139
139
  add-budget-items --budgetId --items '[{"itemId":"…","quantity":1,"markup":30},…]'
140
- update-budget-item --id <budgetItemId> [--description] [--note] [--quantity] [--markup] [--cost] [--unitPrice] [--isFreeOfCharge true|false] [--gstInclusive true|false]
140
+ update-budget-item --id <budgetItemId> [--description plain-text] [--note plain-text] [--quantity] [--markup] [--cost] [--unitPrice] [--isFreeOfCharge true|false] [--gstInclusive true|false]
141
141
  remove-budget-item <budgetItemId>
142
142
  reorder-budget-items <budgetId> --itemIds <csv>
143
143
  update-budget-item-supplier --budgetItemId --supplierId
@@ -152,6 +152,7 @@ Bills
152
152
  list-bills [--projectId] [--budgetId] [--status CSV] [--search <text>] [--isClaimable true|false] [--createdByIds <csv>] [--sortBy createdAt|amount|status] [--sortDir asc|desc] [--page] [--pageSize]
153
153
  list-claims same flags as list-bills; only reimbursable claims (ignores --isClaimable)
154
154
  isClaimable differentiates the shared bill/claim records: false = bill, true = claim.
155
+ New supplier bills from 1 Jul 2026 00:00 SGT require approved quotation coverage for each non-legacy line item.
155
156
  create-bill-approval <billId> (also sends approval request emails)
156
157
  create-bill --payload '<json>' (bill.create; payload.isClaimable false = bill, true = claim; use upload-bill-attachment after create to add files)
157
158
  update-bill --payload '<json>' (bill.update; must include id)
@@ -165,8 +166,18 @@ Bills
165
166
  upload-bill-attachment <billId> <filePath> Presigned S3 upload + confirm (same flow as web UI)
166
167
  get-bill-details <billId>
167
168
 
169
+ Quotations
170
+ list-quotations [--projectId] [--budgetId] [--supplierId] [--status CSV] [--page] [--pageSize]
171
+ get-quotation <quotationId>
172
+ upload-quotation-attachment <projectId> <filePath> Presigned S3 upload; returns attachment JSON for create-quotation payload
173
+ create-quotation --payload '<json>' (quotation.createDraft)
174
+ submit-quotation <quotationId>
175
+ approve-quotation <quotationId>
176
+ reject-quotation <quotationId> --reason <text>
177
+ download-quotation-pdf <quotationId> [original|staff|final]
178
+
168
179
  Approvals
169
- list-approvals | get-pending-approvals [--type budget|supplier|bill|all]
180
+ list-approvals | get-pending-approvals [--type budget|supplier|bill|quotation|all]
170
181
  approve-bill <billId> (also sends requester reply email)
171
182
  reject-bill <billId> --reason <text> (also sends requester reply email)
172
183
  approve-budget <budgetId> (also sends requester reply email)
@@ -674,6 +685,88 @@ async function main() {
674
685
  await getBillDetails(id);
675
686
  break;
676
687
  }
688
+ case "list-quotations": {
689
+ const statusRaw = getFlag(flags, "status");
690
+ await listQuotations({
691
+ projectId: getFlag(flags, "projectId"),
692
+ budgetId: getFlag(flags, "budgetId"),
693
+ supplierId: getFlag(flags, "supplierId"),
694
+ status: statusRaw !== undefined
695
+ ? String(statusRaw)
696
+ .split(",")
697
+ .map((value) => value.trim().toUpperCase())
698
+ .filter(Boolean)
699
+ : undefined,
700
+ page: getFlag(flags, "page") !== undefined
701
+ ? Number(getFlag(flags, "page"))
702
+ : undefined,
703
+ pageSize: getFlag(flags, "pageSize") !== undefined
704
+ ? Number(getFlag(flags, "pageSize"))
705
+ : undefined,
706
+ });
707
+ break;
708
+ }
709
+ case "get-quotation": {
710
+ const id = positional[0];
711
+ if (!id)
712
+ throw new Error("get-quotation requires <quotationId>");
713
+ await getQuotationDetails(id);
714
+ break;
715
+ }
716
+ case "upload-quotation-attachment": {
717
+ const [projectId, filePath] = positional;
718
+ if (!projectId || !filePath) {
719
+ throw new Error("upload-quotation-attachment requires <projectId> <filePath>");
720
+ }
721
+ await uploadQuotationAttachmentFromPath(projectId, filePath);
722
+ break;
723
+ }
724
+ case "create-quotation": {
725
+ const payloadRaw = getFlag(flags, "payload");
726
+ if (!payloadRaw) {
727
+ throw new Error("create-quotation requires --payload '<json>'");
728
+ }
729
+ const raw = parseJsonFlag(String(payloadRaw), "--payload");
730
+ await createQuotationFromPayload(raw);
731
+ break;
732
+ }
733
+ case "submit-quotation": {
734
+ const id = positional[0];
735
+ if (!id)
736
+ throw new Error("submit-quotation requires <quotationId>");
737
+ await submitQuotation(id);
738
+ break;
739
+ }
740
+ case "approve-quotation": {
741
+ const id = positional[0];
742
+ if (!id)
743
+ throw new Error("approve-quotation requires <quotationId>");
744
+ await approveQuotation(id);
745
+ break;
746
+ }
747
+ case "reject-quotation": {
748
+ const id = positional[0];
749
+ const reason = getFlag(flags, "reason");
750
+ if (!id || reason === undefined) {
751
+ throw new Error("reject-quotation requires <quotationId> --reason <text>");
752
+ }
753
+ await rejectQuotation(id, reason);
754
+ break;
755
+ }
756
+ case "download-quotation-pdf": {
757
+ const id = positional[0];
758
+ if (!id) {
759
+ throw new Error("download-quotation-pdf requires <quotationId> [original|staff|final]");
760
+ }
761
+ const roleRaw = (positional[1] ?? "final").toLowerCase();
762
+ const fileRole = roleRaw === "original"
763
+ ? "ORIGINAL"
764
+ : roleRaw === "staff"
765
+ ? "STAFF_SIGNED"
766
+ : "FINAL_SIGNED";
767
+ await downloadQuotationPdf(id, fileRole);
768
+ break;
769
+ }
677
770
  case "list-approvals":
678
771
  case "get-pending-approvals": {
679
772
  const typeRaw = getFlag(flags, "type");
@@ -215,7 +215,7 @@ export function parseOptionalBillListSortDir(raw) {
215
215
  return raw;
216
216
  }
217
217
  function isPendingRequestType(s) {
218
- return s === "BUDGET" || s === "BILL" || s === "SUPPLIER";
218
+ return (s === "BUDGET" || s === "BILL" || s === "SUPPLIER" || s === "QUOTATION");
219
219
  }
220
220
  export function parseApprovalTypeFlag(raw) {
221
221
  if (raw === undefined)
@@ -224,7 +224,7 @@ export function parseApprovalTypeFlag(raw) {
224
224
  if (u === "ALL")
225
225
  return "ALL";
226
226
  if (!isPendingRequestType(u)) {
227
- throw new Error(`Invalid --type "${raw}". Use budget, supplier, bill, or all.`);
227
+ throw new Error(`Invalid --type "${raw}". Use budget, supplier, bill, quotation, or all.`);
228
228
  }
229
229
  return u;
230
230
  }
@@ -1,3 +1,4 @@
1
+ import { normalizePlainTextRichTextField } from "./rich-text.js";
1
2
  function requireObject(raw, label) {
2
3
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
3
4
  throw new Error(`${label} must be a JSON object.`);
@@ -237,12 +238,20 @@ export function parseSupplierUpdatePayload(raw) {
237
238
  return raw;
238
239
  }
239
240
  export function parseItemCreatePayload(raw) {
240
- requireObject(raw, "create-item payload");
241
- return raw;
241
+ const input = requireObject(raw, "create-item payload");
242
+ return normalizePlainTextRichTextField({
243
+ input,
244
+ textField: "description",
245
+ richTextField: "descriptionRichText",
246
+ });
242
247
  }
243
248
  export function parseItemUpdatePayload(raw) {
244
- requireObject(raw, "update-item payload");
245
- return raw;
249
+ const input = requireObject(raw, "update-item payload");
250
+ return normalizePlainTextRichTextField({
251
+ input,
252
+ textField: "description",
253
+ richTextField: "descriptionRichText",
254
+ });
246
255
  }
247
256
  export function parseContactCreatePayload(raw) {
248
257
  requireObject(raw, "create-contact-person payload");
@@ -33,6 +33,8 @@ export const ApprovalType = {
33
33
  BUDGET: "BUDGET",
34
34
  SUPPLIER: "SUPPLIER",
35
35
  BILL: "BILL",
36
+ CUSTOMER_INVOICE: "CUSTOMER_INVOICE",
37
+ QUOTATION: "QUOTATION",
36
38
  };
37
39
  export const ErrorSeverity = {
38
40
  LOW: "LOW",
@@ -0,0 +1,34 @@
1
+ const escapeHtml = (text) => text
2
+ .replace(/&/g, "&amp;")
3
+ .replace(/</g, "&lt;")
4
+ .replace(/>/g, "&gt;")
5
+ .replace(/"/g, "&quot;")
6
+ .replace(/'/g, "&#39;");
7
+ export const createRichTextFromPlainText = (text) => {
8
+ const lines = text?.split(/\r?\n/) ?? [];
9
+ const paragraphs = lines.length ? lines : [""];
10
+ return {
11
+ json: {
12
+ type: "doc",
13
+ content: paragraphs.map((line) => ({
14
+ type: "paragraph",
15
+ ...(line ? { content: [{ type: "text", text: line }] } : {}),
16
+ })),
17
+ },
18
+ html: paragraphs.map((line) => `<p>${escapeHtml(line)}</p>`).join(""),
19
+ };
20
+ };
21
+ export const isRichTextJson = (value) => Boolean(value &&
22
+ typeof value === "object" &&
23
+ "json" in value &&
24
+ "html" in value &&
25
+ typeof value.html === "string");
26
+ export const normalizePlainTextRichTextField = ({ input, textField, richTextField, }) => {
27
+ if (isRichTextJson(input[richTextField]) || input[textField] === undefined) {
28
+ return input;
29
+ }
30
+ return {
31
+ ...input,
32
+ [richTextField]: createRichTextFromPlainText(String(input[textField] ?? "")),
33
+ };
34
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@go-labs-sg/bb",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Budget Builder CLI for AI agents — manage budgets, bills, and claims; bill records use isClaimable=false for bills and isClaimable=true for claims.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "postpack": "node scripts/restore-package-json.js"
22
22
  },
23
23
  "dependencies": {
24
- "@trpc/client": "^11.17.0",
24
+ "@trpc/client": "^11.18.0",
25
25
  "dotenv": "^17.4.2",
26
26
  "mime-types": "^3.0.2",
27
27
  "superjson": "^2.2.6"