Files
Neon-Desk/lib/server/repos/filing-statements.ts
francy51 14a7773504 Add consolidated disclosure statement type
Create unified disclosure statement to organize footnote disclosures
separate from primary financial statements. Disclosures are now grouped
by type (tax, debt, securities, derivatives, leases, intangibles, ma,
revenue, cash_flow) in a dedicated statement type for cleaner UI
presentation.
2026-03-16 18:54:23 -04:00

260 lines
6.8 KiB
TypeScript

import { and, desc, eq, gte, lt, sql } from "drizzle-orm";
import { db } from "@/lib/server/db";
import { filingStatementSnapshot } from "@/lib/server/db/schema";
type FilingStatementSnapshotRow = typeof filingStatementSnapshot.$inferSelect;
type ParseStatus = "ready" | "partial" | "failed";
type SnapshotSource =
| "sec_filing_summary"
| "xbrl_instance"
| "companyfacts_fallback";
type FinancialStatementKind =
| "income"
| "balance"
| "cash_flow"
| "disclosure"
| "equity"
| "comprehensive_income";
type StatementValuesByPeriod = Record<string, number | null>;
export type FilingStatementSnapshotPeriod = {
id: string;
filingId: number;
accessionNumber: string;
filingDate: string;
periodStart: string | null;
periodEnd: string | null;
filingType: "10-K" | "10-Q";
periodLabel: string;
};
export type FilingFaithfulStatementSnapshotRow = {
key: string;
label: string;
concept: string | null;
order: number;
depth: number;
isSubtotal: boolean;
values: StatementValuesByPeriod;
};
export type StandardizedStatementSnapshotRow = {
key: string;
label: string;
concept: string;
category: string;
sourceConcepts: string[];
values: StatementValuesByPeriod;
};
export type DimensionStatementSnapshotRow = {
rowKey: string;
concept: string | null;
periodId: string;
axis: string;
member: string;
value: number | null;
unit: string | null;
};
export type FilingStatementBundle = {
periods: FilingStatementSnapshotPeriod[];
statements: Record<
FinancialStatementKind,
FilingFaithfulStatementSnapshotRow[]
>;
};
export type StandardizedStatementBundle = {
periods: FilingStatementSnapshotPeriod[];
statements: Record<
FinancialStatementKind,
StandardizedStatementSnapshotRow[]
>;
};
export type DimensionStatementBundle = {
statements: Record<FinancialStatementKind, DimensionStatementSnapshotRow[]>;
};
export type FilingStatementSnapshotRecord = {
id: number;
filing_id: number;
ticker: string;
filing_date: string;
filing_type: "10-K" | "10-Q";
period_end: string | null;
statement_bundle: FilingStatementBundle | null;
standardized_bundle: StandardizedStatementBundle | null;
dimension_bundle: DimensionStatementBundle | null;
parse_status: ParseStatus;
parse_error: string | null;
source: SnapshotSource;
created_at: string;
updated_at: string;
};
export type UpsertFilingStatementSnapshotInput = {
filing_id: number;
ticker: string;
filing_date: string;
filing_type: "10-K" | "10-Q";
period_end: string | null;
statement_bundle: FilingStatementBundle | null;
standardized_bundle: StandardizedStatementBundle | null;
dimension_bundle: DimensionStatementBundle | null;
parse_status: ParseStatus;
parse_error: string | null;
source: SnapshotSource;
};
function toSnapshotRecord(
row: FilingStatementSnapshotRow,
): FilingStatementSnapshotRecord {
return {
id: row.id,
filing_id: row.filing_id,
ticker: row.ticker,
filing_date: row.filing_date,
filing_type: row.filing_type,
period_end: row.period_end,
statement_bundle: row.statement_bundle ?? null,
standardized_bundle: row.standardized_bundle ?? null,
dimension_bundle: row.dimension_bundle ?? null,
parse_status: row.parse_status,
parse_error: row.parse_error,
source: row.source,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
function tenYearsAgoIso() {
const date = new Date();
date.setUTCFullYear(date.getUTCFullYear() - 10);
return date.toISOString().slice(0, 10);
}
export async function getFilingStatementSnapshotByFilingId(filingId: number) {
const [row] = await db
.select()
.from(filingStatementSnapshot)
.where(eq(filingStatementSnapshot.filing_id, filingId))
.limit(1);
return row ? toSnapshotRecord(row) : null;
}
export async function upsertFilingStatementSnapshot(
input: UpsertFilingStatementSnapshotInput,
) {
const now = new Date().toISOString();
const [saved] = await db
.insert(filingStatementSnapshot)
.values({
filing_id: input.filing_id,
ticker: input.ticker,
filing_date: input.filing_date,
filing_type: input.filing_type,
period_end: input.period_end,
statement_bundle: input.statement_bundle,
standardized_bundle: input.standardized_bundle,
dimension_bundle: input.dimension_bundle,
parse_status: input.parse_status,
parse_error: input.parse_error,
source: input.source,
created_at: now,
updated_at: now,
})
.onConflictDoUpdate({
target: filingStatementSnapshot.filing_id,
set: {
ticker: input.ticker,
filing_date: input.filing_date,
filing_type: input.filing_type,
period_end: input.period_end,
statement_bundle: input.statement_bundle,
standardized_bundle: input.standardized_bundle,
dimension_bundle: input.dimension_bundle,
parse_status: input.parse_status,
parse_error: input.parse_error,
source: input.source,
updated_at: now,
},
})
.returning();
return toSnapshotRecord(saved);
}
export async function listFilingStatementSnapshotsByTicker(input: {
ticker: string;
window: "10y" | "all";
limit?: number;
cursor?: string | null;
}) {
const safeLimit = Math.min(Math.max(Math.trunc(input.limit ?? 40), 1), 120);
const cursorId = input.cursor ? Number.parseInt(input.cursor, 10) : null;
const constraints = [
eq(filingStatementSnapshot.ticker, input.ticker.trim().toUpperCase()),
];
if (input.window === "10y") {
constraints.push(
gte(filingStatementSnapshot.filing_date, tenYearsAgoIso()),
);
}
if (cursorId && Number.isFinite(cursorId) && cursorId > 0) {
constraints.push(lt(filingStatementSnapshot.id, cursorId));
}
const rows = await db
.select()
.from(filingStatementSnapshot)
.where(and(...constraints))
.orderBy(
desc(filingStatementSnapshot.filing_date),
desc(filingStatementSnapshot.id),
)
.limit(safeLimit + 1);
const hasMore = rows.length > safeLimit;
const usedRows = hasMore ? rows.slice(0, safeLimit) : rows;
const nextCursor = hasMore
? String(usedRows[usedRows.length - 1]?.id ?? "")
: null;
return {
snapshots: usedRows.map(toSnapshotRecord),
nextCursor,
};
}
export async function countFilingStatementSnapshotStatuses(ticker: string) {
const rows = await db
.select({
status: filingStatementSnapshot.parse_status,
count: sql<string>`count(*)`,
})
.from(filingStatementSnapshot)
.where(eq(filingStatementSnapshot.ticker, ticker.trim().toUpperCase()))
.groupBy(filingStatementSnapshot.parse_status);
return rows.reduce<Record<ParseStatus, number>>(
(acc, row) => {
acc[row.status] = Number(row.count);
return acc;
},
{
ready: 0,
partial: 0,
failed: 0,
},
);
}