@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.
@@ -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 BASE_URL = "https://budget-builder.getout.events";
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 api = createTRPCProxyClient({
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: `${BASE_URL}/api/trpc`,
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
- console.error(JSON.stringify({
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
- console.log(JSON.stringify(data, null, 2));
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 assertSensitiveWorkflowConfirmed = async ({ action, entity, details, }) => {
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 detailText = details ? ` ${details}` : "";
324
+ const preview = buildSensitiveWorkflowPreview(input);
296
325
  if (!process.stdin.isTTY || !process.stderr.isTTY) {
297
- throw new Error(`${action} on ${entity}${detailText} is a sensitive Budget Builder workflow change. Run this command in an interactive terminal after the user explicitly confirms the exact action, then type "${expected}" to continue.`);
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(`${action} on ${entity}${detailText} may change workflow status, approvals, or send notification emails. Confirm the user explicitly approved this exact action, then type "${expected}" to continue: `);
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(result.updatedApproval);
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 Attachment rows and refuses to delete a key
713
- // that an ambiguously successful status update already attached.
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.listByBudget.query(input);
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: "create the invoice in QuickBooks and either approve it as admin or send approval-request emails",
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: "delete its invoices from QuickBooks and remove the local batch",
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: "void its invoices in QuickBooks",
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 notify its creator",
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: `with reason "${rejectionReason}", void it in QuickBooks, and notify its creator`,
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: "and update its local status, QuickBooks metadata, balance, and history",
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(result.updatedApproval);
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(result.updatedApproval);
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
- ...result.updatedApproval,
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
- ...result.updatedApproval,
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(result.updatedApproval);
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);