Analytic Service
analytic-service is the backend owner of rAInty analytics.
It owns analytics semantics and derived analytics execution. It does not replace data-service as the frontend-facing authorization boundary.
Ownership
analytic-service owns:
- analytics domain rules
- inclusion and exclusion rules for economic facts
- aggregation logic
- derived read model generation
- access to the
rainty_analyticsschema - analytics cache keys and invalidation rules
- scheduled or event-driven rebuild workflows
- canonical analytics response shapes for internal service-to-service use
Analytics rules must not be implemented page by page in UserInterface and must not be embedded directly inside data-service.
Boundary with data-service
data-service remains the only frontend-facing business data boundary for analytics.
data-service must:
- authenticate frontend requests
- derive the logged-in user from the token
- enforce ownership and sharing rules
- call
analytic-servicethrough internal service-to-service APIs - return only analytics data the logged-in user is allowed to access
Frontend-facing routes exposed by data-service:
GET /data-service/analytics/overviewGET /data-service/analytics/properties/:id/summaryGET /data-service/analytics/contracts/:id/healthGET /data-service/analytics/bank-accounts/:id/summaryGET /data-service/analytics/report
These routes live in backend/data-service/routes/analytics.js and delegate to
backend/data-service/modules/analytics/analyticsFacade.js.
analytic-service must not trust user identifiers supplied by a browser. User-scoped analytics requests must arrive through data-service, which is responsible for authorization.
Boundary with UserInterface
UserInterface must call only data-service for analytics data.
UserInterface must not call analytic-service directly, even when the requested data is read-only or already aggregated.
This keeps:
- authentication behavior centralized
- ownership filtering consistent
- frontend deployments independent from internal analytics service topology
- future replacement of
analytic-servicepossible without changing UserInterface contracts
Contract principle
analytic-service and data-service must exchange canonical analytics models, not visualization-provider payloads.
The backend contract must stay independent from frontend chart libraries and from any future market analytics engine.
Internal API contract
analytic-service exposes internal analytics APIs under:
POST /internal/analytics/overviewPOST /internal/analytics/properties/:propertyId/summaryPOST /internal/analytics/contracts/:contractId/summaryPOST /internal/analytics/contracts/:contractId/healthPOST /internal/analytics/bank-accounts/:bankAccountId/summaryPOST /internal/analytics/report
These endpoints are internal service-to-service APIs. They are not UserInterface APIs.
Authentication requirements:
- caller must use an internal JWT signed through
backend/shared/internalAuth.js - token audience must be
analytic-service - token scope must include
analytics:read - caller identity must be
data-service data-servicemust receiveINTERNAL_JWT_PRIVATE_KEYanalytic-servicemust receive the matchingINTERNAL_JWT_PUBLIC_KEY
Request body shape:
{
"authorizationContext": {
"authorizedBy": "data-service",
"requestId": "optional-correlation-id",
"subject": {
"userId": 123,
"partyId": 456
},
"accessScope": {
"propertyIds": [10, 11],
"contractIds": [20],
"bankAccountIds": [30],
"supplierPartyIds": [40]
}
},
"filters": {
"from": "2026-01-01",
"to": "2026-12-31"
},
"dimensions": ["month", "property"],
"metrics": ["income", "expenses", "net"]
}
Rules:
- top-level
userIdis forbidden authorizationContext.subject.userIdmust be derived bydata-service, never copied from a browser payloadaccessScopemust contain only entity identifiers already authorized bydata-service- route parameters such as
propertyId,contractId, andbankAccountIdmust be positive integers - date filters must use
YYYY-MM-DD - business-specific analytics execution belongs in dedicated modules, not route handlers
Implementation rule:
backend/analytic-service/routes/internalAnalyticsRoutes.jsmust perform only internal authentication, contract validation, module lookup, and HTTP response/error mapping- analytics execution must be delegated to
analyticsQueryService - route handlers must not contain aggregation rules, inclusion/exclusion decisions, SQL, cache semantics, or chart-provider adaptation
- adding a new internal analytics endpoint requires adding the contract validation and delegating to a dedicated module method
Canonical response payload
Every successful analytics response must use the same canonical top-level shape:
{
"summary": [],
"facts": [],
"series": [],
"breakdown": [],
"alerts": [],
"meta": {
"schemaVersion": "analytics-canonical-v1",
"generatedAt": "2026-05-13T00:00:00.000Z",
"currency": "EUR"
}
}
summary
summary contains KPI-style values ready for cards or compact totals.
Recommended item shape:
{
"id": "net_cashflow",
"label": "Net cashflow",
"value": 1250.5,
"unit": "currency",
"currency": "EUR",
"period": {
"from": "2026-01-01",
"to": "2026-12-31"
},
"trend": {
"direction": "up",
"delta": 120.25,
"deltaPercent": 10.6,
"comparisonPeriod": "previous_period"
}
}
facts
facts contains canonical analytics facts after backend inclusion and exclusion rules have been applied.
Recommended item shape:
{
"id": "fact-123",
"direction": "INCOME",
"amount": 1200,
"currency": "EUR",
"competenceDate": "2026-01-31",
"paymentDate": "2026-02-02",
"propertyId": 10,
"contractId": 20,
"bankAccountId": 30,
"categoryId": 40,
"source": {
"type": "bank_transaction_split",
"id": 500
}
}
series
series contains chart-neutral time or category series.
Recommended item shape:
{
"id": "cashflow_monthly",
"label": "Cashflow",
"dimension": "month",
"measure": "net",
"points": [
{ "x": "2026-01", "y": 1200 },
{ "x": "2026-02", "y": 900 }
]
}
breakdown
breakdown contains grouped values by dimension.
Recommended item shape:
{
"dimension": "category",
"id": "utilities",
"label": "Utilities",
"value": 430.2,
"unit": "currency",
"currency": "EUR",
"count": 6,
"share": 0.24
}
alerts
alerts contains analytics-derived warnings and actionable states.
Recommended item shape:
{
"id": "contract-20-overdue",
"type": "contract_payment_overdue",
"severity": "warning",
"messageKey": "analytics.alerts.contractPaymentOverdue",
"entity": {
"type": "contract",
"id": 20
},
"dueDate": "2026-02-05",
"amount": 1200,
"currency": "EUR"
}
meta
meta contains response metadata and diagnostics that are safe to expose through data-service.
Recommended fields:
schemaVersiongeneratedAtcurrencyperiodfilterssourcecache
Vendor independence
Canonical analytics payloads must not contain chart-provider or analytics-vendor specific configuration.
Forbidden examples:
Chart.jsdatasetsreact-chartjs-2component propsEChartsoptionsRechartscomponent models- vendor query language responses
chartOptionschartType
UserInterface may adapt series and breakdown into chart-library models inside analytics page adapters only.
Economic inclusion and exclusion rules
analytic-service must build economic facts only from economically validated source records.
Included source records:
- bank transactions with
is_reconciled = 1,reconciliation_statusinAUTO_RECONCILEDorMANUALLY_RECONCILED, andreconciliation_outcomeinVALID,SPLIT, orPARTIAL - bank transaction splits whose parent transaction satisfies the same reconciled transaction rule and whose split source is
RECONCILIATION_RULE,EXPECTED_MATCH,RENT_MATCH, orMANUAL - manual payments that are explicitly
CONFIRMEDorPAIDand linked to an expected expense, utility bill, rent installment, or equivalent payable item - expenses created by valid flows and promoted to
CONFIRMEDorPAID
Excluded source records:
- draft or pending operational records
- ignored transactions, even when they are operationally closed
- unreconciled transactions
- transactions in
AI_SUGGESTED,NEEDS_REVIEW,DUPLICATE, orERROR - transactions with
IGNORE,DUPLICATE, orUNKNOWNreconciliation outcomes - cancelled, disputed, duplicate, or error operational records
The implementation lives in backend/analytic-service/modules/analytics/inclusionRules.js.
economicFactsBuilder.js must reject any fact that is not explicitly marked as economically validated or cannot be validated from its source record.
Persistence rule
rainty_analytics is a derived read-model schema only.
It may store:
- canonical economic facts derived from confirmed operational records
- read models
- aggregate buckets
- health projections
- overdue projections
- refresh and rebuild logs
It must not store:
- operational source-of-truth records
- browser-submitted business data
- ownership or authorization source tables
- user settings
- data that cannot be rebuilt from the
raintyoperational schema
If a table in rainty_analytics cannot be regenerated from operational data and deterministic analytics rules, it does not belong in rainty_analytics.
Repository layer
analytic-service uses dedicated repositories for derived analytics persistence.
Current repository files:
backend/analytic-service/repositories/economicFactsRepository.jsbackend/analytic-service/repositories/contractHealthRepository.jsbackend/analytic-service/repositories/monthlyAggregatesRepository.js
Repository responsibilities:
- execute parameterized SQL against
rainty_analytics - upsert derived read model rows
- list rows by already-authorized user scope
- delete stale derived rows by deterministic keys
Repository non-responsibilities:
- deriving analytics rules
- deciding ownership or sharing
- accepting browser-provided user identifiers
- creating operational source-of-truth records
- returning chart-provider payloads
The repository layer is initialized by backend/analytic-service/modules/main.js and exposed through service.getRepositories().
Business modules
Analytics business logic lives in dedicated modules under backend/analytic-service/modules/analytics/.
Current modules:
economicFactsBuilder.js: builds canonical economic facts from already validated analytics inputs.inclusionRules.js: applies economic inclusion and exclusion rules before facts can be persisted.contractHealthBuilder.js: derives contract health status from scheduled and paid installment data.monthlyAggregationBuilder.js: builds single-dimension monthly aggregate buckets from canonical facts.analyticsQueryService.js: composes repositories into canonical analytics payloads for internal endpoints.
Business module responsibilities:
- apply deterministic analytics rules
- prepare derived read models
- compose canonical payload sections
- keep chart-provider and vendor-specific models out of backend responses
Business module non-responsibilities:
- frontend authorization
- ownership or sharing decisions
- raw browser request parsing
- SQL persistence details
- UI-specific chart adaptation
Current internal route delegation:
POST /internal/analytics/overviewdelegates toanalyticsQueryService.getOverviewPOST /internal/analytics/properties/:propertyId/summarydelegates toanalyticsQueryService.getPropertySummaryPOST /internal/analytics/contracts/:contractId/healthdelegates toanalyticsQueryService.getContractHealthPOST /internal/analytics/bank-accounts/:bankAccountId/summarydelegates toanalyticsQueryService.getBankAccountSummaryPOST /internal/analytics/reportdelegates toanalyticsQueryService.getReport
Refresh and rebuild pipeline
analytic-service owns refresh and rebuild orchestration for derived analytics
read models.
It subscribes to:
<ENV>.analytics.refresh
Accepted event shape:
{
"type": "analytics.refresh.requested",
"reason": "reconciliation.manual_applied",
"scope": {
"type": "TRANSACTION",
"id": 123
},
"impacted": {
"userId": 2,
"propertyId": 10,
"contractId": 20,
"bankAccountId": 30,
"supplierPartyId": 40,
"categoryId": 50,
"months": ["2026-05"]
},
"requestedBy": {
"service": "reconciliation-service"
}
}
Initial trigger reasons:
reconciliation.auto_appliedreconciliation.manual_appliedreconciliation.split_changedreconciliation.resetexpected_expense.status_changedutility_bill.status_changedrent_installment.status_changedmanual_payment.registeredanalytics.scheduled_partial_rebuildanalytics.admin_full_rebuild
Scheduled all-users rebuild:
schedulerregisters the system jobanalytics-rebuild-all-users- default cron is
0 * * * *, so the job runs once per hour - AdminInterface shows it in the Scheduler system jobs page through
GET /scheduler/system-jobs - runtime settings are
ANALYTICS_REBUILD_ALL_USERS_ENABLED,ANALYTICS_REBUILD_ALL_USERS_CRON, andANALYTICS_REBUILD_ALL_USERS_TIMEZONE - the scheduler calls
POST /internal/analytics/refresh/all-users - the internal token must have audience
analytic-service, callerscheduler, and scopeanalytics:refresh analytic-servicethen queues one boundedUSERrefresh event per active non-service user on<ENV>.analytics.refresh
Bounded rebuild scopes:
TRANSACTIONBANK_TRANSACTION_SPLITPROPERTYCONTRACTBANK_ACCOUNTEXPECTED_EXPENSEUTILITY_BILLUSER
GLOBAL scope is rejected for normal events. It is accepted only for an
explicit admin full rebuild command with triggerType: "FULL_REBUILD",
adminConfirmed: true, and an admin/DEV execution context.
Refresh planning responsibilities:
- identify impacted read models: facts, monthly aggregates, contract health, overdue items
- keep rebuilds bounded to the smallest available entity scope
- record every refresh run in
analytics_refresh_runs - expose cache tags that must be invalidated when concrete rebuild workers are attached
Implementation files:
backend/analytic-service/modules/analytics/refreshEvents.jsbackend/analytic-service/modules/analytics/refreshPlanner.jsbackend/analytic-service/modules/analytics/analyticsRefreshService.jsbackend/analytic-service/repositories/refreshRunsRepository.jsbackend/analytic-service/events.manifest.json
Refresh planning now drives concrete workers for fact generation, monthly aggregates, contract health, and targeted cache invalidation. The overdue-item worker is the next read-model worker to attach to the generated plan.
Economic fact generation
The first concrete rebuild worker generates analytics_economic_facts from
operational source tables in the rainty schema.
Implementation files:
backend/analytic-service/modules/db/operationalDatabase.jsbackend/analytic-service/modules/analytics/economicFactsSourceReader.jsbackend/analytic-service/modules/analytics/economicFactsGenerationService.jsbackend/analytic-service/modules/analytics/monthlyAggregatesRefreshService.jsbackend/analytic-service/modules/analytics/contractHealthSourceReader.jsbackend/analytic-service/modules/analytics/contractHealthRefreshService.js
Operational sources:
bank_transaction_splitsrent_payment_matchesexpense_payment_matchesutility_bill_payment_matches- confirmed or paid manual
expenseswithout a linked bank transaction
Rules:
- only economically valid reconciled source rows are converted into facts
- source rows are converted through
economicFactsBuilder, so inclusion/exclusion rules still apply before persistence - facts are generated per authorized user context derived from property, contract, or bank account relationships
- split facts take precedence over rent and expected-expense payment matches for the same transaction and payable item, avoiding double counting
- every bounded refresh deletes facts for the affected scope before inserting regenerated facts
- every bounded refresh rebuilds monthly aggregate buckets touched by old or new facts
GLOBALrebuild remains admin-only and is not used by normal operational events
Generated fact examples:
BANK_TRANSACTION_SPLIT-> income or expense based on the parent transaction directionRENT_PAYMENT_MATCH-> incomeEXPENSE_PAYMENT_MATCH-> expenseUTILITY_BILL_PAYMENT-> expenseMANUAL_EXPENSE_PAYMENT-> expense
Monthly aggregates are refreshed from generated facts, not directly from operational tables. The refresh deletes impacted buckets and upserts rebuilt aggregates for these dimensions:
PROPERTYCATEGORYBANK_ACCOUNTCONTRACTSERVICE_TYPE
Contract health generation
analytics_contract_health is rebuilt from operational obligations and actual
payments.
Operational sources:
rent_installments- contract-linked
expected_expenses rent_payment_matchesexpense_payment_matchesbank_transaction_splits
Rules:
- only valid reconciled payments contribute to paid amounts
- valid splits take precedence over direct rent or expected-expense matches for the same transaction and payable item
PAIDandMATCHEDstatuses are used as a fallback only when no payment rows exist- cancelled obligations are ignored by
contractHealthBuilder - rebuilds stay bounded to impacted contracts derived from the refresh scope
Implementation files:
backend/analytic-service/modules/analytics/contractHealthSourceReader.jsbackend/analytic-service/modules/analytics/contractHealthRefreshService.jsbackend/analytic-service/modules/analytics/contractHealthBuilder.jsbackend/analytic-service/repositories/contractHealthRepository.js
Operational schema configuration:
OPERATIONAL_MYSQL_DATABASE, fallbackMYSQL_DATABASE, defaultraintyOPERATIONAL_MYSQL_HOST, fallbackMYSQL_HOSTOPERATIONAL_MYSQL_PORT, fallbackMYSQL_PORTOPERATIONAL_MYSQL_USER, fallbackMYSQL_USEROPERATIONAL_MYSQL_PASSWORD, fallbackMYSQL_PASSWORD
Analytics read cache
analytic-service may use Redis as a read-through cache for common analytics
reads after the first complete query path is working.
Redis cache rules:
- Redis is not source of truth
- cached payloads must be rebuildable from
rainty_analytics - cache read or write failures must fall back to repository reads
- cache keys must include the authorized user and a hash of filters plus
authorizationContext.accessScope - cached responses must remain canonical analytics payloads
- cached payloads must not contain chart-provider configuration
Default key patterns:
analytics:overview:user:<id>:<scope-hash>analytics:property:<id>:summary:user:<id>:<scope-hash>analytics:contract-health:user:<id>:contract:<id>:<scope-hash>analytics:bank-account:<id>:summary:user:<id>:<scope-hash>analytics:report:user:<id>:<scope-hash>
The implementation lives in backend/analytic-service/modules/analytics/analyticsCache.js.
analyticsQueryService owns cache usage because it is the read composition layer.
analyticsRefreshService owns invalidation after successful rebuilds.
Invalidation rules:
- refresh plans emit targeted
analytics:*cache tags AnalyticsCachedeletes matching Redis keys by prefix because concrete keys include a scope hash- invalidation runs after facts, monthly aggregates, or contract health are rebuilt
- full Redis flushes are not allowed
Configuration:
ANALYTICS_CACHE_ENABLED, defaulttrueANALYTICS_CACHE_TTL_SECONDS, default120
Deployment note
The current environment-sync-service schema migration flow cannot create the initial rainty_analytics schema by itself because datahub currently rejects CREATE DATABASE, USE, GRANT, and schema-qualified CREATE TABLE statements.
Before TEST can rely on environment-sync-service for this migration, adapt the datahub schema migration runner to support guarded cross-schema additive DDL, or execute the initial schema/grant setup through a DBA/deploy-admin step.
TEST deployment checks:
- Verify the
analytic-serviceimage is built and published withANALYTIC_SERVICE_VERSION. - Verify
docker-compose.test.ymlincludes theanalytic-serviceprofile and thatCOMPOSE_PROFILESincludesanalytic-service. - Verify the TEST environment has both internal JWT secrets configured:
INTERNAL_JWT_PRIVATE_KEYis mounted only intodata-service.INTERNAL_JWT_PUBLIC_KEYis mounted only intoanalytic-service.- the key pair must match; otherwise
analytic-servicemust rejectdata-serviceinternal calls.
- Verify
data-serviceusesANALYTIC_SERVICE_URL=http://analytic-service:3013. - Before starting or refreshing
analytic-servicein TEST, createrainty_analyticsand grants with the admin migration step:
CREATE DATABASE IF NOT EXISTS `rainty_analytics`;
GRANT ALL PRIVILEGES ON `rainty_analytics`.* TO 'rainty_user'@'%';
FLUSH PRIVILEGES;
- Apply the analytics DDL from
db/migrations/001_analytic_service_schema.sqland later additive analytics migrations against TEST. - Verify the schema exists before refresh:
SHOW DATABASES LIKE 'rainty_analytics';
SHOW TABLES FROM `rainty_analytics`;
- Verify
analytic-servicehealth after deploy:
docker compose --env-file .env -f docker-compose.test.yml -p test ps analytic-service
docker logs --tail=100 test-analytic-service-1
- Verify the frontend-facing path end-to-end through
data-service, not by callinganalytic-servicedirectly from UserInterface.
See also: Analytics Implementation Plan.