@go-labs-sg/bb 1.16.0 → 1.18.1

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
@@ -37,6 +37,20 @@ export BB_API_KEY=<your-key>
37
37
 
38
38
  Create or revoke keys in the web app: **Goracle → API Keys** (MCP API keys).
39
39
 
40
+ Admins can provision API-only service identities and manage their keys from the
41
+ CLI. These identities are not human web accounts: `create-user` does not create
42
+ or link a Google OAuth account, so the resulting user cannot sign in with Google.
43
+
44
+ ```bash
45
+ bb create-user --email agent@example.com --name "Budget Agent" --role USER
46
+ bb create-api-key --userId <user-id> --name "Budget Agent CLI"
47
+ bb list-api-keys --userId <user-id>
48
+ bb revoke-api-key <api-key-id> --userId <user-id>
49
+ ```
50
+
51
+ The raw key is returned only by `create-api-key`; copy it immediately. Listing
52
+ keys returns metadata only.
53
+
40
54
  The CLI talks to the production API: `https://budget-builder.getout.events`.
41
55
 
42
56
  ## Usage
@@ -56,10 +70,12 @@ Global options and flags use `--key=value` or `--key value` (see `bb help`).
56
70
 
57
71
  **Authoritative command list:** run `bb help` — it includes every command, positional args, and flags. MCP exposes a subset of the same tRPC surface; the CLI additionally includes a few procedures mainly used by the web UI (e.g. `reorder-budget-items`, `update-budget-item-supplier`). You can also use MCP-style `snake_case` (e.g. `bb list_bills`); it is normalized to kebab-case.
58
72
 
59
- **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`.
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`.
60
74
 
61
75
  **Sensitive workflow changes:** Approval decisions/requests, entity status updates, `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.
62
76
 
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
+
63
79
  **Locked estimate budget changes:** Budget-changing CLI commands require an interactive `yes` confirmation when the current budget status is `ESTIMATE_CREATED`, `ESTIMATE_SENT`, `ESTIMATE_ACCEPTED`, `ESTIMATE_REJECTED`, or `ESTIMATE_CLOSED`. There is no force/override flag; non-interactive runs abort before mutating locked budgets.
64
80
 
65
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).
@@ -68,14 +84,14 @@ Global options and flags use `--key=value` or `--key value` (see `bb help`).
68
84
 
69
85
  | Area | Commands (non-exhaustive) |
70
86
  | --- | --- |
71
- | **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`) |
72
- | **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` |
73
- | **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`) |
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`, `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`, `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`) |
88
+ | **Bills / claims** | `list-bills` (`--isClaimable false` for bills, `--isClaimable true` for claims, omit for both), `list-claims` (claims only), `stage-bill-attachment` (securely uploads invoice/payment-proof files before creation and returns attachment JSON), `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`), `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
+ | **Quotations** | `list-quotations`, `get-quotation`, `upload-quotation-attachment` (`<projectId>` + local PDF path; returns attachment JSON for payload use), `create-quotation` (`--payload` for `quotation.createDraft`; include `amountWithoutGst` and `gstAmount`), `submit-quotation`, `approve-quotation`, `reject-quotation`, `download-quotation-pdf` (`original`, `staff`, or `final`) |
74
90
  | **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` |
75
- | **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`, `--startDate` as ISO datetime for project/window start; when `--asanaTaskId` is omitted, the CLI searches open Asana lead tasks in the Deals project, prompts for one of the top five matches, and resolves Slack channel fields from the selected deal card; optional `--asanaSearch`, `--pax`, `--endDate`, `--description`; always requests QBO project import like the web app), `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`) |
91
+ | **Companies & projects** | `list-companies`, `get-company`, `create-company`, `update-company` (`--payload`), `delete-company`, `list-projects`, `get-project` (its budget overview returns `totalRevenue`, calculated only from Estimate Accepted and Estimate Closed budgets), `create-project` (required: `--name`, `--companyId`, `--contactPersonId`, `--insideSalesId`, `--businessDevelopmentId`, `--venue`, `--startDate` as ISO datetime for project/window start; when `--asanaTaskId` is omitted, the CLI searches open Asana lead tasks in the Deals project, prompts for one of the top five matches, and resolves Slack channel fields from the selected deal card; optional `--asanaSearch`, `--pax`, `--endDate`, `--description`, `--projectManagerId`; always requests QBO project import like the web app), `update-project` (`--payload` with `dateRange.from` / `dateRange.to` for the project window; optional `projectManagerId` and `requestQboAccountantNotification` in JSON), `delete-project`, `update-project-status` (`<id>` `<status>`: `PITCH` \| `WON` \| `COMPLETED` \| `RECONCILED` \| `LOST`; close-out follows `WON` → `RECONCILED` → `COMPLETED`, and reconciliation requires every accepted/closed budget line to have an Approved/Paid bill or claim or be explicitly Not Utilized; for `PITCH` → `WON` also pass a Budget Builder user `--projectManagerId` or `--projectManagerEmail`; when marking `WON` without an accepted/closed budget or proof, pass `--wonOverrideReason`) |
76
92
  | **Contacts** | `list-contacts`, `create-contact-person` (`--payload`), `update-contact-person` (`--payload`) |
77
93
  | **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) |
78
- | **Dashboard & users** | `list-users`, `get-user-performance`, `get-dashboard`, `get-monthly-metrics`, `get-system-overview`, `get-estimate-performance`, `get-financial-overview` |
94
+ | **Dashboard & users** | `list-users`, `create-user` (admin; provisions an API-only service identity with no Google sign-in; `--email`, optional `--name`, optional `--role` defaulting to `USER`), `create-api-key` (admin; `--userId`, `--name`; raw key shown once), `list-api-keys` (admin; optional `--userId`), `revoke-api-key` (admin; key ID plus `--userId` for another user's key), `get-user-performance`, `get-dashboard`, `get-monthly-metrics`, `get-system-overview`, `get-estimate-performance`, `get-financial-overview` |
79
95
  | **Errors** | `get-recent-errors`, `get-error-metrics` |
80
96
  | **Historical / benchmarks** | `get-approved-budgets`, `get-budget-category-benchmarks`, `get-item-pricing-history`, `get-supplier-pricing-history` |
81
97
 
@@ -8,10 +8,20 @@ function getAuthHeader() {
8
8
  return undefined;
9
9
  return `Bearer ${key.trim()}`;
10
10
  }
11
+ const containsRawApiKey = (value, seen = new WeakSet()) => {
12
+ if (typeof value !== "object" || value === null)
13
+ return false;
14
+ if (seen.has(value))
15
+ return false;
16
+ seen.add(value);
17
+ return Object.entries(value).some(([key, nestedValue]) => (key.toLowerCase() === "apikey" && typeof nestedValue === "string") ||
18
+ containsRawApiKey(nestedValue, seen));
19
+ };
11
20
  export const api = createTRPCProxyClient({
12
21
  links: [
13
22
  loggerLink({
14
- enabled: () => shouldLogCliActions(),
23
+ enabled: (opts) => shouldLogCliActions() &&
24
+ !(opts.direction === "down" && containsRawApiKey(opts.result)),
15
25
  colorMode: "ansi",
16
26
  withContext: false,
17
27
  // tRPC defaults use console.log for requests; stderr keeps stdout JSON-safe for pipes.
package/dist/commands.js CHANGED
@@ -19,6 +19,34 @@ const ASANA_WON_LOST_SECTION_GIDS = new Set([
19
19
  ]);
20
20
  const MAX_ASANA_LEAD_CANDIDATES = 5;
21
21
  const ASANA_SEARCH_DETAIL_LIMIT = 15;
22
+ const NOTIFICATION_PAGE_LIMIT = 50;
23
+ const getAllNotifications = async () => {
24
+ const notifications = [];
25
+ let cursor;
26
+ do {
27
+ const page = await api.budget.getNotifications.query({
28
+ cursor,
29
+ limit: NOTIFICATION_PAGE_LIMIT,
30
+ });
31
+ notifications.push(...page.notifications);
32
+ cursor = page.nextCursor;
33
+ } while (cursor);
34
+ return notifications;
35
+ };
36
+ const createApprovalEmailDeliveryReport = ({ sentCount, skippedCount, message, }) => ({
37
+ sent: sentCount > 0,
38
+ count: sentCount,
39
+ sentCount,
40
+ skippedCount,
41
+ message,
42
+ });
43
+ const createApprovalEmailDeliveryFailure = (error) => ({
44
+ sent: false,
45
+ count: 0,
46
+ sentCount: 0,
47
+ skippedCount: 0,
48
+ error,
49
+ });
22
50
  function isPendingRequest(n) {
23
51
  return n.notificationType === "request" && n.status === "PENDING_APPROVAL";
24
52
  }
@@ -212,6 +240,27 @@ const contentTypeHeaderForFileName = (fileName) => {
212
240
  const ct = contentType(fileName);
213
241
  return typeof ct === "string" ? ct : "application/octet-stream";
214
242
  };
243
+ const supportedAttachmentContentTypes = [
244
+ "application/pdf",
245
+ "image/gif",
246
+ "image/jpeg",
247
+ "image/png",
248
+ "image/webp",
249
+ ];
250
+ const attachmentContentTypeForFileName = (fileName) => {
251
+ const resolvedContentType = contentTypeHeaderForFileName(fileName);
252
+ const supportedContentType = supportedAttachmentContentTypes.find((contentTypeValue) => contentTypeValue === resolvedContentType);
253
+ if (supportedContentType)
254
+ return supportedContentType;
255
+ throw new Error(`Unsupported attachment type: ${fileName}`);
256
+ };
257
+ const billAttachmentContentTypeForFileName = (fileName) => {
258
+ const resolvedContentType = attachmentContentTypeForFileName(fileName);
259
+ if (resolvedContentType === "image/webp") {
260
+ throw new Error(`Unsupported QuickBooks bill attachment type: ${fileName}. Use PDF, GIF, JPEG, or PNG.`);
261
+ }
262
+ return resolvedContentType;
263
+ };
215
264
  const LOCKED_BUDGET_STATUSES_REQUIRING_CONFIRMATION = new Set([
216
265
  BudgetStatus.ESTIMATE_CREATED,
217
266
  BudgetStatus.ESTIMATE_SENT,
@@ -424,7 +473,7 @@ export async function approveBill(billId) {
424
473
  entity: `bill ${billId}`,
425
474
  details: "and send requester reply email",
426
475
  });
427
- const notifications = await api.budget.getNotifications.query();
476
+ const notifications = await getAllNotifications();
428
477
  const pending = notifications.filter((n) => isPendingRequest(n) &&
429
478
  n.type === BILL &&
430
479
  (n.billId === billId || n.bill?.id === billId));
@@ -436,6 +485,10 @@ export async function approveBill(billId) {
436
485
  billApprovalId: approval.id,
437
486
  status: "APPROVED",
438
487
  });
488
+ if (result.processing) {
489
+ out(result);
490
+ return;
491
+ }
439
492
  let email;
440
493
  try {
441
494
  await api.email.sendReplyBillApprovalEmail.mutate(result.updatedApproval);
@@ -453,7 +506,7 @@ export async function approveBill(billId) {
453
506
  });
454
507
  }
455
508
  export async function listApprovals(opts) {
456
- const notifications = await api.budget.getNotifications.query();
509
+ const notifications = await getAllNotifications();
457
510
  const pending = notifications.filter(isPendingRequest);
458
511
  const typeFilter = opts.type === undefined || opts.type === "ALL"
459
512
  ? (_n) => true
@@ -524,19 +577,15 @@ export async function createBillApproval(billId) {
524
577
  const approvalIds = (result.results ?? []).map((item) => ({ id: item.id }));
525
578
  let email;
526
579
  if (approvalIds.length === 0) {
527
- email = { sent: false, count: 0, error: "No approval ids returned" };
580
+ email = createApprovalEmailDeliveryFailure("No approval ids returned");
528
581
  }
529
582
  else {
530
583
  try {
531
- await api.email.sendBillApprovalRequestEmail.mutate(approvalIds);
532
- email = { sent: true, count: approvalIds.length };
584
+ const delivery = await api.email.sendBillApprovalRequestEmail.mutate(approvalIds);
585
+ email = createApprovalEmailDeliveryReport(delivery);
533
586
  }
534
587
  catch (error) {
535
- email = {
536
- sent: false,
537
- count: approvalIds.length,
538
- error: error instanceof Error ? error.message : String(error),
539
- };
588
+ email = createApprovalEmailDeliveryFailure(error instanceof Error ? error.message : String(error));
540
589
  }
541
590
  }
542
591
  out({
@@ -589,30 +638,104 @@ export async function getBillAttachments(id) {
589
638
  const result = await api.bill.getAttachments.query({ id });
590
639
  out(result);
591
640
  }
592
- export async function uploadBillAttachmentFromPath(billId, filePath) {
641
+ export async function stageBillAttachmentsFromPaths(projectId, filePaths) {
642
+ const attachments = [];
643
+ for (const filePath of filePaths) {
644
+ const buffer = await readFile(filePath);
645
+ const fileName = basename(filePath);
646
+ const size = buffer.byteLength;
647
+ const contentType = billAttachmentContentTypeForFileName(fileName);
648
+ const { uploadUrl, key } = await api.attachment.requestStagedBillAttachmentUpload.mutate({
649
+ projectId,
650
+ fileName,
651
+ size,
652
+ contentType,
653
+ });
654
+ const response = await fetch(uploadUrl, {
655
+ method: "PUT",
656
+ body: buffer,
657
+ headers: { "Content-Type": contentType },
658
+ });
659
+ if (!response.ok) {
660
+ throw new Error(`S3 upload failed: HTTP ${response.status} ${(await response.text()).slice(0, 500)}`);
661
+ }
662
+ attachments.push({ id: randomUUID(), key, name: fileName, size });
663
+ }
664
+ out({
665
+ success: true,
666
+ projectId,
667
+ count: attachments.length,
668
+ attachments,
669
+ });
670
+ }
671
+ export async function uploadBillAttachmentFromPath(billId, filePath, type = "BILL") {
672
+ const confirmed = await uploadSingleBillAttachmentFromPath(billId, filePath, type);
673
+ out(confirmed);
674
+ }
675
+ const uploadSingleBillAttachmentFromPath = async (billId, filePath, type = "BILL") => {
593
676
  const buf = await readFile(filePath);
594
677
  const fileName = basename(filePath);
595
678
  const size = buf.byteLength;
679
+ const attachmentContentType = billAttachmentContentTypeForFileName(fileName);
596
680
  const { uploadUrl, key } = await api.attachment.requestBillAttachmentUpload.mutate({
597
681
  billId,
682
+ contentType: attachmentContentType,
598
683
  fileName,
599
684
  size,
600
685
  });
601
686
  const res = await fetch(uploadUrl, {
602
687
  method: "PUT",
603
688
  body: buf,
604
- headers: { "Content-Type": contentTypeHeaderForFileName(fileName) },
689
+ headers: { "Content-Type": attachmentContentType },
605
690
  });
606
691
  if (!res.ok) {
607
692
  throw new Error(`S3 upload failed: HTTP ${res.status} ${(await res.text()).slice(0, 500)}`);
608
693
  }
609
- const confirmed = await api.attachment.confirmBillAttachment.mutate({
694
+ return await api.attachment.confirmBillAttachment.mutate({
610
695
  billId,
611
696
  key,
612
697
  name: fileName,
613
698
  size,
699
+ type,
700
+ });
701
+ };
702
+ export async function uploadBillAttachmentsFromPaths(billId, filePaths, type = "BILL") {
703
+ if (filePaths.length === 0) {
704
+ throw new Error("At least one bill attachment file path is required");
705
+ }
706
+ const results = [];
707
+ for (const filePath of filePaths) {
708
+ results.push(await uploadSingleBillAttachmentFromPath(billId, filePath, type));
709
+ }
710
+ out({
711
+ success: true,
712
+ billId,
713
+ type,
714
+ count: results.length,
715
+ attachments: results.map((result) => result.attachment),
716
+ results,
717
+ });
718
+ }
719
+ export async function uploadBillDocumentsFromPaths({ billId, invoicePaths = [], paymentProofPaths = [], }) {
720
+ if (invoicePaths.length === 0 && paymentProofPaths.length === 0) {
721
+ throw new Error("At least one invoice or payment proof file path is required");
722
+ }
723
+ const invoiceResults = [];
724
+ for (const filePath of invoicePaths) {
725
+ invoiceResults.push(await uploadSingleBillAttachmentFromPath(billId, filePath, "BILL"));
726
+ }
727
+ const paymentProofResults = [];
728
+ for (const filePath of paymentProofPaths) {
729
+ paymentProofResults.push(await uploadSingleBillAttachmentFromPath(billId, filePath, "BILL_PAYMENT_PROOF"));
730
+ }
731
+ out({
732
+ success: true,
733
+ billId,
734
+ count: invoiceResults.length + paymentProofResults.length,
735
+ invoiceAttachments: invoiceResults.map((result) => result.attachment),
736
+ paymentProofAttachments: paymentProofResults.map((result) => result.attachment),
737
+ results: [...invoiceResults, ...paymentProofResults],
614
738
  });
615
- out(confirmed);
616
739
  }
617
740
  export async function uploadQuotationAttachmentFromPath(projectId, filePath) {
618
741
  const buf = await readFile(filePath);
@@ -646,15 +769,17 @@ export async function uploadBudgetAttachmentFromPath(budgetId, filePath) {
646
769
  const buf = await readFile(filePath);
647
770
  const fileName = basename(filePath);
648
771
  const size = buf.byteLength;
772
+ const attachmentContentType = attachmentContentTypeForFileName(fileName);
649
773
  const { uploadUrl, key } = await api.attachment.requestBudgetAttachmentUpload.mutate({
650
774
  budgetId,
775
+ contentType: attachmentContentType,
651
776
  fileName,
652
777
  size,
653
778
  });
654
779
  const res = await fetch(uploadUrl, {
655
780
  method: "PUT",
656
781
  body: buf,
657
- headers: { "Content-Type": contentTypeHeaderForFileName(fileName) },
782
+ headers: { "Content-Type": attachmentContentType },
658
783
  });
659
784
  if (!res.ok) {
660
785
  throw new Error(`S3 upload failed: HTTP ${res.status} ${(await res.text()).slice(0, 500)}`);
@@ -677,15 +802,17 @@ export async function uploadBudgetWinProofFromPath(budgetId, filePath, opts) {
677
802
  const buf = await readFile(filePath);
678
803
  const fileName = basename(filePath);
679
804
  const size = buf.byteLength;
805
+ const attachmentContentType = attachmentContentTypeForFileName(fileName);
680
806
  const { uploadUrl, key } = await api.attachment.requestBudgetWinProofUpload.mutate({
681
807
  budgetId,
808
+ contentType: attachmentContentType,
682
809
  fileName,
683
810
  size,
684
811
  });
685
812
  const res = await fetch(uploadUrl, {
686
813
  method: "PUT",
687
814
  body: buf,
688
- headers: { "Content-Type": contentTypeHeaderForFileName(fileName) },
815
+ headers: { "Content-Type": attachmentContentType },
689
816
  });
690
817
  if (!res.ok) {
691
818
  throw new Error(`S3 upload failed: HTTP ${res.status} ${(await res.text()).slice(0, 500)}`);
@@ -774,19 +901,15 @@ export async function createBudgetApproval(budgetId, preflight) {
774
901
  const approvalIds = (result.results ?? []).map((item) => ({ id: item.id }));
775
902
  let email;
776
903
  if (approvalIds.length === 0) {
777
- email = { sent: false, count: 0, error: "No approval ids returned" };
904
+ email = createApprovalEmailDeliveryFailure("No approval ids returned");
778
905
  }
779
906
  else {
780
907
  try {
781
- await api.email.sendApprovalRequestEmail.mutate(approvalIds);
782
- email = { sent: true, count: approvalIds.length };
908
+ const delivery = await api.email.sendApprovalRequestEmail.mutate(approvalIds);
909
+ email = createApprovalEmailDeliveryReport(delivery);
783
910
  }
784
911
  catch (error) {
785
- email = {
786
- sent: false,
787
- count: approvalIds.length,
788
- error: error instanceof Error ? error.message : String(error),
789
- };
912
+ email = createApprovalEmailDeliveryFailure(error instanceof Error ? error.message : String(error));
790
913
  }
791
914
  }
792
915
  out({
@@ -967,19 +1090,15 @@ export async function createSupplierFromPayload(raw) {
967
1090
  let emailResult;
968
1091
  const approvalIds = (approval.results ?? []).map((item) => ({ id: item.id }));
969
1092
  if (approvalIds.length === 0) {
970
- emailResult = { sent: false, count: 0, error: "No approval ids returned" };
1093
+ emailResult = createApprovalEmailDeliveryFailure("No approval ids returned");
971
1094
  }
972
1095
  else {
973
1096
  try {
974
- await api.email.sendSupplierApprovalRequestEmail.mutate(approvalIds);
975
- emailResult = { sent: true, count: approvalIds.length };
1097
+ const delivery = await api.email.sendSupplierApprovalRequestEmail.mutate(approvalIds);
1098
+ emailResult = createApprovalEmailDeliveryReport(delivery);
976
1099
  }
977
1100
  catch (error) {
978
- emailResult = {
979
- sent: false,
980
- count: approvalIds.length,
981
- error: error instanceof Error ? error.message : String(error),
982
- };
1101
+ emailResult = createApprovalEmailDeliveryFailure(error instanceof Error ? error.message : String(error));
983
1102
  }
984
1103
  }
985
1104
  out({
@@ -1016,27 +1135,23 @@ export async function updateSupplierFromPayload(raw) {
1016
1135
  approvalResult = {
1017
1136
  requested: true,
1018
1137
  approvalCount: 0,
1019
- email: { sent: false, count: 0, error: "No approval ids returned" },
1138
+ email: createApprovalEmailDeliveryFailure("No approval ids returned"),
1020
1139
  };
1021
1140
  }
1022
1141
  else {
1023
1142
  try {
1024
- await api.email.sendSupplierApprovalRequestEmail.mutate(approvalIds);
1143
+ const delivery = await api.email.sendSupplierApprovalRequestEmail.mutate(approvalIds);
1025
1144
  approvalResult = {
1026
1145
  requested: true,
1027
1146
  approvalCount: approvalIds.length,
1028
- email: { sent: true, count: approvalIds.length },
1147
+ email: createApprovalEmailDeliveryReport(delivery),
1029
1148
  };
1030
1149
  }
1031
1150
  catch (error) {
1032
1151
  approvalResult = {
1033
1152
  requested: true,
1034
1153
  approvalCount: approvalIds.length,
1035
- email: {
1036
- sent: false,
1037
- count: approvalIds.length,
1038
- error: error instanceof Error ? error.message : String(error),
1039
- },
1154
+ email: createApprovalEmailDeliveryFailure(error instanceof Error ? error.message : String(error)),
1040
1155
  };
1041
1156
  }
1042
1157
  }
@@ -1089,7 +1204,7 @@ export async function approveBudget(budgetId) {
1089
1204
  entity: `budget ${budgetId}`,
1090
1205
  details: "and send requester reply email",
1091
1206
  });
1092
- const notifications = await api.budget.getNotifications.query();
1207
+ const notifications = await getAllNotifications();
1093
1208
  const pending = notifications.filter((n) => n.notificationType === "request" &&
1094
1209
  n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
1095
1210
  n.type === ExtendedApprovalType.BUDGET &&
@@ -1125,7 +1240,7 @@ export async function rejectBudget(budgetId, reason) {
1125
1240
  entity: `budget ${budgetId}`,
1126
1241
  details: `with reason "${reason}" and send requester reply email`,
1127
1242
  });
1128
- const notifications = await api.budget.getNotifications.query();
1243
+ const notifications = await getAllNotifications();
1129
1244
  const pending = notifications.filter((n) => n.notificationType === "request" &&
1130
1245
  n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
1131
1246
  n.type === ExtendedApprovalType.BUDGET &&
@@ -1223,7 +1338,7 @@ export async function approveSupplier(supplierId) {
1223
1338
  entity: `supplier ${supplierId}`,
1224
1339
  details: "and send requester reply email",
1225
1340
  });
1226
- const notifications = await api.budget.getNotifications.query();
1341
+ const notifications = await getAllNotifications();
1227
1342
  const pending = notifications.filter((n) => n.notificationType === "request" &&
1228
1343
  n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
1229
1344
  n.type === ExtendedApprovalType.SUPPLIER &&
@@ -1261,7 +1376,7 @@ export async function rejectSupplier(supplierId, reason) {
1261
1376
  entity: `supplier ${supplierId}`,
1262
1377
  details: `with reason "${reason}" and send requester reply email`,
1263
1378
  });
1264
- const notifications = await api.budget.getNotifications.query();
1379
+ const notifications = await getAllNotifications();
1265
1380
  const pending = notifications.filter((n) => n.notificationType === "request" &&
1266
1381
  n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
1267
1382
  n.type === ExtendedApprovalType.SUPPLIER &&
@@ -1300,7 +1415,7 @@ export async function rejectBill(billId, reason) {
1300
1415
  entity: `bill ${billId}`,
1301
1416
  details: `with reason "${reason}" and send requester reply email`,
1302
1417
  });
1303
- const notifications = await api.budget.getNotifications.query();
1418
+ const notifications = await getAllNotifications();
1304
1419
  const pending = notifications.filter((n) => n.notificationType === "request" &&
1305
1420
  n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
1306
1421
  n.type === ExtendedApprovalType.BILL &&
@@ -1314,6 +1429,10 @@ export async function rejectBill(billId, reason) {
1314
1429
  status: ExtendedApprovalStatus.REJECTED,
1315
1430
  rejectionReason: reason,
1316
1431
  });
1432
+ if (result.processing) {
1433
+ out(result);
1434
+ return;
1435
+ }
1317
1436
  let email;
1318
1437
  try {
1319
1438
  await api.email.sendReplyBillApprovalEmail.mutate(result.updatedApproval);
@@ -1335,7 +1454,7 @@ export async function approveQuotation(quotationId) {
1335
1454
  action: "Approve quotation",
1336
1455
  entity: `quotation ${quotationId}`,
1337
1456
  });
1338
- const notifications = await api.budget.getNotifications.query();
1457
+ const notifications = await getAllNotifications();
1339
1458
  const pending = notifications.filter((n) => isPendingRequest(n) &&
1340
1459
  n.type === QUOTATION &&
1341
1460
  (n.supplierQuotationId === quotationId ||
@@ -1355,7 +1474,7 @@ export async function rejectQuotation(quotationId, reason) {
1355
1474
  entity: `quotation ${quotationId}`,
1356
1475
  details: `with reason "${reason}"`,
1357
1476
  });
1358
- const notifications = await api.budget.getNotifications.query();
1477
+ const notifications = await getAllNotifications();
1359
1478
  const pending = notifications.filter((n) => isPendingRequest(n) &&
1360
1479
  n.type === QUOTATION &&
1361
1480
  (n.supplierQuotationId === quotationId ||
@@ -1424,6 +1543,7 @@ export async function createProject(opts) {
1424
1543
  contactPersonId: opts.contactPersonId,
1425
1544
  insideSalesId: opts.insideSalesId,
1426
1545
  businessDevelopmentId: opts.businessDevelopmentId,
1546
+ projectManagerId: opts.projectManagerId,
1427
1547
  venue: opts.venue,
1428
1548
  pax: opts.pax,
1429
1549
  asanaTaskId: links.asanaTaskId,
@@ -1447,8 +1567,8 @@ export async function updateProjectStatus(id, status, opts) {
1447
1567
  id,
1448
1568
  status,
1449
1569
  ...(opts?.projectManagerId && { projectManagerId: opts.projectManagerId }),
1450
- ...(opts?.projectManagerName && {
1451
- projectManagerName: opts.projectManagerName,
1570
+ ...(opts?.projectManagerEmail && {
1571
+ projectManagerEmail: opts.projectManagerEmail,
1452
1572
  }),
1453
1573
  ...(opts?.wonOverrideReason && {
1454
1574
  wonOverrideReason: opts.wonOverrideReason,
@@ -1495,6 +1615,22 @@ export async function getSupplierAnalytics(opts) {
1495
1615
  out(result);
1496
1616
  }
1497
1617
  // --- Dashboard / analytics ---
1618
+ export async function createUser(input) {
1619
+ const user = await api.user.create.mutate(input);
1620
+ out(user);
1621
+ }
1622
+ export async function createApiKeyForUser(input) {
1623
+ const apiKey = await api.mcpApiKey.create.mutate(input);
1624
+ out(apiKey);
1625
+ }
1626
+ export async function listApiKeysForUser(input) {
1627
+ const apiKeys = await api.mcpApiKey.list.query(input);
1628
+ out(apiKeys);
1629
+ }
1630
+ export async function revokeApiKeyForUser(input) {
1631
+ const result = await api.mcpApiKey.revoke.mutate(input);
1632
+ out(result);
1633
+ }
1498
1634
  export async function listUsers() {
1499
1635
  const users = await api.user.getAllUsers.query();
1500
1636
  out(users);
package/dist/index.js CHANGED
@@ -2,9 +2,9 @@
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, 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";
5
+ import { addBudgetItems, approveBill, approveBudget, approveQuotation, approveSupplier, createApiKeyForUser, createBillApproval, createBillFromPayload, createBudgetApproval, createBudgetCategory, createBudgetFromPayload, createCompany, createContactPersonFromPayload, createEstimate, createItemCategory, createItemFromPayload, createProject, createQuotationFromPayload, createSupplierCertification, createSupplierFromPayload, createSupplierPaymentMethod, createSupplierRoleOption, createSupplierTagOption, createUser, 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, listApiKeysForUser, listApprovals, listBills, listBudgets, listCompanies, listContacts, listItemCategories, listItems, listProjects, listQuotations, listSuppliers, listUsers, markBudgetWonWithProof, patchBillInvoiceNumber, patchBillPayment, reactivateSuppliersByIds, rejectBill, rejectBudget, rejectQuotation, rejectSupplier, removeBudgetItem, reorderBudgetItemsCli, revokeApiKeyForUser, stageBillAttachmentsFromPaths, submitQuotation, updateBillFromPayload, updateBillStatus, updateBudgetCategory, updateBudgetCommissionFromPayload, updateBudgetDiscountFromPayload, updateBudgetFromPayload, updateBudgetItem, updateBudgetItemSupplierCli, updateBudgetStatus, updateCompanyFromPayload, updateContactPersonFromPayload, updateItemCategory, updateItemFromPayload, updateProjectFromPayload, updateProjectStatus, updateSupplierFromPayload, uploadBillAttachmentFromPath, uploadBillAttachmentsFromPaths, uploadBillDocumentsFromPaths, uploadBudgetAttachmentFromPath, uploadQuotationAttachmentFromPath, } from "./commands.js";
6
6
  import { getFlag, parseArgs } from "./parse-args.js";
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";
7
+ import { billStatusesForUpdateHelp, budgetStatusesForHelp, isBudgetStatusUpdate, parseApprovalTypeFlag, parseBillStatusForUpdate, parseCommaSeparatedBillStatuses, parseCommaSeparatedBudgetStatuses, parseCommaSeparatedIds, 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";
9
9
  function parsePositiveIntFlag(value, flagName) {
10
10
  if (value === undefined)
@@ -145,7 +145,7 @@ Budgets
145
145
  create-budget-approval <budgetId> (also sends approval request emails)
146
146
  create-estimate <budgetId> (creates QuickBooks estimate; supports preflight updates)
147
147
  add-budget-items --budgetId --items '[{"itemId":"…","quantity":1,"markup":30},…]'
148
- update-budget-item --id <budgetItemId> [--description plain-text] [--note plain-text] [--quantity] [--markup] [--cost] [--unitPrice] [--isFreeOfCharge true|false] [--gstInclusive true|false]
148
+ update-budget-item --id <budgetItemId> [--description plain-text] [--note plain-text] [--quantity] [--markup] [--cost] [--unitPrice] [--isFreeOfCharge true|false] [--gstInclusive true|false] [--gstOutOfScope true|false]
149
149
  remove-budget-item <budgetItemId>
150
150
  reorder-budget-items <budgetId> --itemIds <csv>
151
151
  update-budget-item-supplier --budgetItemId --supplierId
@@ -162,23 +162,27 @@ Bills
162
162
  isClaimable differentiates the shared bill/claim records: false = bill, true = claim.
163
163
  New supplier bills from 1 Jul 2026 00:00 SGT require approved quotation coverage for each non-legacy line item.
164
164
  create-bill-approval <billId> (also sends approval request emails)
165
- create-bill --payload '<json>' (bill.create; payload.isClaimable false = bill, true = claim; use upload-bill-attachment after create to add files)
165
+ create-bill --payload '<json>' (bill.create; payload.isClaimable false = bill, true = claim)
166
+ stage-bill-attachment <projectId> <filePath...> [--file <path>] [--files <csv>] Upload files before create-bill; returns attachment JSON for attachments/paymentProofAttachments
166
167
  update-bill --payload '<json>' (bill.update; must include id)
167
168
  delete-bill <billId>
168
169
  update-bill-status <id> <status> [--rejectionReason] [--paymentTrackingUrl] [--paymentReference]
169
170
  status (${billStatusesForUpdateHelp.join(", ")}) — not PENDING_APPROVAL; use create-bill-approval
171
+ 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.
170
172
  patch-bill-payment <billId> [--paymentTrackingUrl] [--paymentReference] [--quickbooksBillId] [--paymentDate <ISO>]
171
173
  Clear a field: --clearPaymentTrackingUrl true | --clearPaymentReference true | --clearQuickbooksBillId true | --clearPaymentDate true
172
174
  patch-bill-invoice-number <billId> <invoiceNumber>
173
175
  get-bill-attachments <billId>
174
- upload-bill-attachment <billId> <filePath> Presigned S3 upload + confirm (same flow as web UI)
176
+ upload-bill-attachment <billId> <filePath...> [--file <path>] [--files <csv>] Attach invoice/supporting files as BILL attachments to any bill status, including PAID bills
177
+ attach-bill-payment-receipt <billId> <filePath...> [--file <path>] [--files <csv>] Attach Wise/payment-proof PDFs as BILL_PAYMENT_PROOF attachments
178
+ attach-bill-documents <billId> [--invoice <path>] [--invoices <csv>] [--paymentProof|--payment-proof|--proof <path>] [--paymentProofs|--payment-proofs|--proofs <csv>] Attach invoice and/or payment proof files with separate BB attachment types
175
179
  get-bill-details <billId>
176
180
 
177
181
  Quotations
178
182
  list-quotations [--projectId] [--budgetId] [--supplierId] [--status CSV] [--page] [--pageSize]
179
183
  get-quotation <quotationId>
180
184
  upload-quotation-attachment <projectId> <filePath> Presigned S3 upload; returns attachment JSON for create-quotation payload
181
- create-quotation --payload '<json>' (quotation.createDraft)
185
+ create-quotation --payload '<json>' (quotation.createDraft; requires amountWithoutGst and gstAmount)
182
186
  submit-quotation <quotationId>
183
187
  approve-quotation <quotationId>
184
188
  reject-quotation <quotationId> --reason <text>
@@ -203,11 +207,12 @@ Companies & projects
203
207
  list-projects [--companyId] [--name] [--status] [--active true|false] [--page] [--perPage]
204
208
  Omit --active to include both active and inactive projects.
205
209
  get-project <id>
206
- create-project --name --companyId --contactPersonId --insideSalesId --businessDevelopmentId --venue --startDate <ISO> [--asanaTaskId] [--asanaSearch <text>] [--slackChannelId --slackChannelUrl --slackChannelName] [--pax] [--endDate <ISO>] [--description]
207
- update-project --payload '<json>' (project.updateProject; optional requestQboAccountantNotification; default notify like web)
210
+ Budget overview totalRevenue includes Estimate Accepted and Estimate Closed budgets only.
211
+ create-project --name --companyId --contactPersonId --insideSalesId --businessDevelopmentId --venue --startDate <ISO> [--projectManagerId <userId>] [--asanaTaskId] [--asanaSearch <text>] [--slackChannelId --slackChannelUrl --slackChannelName] [--pax] [--endDate <ISO>] [--description]
212
+ update-project --payload '<json>' (project.updateProject; optional projectManagerId and requestQboAccountantNotification; default notify like web)
208
213
  delete-project <id>
209
- update-project-status <id> <status> [--projectManagerId <id>] [--projectManagerName <name>] [--wonOverrideReason <text>]
210
- Required when moving PITCH → WON: supply --projectManagerId or --projectManagerName.
214
+ update-project-status <id> <status> [--projectManagerId <userId>] [--projectManagerEmail <email>] [--wonOverrideReason <text>]
215
+ Required when moving PITCH → WON: supply --projectManagerId or --projectManagerEmail.
211
216
  Required when moving to WON without accepted/closed budget or win proof: --wonOverrideReason (10+ chars).
212
217
  status: ${projectStatusesForHelp.join(", ")}
213
218
 
@@ -241,6 +246,10 @@ Suppliers & items
241
246
 
242
247
  Dashboard & users
243
248
  list-users
249
+ create-user --email <email> [--name <name>] [--role ${userRolesForHelp.join("|")}] (admin; API-only service identity with no Google sign-in)
250
+ create-api-key --userId <userId> --name <label> (admin; raw key is shown once)
251
+ list-api-keys [--userId <userId>] (admin; defaults to the caller)
252
+ revoke-api-key <apiKeyId> [--userId <userId>] (admin; --userId is required for another user's key)
244
253
  get-user-performance [--userId]
245
254
  get-dashboard [--userId] [--role BD|CREATOR|INSIDE_SALES|ALL] [--deals ALL|SUCCESSFUL|LOST] [--timeFrame] [--startDate] [--endDate]
246
255
  get-monthly-metrics [same optional flags as get-dashboard] (dashboard.getMonthlyMetrics)
@@ -472,6 +481,7 @@ async function main() {
472
481
  unitPrice: parseOptNum(getFlag(flags, "unitPrice"), "--unitPrice"),
473
482
  isFreeOfCharge: parseOptionalBoolFlag(flags, "isFreeOfCharge"),
474
483
  gstInclusive: parseOptionalBoolFlag(flags, "gstInclusive"),
484
+ gstOutOfScope: parseOptionalBoolFlag(flags, "gstOutOfScope"),
475
485
  });
476
486
  break;
477
487
  }
@@ -684,12 +694,91 @@ async function main() {
684
694
  await getBillAttachments(id);
685
695
  break;
686
696
  }
687
- case "upload-bill-attachment": {
688
- const [billId, filePath] = positional;
689
- if (!billId || !filePath) {
690
- throw new Error("upload-bill-attachment requires <billId> <filePath>");
697
+ case "stage-bill-attachment": {
698
+ const [projectId, ...positionalFilePaths] = positional;
699
+ const fileFlag = getFlag(flags, "file");
700
+ const filesFlag = getFlag(flags, "files");
701
+ const filePaths = [
702
+ ...positionalFilePaths,
703
+ ...(fileFlag !== undefined ? [String(fileFlag)] : []),
704
+ ...(filesFlag !== undefined
705
+ ? String(filesFlag)
706
+ .split(",")
707
+ .map((value) => value.trim())
708
+ .filter(Boolean)
709
+ : []),
710
+ ];
711
+ if (!projectId || filePaths.length === 0) {
712
+ throw new Error("stage-bill-attachment requires <projectId> <filePath...> or --file/--files");
713
+ }
714
+ await stageBillAttachmentsFromPaths(projectId, filePaths);
715
+ break;
716
+ }
717
+ case "upload-bill-attachment":
718
+ case "attach-bill-attachment":
719
+ case "attach-bill-payment-receipt": {
720
+ const [billId, ...positionalFilePaths] = positional;
721
+ const fileFlag = getFlag(flags, "file");
722
+ const filesFlag = getFlag(flags, "files");
723
+ const filePaths = [
724
+ ...positionalFilePaths,
725
+ ...(fileFlag !== undefined ? [String(fileFlag)] : []),
726
+ ...(filesFlag !== undefined
727
+ ? String(filesFlag)
728
+ .split(",")
729
+ .map((value) => value.trim())
730
+ .filter(Boolean)
731
+ : []),
732
+ ];
733
+ if (!billId || filePaths.length === 0) {
734
+ throw new Error(`${cmd} requires <billId> <filePath...> or --file/--files`);
735
+ }
736
+ const attachmentType = cmd === "attach-bill-payment-receipt" ? "BILL_PAYMENT_PROOF" : "BILL";
737
+ const [firstFilePath] = filePaths;
738
+ if (filePaths.length === 1 && firstFilePath !== undefined) {
739
+ await uploadBillAttachmentFromPath(billId, firstFilePath, attachmentType);
691
740
  }
692
- await uploadBillAttachmentFromPath(billId, filePath);
741
+ else {
742
+ await uploadBillAttachmentsFromPaths(billId, filePaths, attachmentType);
743
+ }
744
+ break;
745
+ }
746
+ case "attach-bill-documents": {
747
+ const billId = positional[0];
748
+ if (!billId)
749
+ throw new Error("attach-bill-documents requires <billId>");
750
+ const getFlagAlias = (...flagNames) => {
751
+ for (const flagName of flagNames) {
752
+ const value = getFlag(flags, flagName);
753
+ if (value !== undefined)
754
+ return value;
755
+ }
756
+ return undefined;
757
+ };
758
+ const parseFilesFlag = (...flagNames) => {
759
+ const raw = getFlagAlias(...flagNames);
760
+ if (raw === undefined)
761
+ return [];
762
+ return String(raw)
763
+ .split(",")
764
+ .map((value) => value.trim())
765
+ .filter(Boolean);
766
+ };
767
+ const invoicePath = getFlagAlias("invoice", "invoice-file");
768
+ const paymentProofPath = getFlagAlias("paymentProof", "payment-proof", "proof", "receipt");
769
+ const invoicePaths = [
770
+ ...(invoicePath !== undefined ? [String(invoicePath)] : []),
771
+ ...parseFilesFlag("invoices", "invoice-files"),
772
+ ];
773
+ const paymentProofPaths = [
774
+ ...(paymentProofPath !== undefined ? [String(paymentProofPath)] : []),
775
+ ...parseFilesFlag("paymentProofs", "payment-proofs", "proofs", "receipts"),
776
+ ];
777
+ await uploadBillDocumentsFromPaths({
778
+ billId,
779
+ invoicePaths,
780
+ paymentProofPaths,
781
+ });
693
782
  break;
694
783
  }
695
784
  case "get-bill-details": {
@@ -911,6 +1000,7 @@ async function main() {
911
1000
  const contactPersonId = getFlag(flags, "contactPersonId");
912
1001
  const insideSalesId = getFlag(flags, "insideSalesId");
913
1002
  const businessDevelopmentId = getFlag(flags, "businessDevelopmentId");
1003
+ const projectManagerId = getFlag(flags, "projectManagerId");
914
1004
  const venue = getFlag(flags, "venue");
915
1005
  const pax = getFlag(flags, "pax");
916
1006
  const asanaTaskId = getFlag(flags, "asanaTaskId");
@@ -929,7 +1019,7 @@ async function main() {
929
1019
  !businessDevelopmentId ||
930
1020
  !venue ||
931
1021
  !startDate) {
932
- throw new Error("create-project requires --name --companyId --contactPersonId --insideSalesId --businessDevelopmentId --venue --startDate (ISO/datetime flags as appropriate); optional --asanaTaskId, --asanaSearch, --pax and --endDate");
1022
+ throw new Error("create-project requires --name --companyId --contactPersonId --insideSalesId --businessDevelopmentId --venue --startDate (ISO/datetime flags as appropriate); optional --projectManagerId, --asanaTaskId, --asanaSearch, --pax and --endDate");
933
1023
  }
934
1024
  await createProject({
935
1025
  name,
@@ -937,6 +1027,7 @@ async function main() {
937
1027
  contactPersonId,
938
1028
  insideSalesId,
939
1029
  businessDevelopmentId,
1030
+ projectManagerId,
940
1031
  venue,
941
1032
  pax,
942
1033
  asanaTaskId,
@@ -972,11 +1063,13 @@ async function main() {
972
1063
  throw new Error("update-project-status requires <id> <status>");
973
1064
  }
974
1065
  const pmIdFlag = getFlag(flags, "projectManagerId");
975
- const pmNameFlag = getFlag(flags, "projectManagerName");
1066
+ const pmEmailFlag = getFlag(flags, "projectManagerEmail");
976
1067
  const wonOverrideReasonFlag = getFlag(flags, "wonOverrideReason");
977
1068
  await updateProjectStatus(id, parseProjectStatusForUpdate(statusRaw), {
978
1069
  ...(pmIdFlag && { projectManagerId: String(pmIdFlag) }),
979
- ...(pmNameFlag && { projectManagerName: String(pmNameFlag) }),
1070
+ ...(pmEmailFlag && {
1071
+ projectManagerEmail: String(pmEmailFlag),
1072
+ }),
980
1073
  ...(wonOverrideReasonFlag && {
981
1074
  wonOverrideReason: String(wonOverrideReasonFlag),
982
1075
  }),
@@ -1171,6 +1264,42 @@ async function main() {
1171
1264
  await listUsers();
1172
1265
  break;
1173
1266
  }
1267
+ case "create-user": {
1268
+ const email = getFlag(flags, "email");
1269
+ if (!email) {
1270
+ throw new Error(`create-user requires --email <email> [--name <name>] [--role ${userRolesForHelp.join("|")}]`);
1271
+ }
1272
+ await createUser({
1273
+ email,
1274
+ name: getFlag(flags, "name"),
1275
+ role: parseUserRole(getFlag(flags, "role")),
1276
+ });
1277
+ break;
1278
+ }
1279
+ case "create-api-key": {
1280
+ const userId = getFlag(flags, "userId");
1281
+ const name = getFlag(flags, "name");
1282
+ if (!userId || !name) {
1283
+ throw new Error("create-api-key requires --userId <userId> --name <label>");
1284
+ }
1285
+ await createApiKeyForUser({ userId, name });
1286
+ break;
1287
+ }
1288
+ case "list-api-keys": {
1289
+ await listApiKeysForUser({ userId: getFlag(flags, "userId") });
1290
+ break;
1291
+ }
1292
+ case "revoke-api-key": {
1293
+ const id = positional[0];
1294
+ if (!id) {
1295
+ throw new Error("revoke-api-key requires <apiKeyId> [--userId <userId>]");
1296
+ }
1297
+ await revokeApiKeyForUser({
1298
+ id,
1299
+ userId: getFlag(flags, "userId"),
1300
+ });
1301
+ break;
1302
+ }
1174
1303
  case "get-user-performance": {
1175
1304
  await getUserPerformance(getFlag(flags, "userId"));
1176
1305
  break;
@@ -1,8 +1,9 @@
1
1
  import { BudgetRole, Deals, ExtendedApprovalStatus, ExtendedBillStatus, ExtendedErrorSeverity, ExtendedErrorStatus, ExtendedProjectStatus, TimeFrame, } from "./filter-enums.js";
2
- import { BillStatus, BudgetStatus, ProjectStatus } from "./prisma-enums.js";
2
+ import { BillStatus, BudgetStatus, ProjectStatus, UserRole, } from "./prisma-enums.js";
3
3
  const BUDGET_STATUS_VALUES = new Set(Object.values(BudgetStatus));
4
4
  const BILL_STATUS_VALUES = new Set(Object.values(BillStatus));
5
5
  const PROJECT_STATUS_VALUES = new Set(Object.values(ProjectStatus));
6
+ const USER_ROLE_VALUES = new Set(Object.values(UserRole));
6
7
  const EXTENDED_PROJECT_STATUS_VALUES = new Set(Object.values(ExtendedProjectStatus));
7
8
  const DASHBOARD_ROLE_VALUES = new Set([
8
9
  "BD",
@@ -52,6 +53,15 @@ export function parseProjectStatusForUpdate(raw) {
52
53
  }
53
54
  return raw;
54
55
  }
56
+ export function parseUserRole(raw) {
57
+ if (raw === undefined || raw === "")
58
+ return UserRole.USER;
59
+ const normalizedRole = raw.trim().toUpperCase();
60
+ if (!USER_ROLE_VALUES.has(normalizedRole)) {
61
+ throw new Error(`Invalid user role "${raw}". Use one of: ${[...USER_ROLE_VALUES].join(", ")}.`);
62
+ }
63
+ return normalizedRole;
64
+ }
55
65
  export function parseOptionalExtendedProjectStatus(raw) {
56
66
  if (raw === undefined || raw === "")
57
67
  return undefined;
@@ -233,3 +243,4 @@ export const budgetStatusesForHelp = Object.values(BudgetStatus);
233
243
  export const billStatusesForHelp = Object.values(BillStatus);
234
244
  export const billStatusesForUpdateHelp = Object.values(BillStatus).filter((s) => s !== BillStatus.PENDING_APPROVAL);
235
245
  export const projectStatusesForHelp = Object.values(ProjectStatus);
246
+ export const userRolesForHelp = Object.values(UserRole);
@@ -77,8 +77,8 @@ export function parseBudgetDiscountPayload(raw) {
77
77
  totalSellingBeforeDiscount: Number(o.totalSellingBeforeDiscount),
78
78
  };
79
79
  }
80
- const billAttachment = (x, i) => {
81
- const label = `create-bill payload.attachments[${i}]`;
80
+ const billAttachment = (x, i, field = "attachments") => {
81
+ const label = `create-bill payload.${field}[${i}]`;
82
82
  const a = requireObject(x, label);
83
83
  return {
84
84
  id: requiredString(a.id, `${label}.id`),
@@ -102,6 +102,16 @@ export function parseCreateBillPayload(raw) {
102
102
  const attachments = Array.isArray(attachmentsRaw)
103
103
  ? attachmentsRaw.map((x, i) => billAttachment(x, i))
104
104
  : [];
105
+ const alreadyPaidRaw = o.alreadyPaid;
106
+ if (alreadyPaidRaw !== undefined &&
107
+ alreadyPaidRaw !== null &&
108
+ typeof alreadyPaidRaw !== "boolean") {
109
+ throw new Error("create-bill payload.alreadyPaid must be boolean.");
110
+ }
111
+ const paymentProofAttachmentsRaw = o.paymentProofAttachments;
112
+ const paymentProofAttachments = Array.isArray(paymentProofAttachmentsRaw)
113
+ ? paymentProofAttachmentsRaw.map((x, i) => billAttachment(x, i, "paymentProofAttachments"))
114
+ : [];
105
115
  let verificationResults;
106
116
  const vr = o.verificationResults;
107
117
  if (vr !== undefined && vr !== null) {
@@ -153,6 +163,9 @@ export function parseCreateBillPayload(raw) {
153
163
  comment: optionalString(o.comment),
154
164
  isClaimable,
155
165
  attachments,
166
+ alreadyPaid: alreadyPaidRaw ?? undefined,
167
+ paymentReference: optionalString(o.paymentReference),
168
+ paymentProofAttachments,
156
169
  extractedSupplierName: optionalString(o.extractedSupplierName),
157
170
  extractedAmount: o.extractedAmount === undefined || o.extractedAmount === null
158
171
  ? undefined
@@ -214,6 +227,7 @@ export function parseUpdateProjectPayload(raw) {
214
227
  asanaTaskId: requiredString(o.asanaTaskId, "update-project payload.asanaTaskId"),
215
228
  insideSalesId: requiredString(o.insideSalesId, "update-project payload.insideSalesId"),
216
229
  businessDevelopmentId: requiredString(o.businessDevelopmentId, "update-project payload.businessDevelopmentId"),
230
+ projectManagerId: optionalString(o.projectManagerId),
217
231
  venue: requiredString(o.venue, "update-project payload.venue"),
218
232
  pax: optionalString(o.pax),
219
233
  dateRange: {
@@ -36,6 +36,13 @@ export const ApprovalType = {
36
36
  CUSTOMER_INVOICE: "CUSTOMER_INVOICE",
37
37
  QUOTATION: "QUOTATION",
38
38
  };
39
+ export const UserRole = {
40
+ USER: "USER",
41
+ LEAD: "LEAD",
42
+ ADMIN: "ADMIN",
43
+ INSIDE_SALES: "INSIDE_SALES",
44
+ ACCOUNTING_TEAM: "ACCOUNTING_TEAM",
45
+ };
39
46
  export const ErrorSeverity = {
40
47
  LOW: "LOW",
41
48
  MEDIUM: "MEDIUM",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@go-labs-sg/bb",
3
- "version": "1.16.0",
3
+ "version": "1.18.1",
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",