import type { CompanyProfile, CompanyValuationSnapshot } from '@/lib/types'; type FetchImpl = typeof fetch; type SubmissionPayload = { cik?: string; name?: string; tickers?: string[]; exchanges?: string[]; sicDescription?: string; fiscalYearEnd?: string; website?: string; addresses?: { business?: { country?: string | null; countryCode?: string | null; stateOrCountryDescription?: string | null; }; }; }; type CompanyFactsPayload = { facts?: Record }>>; }; type FactPoint = { val?: number; filed?: string; end?: string; }; type ExchangeDirectoryPayload = { fields?: string[]; data?: Array>; }; type ExchangeDirectoryRecord = { cik: string; name: string; ticker: string; exchange: string | null; }; export type SecCompanyProfileResult = { ticker: string; cik: string | null; companyName: string | null; exchange: string | null; industry: string | null; country: string | null; website: string | null; fiscalYearEnd: string | null; employeeCount: number | null; sharesOutstanding: number | null; }; type CacheEntry = { expiresAt: number; value: T; }; const EXCHANGE_DIRECTORY_URL = 'https://www.sec.gov/files/company_tickers_exchange.json'; const SEC_SUBMISSIONS_BASE = 'https://data.sec.gov/submissions'; const SEC_COMPANY_FACTS_BASE = 'https://data.sec.gov/api/xbrl/companyfacts'; const EXCHANGE_CACHE_TTL_MS = 1000 * 60 * 30; const SUBMISSIONS_CACHE_TTL_MS = 1000 * 60 * 30; const COMPANY_FACTS_CACHE_TTL_MS = 1000 * 60 * 30; let exchangeDirectoryCache: CacheEntry> | null = null; const submissionsCache = new Map>(); const companyFactsCache = new Map>(); function envUserAgent() { return process.env.SEC_USER_AGENT || 'Fiscal Clone '; } async function fetchJson(url: string, fetchImpl: FetchImpl = fetch): Promise { const response = await fetchImpl(url, { headers: { 'User-Agent': envUserAgent(), Accept: 'application/json' }, cache: 'no-store' }); if (!response.ok) { throw new Error(`SEC request failed (${response.status})`); } return await response.json() as T; } function asNormalizedString(value: unknown) { if (typeof value !== 'string') { return null; } const normalized = value.trim(); return normalized.length > 0 ? normalized : null; } function normalizeCik(value: string | number | null | undefined) { const digits = String(value ?? '').replace(/\D/g, ''); return digits.length > 0 ? digits : null; } function toPaddedCik(value: string | null) { return value ? value.padStart(10, '0') : null; } function formatFiscalYearEnd(value: string | null | undefined) { const normalized = asNormalizedString(value); if (!normalized) { return null; } const digits = normalized.replace(/\D/g, ''); if (digits.length !== 4) { return normalized; } return `${digits.slice(0, 2)}/${digits.slice(2)}`; } function pointDate(point: FactPoint) { return Date.parse(point.filed ?? point.end ?? ''); } function pickLatestNumericFact(payload: CompanyFactsPayload, namespaces: string[], tags: string[]) { const points: FactPoint[] = []; for (const namespace of namespaces) { const facts = payload.facts?.[namespace] ?? {}; for (const tag of tags) { const entry = facts[tag]; if (!entry?.units) { continue; } for (const series of Object.values(entry.units)) { if (!Array.isArray(series)) { continue; } for (const point of series) { if (typeof point.val === 'number' && Number.isFinite(point.val)) { points.push(point); } } } } } if (points.length === 0) { return null; } const sorted = [...points].sort((left, right) => { const leftDate = pointDate(left); const rightDate = pointDate(right); if (Number.isFinite(leftDate) && Number.isFinite(rightDate)) { return rightDate - leftDate; } if (Number.isFinite(rightDate)) { return 1; } if (Number.isFinite(leftDate)) { return -1; } return 0; }); return sorted[0]?.val ?? null; } async function getExchangeDirectory(fetchImpl?: FetchImpl) { if (exchangeDirectoryCache && exchangeDirectoryCache.expiresAt > Date.now()) { return exchangeDirectoryCache.value; } const payload = await fetchJson(EXCHANGE_DIRECTORY_URL, fetchImpl); const fields = payload.fields ?? []; const cikIndex = fields.indexOf('cik'); const nameIndex = fields.indexOf('name'); const tickerIndex = fields.indexOf('ticker'); const exchangeIndex = fields.indexOf('exchange'); const directory = new Map(); for (const row of payload.data ?? []) { const ticker = asNormalizedString(row[tickerIndex]); const cik = normalizeCik(row[cikIndex]); const name = asNormalizedString(row[nameIndex]); const exchange = asNormalizedString(row[exchangeIndex]); if (!ticker || !cik || !name) { continue; } directory.set(ticker.toUpperCase(), { cik, name, ticker: ticker.toUpperCase(), exchange }); } exchangeDirectoryCache = { value: directory, expiresAt: Date.now() + EXCHANGE_CACHE_TTL_MS }; return directory; } async function getSubmissionByCik(cik: string, fetchImpl?: FetchImpl) { const padded = toPaddedCik(cik); if (!padded) { return null; } const cached = submissionsCache.get(padded); if (cached && cached.expiresAt > Date.now()) { return cached.value; } const payload = await fetchJson(`${SEC_SUBMISSIONS_BASE}/CIK${padded}.json`, fetchImpl); submissionsCache.set(padded, { value: payload, expiresAt: Date.now() + SUBMISSIONS_CACHE_TTL_MS }); return payload; } async function getCompanyFactsByCik(cik: string, fetchImpl?: FetchImpl) { const padded = toPaddedCik(cik); if (!padded) { return null; } const cached = companyFactsCache.get(padded); if (cached && cached.expiresAt > Date.now()) { return cached.value; } const payload = await fetchJson(`${SEC_COMPANY_FACTS_BASE}/CIK${padded}.json`, fetchImpl); companyFactsCache.set(padded, { value: payload, expiresAt: Date.now() + COMPANY_FACTS_CACHE_TTL_MS }); return payload; } export async function getSecCompanyProfile( ticker: string, options?: { fetchImpl?: FetchImpl } ): Promise { const normalizedTicker = ticker.trim().toUpperCase(); if (!normalizedTicker) { return null; } try { const directory = await getExchangeDirectory(options?.fetchImpl); const directoryRecord = directory.get(normalizedTicker) ?? null; const cik = directoryRecord?.cik ?? null; const [submission, companyFacts] = await Promise.all([ cik ? getSubmissionByCik(cik, options?.fetchImpl) : Promise.resolve(null), cik ? getCompanyFactsByCik(cik, options?.fetchImpl) : Promise.resolve(null) ]); const employeeCount = companyFacts ? pickLatestNumericFact(companyFacts, ['dei'], ['EntityNumberOfEmployees']) : null; const sharesOutstanding = companyFacts ? pickLatestNumericFact(companyFacts, ['dei'], ['EntityCommonStockSharesOutstanding', 'CommonStockSharesOutstanding']) : null; return { ticker: normalizedTicker, cik, companyName: asNormalizedString(submission?.name) ?? directoryRecord?.name ?? null, exchange: asNormalizedString(submission?.exchanges?.[0]) ?? directoryRecord?.exchange ?? null, industry: asNormalizedString(submission?.sicDescription), country: asNormalizedString(submission?.addresses?.business?.country) ?? asNormalizedString(submission?.addresses?.business?.stateOrCountryDescription), website: asNormalizedString(submission?.website), fiscalYearEnd: formatFiscalYearEnd(submission?.fiscalYearEnd ?? null), employeeCount, sharesOutstanding }; } catch { return null; } } export function toCompanyProfile(input: SecCompanyProfileResult | null, description: string | null): CompanyProfile { if (!input && !description) { return { description: null, exchange: null, industry: null, country: null, website: null, fiscalYearEnd: null, employeeCount: null, source: 'unavailable' }; } return { description, exchange: input?.exchange ?? null, industry: input?.industry ?? null, country: input?.country ?? null, website: input?.website ?? null, fiscalYearEnd: input?.fiscalYearEnd ?? null, employeeCount: input?.employeeCount ?? null, source: 'sec_derived' }; } export function deriveValuationSnapshot(input: { quote: number | null; sharesOutstanding: number | null; revenue: number | null; cash: number | null; debt: number | null; netIncome: number | null; }): CompanyValuationSnapshot { const hasPrice = typeof input.quote === 'number' && Number.isFinite(input.quote) && input.quote > 0; const hasShares = typeof input.sharesOutstanding === 'number' && Number.isFinite(input.sharesOutstanding) && input.sharesOutstanding > 0; const marketCap = hasPrice && hasShares ? input.quote! * input.sharesOutstanding! : null; const hasCash = typeof input.cash === 'number' && Number.isFinite(input.cash); const hasDebt = typeof input.debt === 'number' && Number.isFinite(input.debt); const enterpriseValue = marketCap !== null && hasCash && hasDebt ? marketCap + input.debt! - input.cash! : null; const hasRevenue = typeof input.revenue === 'number' && Number.isFinite(input.revenue) && input.revenue > 0; const hasNetIncome = typeof input.netIncome === 'number' && Number.isFinite(input.netIncome) && input.netIncome > 0; const trailingPe = marketCap !== null && hasNetIncome ? marketCap / input.netIncome! : null; const evToRevenue = enterpriseValue !== null && hasRevenue ? enterpriseValue / input.revenue! : null; const availableCount = [ input.sharesOutstanding, marketCap, enterpriseValue, trailingPe, evToRevenue ].filter((value) => typeof value === 'number' && Number.isFinite(value)).length; return { sharesOutstanding: input.sharesOutstanding, marketCap, enterpriseValue, trailingPe, evToRevenue, evToEbitda: null, source: availableCount === 0 ? 'unavailable' : availableCount >= 3 ? 'derived' : 'partial' }; } export const __secCompanyProfileInternals = { formatFiscalYearEnd, pickLatestNumericFact };