test(financials-v2): cover statement parsing and aggregation internals
This commit is contained in:
139
lib/server/financial-statements.test.ts
Normal file
139
lib/server/financial-statements.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { __financialStatementsInternals } from './financial-statements';
|
||||||
|
import type { FilingStatementSnapshotRecord } from '@/lib/server/repos/filing-statements';
|
||||||
|
|
||||||
|
function sampleSnapshot(): FilingStatementSnapshotRecord {
|
||||||
|
return {
|
||||||
|
id: 10,
|
||||||
|
filing_id: 44,
|
||||||
|
ticker: 'MSFT',
|
||||||
|
filing_date: '2025-12-31',
|
||||||
|
filing_type: '10-K',
|
||||||
|
period_end: '2025-12-31',
|
||||||
|
statement_bundle: {
|
||||||
|
periods: [
|
||||||
|
{
|
||||||
|
id: '2025-12-31-0001',
|
||||||
|
filingId: 44,
|
||||||
|
accessionNumber: '0001',
|
||||||
|
filingDate: '2025-12-31',
|
||||||
|
periodEnd: '2025-12-31',
|
||||||
|
filingType: '10-K',
|
||||||
|
periodLabel: 'Fiscal Year End'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
statements: {
|
||||||
|
income: [
|
||||||
|
{
|
||||||
|
key: 'revenue-line',
|
||||||
|
label: 'Revenue',
|
||||||
|
concept: 'us-gaap:Revenues',
|
||||||
|
order: 1,
|
||||||
|
depth: 0,
|
||||||
|
isSubtotal: false,
|
||||||
|
values: { '2025-12-31-0001': 120_000 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
balance: [],
|
||||||
|
cash_flow: [],
|
||||||
|
equity: [],
|
||||||
|
comprehensive_income: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
standardized_bundle: {
|
||||||
|
periods: [
|
||||||
|
{
|
||||||
|
id: '2025-12-31-0001',
|
||||||
|
filingId: 44,
|
||||||
|
accessionNumber: '0001',
|
||||||
|
filingDate: '2025-12-31',
|
||||||
|
periodEnd: '2025-12-31',
|
||||||
|
filingType: '10-K',
|
||||||
|
periodLabel: 'Fiscal Year End'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
statements: {
|
||||||
|
income: [
|
||||||
|
{
|
||||||
|
key: 'revenue',
|
||||||
|
label: 'Revenue',
|
||||||
|
concept: 'us-gaap:Revenues',
|
||||||
|
category: 'core',
|
||||||
|
sourceConcepts: ['us-gaap:Revenues'],
|
||||||
|
values: { '2025-12-31-0001': 120_000 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
balance: [],
|
||||||
|
cash_flow: [],
|
||||||
|
equity: [],
|
||||||
|
comprehensive_income: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dimension_bundle: {
|
||||||
|
statements: {
|
||||||
|
income: [
|
||||||
|
{
|
||||||
|
rowKey: 'revenue-line',
|
||||||
|
concept: 'us-gaap:Revenues',
|
||||||
|
periodId: '2025-12-31-0001',
|
||||||
|
axis: 'srt:StatementBusinessSegmentsAxis',
|
||||||
|
member: 'acme:CloudMember',
|
||||||
|
value: 55_000,
|
||||||
|
unit: 'USD'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
balance: [],
|
||||||
|
cash_flow: [],
|
||||||
|
equity: [],
|
||||||
|
comprehensive_income: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parse_status: 'ready',
|
||||||
|
parse_error: null,
|
||||||
|
source: 'sec_filing_summary',
|
||||||
|
created_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00.000Z'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('financial statements service internals', () => {
|
||||||
|
it('builds sorted periods for selected mode/statement', () => {
|
||||||
|
const snapshot = sampleSnapshot();
|
||||||
|
|
||||||
|
const periods = __financialStatementsInternals.buildPeriods(
|
||||||
|
[snapshot],
|
||||||
|
'standardized',
|
||||||
|
'income'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(periods.length).toBe(1);
|
||||||
|
expect(periods[0]?.id).toBe('2025-12-31-0001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds standardized rows and includes dimensions when requested', () => {
|
||||||
|
const snapshot = sampleSnapshot();
|
||||||
|
const periods = __financialStatementsInternals.buildPeriods(
|
||||||
|
[snapshot],
|
||||||
|
'standardized',
|
||||||
|
'income'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = __financialStatementsInternals.buildRows(
|
||||||
|
[snapshot],
|
||||||
|
periods,
|
||||||
|
'standardized',
|
||||||
|
'income',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.rows.length).toBe(1);
|
||||||
|
expect(result.rows[0]?.hasDimensions).toBe(true);
|
||||||
|
expect(result.dimensions).not.toBeNull();
|
||||||
|
expect(result.dimensions?.['revenue-line']?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default sync limits by window', () => {
|
||||||
|
expect(__financialStatementsInternals.defaultFinancialSyncLimit('10y')).toBe(60);
|
||||||
|
expect(__financialStatementsInternals.defaultFinancialSyncLimit('all')).toBe(120);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it, mock } from 'bun:test';
|
import { describe, expect, it, mock } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
|
__statementInternals,
|
||||||
fetchFilingMetricsForFilings,
|
fetchFilingMetricsForFilings,
|
||||||
|
hydrateFilingStatementSnapshot,
|
||||||
fetchPrimaryFilingText,
|
fetchPrimaryFilingText,
|
||||||
normalizeSecDocumentText,
|
normalizeSecDocumentText,
|
||||||
resolvePrimaryFilingUrl,
|
resolvePrimaryFilingUrl,
|
||||||
@@ -190,3 +192,133 @@ describe('sec filing text helpers', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('statement snapshot parsing', () => {
|
||||||
|
it('parses FilingSummary reports and statement rows with order/depth/subtotals', () => {
|
||||||
|
const reports = __statementInternals.parseFilingSummaryReports(`
|
||||||
|
<FilingSummary>
|
||||||
|
<Report>
|
||||||
|
<ShortName>Statements of Operations</ShortName>
|
||||||
|
<LongName>Consolidated Statements of Operations</LongName>
|
||||||
|
<HtmlFileName>income.htm</HtmlFileName>
|
||||||
|
</Report>
|
||||||
|
</FilingSummary>
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(reports.length).toBe(1);
|
||||||
|
expect(reports[0]?.htmlFileName).toBe('income.htm');
|
||||||
|
|
||||||
|
const rows = __statementInternals.parseStatementRowsFromReport(`
|
||||||
|
<html>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td style="padding-left: 0px"><a id="defref_us-gaap_Revenues">Revenue</a></td>
|
||||||
|
<td>$120,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding-left: 24px"><a id="defref_us-gaap_CostOfRevenue">Cost of Revenue</a></td>
|
||||||
|
<td>(50,000)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding-left: 0px">Total Net Income</td>
|
||||||
|
<td>25,000</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(rows.length).toBe(3);
|
||||||
|
expect(rows[0]?.label).toBe('Revenue');
|
||||||
|
expect(rows[0]?.order).toBe(1);
|
||||||
|
expect(rows[1]?.depth).toBe(2);
|
||||||
|
expect(rows[1]?.value).toBe(-50_000);
|
||||||
|
expect(rows[2]?.isSubtotal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts dimensional facts from inline XBRL contexts', () => {
|
||||||
|
const dimensions = __statementInternals.parseDimensionFacts(`
|
||||||
|
<xbrli:context id="ctx_seg">
|
||||||
|
<xbrli:period><xbrli:endDate>2025-12-31</xbrli:endDate></xbrli:period>
|
||||||
|
<xbrli:scenario>
|
||||||
|
<xbrldi:explicitMember dimension="srt:ProductOrServiceAxis">us-gaap:ProductMember</xbrldi:explicitMember>
|
||||||
|
</xbrli:scenario>
|
||||||
|
</xbrli:context>
|
||||||
|
<ix:nonFraction name="us-gaap:Revenues" contextRef="ctx_seg" unitRef="USD">50000</ix:nonFraction>
|
||||||
|
`, 'fallback-period');
|
||||||
|
|
||||||
|
expect(dimensions.income.length).toBe(1);
|
||||||
|
expect(dimensions.income[0]?.axis).toContain('ProductOrServiceAxis');
|
||||||
|
expect(dimensions.income[0]?.member).toContain('ProductMember');
|
||||||
|
expect(dimensions.income[0]?.periodId).toBe('2025-12-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates a filing snapshot with partial status when only one statement is found', async () => {
|
||||||
|
const fetchImpl = mock(async (input: RequestInfo | URL, _init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
|
||||||
|
if (url.endsWith('FilingSummary.xml')) {
|
||||||
|
return new Response(`
|
||||||
|
<FilingSummary>
|
||||||
|
<Report>
|
||||||
|
<ShortName>Statements of Operations</ShortName>
|
||||||
|
<LongName>Consolidated Statements of Operations</LongName>
|
||||||
|
<HtmlFileName>income.htm</HtmlFileName>
|
||||||
|
</Report>
|
||||||
|
</FilingSummary>
|
||||||
|
`, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith('income.htm')) {
|
||||||
|
return new Response(`
|
||||||
|
<html>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><a id="defref_us-gaap_Revenues">Revenue</a></td>
|
||||||
|
<td>120000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a id="defref_us-gaap_NetIncomeLoss">Net Income</a></td>
|
||||||
|
<td>24000</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</html>
|
||||||
|
`, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(`
|
||||||
|
<xbrli:context id="ctx_seg">
|
||||||
|
<xbrli:period><xbrli:endDate>2025-12-31</xbrli:endDate></xbrli:period>
|
||||||
|
<xbrli:scenario>
|
||||||
|
<xbrldi:explicitMember dimension="srt:StatementBusinessSegmentsAxis">acme:EnterpriseMember</xbrldi:explicitMember>
|
||||||
|
</xbrli:scenario>
|
||||||
|
</xbrli:context>
|
||||||
|
<ix:nonFraction name="us-gaap:Revenues" contextRef="ctx_seg" unitRef="USD">120000</ix:nonFraction>
|
||||||
|
`, { status: 200 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const snapshot = await hydrateFilingStatementSnapshot({
|
||||||
|
filingId: 99,
|
||||||
|
ticker: 'MSFT',
|
||||||
|
cik: '0000789019',
|
||||||
|
accessionNumber: '0000789019-25-000001',
|
||||||
|
filingDate: '2025-12-31',
|
||||||
|
filingType: '10-K',
|
||||||
|
filingUrl: 'https://www.sec.gov/Archives/edgar/data/789019/000078901925000001/msft10k.htm',
|
||||||
|
primaryDocument: 'msft10k.htm',
|
||||||
|
metrics: {
|
||||||
|
revenue: 120_000,
|
||||||
|
netIncome: 24_000,
|
||||||
|
totalAssets: 450_000,
|
||||||
|
cash: 90_000,
|
||||||
|
debt: 110_000
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
fetchImpl
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot.parse_status).toBe('partial');
|
||||||
|
expect(snapshot.statement_bundle?.statements.income.length).toBeGreaterThan(0);
|
||||||
|
expect(snapshot.standardized_bundle?.statements.income.find((row) => row.key === 'revenue')?.values).toBeDefined();
|
||||||
|
expect(snapshot.dimension_bundle?.statements.income.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user