work project
Financial Analytics Platform
One ledger model behind forty banking, accounting, and ERP integrations.
- TypeScript
- NestJS
- Prisma
- PostgreSQL
- Redis
- OAuth2 / JWT
- Azure Blob Storage
- Azure Key Vault
- Docker
- GitHub Actions
- OpenTelemetry
- Built a multi-tenant financial analytics backend on NestJS and Prisma, normalizing 40+ banking, accounting, and ERP integrations into one internal ledger model.
- Shipped a formula-driven KPI engine covering 50+ customizable financial metrics, with benchmarking, trend analysis, and threshold alerts on top of a shared schema.
- Delivered an encrypted data room on Azure Blob with scoped external sharing, expiry, and full audit trails over tenant-isolated financial records.
What it was
A backend that let a business see its own financial position in one place. Bank feeds, accounting software, and ERP data went in; normalized statements, custom KPIs, scheduled reports, and a secure document room came out. It was multi-tenant from the start, because the customers were businesses and the data was their actual books.
What made it hard
Every financial provider is its own small country. Different auth model, different rate limit, different pagination scheme, different opinion on what a transaction is and which timestamp on it matters. OAuth tokens expire on their own schedule and refresh flows fail in provider-specific ways at 3 AM. A single integration is a week of work; forty of them is a maintenance surface that will quietly rot if nothing forces consistency.
Then the tenancy problem sits on top. This is other companies’ financial data in a shared database. There is no acceptable version of a query that returns one tenant’s numbers to another, and “we were careful” is not an architecture.
What I owned
I designed the core architecture on NestJS, Prisma, and PostgreSQL, and built the tenant isolation and role-based access model underneath it. I wrote the integration framework and the provider connectors, the KPI definition and calculation engine, the reporting and export pipeline, and the encrypted data room on Azure Blob with its sharing and audit controls. I also handled containerization and the deployment pipeline.
The decision I would defend
Normalize on write. Every provider payload got mapped into one internal ledger schema at ingestion time, rather than stored provider-native and translated at query time.
The tax is paid on every new integration: you cannot ship a connector until you have written and back-filled its mapping, and anything the provider knows that the internal model has no field for gets dropped at the door. That is a real loss and it is permanent. What it bought was that KPI formulas, reports, benchmarks, and trend analysis were written exactly once against one schema. The alternative — a translation layer consulted by every read path — makes each of those features N times more expensive and guarantees they drift.
Where it landed
40+ financial integrations across banking, accounting, and ERP providers, feeding a KPI engine covering 50+ customizable metrics, with templated reporting and multi-format export replacing what had been a manual month-end assembly job.
What I would do differently
Normalize on write was the right call. Discarding the raw payload was not. We stored only the normalized row, and it turned out that nearly every support escalation began with the same question — what did the provider actually send us? — which we could not answer without re-fetching, assuming the data was still in the provider’s retention window. Storing the raw response alongside the normalized record costs almost nothing and would have saved a great deal of time.
The other thing is that tenant isolation lived in a tenantId column plus
guards in the application layer. It held, but it held because every
developer remembered every time. That is a property of the team, not of the
system, and it degrades the moment the team grows. I would put the
guarantee in Postgres with row-level security so the database refuses the
cross-tenant read rather than trusting the query that asked for it.