How I model TFSA, FHSA and RRSP contribution room in MapleWealth: ledgers instead of balances, carry-forward, re-contribution timing and the edge cases.

If you are building software that tracks Canadian registered accounts, the most important decision is this: contribution room is not a balance, it is the result of a ledger of events. This post walks through how I approached TFSA, FHSA and RRSP modeling while building MapleWealth, the rules that shape the data model, and the edge cases that catch generic finance apps out.
It is written for developers and technical founders. It is not financial or tax advice; see the note at the end.
Why don't generic finance apps handle Canadian accounts well?
Most consumer finance tools are designed around a US audience. They model a savings account as one bucket with a balance, and that is enough to draw a net worth chart. It is not enough to answer the questions Canadians actually ask: how much can I still put into my TFSA this year, will this FHSA contribution reduce my taxes, and what happens if I withdraw now?
Those answers depend on rules that are different for each account type, depend on time, and depend on history. A balance tells you none of that. Two people with $20,000 in a TFSA can have completely different amounts of room left, because room depends on when they turned 18, what they contributed in which years, and when they took money out.
MapleWealth is a private-first personal finance dashboard I built with Next.js 16, TypeScript, Prisma, PostgreSQL and Better Auth. The core idea was to treat TFSA, FHSA and RRSP as first-class account types with their own rules, instead of generic savings buckets with different labels.
The rules the data model has to respect
Before writing any schema, I wrote down the mechanics for each account in plain language. This is the short version, current as of 2026. Always confirm against the Canada Revenue Agency, because limits are indexed or changed by budget announcements.
TFSA: room that comes back, but not right away
TFSA room starts accumulating in the year you turn 18 (or 2009, when the TFSA was introduced, whichever is later), as long as you are a Canadian resident with a valid SIN. Every year, the annual limit set by CRA is added to your room, and unused room carries forward indefinitely.
Contributions are not deductible, but growth and withdrawals are tax-free. The detail that matters most for software: when you withdraw, that amount is added back to your room on January 1 of the following calendar year, not immediately. Re-contributing in the same year without enough unused room creates an over-contribution, and CRA charges a tax of 1% per month on the highest excess amount in each month it remains.
For modeling, that means a withdrawal is not simply negative contribution. It is an event that creates future room with an effective date of the next January 1.
FHSA: annual limit, lifetime cap and limited carry-forward
The First Home Savings Account combines features of both other accounts. Contributions are deductible like an RRSP, and a qualifying withdrawal to buy a first home is tax-free like a TFSA. The rules that drive the data model are:
- An annual contribution limit of $8,000 and a lifetime limit of $40,000.
- Participation room only starts once you open your first FHSA. Opening one early is what starts the clock.
- Unused room carries forward, but only up to $8,000. The most you can contribute in a single year is therefore $16,000 ($8,000 of new room plus up to $8,000 carried forward).
- Withdrawals do not restore contribution room, which is the opposite of the TFSA.
- The account has a limited lifespan. It must be closed by the end of the 15th year after you opened your first FHSA, or the end of the year you turn 71, whichever comes first. Unused funds can be transferred to an RRSP without using RRSP room.
RRSP: room derived from income
RRSP room is the only one of the three that depends on earned income. New room each year is 18% of your previous year's earned income, up to an annual dollar maximum set by CRA, reduced by any pension adjustment from a workplace pension plan. Unused room carries forward indefinitely.
Contributions made in the first 60 days of a year can be deducted on the previous year's return, which means a single contribution date can belong to two different tax years depending on how the user chooses to claim it. You can contribute until the end of the year you turn 71. There is a $2,000 lifetime over-contribution buffer before the 1% monthly tax applies.
The authoritative number for RRSP room is the deduction limit on the user's latest Notice of Assessment. Software should not try to out-calculate CRA here. It should anchor to that figure and track activity since.
How should you store contribution room?
The mistake I wanted to avoid was a column called room_remaining that gets incremented and decremented in application code. It drifts the moment a transaction is edited, imported twice or backdated, and there is no way to explain to the user how the number was produced.
The approach I recommend, and the way I reason about these accounts, is a ledger. Each account holds a list of dated events: contribution, withdrawal, transfer in, transfer out, annual room grant, and an anchor event that records the figure from a CRA statement. Room for any date is calculated by replaying the events up to that date under the rules for that account type. The stored data is facts; the derived numbers are always computed.
That gives three useful properties. Backdated or corrected transactions just work, because the calculation replays from the anchor. Each number can be explained, because you can show the user exactly which events produced it. And the rules for each account type live in one pure function per type, which is easy to unit test against worked examples from CRA guides.
Where do the edge cases hide?
Most of the complexity is in time, not arithmetic. These are the cases worth designing around before you write the first migration:
- TFSA re-contribution timing: a withdrawal in March only creates room on the next January 1. The ledger needs effective dates separate from transaction dates.
- Transfers between institutions: a direct transfer between two TFSAs is not a withdrawal and a contribution. If a CSV import from one bank shows money leaving and another shows money arriving, treating them as two events overstates contributions. Imports need a way to mark a pair as a transfer.
- The RRSP first-60-days window: the same contribution can count toward either tax year, so the model stores the user's choice instead of guessing from the date.
- FHSA carry-forward cap: unused room above $8,000 is simply lost, so a naive running total overstates available room for anyone who opened an account and left it alone for a few years.
- Multiple accounts of the same type: room is per person, not per account. Someone with TFSAs at two institutions has one pool of room, so room calculations aggregate across accounts owned by the same user.
Why private-first shaped the architecture
Financial history is about as sensitive as personal data gets. In MapleWealth, privacy was a design constraint from the start rather than a settings page. Authentication runs through Better Auth, and the data model is scoped to a single-tenant, privacy-conscious experience rather than assuming a broad multi-tenant SaaS from day one.
A ledger design helps here too. Because derived numbers are computed rather than stored, there is less duplicated sensitive data sitting in the database, and deleting a user's events cleanly deletes everything derived from them.
Building on top of the account model
Once the account rules are solid, the features users see become much simpler to build. MapleWealth adds net worth projections, rule-based allocation between an emergency fund and other savings goals, CSV transaction imports so nobody has to re-enter their history by hand, and dividend tracking.
Rule-based allocation is a good example of why the model matters. A rule like 'fill the emergency fund first, then the FHSA, then the TFSA' is only useful if the app knows how much room each account actually has. With a ledger, the allocation logic asks the account model for available room and never has to know the rules itself.
It also stayed fast. The production Lighthouse results recorded in the case study are 99 for performance and 100 for SEO, with a 2.2s Largest Contentful Paint, on a dashboard that is data-dense and chart-heavy.
Trade-offs and what I'd do differently
Replaying a ledger costs more than reading a stored number. For a personal finance app the event volume per user is small, so replaying is cheap. If it ever stops being cheap, the fix is a cached snapshot per year that is invalidated whenever an earlier event changes, not a mutable running total.
If I started again, I would put the yearly limits (TFSA annual limit, RRSP dollar maximum) in a dated reference table from the very first commit, rather than constants in code. They change every year, and a table with effective years means historical calculations stay correct when a new limit is added.
I would also design CSV import around transfer detection earlier. Bank exports describe movements between accounts inconsistently, and recognising transfers is the single biggest factor in whether imported history produces correct room figures.
Not financial advice
This post describes how I model these rules in software. It is not financial, tax or investment advice. Contribution limits and rules change, and your own room depends on your personal history. Check your figures in CRA My Account or on your Notice of Assessment, and talk to a qualified professional before making decisions.
Building something similar?
If you are working on a Canadian fintech product and want help with the domain model, the data layer or the dashboard itself, take a look at my service packages or get in touch. You can also explore the full MapleWealth case study to see the finished product.

