@go-labs-sg/bb 1.19.0 → 1.20.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
@@ -6,7 +6,7 @@ For AI agents: bills and claims are stored as the same underlying bill records.
6
6
 
7
7
  For AI agents using this CLI: always check whether a newer `@go-labs-sg/bb` version is available before running Budget Builder commands, then update the CLI if needed so command flags and API behavior stay current.
8
8
 
9
- For AI agents using this CLI: do not execute approval decisions, approval requests, status updates, customer-invoice changes, integration retries, `mark-budget-won`, `submit-quotation`, or supplier approval request side effects until the user explicitly confirms the exact entity, target state, and side effects. Inspect the entity first, summarize what will change and whether emails/integrations will run, then wait for a clear confirmation from the user. These sensitive workflow commands also require an interactive `CONFIRM` prompt at runtime and abort in non-interactive shells.
9
+ For AI agents using this CLI: do not execute approval decisions, approval requests, status updates, estimate emails, customer-invoice changes, integration retries, `mark-budget-won`, `submit-quotation`, or supplier approval request side effects until the user explicitly confirms the exact entity, target state, and side effects. Inspect the entity first, summarize what will change and whether emails/integrations will run, then wait for a clear confirmation from the user. These sensitive workflow commands also require an interactive `CONFIRM` prompt at runtime and abort in non-interactive shells.
10
10
 
11
11
  **Registry:** [`@go-labs-sg/bb`](https://www.npmjs.com/package/@go-labs-sg/bb)
12
12
 
@@ -72,7 +72,7 @@ Global options and flags use `--key=value` or `--key value` (see `bb help`).
72
72
 
73
73
  **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 an Asana Event Ops section from Prompt 5 tasks. 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. When rejecting the only accepted/closed budget on a commercial project, pass `--projectStatusOnCommercialRejection PITCH|LOST`.
74
74
 
75
- **Sensitive workflow changes:** Approval decisions/requests, entity status updates, customer-invoice changes, integration retries, `mark-budget-won`, `submit-quotation`, and supplier approval request side effects require an interactive `CONFIRM` prompt. Non-interactive runs abort before the guarded workflow mutation. Agents must get user confirmation in chat before attempting the command; the prompt is a final runtime guard, not a replacement for user approval.
75
+ **Sensitive workflow changes:** Approval decisions/requests, entity status updates, estimate emails, customer-invoice changes, integration retries, `mark-budget-won`, `submit-quotation`, and supplier approval request side effects require an interactive `CONFIRM` prompt. Non-interactive runs abort before the guarded workflow mutation. Agents must get user confirmation in chat before attempting the command; the prompt is a final runtime guard, not a replacement for user approval.
76
76
 
77
77
  **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.
78
78
 
@@ -80,12 +80,14 @@ Global options and flags use `--key=value` or `--key value` (see `bb help`).
80
80
 
81
81
  **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).
82
82
 
83
+ **Contact-person estimate email:** `send-estimate-to-contact-person --payload '<json>'` calls the same `email.sendEstimateToContactPerson` procedure as the web composer. The payload requires `budgetId`, `estimateId`, optional `estimateDocNumber`, `to`, `cc`, `replyTo`, `subject`, HTML `content`, and HTML `signature`. It sends the raw PDF returned by QuickBooks together with the standard terms and Budget Builder budget attachments, then marks the budget `ESTIMATE_SENT`. The command requires interactive `CONFIRM`; inspect the budget, recipients, and HTML first, and do not retry blindly after an ambiguous delivery failure.
84
+
83
85
  ### Command overview
84
86
 
85
87
  | Area | Commands (non-exhaustive) |
86
88
  | --- | --- |
87
- | **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`), `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`, `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`) |
88
- | **Bills / claims** | `list-bills` (`--isClaimable false` for bills, `--isClaimable true` for claims, omit for both), `list-claims` (claims only), `validate-bill-selection` (`--payload`; runs the same supplier, line-item, and quotation-coverage checks as the web flow), `stage-bill-attachment` (securely uploads invoice/payment-proof files before creation and returns attachment JSON), `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`, and is created pending finance approval; non-legacy supplier bills from 1 Jul 2026 00:00 SGT also require approved quotation coverage), `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` (moving a bill or claim to `PAID` sends creator and supplier emails, CC `m@getout.sg` and BCC `noah@getout.sg`; a claim sends one email when both recipient addresses match), `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` |
89
+ | **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`), `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`, `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`) |
90
+ | **Bills / claims** | `list-bills` (`--isClaimable false` for bills, `--isClaimable true` for claims, omit for both), `list-claims` (claims only), `validate-bill-selection` (`--payload`; runs the same supplier, line-item, and quotation-coverage checks as the web flow), `stage-bill-attachment` (securely uploads invoice/payment-proof files before creation and returns attachment JSON), `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`, and is created pending finance approval; non-legacy supplier bills from 1 Jul 2026 00:00 SGT also require approved quotation coverage), `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` |
89
91
  | **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`) |
90
92
  | **Customer invoices** | `check-customer-invoice-readiness`, `list-customer-invoices`, `get-customer-invoice`, `create-customer-invoice`, `discard-customer-invoice`, `delete-customer-invoice`, `void-customer-invoice`, `approve-customer-invoice`, `reject-customer-invoice`, `download-customer-invoice-pdf`, `sync-customer-invoice` |
91
93
  | **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` |
@@ -1,6 +1,6 @@
1
1
  import { createTRPCProxyClient, httpBatchLink, loggerLink } from "@trpc/client";
2
2
  import SuperJSON from "superjson";
3
- import { shouldLogCliActions } from "./cli-trace.js";
3
+ import { sanitizeValueForTrace, shouldLogCliActions } from "./cli-trace.js";
4
4
  const BASE_URL = "https://budget-builder.getout.events";
5
5
  function getAuthHeader() {
6
6
  const key = process.env.BB_API_KEY;
@@ -26,8 +26,8 @@ export const api = createTRPCProxyClient({
26
26
  withContext: false,
27
27
  // tRPC defaults use console.log for requests; stderr keeps stdout JSON-safe for pipes.
28
28
  console: {
29
- log: (...args) => console.error(...args),
30
- error: (...args) => console.error(...args),
29
+ log: (...args) => console.error(...args.map((arg) => sanitizeValueForTrace(arg))),
30
+ error: (...args) => console.error(...args.map((arg) => sanitizeValueForTrace(arg))),
31
31
  },
32
32
  }),
33
33
  httpBatchLink({
package/dist/cli-trace.js CHANGED
@@ -17,9 +17,39 @@ const SENSITIVE_FLAG = new Set([
17
17
  "bb_api_key",
18
18
  "token",
19
19
  "password",
20
+ "payment-proof",
21
+ "paymentproof",
22
+ "paymentreference",
23
+ "receipt",
20
24
  "secret",
21
25
  "authorization",
22
26
  ]);
27
+ const SENSITIVE_TRACE_FIELD = new Set([
28
+ "paymentproof",
29
+ "paymentproofattachments",
30
+ "paymentproofpath",
31
+ "paymentreference",
32
+ "receipt",
33
+ ]);
34
+ export const sanitizeValueForTrace = (value, seen = new WeakSet()) => {
35
+ if (typeof value !== "object" || value === null)
36
+ return value;
37
+ if (seen.has(value))
38
+ return "[circular]";
39
+ seen.add(value);
40
+ if (Array.isArray(value)) {
41
+ return value.map((item) => sanitizeValueForTrace(item, seen));
42
+ }
43
+ const prototype = Object.getPrototypeOf(value);
44
+ if (prototype !== Object.prototype && prototype !== null)
45
+ return value;
46
+ return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => [
47
+ key,
48
+ SENSITIVE_TRACE_FIELD.has(key.toLowerCase())
49
+ ? "[redacted]"
50
+ : sanitizeValueForTrace(nestedValue, seen),
51
+ ]));
52
+ };
23
53
  export const sanitizeFlagsForTrace = (flags) => {
24
54
  const out = {};
25
55
  for (const [k, v] of Object.entries(flags)) {
package/dist/commands.js CHANGED
@@ -6,8 +6,8 @@ import { contentType } from "mime-types";
6
6
  import { api } from "./api-client.js";
7
7
  import { BudgetRole, Deals, ExtendedApprovalStatus, ExtendedApprovalType, ExtendedBudgetStatus, TimeFrame, } from "./filter-enums.js";
8
8
  import { billStatusesForApi, } from "./parse-cli-enums.js";
9
- import { parseBudgetDiscountPayload, parseCompanyUpdatePayload, parseContactCreatePayload, parseContactUpdatePayload, parseCreateBillPayload, parseCreateBudgetPayload, parseCreateCustomerInvoicePayload, parseCreateQuotationPayload, parseItemCreatePayload, parseItemUpdatePayload, parseSupplierCreatePayload, parseSupplierUpdatePayload, parseUpdateBillPayload, parseUpdateBillPaymentEvidencePayload, parseUpdateBudgetCommissionPayload, parseUpdateBudgetPayload, parseUpdateProjectPayload, parseUpdateQuotationPayload, parseValidateBillSelectionPayload, } from "./parse-mutation-payload.js";
10
- import { BudgetStatus, ProjectStatus, } from "./prisma-enums.js";
9
+ import { parseBudgetDiscountPayload, parseCompanyUpdatePayload, parseContactCreatePayload, parseContactUpdatePayload, parseCreateBillPayload, parseCreateBudgetPayload, parseCreateCustomerInvoicePayload, parseCreateQuotationPayload, parseItemCreatePayload, parseItemUpdatePayload, parseSendEstimateToContactPersonPayload, parseSupplierCreatePayload, parseSupplierUpdatePayload, parseUpdateBillPayload, parseUpdateBillPaymentEvidencePayload, parseUpdateBudgetCommissionPayload, parseUpdateBudgetPayload, parseUpdateProjectPayload, parseUpdateQuotationPayload, parseValidateBillSelectionPayload, } from "./parse-mutation-payload.js";
10
+ import { BillStatus, BudgetStatus, ProjectStatus } from "./prisma-enums.js";
11
11
  import { createRichTextFromPlainText } from "./rich-text.js";
12
12
  const BUDGET = "BUDGET";
13
13
  const BILL = "BILL";
@@ -262,6 +262,7 @@ const billAttachmentContentTypeForFileName = (fileName) => {
262
262
  }
263
263
  return resolvedContentType;
264
264
  };
265
+ const PAYMENT_PROOF_MAX_SIZE = 20 * 1024 * 1024;
265
266
  const QUOTATION_ATTACHMENT_MAX_SIZE = 20 * 1024 * 1024;
266
267
  const quotationAttachmentContentTypeForFileName = (fileName) => {
267
268
  const resolvedContentType = contentTypeHeaderForFileName(fileName);
@@ -608,20 +609,112 @@ export async function createBillApproval(billId) {
608
609
  email,
609
610
  });
610
611
  }
612
+ export const validateBillStatusUpdateOptions = (opts) => {
613
+ if (opts.status === BillStatus.PAID && !opts.paymentReference?.trim()) {
614
+ throw new Error("update-bill-status requires --paymentReference when status is PAID.");
615
+ }
616
+ if (opts.status !== BillStatus.PAID && opts.paymentProofPath !== undefined) {
617
+ throw new Error("--paymentProof can only be used when status is PAID.");
618
+ }
619
+ if (opts.paymentProofPath !== undefined && !opts.paymentProofPath.trim()) {
620
+ throw new Error("--paymentProof requires a PDF file path.");
621
+ }
622
+ };
623
+ const deleteStagedPaymentProof = async (projectId, key) => {
624
+ const cleanup = await api.attachment.deleteStagedBillAttachments.mutate({
625
+ projectId,
626
+ keys: [key],
627
+ });
628
+ if (cleanup.failedKeys.includes(key)) {
629
+ throw new Error(`Failed to delete staged payment proof ${key}.`);
630
+ }
631
+ };
632
+ export const rethrowAfterStagedPaymentProofCleanup = async (error, cleanup) => {
633
+ if (cleanup) {
634
+ try {
635
+ await cleanup();
636
+ }
637
+ catch (cleanupError) {
638
+ throw new AggregateError([error, cleanupError], "Bill status update failed and its staged payment proof could not be cleaned up.");
639
+ }
640
+ }
641
+ throw error;
642
+ };
643
+ const stageBillPaymentProof = async (projectId, filePath) => {
644
+ const buffer = await readFile(filePath);
645
+ const fileName = basename(filePath);
646
+ const size = buffer.byteLength;
647
+ const contentType = billAttachmentContentTypeForFileName(fileName);
648
+ if (!fileName.toLowerCase().endsWith(".pdf") ||
649
+ contentType !== "application/pdf") {
650
+ throw new Error("Payment proof must be a PDF.");
651
+ }
652
+ if (size === 0) {
653
+ throw new Error("Payment proof must not be empty.");
654
+ }
655
+ if (size > PAYMENT_PROOF_MAX_SIZE) {
656
+ throw new Error("Payment proof must be 20MB or smaller.");
657
+ }
658
+ const { uploadUrl, key } = await api.attachment.requestStagedBillAttachmentUpload.mutate({
659
+ projectId,
660
+ fileName,
661
+ size,
662
+ contentType,
663
+ });
664
+ try {
665
+ const response = await fetch(uploadUrl, {
666
+ method: "PUT",
667
+ body: buffer,
668
+ headers: { "Content-Type": contentType },
669
+ });
670
+ if (!response.ok) {
671
+ throw new Error(`S3 upload failed: HTTP ${response.status} ${(await response.text()).slice(0, 500)}`);
672
+ }
673
+ }
674
+ catch (uploadError) {
675
+ try {
676
+ await deleteStagedPaymentProof(projectId, key);
677
+ }
678
+ catch (cleanupError) {
679
+ throw new AggregateError([uploadError, cleanupError], "Payment-proof upload failed and its staged object could not be cleaned up.");
680
+ }
681
+ throw uploadError;
682
+ }
683
+ return { id: randomUUID(), key, name: fileName, size };
684
+ };
611
685
  export async function updateBillStatus(opts) {
686
+ validateBillStatusUpdateOptions(opts);
612
687
  await assertSensitiveWorkflowConfirmed({
613
688
  action: "Update bill status",
614
689
  entity: `bill ${opts.id}`,
615
690
  details: `to ${opts.status}`,
616
691
  });
617
- const result = await api.bill.updateStatus.mutate({
618
- id: opts.id,
619
- status: opts.status,
620
- rejectionReason: opts.rejectionReason,
621
- paymentTrackingUrl: opts.paymentTrackingUrl,
622
- paymentReference: opts.paymentReference,
623
- });
624
- out(result);
692
+ const bill = opts.paymentProofPath
693
+ ? await api.bill.getById.query({ id: opts.id })
694
+ : null;
695
+ const paymentProof = bill && opts.paymentProofPath
696
+ ? await stageBillPaymentProof(bill.projectId, opts.paymentProofPath)
697
+ : undefined;
698
+ try {
699
+ const result = await api.bill.updateStatus.mutate({
700
+ id: opts.id,
701
+ status: opts.status,
702
+ rejectionReason: opts.rejectionReason,
703
+ paymentTrackingUrl: opts.paymentTrackingUrl,
704
+ paymentReference: opts.paymentReference,
705
+ paymentProofAttachments: paymentProof ? [paymentProof] : undefined,
706
+ });
707
+ out(result);
708
+ }
709
+ catch (error) {
710
+ await rethrowAfterStagedPaymentProofCleanup(error, bill && paymentProof
711
+ ? async () => {
712
+ // The cleanup endpoint checks Attachment rows and refuses to delete a key
713
+ // that an ambiguously successful status update already attached.
714
+ await deleteStagedPaymentProof(bill.projectId, paymentProof.key);
715
+ }
716
+ : undefined);
717
+ }
625
718
  }
626
719
  export async function patchBillPayment(opts) {
627
720
  const input = { id: opts.id };
@@ -961,6 +1054,17 @@ export async function createEstimate(budgetId, preflight) {
961
1054
  const result = await api.quickbooks.createEstimate.mutate({ budgetId });
962
1055
  out(result);
963
1056
  }
1057
+ export const describeEstimateEmailConfirmation = (input) => `to ${input.to}; CC ${input.cc.length > 0 ? input.cc.join(", ") : "none"}; reply-to ${input.replyTo}; subject "${input.subject}"; attach the QBO estimate PDF, terms and conditions, and Budget Builder attachments, then mark the estimate sent`;
1058
+ export const sendEstimateToContactPersonFromPayload = async (raw) => {
1059
+ const input = parseSendEstimateToContactPersonPayload(raw);
1060
+ await assertSensitiveWorkflowConfirmed({
1061
+ action: "Send estimate email",
1062
+ entity: `budget ${input.budgetId}`,
1063
+ details: describeEstimateEmailConfirmation(input),
1064
+ });
1065
+ const result = await api.email.sendEstimateToContactPerson.mutate(input);
1066
+ out(result);
1067
+ };
964
1068
  export async function getBudgetDetails(budgetId) {
965
1069
  const detail = await api.budget.getBudgetDetail.query({ id: budgetId });
966
1070
  out(detail);
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, approveCustomerInvoice, approveQuotation, approveSupplier, checkCustomerInvoiceReadiness, checkProjectReconciliation, cleanupStagedBillAttachments, cleanupStagedQuotationAttachments, completeProject, createApiKeyForUser, createBillApproval, createBillFromPayload, createBudgetApproval, createBudgetCategory, createBudgetFromPayload, createCompany, createContactPersonFromPayload, createCustomerInvoice, createEstimate, createItemCategory, createItemFromPayload, createPlaceholderBillForBudgetItem, createProject, createQuotationFromPayload, createSupplierCertification, createSupplierFromPayload, createSupplierPaymentMethod, createSupplierRoleOption, createSupplierTagOption, createUser, deleteBillById, deleteBudgetById, deleteBudgetCategory, deleteBudgetCommission, deleteBudgetDiscount, deleteCompanyById, deleteCustomerInvoice, deleteItemById, deleteItemCategoriesByIds, deleteProjectById, deleteQuotationById, deleteSuppliersByIds, discardCreatingCustomerInvoice, downloadCustomerInvoicePdf, downloadQuotationPdf, getApprovedBudgets, getBillAttachments, getBillDetails, getBudget, getBudgetCategories, getBudgetCategoryBenchmarks, getBudgetDetails, getBudgetItemsOnly, getBudgetVersions, getCompany, getCustomerInvoice, getDashboard, getErrorMetrics, getEstimatePerformance, getFinancialOverview, getItem, getItemPricingHistory, getMonthlyMetrics, getProject, getQuotationDetails, getRecentErrors, getSupplierAnalytics, getSupplierDetails, getSupplierPricingHistory, getSystemOverview, getUserPerformance, importQuickBooksProjectId, listApiKeysForUser, listApprovals, listBills, listBudgets, listCompanies, listContacts, listCustomerInvoices, listIntegrationOperations, listItemCategories, listItems, listProjects, listQuotations, listSuppliers, listUsers, markBudgetWonWithProof, patchBillInvoiceNumber, patchBillPayment, reactivateSuppliersByIds, reconcileProject, rejectBill, rejectBudget, rejectCustomerInvoice, rejectQuotation, rejectSupplier, removeBudgetItem, renameBudgetVersion, reorderBudgetItemsCli, restoreBudgetVersion, retryIntegrationOperation, revokeApiKeyForUser, setBudgetItemsNotUtilized, stageBillAttachmentsFromPaths, submitQuotation, syncCustomerInvoice, updateBillFromPayload, updateBillPaymentEvidenceFromPayload, updateBillStatus, updateBudgetCategory, updateBudgetCommissionFromPayload, updateBudgetDiscountFromPayload, updateBudgetFromPayload, updateBudgetItem, updateBudgetItemSupplierCli, updateBudgetStatus, updateCompanyFromPayload, updateContactPersonFromPayload, updateItemCategory, updateItemFromPayload, updateProjectFromPayload, updateProjectStatus, updateQuotationFromPayload, updateSupplierFromPayload, uploadBillAttachmentFromPath, uploadBillAttachmentsFromPaths, uploadBillDocumentsFromPaths, uploadBudgetAttachmentFromPath, uploadQuotationAttachmentFromPath, validateBillSelectionFromPayload, voidCustomerInvoice, } from "./commands.js";
5
+ import { addBudgetItems, approveBill, approveBudget, approveCustomerInvoice, approveQuotation, approveSupplier, checkCustomerInvoiceReadiness, checkProjectReconciliation, cleanupStagedBillAttachments, cleanupStagedQuotationAttachments, completeProject, createApiKeyForUser, createBillApproval, createBillFromPayload, createBudgetApproval, createBudgetCategory, createBudgetFromPayload, createCompany, createContactPersonFromPayload, createCustomerInvoice, createEstimate, createItemCategory, createItemFromPayload, createPlaceholderBillForBudgetItem, createProject, createQuotationFromPayload, createSupplierCertification, createSupplierFromPayload, createSupplierPaymentMethod, createSupplierRoleOption, createSupplierTagOption, createUser, deleteBillById, deleteBudgetById, deleteBudgetCategory, deleteBudgetCommission, deleteBudgetDiscount, deleteCompanyById, deleteCustomerInvoice, deleteItemById, deleteItemCategoriesByIds, deleteProjectById, deleteQuotationById, deleteSuppliersByIds, discardCreatingCustomerInvoice, downloadCustomerInvoicePdf, downloadQuotationPdf, getApprovedBudgets, getBillAttachments, getBillDetails, getBudget, getBudgetCategories, getBudgetCategoryBenchmarks, getBudgetDetails, getBudgetItemsOnly, getBudgetVersions, getCompany, getCustomerInvoice, getDashboard, getErrorMetrics, getEstimatePerformance, getFinancialOverview, getItem, getItemPricingHistory, getMonthlyMetrics, getProject, getQuotationDetails, getRecentErrors, getSupplierAnalytics, getSupplierDetails, getSupplierPricingHistory, getSystemOverview, getUserPerformance, importQuickBooksProjectId, listApiKeysForUser, listApprovals, listBills, listBudgets, listCompanies, listContacts, listCustomerInvoices, listIntegrationOperations, listItemCategories, listItems, listProjects, listQuotations, listSuppliers, listUsers, markBudgetWonWithProof, patchBillInvoiceNumber, patchBillPayment, reactivateSuppliersByIds, reconcileProject, rejectBill, rejectBudget, rejectCustomerInvoice, rejectQuotation, rejectSupplier, removeBudgetItem, renameBudgetVersion, reorderBudgetItemsCli, restoreBudgetVersion, retryIntegrationOperation, revokeApiKeyForUser, sendEstimateToContactPersonFromPayload, setBudgetItemsNotUtilized, stageBillAttachmentsFromPaths, submitQuotation, syncCustomerInvoice, updateBillFromPayload, updateBillPaymentEvidenceFromPayload, updateBillStatus, updateBudgetCategory, updateBudgetCommissionFromPayload, updateBudgetDiscountFromPayload, updateBudgetFromPayload, updateBudgetItem, updateBudgetItemSupplierCli, updateBudgetStatus, updateCompanyFromPayload, updateContactPersonFromPayload, updateItemCategory, updateItemFromPayload, updateProjectFromPayload, updateProjectStatus, updateQuotationFromPayload, updateSupplierFromPayload, uploadBillAttachmentFromPath, uploadBillAttachmentsFromPaths, uploadBillDocumentsFromPaths, uploadBudgetAttachmentFromPath, uploadQuotationAttachmentFromPath, validateBillSelectionFromPayload, voidCustomerInvoice, } from "./commands.js";
6
6
  import { getFlag, parseArgs } from "./parse-args.js";
7
7
  import { billStatusesForUpdateHelp, budgetStatusesForHelp, isBudgetStatusUpdate, parseApprovalTypeFlag, parseBillStatusForUpdate, parseCommaSeparatedBillStatuses, parseCommaSeparatedBudgetStatuses, parseCommaSeparatedCustomerInvoiceStatuses, parseCommaSeparatedIds, parseCommaSeparatedQuotationStatuses, parseCommaSeparatedSupplierStatuses, parseOptionalBillListSortBy, parseOptionalBillListSortDir, parseOptionalDashboardRole, parseOptionalDeals, parseOptionalErrorSeverity, parseOptionalErrorStatus, parseOptionalExtendedProjectStatus, parseOptionalFinancialRole, parseOptionalSupplierAnalyticsTimeFrame, parseOptionalTimeFrame, parseProjectStatusForUpdate, parseUserRole, projectStatusesForHelp, userRolesForHelp, } from "./parse-cli-enums.js";
8
8
  import { parseJsonFlag, parseOptionalNumber as parseOptNum, } from "./parse-json-flag.js";
@@ -149,9 +149,9 @@ Global: --quiet | -q | BB_CLI_QUIET=1 — hide action logs (default: log command
149
149
  Auth: Set BB_API_KEY in the environment (same API key as MCP / Goracle).
150
150
 
151
151
  Sensitive workflow changes: approval decisions/requests, entity status updates,
152
- customer invoices, integration retries, mark-budget-won, submit-quotation, and
153
- supplier approval request side effects require an interactive CONFIRM prompt
154
- and abort in non-interactive shells.
152
+ estimate emails, customer invoices, integration retries, mark-budget-won,
153
+ submit-quotation, and supplier approval request side effects require an
154
+ interactive CONFIRM prompt and abort in non-interactive shells.
155
155
  AI agents must inspect the entity first, summarize the exact entity, target
156
156
  state, and side effects, then wait for explicit user confirmation before running
157
157
  the sensitive command.
@@ -176,6 +176,7 @@ Budgets
176
176
  delete-budget <budgetId>
177
177
  create-budget-approval <budgetId> (also sends approval request emails)
178
178
  create-estimate <budgetId> (creates QuickBooks estimate; supports preflight updates)
179
+ send-estimate-to-contact-person --payload '<json>' Send using the web contact-person workflow; payload includes recipient fields plus HTML content/signature.
179
180
  add-budget-items --budgetId --items '[{"itemId":"…","quantity":1,"markup":30},…]'
180
181
  update-budget-item --id <budgetItemId> [--description plain-text] [--note plain-text] [--quantity] [--markup] [--cost] [--unitPrice] [--isFreeOfCharge true|false] [--gstInclusive true|false] [--gstOutOfScope true|false]
181
182
  remove-budget-item <budgetItemId>
@@ -206,9 +207,10 @@ Bills
206
207
  update-bill --payload '<json>' (bill.update; must include id)
207
208
  update-bill-payment-evidence --payload '<json>' Replace paymentReference/paymentProofAttachments for an already-paid bill.
208
209
  delete-bill <billId>
209
- update-bill-status <id> <status> [--rejectionReason] [--paymentTrackingUrl] [--paymentReference]
210
+ update-bill-status <id> <status> [--rejectionReason] [--paymentTrackingUrl] [--paymentReference] [--paymentProof <receipt.pdf>]
210
211
  status (${billStatusesForUpdateHelp.join(", ")}) — not PENDING_APPROVAL; use create-bill-approval
211
- Moving a bill or claim to PAID sends creator and supplier emails, CC m@getout.sg and BCC noah@getout.sg; duplicate claim recipients receive one email.
212
+ PAID requires --paymentReference and a payment-proof PDF. Use --paymentProof to stage and submit the PDF atomically, or omit it only when BB already has a BILL_PAYMENT_PROOF attachment.
213
+ Moving a bill or claim to PAID runs the server's paid-bill notification workflow.
212
214
  patch-bill-payment <billId> [--paymentTrackingUrl] [--paymentReference] [--quickbooksBillId] [--paymentDate <ISO>]
213
215
  Clear a field: --clearPaymentTrackingUrl true | --clearPaymentReference true | --clearQuickbooksBillId true | --clearPaymentDate true
214
216
  patch-bill-invoice-number <billId> <invoiceNumber>
@@ -515,6 +517,16 @@ async function main() {
515
517
  });
516
518
  break;
517
519
  }
520
+ case "send-estimate-email":
521
+ case "send-estimate-to-contact-person": {
522
+ const payloadRaw = getFlag(flags, "payload");
523
+ if (!payloadRaw) {
524
+ throw new Error("send-estimate-to-contact-person requires --payload '<json>'");
525
+ }
526
+ const raw = parseJsonFlag(String(payloadRaw), "--payload");
527
+ await sendEstimateToContactPersonFromPayload(raw);
528
+ break;
529
+ }
518
530
  case "create-budget": {
519
531
  const payloadRaw = getFlag(flags, "payload");
520
532
  if (!payloadRaw) {
@@ -745,7 +757,11 @@ async function main() {
745
757
  case "update-bill-status": {
746
758
  const [id, statusRaw] = positional;
747
759
  if (!id || !statusRaw) {
748
- throw new Error("update-bill-status requires <billId> <status> [--rejectionReason] [--paymentTrackingUrl] [--paymentReference]");
760
+ throw new Error("update-bill-status requires <billId> <status> [--rejectionReason] [--paymentTrackingUrl] [--paymentReference] [--paymentProof <receipt.pdf>]");
761
+ }
762
+ const paymentProofFlag = flags.paymentProof ?? flags["payment-proof"] ?? flags.receipt;
763
+ if (paymentProofFlag === true) {
764
+ throw new Error("--paymentProof requires a PDF file path.");
749
765
  }
750
766
  const status = parseBillStatusForUpdate(statusRaw);
751
767
  await updateBillStatus({
@@ -754,6 +770,7 @@ async function main() {
754
770
  rejectionReason: getFlag(flags, "rejectionReason"),
755
771
  paymentTrackingUrl: getFlag(flags, "paymentTrackingUrl"),
756
772
  paymentReference: getFlag(flags, "paymentReference"),
773
+ paymentProofPath: paymentProofFlag,
757
774
  });
758
775
  break;
759
776
  }
@@ -34,6 +34,32 @@ function requiredString(value, fieldLabel) {
34
34
  }
35
35
  return t;
36
36
  }
37
+ const EMAIL_ADDRESS_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
38
+ const requiredEmail = (value, fieldLabel) => {
39
+ const email = requiredString(value, fieldLabel);
40
+ if (!EMAIL_ADDRESS_PATTERN.test(email)) {
41
+ throw new Error(`${fieldLabel} must be a valid email address.`);
42
+ }
43
+ return email;
44
+ };
45
+ /** Matches the web estimate composer (`email.sendEstimateToContactPerson`). */
46
+ export const parseSendEstimateToContactPersonPayload = (raw) => {
47
+ const input = requireObject(raw, "send-estimate-to-contact-person payload");
48
+ if (!Array.isArray(input.cc)) {
49
+ throw new Error("send-estimate-to-contact-person payload.cc must be an email array.");
50
+ }
51
+ return {
52
+ to: requiredEmail(input.to, "send-estimate-to-contact-person payload.to"),
53
+ cc: input.cc.map((email, index) => requiredEmail(email, `send-estimate-to-contact-person payload.cc[${index}]`)),
54
+ replyTo: requiredEmail(input.replyTo, "send-estimate-to-contact-person payload.replyTo"),
55
+ subject: requiredString(input.subject, "send-estimate-to-contact-person payload.subject"),
56
+ content: requiredString(input.content, "send-estimate-to-contact-person payload.content"),
57
+ signature: requiredString(input.signature, "send-estimate-to-contact-person payload.signature"),
58
+ estimateId: requiredString(input.estimateId, "send-estimate-to-contact-person payload.estimateId"),
59
+ estimateDocNumber: optionalString(input.estimateDocNumber),
60
+ budgetId: requiredString(input.budgetId, "send-estimate-to-contact-person payload.budgetId"),
61
+ };
62
+ };
37
63
  /** Matches budget.createBudget — optional pipedriveDealId; Asana deal is project.asanaTaskId. */
38
64
  export function parseCreateBudgetPayload(raw) {
39
65
  const o = requireObject(raw, "create-budget payload");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@go-labs-sg/bb",
3
- "version": "1.19.0",
3
+ "version": "1.20.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",