@go-labs-sg/bb 1.18.1 → 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, `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, `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,21 +80,27 @@ 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`, `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`) |
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` |
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`) |
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` |
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`) |
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` |
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` |
94
+ | **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`, `check-project-reconciliation <id>` (runs the web app's live checks without changing status), `reconcile-project <id>` (reruns checks transactionally and marks an eligible project `RECONCILED`), `complete-project <id>` (reruns validation, marks a reconciled project `COMPLETED`, and queues QuickBooks placeholder cleanup), `import-qbo-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; marking `COMPLETED` queues deletion of every remaining project placeholder bill from QuickBooks; for `PITCH` → `WON` also pass a Budget Builder user `--projectManagerId` or `--projectManagerEmail`; when marking `WON` without an accepted/closed budget or proof, pass `--wonOverrideReason`) |
92
95
  | **Contacts** | `list-contacts`, `create-contact-person` (`--payload`), `update-contact-person` (`--payload`) |
93
96
  | **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) |
94
97
  | **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` |
95
98
  | **Errors** | `get-recent-errors`, `get-error-metrics` |
99
+ | **Automation (admin)** | `list-integration-operations`, `retry-integration-operation` |
96
100
  | **Historical / benchmarks** | `get-approved-budgets`, `get-budget-category-benchmarks`, `get-item-pricing-history`, `get-supplier-pricing-history` |
97
101
 
102
+ The CLI intentionally wraps low-level upload-request/confirm procedures into file-based commands and omits browser-only helpers such as navigation counts, combobox/facet data, recent-page bookkeeping, and live UI subscriptions. Operational workflows for bills, claims, quotations, customer invoices, project reconciliation/completion, and integration recovery are available directly.
103
+
98
104
  ## Output
99
105
 
100
106
  - **Stdout:** JSON (pretty-printed)
@@ -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
@@ -1,18 +1,19 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readFile } from "node:fs/promises";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
3
  import { basename } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  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, parseItemCreatePayload, parseItemUpdatePayload, parseSupplierCreatePayload, parseSupplierUpdatePayload, parseUpdateBillPayload, parseUpdateBudgetCommissionPayload, parseUpdateBudgetPayload, parseUpdateProjectPayload, } from "./parse-mutation-payload.js";
10
- import { BudgetStatus, } 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";
14
14
  const SUPPLIER = "SUPPLIER";
15
15
  const QUOTATION = "QUOTATION";
16
+ const CUSTOMER_INVOICE = "CUSTOMER_INVOICE";
16
17
  const ASANA_WON_LOST_SECTION_GIDS = new Set([
17
18
  "1211678338364908",
18
19
  "1211678338364907",
@@ -261,6 +262,17 @@ const billAttachmentContentTypeForFileName = (fileName) => {
261
262
  }
262
263
  return resolvedContentType;
263
264
  };
265
+ const PAYMENT_PROOF_MAX_SIZE = 20 * 1024 * 1024;
266
+ const QUOTATION_ATTACHMENT_MAX_SIZE = 20 * 1024 * 1024;
267
+ const quotationAttachmentContentTypeForFileName = (fileName) => {
268
+ const resolvedContentType = contentTypeHeaderForFileName(fileName);
269
+ if (resolvedContentType === "application/pdf" ||
270
+ resolvedContentType === "image/jpeg" ||
271
+ resolvedContentType === "image/png") {
272
+ return resolvedContentType;
273
+ }
274
+ throw new Error(`Unsupported quotation attachment type: ${fileName}. Use PDF, JPEG, or PNG.`);
275
+ };
264
276
  const LOCKED_BUDGET_STATUSES_REQUIRING_CONFIRMATION = new Set([
265
277
  BudgetStatus.ESTIMATE_CREATED,
266
278
  BudgetStatus.ESTIMATE_SENT,
@@ -533,6 +545,10 @@ export async function listApprovals(opts) {
533
545
  id = n.supplierQuotation.id;
534
546
  name = `Quotation ${n.supplierQuotation.supplier.name}`;
535
547
  }
548
+ else if (n.type === CUSTOMER_INVOICE && n.customerInvoiceBatch) {
549
+ id = n.customerInvoiceBatch.id;
550
+ name = `Customer invoice ${n.customerInvoiceBatch.project.name}`;
551
+ }
536
552
  else {
537
553
  id = n.id;
538
554
  name = "Unknown";
@@ -593,20 +609,112 @@ export async function createBillApproval(billId) {
593
609
  email,
594
610
  });
595
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
+ };
596
685
  export async function updateBillStatus(opts) {
686
+ validateBillStatusUpdateOptions(opts);
597
687
  await assertSensitiveWorkflowConfirmed({
598
688
  action: "Update bill status",
599
689
  entity: `bill ${opts.id}`,
600
690
  details: `to ${opts.status}`,
601
691
  });
602
- const result = await api.bill.updateStatus.mutate({
603
- id: opts.id,
604
- status: opts.status,
605
- rejectionReason: opts.rejectionReason,
606
- paymentTrackingUrl: opts.paymentTrackingUrl,
607
- paymentReference: opts.paymentReference,
608
- });
609
- 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
+ }
610
718
  }
611
719
  export async function patchBillPayment(opts) {
612
720
  const input = { id: opts.id };
@@ -668,6 +776,13 @@ export async function stageBillAttachmentsFromPaths(projectId, filePaths) {
668
776
  attachments,
669
777
  });
670
778
  }
779
+ export const cleanupStagedBillAttachments = async (projectId, keys) => {
780
+ const result = await api.attachment.deleteStagedBillAttachments.mutate({
781
+ projectId,
782
+ keys,
783
+ });
784
+ out(result);
785
+ };
671
786
  export async function uploadBillAttachmentFromPath(billId, filePath, type = "BILL") {
672
787
  const confirmed = await uploadSingleBillAttachmentFromPath(billId, filePath, type);
673
788
  out(confirmed);
@@ -740,6 +855,10 @@ export async function uploadBillDocumentsFromPaths({ billId, invoicePaths = [],
740
855
  export async function uploadQuotationAttachmentFromPath(projectId, filePath) {
741
856
  const buf = await readFile(filePath);
742
857
  const fileName = basename(filePath);
858
+ if (buf.byteLength > QUOTATION_ATTACHMENT_MAX_SIZE) {
859
+ throw new Error("Quotation attachment must be 20MB or smaller.");
860
+ }
861
+ const attachmentContentType = quotationAttachmentContentTypeForFileName(fileName);
743
862
  const ext = fileName
744
863
  .split(".")
745
864
  .pop()
@@ -752,7 +871,7 @@ export async function uploadQuotationAttachmentFromPath(projectId, filePath) {
752
871
  const res = await fetch(uploadUrl, {
753
872
  method: "PUT",
754
873
  body: buf,
755
- headers: { "Content-Type": contentTypeHeaderForFileName(fileName) },
874
+ headers: { "Content-Type": attachmentContentType },
756
875
  });
757
876
  if (!res.ok) {
758
877
  throw new Error(`S3 upload failed: HTTP ${res.status} ${(await res.text()).slice(0, 500)}`);
@@ -764,6 +883,13 @@ export async function uploadQuotationAttachmentFromPath(projectId, filePath) {
764
883
  size: buf.byteLength,
765
884
  });
766
885
  }
886
+ export const cleanupStagedQuotationAttachments = async (projectId, keys) => {
887
+ const result = await api.quotation.deleteStagedAttachments.mutate({
888
+ projectId,
889
+ keys,
890
+ });
891
+ out(result);
892
+ };
767
893
  export async function uploadBudgetAttachmentFromPath(budgetId, filePath) {
768
894
  await confirmLockedBudgetChangeByBudgetId(budgetId, "Upload budget attachment");
769
895
  const buf = await readFile(filePath);
@@ -928,6 +1054,17 @@ export async function createEstimate(budgetId, preflight) {
928
1054
  const result = await api.quickbooks.createEstimate.mutate({ budgetId });
929
1055
  out(result);
930
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
+ };
931
1068
  export async function getBudgetDetails(budgetId) {
932
1069
  const detail = await api.budget.getBudgetDetail.query({ id: budgetId });
933
1070
  out(detail);
@@ -990,6 +1127,68 @@ export async function updateBudgetDiscountFromPayload(raw) {
990
1127
  const result = await api.budget.updateBudgetDiscount.mutate(input);
991
1128
  out(result);
992
1129
  }
1130
+ export const deleteBudgetDiscount = async (budgetId) => {
1131
+ await assertSensitiveWorkflowConfirmed({
1132
+ action: "Remove budget discount",
1133
+ entity: `budget ${budgetId}`,
1134
+ details: "and return the budget to DRAFT",
1135
+ });
1136
+ const result = await api.budget.deleteBudgetDiscount.mutate({ budgetId });
1137
+ out(result);
1138
+ };
1139
+ export const deleteBudgetCommission = async (budgetId) => {
1140
+ await assertSensitiveWorkflowConfirmed({
1141
+ action: "Remove budget commission",
1142
+ entity: `budget ${budgetId}`,
1143
+ details: "and return the budget to DRAFT",
1144
+ });
1145
+ const result = await api.budget.deleteBudgetCommission.mutate({ budgetId });
1146
+ out(result);
1147
+ };
1148
+ export const setBudgetItemsNotUtilized = async (budgetItemIds, notUtilized) => {
1149
+ await assertSensitiveWorkflowConfirmed({
1150
+ action: notUtilized
1151
+ ? "Mark budget lines Not Utilized"
1152
+ : "Restore budget line",
1153
+ entity: `budget item${budgetItemIds.length === 1 ? "" : "s"} ${budgetItemIds.join(", ")}`,
1154
+ details: notUtilized
1155
+ ? "and remove linked QuickBooks placeholder expenses"
1156
+ : "and restore its QuickBooks placeholder expense when required",
1157
+ });
1158
+ const result = await api.budget.setBudgetItemsNotUtilized.mutate({
1159
+ budgetItemIds,
1160
+ notUtilized,
1161
+ });
1162
+ out(result);
1163
+ };
1164
+ export const createPlaceholderBillForBudgetItem = async (budgetItemId) => {
1165
+ await assertSensitiveWorkflowConfirmed({
1166
+ action: "Create QuickBooks placeholder bill",
1167
+ entity: `budget item ${budgetItemId}`,
1168
+ });
1169
+ const result = await api.budget.createPlaceholderBillForBudgetItem.mutate({
1170
+ budgetItemId,
1171
+ });
1172
+ out(result);
1173
+ };
1174
+ export const renameBudgetVersion = async (versionId, newName) => {
1175
+ const result = await api.budgetVersion.renameBudgetVersion.mutate({
1176
+ versionId,
1177
+ newName,
1178
+ });
1179
+ out(result);
1180
+ };
1181
+ export const restoreBudgetVersion = async (versionId) => {
1182
+ await assertSensitiveWorkflowConfirmed({
1183
+ action: "Restore budget version",
1184
+ entity: `budget version ${versionId}`,
1185
+ details: "replace the current budget, project fields, items, and attachments, clear its QuickBooks estimate link, and return it to DRAFT",
1186
+ });
1187
+ const result = await api.budgetVersion.restoreBudgetVersion.mutate({
1188
+ versionId,
1189
+ });
1190
+ out(result);
1191
+ };
993
1192
  export async function deleteItemById(itemId) {
994
1193
  const result = await api.item.deleteItem.mutate({ id: itemId });
995
1194
  out(result);
@@ -1044,9 +1243,19 @@ export async function getQuotationDetails(id) {
1044
1243
  out(result);
1045
1244
  }
1046
1245
  export async function createQuotationFromPayload(raw) {
1047
- const result = await api.quotation.createDraft.mutate(raw);
1246
+ const input = parseCreateQuotationPayload(raw);
1247
+ const result = await api.quotation.createDraft.mutate(input);
1048
1248
  out(result);
1049
1249
  }
1250
+ export const updateQuotationFromPayload = async (raw) => {
1251
+ const input = parseUpdateQuotationPayload(raw);
1252
+ const result = await api.quotation.updateDraftOrRejected.mutate(input);
1253
+ out(result);
1254
+ };
1255
+ export const deleteQuotationById = async (id) => {
1256
+ const result = await api.quotation.delete.mutate({ id });
1257
+ out(result);
1258
+ };
1050
1259
  export async function submitQuotation(id) {
1051
1260
  await assertSensitiveWorkflowConfirmed({
1052
1261
  action: "Submit quotation for approval",
@@ -1062,11 +1271,121 @@ export async function downloadQuotationPdf(id, fileRole) {
1062
1271
  });
1063
1272
  out(result);
1064
1273
  }
1274
+ export const checkCustomerInvoiceReadiness = async (budgetId) => {
1275
+ const result = await api.customerInvoice.getInvoiceReadiness.query({
1276
+ budgetId,
1277
+ });
1278
+ out(result);
1279
+ };
1280
+ export const listCustomerInvoices = async (input) => {
1281
+ const result = await api.customerInvoice.listByBudget.query(input);
1282
+ out(result);
1283
+ };
1284
+ export const getCustomerInvoice = async (batchId) => {
1285
+ const result = await api.customerInvoice.getInvoiceDetail.query({ batchId });
1286
+ out(result);
1287
+ };
1288
+ export const createCustomerInvoice = async (raw) => {
1289
+ const input = parseCreateCustomerInvoicePayload(raw);
1290
+ await assertSensitiveWorkflowConfirmed({
1291
+ action: "Create customer invoice",
1292
+ entity: `budget ${input.budgetId}`,
1293
+ details: "create the invoice in QuickBooks and either approve it as admin or send approval-request emails",
1294
+ });
1295
+ const result = await api.customerInvoice.createInvoiceBatch.mutate(input);
1296
+ out(result);
1297
+ };
1298
+ export const discardCreatingCustomerInvoice = async (batchId) => {
1299
+ await assertSensitiveWorkflowConfirmed({
1300
+ action: "Discard unfinished customer invoice",
1301
+ entity: `invoice batch ${batchId}`,
1302
+ });
1303
+ const result = await api.customerInvoice.discardCreatingInvoiceBatch.mutate({
1304
+ batchId,
1305
+ });
1306
+ out(result);
1307
+ };
1308
+ export const deleteCustomerInvoice = async (batchId) => {
1309
+ await assertSensitiveWorkflowConfirmed({
1310
+ action: "Delete customer invoice",
1311
+ entity: `invoice batch ${batchId}`,
1312
+ details: "delete its invoices from QuickBooks and remove the local batch",
1313
+ });
1314
+ const result = await api.customerInvoice.deleteInvoiceBatch.mutate({
1315
+ batchId,
1316
+ });
1317
+ out(result);
1318
+ };
1319
+ export const voidCustomerInvoice = async (batchId) => {
1320
+ await assertSensitiveWorkflowConfirmed({
1321
+ action: "Void customer invoice",
1322
+ entity: `invoice batch ${batchId}`,
1323
+ details: "void its invoices in QuickBooks",
1324
+ });
1325
+ const result = await api.customerInvoice.voidInvoiceBatch.mutate({ batchId });
1326
+ out(result);
1327
+ };
1328
+ export const approveCustomerInvoice = async (batchId) => {
1329
+ await assertSensitiveWorkflowConfirmed({
1330
+ action: "Approve customer invoice",
1331
+ entity: `invoice batch ${batchId}`,
1332
+ details: "and notify its creator",
1333
+ });
1334
+ const result = await api.customerInvoice.approveInvoiceBatch.mutate({
1335
+ batchId,
1336
+ });
1337
+ out(result);
1338
+ };
1339
+ export const rejectCustomerInvoice = async (batchId, rejectionReason) => {
1340
+ await assertSensitiveWorkflowConfirmed({
1341
+ action: "Reject customer invoice",
1342
+ entity: `invoice batch ${batchId}`,
1343
+ details: `with reason "${rejectionReason}", void it in QuickBooks, and notify its creator`,
1344
+ });
1345
+ const result = await api.customerInvoice.rejectInvoiceBatch.mutate({
1346
+ batchId,
1347
+ rejectionReason,
1348
+ });
1349
+ out(result);
1350
+ };
1351
+ export const downloadCustomerInvoicePdf = async (invoiceId, outputPath) => {
1352
+ const result = await api.customerInvoice.downloadPdf.mutate({ invoiceId });
1353
+ const resolvedOutputPath = outputPath?.trim() || result.fileName;
1354
+ const contents = Buffer.from(result.base64, "base64");
1355
+ await writeFile(resolvedOutputPath, contents);
1356
+ out({
1357
+ contentType: result.contentType,
1358
+ fileName: result.fileName,
1359
+ outputPath: resolvedOutputPath,
1360
+ size: contents.byteLength,
1361
+ });
1362
+ };
1363
+ export const syncCustomerInvoice = async (invoiceId) => {
1364
+ await assertSensitiveWorkflowConfirmed({
1365
+ action: "Sync customer invoice from QuickBooks",
1366
+ entity: `invoice ${invoiceId}`,
1367
+ details: "and update its local status, QuickBooks metadata, balance, and history",
1368
+ });
1369
+ const result = await api.customerInvoice.syncInvoiceStatus.mutate({
1370
+ invoiceId,
1371
+ });
1372
+ out(result);
1373
+ };
1065
1374
  export async function updateBillFromPayload(raw) {
1066
1375
  const input = parseUpdateBillPayload(raw);
1067
1376
  const result = await api.bill.update.mutate(input);
1068
1377
  out(result);
1069
1378
  }
1379
+ export const validateBillSelectionFromPayload = async (raw) => {
1380
+ const input = parseValidateBillSelectionPayload(raw);
1381
+ const result = await api.bill.validateSelection.mutate(input);
1382
+ out(result);
1383
+ };
1384
+ export const updateBillPaymentEvidenceFromPayload = async (raw) => {
1385
+ const input = parseUpdateBillPaymentEvidencePayload(raw);
1386
+ const result = await api.bill.updatePaymentEvidence.mutate(input);
1387
+ out(result);
1388
+ };
1070
1389
  export async function deleteBillById(id) {
1071
1390
  const result = await api.bill.delete.mutate({ id });
1072
1391
  out(result);
@@ -1576,6 +1895,47 @@ export async function updateProjectStatus(id, status, opts) {
1576
1895
  });
1577
1896
  out(result);
1578
1897
  }
1898
+ export const checkProjectReconciliation = async (projectId) => {
1899
+ const result = await api.project.getReconciliationReview.query({ projectId });
1900
+ out(result);
1901
+ };
1902
+ export const reconcileProject = async (projectId) => {
1903
+ await assertSensitiveWorkflowConfirmed({
1904
+ action: "Reconcile project",
1905
+ entity: `project ${projectId}`,
1906
+ details: "rerun all reconciliation checks and mark it RECONCILED",
1907
+ });
1908
+ const result = await api.project.reconcile.mutate({ projectId });
1909
+ out(result);
1910
+ };
1911
+ export const completeProject = async (projectId) => {
1912
+ await updateProjectStatus(projectId, ProjectStatus.COMPLETED);
1913
+ };
1914
+ export const importQuickBooksProjectId = async (projectId, projectUrl) => {
1915
+ const result = await api.project.importQboProjectId.mutate({
1916
+ projectId,
1917
+ projectUrl,
1918
+ });
1919
+ out(result);
1920
+ };
1921
+ export const listIntegrationOperations = async (input) => {
1922
+ const result = await api.integration.listOperations.query(input);
1923
+ out(result);
1924
+ };
1925
+ export const retryIntegrationOperation = async (operationId, confirmExternalStateReconciled) => {
1926
+ await assertSensitiveWorkflowConfirmed({
1927
+ action: "Retry integration operation",
1928
+ entity: `operation ${operationId}`,
1929
+ details: confirmExternalStateReconciled
1930
+ ? "after confirming the external state has been reconciled"
1931
+ : undefined,
1932
+ });
1933
+ const result = await api.integration.retryOperation.mutate({
1934
+ operationId,
1935
+ confirmExternalStateReconciled,
1936
+ });
1937
+ out(result);
1938
+ };
1579
1939
  // --- Contacts ---
1580
1940
  export async function listContacts(companyId) {
1581
1941
  const contacts = await api.contactPerson.getContactPersonByCompanyId.query({