PersonHog was the last of it, and it was not only a name. The package is personinsights and the types in it are already Person* -- the shipped descriptor under personinsights_client/proto/generated says PersonService and the tests say TestFakePersonClient -- but the .proto still declared PersonHogService. A client dialling one service name and a server built from a .proto declaring another is not a naming problem, it is a wire that does not connect; the only reason nobody hit it is that none of these services runs here yet. Five service declarations now say what the generated client has said all along. StreamHog was a second fictional company, invented so a Stripe mock had someone to bill. This fork already has Hedgebox, in five hundred fixtures, so the mock bills Hedgebox and there is one made-up company again instead of two. REVIEW_HOG was an enum member in three enums that had to agree, whose value has been `review` since the app was renamed. Only the member said otherwise. Every rule is in bin/debrand and every spelling is refused there, so a fresh upstream import lands on these names and none of them can come back. Two are named as outstanding rather than refused, with the reason each is still sayable: hogdesk survives inside applied migrations' choices lists, where Django compares it in Python and never writes it -- the member is Desk = "desk" and all three tables that store it hold no rows; sherlockhog keys a service in another repository that still calls itself that, and renaming our half would classify its traffic as a product that does not exist. Co-authored-by: Hanzo Dev <[email protected]>
Revenue Analytics Views
This module provides a flexible architecture for generating revenue analytics views from multiple data sources (events and external data warehouse sources like Stripe). It transforms raw data into standardized schemas for revenue analysis across different view types.
Architecture Overview
The system follows a builder pattern where:
- Sources define how to extract data from different systems (events, Stripe, etc.)
- Schemas define the standardized output format for each view type
- Orchestrator coordinates the process and builds concrete view instances
- Views are the final InsightsQL queries registered in the database schema
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌───────────────┐
│ Sources │───▶│ Builders │───▶│ Orchestrator │───▶│ View Objects │
│ │ │ │ │ │ │ │
│ • Events │ │ • Transform │ │ • Coordinates │ │ • InsightsQL Query │
│ • Stripe │ │ • Normalize │ │ • Applies │ │ • Schema │
│ • Other DW │ │ • Convert │ │ schemas │ │ • Metadata │
└─────────────┘ └──────────────┘ └─────────────────┘ └───────────────┘
Core Components
1. View Types (6 standardized views)
- Charge: Individual payment transactions
- Customer: Customer profiles and metadata
- MRR: View on the current MRR for customers
- Product: Product/service definitions
- Revenue Item: Line items from invoices/subscriptions
- Subscription: Recurring subscription data
2. Data Sources
- Events (
sources/events/): Transforms Insights events into revenue views using team-configured revenue events. - Stripe (
sources/stripe/)
3. Schema System
Each view type has a predefined schema (schemas/) that ensures consistency across sources:
# Example: Charge schema
FIELDS = {
"id": StringDatabaseField(name="id"),
"timestamp": DateTimeDatabaseField(name="timestamp"),
"customer_id": StringDatabaseField(name="customer_id"),
"amount": DecimalDatabaseField(name="amount"),
"currency": StringDatabaseField(name="currency"),
# ... plus currency conversion fields
}
Adding New Data Warehouse Sources
To extend the system with a new data warehouse source (e.g., Chargebee, RevenueCat, Polar, etc.), follow these steps:
Step 1: Add Source Type Support
Add your source type to orchestrator.py:
SUPPORTED_SOURCES: list[ExternalDataSourceType] = [
ExternalDataSourceType.STRIPE,
ExternalDataSourceType.CHARGEBEE, # Add your source
]
Step 2: Create Source Builders
Create a new directory sources/chargebee/ with builder modules for each view type:
sources/chargebee/
├── __init__.py
├── charge.py
├── customer.py
├── mrr.py
├── product.py
├── revenue_item.py
└── subscription.py
Step 3: Implement Builders
Each builder must implement the Builder function signature:
# sources/chargebee/charge.py
from typing import Iterable
from insights.insightsql import ast
from products.revenue_analytics.backend.views.core import BuiltQuery, SourceHandle, view_prefix_for_source
def build(handle: SourceHandle) -> Iterable[BuiltQuery]:
source = handle.source
if source is None:
return
# Find the relevant schema/table for this source
schemas = source.schemas.all()
charge_schema = next((s for s in schemas if s.name == "Charge"), None)
if charge_schema is None or charge_schema.table is None:
return
table = charge_schema.table
prefix = view_prefix_for_source(source)
# Build InsightsQL AST that transforms source data to match charge schema
query = ast.SelectQuery(
select=[
ast.Alias(alias="id", expr=ast.Field(chain=["id"])),
ast.Alias(alias="source_label", expr=ast.Constant(value=prefix)),
ast.Alias(alias="timestamp", expr=ast.Field(chain=["created_at"])),
ast.Alias(alias="customer_id", expr=ast.Field(chain=["customer_id"])),
# Map source fields to schema fields
# Handle currency conversion using helpers from sources.helpers
# ...
],
select_from=ast.JoinExpr(table=ast.Field(chain=[table.name])),
where=ast.CompareOperation(
left=ast.Field(chain=["status"]),
right=ast.Constant(value="paid"),
op=ast.CompareOperationOp.Eq,
),
)
yield BuiltQuery(key=str(table.id), prefix=prefix, query=query)
Step 4: Register Builders
Create the builder registry in sources/chargebee/__init__.py:
from insights.schema import DatabaseSchemaManagedViewTableKind
from products.revenue_analytics.backend.views.core import Builder
from .charge import build as charge_builder
from .customer import build as customer_builder
from .mrr import build as mrr_builder
from .product import build as product_builder
from .revenue_item import build as revenue_item_builder
from .subscription import build as subscription_builder
BUILDER: Builder = {
DatabaseSchemaManagedViewTableKind.REVENUE_ANALYTICS_CHARGE: charge_builder,
DatabaseSchemaManagedViewTableKind.REVENUE_ANALYTICS_CUSTOMER: customer_builder,
DatabaseSchemaManagedViewTableKind.REVENUE_ANALYTICS_MRR: mrr_builder,
DatabaseSchemaManagedViewTableKind.REVENUE_ANALYTICS_PRODUCT: product_builder,
DatabaseSchemaManagedViewTableKind.REVENUE_ANALYTICS_REVENUE_ITEM: revenue_item_builder,
DatabaseSchemaManagedViewTableKind.REVENUE_ANALYTICS_SUBSCRIPTION: subscription_builder,
}
Step 5: Register in Global Registry
Add your builder to sources/registry.py:
from products.revenue_analytics.backend.views.sources.chargebee import BUILDER as CHARGEBEE_BUILDER
BUILDERS: Dict[str, Builder] = {
"events": EVENTS_BUILDER,
"stripe": STRIPE_BUILDER,
"chargebee": CHARGEBEE_BUILDER, # Add this line
}
Implementation Guidelines
Currency Handling
Use the currency helpers from sources/helpers.py for proper currency conversion:
from products.revenue_analytics.backend.views.sources.helpers import (
currency_aware_divider,
currency_aware_amount,
)
# In your builder's select:
ast.Alias(alias="original_currency", expr=ast.Field(chain=["currency"])),
ast.Alias(alias="original_amount", expr=ast.Field(chain=["amount"])),
currency_aware_divider(), # Handles zero-decimal currencies
currency_aware_amount(), # Converts to proper decimal amount
Field Mapping
Each builder must map source fields to the standardized schema fields. Reference existing schemas in schemas/ to understand required fields.
Error Handling
- Return empty iterator if required schemas/tables don't exist
- Use graceful degradation - missing optional fields should not break the build
- Consider prefetching related data to avoid N+1 queries
Naming Conventions
- Source builders: Match the lowercase source type (e.g., "stripe", "chargebee")
- Table names: Match exact schema names from the external source
- View names: Auto-generated using
view_name_for_source()helper
Helper Functions
Currency Conversion
currency_aware_divider(): Handles zero-decimal currencies (JPY, KRW, etc.)currency_aware_amount(): Applies proper decimal conversionis_zero_decimal_in_stripe(): Checks if currency needs division
Naming
view_prefix_for_source(): Generates view prefix from source configview_name_for_source(): Full view name with suffix
Data Extraction
extract_json_string(): Extract nested JSON fieldsextract_json_uint(): Extract nested JSON numbersget_cohort_expr(): Generate cohort grouping expressions
Testing Your Implementation
Testing Architecture Overview
The testing system follows a structured approach with dedicated test suites for each source type. Each source has its own test directory with comprehensive coverage of builders, edge cases, and integration scenarios.
Test Directory Structure
sources/test/
├── base.py # Core testing infrastructure
├── events/ # Event source tests
│ ├── base.py # Events-specific base test class
│ ├── test_charge.py # Charge builder tests
│ ├── test_customer.py # Customer builder tests
│ ├── test_mrr.py # MRR builder tests
│ ├── test_product.py # Product builder tests
│ ├── test_revenue_item.py # Revenue item builder tests
│ ├── test_subscription.py # Subscription builder tests
│ └── __snapshots__/ # Query snapshots for regression testing
└── stripe/ # Stripe source tests
├── base.py # Stripe-specific base test class
├── test_stripe_charge.py # Stripe charge builder tests
├── test_stripe_customer.py # Stripe customer builder tests
├── test_stripe_mrr.py # Stripe MRR builder tests
├── test_stripe_product.py # Stripe product builder tests
├── test_stripe_revenue_item.py # Stripe revenue item builder tests
├── test_stripe_subscription.py # Stripe subscription builder tests
└── __snapshots__/ # Query snapshots for regression testing
Base Test Classes
1. Core Base Test (sources/test/base.py)
RevenueAnalyticsViewSourceBaseTest: Provides fundamental testing infrastructure- Includes Datastore query testing capabilities
- Query snapshot testing with
assertQueryMatchesSnapshot - API testing infrastructure
- Common assertion helpers for query structure validation
2. Source-Specific Base Tests
EventsSourceBaseTest: Specialized for event-based revenue analytics- Revenue analytics event configuration helpers
- Team base currency management
- Event clearing and setup utilities
StripeSourceBaseTest: Specialized for Stripe external data sources- Mock external data source and schema creation
- Stripe-specific test fixtures and helpers
- Currency validation and testing support
Testing Guidelines for New Sources
When adding a new source (e.g., Chargebee), follow this testing pattern:
1. Create Source Test Directory
mkdir sources/test/chargebee/
2. Create Base Test Class (sources/test/chargebee/base.py)
"""
Base test class for revenue analytics Chargebee source tests.
"""
from unittest.mock import Mock
from uuid import uuid4
from typing import List, Dict, Optional
from products.data_warehouse.backend.models import ExternalDataSchema, ExternalDataSource, DataWarehouseTable
from products.revenue_analytics.backend.views.core import SourceHandle
from products.revenue_analytics.backend.views.sources.test.base import RevenueAnalyticsViewSourceBaseTest
# Mock creation functions
def create_mock_chargebee_external_data_source(team, schemas: Optional[List[str]] = None):
"""Create mock external data source for Chargebee with specified schemas."""
# Implementation similar to Stripe pattern
pass
class ChargebeeSourceBaseTest(RevenueAnalyticsViewSourceBaseTest):
"""Base test class for Chargebee source revenue analytics tests."""
def setup_chargebee_external_data_source(self, schemas: Optional[List[str]] = None):
"""Create a mock Chargebee external data source with specified schemas."""
pass
def get_chargebee_schema_by_name(self, schema_name: str):
"""Get a specific schema by name from the external data source."""
pass
3. Create Individual Builder Tests
Each builder should have comprehensive test coverage:
# sources/test/chargebee/test_chargebee_charge.py
from products.revenue_analytics.backend.views.sources.chargebee.charge import build
from products.revenue_analytics.backend.views.sources.test.chargebee.base import ChargebeeSourceBaseTest
from products.revenue_analytics.backend.views.schemas.charge import SCHEMA as CHARGE_SCHEMA
class TestChargeChargebeeBuilder(ChargebeeSourceBaseTest):
def test_build_charge_query_with_charge_schema(self):
"""Test building charge query when charge schema exists."""
# Happy path testing
pass
def test_build_with_no_charge_schema(self):
"""Test that build returns empty when no charge schema exists."""
# Error case testing
pass
def test_build_with_no_source(self):
"""Test that build returns empty when source is None."""
# Edge case testing
pass
def test_charge_query_contains_required_fields(self):
"""Test that the generated query contains all required charge fields."""
# Field validation testing
pass
Snapshot Testing
Use snapshot testing for regression protection on generated InsightsQL queries:
def test_build_charge_query_snapshot(self):
"""Test that generated query matches expected structure."""
self.setup_chargebee_external_data_source(schemas=[CHARGE_RESOURCE_NAME])
queries = list(build(self.chargebee_handle))
charge_query = queries[0]
query_sql = charge_query.query.to_insightsql()
self.assertQueryMatchesSnapshot(query_sql, replace_all_numbers=True)
Assertion Helpers
Use the provided assertion helpers for consistent testing:
# Test query structure
self.assertBuiltQueryStructure(
built_query,
expected_key,
expected_prefix
)
# Test schema compliance
self.assertQueryContainsFields(query, schema)
# Test currency handling
self.set_team_base_currency("EUR")
# ... test currency conversion logic
Running Tests
1. Orchestrator Tests
pytest products/revenue_analytics/backend/views/test/test_orchestrator.py -v
2. Source-Specific Tests
# Test all events source builders
pytest products/revenue_analytics/backend/views/sources/test/events/ -v --snapshot-update
# Test all Stripe source builders
pytest products/revenue_analytics/backend/views/sources/test/stripe/ -v --snapshot-update
# Test specific builder
pytest products/revenue_analytics/backend/views/sources/test/stripe/test_stripe_charge.py -v --snapshot-update
3. InsightsQL Query Integration Tests
These are useful to know whether changes in your code produced any change on the output queries
pytest products/revenue_analytics/backend/views/test/ -v --snapshot-update
pytest insights/insightsql/database/schema/test/test_persons_revenue_analytics.py insights/insightsql/database/schema/test/test_groups_revenue_analytics.py -v --snapshot-update
Testing your implementation via queries
The view tests above are currently only testing Stripe and events. It's usually a wise idea to extend them with your new source.
View Registration
Views are automatically registered in Insights's InsightsQL database schema through the orchestrator. The system will:
- Discover your source through
ExternalDataSourcerecords - Run your builders for each supported view type
- Apply the standardized schema to your generated queries
- Register views with names like
{source_type}.{prefix}.{view_suffix}
Example view names:
chargebee.production.charge_revenue_viewchargebee.customer_revenue_view
The prefix comes from the ExternalDataSource.prefix field, allowing multiple instances of the same source type - and, of course, it can be empty.