add analysis component

This commit is contained in:
Anibal Angulo
2025-11-09 10:24:58 -06:00
parent 77a11ef32e
commit 1ce4162e4a
11 changed files with 1155 additions and 8 deletions

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
from typing import Any, Iterable, List
from app.agents.form_auditor.models import ExtractedIrsForm990PfDataSchema
from .agent import agent
from .metrics import SnapshotBundle, build_key_metrics, build_snapshots
from .models import AnalystReport, AnalystState
__all__ = ["build_performance_report"]
def _resolve_year(
entry: dict[str, Any], extraction: ExtractedIrsForm990PfDataSchema
) -> int:
candidates: Iterable[Any] = (
entry.get("calendar_year"),
entry.get("year"),
entry.get("tax_year"),
entry.get("return_year"),
entry.get("metadata", {}).get("return_year")
if isinstance(entry.get("metadata"), dict)
else None,
entry.get("metadata", {}).get("tax_year")
if isinstance(entry.get("metadata"), dict)
else None,
extraction.core_organization_metadata.calendar_year,
)
for candidate in candidates:
if candidate in (None, ""):
continue
try:
return int(candidate)
except (TypeError, ValueError):
continue
raise ValueError("Unable to determine filing year for one of the payload entries.")
async def build_performance_report(payloads: List[dict[str, Any]]) -> AnalystReport:
if not payloads:
raise ValueError("At least one payload is required for performance analysis.")
bundles: List[SnapshotBundle] = []
organisation_name = ""
organisation_ein = ""
for entry in payloads:
if not isinstance(entry, dict):
raise TypeError("Each payload entry must be a dict.")
extraction_payload = entry.get("extraction") if "extraction" in entry else entry
extraction = ExtractedIrsForm990PfDataSchema.model_validate(extraction_payload)
year = _resolve_year(entry, extraction)
if not organisation_ein:
organisation_ein = extraction.core_organization_metadata.ein
organisation_name = extraction.core_organization_metadata.legal_name
else:
if extraction.core_organization_metadata.ein != organisation_ein:
raise ValueError(
"All payload entries must belong to the same organization."
)
bundles.append(SnapshotBundle(year=year, extraction=extraction))
bundles.sort(key=lambda bundle: bundle.year)
snapshots = build_snapshots(bundles)
metrics = build_key_metrics(snapshots)
notes = []
if metrics:
revenue_metric = metrics[0]
expense_metric = metrics[1] if len(metrics) > 1 else None
if revenue_metric.cagr is not None:
notes.append(f"Revenue CAGR: {revenue_metric.cagr:.2%}")
if expense_metric and expense_metric.cagr is not None:
notes.append(f"Expense CAGR: {expense_metric.cagr:.2%}")
surplus_metric = next(
(m for m in metrics if m.name == "Operating Surplus"), None
)
if surplus_metric:
last_value = surplus_metric.points[-1].value if surplus_metric.points else 0
notes.append(f"Latest operating surplus: {last_value:,.0f}")
state = AnalystState(
organisation_name=organisation_name,
organisation_ein=organisation_ein,
series=snapshots,
key_metrics=metrics,
notes=notes,
)
prompt = (
"Analyze the provided multi-year financial context. Quantify notable trends, "
"call out risks or strengths, and supply actionable recommendations. "
"Capture both positive momentum and areas requiring attention."
)
result = await agent.run(prompt, deps=state)
report = result.output
years = [snapshot.year for snapshot in snapshots]
return report.model_copy(
update={
"organisation_name": organisation_name,
"organisation_ein": organisation_ein,
"years_analyzed": years,
"key_metrics": metrics,
}
)

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.azure import AzureProvider
from app.core.config import settings
from .models import AnalystReport, AnalystState
provider = AzureProvider(
azure_endpoint=settings.AZURE_OPENAI_ENDPOINT,
api_version=settings.AZURE_OPENAI_API_VERSION,
api_key=settings.AZURE_OPENAI_API_KEY,
)
model = OpenAIChatModel(model_name="gpt-4o", provider=provider)
agent = Agent(
model=model,
name="MultiYearAnalyst",
deps_type=AnalystState,
output_type=AnalystReport,
system_prompt=(
"You are a nonprofit financial analyst. You receive multi-year Form 990 extractions "
"summarized into deterministic metrics (series, ratios, surplus, CAGR). Use the context "
"to highlight performance trends, governance implications, and forward-looking risks. "
"Focus on numeric trends: revenue growth, expense discipline, surplus stability, "
"program-vs-admin mix, and fundraising efficiency. Provide concise bullet insights, "
"clear recommendations tied to the data, and a balanced outlook (strengths vs watch items). "
"Only cite facts available in the provided series—do not invent figures."
),
)

View File

@@ -0,0 +1,197 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, List, Sequence, Tuple
from app.agents.form_auditor.models import ExtractedIrsForm990PfDataSchema
from .models import TrendDirection, TrendMetric, TrendMetricPoint, YearlySnapshot
@dataclass
class SnapshotBundle:
year: int
extraction: ExtractedIrsForm990PfDataSchema
def _safe_ratio(numerator: float, denominator: float) -> float | None:
if denominator in (0, None):
return None
try:
return numerator / denominator
except ZeroDivisionError:
return None
def _growth(current: float, previous: float | None) -> float | None:
if previous in (None, 0):
return None
try:
return (current - previous) / previous
except ZeroDivisionError:
return None
def _direction_from_points(values: Sequence[float | None]) -> TrendDirection:
clean = [value for value in values if value is not None]
if len(clean) < 2:
return TrendDirection.STABLE
start, end = clean[0], clean[-1]
if start is None or end is None:
return TrendDirection.STABLE
delta = end - start
tolerance = abs(start) * 0.02 if start else 0.01
if abs(delta) <= tolerance:
return TrendDirection.STABLE
if len(clean) > 2:
swings = sum(
1
for idx in range(1, len(clean) - 1)
if (clean[idx] - clean[idx - 1]) * (clean[idx + 1] - clean[idx]) < 0
)
if swings >= len(clean) // 2:
return TrendDirection.VOLATILE
return TrendDirection.IMPROVING if delta > 0 else TrendDirection.DECLINING
def _cagr(start: float | None, end: float | None, periods: int) -> float | None:
if start is None or end is None or start <= 0 or end <= 0 or periods <= 0:
return None
return (end / start) ** (1 / periods) - 1
def build_snapshots(bundles: Sequence[SnapshotBundle]) -> List[YearlySnapshot]:
snapshots: List[YearlySnapshot] = []
previous_revenue = None
previous_expenses = None
for bundle in bundles:
rev = bundle.extraction.revenue_breakdown.total_revenue
exp = bundle.extraction.expenses_breakdown.total_expenses
program = bundle.extraction.expenses_breakdown.program_services_expenses
admin = bundle.extraction.expenses_breakdown.management_general_expenses
fundraising = bundle.extraction.expenses_breakdown.fundraising_expenses
snapshots.append(
YearlySnapshot(
year=bundle.year,
total_revenue=rev,
total_expenses=exp,
revenue_growth=_growth(rev, previous_revenue),
expense_growth=_growth(exp, previous_expenses),
surplus=rev - exp,
program_ratio=_safe_ratio(program, exp),
admin_ratio=_safe_ratio(admin, exp),
fundraising_ratio=_safe_ratio(fundraising, exp),
net_margin=_safe_ratio(rev - exp, rev),
)
)
previous_revenue = rev
previous_expenses = exp
return snapshots
def _metric_from_series(
name: str,
unit: str,
description: str,
values: Iterable[Tuple[int, float | None]],
) -> TrendMetric:
points = [
TrendMetricPoint(year=year, value=value or 0.0, growth=None)
for year, value in values
]
for idx in range(1, len(points)):
prev = points[idx - 1].value
curr = points[idx].value
points[idx].growth = _growth(curr, prev)
data_values = [point.value for point in points]
direction = _direction_from_points(data_values)
cagr = None
if len(points) >= 2:
cagr = _cagr(points[0].value, points[-1].value, len(points) - 1)
return TrendMetric(
name=name,
unit=unit,
description=description,
points=points,
cagr=cagr,
direction=direction,
)
def build_key_metrics(snapshots: Sequence[YearlySnapshot]) -> List[TrendMetric]:
if not snapshots:
return []
metrics = [
_metric_from_series(
"Total Revenue",
"USD",
"Reported total revenue in Part I.",
[(snap.year, snap.total_revenue) for snap in snapshots],
),
_metric_from_series(
"Total Expenses",
"USD",
"Reported total expenses in Part I.",
[(snap.year, snap.total_expenses) for snap in snapshots],
),
_metric_from_series(
"Operating Surplus",
"USD",
"Difference between total revenue and total expenses.",
[(snap.year, snap.surplus) for snap in snapshots],
),
_metric_from_series(
"Program Service Ratio",
"Ratio",
"Program service expenses divided by total expenses.",
[
(
snap.year,
snap.program_ratio if snap.program_ratio is not None else 0.0,
)
for snap in snapshots
],
),
_metric_from_series(
"Administrative Ratio",
"Ratio",
"Management & general expenses divided by total expenses.",
[
(snap.year, snap.admin_ratio if snap.admin_ratio is not None else 0.0)
for snap in snapshots
],
),
_metric_from_series(
"Fundraising Ratio",
"Ratio",
"Fundraising expenses divided by total expenses.",
[
(
snap.year,
snap.fundraising_ratio
if snap.fundraising_ratio is not None
else 0.0,
)
for snap in snapshots
],
),
]
for metric in metrics:
if metric.name.endswith("Ratio"):
metric.notes = "Higher values indicate greater spending share."
elif metric.name == "Operating Surplus":
metric.notes = "Positive surplus implies revenues exceeded expenses."
return metrics

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
from enum import Enum
from typing import List
from pydantic import BaseModel, Field
class TrendDirection(str, Enum):
IMPROVING = "Improving"
DECLINING = "Declining"
STABLE = "Stable"
VOLATILE = "Volatile"
class TrendMetricPoint(BaseModel):
year: int
value: float
growth: float | None = Field(
default=None, description="Year-over-year growth expressed as a decimal."
)
class TrendMetric(BaseModel):
name: str
unit: str
description: str
points: List[TrendMetricPoint]
cagr: float | None = Field(
default=None,
description="Compound annual growth rate across the analyzed period.",
)
direction: TrendDirection = Field(
default=TrendDirection.STABLE, description="Overall direction of the metric."
)
notes: str | None = None
class TrendInsight(BaseModel):
category: str
direction: TrendDirection
summary: str
confidence: float = Field(default=0.7, ge=0.0, le=1.0)
class AnalystReport(BaseModel):
organisation_name: str
organisation_ein: str
years_analyzed: List[int] = Field(default_factory=list)
key_metrics: List[TrendMetric] = Field(default_factory=list)
insights: List[TrendInsight] = Field(default_factory=list)
recommendations: List[str] = Field(default_factory=list)
outlook: str = "Pending analysis"
class YearlySnapshot(BaseModel):
year: int
total_revenue: float
total_expenses: float
revenue_growth: float | None = None
expense_growth: float | None = None
surplus: float | None = None
program_ratio: float | None = None
admin_ratio: float | None = None
fundraising_ratio: float | None = None
net_margin: float | None = None
class AnalystState(BaseModel):
organisation_name: str
organisation_ein: str
series: List[YearlySnapshot]
key_metrics: List[TrendMetric]
notes: List[str] = Field(default_factory=list)

View File

@@ -106,6 +106,11 @@ class CoreOrganizationMetadata(BaseModel):
incorporation_state: str = Field(
..., description="State of incorporation.", title="Incorporation State"
)
calendar_year: str | None = Field(
default=None,
description="Calendar year covered by the return (if different from fiscal year).",
title="Calendar Year",
)
class RevenueBreakdown(BaseModel):
@@ -579,6 +584,7 @@ def _transform_flat_payload(data: dict[str, Any]) -> dict[str, Any]:
"organization_type": get_str("organization_type"),
"year_of_formation": get_str("year_of_formation"),
"incorporation_state": get_str("incorporation_state"),
"calendar_year": get_str("calendar_year"),
},
"revenue_breakdown": {
"total_revenue": get_value("total_revenue"),

View File

@@ -1,4 +1,5 @@
import json
import logging
from dataclasses import dataclass
from typing import Annotated, Any
@@ -10,7 +11,7 @@ from pydantic_ai.ui.vercel_ai import VercelAIAdapter
from starlette.requests import Request
from starlette.responses import Response
from app.agents import form_auditor, web_search
from app.agents import analyst, form_auditor, web_search
from app.core.config import settings
from app.services.extracted_data_service import get_extracted_data_service
@@ -24,27 +25,43 @@ model = OpenAIChatModel(model_name="gpt-4o", provider=provider)
@dataclass
class Deps:
extracted_data: dict[str, Any]
extracted_data: list[dict[str, Any]]
agent = Agent(model=model, deps_type=Deps)
router = APIRouter(prefix="/api/v1/agent", tags=["Agent"])
logger = logging.getLogger(__name__)
@agent.tool
async def build_audit_report(ctx: RunContext[Deps]):
"""Calls the audit subagent to get a full audit report of the organization"""
data = ctx.deps.extracted_data
with open("data/audit_report.json", "w") as f:
json.dump(data, f)
data = ctx.deps.extracted_data[0]
result = await form_auditor.build_audit_report(data)
return result.model_dump()
@agent.tool
async def build_analysis_report(ctx: RunContext[Deps]):
"""Calls the analyst subagent to get a full report of the organization's performance across years"""
data = ctx.deps.extracted_data
if not data:
raise ValueError("No extracted data available for analysis.")
if len(data) == 1:
logger.info(
"build_analysis_report called with single-year data; report will still be generated but trends may be limited."
)
result = await analyst.build_performance_report(data)
return result.model_dump()
@agent.tool_plain
async def search_web_information(query: str, max_results: int = 5):
"""Search the web for up-to-date information using Tavily. Use this when you need current information, news, research, or facts not in your knowledge base."""
@@ -61,6 +78,8 @@ async def chat(request: Request, tema: Annotated[str, Header()]) -> Response:
extracted_data = [doc.get_extracted_data() for doc in data]
deps = Deps(extracted_data=extracted_data[0])
logger.info(f"Extracted data amount: {len(extracted_data)}")
deps = Deps(extracted_data=extracted_data)
return await VercelAIAdapter.dispatch_request(request, agent=agent, deps=deps)

View File

@@ -12,6 +12,15 @@
"max_value": null,
"pattern": "^\\d{2}-\\d{7}$"
},
{
"name": "calendar_year",
"type": "integer",
"description": "Calendar year for which the data is reported",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "legal_name",
"type": "string",

View File

@@ -0,0 +1,230 @@
import {
TrendingUp,
TrendingDown,
Activity,
Info,
Target,
Lightbulb,
ArrowRight,
AlertTriangle,
} from "lucide-react";
type TrendDirection = "Improving" | "Declining" | "Stable" | "Volatile";
interface TrendMetricPoint {
year: number;
value: number;
growth?: number | null;
}
interface TrendMetric {
name: string;
unit: string;
description: string;
points: TrendMetricPoint[];
cagr?: number | null;
direction: TrendDirection;
notes?: string | null;
}
interface TrendInsight {
category: string;
direction: TrendDirection;
summary: string;
confidence: number;
}
interface AnalystReportData {
organisation_name: string;
organisation_ein: string;
years_analyzed: number[];
key_metrics: TrendMetric[];
insights: TrendInsight[];
recommendations: string[];
outlook: string;
}
interface AnalystReportProps {
data: AnalystReportData;
}
const directionBadgeClasses: Record<TrendDirection, string> = {
Improving: "bg-green-100 text-green-700 border-green-200",
Declining: "bg-red-100 text-red-700 border-red-200",
Stable: "bg-blue-100 text-blue-700 border-blue-200",
Volatile: "bg-yellow-100 text-yellow-700 border-yellow-200",
};
const directionIcon = (direction: TrendDirection) => {
switch (direction) {
case "Improving":
return <TrendingUp className="w-4 h-4" />;
case "Declining":
return <TrendingDown className="w-4 h-4" />;
case "Volatile":
return <Activity className="w-4 h-4" />;
default:
return <Info className="w-4 h-4" />;
}
};
const formatNumber = (value: number, unit: string) => {
if (unit === "USD") {
return `$${value.toLocaleString("en-US", {
maximumFractionDigits: 0,
})}`;
}
if (unit === "Ratio") {
return `${(value * 100).toFixed(1)}%`;
}
return value.toLocaleString("en-US", { maximumFractionDigits: 2 });
};
const formatPercent = (value?: number | null) => {
if (value === undefined || value === null || Number.isNaN(value)) {
return "—";
}
return `${(value * 100).toFixed(1)}%`;
};
export function AnalystReport({ data }: AnalystReportProps) {
const {
organisation_name,
organisation_ein,
years_analyzed,
key_metrics,
insights,
recommendations,
outlook,
} = data;
return (
<div className="w-full bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<div className="bg-gradient-to-r from-emerald-50 to-sky-50 px-4 py-3 border-b border-emerald-100">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide">
Multi-year performance analysis
</p>
<h3 className="text-lg font-semibold text-gray-900">
{organisation_name}
</h3>
<p className="text-xs text-gray-600">
EIN {organisation_ein} {years_analyzed.join(" ")}
</p>
</div>
<div className="text-right text-sm text-gray-600">
<span className="font-medium text-gray-900">Outlook</span>
<p className="text-xs text-gray-600 max-w-xs">{outlook}</p>
</div>
</div>
</div>
{/* Key Metrics */}
{key_metrics.length > 0 && (
<div className="p-4 space-y-3">
<h4 className="text-sm font-semibold text-gray-800 flex items-center gap-2">
<Activity className="w-4 h-4 text-emerald-600" />
Core Trend Metrics
</h4>
<div className="grid gap-3 md:grid-cols-2">
{key_metrics.slice(0, 4).map((metric, index) => {
const latest = metric.points[metric.points.length - 1];
const prior =
metric.points.length > 1
? metric.points[metric.points.length - 2]
: null;
const yoy = latest && prior ? formatPercent(latest.growth) : "—";
return (
<div
key={`${metric.name}-${index}`}
className="border rounded-lg p-3 bg-gray-50"
>
<div className="flex items-center justify-between mb-1">
<div className="font-medium text-sm text-gray-900">
{metric.name}
</div>
<span
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border ${directionBadgeClasses[metric.direction]}`}
>
{directionIcon(metric.direction)}
{metric.direction}
</span>
</div>
<p className="text-2xl font-semibold text-gray-900">
{latest ? formatNumber(latest.value, metric.unit) : "—"}
</p>
<div className="flex justify-between text-xs text-gray-600 mt-1">
<span>YoY: {yoy}</span>
<span>CAGR: {formatPercent(metric.cagr)}</span>
</div>
{metric.notes && (
<p className="text-xs text-gray-500 mt-2">{metric.notes}</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* Insights */}
{insights.length > 0 && (
<div className="px-4 pb-4">
<h4 className="text-sm font-semibold text-gray-800 mb-2 flex items-center gap-2">
<Lightbulb className="w-4 h-4 text-amber-500" />
Key Insights
</h4>
<div className="space-y-2">
{insights.map((insight, index) => (
<div
key={`${insight.category}-${index}`}
className="border rounded-lg p-3 bg-white"
>
<div className="flex justify-between items-center mb-1">
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
{directionIcon(insight.direction)}
{insight.category}
</div>
<span className="text-xs text-gray-500">
{Math.round(insight.confidence * 100)}% confidence
</span>
</div>
<p className="text-sm text-gray-700">{insight.summary}</p>
</div>
))}
</div>
</div>
)}
{/* Recommendations */}
{recommendations.length > 0 && (
<div className="px-4 pb-4">
<h4 className="text-sm font-semibold text-gray-800 mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-indigo-500" />
Recommended Actions
</h4>
<ul className="space-y-1 text-sm text-gray-700">
{recommendations.map((rec, index) => (
<li
key={`rec-${index}`}
className="flex items-start gap-2 bg-gray-50 border border-gray-100 rounded-lg p-2"
>
<ArrowRight className="w-4 h-4 text-gray-500 mt-0.5" />
<span>{rec}</span>
</li>
))}
</ul>
</div>
)}
{/* Empty states fallback */}
{recommendations.length === 0 && insights.length === 0 && (
<div className="px-4 py-6 text-sm text-gray-600 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-gray-400" />
No trend insights available yet. Try requesting an annual comparison.
</div>
)}
</div>
);
}

View File

@@ -29,6 +29,7 @@ import {
User,
} from "lucide-react";
import { AuditReport } from "./AuditReport";
import { AnalystReport } from "./AnalystReport";
import { WebSearchResults } from "./WebSearchResults";
import { Loader } from "@/components/ai-elements/loader";
import { DefaultChatTransport } from "ai";
@@ -244,6 +245,51 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
default:
return null;
}
case "tool-build_analysis_report":
switch (part.state) {
case "input-available":
return (
<div
key={`${message.id}-${i}`}
className="flex items-center gap-2 p-4 bg-purple-50 rounded-lg border border-purple-200"
>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-600"></div>
<span className="text-sm text-purple-700">
Generando análisis histórico...
</span>
</div>
);
case "output-available":
return (
<div
key={`${message.id}-${i}`}
className="mt-4 w-full"
>
<div className="max-w-full overflow-hidden">
<AnalystReport data={part.output} />
</div>
</div>
);
case "output-error":
return (
<div
key={`${message.id}-${i}`}
className="p-4 bg-red-50 border border-red-200 rounded-lg"
>
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-600" />
<span className="text-sm font-medium text-red-800">
Error generando análisis histórico
</span>
</div>
<p className="text-sm text-red-600 mt-1">
{part.errorText}
</p>
</div>
);
default:
return null;
}
case "tool-search_web_information":
switch (part.state) {
case "input-available":

View File

@@ -300,3 +300,424 @@ $20
extraction_timestamp
$26
2025-11-08T16:17:31.137820
*2
$6
SELECT
$1
0
*14
$4
HSET
$44
extracted_doc:doc:01K9MH40SHHNWXNGAP4MDX6HZF
$2
pk
$26
01K9MH40SHHNWXNGAP4MDX6HZF
$9
file_name
$21
tax_year_2019_990.pdf
$4
tema
$4
ABBY
$15
collection_name
$4
ABBY
$19
extracted_data_json
$4954
{
"ein": "31-0329725",
"legal_name": "Abbey Credit Union Inc",
"phone_number": "937-898-7800",
"website_url": "www.abbeycu.com",
"return_type": "990",
"amended_return": "",
"group_exemption_number": "",
"subsection_code": "501(c)(14)",
"ruling_date": "",
"accounting_method": "Accrual",
"organization_type": "Corporation",
"year_of_formation": "1937",
"incorporation_state": "OH",
"total_revenue": 4659611,
"contributions_gifts_grants": 0,
"program_service_revenue": 4114069,
"membership_dues": 0,
"investment_income": 545542,
"gains_losses_sales_assets": 0,
"rental_income": 6725,
"related_organizations_revenue": 0,
"gaming_revenue": 0,
"other_revenue": 0,
"government_grants": 0,
"foreign_contributions": 0,
"total_expenses": 4159254,
"program_services_expenses": 0,
"management_general_expenses": 0,
"fundraising_expenses": 0,
"grants_us_organizations": 0,
"grants_us_individuals": 0,
"grants_foreign_organizations": 0,
"grants_foreign_individuals": 0,
"compensation_officers": 338208,
"compensation_other_staff": 1018956,
"payroll_taxes_benefits": 309032,
"professional_fees": 34366,
"office_occupancy_costs": 0,
"information_technology_costs": 0,
"travel_conference_expenses": 0,
"depreciation_amortization": 0,
"insurance": 0,
"officers_list": [
"Michael Thein, Chairman",
"Nancy Wood, Vice Chairman",
"Steve Wilmoth, Treasurer",
"Julie Trick, Secretary",
"Michele Blake, Board Member",
"Cheryl Saunders, Board Member",
"Heather Scaggs-Richardson, Board Member",
"Dean Pielemeier, CEO",
"Teri Puthoff, VP of Finance",
"Blanca Ortiz, VP of Business Development"
],
"governing_body_size": 7,
"independent_members": 7,
"financial_statements_reviewed": "Yes",
"form_990_provided_to_governing_body": "Yes",
"conflict_of_interest_policy": "Yes",
"whistleblower_policy": "Yes",
"document_retention_policy": "Yes",
"ceo_compensation_review_process": "Compensation for Dean Pielemeier, CEO is reviewed by the Personnel Committee of the board of directors and is acted upon by the board of directors per committee recommendation Compensation for officers is determined by Dean Pielemeier, CEO",
"public_disclosure_practices": "Documents are not made available to the public",
"program_accomplishments_list": [
"Our mission is to help our members improve their economic well-being and quality of life by being competitive, convenient, and cutting edge",
"LENDING - ABBEY PROVIDES PERSONAL, VEHICLE, MORTGAGE, AND CREDIT CARD LOANS TO ITS MEMBERS.",
"FINANCIAL SERVICES - ABBEY PROVIDES CHECKING ACCOUNTS, SAVINGS ACCOUNTS, MONEY MARKET ACCOUNTS"
],
"total_fundraising_event_revenue": 0,
"total_fundraising_event_expenses": 0,
"professional_fundraiser_fees": 0,
"number_of_employees": 36,
"number_of_volunteers": 7,
"occupancy_costs": 0,
"fundraising_method_descriptions": "",
"joint_ventures_disregarded_entities": "",
"base_compensation": 154796,
"bonus": 6409,
"incentive": 0,
"other_compensation": 4642,
"non_fixed_compensation": "",
"first_class_travel": "",
"housing_allowance": "",
"expense_account_usage": "",
"supplemental_retirement": "Yes",
"lobbying_expenditures_direct": 0,
"lobbying_expenditures_grassroots": 0,
"election_501h_status": "",
"political_campaign_expenditures": 0,
"related_organizations_affiliates": "",
"investment_types": "",
"donor_restricted_endowment_values": 0,
"net_appreciation_depreciation": 281072,
"related_organization_transactions": "",
"loans_to_from_related_parties": "LYNN COOK, PRIOR CEO, LIFE INSURAN, Loan from organization, $140,000 balance due",
"penalties_excise_taxes_reported": "",
"unrelated_business_income_disclosure": "Yes",
"foreign_bank_account_reporting": "No",
"schedule_o_narrative_explanations": "Our mission is to help our members improve their economic well-being and quality of life by being competitive, convenient, and cutting edge. Membership to Abbey is open to those who live, work, or worship, or attend school in Montgomery, Miami, Shelby, Darke, or Greene counties. Abbey Credit Union is owned by these people that open an account at Abbey CU. Yes - Each member gets one vote at the annual election. Yes - Pursuant to the regulation of the Ohio Division of Credit Unions. The VP of Finance prepared the Form 990 based on financial records Financial statements are audited annually The CEO reviews the returns prior to filing. The Credit Union has a written conflict of interest policy that states that board members are responsible for disclosing possible conflicts of interest as they arise This is reviewed annually. Compensation for Dean Pielemeier, CEO is reviewed by the Personnel Committee of the board of directors and is acted upon by the board of directors per committee recommendation Compensation for officers is determined by Dean Pielemeier, CEO."
}
$20
extraction_timestamp
$26
2025-11-09T14:42:59.494840
*10
$4
HSET
$56
:app.models.dataroom.DataRoom:01K9MNBVRY6KVF4XCK70JF70T0
$2
pk
$26
01K9MNBVRY6KVF4XCK70JF70T0
$4
name
$5
ABBEY
$10
collection
$5
abbey
$7
storage
$5
abbey
*14
$4
HSET
$44
extracted_doc:doc:01K9MNMNYH1MYP0TQ5SWZRS5BG
$2
pk
$26
01K9MNMNYH1MYP0TQ5SWZRS5BG
$9
file_name
$21
tax_year_2022_990.pdf
$4
tema
$5
ABBEY
$15
collection_name
$5
ABBEY
$19
extracted_data_json
$5525
{
"ein": "31-0329725",
"calendar_year": 2022,
"legal_name": "0220 ABBEY CREDIT UNION INC",
"phone_number": "(397) 898-7800",
"website_url": "www.abbeycu.com",
"return_type": "990",
"amended_return": "",
"group_exemption_number": "",
"subsection_code": "501(c)(14)",
"ruling_date": "",
"accounting_method": "Accrual",
"organization_type": "Corporation",
"year_of_formation": "1937",
"incorporation_state": "OH",
"total_revenue": 6146738,
"contributions_gifts_grants": 0,
"program_service_revenue": 5656278,
"membership_dues": 0,
"investment_income": 490460,
"gains_losses_sales_assets": 0,
"rental_income": 0,
"related_organizations_revenue": 0,
"gaming_revenue": 0,
"other_revenue": 0,
"government_grants": 0,
"foreign_contributions": 0,
"total_expenses": 5526970,
"program_services_expenses": 386454,
"management_general_expenses": 5140516,
"fundraising_expenses": 0,
"grants_us_organizations": 0,
"grants_us_individuals": 0,
"grants_foreign_organizations": 0,
"grants_foreign_individuals": 0,
"compensation_officers": 629393,
"compensation_other_staff": 1329887,
"payroll_taxes_benefits": 452511,
"professional_fees": 96462,
"office_occupancy_costs": 292244,
"information_technology_costs": 287125,
"travel_conference_expenses": 26662,
"depreciation_amortization": 236836,
"insurance": 39570,
"officers_list": [
"Lisa Burk, Chief Experience Officer, 40.00 hrs/wk, Officer, $73,286 compensation, $8,931 other compensation",
"Eric Stetzel, VP of Business Services, 40.00 hrs/wk, Key employee, $111,064 compensation, $15,979 other compensation",
"Dean Pielemeier, CEO, 40.00 hrs/wk, Officer, $212,994 compensation, $20,202 other compensation",
"Blanca Criner, Chief Marketing and Business Development Officer, 40.00 hrs/wk, Officer, $121,036 compensation, $4,540 other compensation",
"Teri Puthoff, CFO, 40.00 hrs/wk, Officer, $120,962 compensation, $15,754 other compensation",
"Michael Thein, Chairman, 1.00 hrs/wk, Individual trustee or director, $0 compensation",
"Latham Farley, Board Member, 1.00 hrs/wk, Individual trustee or director, $0 compensation",
"Steve Wilmoth, Treasurer, 1.00 hrs/wk, Individual trustee or director, $0 compensation",
"Julie Trick, Secretary, 1.00 hrs/wk, Individual trustee or director, $0 compensation",
"Michele Blake, Board Member, 1.00 hrs/wk, Individual trustee or director, $0 compensation",
"Cheryl Saunders, Board Member, 1.00 hrs/wk, Individual trustee or director, $0 compensation",
"Heather Scaggs-Richardson, Vice Chairman, 1.00 hrs/wk, Individual trustee or director, $0 compensation"
],
"governing_body_size": 7,
"independent_members": 7,
"financial_statements_reviewed": "No",
"form_990_provided_to_governing_body": "Yes",
"conflict_of_interest_policy": "Yes",
"whistleblower_policy": "Yes",
"document_retention_policy": "Yes",
"ceo_compensation_review_process": "Approval of Compensation Committee and Board of Directors",
"public_disclosure_practices": "Audited financial statements are handed out each year at the annual meeting. Other documents are not made available to the public unless requested.",
"program_accomplishments_list": [
"Abbey provides checking savings accounts money market accounts certificates of deposit and IRAs. Abbey also offers free atm debit card services wire transfer services mobile banking and insurance related products.",
"Abbey provides personal vehicle mortgage and credit card loans to its members."
],
"total_fundraising_event_revenue": 0,
"total_fundraising_event_expenses": 0,
"professional_fundraiser_fees": 0,
"number_of_employees": 50,
"number_of_volunteers": 7,
"occupancy_costs": 132399,
"fundraising_method_descriptions": "",
"joint_ventures_disregarded_entities": "",
"base_compensation": 544779,
"bonus": 34315,
"incentive": 59692,
"other_compensation": 0,
"non_fixed_compensation": "",
"first_class_travel": "No",
"housing_allowance": "No",
"expense_account_usage": "No",
"supplemental_retirement": "No",
"lobbying_expenditures_direct": 0,
"lobbying_expenditures_grassroots": 0,
"election_501h_status": "",
"political_campaign_expenditures": 0,
"related_organizations_affiliates": "",
"investment_types": "Publicly traded securities",
"donor_restricted_endowment_values": 0,
"net_appreciation_depreciation": -2190939,
"related_organization_transactions": "",
"loans_to_from_related_parties": "Lynn Cook (Retired CEO) and Dean Pielemeier (CEO) received life insurance loans from the organization, each with a balance due of $1,400,000 and $1,239,997 respectively.",
"penalties_excise_taxes_reported": "No",
"unrelated_business_income_disclosure": "Yes",
"foreign_bank_account_reporting": "No",
"schedule_o_narrative_explanations": "Membership to Abbey is open to those who live work worship or attend school in Montgomery Miami Shelby Darke or Greene counties. Abbey CU is owned by the people who open a savings account at Abbey CU. Each member has one vote at the annual election of the Board of Directors. Decisions that require their approval: Ohio Division of Financial Institutions Pursuant to regulation. CFO prepares the form and CEO reviews before submitting. Approval of Compensation Committee and Board of Directors for CEO compensation. Audited financial statements are handed out each year at the annual meeting. Other documents are not made available to the public unless requested."
}
$20
extraction_timestamp
$26
2025-11-09T16:01:59.759953
*14
$4
HSET
$44
extracted_doc:doc:01K9MP8DBNWMRH9A966RRJ4E7V
$2
pk
$26
01K9MP8DBNWMRH9A966RRJ4E7V
$9
file_name
$21
tax_year_2019_990.pdf
$4
tema
$5
ABBEY
$15
collection_name
$5
ABBEY
$19
extracted_data_json
$4959
{
"ein": "31-0329725",
"calendar_year": 2019,
"legal_name": "Abbey Credit Union Inc",
"phone_number": "937-898-7800",
"website_url": "www.abbeycu.com",
"return_type": "990",
"amended_return": "",
"group_exemption_number": "",
"subsection_code": "501(c)(14)",
"ruling_date": "",
"accounting_method": "Accrual",
"organization_type": "Corporation",
"year_of_formation": "1937",
"incorporation_state": "OH",
"total_revenue": 4659611,
"contributions_gifts_grants": 0,
"program_service_revenue": 4114069,
"membership_dues": 0,
"investment_income": 545542,
"gains_losses_sales_assets": 0,
"rental_income": 6725,
"related_organizations_revenue": 0,
"gaming_revenue": 0,
"other_revenue": 0,
"government_grants": 0,
"foreign_contributions": 0,
"total_expenses": 4159254,
"program_services_expenses": 0,
"management_general_expenses": 0,
"fundraising_expenses": 0,
"grants_us_organizations": 0,
"grants_us_individuals": 0,
"grants_foreign_organizations": 0,
"grants_foreign_individuals": 0,
"compensation_officers": 338208,
"compensation_other_staff": 1018956,
"payroll_taxes_benefits": 273057,
"professional_fees": 34366,
"office_occupancy_costs": 200152,
"information_technology_costs": 0,
"travel_conference_expenses": 46563,
"depreciation_amortization": 0,
"insurance": 0,
"officers_list": {
"value": [
"Michael Thein, Chairman",
"Nancy Wood, Vice Chairman",
"Steve Wilmoth, Treasurer",
"Julie Trick, Secretary",
"Michele Blake, Board Member",
"Cheryl Saunders, Board Member",
"Heather Scaggs-Richardson, Board Member",
"Dean Pielemeier, CEO",
"Teri Puthoff, VP of Finance",
"Blanca Ortiz, VP of Business Development"
],
"chunk_references": []
},
"governing_body_size": 7,
"independent_members": 7,
"financial_statements_reviewed": "Yes",
"form_990_provided_to_governing_body": "Yes",
"conflict_of_interest_policy": "Yes",
"whistleblower_policy": "Yes",
"document_retention_policy": "Yes",
"ceo_compensation_review_process": "Compensation for Dean Pielemeier, CEO is reviewed by the Personnel Committee of the board of directors and is acted upon by the board of directors per committee recommendation.",
"public_disclosure_practices": "Documents are not made available to the public",
"program_accomplishments_list": {
"value": [
"LENDING - ABBEY PROVIDES PERSONAL, VEHICLE, MORTGAGE, AND CREDIT CARD LOANS TO ITS MEMBERS.",
"FINANCIAL SERVICES - ABBEY PROVIDES CHECKING ACCOUNTS, SAVINGS ACCOUNTS, MONEY MARKET ACCOUNTS, ..."
],
"chunk_references": []
},
"total_fundraising_event_revenue": 0,
"total_fundraising_event_expenses": 0,
"professional_fundraiser_fees": 0,
"number_of_employees": 36,
"number_of_volunteers": 7,
"occupancy_costs": 87912,
"fundraising_method_descriptions": "",
"joint_ventures_disregarded_entities": "",
"base_compensation": 154796,
"bonus": 6409,
"incentive": 0,
"other_compensation": 4642,
"non_fixed_compensation": "",
"first_class_travel": "",
"housing_allowance": "",
"expense_account_usage": "",
"supplemental_retirement": "Yes",
"lobbying_expenditures_direct": 0,
"lobbying_expenditures_grassroots": 0,
"election_501h_status": "",
"political_campaign_expenditures": 0,
"related_organizations_affiliates": "",
"investment_types": "",
"donor_restricted_endowment_values": 0,
"net_appreciation_depreciation": 281072,
"related_organization_transactions": "",
"loans_to_from_related_parties": "Loans to prior CEO Lynn Cook for life insurance, $1,400,000 total outstanding.",
"penalties_excise_taxes_reported": "",
"unrelated_business_income_disclosure": "Yes",
"foreign_bank_account_reporting": "No",
"schedule_o_narrative_explanations": "Our mission is to help our members improve their economic well-being and quality of life by being competitive, convenient, and cutting edge. Membership to Abbey is open to those who live, work, or worship, or attend school in Montgomery, Miami, Shelby, Darke, or Greene counties. Abbey Credit Union is owned by these people that open an account at Abbey CU. Yes - Each member gets one vote at the annual election. Yes - Pursuant to the regulation of the Ohio Division of Credit Unions. The VP of Finance prepared the Form 990 based on financial records. Financial statements are audited annually. The CEO reviews the returns prior to filing. The Credit Union has a written conflict of interest policy that states that board members are responsible for disclosing possible conflicts of interest as they arise. This is reviewed annually. Compensation for Dean Pielemeier, CEO is reviewed by the Personnel Committee of the board of directors and is acted upon by the board of directors per committee recommendation. Compensation for officers is determined by Dean Pielemeier, CEO. Documents are not made available to the public."
}
$20
extraction_timestamp
$26
2025-11-09T16:12:46.318554

Binary file not shown.