@go-labs-sg/bb 1.20.0 → 2.0.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 +142 -43
- package/command-manifest.json +6606 -0
- package/command-reference.md +1153 -0
- package/dist/api-client.js +44 -8
- package/dist/cli-trace.js +6 -1
- package/dist/commands.js +183 -23
- package/dist/index.js +389 -44
- package/dist/load-env.js +1 -1
- package/dist/parse-mutation-payload.js +22 -9
- package/dist/registry/generate-command-artifacts.js +36 -0
- package/dist/registry/index.js +583 -0
- package/dist/runtime/confirmation.js +76 -0
- package/dist/runtime/error.js +143 -0
- package/dist/runtime/index.js +6 -0
- package/dist/runtime/output.js +36 -0
- package/dist/runtime/process-runtime.js +28 -0
- package/dist/runtime/sanitize.js +50 -0
- package/dist/runtime/session.js +50 -0
- package/dist/runtime/types.js +1 -0
- package/package.json +13 -19
- package/role-aware-agent-guide.md +169 -0
package/dist/api-client.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import { createTRPCProxyClient, httpBatchLink, loggerLink } from "@trpc/client";
|
|
1
|
+
import { createTRPCProxyClient, httpBatchLink, loggerLink, } from "@trpc/client";
|
|
2
2
|
import SuperJSON from "superjson";
|
|
3
3
|
import { sanitizeValueForTrace, shouldLogCliActions } from "./cli-trace.js";
|
|
4
|
-
const
|
|
4
|
+
export const DEFAULT_BUDGET_BUILDER_API_BASE_URL = "https://budget-builder.getout.events";
|
|
5
|
+
const normalizeApiBaseUrl = (value) => {
|
|
6
|
+
const url = new URL(value);
|
|
7
|
+
const isLocalHttp = url.protocol === "http:" &&
|
|
8
|
+
(url.hostname === "localhost" || url.hostname === "127.0.0.1");
|
|
9
|
+
if (url.protocol !== "https:" && !isLocalHttp) {
|
|
10
|
+
throw new Error("Budget Builder API URL must use HTTPS, except for localhost development.");
|
|
11
|
+
}
|
|
12
|
+
url.pathname = url.pathname.replace(/\/$/, "");
|
|
13
|
+
return url.toString().replace(/\/$/, "");
|
|
14
|
+
};
|
|
15
|
+
export let BUDGET_BUILDER_API_BASE_URL = DEFAULT_BUDGET_BUILDER_API_BASE_URL;
|
|
5
16
|
function getAuthHeader() {
|
|
6
17
|
const key = process.env.BB_API_KEY;
|
|
7
18
|
if (!key?.trim())
|
|
@@ -17,7 +28,7 @@ const containsRawApiKey = (value, seen = new WeakSet()) => {
|
|
|
17
28
|
return Object.entries(value).some(([key, nestedValue]) => (key.toLowerCase() === "apikey" && typeof nestedValue === "string") ||
|
|
18
29
|
containsRawApiKey(nestedValue, seen));
|
|
19
30
|
};
|
|
20
|
-
export const
|
|
31
|
+
export const createBudgetBuilderClient = (apiBaseUrl = BUDGET_BUILDER_API_BASE_URL) => createTRPCProxyClient({
|
|
21
32
|
links: [
|
|
22
33
|
loggerLink({
|
|
23
34
|
enabled: (opts) => shouldLogCliActions() &&
|
|
@@ -31,7 +42,7 @@ export const api = createTRPCProxyClient({
|
|
|
31
42
|
},
|
|
32
43
|
}),
|
|
33
44
|
httpBatchLink({
|
|
34
|
-
url: `${
|
|
45
|
+
url: `${apiBaseUrl}/api/trpc`,
|
|
35
46
|
transformer: SuperJSON,
|
|
36
47
|
headers: () => {
|
|
37
48
|
const headers = {
|
|
@@ -45,13 +56,38 @@ export const api = createTRPCProxyClient({
|
|
|
45
56
|
}),
|
|
46
57
|
],
|
|
47
58
|
});
|
|
59
|
+
let activeClient = createBudgetBuilderClient();
|
|
60
|
+
export const configureBudgetBuilderApiBaseUrl = (value) => {
|
|
61
|
+
const normalized = normalizeApiBaseUrl(value);
|
|
62
|
+
BUDGET_BUILDER_API_BASE_URL = normalized;
|
|
63
|
+
activeClient = createBudgetBuilderClient(normalized);
|
|
64
|
+
return normalized;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Stable facade used by command handlers. Tests can replace the transport without
|
|
68
|
+
* teaching commands about tRPC or process-wide authentication.
|
|
69
|
+
*/
|
|
70
|
+
export const api = new Proxy({}, {
|
|
71
|
+
get: (_target, property, receiver) => Reflect.get(activeClient, property, receiver),
|
|
72
|
+
});
|
|
73
|
+
export const setBudgetBuilderClientForTesting = (client) => {
|
|
74
|
+
const previous = activeClient;
|
|
75
|
+
activeClient = client;
|
|
76
|
+
return () => {
|
|
77
|
+
activeClient = previous;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
export class MissingApiKeyError extends Error {
|
|
81
|
+
code = "AUTHENTICATION";
|
|
82
|
+
constructor() {
|
|
83
|
+
super("BB_API_KEY is required. Set it in the environment.");
|
|
84
|
+
this.name = "MissingApiKeyError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
48
87
|
export function requireApiKey() {
|
|
49
88
|
const key = process.env.BB_API_KEY?.trim();
|
|
50
89
|
if (!key) {
|
|
51
|
-
|
|
52
|
-
error: "BB_API_KEY is required. Set it in the environment.",
|
|
53
|
-
}));
|
|
54
|
-
process.exit(1);
|
|
90
|
+
throw new MissingApiKeyError();
|
|
55
91
|
}
|
|
56
92
|
return key;
|
|
57
93
|
}
|
package/dist/cli-trace.js
CHANGED
|
@@ -29,8 +29,12 @@ const SENSITIVE_TRACE_FIELD = new Set([
|
|
|
29
29
|
"paymentproofattachments",
|
|
30
30
|
"paymentproofpath",
|
|
31
31
|
"paymentreference",
|
|
32
|
+
"proof",
|
|
32
33
|
"receipt",
|
|
33
34
|
]);
|
|
35
|
+
const isPresignedUrl = (value) => typeof value === "string" &&
|
|
36
|
+
(/[?&]X-Amz-(?:Algorithm|Credential|Signature)=/i.test(value) ||
|
|
37
|
+
/[?&]X-Goog-(?:Algorithm|Credential|Signature)=/i.test(value));
|
|
34
38
|
export const sanitizeValueForTrace = (value, seen = new WeakSet()) => {
|
|
35
39
|
if (typeof value !== "object" || value === null)
|
|
36
40
|
return value;
|
|
@@ -45,7 +49,8 @@ export const sanitizeValueForTrace = (value, seen = new WeakSet()) => {
|
|
|
45
49
|
return value;
|
|
46
50
|
return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => [
|
|
47
51
|
key,
|
|
48
|
-
SENSITIVE_TRACE_FIELD.has(key.toLowerCase())
|
|
52
|
+
SENSITIVE_TRACE_FIELD.has(key.toLowerCase()) ||
|
|
53
|
+
isPresignedUrl(nestedValue)
|
|
49
54
|
? "[redacted]"
|
|
50
55
|
: sanitizeValueForTrace(nestedValue, seen),
|
|
51
56
|
]));
|
package/dist/commands.js
CHANGED
|
@@ -6,9 +6,10 @@ import { contentType } from "mime-types";
|
|
|
6
6
|
import { api } from "./api-client.js";
|
|
7
7
|
import { BudgetRole, Deals, ExtendedApprovalStatus, ExtendedApprovalType, ExtendedBudgetStatus, TimeFrame, } from "./filter-enums.js";
|
|
8
8
|
import { billStatusesForApi, } from "./parse-cli-enums.js";
|
|
9
|
-
import { parseBudgetDiscountPayload, parseCompanyUpdatePayload, parseContactCreatePayload, parseContactUpdatePayload, parseCreateBillPayload, parseCreateBudgetPayload, parseCreateCustomerInvoicePayload, parseCreateQuotationPayload, parseItemCreatePayload, parseItemUpdatePayload, parseSendEstimateToContactPersonPayload, parseSupplierCreatePayload, parseSupplierUpdatePayload, parseUpdateBillPayload, parseUpdateBillPaymentEvidencePayload, parseUpdateBudgetCommissionPayload, parseUpdateBudgetPayload, parseUpdateProjectPayload, parseUpdateQuotationPayload, parseValidateBillSelectionPayload, } from "./parse-mutation-payload.js";
|
|
9
|
+
import { parseBudgetDiscountPayload, parseCompanyUpdatePayload, parseContactCreatePayload, parseContactUpdatePayload, parseCreateBillPayload, parseCreateBudgetPayload, parseCreateCustomerInvoicePayload, parseCreateQuotationPayload, parseItemCreatePayload, parseItemUpdatePayload, parseSendCustomerInvoiceToContactPersonPayload, parseSendEstimateToContactPersonPayload, parseSupplierCreatePayload, parseSupplierUpdatePayload, parseUpdateBillPayload, parseUpdateBillPaymentEvidencePayload, parseUpdateBudgetCommissionPayload, parseUpdateBudgetPayload, parseUpdateProjectPayload, parseUpdateQuotationPayload, parseValidateBillSelectionPayload, } from "./parse-mutation-payload.js";
|
|
10
10
|
import { BillStatus, BudgetStatus, ProjectStatus } from "./prisma-enums.js";
|
|
11
11
|
import { createRichTextFromPlainText } from "./rich-text.js";
|
|
12
|
+
import { confirmCurrentCommand, emitCommandResult, getActiveCliSession, } from "./runtime/index.js";
|
|
12
13
|
const BUDGET = "BUDGET";
|
|
13
14
|
const BILL = "BILL";
|
|
14
15
|
const SUPPLIER = "SUPPLIER";
|
|
@@ -52,7 +53,7 @@ function isPendingRequest(n) {
|
|
|
52
53
|
return n.notificationType === "request" && n.status === "PENDING_APPROVAL";
|
|
53
54
|
}
|
|
54
55
|
const out = (data) => {
|
|
55
|
-
|
|
56
|
+
emitCommandResult(data);
|
|
56
57
|
};
|
|
57
58
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
59
|
const stringField = (record, key) => {
|
|
@@ -263,7 +264,18 @@ const billAttachmentContentTypeForFileName = (fileName) => {
|
|
|
263
264
|
return resolvedContentType;
|
|
264
265
|
};
|
|
265
266
|
const PAYMENT_PROOF_MAX_SIZE = 20 * 1024 * 1024;
|
|
267
|
+
const CUSTOMER_INVOICE_PAYMENT_PROOF_MAX_SIZE = 20 * 1024 * 1024;
|
|
266
268
|
const QUOTATION_ATTACHMENT_MAX_SIZE = 20 * 1024 * 1024;
|
|
269
|
+
const customerInvoicePaymentProofContentTypeForFileName = (fileName) => {
|
|
270
|
+
const resolvedContentType = contentTypeHeaderForFileName(fileName);
|
|
271
|
+
if (resolvedContentType === "application/pdf" ||
|
|
272
|
+
resolvedContentType === "image/gif" ||
|
|
273
|
+
resolvedContentType === "image/jpeg" ||
|
|
274
|
+
resolvedContentType === "image/png") {
|
|
275
|
+
return resolvedContentType;
|
|
276
|
+
}
|
|
277
|
+
throw new Error(`Unsupported customer invoice payment-proof type: ${fileName}. Use PDF, GIF, JPEG, or PNG.`);
|
|
278
|
+
};
|
|
267
279
|
const quotationAttachmentContentTypeForFileName = (fileName) => {
|
|
268
280
|
const resolvedContentType = contentTypeHeaderForFileName(fileName);
|
|
269
281
|
if (resolvedContentType === "application/pdf" ||
|
|
@@ -290,18 +302,35 @@ const lockedBudgetStatusLabels = {
|
|
|
290
302
|
const lockedBudgetStatusLabel = (status) => status in lockedBudgetStatusLabels
|
|
291
303
|
? lockedBudgetStatusLabels[status]
|
|
292
304
|
: status;
|
|
293
|
-
const
|
|
305
|
+
export const buildSensitiveWorkflowPreview = ({ action, entity, details, }) => {
|
|
306
|
+
const effects = details ?? "may change workflow state or send notifications";
|
|
307
|
+
return [
|
|
308
|
+
"Budget Builder workflow preview",
|
|
309
|
+
`Action: ${action}`,
|
|
310
|
+
`Target: ${entity}`,
|
|
311
|
+
`Effects: ${effects}`,
|
|
312
|
+
].join("\n");
|
|
313
|
+
};
|
|
314
|
+
const assertSensitiveWorkflowConfirmed = async (input) => {
|
|
315
|
+
if (getActiveCliSession()?.invocation.legacy !== true) {
|
|
316
|
+
await confirmCurrentCommand({
|
|
317
|
+
action: input.action,
|
|
318
|
+
target: input.entity,
|
|
319
|
+
details: input.details,
|
|
320
|
+
});
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
294
323
|
const expected = "CONFIRM";
|
|
295
|
-
const
|
|
324
|
+
const preview = buildSensitiveWorkflowPreview(input);
|
|
296
325
|
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
297
|
-
throw new Error(`${
|
|
326
|
+
throw new Error(`${preview}\nThis is a sensitive workflow change. Run it in an interactive terminal only after the user explicitly confirms this exact action, then type "${expected}" to continue.`);
|
|
298
327
|
}
|
|
299
328
|
const rl = createInterface({
|
|
300
329
|
input: process.stdin,
|
|
301
330
|
output: process.stderr,
|
|
302
331
|
});
|
|
303
332
|
try {
|
|
304
|
-
const answer = await rl.question(`${
|
|
333
|
+
const answer = await rl.question(`${preview}\nConfirm the user explicitly approved this exact action, then type "${expected}" to continue: `);
|
|
305
334
|
if (answer.trim() !== expected) {
|
|
306
335
|
throw new Error("Aborted.");
|
|
307
336
|
}
|
|
@@ -315,6 +344,15 @@ const assertLockedBudgetChangeConfirmed = async ({ budget, action, }) => {
|
|
|
315
344
|
return;
|
|
316
345
|
}
|
|
317
346
|
const statusLabel = lockedBudgetStatusLabel(budget.status);
|
|
347
|
+
if (getActiveCliSession()?.invocation.legacy !== true) {
|
|
348
|
+
await confirmCurrentCommand({
|
|
349
|
+
action,
|
|
350
|
+
target: `budget ${budget.id}`,
|
|
351
|
+
details: `Changes locked budget "${budget.name}" in status ${statusLabel}.`,
|
|
352
|
+
effects: ["state-change"],
|
|
353
|
+
});
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
318
356
|
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
319
357
|
throw new Error(`${action} would change locked budget "${budget.name}" (${budget.id}) with status ${statusLabel}. Run this command in an interactive terminal and type "yes" to confirm.`);
|
|
320
358
|
}
|
|
@@ -392,6 +430,9 @@ export async function updateBudgetStatus(budgetId, status, opts) {
|
|
|
392
430
|
...(opts?.projectStatusOnCommercialRejection !== undefined && {
|
|
393
431
|
projectStatusOnCommercialRejection: opts.projectStatusOnCommercialRejection,
|
|
394
432
|
}),
|
|
433
|
+
...(opts?.projectInvoiceSettings !== undefined && {
|
|
434
|
+
projectInvoiceSettings: opts.projectInvoiceSettings,
|
|
435
|
+
}),
|
|
395
436
|
});
|
|
396
437
|
out(result);
|
|
397
438
|
}
|
|
@@ -503,7 +544,9 @@ export async function approveBill(billId) {
|
|
|
503
544
|
}
|
|
504
545
|
let email;
|
|
505
546
|
try {
|
|
506
|
-
await api.email.sendReplyBillApprovalEmail.mutate(
|
|
547
|
+
await api.email.sendReplyBillApprovalEmail.mutate({
|
|
548
|
+
id: result.updatedApproval.id,
|
|
549
|
+
});
|
|
507
550
|
email = { sent: true };
|
|
508
551
|
}
|
|
509
552
|
catch (error) {
|
|
@@ -709,8 +752,8 @@ export async function updateBillStatus(opts) {
|
|
|
709
752
|
catch (error) {
|
|
710
753
|
await rethrowAfterStagedPaymentProofCleanup(error, bill && paymentProof
|
|
711
754
|
? async () => {
|
|
712
|
-
// The cleanup endpoint checks
|
|
713
|
-
// that an ambiguously successful
|
|
755
|
+
// The cleanup endpoint checks the staged-upload record and refuses to
|
|
756
|
+
// delete a key that an ambiguously successful update already consumed.
|
|
714
757
|
await deleteStagedPaymentProof(bill.projectId, paymentProof.key);
|
|
715
758
|
}
|
|
716
759
|
: undefined);
|
|
@@ -867,6 +910,8 @@ export async function uploadQuotationAttachmentFromPath(projectId, filePath) {
|
|
|
867
910
|
const key = `quotations/${projectId}/${randomUUID()}.${ext || "pdf"}`;
|
|
868
911
|
const uploadUrl = await api.attachment.getPresignedUrlToUpload.mutate({
|
|
869
912
|
key,
|
|
913
|
+
size: buf.byteLength,
|
|
914
|
+
contentType: attachmentContentType,
|
|
870
915
|
});
|
|
871
916
|
const res = await fetch(uploadUrl, {
|
|
872
917
|
method: "PUT",
|
|
@@ -958,6 +1003,9 @@ export async function uploadBudgetWinProofFromPath(budgetId, filePath, opts) {
|
|
|
958
1003
|
...(opts?.markProjectWon !== undefined && {
|
|
959
1004
|
markProjectWon: opts.markProjectWon,
|
|
960
1005
|
}),
|
|
1006
|
+
...(opts?.projectInvoiceSettings !== undefined && {
|
|
1007
|
+
projectInvoiceSettings: opts.projectInvoiceSettings,
|
|
1008
|
+
}),
|
|
961
1009
|
});
|
|
962
1010
|
out(result);
|
|
963
1011
|
}
|
|
@@ -1278,19 +1326,31 @@ export const checkCustomerInvoiceReadiness = async (budgetId) => {
|
|
|
1278
1326
|
out(result);
|
|
1279
1327
|
};
|
|
1280
1328
|
export const listCustomerInvoices = async (input) => {
|
|
1281
|
-
const result = await api.customerInvoice.
|
|
1329
|
+
const result = await api.customerInvoice.list.query(input);
|
|
1330
|
+
out(result);
|
|
1331
|
+
};
|
|
1332
|
+
export const listEligibleCustomerInvoiceBudgets = async (projectId) => {
|
|
1333
|
+
const result = await api.customerInvoice.getEligibleBudgets.query({
|
|
1334
|
+
projectId,
|
|
1335
|
+
});
|
|
1282
1336
|
out(result);
|
|
1283
1337
|
};
|
|
1284
1338
|
export const getCustomerInvoice = async (batchId) => {
|
|
1285
1339
|
const result = await api.customerInvoice.getInvoiceDetail.query({ batchId });
|
|
1286
1340
|
out(result);
|
|
1287
1341
|
};
|
|
1342
|
+
export const getCustomerInvoiceEmailContext = async (batchId) => {
|
|
1343
|
+
const result = await api.customerInvoice.getInvoiceEmailContext.query({
|
|
1344
|
+
batchId,
|
|
1345
|
+
});
|
|
1346
|
+
out(result);
|
|
1347
|
+
};
|
|
1288
1348
|
export const createCustomerInvoice = async (raw) => {
|
|
1289
1349
|
const input = parseCreateCustomerInvoicePayload(raw);
|
|
1290
1350
|
await assertSensitiveWorkflowConfirmed({
|
|
1291
1351
|
action: "Create customer invoice",
|
|
1292
1352
|
entity: `budget ${input.budgetId}`,
|
|
1293
|
-
details: "
|
|
1353
|
+
details: "creates one invoice in QuickBooks; admin-created invoices are approved immediately, while other invoices request approval; approved cumulative coverage of 100% closes the QuickBooks estimate",
|
|
1294
1354
|
});
|
|
1295
1355
|
const result = await api.customerInvoice.createInvoiceBatch.mutate(input);
|
|
1296
1356
|
out(result);
|
|
@@ -1299,6 +1359,7 @@ export const discardCreatingCustomerInvoice = async (batchId) => {
|
|
|
1299
1359
|
await assertSensitiveWorkflowConfirmed({
|
|
1300
1360
|
action: "Discard unfinished customer invoice",
|
|
1301
1361
|
entity: `invoice batch ${batchId}`,
|
|
1362
|
+
details: "removes only a reserved CREATING batch that has no invoice created in QuickBooks",
|
|
1302
1363
|
});
|
|
1303
1364
|
const result = await api.customerInvoice.discardCreatingInvoiceBatch.mutate({
|
|
1304
1365
|
batchId,
|
|
@@ -1309,7 +1370,7 @@ export const deleteCustomerInvoice = async (batchId) => {
|
|
|
1309
1370
|
await assertSensitiveWorkflowConfirmed({
|
|
1310
1371
|
action: "Delete customer invoice",
|
|
1311
1372
|
entity: `invoice batch ${batchId}`,
|
|
1312
|
-
details: "
|
|
1373
|
+
details: "deletes its invoices from QuickBooks and removes the local batch; paid invoices are blocked until their QBO payments are reversed; removing approved coverage may reopen the estimate",
|
|
1313
1374
|
});
|
|
1314
1375
|
const result = await api.customerInvoice.deleteInvoiceBatch.mutate({
|
|
1315
1376
|
batchId,
|
|
@@ -1320,7 +1381,7 @@ export const voidCustomerInvoice = async (batchId) => {
|
|
|
1320
1381
|
await assertSensitiveWorkflowConfirmed({
|
|
1321
1382
|
action: "Void customer invoice",
|
|
1322
1383
|
entity: `invoice batch ${batchId}`,
|
|
1323
|
-
details: "
|
|
1384
|
+
details: "voids its invoices in QuickBooks and updates Budget Builder; paid invoices are blocked until their QBO payments are reversed; removing approved coverage may reopen the estimate",
|
|
1324
1385
|
});
|
|
1325
1386
|
const result = await api.customerInvoice.voidInvoiceBatch.mutate({ batchId });
|
|
1326
1387
|
out(result);
|
|
@@ -1329,7 +1390,7 @@ export const approveCustomerInvoice = async (batchId) => {
|
|
|
1329
1390
|
await assertSensitiveWorkflowConfirmed({
|
|
1330
1391
|
action: "Approve customer invoice",
|
|
1331
1392
|
entity: `invoice batch ${batchId}`,
|
|
1332
|
-
details: "and
|
|
1393
|
+
details: "approves the batch and notifies its creator; voided invoices cannot be approved; cumulative approved coverage of 100% closes the QuickBooks estimate",
|
|
1333
1394
|
});
|
|
1334
1395
|
const result = await api.customerInvoice.approveInvoiceBatch.mutate({
|
|
1335
1396
|
batchId,
|
|
@@ -1340,7 +1401,7 @@ export const rejectCustomerInvoice = async (batchId, rejectionReason) => {
|
|
|
1340
1401
|
await assertSensitiveWorkflowConfirmed({
|
|
1341
1402
|
action: "Reject customer invoice",
|
|
1342
1403
|
entity: `invoice batch ${batchId}`,
|
|
1343
|
-
details: `
|
|
1404
|
+
details: `records the rejection reason, voids it in QuickBooks, notifies its creator, and reopens a prematurely closed estimate when applicable`,
|
|
1344
1405
|
});
|
|
1345
1406
|
const result = await api.customerInvoice.rejectInvoiceBatch.mutate({
|
|
1346
1407
|
batchId,
|
|
@@ -1364,13 +1425,104 @@ export const syncCustomerInvoice = async (invoiceId) => {
|
|
|
1364
1425
|
await assertSensitiveWorkflowConfirmed({
|
|
1365
1426
|
action: "Sync customer invoice from QuickBooks",
|
|
1366
1427
|
entity: `invoice ${invoiceId}`,
|
|
1367
|
-
details: "
|
|
1428
|
+
details: "refreshes local status, QuickBooks metadata, balance, and history; QBO Paid or a zero balance becomes PAID, a reversed payment restores the delivery or approval status, and a QBO void becomes VOIDED",
|
|
1368
1429
|
});
|
|
1369
1430
|
const result = await api.customerInvoice.syncInvoiceStatus.mutate({
|
|
1370
1431
|
invoiceId,
|
|
1371
1432
|
});
|
|
1372
1433
|
out(result);
|
|
1373
1434
|
};
|
|
1435
|
+
export const describeCustomerInvoiceEmailConfirmation = (input) => `emails the QBO invoice PDF to ${input.to}; requested CC ${input.cc.length > 0 ? input.cc.join(", ") : "none"}; reply-to ${input.replyTo}; subject "${input.subject}"; server-required admin, creator, business-development, and inside-sales CC recipients are added; success marks the invoice SENT; do not retry blindly after an ambiguous delivery failure`;
|
|
1436
|
+
export const sendCustomerInvoiceToContactPersonFromPayload = async (raw) => {
|
|
1437
|
+
const input = parseSendCustomerInvoiceToContactPersonPayload(raw);
|
|
1438
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1439
|
+
action: "Send customer invoice email",
|
|
1440
|
+
entity: `invoice ${input.invoiceId}`,
|
|
1441
|
+
details: describeCustomerInvoiceEmailConfirmation(input),
|
|
1442
|
+
});
|
|
1443
|
+
const result = await api.customerInvoice.sendInvoiceToContactPerson.mutate(input);
|
|
1444
|
+
out(result);
|
|
1445
|
+
};
|
|
1446
|
+
export const describeCustomerInvoicePaymentConfirmation = ({ invoiceId, paymentDate, paymentReference, proofFileName, proofSize, }) => `for invoice ${invoiceId}, records payment date ${paymentDate}${paymentReference
|
|
1447
|
+
? " with a payment reference"
|
|
1448
|
+
: " without a payment reference"}; uploads ${proofFileName} (${proofSize} bytes); creates a QuickBooks Payment for the live outstanding balance when non-zero, attaches the proof to QBO when possible, and records the payment in Budget Builder`;
|
|
1449
|
+
const assertPaymentDate = (paymentDate) => {
|
|
1450
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(paymentDate)) {
|
|
1451
|
+
throw new Error("--paymentDate must use YYYY-MM-DD.");
|
|
1452
|
+
}
|
|
1453
|
+
const parsed = new Date(`${paymentDate}T00:00:00.000Z`);
|
|
1454
|
+
if (Number.isNaN(parsed.getTime()) ||
|
|
1455
|
+
parsed.toISOString().slice(0, 10) !== paymentDate) {
|
|
1456
|
+
throw new Error("--paymentDate must be a valid calendar date.");
|
|
1457
|
+
}
|
|
1458
|
+
return paymentDate;
|
|
1459
|
+
};
|
|
1460
|
+
export const markCustomerInvoicePaidFromPath = async ({ invoiceId, paymentDate, paymentProofPath, paymentReference, }) => {
|
|
1461
|
+
const normalizedPaymentDate = assertPaymentDate(paymentDate);
|
|
1462
|
+
const proofContents = await readFile(paymentProofPath);
|
|
1463
|
+
const proofFileName = basename(paymentProofPath);
|
|
1464
|
+
if (proofContents.byteLength === 0) {
|
|
1465
|
+
throw new Error("Customer invoice payment proof must not be empty.");
|
|
1466
|
+
}
|
|
1467
|
+
if (proofContents.byteLength > CUSTOMER_INVOICE_PAYMENT_PROOF_MAX_SIZE) {
|
|
1468
|
+
throw new Error("Customer invoice payment proof must be 20MB or smaller.");
|
|
1469
|
+
}
|
|
1470
|
+
const proofContentType = customerInvoicePaymentProofContentTypeForFileName(proofFileName);
|
|
1471
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1472
|
+
action: "Mark customer invoice paid",
|
|
1473
|
+
entity: `invoice ${invoiceId}`,
|
|
1474
|
+
details: describeCustomerInvoicePaymentConfirmation({
|
|
1475
|
+
invoiceId,
|
|
1476
|
+
paymentDate: normalizedPaymentDate,
|
|
1477
|
+
paymentReference,
|
|
1478
|
+
proofFileName,
|
|
1479
|
+
proofSize: proofContents.byteLength,
|
|
1480
|
+
}),
|
|
1481
|
+
});
|
|
1482
|
+
const proofUpload = await api.customerInvoice.requestPaymentProofUpload.mutate({
|
|
1483
|
+
invoiceId,
|
|
1484
|
+
fileName: proofFileName,
|
|
1485
|
+
size: proofContents.byteLength,
|
|
1486
|
+
contentType: proofContentType,
|
|
1487
|
+
});
|
|
1488
|
+
const uploadResponse = await fetch(proofUpload.uploadUrl, {
|
|
1489
|
+
method: "PUT",
|
|
1490
|
+
body: proofContents,
|
|
1491
|
+
headers: { "Content-Type": proofUpload.contentType },
|
|
1492
|
+
});
|
|
1493
|
+
if (!uploadResponse.ok) {
|
|
1494
|
+
throw new Error(`Payment-proof upload failed: HTTP ${uploadResponse.status} ${(await uploadResponse.text()).slice(0, 500)}`);
|
|
1495
|
+
}
|
|
1496
|
+
const result = await api.customerInvoice.markPaid.mutate({
|
|
1497
|
+
invoiceId,
|
|
1498
|
+
paymentDate: normalizedPaymentDate,
|
|
1499
|
+
paymentReference,
|
|
1500
|
+
proof: {
|
|
1501
|
+
key: proofUpload.key,
|
|
1502
|
+
name: proofUpload.name,
|
|
1503
|
+
size: proofContents.byteLength,
|
|
1504
|
+
contentType: proofUpload.contentType,
|
|
1505
|
+
},
|
|
1506
|
+
});
|
|
1507
|
+
out(result);
|
|
1508
|
+
};
|
|
1509
|
+
export const downloadCustomerInvoicePaymentProof = async (invoiceId, outputPath) => {
|
|
1510
|
+
const proof = await api.customerInvoice.getPaymentProofDownloadUrl.mutate({
|
|
1511
|
+
invoiceId,
|
|
1512
|
+
});
|
|
1513
|
+
const response = await fetch(proof.url);
|
|
1514
|
+
if (!response.ok) {
|
|
1515
|
+
throw new Error(`Payment-proof download failed: HTTP ${response.status} ${(await response.text()).slice(0, 500)}`);
|
|
1516
|
+
}
|
|
1517
|
+
const contents = Buffer.from(await response.arrayBuffer());
|
|
1518
|
+
const resolvedOutputPath = outputPath?.trim() || basename(proof.fileName);
|
|
1519
|
+
await writeFile(resolvedOutputPath, contents);
|
|
1520
|
+
out({
|
|
1521
|
+
fileName: proof.fileName,
|
|
1522
|
+
outputPath: resolvedOutputPath,
|
|
1523
|
+
size: contents.byteLength,
|
|
1524
|
+
});
|
|
1525
|
+
};
|
|
1374
1526
|
export async function updateBillFromPayload(raw) {
|
|
1375
1527
|
const input = parseUpdateBillPayload(raw);
|
|
1376
1528
|
const result = await api.bill.update.mutate(input);
|
|
@@ -1539,7 +1691,9 @@ export async function approveBudget(budgetId) {
|
|
|
1539
1691
|
});
|
|
1540
1692
|
let email;
|
|
1541
1693
|
try {
|
|
1542
|
-
await api.email.sendReplyApprovalEmail.mutate(
|
|
1694
|
+
await api.email.sendReplyApprovalEmail.mutate({
|
|
1695
|
+
id: result.updatedApproval.id,
|
|
1696
|
+
});
|
|
1543
1697
|
email = { sent: true };
|
|
1544
1698
|
}
|
|
1545
1699
|
catch (error) {
|
|
@@ -1576,7 +1730,9 @@ export async function rejectBudget(budgetId, reason) {
|
|
|
1576
1730
|
});
|
|
1577
1731
|
let email;
|
|
1578
1732
|
try {
|
|
1579
|
-
await api.email.sendReplyApprovalEmail.mutate(
|
|
1733
|
+
await api.email.sendReplyApprovalEmail.mutate({
|
|
1734
|
+
id: result.updatedApproval.id,
|
|
1735
|
+
});
|
|
1580
1736
|
email = { sent: true };
|
|
1581
1737
|
}
|
|
1582
1738
|
catch (error) {
|
|
@@ -1673,8 +1829,7 @@ export async function approveSupplier(supplierId) {
|
|
|
1673
1829
|
let email;
|
|
1674
1830
|
try {
|
|
1675
1831
|
await api.email.sendReplySupplierApprovalEmail.mutate({
|
|
1676
|
-
|
|
1677
|
-
rejectionReason: result.updatedApproval.rejectionReason ?? undefined,
|
|
1832
|
+
id: result.updatedApproval.id,
|
|
1678
1833
|
});
|
|
1679
1834
|
email = { sent: true };
|
|
1680
1835
|
}
|
|
@@ -1712,8 +1867,7 @@ export async function rejectSupplier(supplierId, reason) {
|
|
|
1712
1867
|
let email;
|
|
1713
1868
|
try {
|
|
1714
1869
|
await api.email.sendReplySupplierApprovalEmail.mutate({
|
|
1715
|
-
|
|
1716
|
-
rejectionReason: result.updatedApproval.rejectionReason ?? undefined,
|
|
1870
|
+
id: result.updatedApproval.id,
|
|
1717
1871
|
});
|
|
1718
1872
|
email = { sent: true };
|
|
1719
1873
|
}
|
|
@@ -1754,7 +1908,9 @@ export async function rejectBill(billId, reason) {
|
|
|
1754
1908
|
}
|
|
1755
1909
|
let email;
|
|
1756
1910
|
try {
|
|
1757
|
-
await api.email.sendReplyBillApprovalEmail.mutate(
|
|
1911
|
+
await api.email.sendReplyBillApprovalEmail.mutate({
|
|
1912
|
+
id: result.updatedApproval.id,
|
|
1913
|
+
});
|
|
1758
1914
|
email = { sent: true };
|
|
1759
1915
|
}
|
|
1760
1916
|
catch (error) {
|
|
@@ -1995,6 +2151,10 @@ export async function listUsers() {
|
|
|
1995
2151
|
const users = await api.user.getAllUsers.query();
|
|
1996
2152
|
out(users);
|
|
1997
2153
|
}
|
|
2154
|
+
export async function whoAmI() {
|
|
2155
|
+
const user = await api.user.getCurrentUser.query();
|
|
2156
|
+
out(user);
|
|
2157
|
+
}
|
|
1998
2158
|
export async function getUserPerformance(userId) {
|
|
1999
2159
|
const result = await api.dashboard.getUserPerformance.query({ userId });
|
|
2000
2160
|
out(result);
|