@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.
- package/dist/document-models/invoice/src/reducers/general.d.ts.map +1 -1
- package/dist/document-models/invoice/src/reducers/general.js +8 -0
- package/dist/document-models/invoice/src/reducers/transitions.d.ts.map +1 -1
- package/dist/document-models/invoice/src/reducers/transitions.js +7 -0
- package/dist/document-models/invoice/tests/general.test.js +11 -2
- package/dist/document-models/invoice/tests/transitions.test.js +7 -2
- package/dist/editors/builder-team-admin/components/DriveExplorer.d.ts.map +1 -1
- package/dist/editors/builder-team-admin/components/DriveExplorer.js +64 -4
- package/dist/editors/builder-team-admin/module.js +1 -1
- package/dist/editors/contributor-billing/components/DashboardHome.d.ts.map +1 -1
- package/dist/editors/contributor-billing/components/DashboardHome.js +2 -8
- package/dist/editors/contributor-billing/components/DriveExplorer.d.ts.map +1 -1
- package/dist/editors/contributor-billing/components/DriveExplorer.js +52 -1
- package/dist/editors/contributor-billing/components/FolderTree.d.ts.map +1 -1
- package/dist/editors/contributor-billing/components/FolderTree.js +6 -15
- package/dist/editors/contributor-billing/components/MonthReportCard.d.ts +8 -1
- package/dist/editors/contributor-billing/components/MonthReportCard.d.ts.map +1 -1
- package/dist/editors/contributor-billing/components/MonthReportCard.js +6 -3
- package/dist/editors/contributor-billing/components/MonthlyReportsOverview.d.ts +1 -1
- package/dist/editors/contributor-billing/components/MonthlyReportsOverview.d.ts.map +1 -1
- package/dist/editors/contributor-billing/components/MonthlyReportsOverview.js +56 -3
- package/dist/editors/contributor-billing/components/cbToast.d.ts.map +1 -1
- package/dist/editors/contributor-billing/hooks/useDocumentAutoPlacement.d.ts.map +1 -1
- package/dist/editors/contributor-billing/hooks/useDocumentAutoPlacement.js +42 -2
- package/dist/editors/contributor-billing/module.js +1 -1
- package/dist/editors/invoice/editor.d.ts.map +1 -1
- package/dist/editors/invoice/editor.js +3 -2
- package/dist/editors/invoice/legalEntity/legalEntity.d.ts +2 -1
- package/dist/editors/invoice/legalEntity/legalEntity.d.ts.map +1 -1
- package/dist/editors/invoice/legalEntity/legalEntity.js +2 -2
- package/dist/editors/invoice/legalEntity/walletSection.d.ts +1 -0
- package/dist/editors/invoice/legalEntity/walletSection.d.ts.map +1 -1
- package/dist/editors/invoice/legalEntity/walletSection.js +2 -2
- package/dist/editors/invoice/validation/validationHandler.d.ts +1 -1
- package/dist/editors/invoice/validation/validationHandler.d.ts.map +1 -1
- package/dist/editors/invoice/validation/validationHandler.js +14 -1
- package/dist/editors/invoice/validation/validationManager.d.ts +1 -1
- package/dist/editors/invoice/validation/validationManager.d.ts.map +1 -1
- package/dist/editors/invoice/validation/validationManager.js +2 -1
- package/dist/editors/invoice/validation/validationRules.d.ts +1 -0
- package/dist/editors/invoice/validation/validationRules.d.ts.map +1 -1
- package/dist/editors/invoice/validation/validationRules.js +26 -1
- package/dist/style.css +96 -3
- package/dist/subgraphs/budget-statements/resolvers.d.ts +38 -0
- package/dist/subgraphs/budget-statements/resolvers.d.ts.map +1 -1
- package/dist/subgraphs/budget-statements/resolvers.js +151 -44
- package/dist/subgraphs/budget-statements/resolvers.test.d.ts +2 -0
- package/dist/subgraphs/budget-statements/resolvers.test.d.ts.map +1 -0
- package/dist/subgraphs/budget-statements/resolvers.test.js +329 -0
- package/dist/subgraphs/budget-statements/schema.d.ts.map +1 -1
- package/dist/subgraphs/budget-statements/schema.js +8 -0
- package/package.json +16 -16
- package/dist/editors/contributor-billing/components/CreateHubProfileModal.d.ts +0 -12
- package/dist/editors/contributor-billing/components/CreateHubProfileModal.d.ts.map +0 -1
- 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
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
return `${formatDate(startDate)}_${formatDate(endDate)}`;
|
|
16
|
+
return `${start}_${end}`;
|
|
13
17
|
};
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
if (
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 @@
|
|
|
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,
|
|
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!
|