= z.object({
id: z.string().uuid(),
name: z.string().min(1),
slug: z.string().regex(/^[a-z0-9_]+$/),
unit: z.enum(['currency', 'count', 'percentage', 'duration']),
source: z.enum(['stripe', 'posthog', 'github', 'custom']),
calculation: z.string(), // SQL or formula reference
isPublic: z.boolean().default(false),
lastCalculated: z.date().nullable(),
});
export type MetricDefinition = z.infer<typeof MetricDefinition>;
export const InvestorUpdate = z.object({
id: z.string().uuid(),
period: z.string().regex(/^\d{4}-\d{2}$/), // YYYY-MM
narrative: z.string().max(5000),
metricsSnapshot: z.record(z.string(), z.number()), // Metric slug -> value
attachments: z.array(z.object({
url: z.string().url(),
type: z.enum(['pdf', 'csv', 'link']),
accessLevel: z.enum(['all', 'board', 'specific']),
})),
publishedAt: z.date(),
version: z.number().default(1),
});
export type InvestorUpdate = z.infer<typeof InvestorUpdate>;
#### Step 2: Build the Metrics Aggregation Pipeline
Create a service that fetches data from source systems and maps it to the standardized schema. This service should run on a scheduled cron job or be triggered by webhooks.
```typescript
// src/ir/metrics-aggregator.ts
import { MetricDefinition, MetricDefinitionSchema } from './schema';
import { stripeClient } from '../integrations/stripe';
import { posthogClient } from '../integrations/posthog';
export class MetricsAggregator {
async aggregateMetric(metric: MetricDefinition): Promise<number> {
switch (metric.source) {
case 'stripe':
return this.fetchStripeMetric(metric);
case 'posthog':
return this.fetchPosthogMetric(metric);
case 'custom':
return this.fetchCustomMetric(metric);
default:
throw new Error(`Unsupported source: ${metric.source}`);
}
}
private async fetchStripeMetric(metric: MetricDefinition): Promise<number> {
// Example: Fetching MRR
if (metric.slug === 'mrr') {
const subscriptions = await stripeClient.subscriptions.list({ status: 'active' });
return subscriptions.data.reduce((acc, sub) => acc + (sub.plan.amount || 0), 0) / 100;
}
throw new Error(`Stripe metric ${metric.slug} not implemented`);
}
private async fetchPosthogMetric(metric: MetricDefinition): Promise<number> {
// Example: Fetching DAU
if (metric.slug === 'dau') {
const result = await posthogClient.query({
event: 'pageview',
interval: 'day',
properties: { distinct_id: { $exists: true } }
});
return result.length;
}
throw new Error(`PostHog metric ${metric.slug} not implemented`);
}
}
Step 3: Implement the IR Service with Auditability
The service layer orchestrates updates, manages versioning, and writes to the audit log.
// src/ir/ir-service.ts
import { InvestorUpdate, InvestorUpdateSchema } from './schema';
import { db } from '../db';
import { auditLogger } from '../logging/audit';
export class IRService {
async publishUpdate(update: InvestorUpdate): Promise<void> {
const validatedUpdate = InvestorUpdateSchema.parse(update);
// Check for version conflicts
const existing = await db.investorUpdates.findFirst({
where: { period: validatedUpdate.period }
});
if (existing) {
validatedUpdate.version = existing.version + 1;
await auditLogger.log({
action: 'UPDATE_OVERWRITE',
period: validatedUpdate.period,
previousVersion: existing.version,
newVersion: validatedUpdate.version,
timestamp: new Date(),
});
}
// Transactional write to ensure data integrity
await db.$transaction([
db.investorUpdates.upsert({
where: { id: validatedUpdate.id },
create: validatedUpdate,
update: validatedUpdate,
}),
db.investorNotifications.create({
data: {
updateId: validatedUpdate.id,
sentAt: new Date(),
status: 'pending',
}
})
]);
await auditLogger.log({
action: 'UPDATE_PUBLISHED',
period: validatedUpdate.period,
version: validatedUpdate.version,
timestamp: new Date(),
});
}
async getInvestorAccess(investorId: string): Promise<InvestorUpdate[]> {
// RBAC enforcement: Filter updates based on investor tier and permissions
const investor = await db.investors.findUnique({ where: { id: investorId } });
if (!investor) throw new Error('Investor not found');
return db.investorUpdates.findMany({
where: {
OR: [
{ 'metricsSnapshot.isPublic': true },
{ attachments: { every: { accessLevel: 'all' } } },
{ investorId: investorId }, // Specific access
]
},
orderBy: { publishedAt: 'desc' }
});
}
}
Step 4: Secure API Endpoints
Expose the IR data via a GraphQL or REST API with strict authentication. Investors use API keys scoped to their organization.
// src/api/ir-router.ts
import { Router } from 'express';
import { authenticateInvestor } from '../middleware/auth';
import { IRService } from '../ir/ir-service';
const router = Router();
const irService = new IRService();
router.get('/updates', authenticateInvestor, async (req, res) => {
try {
const updates = await irService.getInvestorAccess(req.investor.id);
res.json({ data: updates, meta: { count: updates.length } });
} catch (error) {
res.status(500).json({ error: 'Failed to fetch updates' });
}
});
router.get('/metrics/:slug', authenticateInvestor, async (req, res) => {
// Endpoint for real-time metric retrieval
// Implementation depends on caching strategy (Redis) for performance
});
export default router;
Pitfall Guide
1. Metric Drift and Inconsistency
Mistake: Allowing manual overrides of calculated metrics without documentation.
Impact: Investors detect discrepancies between updates, leading to loss of trust and potential legal exposure.
Best Practice: Lock metric calculations in code. If a metric definition changes, increment the version and notify investors of the methodology change. Never silently alter historical values.
2. Hardcoded Business Logic
Mistake: Embedding metric formulas directly in API endpoints or frontend components.
Impact: Duplication, calculation errors across different views, and difficulty in updating logic.
Best Practice: Centralize all metric logic in the MetricsAggregator service. Use a configuration-driven approach where formulas are stored in a database or config file, not scattered in code.
3. Inadequate RBAC Implementation
Mistake: Using a single "admin" key for all investors or failing to segregate data at the query level.
Impact: One investor accessing another's confidential data or seeing internal board-only notes.
Best Practice: Implement row-level security in the database and enforce access checks in the service layer. Use scoped API keys that expire and can be rotated instantly.
4. Ignoring Negative Trends in Automation
Mistake: Building a dashboard that only highlights positive metrics or suppressing negative data via filters.
Impact: Investors feel blindsided by bad news. Automated systems should surface anomalies, not hide them.
Best Practice: Configure the system to flag significant deviations (e.g., MRR drop > 5%) and require a narrative explanation before publishing. Automation should enforce transparency, not filter reality.
5. Lack of Audit Trails
Mistake: Overwriting update records without preserving history.
Impact: Inability to reconstruct the state of the company during due diligence. Investors may suspect retroactive manipulation.
Best Practice: Maintain an immutable append-only log for all updates. Use database triggers or application-level events to record every change, including who made it and when.
6. Over-Engineering the UI vs. Data Quality
Mistake: Spending weeks building a polished investor portal while the underlying data pipeline is fragile.
Impact: A beautiful dashboard with stale or incorrect data is worse than a simple email with accurate numbers.
Best Practice: Prioritize the data pipeline, schema validation, and accuracy. The UI can be a minimal dashboard or even a well-formatted API response initially. Data integrity is the product.
7. Notification Fatigue
Mistake: Sending real-time alerts for every minor metric fluctuation.
Impact: Investors ignore updates, missing critical signals.
Best Practice: Implement threshold-based notifications. Only alert on significant events or scheduled periodic updates. Allow investors to configure their notification preferences via the API.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Pre-Seed / Early Seed | Manual Updates + Shared Drive | Low volume, high relationship focus; automation overhead outweighs benefits. | Low dev cost, high founder time. |
| Seed to Series A | Automated IR API + Basic Dashboard | Volume increases; need for consistency and auditability grows. | Medium dev cost, high efficiency gain. |
| Series A+ / High Growth | Full IR Platform with Real-time Data | Investor base expands; due diligence requires robust data pipelines and RBAC. | High dev cost, critical for fundraising velocity. |
| Regulated Industry | Immutable Ledger + Compliance Module | Legal requirements for data immutability and access logging. | High dev cost, necessary for compliance. |
Configuration Template
Copy this template to initialize the IR configuration in your project.
// src/config/ir.config.ts
export const IRConfig = {
metrics: {
mrr: {
slug: 'mrr',
unit: 'currency',
source: 'stripe',
threshold: { alert: 0.05, critical: 0.10 }, // 5% and 10% deviation alerts
isPublic: true,
},
churn: {
slug: 'churn',
unit: 'percentage',
source: 'stripe',
threshold: { alert: 0.02, critical: 0.05 },
isPublic: true,
},
dau: {
slug: 'dau',
unit: 'count',
source: 'posthog',
threshold: { alert: 0.10, critical: 0.20 },
isPublic: false, // Internal only
},
},
updateFrequency: 'monthly',
retentionDays: 365 * 10, // 10 years for audit
auditLogEnabled: true,
rbac: {
tiers: ['board', 'lead', 'participant', 'observer'],
defaultAccess: 'participant',
},
};
Quick Start Guide
-
Initialize Project:
npx create-next-app@latest startup-ir --typescript --tailwind --app
cd startup-ir
npm install zod prisma @prisma/client express
npx prisma init
-
Setup Database Schema:
Add models to prisma/schema.prisma for Investor, Update, and AuditLog. Run npx prisma migrate dev.
-
Copy Core Modules:
Paste the schema definitions, aggregator service, and IR service code from the Core Solution into your src/ir directory.
-
Run Seed Script:
Create scripts/seed.ts to populate initial metrics and a test investor. Execute with npx tsx scripts/seed.ts.
-
Start Development:
Run npm run dev. Access the IR dashboard at http://localhost:3000/ir. Verify data ingestion by triggering a mock metric update via the API.
This architecture provides a robust, scalable foundation for investor relations, transforming a manual process into a secure, automated product that enhances trust and operational efficiency.