Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions clients/LedgerClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,73 @@ describe('LedgerClient', () => {
mockFetch.mockResolvedValueOnce(gqlResponse({ mappings: null }))
expect(await client.listMappings('graph_1')).toEqual([])
})

it('names whose chart each mapping maps, and keeps one entity on request', async () => {
mockFetch.mockResolvedValueOnce(
gqlResponse({
mappings: {
structures: [
{
id: 'map_sub',
name: 'CoA → GAAP',
description: null,
blockType: 'coa_mapping',
taxonomyId: 'tax_map_sub',
isActive: true,
framework: 'rs-gaap',
entityId: 'ent_sub',
},
],
},
})
)
const mappings = await client.listMappings('graph_1', { entityId: 'ent_sub' })
expect(mappings[0].entityId).toBe('ent_sub')
const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string)
expect(body.variables.entityId).toBe('ent_sub')
})

it('sends a null entity to list every entity mapping', async () => {
mockFetch.mockResolvedValueOnce(gqlResponse({ mappings: { structures: [] } }))
await client.listMappings('graph_1')
const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string)
expect(body.variables.entityId).toBeNull()
})
})

describe('getMappingCandidates', () => {
it('narrows by the named entity', async () => {
mockFetch.mockResolvedValueOnce(gqlResponse({ mappingCandidates: [] }))
await client.getMappingCandidates('graph_1', 'asset', { entityId: 'ent_sub' })
const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string)
expect(body.variables).toEqual({ classification: 'asset', entityId: 'ent_sub' })
})
})

describe('information blocks', () => {
const variablesOf = (call: number) =>
JSON.parse((mockFetch.mock.calls[call][1] as RequestInit).body as string).variables

it('getInformationBlock sends the entity, windowed or not', async () => {
mockFetch.mockResolvedValue(gqlResponse({ informationBlock: null }))
await client.getInformationBlock('graph_1', 'struct_bs', { entityId: 'ent_sub' })
await client.getInformationBlock('graph_1', 'struct_bs', {
entityId: 'ent_sub',
series: true,
seriesHistory: 12,
})
await client.getInformationBlock('graph_1', 'struct_bs')
expect(variablesOf(0).entityId).toBe('ent_sub')
expect(variablesOf(1).entityId).toBe('ent_sub')
expect(variablesOf(1).seriesHistory).toBe(12)
expect(variablesOf(2).entityId).toBeNull()
})

it('listInformationBlocks sends the entity', async () => {
mockFetch.mockResolvedValueOnce(gqlResponse({ informationBlocks: [] }))
await client.listInformationBlocks('graph_1', { blockType: 'forecast', entityId: 'ent_sub' })
expect(variablesOf(0)).toMatchObject({ blockType: 'forecast', entityId: 'ent_sub' })
})
})

describe('getMappingCoverage', () => {
Expand Down Expand Up @@ -1653,6 +1720,24 @@ describe('LedgerClient', () => {
})

describe('createSchedule', () => {
it('books the schedule to a named entity', async () => {
mockFetch.mockResolvedValueOnce(
envelopeResponse('create-schedule', { id: 'str_1', block_type: 'schedule', name: 'Rent' })
)
await client.createSchedule('graph_1', {
name: 'Rent',
elementIds: ['elem_1'],
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
monthlyAmount: 1000,
entryTemplate: { debitElementId: 'elem_rent', creditElementId: 'elem_prepaid' },
entityId: 'ent_sub',
})
const req = mockFetch.mock.calls[0][0] as Request
const body = JSON.parse(await req.text())
expect(body.payload.entity_id).toBe('ent_sub')
})

it('serializes options into snake_case body and converts the result', async () => {
// `create-information-block` returns an InformationBlockEnvelope. The
// previous mock invented a ScheduleCreatedResponse-shaped body that the
Expand Down Expand Up @@ -1806,6 +1891,21 @@ describe('LedgerClient', () => {
expect(body.mapping_id).toBe('map_1')
expect(body.period_start).toBe('2025-01-01')
expect(body.period_end).toBe('2025-12-31')
expect(body).not.toHaveProperty('entity_id')
})

it('passes the entity the report is for', async () => {
mockFetch.mockResolvedValueOnce(envelopeResponse('create-report', { id: 'rpt_1' }))
await client.createReport('graph_42', {
name: 'FY25',
mappingId: 'map_sub',
periodStart: '2025-01-01',
periodEnd: '2025-12-31',
entityId: 'ent_sub',
})
const req = mockFetch.mock.calls[0][0] as Request
const body = JSON.parse(await req.text())
expect(body.entity_id).toBe('ent_sub')
})

it('throws when the envelope carries no result', async () => {
Expand Down
51 changes: 43 additions & 8 deletions clients/LedgerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,12 @@ export interface CreateReportOptions {
periodType?: string
comparative?: boolean
periods?: PeriodSpecInput[]
/**
* The entity the report is for. Omit for the entity whose chart
* `mappingId` maps from; named alongside another entity's mapping, the
* server refuses it.
*/
entityId?: string | null
}

// ── Write result shapes (envelope.result payloads) ─────────────────────
Expand Down Expand Up @@ -733,6 +739,8 @@ export interface CreateScheduleOptions {
*/
bookedOn?: string
}
/** The entity whose books the schedule belongs to. Omit for the group parent. */
entityId?: string | null
}

export interface JournalEntryLineItem {
Expand Down Expand Up @@ -1338,18 +1346,20 @@ export class LedgerClient {
/**
* rs-gaap concepts a CoA element of the given EFS `classification`
* (asset / liability / equity / revenue / expense) may map to — limited
* to concepts that render under the graph's active Reporting Style, with
* statement-level subtotals excluded. Use this to populate the mapping
* picker so it never offers an unreachable target.
* to concepts that render under the Reporting Style of the entity whose
* chart is being mapped (`options.entityId`, default the group parent),
* with statement-level subtotals excluded. Use this to populate the
* mapping picker so it never offers an unreachable target.
*/
async getMappingCandidates(
graphId: string,
classification: string
classification: string,
options?: { entityId?: string | null }
): Promise<LedgerMappingCandidate[]> {
return this.gqlQuery(
graphId,
MappingCandidatesDocument,
{ classification },
{ classification, entityId: options?.entityId ?? null },
'Mapping candidates',
(data) => data.mappingCandidates
)
Expand Down Expand Up @@ -1393,13 +1403,18 @@ export class LedgerClient {

/**
* List active CoA→reporting mapping structures, the book mapping first.
* Each carries `framework`: the reporting framework it maps the chart into.
* Each carries `framework`: the reporting framework it maps the chart into,
* and `entityId`: the entity whose chart it maps from, so whose reports it
* can produce. `options.entityId` keeps that entity's mappings only.
*/
async listMappings(graphId: string): Promise<LedgerMappingInfo[]> {
async listMappings(
graphId: string,
options?: { entityId?: string | null }
): Promise<LedgerMappingInfo[]> {
const list = await this.gqlQuery(
graphId,
ListLedgerMappingsDocument,
undefined,
{ entityId: options?.entityId ?? null },
'List mappings',
(data) => data.mappings
)
Expand Down Expand Up @@ -1493,6 +1508,12 @@ export class LedgerClient {
* window arguments — a pre-window server rejects it. Callers that
* never pass a window keep the un-windowed document and stay
* compatible with older backends.
*
* `options.entityId` picks whose books a block shared by the group
* reads (statements, metrics, disclosures); omitted, the scenario's
* entity, else the group parent. A schedule, reconciliation or forecast
* always reads its own entity's. The envelope's `entityId` names the
* entity it read.
*/
async getInformationBlock(
graphId: string,
Expand All @@ -1502,6 +1523,7 @@ export class LedgerClient {
series?: boolean
seriesHistory?: number
seriesForecast?: number
entityId?: string | null
}
): Promise<InformationBlock | null> {
const windowed = options?.seriesHistory !== undefined || options?.seriesForecast !== undefined
Expand All @@ -1515,6 +1537,7 @@ export class LedgerClient {
series: options?.series ?? false,
seriesHistory: options?.seriesHistory ?? null,
seriesForecast: options?.seriesForecast ?? null,
entityId: options?.entityId ?? null,
},
'Get information block',
(data) => data.informationBlock ?? null
Expand All @@ -1526,6 +1549,7 @@ export class LedgerClient {
id,
scenarioId: options?.scenarioId ?? null,
series: options?.series ?? false,
entityId: options?.entityId ?? null,
},
'Get information block',
(data) => data.informationBlock ?? null
Expand All @@ -1539,6 +1563,11 @@ export class LedgerClient {
* `{blockType: 'schedule'}` to get the same set of blocks.
* `options.scenarioId` threads into each envelope's FactSet binding
* (the block list itself is scenario-independent).
*
* `options.entityId` (omitted: the scenario's entity, else the group
* parent) lists the blocks shared by the group plus that entity's own
* schedules, reconciliations and forecasts, never another entity's;
* shared blocks read its books.
*/
async listInformationBlocks(
graphId: string,
Expand All @@ -1548,6 +1577,7 @@ export class LedgerClient {
limit?: number
offset?: number
scenarioId?: string
entityId?: string | null
}
): Promise<InformationBlockList> {
const blocks = await this.gqlQuery(
Expand All @@ -1559,6 +1589,7 @@ export class LedgerClient {
limit: options?.limit ?? null,
offset: options?.offset ?? null,
scenarioId: options?.scenarioId ?? null,
entityId: options?.entityId ?? null,
},
'List information blocks',
(data) => data.informationBlocks
Expand Down Expand Up @@ -1642,6 +1673,7 @@ export class LedgerClient {
memo_template: options.entryTemplate.memoTemplate,
},
taxonomy_id: options.taxonomyId,
...(options.entityId ? { entity_id: options.entityId } : {}),
schedule_metadata: options.scheduleMetadata
? {
method: options.scheduleMetadata.method,
Expand Down Expand Up @@ -2565,6 +2597,9 @@ export class LedgerClient {
if (options.periods && options.periods.length > 0) {
body.periods = options.periods
}
if (options.entityId) {
body.entity_id = options.entityId
}
const envelope = await this.callOperation('Create report', (o) =>
createReport({ ...o, path: { graph_id: graphId }, body })
)
Expand Down
Loading
Loading