node-autotask 0.34.0 → 0.37.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/MCP_TOOL_CATALOG.json +26 -9
  3. package/MCP_TOOL_MANIFEST.md +14 -13
  4. package/N8N_MCP_MANIFEST.json +5 -5
  5. package/N8N_MCP_MANIFEST.md +4 -4
  6. package/README.md +12 -5
  7. package/capabilities.json +3097 -1344
  8. package/capabilities.schema.json +4 -0
  9. package/dist/capabilities.cjs +2 -2
  10. package/dist/capabilities.d.cts +2 -0
  11. package/dist/capabilities.d.ts +2 -0
  12. package/dist/capabilities.js +1 -1
  13. package/dist/{chunk-RDFL5QYE.js → chunk-2PJCQGXZ.js} +12 -12
  14. package/dist/{chunk-DKJP73FX.cjs → chunk-4GHBN2AW.cjs} +4657 -9597
  15. package/dist/{chunk-6ADFCU3I.js → chunk-75WJS26D.js} +4456 -9396
  16. package/dist/{chunk-P6YN2POI.cjs → chunk-7WR5WUQT.cjs} +143 -143
  17. package/dist/{chunk-EZKM4HQ6.js → chunk-CH2WXTYD.js} +8426 -3099
  18. package/dist/{chunk-GDQ4QM7Q.cjs → chunk-FYF46OT3.cjs} +8403 -3076
  19. package/dist/{chunk-FOOHJBPP.cjs → chunk-HBYKTXN3.cjs} +3096 -1343
  20. package/dist/{chunk-4MUBUSTL.js → chunk-KQ6HMBB4.js} +3096 -1343
  21. package/dist/{index-ByMU8IC2.d.ts → index-DAFg_68I.d.ts} +88 -11
  22. package/dist/{index-Dx4-zpDo.d.cts → index-g8J6Qiu1.d.cts} +88 -11
  23. package/dist/index.cjs +23 -23
  24. package/dist/index.d.cts +2 -2
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +15 -15
  27. package/dist/operations/index.cjs +828 -137
  28. package/dist/operations/index.d.cts +145 -5
  29. package/dist/operations/index.d.ts +145 -5
  30. package/dist/operations/index.js +697 -6
  31. package/dist/pool.cjs +9 -9
  32. package/dist/pool.d.cts +1 -1
  33. package/dist/pool.d.ts +1 -1
  34. package/dist/pool.js +4 -4
  35. package/dist/resources/index.cjs +5 -4
  36. package/dist/resources/index.d.cts +1 -1
  37. package/dist/resources/index.d.ts +1 -1
  38. package/dist/resources/index.js +14 -13
  39. package/docs/API.md +321 -11
  40. package/docs/LIVE-TESTING.md +11 -11
  41. package/package.json +1 -1
@@ -3,8 +3,13 @@ import {
3
3
  AutotaskError,
4
4
  BLIND_SURFACE_INVENTORY,
5
5
  BOUNDED_QUERY_MAX_LIMIT,
6
+ BillingCodesResource,
7
+ BillingItemsResource,
8
+ CHARGE_LINE_FKS,
6
9
  CREATE_GUIDANCE,
10
+ CompaniesResource,
7
11
  ConfirmationRequiredError,
12
+ ContractsResource,
8
13
  DELETE_BOUNDARY_NOTES,
9
14
  DeleteBoundaryError,
10
15
  ENTITY_TYPE_TO_RESOURCE,
@@ -14,40 +19,55 @@ import {
14
19
  FETCH_MANY_MAX_ITEMS,
15
20
  FIELD_CACHE_KINDS,
16
21
  FIELD_CATALOG_HONESTY_NOTE,
22
+ InvoicesResource,
17
23
  MARKER_FIELDS,
18
24
  MAX_FIELD_CATALOG_WIRE,
19
25
  MODEL_DATE_FIELDS,
20
26
  MODEL_FIELD_SETS,
21
27
  MarkerUnsafeIdError,
22
28
  NotFoundError,
29
+ OrganizationalLevel1sResource,
30
+ OrganizationalLevel2sResource,
31
+ OrganizationalLevelAssociationsResource,
23
32
  PICKLIST_VALUE_NOT_SERVED_NOTE,
33
+ PROFITABILITY_MAX_WINDOW_MONTHS,
24
34
  REACHABILITY_PROBE_DEFAULTS,
25
35
  RELATIONSHIP_MAP,
26
36
  RELATIONSHIP_ZONE,
27
37
  RecoveryTruncatedError,
38
+ ResourcesResource,
39
+ SERVICE_LINE_FKS,
40
+ TimeEntriesResource,
28
41
  UI_LINK_ENTITIES,
29
42
  WORKFLOW_RESOURCES,
43
+ billingLineType,
30
44
  buildFilter,
31
45
  buildUiLink,
32
46
  camelKey,
33
47
  clampPageSize,
48
+ dayAfter,
34
49
  effectiveRequiredFields,
35
50
  expandResourceNames,
36
51
  fetchByIds,
37
52
  fetchMany,
38
53
  fieldCatalog,
54
+ internalAmount,
39
55
  isAutotaskError,
56
+ isDateYMD,
40
57
  isEnumerableResource,
41
58
  isRecord,
59
+ monthsInWindow,
60
+ num,
42
61
  parseIdentifier,
62
+ readPicklists,
43
63
  resourceForEntityType,
44
64
  uiLink,
45
65
  validateFetchManyItems,
46
66
  validateQueryFieldNames
47
- } from "../chunk-EZKM4HQ6.js";
67
+ } from "../chunk-CH2WXTYD.js";
48
68
  import {
49
69
  CAPABILITIES
50
- } from "../chunk-4MUBUSTL.js";
70
+ } from "../chunk-KQ6HMBB4.js";
51
71
 
52
72
  // src/operations/searchAcrossResources.ts
53
73
  var DEFAULT_LIMIT = 25;
@@ -233,7 +253,7 @@ var QUERY_RESOURCE_KEYS = (() => {
233
253
  })();
234
254
  var AGGREGATE_OPT_KEYS = ["groupBy", "sum", "top", "limit"];
235
255
  var OP_RE = /^([a-z0-9_]+)\.query$/;
236
- function num(v) {
256
+ function num2(v) {
237
257
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
238
258
  }
239
259
  function parseOperationKey(operation, problems) {
@@ -398,7 +418,7 @@ function foldGroups(items, v) {
398
418
  }
399
419
  g.count += 1;
400
420
  if (g.sums !== void 0) {
401
- for (const f of v.sum) g.sums[f] = (g.sums[f] ?? 0) + num(item[f]);
421
+ for (const f of v.sum) g.sums[f] = (g.sums[f] ?? 0) + num2(item[f]);
402
422
  }
403
423
  }
404
424
  return [...groups.values()].sort((a, b) => b.count - a.count || (a.keyOrder < b.keyOrder ? -1 : a.keyOrder > b.keyOrder ? 1 : 0));
@@ -437,6 +457,674 @@ async function aggregate(client, operation, filter, opts = {}) {
437
457
  return result;
438
458
  }
439
459
 
460
+ // src/operations/revenueRollup.ts
461
+ var OP = "operations.revenueRollup";
462
+ var PAGE = 500;
463
+ var CONCURRENCY = 3;
464
+ var DEFAULT_BUDGET = 90;
465
+ var DEFAULT_BUDGET_NON_CHARGEABLE = 150;
466
+ var BUDGET_CAP = 200;
467
+ var TOP_MAX = 100;
468
+ var SCOPE_IDS_MAX = 100;
469
+ var TENANT_HOURLY_LIMIT = 1e4;
470
+ var DEFAULT_GROUP_BY = ["vertical", "contractType"];
471
+ var DEFAULT_CONTRACT_TYPE_RE = /^\s*(recurring|fixed)\b/i;
472
+ var CONTRACTS_PATH = "/V1.0/Contracts";
473
+ var COMPANIES_PATH = "/V1.0/Companies";
474
+ var BILLING_ITEMS_PATH = "/V1.0/BillingItems";
475
+ var LINE_TYPE_FIELDS = ["billingItemType", "timeEntryID", "expenseItemID", ...CHARGE_LINE_FKS, ...SERVICE_LINE_FKS];
476
+ var isLabour = (line) => billingLineType(line) === "labour";
477
+ function picklistKey(c, dim, path, field, raw, missing) {
478
+ const id = num(raw);
479
+ if (id === 0) return { id: null, label: missing };
480
+ const label = c.labels[path]?.get(field)?.get(id);
481
+ if (label === void 0) {
482
+ c.unlabelled.add(`${dim}: ${field} value ${id} has no label on the live ${path} picklist (id-only)`);
483
+ return { id };
484
+ }
485
+ return { id, label };
486
+ }
487
+ var DIMENSIONS = {
488
+ vertical: {
489
+ joins: { companies: ["marketSegmentID"] },
490
+ picklist: COMPANIES_PATH,
491
+ key: (c) => picklistKey(c, "vertical", COMPANIES_PATH, "marketSegmentID", c.company?.["marketSegmentID"], "unassigned")
492
+ },
493
+ contractType: {
494
+ key: (c) => picklistKey(c, "contractType", CONTRACTS_PATH, "contractType", c.contract["contractType"], "none")
495
+ },
496
+ contractCategory: {
497
+ key: (c) => picklistKey(c, "contractCategory", CONTRACTS_PATH, "contractCategory", c.contract["contractCategory"], "uncategorised")
498
+ },
499
+ company: {
500
+ joins: { companies: ["companyName"] },
501
+ key: (c) => ({ id: num(c.line["companyID"]), name: String(c.company?.["companyName"] ?? "") })
502
+ },
503
+ month: {
504
+ key: (c) => ({ id: c.invoice.month })
505
+ },
506
+ billingItemKind: {
507
+ lineFields: LINE_TYPE_FIELDS,
508
+ picklist: BILLING_ITEMS_PATH,
509
+ key: (c) => num(c.line["billingItemType"]) === 0 ? { id: billingLineType(c.line), label: billingLineType(c.line) } : picklistKey(c, "billingItemKind", BILLING_ITEMS_PATH, "billingItemType", c.line["billingItemType"], "other")
510
+ },
511
+ contract: {
512
+ joins: { contracts: ["contractName"] },
513
+ key: (c) => ({ id: num(c.line["contractID"]), name: String(c.contract["contractName"] ?? "") })
514
+ },
515
+ // Per line (no contract-pointer fallback): association → level2 (the Line of Business, `name`) + level1 (the division).
516
+ lineOfBusiness: {
517
+ lineFields: ["organizationalLevelAssociationID"],
518
+ joins: { organizationalLevelAssociations: [], organizationalLevel1s: [], organizationalLevel2s: [] },
519
+ unresolved: "unresolvedAssociations",
520
+ key: (c) => {
521
+ const id = num(c.line["organizationalLevelAssociationID"]);
522
+ if (id === 0) return { id: null, label: "no LOB assigned" };
523
+ const a = c.joined.organizationalLevelAssociations.get(id);
524
+ const l1 = c.joined.organizationalLevel1s.get(num(a?.["organizationalLevel1ID"]));
525
+ const l2 = c.joined.organizationalLevel2s.get(num(a?.["organizationalLevel2ID"]));
526
+ if (l1 === void 0 || l2 === void 0) return "unresolvedAssociations";
527
+ return { id, name: String(l2["name"] ?? ""), division: String(l1["name"] ?? "") };
528
+ }
529
+ },
530
+ // Labour lines only; a non-labour line (or a labour line with no time entry, e.g. an adjustment) is "n/a".
531
+ resource: {
532
+ lineFields: LINE_TYPE_FIELDS,
533
+ joins: { timeEntries: [], resources: [] },
534
+ unresolved: "unresolvedTimeEntries",
535
+ key: (c) => {
536
+ const te = num(c.line["timeEntryID"]);
537
+ if (!isLabour(c.line) || te === 0) return { id: null, label: "n/a" };
538
+ const id = num(c.joined.timeEntries.get(te)?.["resourceID"]);
539
+ const r = c.joined.resources.get(id);
540
+ if (r === void 0) return "unresolvedTimeEntries";
541
+ return { id, name: `${String(r["firstName"] ?? "")} ${String(r["lastName"] ?? "")}`.trim() };
542
+ }
543
+ },
544
+ billingCode: {
545
+ lineFields: ["billingCodeID"],
546
+ joins: { billingCodes: [] },
547
+ unresolved: "unresolvedBillingCodes",
548
+ key: (c) => {
549
+ const id = num(c.line["billingCodeID"]);
550
+ if (id === 0) return { id: null, label: "none" };
551
+ const b = c.joined.billingCodes.get(id);
552
+ return b === void 0 ? "unresolvedBillingCodes" : { id, name: String(b["name"] ?? "") };
553
+ }
554
+ }
555
+ };
556
+ var fk = (rows, field) => [...rows].map((r) => num(r[field]));
557
+ var ONE_CHUNK = (idChunks) => Math.min(1, idChunks);
558
+ var JOINS = {
559
+ contracts: {
560
+ make: (http) => new ContractsResource(http, PAGE),
561
+ fields: ["id", "contractType", "contractCategory"],
562
+ ids: (lines) => fk(lines, "contractID")
563
+ },
564
+ companies: {
565
+ make: (http) => new CompaniesResource(http, PAGE),
566
+ fields: ["id"],
567
+ ids: (lines) => fk(lines, "companyID")
568
+ },
569
+ organizationalLevelAssociations: {
570
+ make: (http) => new OrganizationalLevelAssociationsResource(http, PAGE),
571
+ fields: ["id", "organizationalLevel1ID", "organizationalLevel2ID"],
572
+ ids: (lines) => fk(lines, "organizationalLevelAssociationID")
573
+ },
574
+ organizationalLevel1s: {
575
+ make: (http) => new OrganizationalLevel1sResource(http, PAGE),
576
+ fields: ["id", "name"],
577
+ ids: (_, j) => fk(j["organizationalLevelAssociations"].values(), "organizationalLevel1ID"),
578
+ chunks: ONE_CHUNK
579
+ },
580
+ organizationalLevel2s: {
581
+ make: (http) => new OrganizationalLevel2sResource(http, PAGE),
582
+ fields: ["id", "name"],
583
+ ids: (_, j) => fk(j["organizationalLevelAssociations"].values(), "organizationalLevel2ID"),
584
+ chunks: ONE_CHUNK
585
+ },
586
+ timeEntries: {
587
+ make: (http) => new TimeEntriesResource(http, PAGE),
588
+ fields: ["id", "resourceID"],
589
+ ids: (lines) => fk(lines.filter(isLabour), "timeEntryID"),
590
+ // One time entry per labour line: the line count is the upper bound (labour share unknown pre-walk).
591
+ chunks: (_, lines) => pages(lines)
592
+ },
593
+ resources: {
594
+ make: (http, client) => new ResourcesResource(http, client.config, PAGE),
595
+ fields: ["id", "firstName", "lastName"],
596
+ ids: (_, j) => fk(j["timeEntries"].values(), "resourceID"),
597
+ chunks: ONE_CHUNK
598
+ },
599
+ billingCodes: {
600
+ make: (http) => new BillingCodesResource(http, PAGE),
601
+ fields: ["id", "name"],
602
+ ids: (lines) => fk(lines, "billingCodeID")
603
+ }
604
+ };
605
+ var LINE_FIELDS = ["id", "invoiceID", "companyID", "contractID", "totalAmount", "internalCurrencyTotalAmount", "extendedPrice", "internalCurrencyExtendedPrice"];
606
+ var OPT_KEYS = ["companyIDs", "ownerResourceID", "contractIDs", "tenantWide", "dateFrom", "dateTo", "contractTypes", "contractCategories", "groupBy", "top", "maxVendorCalls", "includeNonChargeable"];
607
+ var NC_LINE_FIELDS = ["nonBillable", "quantity", ...LINE_TYPE_FIELDS];
608
+ var SCOPE_KEYS = ["companyIDs", "ownerResourceID", "contractIDs", "tenantWide"];
609
+ var isPosInt = (v) => Number.isSafeInteger(v) && v > 0;
610
+ function checkIdList(name, v, problems) {
611
+ if (!Array.isArray(v) || v.length === 0 || v.length > SCOPE_IDS_MAX || !v.every(isPosInt)) {
612
+ problems.push(`${name} must be a non-empty array of at most ${SCOPE_IDS_MAX} positive integer ids`);
613
+ return void 0;
614
+ }
615
+ return [...new Set(v)].sort((a, b) => a - b);
616
+ }
617
+ function checkSelector(name, v, problems) {
618
+ if (v === void 0) return void 0;
619
+ if (!Array.isArray(v) || v.length === 0 || !v.every((x) => isPosInt(x) || typeof x === "string" && x.trim() !== "")) {
620
+ problems.push(`${name} must be a non-empty array of picklist ids or labels`);
621
+ return void 0;
622
+ }
623
+ return v;
624
+ }
625
+ function checkInt(name, v, def, max, problems) {
626
+ if (v === void 0) return def;
627
+ if (!Number.isInteger(v) || v < 1 || v > max) {
628
+ problems.push(`${name} must be an integer 1..${max} (got ${String(v)})`);
629
+ return def;
630
+ }
631
+ return v;
632
+ }
633
+ function validate(opts) {
634
+ if (typeof opts !== "object" || opts === null || Array.isArray(opts)) {
635
+ throw new AutotaskConfigError("revenueRollup: opts must be the options object { <scope>, dateFrom, dateTo, ... } (zero wire was issued)", { operation: OP });
636
+ }
637
+ const o = opts;
638
+ const problems = [];
639
+ for (const k of Object.keys(o)) if (!OPT_KEYS.includes(k)) problems.push(`unknown opts key "${k}" \u2014 the bag is closed: ${OPT_KEYS.join(", ")}`);
640
+ const given = SCOPE_KEYS.filter((k) => o[k] !== void 0);
641
+ if (given.length !== 1) {
642
+ problems.push(given.length === 0 ? "a scope is required: exactly one of companyIDs, ownerResourceID, contractIDs, or tenantWide: true" : `exactly one scope key is allowed (got ${given.join(", ")})`);
643
+ }
644
+ const scopeKind = given[0] ?? "tenantWide";
645
+ let scopeIds;
646
+ if (given.length === 1) {
647
+ if (scopeKind === "companyIDs" || scopeKind === "contractIDs") scopeIds = checkIdList(scopeKind, o[scopeKind], problems);
648
+ if (scopeKind === "ownerResourceID" && !isPosInt(o.ownerResourceID)) problems.push("ownerResourceID must be a positive integer resource id");
649
+ if (scopeKind === "tenantWide" && o.tenantWide !== true) problems.push("tenantWide must be exactly true");
650
+ }
651
+ const { dateFrom, dateTo } = o;
652
+ if (!isDateYMD(dateFrom) || !isDateYMD(dateTo)) {
653
+ problems.push("dateFrom and dateTo are both required as YYYY-MM-DD (the window is inclusive, whole UTC days)");
654
+ } else if (dateFrom > dateTo) {
655
+ problems.push(`dateFrom ${dateFrom} is after dateTo ${dateTo}`);
656
+ } else if (monthsInWindow(dateFrom, dateTo) > PROFITABILITY_MAX_WINDOW_MONTHS) {
657
+ problems.push(`the window spans ${monthsInWindow(dateFrom, dateTo)} months; the cap is ${PROFITABILITY_MAX_WINDOW_MONTHS}`);
658
+ }
659
+ let groupBy = [...DEFAULT_GROUP_BY];
660
+ if (o.groupBy !== void 0) {
661
+ const g = o.groupBy;
662
+ if (!Array.isArray(g) || g.length === 0 || new Set(g).size !== g.length) {
663
+ problems.push(`groupBy must be a non-empty array of distinct dimensions: ${Object.keys(DIMENSIONS).join(", ")}`);
664
+ } else {
665
+ for (const d of g) if (!Object.hasOwn(DIMENSIONS, d)) problems.push(`unknown groupBy dimension "${String(d)}" \u2014 the dimensions are: ${Object.keys(DIMENSIONS).join(", ")}`);
666
+ groupBy = g;
667
+ }
668
+ }
669
+ const v = {
670
+ dateFrom,
671
+ dateTo,
672
+ scopeKind,
673
+ scopeIds,
674
+ ownerResourceID: scopeKind === "ownerResourceID" ? o.ownerResourceID : void 0,
675
+ contractTypes: checkSelector("contractTypes", o.contractTypes, problems),
676
+ contractCategories: checkSelector("contractCategories", o.contractCategories, problems),
677
+ groupBy,
678
+ top: checkInt("top", o.top, TOP_MAX, TOP_MAX, problems),
679
+ budget: checkInt("maxVendorCalls", o.maxVendorCalls, o.includeNonChargeable === true ? DEFAULT_BUDGET_NON_CHARGEABLE : DEFAULT_BUDGET, BUDGET_CAP, problems),
680
+ nonChargeable: o.includeNonChargeable === true
681
+ };
682
+ if (o.includeNonChargeable !== void 0 && typeof o.includeNonChargeable !== "boolean") problems.push("includeNonChargeable must be a boolean");
683
+ if (problems.length > 0) {
684
+ throw new AutotaskConfigError(`revenueRollup: ${problems.length} problem(s) found pre-request (zero wire was issued)
685
+ - ${problems.join("\n- ")}`, { operation: OP });
686
+ }
687
+ return v;
688
+ }
689
+ function resolveSelector(name, sel, values) {
690
+ const out = /* @__PURE__ */ new Map();
691
+ const misses = [];
692
+ for (const s of sel) {
693
+ const hits = typeof s === "number" ? values.has(s) ? [s] : [] : [...values].filter(([, l]) => l.trim().toLowerCase() === s.trim().toLowerCase()).map(([id]) => id);
694
+ if (hits.length === 0) misses.push(JSON.stringify(s));
695
+ for (const h of hits) out.set(h, values.get(h));
696
+ }
697
+ if (misses.length > 0) throw unmatched(name, `${misses.join(", ")} matched no live value`, values);
698
+ return out;
699
+ }
700
+ function unmatched(name, what, values) {
701
+ const live = [...values].map(([id, l]) => `${id} ${l}`).join("; ") || "(the picklist serves no values)";
702
+ return new AutotaskConfigError(`revenueRollup: ${name}: ${what}. Live values (id label): ${live}`, {
703
+ operation: OP,
704
+ suggestedAction: `Pass ${name} as ids or exact labels from the live list.`
705
+ });
706
+ }
707
+ var sortedRecord = (m) => Object.fromEntries([...m].sort((a, b) => a[0] - b[0]).map(([id, l]) => [String(id), l]));
708
+ var chunk = (a) => {
709
+ const out = [];
710
+ for (let i = 0; i < a.length; i += PAGE) out.push(a.slice(i, i + PAGE));
711
+ return out;
712
+ };
713
+ var byId = (rows) => [...new Map(rows.map((r) => [num(r["id"]), r])).values()];
714
+ var cents = (v) => Math.round(num(v) * 100);
715
+ var revC = (line) => cents(internalAmount(line, "internalCurrencyTotalAmount", "totalAmount"));
716
+ var extC = (line) => cents(internalAmount(line, "internalCurrencyExtendedPrice", "extendedPrice"));
717
+ var INTERNAL_FIELDS = ["internalCurrencyTotalAmount", "internalCurrencyExtendedPrice"];
718
+ var lacksInternal = (line) => INTERNAL_FIELDS.some((k) => !Number.isFinite(line[k]));
719
+ var money = (c) => c / 100;
720
+ var pages = (n) => Math.ceil(n / PAGE);
721
+ async function collect(res, filter, includeFields) {
722
+ const out = [];
723
+ for await (const r of res.query({ filter, includeFields, maxRecords: PAGE })) out.push(r);
724
+ return out;
725
+ }
726
+ function blindCount() {
727
+ return new AutotaskError("revenueRollup: a gate count came back blind (unknown, not zero); the roll-up cannot be sized", {
728
+ code: "INVALID_RESPONSE",
729
+ category: "server",
730
+ operation: OP,
731
+ details: { reason: "blind_count" },
732
+ suggestedAction: "Check the API user can see invoices and billing items (operations.blindSurfaces), then retry."
733
+ });
734
+ }
735
+ function countMismatch(what, streamed, counted, rows) {
736
+ const rowNote = rows !== void 0 && rows !== streamed ? ` (${rows} rows)` : "";
737
+ return new AutotaskError(`revenueRollup: the ${what} stream returned ${streamed} distinct ids${rowNote} but the gate count was ${counted} (the data changed mid-walk or a page dropped rows); the fold was discarded`, {
738
+ code: "INVALID_RESPONSE",
739
+ category: "server",
740
+ operation: OP,
741
+ details: { reason: "count_mismatch", stream: what, streamed, counted },
742
+ suggestedAction: "Retry the call."
743
+ });
744
+ }
745
+ function budgetError(message, details, suggestedAction) {
746
+ return new AutotaskError(message, { code: "BUDGET_EXCEEDED", category: "policy", operation: OP, details, suggestedAction });
747
+ }
748
+ function lastMonthsFrom(dateTo, months) {
749
+ return new Date(Date.UTC(Number(dateTo.slice(0, 4)), Number(dateTo.slice(5, 7)) - months, 1)).toISOString().slice(0, 10);
750
+ }
751
+ async function revenueRollup(client, opts) {
752
+ const v = validate(opts);
753
+ const raw = client.http;
754
+ let calls = 0;
755
+ let estimate = 0;
756
+ const sizingMin = [.../* @__PURE__ */ new Set([CONTRACTS_PATH, ...v.groupBy.flatMap((d) => DIMENSIONS[d].picklist ? [DIMENSIONS[d].picklist] : [])])].length + (v.scopeKind === "ownerResourceID" ? 1 : 0) + 2 + (v.nonChargeable ? 1 : 0);
757
+ const http = Object.create(raw);
758
+ http.request = (o) => {
759
+ if (calls >= v.budget && estimate === 0) {
760
+ throw budgetError(
761
+ `revenueRollup: the vendor-call budget of ${v.budget} is below the sizing overhead (labels, scope and count calls: at least ${sizingMin} for this call); the roll-up could not be sized and no stream was read. Retry with a higher maxVendorCalls (cap ${BUDGET_CAP}); the refusal then names the full estimate.`,
762
+ { phase: "estimate", estimate: sizingMin, budget: v.budget, vendorCalls: calls, suggestions: [{ maxVendorCalls: sizingMin }] },
763
+ `Pass maxVendorCalls >= ${sizingMin} (cap ${BUDGET_CAP}).`
764
+ );
765
+ }
766
+ if (calls >= v.budget) {
767
+ throw budgetError(
768
+ `revenueRollup: the vendor-call budget of ${v.budget} was exhausted mid-walk (pre-walk estimate ${estimate}); the partial fold was discarded. Retry with a higher maxVendorCalls (cap ${BUDGET_CAP}) or a shorter window / a scope.`,
769
+ { phase: "walk", budget: v.budget, vendorCalls: calls, estimate },
770
+ `Pass maxVendorCalls > ${v.budget} (cap ${BUDGET_CAP}), a later dateFrom, or a scope.`
771
+ );
772
+ }
773
+ calls += 1;
774
+ return raw.request(o);
775
+ };
776
+ const groupDims = v.groupBy.map((d) => DIMENSIONS[d]);
777
+ const paths = [.../* @__PURE__ */ new Set([CONTRACTS_PATH, ...groupDims.flatMap((d) => d.picklist ? [d.picklist] : [])])];
778
+ const labels = {};
779
+ for (const p of paths) labels[p] = await readPicklists(http, p, OP);
780
+ const contractLabels = labels[CONTRACTS_PATH];
781
+ const typeValues = contractLabels.get("contractType") ?? /* @__PURE__ */ new Map();
782
+ const inTypes = v.contractTypes ? resolveSelector("contractTypes", v.contractTypes, typeValues) : new Map([...typeValues].filter(([, l]) => DEFAULT_CONTRACT_TYPE_RE.test(l)));
783
+ if (inTypes.size === 0) throw unmatched("contractTypes", "no live contract type label starts with 'Recurring' or 'Fixed' (the default)", typeValues);
784
+ const inCategories = v.contractCategories ? resolveSelector("contractCategories", v.contractCategories, contractLabels.get("contractCategory") ?? /* @__PURE__ */ new Map()) : void 0;
785
+ let scopeIds = v.scopeIds;
786
+ if (v.scopeKind === "ownerResourceID") {
787
+ const owned = /* @__PURE__ */ new Set();
788
+ for await (const c of new CompaniesResource(http, PAGE).query({ filter: [{ field: "ownerResourceID", op: "eq", value: v.ownerResourceID }], includeFields: ["id"], maxRecords: PAGE })) {
789
+ const id = num(c.id);
790
+ if (id > 0) owned.add(id);
791
+ if (owned.size > SCOPE_IDS_MAX) break;
792
+ }
793
+ scopeIds = [...owned].sort((a, b) => a - b);
794
+ if (scopeIds.length === 0 || scopeIds.length > SCOPE_IDS_MAX) {
795
+ throw new AutotaskConfigError(
796
+ `revenueRollup: ownerResourceID ${v.ownerResourceID} resolves to ${scopeIds.length > SCOPE_IDS_MAX ? `more than ${SCOPE_IDS_MAX}` : 0} companies (companies.query; 1..${SCOPE_IDS_MAX} supported)`,
797
+ { operation: OP, suggestedAction: "Pass companyIDs explicitly, or tenantWide: true." }
798
+ );
799
+ }
800
+ }
801
+ const scopeCond = scopeIds === void 0 ? [] : [{ field: v.scopeKind === "contractIDs" ? "contractID" : "companyID", op: "in", value: scopeIds }];
802
+ const invoices = new InvoicesResource(http, PAGE);
803
+ const billingItems = new BillingItemsResource(http, PAGE);
804
+ const windowOn = (field) => [{ field, op: "gte", value: v.dateFrom }, { field, op: "lt", value: dayAfter(v.dateTo) }];
805
+ const invoiceFilter = (voided2) => [...windowOn("invoiceDateTime"), { field: "isVoided", op: "eq", value: voided2 }];
806
+ const withVoided = v.scopeKind !== "contractIDs";
807
+ const voidedFilter = [...invoiceFilter(true), ...scopeIds !== void 0 && withVoided ? [{ field: "companyID", op: "in", value: scopeIds }] : []];
808
+ const nInvoices = await invoices.count({ filter: invoiceFilter(false) });
809
+ const nLines = await billingItems.count({ filter: [...windowOn("itemDate"), { field: "invoiceID", op: "exist" }, ...scopeCond] });
810
+ const uninvoicedFilter = [{ field: "invoiceID", op: "notExist" }, ...windowOn("itemDate"), ...scopeCond];
811
+ const nUninvoiced = v.nonChargeable ? await billingItems.count({ filter: uninvoicedFilter }) : 0;
812
+ if (typeof nInvoices !== "number" || typeof nLines !== "number" || typeof nUninvoiced !== "number") throw blindCount();
813
+ const joinsNeeded = Object.keys(JOINS).filter((j) => j === "contracts" || groupDims.some((d) => d.joins?.[j]));
814
+ const chunks = pages(nInvoices);
815
+ const linePages = chunks + Math.max(0, pages(nLines) - 1) + chunks;
816
+ const uninvoicedPages = v.nonChargeable ? Math.max(1, pages(nUninvoiced)) : 0;
817
+ const idChunks = pages(Math.min(nLines + nUninvoiced, nInvoices));
818
+ const joinChunks = joinsNeeded.reduce((s, j) => {
819
+ const spec = JOINS[j];
820
+ return s + ("chunks" in spec ? spec.chunks(idChunks, nLines + nUninvoiced) : idChunks);
821
+ }, 0);
822
+ const fixed = calls + (withVoided ? 1 : 0);
823
+ estimate = fixed + Math.max(1, chunks) + linePages + uninvoicedPages + joinChunks;
824
+ if (estimate > v.budget) throw refusal(v, estimate, fixed, scopeIds, joinsNeeded.length);
825
+ const invoiceRows = await collect(invoices, invoiceFilter(false), ["id", "invoiceTotal", "invoiceDateTime", "companyID"]);
826
+ const invoiceById = /* @__PURE__ */ new Map();
827
+ for (const r of invoiceRows) {
828
+ invoiceById.set(num(r["id"]), { total: cents(r["invoiceTotal"]), month: String(r["invoiceDateTime"] ?? "").slice(0, 7), companyID: num(r["companyID"]) });
829
+ }
830
+ if (invoiceById.size !== nInvoices) throw countMismatch("invoice", invoiceById.size, nInvoices, invoiceRows.length);
831
+ const voided = withVoided ? byId(await collect(invoices, voidedFilter, ["id", "invoiceTotal"])) : void 0;
832
+ const lineFields = [.../* @__PURE__ */ new Set([...LINE_FIELDS, ...groupDims.flatMap((d) => d.lineFields ?? []), ...v.nonChargeable ? NC_LINE_FIELDS : []])];
833
+ const ids = [...invoiceById.keys()].sort((a, b) => a - b);
834
+ const lines = (await client.batch(async (c) => {
835
+ const filter = [{ field: "invoiceID", op: "in", value: c }, ...scopeCond];
836
+ const n = await billingItems.count({ filter });
837
+ if (typeof n !== "number") throw blindCount();
838
+ const rows = byId(await collect(billingItems, filter, lineFields));
839
+ if (rows.length !== n) throw countMismatch("line", rows.length, n);
840
+ return rows;
841
+ }, chunk(ids), { concurrency: CONCURRENCY })).flat();
842
+ const orphan = lines.find((l) => !invoiceById.has(num(l["invoiceID"])));
843
+ if (orphan !== void 0) {
844
+ throw new AutotaskError(`revenueRollup: billing line ${num(orphan["id"])} belongs to invoice ${num(orphan["invoiceID"])}, which is not among the ${invoiceById.size} streamed invoices; the fold was discarded`, {
845
+ code: "INVALID_RESPONSE",
846
+ category: "server",
847
+ operation: OP,
848
+ details: { reason: "orphan_line", lineID: num(orphan["id"]), invoiceID: num(orphan["invoiceID"]) },
849
+ suggestedAction: "Retry the call."
850
+ });
851
+ }
852
+ const uninvoiced = v.nonChargeable ? byId(await collect(billingItems, uninvoicedFilter, [...lineFields, "itemDate"])) : [];
853
+ if (v.nonChargeable && uninvoiced.length !== nUninvoiced) throw countMismatch("uninvoiced line", uninvoiced.length, nUninvoiced);
854
+ const all = uninvoiced.length > 0 ? [...lines, ...uninvoiced] : lines;
855
+ const joined = Object.fromEntries(Object.keys(JOINS).map((j) => [j, /* @__PURE__ */ new Map()]));
856
+ for (const j of joinsNeeded) {
857
+ const spec = JOINS[j];
858
+ const res = spec.make(http, client);
859
+ const fields = [.../* @__PURE__ */ new Set([...spec.fields, ...groupDims.flatMap((d) => d.joins?.[j] ?? [])])];
860
+ const want = [...new Set(spec.ids(all, joined))].filter((id) => id > 0).sort((a, b) => a - b);
861
+ const rows = await client.batch((c) => collect(res, [{ field: "id", op: "in", value: c }], fields), chunk(want), { concurrency: CONCURRENCY });
862
+ for (const r of rows.flat()) joined[j].set(num(r["id"]), r);
863
+ }
864
+ return fold(v, { invoiceById, voided, lines, uninvoiced, joined, labels, inTypes, inCategories, scopeIds, calls, estimate });
865
+ }
866
+ function refusal(v, estimate, fixed, scopeIds, joinCount) {
867
+ const months = monthsInWindow(v.dateFrom, v.dateTo);
868
+ const variable = estimate - fixed;
869
+ const fitMonths = Math.floor((v.budget - fixed) * months / variable);
870
+ const suggestions = [];
871
+ const text = [];
872
+ let unestimated = false;
873
+ const suggest = (s, t, estimated = false) => {
874
+ suggestions.push(s);
875
+ text.push(t);
876
+ if (!estimated) unestimated = true;
877
+ };
878
+ if (estimate <= BUDGET_CAP) suggest({ maxVendorCalls: estimate }, `maxVendorCalls: ${estimate} (cap ${BUDGET_CAP})`, true);
879
+ if (fitMonths >= 1 && fitMonths < months) {
880
+ const dateFrom = lastMonthsFrom(v.dateTo, fitMonths);
881
+ const est = fixed + Math.ceil(variable * fitMonths / months);
882
+ suggest({ dateFrom, estimate: est }, `dateFrom: '${dateFrom}' (est. ${est})`, true);
883
+ }
884
+ if (v.scopeKind === "tenantWide") suggest({ companyIDs: "[...]" }, "companyIDs: [...] (or ownerResourceID)");
885
+ if (estimate > BUDGET_CAP) {
886
+ if (v.nonChargeable) suggest({ includeNonChargeable: false }, "includeNonChargeable: false");
887
+ if (joinCount > 1) suggest({ groupBy: ["contractType"] }, "groupBy: ['contractType'] (fewer dimensions, fewer id-fetch calls)");
888
+ if (v.scopeKind === "companyIDs" || v.scopeKind === "ownerResourceID") suggest({ contractIDs: "[...]" }, "contractIDs: [...] (one call per group of contracts)");
889
+ const n = (scopeIds ?? []).length;
890
+ if (v.scopeKind === "companyIDs" && n > 1) suggest({ companyIDs: "[...]" }, "a shorter companyIDs list (one call per part)");
891
+ if (v.scopeKind === "ownerResourceID" && n > 1) suggest({ companyIDs: "[...]" }, "companyIDs: a subset of the owner's companies (one call per part)");
892
+ if (v.scopeKind === "contractIDs" && n > 1) suggest({ contractIDs: "[...]" }, "a shorter contractIDs list (one call per part)");
893
+ }
894
+ if (suggestions.length === 0) suggest({ maxVendorCalls: BUDGET_CAP }, `maxVendorCalls: ${BUDGET_CAP} (the cap; the estimate ${estimate} is above it, so this call will be refused at this estimate even at the cap; no narrower shape exists for this scope and window)`, true);
895
+ const caveat = unestimated ? ". Narrowings without an estimate may still be refused: the invoice stream is tenant-wide in every scope, and the line stream costs at least one page and one count per chunk of 500 of those invoices, so a narrower scope or shape does not reduce that part" : "";
896
+ const scope = v.scopeKind === "tenantWide" ? "tenantWide" : `${v.scopeKind} ${(scopeIds ?? []).length} id(s)`;
897
+ return budgetError(
898
+ `revenueRollup: estimated ${estimate} vendor calls exceeds maxVendorCalls ${v.budget} (window ${v.dateFrom}..${v.dateTo}, ${scope}). Narrow with one of: ${text.join("; ")}${caveat}`,
899
+ { phase: "estimate", estimate, budget: v.budget, suggestions },
900
+ `Narrow with one of: ${text.join("; ")}${caveat}`
901
+ );
902
+ }
903
+ var amt = () => ({ amount: 0, lines: 0 });
904
+ var nc = () => ({ contractCovered: { value: 0, hours: 0, lines: 0 }, nonBillable: { value: 0, hours: 0, lines: 0 } });
905
+ var add = (a, c) => {
906
+ a.amount += c;
907
+ a.lines += 1;
908
+ };
909
+ var outAmt = (a) => ({ amount: money(a.amount), lines: a.lines });
910
+ var outNc = (n) => {
911
+ const o = (k) => ({ value: money(n[k].value), hours: n[k].hours / 100, lines: n[k].lines });
912
+ return { contractCovered: o("contractCovered"), nonBillable: o("nonBillable") };
913
+ };
914
+ var outRecord = (r) => Object.fromEntries([...r].sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0).map(([k, a]) => [k, outAmt(a)]));
915
+ function ncClass(line) {
916
+ if (line["nonBillable"] === true || line["nonBillable"] === 1) return "nonBillable";
917
+ return extC(line) > 0 && revC(line) === 0 ? "contractCovered" : void 0;
918
+ }
919
+ function ncAdd(n, k, line) {
920
+ n[k].value += extC(line);
921
+ if (isLabour(line)) n[k].hours += cents(line["quantity"]);
922
+ n[k].lines += 1;
923
+ }
924
+ function compareIds(a, b) {
925
+ for (let i = 0; i < a.length; i++) {
926
+ const x = a[i] ?? null;
927
+ const y = b[i] ?? null;
928
+ if (x === y) continue;
929
+ if (x === null) return -1;
930
+ if (y === null) return 1;
931
+ if (typeof x === "number" && typeof y === "number") return x - y;
932
+ return String(x) < String(y) ? -1 : 1;
933
+ }
934
+ return 0;
935
+ }
936
+ function fold(v, f) {
937
+ const contractLabels = f.labels[CONTRACTS_PATH];
938
+ const bucketLabel = (field, fallback) => (t) => {
939
+ const values = contractLabels.get(field);
940
+ const l = values?.get(t);
941
+ if (l === void 0) return `${fallback} ${t}`;
942
+ return [...values.values()].filter((x) => x === l).length > 1 ? `${l} (${t})` : l;
943
+ };
944
+ const typeLabel = bucketLabel("contractType", "type");
945
+ const catLabel = bucketLabel("contractCategory", "category");
946
+ const needCompany = v.groupBy.some((d) => DIMENSIONS[d].joins?.companies);
947
+ const byLob = v.groupBy.includes("lineOfBusiness");
948
+ const unlabelled = /* @__PURE__ */ new Set();
949
+ const uncontracted = amt();
950
+ const unresolvedContracts = amt();
951
+ const unresolvedCompanies = amt();
952
+ const unresolvedJoins = new Map(v.groupBy.flatMap((d) => DIMENSIONS[d].unresolved ? [[DIMENSIONS[d].unresolved, amt()]] : []));
953
+ const unresolvedNc = amt();
954
+ const noPointer = amt();
955
+ const otherTypes = /* @__PURE__ */ new Map();
956
+ const otherCats = /* @__PURE__ */ new Map();
957
+ const cells = /* @__PURE__ */ new Map();
958
+ const allContracts = /* @__PURE__ */ new Set();
959
+ const lineTotals = /* @__PURE__ */ new Map();
960
+ const zeroTotal = amt();
961
+ const zeroIds = /* @__PURE__ */ new Set();
962
+ const locate = (line, invoice) => {
963
+ const contractID = num(line["contractID"]);
964
+ if (contractID === 0) return { bucket: uncontracted, unresolved: false };
965
+ const contract = f.joined.contracts.get(contractID);
966
+ if (contract === void 0) return { bucket: unresolvedContracts, unresolved: true };
967
+ const type = num(contract["contractType"]);
968
+ if (!f.inTypes.has(type)) return { bucket: slot(otherTypes, typeLabel(type)), unresolved: false };
969
+ const cat = num(contract["contractCategory"]);
970
+ if (f.inCategories !== void 0 && !f.inCategories.has(cat)) return { bucket: slot(otherCats, cat === 0 ? "uncategorised" : catLabel(cat)), unresolved: false };
971
+ const company = f.joined.companies.get(num(line["companyID"]));
972
+ if (needCompany && company === void 0) return { bucket: unresolvedCompanies, unresolved: true };
973
+ const ctx = { line, invoice, contract, company, joined: f.joined, labels: f.labels, unlabelled };
974
+ const keys = {};
975
+ const ids = [];
976
+ for (const d of v.groupBy) {
977
+ const k = DIMENSIONS[d].key(ctx);
978
+ if (typeof k === "string") return { bucket: unresolvedJoins.get(k), unresolved: true };
979
+ keys[d] = k;
980
+ ids.push(k.id);
981
+ }
982
+ const id = JSON.stringify(ids);
983
+ let cell = cells.get(id);
984
+ if (cell === void 0) {
985
+ cell = { keys, ids, revenue: 0, credits: 0, creditLines: 0, included: 0, contracts: /* @__PURE__ */ new Set(), items: 0, nc: nc() };
986
+ cells.set(id, cell);
987
+ }
988
+ return { cell };
989
+ };
990
+ for (const line of f.lines) {
991
+ const invoiceID = num(line["invoiceID"]);
992
+ const invoice = f.invoiceById.get(invoiceID);
993
+ lineTotals.set(invoiceID, (lineTotals.get(invoiceID) ?? 0) + cents(line["totalAmount"]));
994
+ const c = revC(line);
995
+ if (invoice.total === 0) {
996
+ add(zeroTotal, c);
997
+ zeroIds.add(invoiceID);
998
+ }
999
+ const at = locate(line, invoice);
1000
+ if (!("cell" in at)) {
1001
+ add(at.bucket, c);
1002
+ if (at.unresolved && v.nonChargeable && ncClass(line)) add(unresolvedNc, extC(line));
1003
+ continue;
1004
+ }
1005
+ const cell = at.cell;
1006
+ cell.revenue += c;
1007
+ if (c < 0) {
1008
+ cell.credits += c;
1009
+ cell.creditLines += 1;
1010
+ }
1011
+ cell.included += extC(line) - c;
1012
+ cell.contracts.add(num(line["contractID"]));
1013
+ cell.items += 1;
1014
+ allContracts.add(num(line["contractID"]));
1015
+ if (byLob && cell.keys.lineOfBusiness.id === null) add(noPointer, c);
1016
+ const k = v.nonChargeable ? ncClass(line) : void 0;
1017
+ if (k) ncAdd(cell.nc, k, line);
1018
+ }
1019
+ for (const line of f.uninvoiced) {
1020
+ const k = ncClass(line);
1021
+ if (!k) continue;
1022
+ const at = locate(line, { total: 0, month: String(line["itemDate"] ?? "").slice(0, 7), companyID: num(line["companyID"]) });
1023
+ if ("cell" in at) ncAdd(at.cell.nc, k, line);
1024
+ else if (at.unresolved) add(unresolvedNc, extC(line));
1025
+ }
1026
+ const sorted = [...cells.values()].sort((a, b) => b.revenue - a.revenue || compareIds(a.ids, b.ids));
1027
+ const kept = sorted.slice(0, v.top);
1028
+ const cut = sorted.slice(v.top);
1029
+ const sum = (k) => sorted.reduce((s, x) => s + x[k], 0);
1030
+ const ncTotal = nc();
1031
+ for (const x of sorted) for (const k of ["contractCovered", "nonBillable"]) for (const m of ["value", "hours", "lines"]) ncTotal[k][m] += x.nc[k][m];
1032
+ const reconciled = v.scopeKind === "contractIDs" ? [] : [...f.invoiceById].filter(([, inv]) => v.scopeKind === "tenantWide" || f.scopeIds.includes(inv.companyID));
1033
+ let headerTotal = 0;
1034
+ let lineTotal = 0;
1035
+ let mismatched = 0;
1036
+ for (const [id, inv] of reconciled) {
1037
+ const lt = lineTotals.get(id) ?? 0;
1038
+ headerTotal += inv.total;
1039
+ lineTotal += lt;
1040
+ if (lt !== inv.total) mismatched += 1;
1041
+ }
1042
+ const blindSurfaces2 = [...unlabelled].sort();
1043
+ const noInternal = [...f.lines, ...f.uninvoiced].filter(lacksInternal).length;
1044
+ if (noInternal > 0) blindSurfaces2.push(`billing_items: ${noInternal} line(s) without internalCurrencyTotalAmount / internalCurrencyExtendedPrice; their local-currency totalAmount / extendedPrice was used instead`);
1045
+ if (unresolvedCompanies.lines > 0) blindSurfaces2.push("companies.query: some line companies were not returned by the id-in fetch (the companies list surface can be blind to companies the API user does not own); their lines are in coverage.excluded.unresolvedCompanies");
1046
+ if (v.scopeKind === "ownerResourceID") blindSurfaces2.push("companies.query: ownerResourceID was resolved through the companies list surface, which can be blind to companies the API user does not own; a company it hides is missing from the scope and its revenue is not counted (scope.ids lists the companies that were resolved)");
1047
+ const notes = [
1048
+ "basis: invoice date (invoiceDateTime) as whole UTC days; voided invoices excluded; revenue = internalCurrencyTotalAmount (ex-tax, internal currency); includedValue is informational and never in revenue",
1049
+ "prepaid block-hour / retainer contracts: revenue is the billed purchase"
1050
+ ];
1051
+ notes.push("coverage.zeroTotalInvoices is informational: its lines are also folded into rows / the excluded buckets, so it is not part of coverage.excluded (revenue + the excluded line buckets reconcile to the whole)");
1052
+ if (v.scopeKind === "contractIDs") {
1053
+ notes.push("reconciliation is not run for a contractIDs scope (only the scoped contracts' lines are fetched)");
1054
+ notes.push("coverage.excluded.voidedInvoices is omitted for a contractIDs scope: an invoice header cannot be attributed to a contract (zeroTotalInvoices counts the zero-total invoices carrying a scoped line)");
1055
+ } else if (v.scopeKind !== "tenantWide") {
1056
+ notes.push("scope applies to the line's companyID; reconciliation, voidedInvoices and the zero-total count cover the invoices whose header company is in scope (zero-total also counts an invoice carrying an in-scope line; a mismatch can mean a line billed to another company)");
1057
+ }
1058
+ if (v.nonChargeable) {
1059
+ notes.push("nonChargeable is never added to revenue: contractCovered = billable lines with internalCurrencyExtendedPrice > 0 and internalCurrencyTotalAmount = 0, nonBillable = nonBillable lines; value = \u03A3 internalCurrencyExtendedPrice, hours = \u03A3 quantity on labour lines (Labor and Labor Adjustment). Invoiced lines are keyed on the invoice date; uninvoiced lines (invoiceID notExist) have no invoice date, so they are windowed and month-keyed on itemDate (whole UTC days). Uninvoiced lines outside the contract-type/category scope or uncontracted are not counted.");
1060
+ }
1061
+ for (const [id, inv] of f.invoiceById) {
1062
+ if (inv.total === 0 && (v.scopeKind === "tenantWide" || v.scopeKind !== "contractIDs" && f.scopeIds.includes(inv.companyID))) zeroIds.add(id);
1063
+ }
1064
+ const result = {
1065
+ window: { dateFrom: v.dateFrom, dateTo: v.dateTo, basis: "invoiceDate" },
1066
+ scope: {
1067
+ kind: v.scopeKind,
1068
+ ...v.ownerResourceID !== void 0 ? { ownerResourceID: v.ownerResourceID } : {},
1069
+ ...f.scopeIds !== void 0 ? { ids: f.scopeIds } : {}
1070
+ },
1071
+ groupBy: v.groupBy,
1072
+ top: v.top,
1073
+ rows: kept.map((x) => ({
1074
+ keys: x.keys,
1075
+ revenue: money(x.revenue),
1076
+ credits: { amount: money(x.credits), lines: x.creditLines },
1077
+ includedValue: money(x.included),
1078
+ contracts: x.contracts.size,
1079
+ items: x.items,
1080
+ ...v.nonChargeable ? { nonChargeable: outNc(x.nc) } : {}
1081
+ })),
1082
+ ...cut.length > 0 ? { rowsTruncated: { rows: cut.length, revenue: money(cut.reduce((s, x) => s + x.revenue, 0)) } } : {},
1083
+ totals: {
1084
+ revenue: money(sum("revenue")),
1085
+ credits: money(sum("credits")),
1086
+ includedValue: money(sum("included")),
1087
+ contracts: allContracts.size,
1088
+ items: sum("items"),
1089
+ ...v.nonChargeable ? { nonChargeable: outNc(ncTotal) } : {}
1090
+ },
1091
+ coverage: {
1092
+ vendorCalls: f.calls,
1093
+ budget: v.budget,
1094
+ estimate: f.estimate,
1095
+ tenantHourlyShare: `${(f.calls / TENANT_HOURLY_LIMIT * 100).toFixed(2)}% of ${TENANT_HOURLY_LIMIT.toLocaleString("en-US")}/h`,
1096
+ contractTypeMap: sortedRecord(f.inTypes),
1097
+ ...f.inCategories !== void 0 ? { contractCategoryMap: sortedRecord(f.inCategories) } : {},
1098
+ reconciliation: { headerTotal: money(headerTotal), lineTotal: money(lineTotal), delta: money(lineTotal - headerTotal), invoicesChecked: reconciled.length, invoicesMismatched: mismatched },
1099
+ zeroTotalInvoices: { count: zeroIds.size, ...outAmt(zeroTotal) },
1100
+ excluded: {
1101
+ ...f.voided !== void 0 ? { voidedInvoices: { count: f.voided.length, headerAmount: money(f.voided.reduce((s, r) => s + cents(r["invoiceTotal"]), 0)) } } : {},
1102
+ uncontracted: outAmt(uncontracted),
1103
+ otherContractTypes: outRecord(otherTypes),
1104
+ otherContractCategories: outRecord(otherCats),
1105
+ unresolvedContracts: outAmt(unresolvedContracts),
1106
+ unresolvedCompanies: outAmt(unresolvedCompanies),
1107
+ ...Object.fromEntries([...unresolvedJoins].map(([k, a]) => [k, outAmt(a)])),
1108
+ ...v.nonChargeable ? { unresolvedNonChargeable: outAmt(unresolvedNc) } : {}
1109
+ },
1110
+ ...byLob ? {
1111
+ lineOfBusiness: {
1112
+ noPointer: outAmt(noPointer),
1113
+ note: `LOB figures are floors: lines without a pointer are shown separately (the "no LOB assigned" row, id null). LOB is per billing line (no contract fallback), so a row's contracts = contracts with at least one line in that LOB, and per-LOB contract counts can sum to more than totals.contracts.`
1114
+ }
1115
+ } : {},
1116
+ blindSurfaces: blindSurfaces2,
1117
+ notes
1118
+ }
1119
+ };
1120
+ return result;
1121
+ }
1122
+ function slot(m, k) {
1123
+ const a = m.get(k) ?? amt();
1124
+ m.set(k, a);
1125
+ return a;
1126
+ }
1127
+
440
1128
  // src/operations/relationshipMap.ts
441
1129
  async function relationshipMap(client, resourceKey) {
442
1130
  void client;
@@ -1221,7 +1909,7 @@ function unknownKeysRefusal(raw, validKeys, subject, operation) {
1221
1909
 
1222
1910
  // src/operations/invoke/registry.ts
1223
1911
  var RECORDS = CAPABILITIES;
1224
- var byId = new Map(RECORDS.map((r) => [r.id, r]));
1912
+ var byId2 = new Map(RECORDS.map((r) => [r.id, r]));
1225
1913
 
1226
1914
  // src/operations/invoke/dispatch.ts
1227
1915
  var INVOKE_MAX_INLINE_BYTES = 1024 * 1024;
@@ -1237,7 +1925,7 @@ function bufferResult(buf) {
1237
1925
  };
1238
1926
  }
1239
1927
  function resolveRecord(operation) {
1240
- const record = byId.get(operation);
1928
+ const record = byId2.get(operation);
1241
1929
  if (record === void 0) {
1242
1930
  throw new AutotaskConfigError(
1243
1931
  `invoke: unknown operation "${operation}" \u2014 it is not in the capability registry`,
@@ -1297,6 +1985,8 @@ function operationsHolder(client) {
1297
1985
  // <resource>.query op via the house bounded walk and fold client-side
1298
1986
  // (truncation honesty: hasMore/totalScanned — no silent partial sums).
1299
1987
  aggregate: (operation, filter, opts) => aggregate(client, operation, filter, opts),
1988
+ // Issue #234: revenue by dimension in one budgeted call (refuses over budget, never partial).
1989
+ revenueRollup: (opts) => revenueRollup(client, opts),
1300
1990
  // Issue #59 PR-B: the bulk fetch by ids — query-first chunked `id in`
1301
1991
  // reads + the per-id `get` fallback (blind-surface-safe; per-item
1302
1992
  // typed outcomes reuse the fetchMany shapes).
@@ -1473,6 +2163,7 @@ export {
1473
2163
  relationshipMap,
1474
2164
  resolveAny,
1475
2165
  resourceForEntityType,
2166
+ revenueRollup,
1476
2167
  searchAcrossResources,
1477
2168
  uiLink,
1478
2169
  unknownKeysRefusal,