@powerhousedao/contributor-billing 1.0.0-dev.3 → 1.0.0-dev.5

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 (55) hide show
  1. package/dist/document-models/invoice/src/reducers/general.d.ts.map +1 -1
  2. package/dist/document-models/invoice/src/reducers/general.js +8 -0
  3. package/dist/document-models/invoice/src/reducers/transitions.d.ts.map +1 -1
  4. package/dist/document-models/invoice/src/reducers/transitions.js +7 -0
  5. package/dist/document-models/invoice/tests/general.test.js +11 -2
  6. package/dist/document-models/invoice/tests/transitions.test.js +7 -2
  7. package/dist/editors/builder-team-admin/components/DriveExplorer.d.ts.map +1 -1
  8. package/dist/editors/builder-team-admin/components/DriveExplorer.js +64 -4
  9. package/dist/editors/builder-team-admin/module.js +1 -1
  10. package/dist/editors/contributor-billing/components/DashboardHome.d.ts.map +1 -1
  11. package/dist/editors/contributor-billing/components/DashboardHome.js +2 -8
  12. package/dist/editors/contributor-billing/components/DriveExplorer.d.ts.map +1 -1
  13. package/dist/editors/contributor-billing/components/DriveExplorer.js +52 -1
  14. package/dist/editors/contributor-billing/components/FolderTree.d.ts.map +1 -1
  15. package/dist/editors/contributor-billing/components/FolderTree.js +6 -15
  16. package/dist/editors/contributor-billing/components/MonthReportCard.d.ts +8 -1
  17. package/dist/editors/contributor-billing/components/MonthReportCard.d.ts.map +1 -1
  18. package/dist/editors/contributor-billing/components/MonthReportCard.js +6 -3
  19. package/dist/editors/contributor-billing/components/MonthlyReportsOverview.d.ts +1 -1
  20. package/dist/editors/contributor-billing/components/MonthlyReportsOverview.d.ts.map +1 -1
  21. package/dist/editors/contributor-billing/components/MonthlyReportsOverview.js +56 -3
  22. package/dist/editors/contributor-billing/components/cbToast.d.ts.map +1 -1
  23. package/dist/editors/contributor-billing/hooks/useDocumentAutoPlacement.d.ts.map +1 -1
  24. package/dist/editors/contributor-billing/hooks/useDocumentAutoPlacement.js +42 -2
  25. package/dist/editors/contributor-billing/module.js +1 -1
  26. package/dist/editors/invoice/editor.d.ts.map +1 -1
  27. package/dist/editors/invoice/editor.js +3 -2
  28. package/dist/editors/invoice/legalEntity/legalEntity.d.ts +2 -1
  29. package/dist/editors/invoice/legalEntity/legalEntity.d.ts.map +1 -1
  30. package/dist/editors/invoice/legalEntity/legalEntity.js +2 -2
  31. package/dist/editors/invoice/legalEntity/walletSection.d.ts +1 -0
  32. package/dist/editors/invoice/legalEntity/walletSection.d.ts.map +1 -1
  33. package/dist/editors/invoice/legalEntity/walletSection.js +2 -2
  34. package/dist/editors/invoice/validation/validationHandler.d.ts +1 -1
  35. package/dist/editors/invoice/validation/validationHandler.d.ts.map +1 -1
  36. package/dist/editors/invoice/validation/validationHandler.js +14 -1
  37. package/dist/editors/invoice/validation/validationManager.d.ts +1 -1
  38. package/dist/editors/invoice/validation/validationManager.d.ts.map +1 -1
  39. package/dist/editors/invoice/validation/validationManager.js +2 -1
  40. package/dist/editors/invoice/validation/validationRules.d.ts +1 -0
  41. package/dist/editors/invoice/validation/validationRules.d.ts.map +1 -1
  42. package/dist/editors/invoice/validation/validationRules.js +26 -1
  43. package/dist/style.css +96 -3
  44. package/dist/subgraphs/budget-statements/resolvers.d.ts +38 -0
  45. package/dist/subgraphs/budget-statements/resolvers.d.ts.map +1 -1
  46. package/dist/subgraphs/budget-statements/resolvers.js +151 -44
  47. package/dist/subgraphs/budget-statements/resolvers.test.d.ts +2 -0
  48. package/dist/subgraphs/budget-statements/resolvers.test.d.ts.map +1 -0
  49. package/dist/subgraphs/budget-statements/resolvers.test.js +329 -0
  50. package/dist/subgraphs/budget-statements/schema.d.ts.map +1 -1
  51. package/dist/subgraphs/budget-statements/schema.js +8 -0
  52. package/package.json +16 -16
  53. package/dist/editors/contributor-billing/components/CreateHubProfileModal.d.ts +0 -12
  54. package/dist/editors/contributor-billing/components/CreateHubProfileModal.d.ts.map +0 -1
  55. package/dist/editors/contributor-billing/components/CreateHubProfileModal.js +0 -74
@@ -1,38 +1,37 @@
1
1
  import {} from "@powerhousedao/reactor-api";
2
+ // Helper to extract YYYY-MM-DD from an ISO date string without Date object
3
+ // to avoid timezone-dependent parsing
4
+ export const extractIsoDate = (dateStr) => {
5
+ const match = dateStr.match(/^(\d{4}-\d{2}-\d{2})/);
6
+ return match ? match[1] : null;
7
+ };
2
8
  // Helper to create a period key from start and end dates
3
- const getPeriodKey = (periodStart, periodEnd) => {
9
+ export const getPeriodKey = (periodStart, periodEnd) => {
4
10
  if (!periodStart || !periodEnd)
5
11
  return null;
6
- // Normalize dates to YYYY-MM-DD format for consistent matching
7
- const startDate = new Date(periodStart);
8
- const endDate = new Date(periodEnd);
9
- if (isNaN(startDate.getTime()) || isNaN(endDate.getTime()))
12
+ const start = extractIsoDate(periodStart);
13
+ const end = extractIsoDate(periodEnd);
14
+ if (!start || !end)
10
15
  return null;
11
- const formatDate = (d) => d.toISOString().split("T")[0];
12
- return `${formatDate(startDate)}_${formatDate(endDate)}`;
16
+ return `${start}_${end}`;
13
17
  };
14
- // Helper to extract month key from date (format: "JAN2026")
15
- const getMonthKey = (dateStr) => {
18
+ const MONTHS = [
19
+ "JAN", "FEB", "MAR", "APR", "MAY", "JUN",
20
+ "JUL", "AUG", "SEP", "OCT", "NOV", "DEC",
21
+ ];
22
+ // Helper to extract month key from an ISO date string (format: "SEP2025")
23
+ // Parses directly from the string to avoid timezone issues
24
+ export const getMonthKey = (dateStr) => {
16
25
  if (!dateStr)
17
26
  return null;
18
- const date = new Date(dateStr);
19
- if (isNaN(date.getTime()))
27
+ const match = dateStr.match(/^(\d{4})-(\d{2})/);
28
+ if (!match)
29
+ return null;
30
+ const year = match[1];
31
+ const monthIndex = parseInt(match[2], 10) - 1;
32
+ if (monthIndex < 0 || monthIndex > 11)
20
33
  return null;
21
- const months = [
22
- "JAN",
23
- "FEB",
24
- "MAR",
25
- "APR",
26
- "MAY",
27
- "JUN",
28
- "JUL",
29
- "AUG",
30
- "SEP",
31
- "OCT",
32
- "NOV",
33
- "DEC",
34
- ];
35
- return `${months[date.getMonth()]}${date.getFullYear()}`;
34
+ return `${MONTHS[monthIndex]}${year}`;
36
35
  };
37
36
  // Helper to sort budget statements by month (most recent first)
38
37
  const sortByMonth = (a, b) => {
@@ -162,9 +161,50 @@ export const getResolvers = (subgraph) => {
162
161
  }
163
162
  }
164
163
  }
165
- // Step 2: Group reports by ownerId AND period
164
+ // Step 2: Resolve builder profiles and build opHub lookup
165
+ // We need this before grouping so snapshot reports can be shared across op hub members
166
+ const resolvedProfiles = new Map();
167
+ const builderToOpHub = new Map();
168
+ const resolveProfile = async (phid) => {
169
+ if (resolvedProfiles.has(phid))
170
+ return resolvedProfiles.get(phid);
171
+ let doc = builderProfileDocs.get(phid) || null;
172
+ if (!doc) {
173
+ try {
174
+ doc = await reactor.getDocument(phid);
175
+ }
176
+ catch {
177
+ // Profile may not exist
178
+ }
179
+ }
180
+ const state = doc
181
+ ? (doc.state
182
+ ?.global ?? null)
183
+ : null;
184
+ resolvedProfiles.set(phid, state);
185
+ if (state?.operationalHubMember?.phid) {
186
+ builderToOpHub.set(phid, state.operationalHubMember.phid);
187
+ }
188
+ return state;
189
+ };
190
+ // Pre-resolve all builder profiles we'll need
191
+ const allOwnerIds = new Set();
192
+ for (const doc of snapshotReportDocs) {
193
+ const id = doc.state.global.ownerIds?.[0];
194
+ if (id)
195
+ allOwnerIds.add(id);
196
+ }
197
+ for (const doc of expenseReportDocs) {
198
+ const id = doc.state.global.ownerId;
199
+ if (id)
200
+ allOwnerIds.add(id);
201
+ }
202
+ await Promise.all(Array.from(allOwnerIds).map((id) => resolveProfile(id)));
203
+ // Step 3: Group reports by ownerId AND period
166
204
  // Key format: "ownerId_periodStart_periodEnd"
167
205
  const budgetStatementsByOwnerAndPeriod = new Map();
206
+ // Index snapshot reports by opHub + period so they can be shared
207
+ const snapshotByOpHub = new Map();
168
208
  // Group snapshot reports
169
209
  for (const snapshotDoc of snapshotReportDocs) {
170
210
  const state = snapshotDoc.state.global;
@@ -185,6 +225,11 @@ export const getResolvers = (subgraph) => {
185
225
  }
186
226
  budgetStatementsByOwnerAndPeriod.get(key).snapshotReport =
187
227
  snapshotDoc;
228
+ // Also index by opHub + period for sharing across subteams
229
+ const opHubPhid = builderToOpHub.get(ownerId);
230
+ if (opHubPhid) {
231
+ snapshotByOpHub.set(`${opHubPhid}_${periodKey}`, snapshotDoc);
232
+ }
188
233
  }
189
234
  // Group expense reports and match with snapshot reports
190
235
  for (const expenseDoc of expenseReportDocs) {
@@ -206,25 +251,22 @@ export const getResolvers = (subgraph) => {
206
251
  }
207
252
  budgetStatementsByOwnerAndPeriod.get(key).expenseReport = expenseDoc;
208
253
  }
209
- // Step 3: Build the budget statements
254
+ // Step 4: Fill in missing snapshot reports from op hub siblings
255
+ for (const entry of budgetStatementsByOwnerAndPeriod.values()) {
256
+ if (entry.snapshotReport)
257
+ continue; // already has a direct match
258
+ const opHubPhid = builderToOpHub.get(entry.ownerId);
259
+ if (!opHubPhid)
260
+ continue;
261
+ const opHubSnapshot = snapshotByOpHub.get(`${opHubPhid}_${entry.periodKey}`);
262
+ if (opHubSnapshot) {
263
+ entry.snapshotReport = opHubSnapshot;
264
+ }
265
+ }
266
+ // Step 5: Build the budget statements
210
267
  const budgetStatements = [];
211
268
  for (const [key, { ownerId, periodKey, snapshotReport, expenseReport },] of budgetStatementsByOwnerAndPeriod.entries()) {
212
- // Get the builder profile for this owner
213
- let builderProfileDoc = builderProfileDocs.get(ownerId) || null;
214
- // Try to fetch directly if not found
215
- if (!builderProfileDoc) {
216
- try {
217
- builderProfileDoc =
218
- await reactor.getDocument(ownerId);
219
- }
220
- catch {
221
- // Ignore errors - profile may not exist
222
- }
223
- }
224
- // Build owner object
225
- const ownerState = builderProfileDoc
226
- ? (builderProfileDoc.state?.global ?? null)
227
- : null;
269
+ const ownerState = resolvedProfiles.get(ownerId) ?? null;
228
270
  const owner = {
229
271
  id: ownerId,
230
272
  name: ownerState?.name || "Unknown",
@@ -257,16 +299,45 @@ export const getResolvers = (subgraph) => {
257
299
  const status = expenseReport?.state.global.status || "DRAFT";
258
300
  // Get lastModifiedAtUtcIso from the expense report document header
259
301
  const lastModifiedAtUtcIso = expenseReport?.header.lastModifiedAtUtcIso || "";
302
+ // Get operational hub member from builder profile
303
+ const opHubMember = ownerState?.operationalHubMember ?? null;
304
+ const operationalHubMember = opHubMember?.phid || opHubMember?.name
305
+ ? { phid: opHubMember.phid || null, name: opHubMember.name || null }
306
+ : null;
260
307
  budgetStatements.push({
261
308
  id: key,
262
309
  owner,
310
+ operationalHubMember,
263
311
  month,
264
312
  status,
265
313
  lastModifiedAtUtcIso,
314
+ reportedActuals: computeReportedActuals(expenseReportData),
315
+ netExpenseTxns: computeNetExpenseTxns(snapshotReportData),
266
316
  snapshotReport: snapshotReportData,
267
317
  expenseReport: expenseReportData,
268
318
  });
269
319
  }
320
+ // Aggregate reportedActuals by operational hub + month
321
+ // All builders in the same op hub for the same month share the same total
322
+ const opHubActuals = new Map();
323
+ for (const stmt of budgetStatements) {
324
+ const opHubPhid = stmt.operationalHubMember?.phid;
325
+ if (!opHubPhid)
326
+ continue;
327
+ const groupKey = `${opHubPhid}_${stmt.month}`;
328
+ const current = opHubActuals.get(groupKey) || 0;
329
+ opHubActuals.set(groupKey, current + (parseFloat(stmt.reportedActuals.value) || 0));
330
+ }
331
+ for (const stmt of budgetStatements) {
332
+ const opHubPhid = stmt.operationalHubMember?.phid;
333
+ if (!opHubPhid)
334
+ continue;
335
+ const groupKey = `${opHubPhid}_${stmt.month}`;
336
+ const total = opHubActuals.get(groupKey);
337
+ if (total !== undefined) {
338
+ stmt.reportedActuals = { unit: "USDS", value: String(total) };
339
+ }
340
+ }
270
341
  // Sort by month (most recent first)
271
342
  budgetStatements.sort(sortByMonth);
272
343
  return budgetStatements;
@@ -404,3 +475,39 @@ function getCounterPartyName(counterPartyAccountId, accountTransactionsDocs) {
404
475
  }
405
476
  return "";
406
477
  }
478
+ /**
479
+ * Sum of all line item actuals from expense report wallets
480
+ */
481
+ export function computeReportedActuals(expenseReportData) {
482
+ let total = 0;
483
+ for (const wallet of expenseReportData.wallets) {
484
+ for (const item of wallet.lineItems) {
485
+ total += parseFloat(item.actuals.value) || 0;
486
+ }
487
+ }
488
+ return { unit: "USDS", value: String(total) };
489
+ }
490
+ // USD stablecoins to include in net expense calculation
491
+ const USD_STABLECOINS = new Set(["USDS", "USDC", "DAI"]);
492
+ /**
493
+ * Sum of outbound USD stablecoin transactions that leave the Internal wallet grouping.
494
+ * Excludes Swap (token conversion) and Internal (inter-wallet transfers) flowTypes.
495
+ * Only counts USDS, USDC, and DAI — excludes sUSDS, EURe, SKY, MKR, etc.
496
+ */
497
+ export function computeNetExpenseTxns(snapshotReportData) {
498
+ let total = 0;
499
+ for (const account of snapshotReportData.accounts) {
500
+ if (account.type !== "Internal")
501
+ continue;
502
+ for (const tx of account.transactions) {
503
+ if (tx.direction !== "OUTFLOW")
504
+ continue;
505
+ if (tx.flowType === "Swap" || tx.flowType === "Internal")
506
+ continue;
507
+ if (!USD_STABLECOINS.has(tx.amount.unit))
508
+ continue;
509
+ total += parseFloat(tx.amount.value.value) || 0;
510
+ }
511
+ }
512
+ return { unit: "USDS", value: String(total) };
513
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=resolvers.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolvers.test.d.ts","sourceRoot":"","sources":["../../../subgraphs/budget-statements/resolvers.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,329 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeReportedActuals, computeNetExpenseTxns, extractIsoDate, getPeriodKey, getMonthKey, } from "./resolvers.js";
3
+ const amt = (value) => ({
4
+ unit: "USDS",
5
+ value: String(value),
6
+ });
7
+ describe("computeReportedActuals", () => {
8
+ it("returns zero when there are no wallets", () => {
9
+ expect(computeReportedActuals({ wallets: [] })).toEqual(amt(0));
10
+ });
11
+ it("returns zero when wallets have no line items", () => {
12
+ expect(computeReportedActuals({
13
+ wallets: [{ lineItems: [] }, { lineItems: [] }],
14
+ })).toEqual(amt(0));
15
+ });
16
+ it("sums actuals from a single wallet", () => {
17
+ const result = computeReportedActuals({
18
+ wallets: [
19
+ {
20
+ lineItems: [
21
+ { actuals: amt(100) },
22
+ { actuals: amt(250.5) },
23
+ { actuals: amt(49.5) },
24
+ ],
25
+ },
26
+ ],
27
+ });
28
+ expect(result).toEqual(amt(400));
29
+ });
30
+ it("sums actuals across multiple wallets", () => {
31
+ const result = computeReportedActuals({
32
+ wallets: [
33
+ {
34
+ lineItems: [{ actuals: amt(100) }, { actuals: amt(200) }],
35
+ },
36
+ {
37
+ lineItems: [{ actuals: amt(300) }],
38
+ },
39
+ ],
40
+ });
41
+ expect(result).toEqual(amt(600));
42
+ });
43
+ it("treats non-numeric values as zero", () => {
44
+ const result = computeReportedActuals({
45
+ wallets: [
46
+ {
47
+ lineItems: [
48
+ { actuals: amt("abc") },
49
+ { actuals: amt(100) },
50
+ { actuals: amt("") },
51
+ ],
52
+ },
53
+ ],
54
+ });
55
+ expect(result).toEqual(amt(100));
56
+ });
57
+ it("handles negative actuals", () => {
58
+ const result = computeReportedActuals({
59
+ wallets: [
60
+ {
61
+ lineItems: [{ actuals: amt(-50) }, { actuals: amt(200) }],
62
+ },
63
+ ],
64
+ });
65
+ expect(result).toEqual(amt(150));
66
+ });
67
+ it("sums actuals regardless of unit on line items (all currently USDS)", () => {
68
+ // In practice the expense report builder hardcodes unit="USDS",
69
+ // but if line items had different units, values are still summed as-is.
70
+ const result = computeReportedActuals({
71
+ wallets: [
72
+ {
73
+ lineItems: [
74
+ { actuals: { unit: "USDS", value: "100" } },
75
+ { actuals: { unit: "DAI", value: "200" } },
76
+ { actuals: { unit: "ETH", value: "1.5" } },
77
+ ],
78
+ },
79
+ ],
80
+ });
81
+ // 100 + 200 + 1.5 = 301.5 (no conversion, sums raw values)
82
+ expect(result).toEqual(amt(301.5));
83
+ });
84
+ });
85
+ describe("extractIsoDate", () => {
86
+ it("extracts YYYY-MM-DD from full ISO string", () => {
87
+ expect(extractIsoDate("2025-09-01T00:00:00.000Z")).toBe("2025-09-01");
88
+ });
89
+ it("extracts YYYY-MM-DD from date-only string", () => {
90
+ expect(extractIsoDate("2025-09-01")).toBe("2025-09-01");
91
+ });
92
+ it("returns null for invalid input", () => {
93
+ expect(extractIsoDate("not-a-date")).toBeNull();
94
+ expect(extractIsoDate("")).toBeNull();
95
+ });
96
+ });
97
+ describe("getPeriodKey", () => {
98
+ it("creates key from ISO date strings", () => {
99
+ expect(getPeriodKey("2025-09-01T00:00:00.000Z", "2025-09-30T23:59:59.999Z")).toBe("2025-09-01_2025-09-30");
100
+ });
101
+ it("returns null when either date is missing", () => {
102
+ expect(getPeriodKey(null, "2025-09-30T00:00:00.000Z")).toBeNull();
103
+ expect(getPeriodKey("2025-09-01T00:00:00.000Z", null)).toBeNull();
104
+ expect(getPeriodKey(null, null)).toBeNull();
105
+ });
106
+ });
107
+ describe("getMonthKey", () => {
108
+ it("extracts month key from UTC midnight ISO string", () => {
109
+ expect(getMonthKey("2025-09-01T00:00:00.000Z")).toBe("SEP2025");
110
+ });
111
+ it("is not affected by timezone — UTC midnight stays in correct month", () => {
112
+ // This was the original bug: new Date("2025-09-01T00:00:00.000Z").getMonth()
113
+ // returns 7 (August) in UTC-3 because it becomes Aug 31 21:00 local time
114
+ expect(getMonthKey("2025-09-01T00:00:00.000Z")).toBe("SEP2025");
115
+ expect(getMonthKey("2025-01-01T00:00:00.000Z")).toBe("JAN2025");
116
+ expect(getMonthKey("2025-12-01T00:00:00.000Z")).toBe("DEC2025");
117
+ });
118
+ it("handles all months", () => {
119
+ const expected = [
120
+ "JAN", "FEB", "MAR", "APR", "MAY", "JUN",
121
+ "JUL", "AUG", "SEP", "OCT", "NOV", "DEC",
122
+ ];
123
+ for (let i = 0; i < 12; i++) {
124
+ const month = String(i + 1).padStart(2, "0");
125
+ expect(getMonthKey(`2026-${month}-15T12:00:00.000Z`)).toBe(`${expected[i]}2026`);
126
+ }
127
+ });
128
+ it("returns null for null or invalid input", () => {
129
+ expect(getMonthKey(null)).toBeNull();
130
+ expect(getMonthKey("not-a-date")).toBeNull();
131
+ expect(getMonthKey("")).toBeNull();
132
+ });
133
+ });
134
+ const makeTx = (direction, flowType, value, token = "USDS") => ({
135
+ direction,
136
+ flowType,
137
+ amount: { value: { unit: token, value: String(value) }, unit: token },
138
+ });
139
+ describe("computeNetExpenseTxns", () => {
140
+ it("returns zero when there are no accounts", () => {
141
+ expect(computeNetExpenseTxns({ accounts: [] })).toEqual(amt(0));
142
+ });
143
+ it("returns zero when there are no Internal accounts", () => {
144
+ const result = computeNetExpenseTxns({
145
+ accounts: [
146
+ {
147
+ type: "Source",
148
+ transactions: [makeTx("OUTFLOW", "External", 500)],
149
+ },
150
+ {
151
+ type: "Destination",
152
+ transactions: [makeTx("OUTFLOW", "External", 300)],
153
+ },
154
+ {
155
+ type: "External",
156
+ transactions: [makeTx("OUTFLOW", "External", 200)],
157
+ },
158
+ ],
159
+ });
160
+ expect(result).toEqual(amt(0));
161
+ });
162
+ it("sums only OUTFLOW transactions from Internal accounts that leave the group", () => {
163
+ const result = computeNetExpenseTxns({
164
+ accounts: [
165
+ {
166
+ type: "Internal",
167
+ transactions: [
168
+ makeTx("OUTFLOW", "External", 100), // counted: leaves group
169
+ makeTx("INFLOW", "TopUp", 500), // ignored: inflow
170
+ makeTx("OUTFLOW", "Internal", 200), // ignored: stays within group
171
+ ],
172
+ },
173
+ ],
174
+ });
175
+ expect(result).toEqual(amt(100));
176
+ });
177
+ it("excludes Swap transactions", () => {
178
+ const result = computeNetExpenseTxns({
179
+ accounts: [
180
+ {
181
+ type: "Internal",
182
+ transactions: [
183
+ makeTx("OUTFLOW", "External", 100),
184
+ makeTx("OUTFLOW", "Swap", 999), // excluded
185
+ makeTx("OUTFLOW", "TopUp", 50),
186
+ ],
187
+ },
188
+ ],
189
+ });
190
+ expect(result).toEqual(amt(150));
191
+ });
192
+ it("sums across multiple Internal accounts", () => {
193
+ const result = computeNetExpenseTxns({
194
+ accounts: [
195
+ {
196
+ type: "Internal",
197
+ transactions: [makeTx("OUTFLOW", "External", 100)],
198
+ },
199
+ {
200
+ type: "Internal",
201
+ transactions: [makeTx("OUTFLOW", "Return", 200)],
202
+ },
203
+ {
204
+ type: "Source",
205
+ transactions: [makeTx("OUTFLOW", "External", 9999)], // ignored: not Internal
206
+ },
207
+ ],
208
+ });
209
+ expect(result).toEqual(amt(300));
210
+ });
211
+ it("excludes Internal flowType (inter-wallet transfers stay in group)", () => {
212
+ const result = computeNetExpenseTxns({
213
+ accounts: [
214
+ {
215
+ type: "Internal",
216
+ transactions: [
217
+ makeTx("OUTFLOW", "Internal", 500), // excluded: stays in group
218
+ makeTx("OUTFLOW", "Internal", 300), // excluded: stays in group
219
+ makeTx("OUTFLOW", "External", 100), // counted: leaves group
220
+ ],
221
+ },
222
+ ],
223
+ });
224
+ expect(result).toEqual(amt(100));
225
+ });
226
+ it("returns zero when all Internal txns are swaps, internal transfers, or inflows", () => {
227
+ const result = computeNetExpenseTxns({
228
+ accounts: [
229
+ {
230
+ type: "Internal",
231
+ transactions: [
232
+ makeTx("OUTFLOW", "Swap", 100), // excluded: swap
233
+ makeTx("OUTFLOW", "Internal", 400), // excluded: stays in group
234
+ makeTx("INFLOW", "External", 200), // excluded: inflow
235
+ makeTx("INFLOW", "Swap", 300), // excluded: inflow
236
+ ],
237
+ },
238
+ ],
239
+ });
240
+ expect(result).toEqual(amt(0));
241
+ });
242
+ it("counts External, Return, and TopUp outflows as expenses leaving the group", () => {
243
+ const result = computeNetExpenseTxns({
244
+ accounts: [
245
+ {
246
+ type: "Internal",
247
+ transactions: [
248
+ makeTx("OUTFLOW", "External", 100), // counted: to external party
249
+ makeTx("OUTFLOW", "Return", 200), // counted: returning to source
250
+ makeTx("OUTFLOW", "TopUp", 50), // counted: funding destination
251
+ makeTx("OUTFLOW", "Swap", 999), // excluded: token conversion
252
+ makeTx("OUTFLOW", "Internal", 888), // excluded: inter-wallet
253
+ makeTx("INFLOW", "TopUp", 5000), // excluded: inflow
254
+ ],
255
+ },
256
+ ],
257
+ });
258
+ expect(result).toEqual(amt(350));
259
+ });
260
+ it("handles Internal accounts with no transactions", () => {
261
+ const result = computeNetExpenseTxns({
262
+ accounts: [{ type: "Internal", transactions: [] }],
263
+ });
264
+ expect(result).toEqual(amt(0));
265
+ });
266
+ it("treats non-numeric amounts as zero", () => {
267
+ const result = computeNetExpenseTxns({
268
+ accounts: [
269
+ {
270
+ type: "Internal",
271
+ transactions: [
272
+ makeTx("OUTFLOW", "External", "bad"),
273
+ makeTx("OUTFLOW", "External", 100),
274
+ ],
275
+ },
276
+ ],
277
+ });
278
+ expect(result).toEqual(amt(100));
279
+ });
280
+ it("only counts USD stablecoins (USDS, USDC, DAI)", () => {
281
+ const result = computeNetExpenseTxns({
282
+ accounts: [
283
+ {
284
+ type: "Internal",
285
+ transactions: [
286
+ makeTx("OUTFLOW", "External", 500, "USDS"), // counted
287
+ makeTx("OUTFLOW", "External", 200, "USDC"), // counted
288
+ makeTx("OUTFLOW", "External", 100, "DAI"), // counted
289
+ makeTx("OUTFLOW", "External", 1.5, "ETH"), // excluded: not stablecoin
290
+ makeTx("OUTFLOW", "External", 9999, "sUSDS"), // excluded: not stablecoin
291
+ makeTx("OUTFLOW", "External", 5000, "SKY"), // excluded: not stablecoin
292
+ makeTx("OUTFLOW", "External", 134, "MKR"), // excluded: not stablecoin
293
+ makeTx("OUTFLOW", "External", 46, "EURe"), // excluded: not stablecoin
294
+ ],
295
+ },
296
+ ],
297
+ });
298
+ expect(result).toEqual(amt(800));
299
+ });
300
+ it("excludes Swap even for stablecoin tokens", () => {
301
+ const result = computeNetExpenseTxns({
302
+ accounts: [
303
+ {
304
+ type: "Internal",
305
+ transactions: [
306
+ makeTx("OUTFLOW", "External", 100, "USDS"),
307
+ makeTx("OUTFLOW", "Swap", 2000, "USDC"), // excluded: swap
308
+ makeTx("OUTFLOW", "External", 50, "DAI"),
309
+ ],
310
+ },
311
+ ],
312
+ });
313
+ expect(result).toEqual(amt(150));
314
+ });
315
+ it("ignores INFLOW of any token on Internal accounts", () => {
316
+ const result = computeNetExpenseTxns({
317
+ accounts: [
318
+ {
319
+ type: "Internal",
320
+ transactions: [
321
+ makeTx("OUTFLOW", "External", 100, "USDS"),
322
+ makeTx("INFLOW", "TopUp", 5000, "ETH"), // ignored: inflow
323
+ ],
324
+ },
325
+ ],
326
+ });
327
+ expect(result).toEqual(amt(100));
328
+ });
329
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../subgraphs/budget-statements/schema.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,eAAO,MAAM,MAAM,EAAE,YAqIpB,CAAC"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../subgraphs/budget-statements/schema.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,eAAO,MAAM,MAAM,EAAE,YA6IpB,CAAC"}
@@ -15,13 +15,21 @@ export const schema = gql `
15
15
  type BudgetStatement {
16
16
  id: OID!
17
17
  owner: BudgetStatementOwner!
18
+ operationalHubMember: OperationalHubMember
18
19
  month: String! ## JAN2026
19
20
  status: String!
20
21
  lastModifiedAtUtcIso: DateTime!
22
+ reportedActuals: Amount_Currency!
23
+ netExpenseTxns: Amount_Currency!
21
24
  snapshotReport: BudgetStatementSnapshotReport!
22
25
  expenseReport: BudgetStatementExpenseReport!
23
26
  }
24
27
 
28
+ type OperationalHubMember {
29
+ phid: PHID
30
+ name: String
31
+ }
32
+
25
33
  type BudgetStatementOwner {
26
34
  id: PHID!
27
35
  name: String!