Notion
Notion

How to Build a Dynamic Subscription Tracker in Notion

How to Build a Dynamic Subscription Tracker in Notion

How to Build a Dynamic Subscription Tracker in Notion

How to Build a Dynamic Subscription Tracker in Notion

Build a dynamic subscription tracker in Notion that survives pauses, restarts and price changes. Every formula included, plus the free template.

Pause it, restart it, change the price. The math still works.

We all bleed money on subscriptions we forgot about, but the bigger problem is not knowing the real number, because life is never clean. You pause something for two months, you come back on a different day, prices move while you are not looking.

And you have probably built a tracker for this already. Name, price, renewal date. It worked fine until you paused one, and then the totals quietly stopped making sense. That is not you being bad at Notion, it is just what happens when a list has to describe something that moves.

So today we build one that reads your actual history instead of guessing, and reworks every total from what really happened. It is simpler than it looks.

If you just want the thing, duplicate it here, it is free.

→ Dynamic Subscription Tracker (free)

And if you want to build it yourself, everything is below. Three databases, every formula, in the order you should add them.

The one idea behind the whole thing

The subscription row holds nothing dynamic.

The Log holds the truth. Every status change and every price sits in there with a date on it. The subscription's formulas read those logs and rebuild the timeline from scratch every time.

Once that clicks the rest is mostly typing, because every formula below is just a different question asked of the same history.

Three databases, built in this order:

  1. Subscription Log — the helper, no formulas

  2. Subscriptions — the engine, all the formulas

  3. Accounts — simple rollups

Automations last.

1. Subscription Log

Build this first. One row per change: activated, paused, price went up, cancelled.

Property

Type

What it holds

Name

Title

"Framer · activated"

Date

Date

when this change happened

Status

Status

Active / Paused / Cancelled

Price

Number

the price from this date onward

Price Changed

Checkbox

on when this log is a price change

Subscription

Relation → Subscriptions

the parent sub

No formulas here at all. It is pure data, and it is the most important table in the build even though it looks like the most boring one.

2. Subscriptions

Properties first. Do not touch the formulas until the relation to the Log exists, because every formula below reads through it.

Property

Type

What it holds

Name

Title

"Spotify", "Framer"

Cost

Number

current price per cycle

Recurring

Select

Monthly / Quarterly / Yearly

Original Date

Date

first bill date, the cycle anchor

Status

Status

Planned / Active / Paused / Cancelled

Price Changed

Checkbox

signals a price hike

Logs

Relation → Subscription Log

the history

Account

Relation → Accounts

which account it charges

One thing that will save you a headache later: set Original Date as a plain date, no time. If it carries a time, today's bill only counts once that time of day has passed, and you will see one bill fewer than you expect.

Now the formulas. Add them in the order below, because some read the ones above them.

Last Active Date · date

The quiet one the whole engine leans on. It reads the log and finds the last time the sub actually went active, then treats that as the true anchor.

So if you paused and came back on a new date, every number counts from the restart instead of the original signup. You will rarely look at this property, and it is the reason pause and resume works at all.

lets(
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  if(allStarts.length() == 0, anchor, parseDate(allStarts.last()))
)

Next Payment · date

Works out the next time you get charged. It starts from Last Active Date, the real restart pulled from the log rather than the original date, and jumps forward by your cycle until it lands on the first date still ahead of today.

That is why pausing and coming back on a different day moves your renewal to the right place. Everything else hangs off this one date.

lets(
  lastA, prop("Last Active Date"),
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  if(empty(lastA), today,
    lets(
      elapsed, max(0, dateBetween(today, lastA, unit)),
      candidate, dateAdd(lastA, elapsed, unit),
      if(candidate >= today, candidate, dateAdd(lastA, elapsed + 1, unit))
    )
  )
)

Days to Next · number

Dead simple, just the days between now and that next payment. You need it so the tracker can show a live countdown instead of a flat date, and that is a big part of why it feels alive instead of static.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  next, prop("Next Payment"),
  if(empty(next), 0, max(0, dateBetween(next, today, "days")))
)

This Month · checkbox

A quick yes or no. Is this sub getting charged this calendar month, and is it still active?

It answers "how much am I actually paying this month" without counting paused or cancelled stuff. It is the little flag the account totals read.

lets(
  active, prop("Status") == "Active",
  anchor, prop("Last Active Date"),
  monthsDiff, if(empty(anchor), -1, (year(now()) - year(anchor)) * 12 + (month(now()) - month(anchor))),
  if(not active or empty(anchor) or monthsDiff < 0, false,
    ifs(
      prop("Recurring") == "Monthly",   true,
      prop("Recurring") == "Quarterly", mod(monthsDiff, 3) == 0,
      prop("Recurring") == "Yearly",    mod(monthsDiff, 12) == 0,
      false
    )
  )
)

Current Period Spent · number

What you are paying right now, this cycle. It reads your latest log, so if you bumped the plan last week it shows the new price and not the old one. And it only counts if the sub is active, because a paused sub costs you nothing right now.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  defaultCost, if(empty(prop("Cost")), 0, prop("Cost")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  lastA, prop("Last Active Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  lastCharge, if(empty(lastA), today, dateAdd(lastA, max(0, dateBetween(today, lastA, unit)), unit)),
  atCharge, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= lastCharge).sort(current.prop("Date")).last(),
  activeAtCharge, not empty(atCharge) and atCharge.prop("Status") == "Active",
  chargePrice, if(empty(atCharge), 0, if(empty(atCharge.prop("Price")), defaultCost, atCharge.prop("Price"))),
  if(not empty(lastA) and lastCharge <= today and activeAtCharge, chargePrice, 0)
)

This Year Spent · number

This is one of the real ones. It walks every charge from your start date up to today and keeps only the ones that landed this year. For each charge, it checks the log to see whether you were active back then and what the price was at that exact moment.

That is how a pause or a mid-year price change actually changes the number instead of faking it.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  defaultCost, if(empty(prop("Cost")), 0, prop("Cost")),
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  wStart, parseDate(formatDate(now(), "YYYY") + "-01-01"),
  wEnd, parseDate(formatDate(now(), "YYYY") + "-12-31"),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  allStarts.map(lets(
    si, index,
    startDate, parseDate(current),
    startEnd, if(si + 1 < allStarts.length(), dateSubtract(parseDate(allStarts.at(si + 1)), 1, "days"), today),
    capEnd, if(startEnd > today, today, startEnd),
    numBills, if(capEnd < startDate, 0, dateBetween(capEnd, startDate, unit) + 1),
    indices, if(numBills <= 0, [], repeat("x", numBills).split("x").slice(0, numBills)),
    indices.map(lets(
      bi, index,
      billDate, dateAdd(startDate, bi, unit),
      atBill, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= billDate).sort(current.prop("Date")).last(),
      price, if(empty(atBill), defaultCost, if(empty(atBill.prop("Price")), defaultCost, atBill.prop("Price"))),
      if(billDate >= wStart and billDate <= wEnd and not empty(atBill) and atBill.prop("Status") == "Active", price, 0)
    )).sum()
  )).sum()
)

All Time Spent · number

Same engine, no year filter. Every real charge since day one. It rebuilds your whole history from the log, skips the months you were paused, and uses the right price for each period.

This is the true "what has this thing cost me" number.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  defaultCost, if(empty(prop("Cost")), 0, prop("Cost")),
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  allStarts.map(lets(
    si, index,
    startDate, parseDate(current),
    startEnd, if(si + 1 < allStarts.length(), dateSubtract(parseDate(allStarts.at(si + 1)), 1, "days"), today),
    capEnd, if(startEnd > today, today, startEnd),
    numBills, if(capEnd < startDate, 0, dateBetween(capEnd, startDate, unit) + 1),
    indices, if(numBills <= 0, [], repeat("x", numBills).split("x").slice(0, numBills)),
    indices.map(lets(
      bi, index,
      billDate, dateAdd(startDate, bi, unit),
      atBill, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= billDate).sort(current.prop("Date")).last(),
      price, if(empty(atBill), defaultCost, if(empty(atBill.prop("Price")), defaultCost, atBill.prop("Price"))),
      if(not empty(atBill) and atBill.prop("Status") == "Active", price, 0)
    )).sum()
  )).sum()
)

Total Payments · number

Counts how many times you have actually been charged. Real bills only, not the paused months. Same walk through your history, but instead of adding up money it just counts the hits.

Good for a quick "I have paid this eight times" stat.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  allStarts.map(lets(
    si, index,
    startDate, parseDate(current),
    startEnd, if(si + 1 < allStarts.length(), dateSubtract(parseDate(allStarts.at(si + 1)), 1, "days"), today),
    capEnd, if(startEnd > today, today, startEnd),
    numBills, if(capEnd < startDate, 0, dateBetween(capEnd, startDate, unit) + 1),
    indices, if(numBills <= 0, [], repeat("x", numBills).split("x").slice(0, numBills)),
    indices.map(lets(
      bi, index,
      billDate, dateAdd(startDate, bi, unit),
      atBill, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= billDate).sort(current.prop("Date")).last(),
      if(not empty(atBill) and atBill.prop("Status") == "Active", 1, 0)
    )).sum()
  )).sum()
)

The two display chips


These two do no real math. They take the numbers you already worked out and dress them up so the row reads at a glance, which is most of what makes the thing feel finished.

Cycle · text. The next charge date and the days left, or Not started / Stopped when that is the case.

lets(
  latest, prop("Logs").sort(current.prop("Date")).last(),
  isAlive, not empty(latest) and latest.prop("Status") == "Active",
  hasEver, prop("Logs").filter(current.prop("Status") == "Active").length() > 0,
  days, prop("Days to Next"),
  next, prop("Next Payment"),
  if(not hasEver, style("Not started", "c", "b", "gray"),
    if(not isAlive, style("Stopped", "c", "b", "gray"),
      style(formatDate(next, "MMM D"), "b", "white") + " " + style(format(days) + "d left", "c", "b", "blue")
    )
  )
)

Spent · text. All time, this year, and the current period, in one coloured line.

lets(
  all, if(empty(prop("All Time Spent")), 0, prop("All Time Spent")),
  yr, if(empty(prop("This Year Spent")), 0, prop("This Year Spent")),
  pd, if(empty(prop("Current Period Spent")), 0, prop("Current Period Spent")),
  pLabel, if(prop("Recurring") == "Monthly", "mo", if(prop("Recurring") == "Quarterly", "qtr", if(prop("Recurring") == "Yearly", "yr", "now"))),
  style("↻ ", "b", "green")
  + style(format(all), "c", "b", "green") + style(" all", "gray")
  + style("   ·   ", "gray")
  + style(format(yr), "c", "b", "blue") + style(" /year", "gray")
  + style("   ·   ", "gray")
  + style(format(pd), "b", "white") + style(" /" + pLabel, "gray")
)

3. Accounts

The easy one. Two properties and some rollups.

Property

Type

What it holds

Name

Title

"Personal card", "Business"

Subscriptions

Relation → Subscriptions

the subs charging this account

The account does not recalculate anything. It just collects what each sub already worked out, which is why this part is short.

This Year · number

prop("Subscriptions").map(current.prop("This Year Spent")).sum()

All Time · number

prop("Subscriptions").map(current.prop("All Time Spent")).sum()

This Month Spent · number

prop("Subscriptions").filter(current.prop("This Month") and current.prop("Status") == "Active").map(current.prop("Cost")).sum()

Active Subs · number. Small formula, but it is the pulse of the account.

prop("Subscriptions").filter(current.prop("Status") == "Active").length()

Next Sub This Month · text. Finds the active subs still due this month and shows the very next one coming, with its name, date and price. If everything is paid it just says so. This is the "what is about to hit my card" line.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  due, prop("Subscriptions").filter(
    current.prop("Status") == "Active"
    and not empty(current.prop("Next Payment"))
    and current.prop("Next Payment") >= today
    and month(current.prop("Next Payment")) == month(now())
    and year(current.prop("Next Payment")) == year(now())
  ),
  sorted, due.sort(current.prop("Next Payment")),
  nxt, sorted.first(),
  if(empty(nxt), style("All paid this month", "c", "gray"),
    style(nxt.prop("Name") + " · " + formatDate(nxt.prop("Next Payment"), "MMM D") + " · " + format(nxt.prop("Cost")), "c", "b", "orange"))
)

Spent · text. The account chip, same idea as the one on the subscription.

lets(
  all, if(empty(prop("All Time")), 0, prop("All Time")),
  yr, if(empty(prop("This Year")), 0, prop("This Year")),
  mo, if(empty(prop("This Month Spent")), 0, prop("This Month Spent")),
  style(format(all), "c", "b", "green") + style(" all", "gray")
  + style("   ·   ", "gray")
  + style(format(yr), "c", "b", "blue") + style(" year", "gray")
  + style("   ·   ", "gray")
  + style(format(mo), "b", "white") + style(" this mo", "gray")
)

4. The two automations

The formulas are useless without logs. These three keep the log filled so you never write history by hand.

  1. Status changes on a sub → create a Log with the new Status, Date set to now, and Price set to the current Cost.

First activation → set Original Date if it is empty, because that is your anchor.

if(empty(Trigger page.Logs), Trigger page.Original Date, parseDate(formatDate(now(), "YYYY-MM-DD")))

  1. Cost changes on a sub → create a Log with Price set to the new Cost, Price Changed ticked, Status Active.

That is the entire loop. You change the sub, a log gets written, the formulas re-read the history and every number moves on its own.

Test it before you trust it

Build one subscription, then add the logs one at a time and watch the totals move. If your numbers match these, your build is correct.

Set one up like this:

Field

Value

Name

Spotify Test

Recurring

Monthly

Cost

10

Original Date

Nov 16, 2025

Status

Planned

Step 1. Activate it. Set Status to Active. The automation writes the first log on its own: Nov 16, 2025 · Active · 10.

You should see All Time 100 · This Year 80 · Current Period 10.

Step 2. Pause it. Add a log: Mar 20, 2026 · Paused · 10.

Now All Time 40 · This Year 20 · Current Period 0. Paused means nothing right now, and every bill from Mar 20 onward drops off.

Step 3. Reactivate it. Add a log: May 1, 2026 · Active · 10.

Now All Time 90 · This Year 70 · Current Period 10. Notice it re-bills from May 1, not the old 16th. That is the log-driven restart, and it is the whole point of this build.

Step 4. Change the price. Add a log: Jul 5, 2026 · Active · 15 · Price Changed ticked.

Now All Time 100 · This Year 80 · Current Period 15. Only bills on or after Jul 5 use the new price. The old ones stay at 10, which is what actually happened.

Those four numbers moving in the right direction prove the pause logic, the restart anchor and the price history are all working.

One note on dates. Numbers assume you are testing around mid-September 2026. Shift your dates and the totals shift with them, but the relationships hold.

Two things I left out

This build runs in a single currency, and it does not handle switching a plan from monthly to yearly halfway through its life. That second one sounds small and it is actually the deepest part of the whole problem, because every bill before the switch and every bill after it have to be worked out on different rules.

Both are handled in FB7, along with the rest of your money: multi-currency accounts, budgets per category, debts, saving goals, lending, investments and a live net worth. The subscription engine you just built is a lighter version of the one running inside it.

You could build the rest yourself, you just proved that. The question is whether you want to spend the next six months on it.

→ Get FB7

Pause it, restart it, change the price. The math still works.

We all bleed money on subscriptions we forgot about, but the bigger problem is not knowing the real number, because life is never clean. You pause something for two months, you come back on a different day, prices move while you are not looking.

And you have probably built a tracker for this already. Name, price, renewal date. It worked fine until you paused one, and then the totals quietly stopped making sense. That is not you being bad at Notion, it is just what happens when a list has to describe something that moves.

So today we build one that reads your actual history instead of guessing, and reworks every total from what really happened. It is simpler than it looks.

If you just want the thing, duplicate it here, it is free.

→ Dynamic Subscription Tracker (free)

And if you want to build it yourself, everything is below. Three databases, every formula, in the order you should add them.

The one idea behind the whole thing

The subscription row holds nothing dynamic.

The Log holds the truth. Every status change and every price sits in there with a date on it. The subscription's formulas read those logs and rebuild the timeline from scratch every time.

Once that clicks the rest is mostly typing, because every formula below is just a different question asked of the same history.

Three databases, built in this order:

  1. Subscription Log — the helper, no formulas

  2. Subscriptions — the engine, all the formulas

  3. Accounts — simple rollups

Automations last.

1. Subscription Log

Build this first. One row per change: activated, paused, price went up, cancelled.

Property

Type

What it holds

Name

Title

"Framer · activated"

Date

Date

when this change happened

Status

Status

Active / Paused / Cancelled

Price

Number

the price from this date onward

Price Changed

Checkbox

on when this log is a price change

Subscription

Relation → Subscriptions

the parent sub

No formulas here at all. It is pure data, and it is the most important table in the build even though it looks like the most boring one.

2. Subscriptions

Properties first. Do not touch the formulas until the relation to the Log exists, because every formula below reads through it.

Property

Type

What it holds

Name

Title

"Spotify", "Framer"

Cost

Number

current price per cycle

Recurring

Select

Monthly / Quarterly / Yearly

Original Date

Date

first bill date, the cycle anchor

Status

Status

Planned / Active / Paused / Cancelled

Price Changed

Checkbox

signals a price hike

Logs

Relation → Subscription Log

the history

Account

Relation → Accounts

which account it charges

One thing that will save you a headache later: set Original Date as a plain date, no time. If it carries a time, today's bill only counts once that time of day has passed, and you will see one bill fewer than you expect.

Now the formulas. Add them in the order below, because some read the ones above them.

Last Active Date · date

The quiet one the whole engine leans on. It reads the log and finds the last time the sub actually went active, then treats that as the true anchor.

So if you paused and came back on a new date, every number counts from the restart instead of the original signup. You will rarely look at this property, and it is the reason pause and resume works at all.

lets(
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  if(allStarts.length() == 0, anchor, parseDate(allStarts.last()))
)

Next Payment · date

Works out the next time you get charged. It starts from Last Active Date, the real restart pulled from the log rather than the original date, and jumps forward by your cycle until it lands on the first date still ahead of today.

That is why pausing and coming back on a different day moves your renewal to the right place. Everything else hangs off this one date.

lets(
  lastA, prop("Last Active Date"),
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  if(empty(lastA), today,
    lets(
      elapsed, max(0, dateBetween(today, lastA, unit)),
      candidate, dateAdd(lastA, elapsed, unit),
      if(candidate >= today, candidate, dateAdd(lastA, elapsed + 1, unit))
    )
  )
)

Days to Next · number

Dead simple, just the days between now and that next payment. You need it so the tracker can show a live countdown instead of a flat date, and that is a big part of why it feels alive instead of static.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  next, prop("Next Payment"),
  if(empty(next), 0, max(0, dateBetween(next, today, "days")))
)

This Month · checkbox

A quick yes or no. Is this sub getting charged this calendar month, and is it still active?

It answers "how much am I actually paying this month" without counting paused or cancelled stuff. It is the little flag the account totals read.

lets(
  active, prop("Status") == "Active",
  anchor, prop("Last Active Date"),
  monthsDiff, if(empty(anchor), -1, (year(now()) - year(anchor)) * 12 + (month(now()) - month(anchor))),
  if(not active or empty(anchor) or monthsDiff < 0, false,
    ifs(
      prop("Recurring") == "Monthly",   true,
      prop("Recurring") == "Quarterly", mod(monthsDiff, 3) == 0,
      prop("Recurring") == "Yearly",    mod(monthsDiff, 12) == 0,
      false
    )
  )
)

Current Period Spent · number

What you are paying right now, this cycle. It reads your latest log, so if you bumped the plan last week it shows the new price and not the old one. And it only counts if the sub is active, because a paused sub costs you nothing right now.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  defaultCost, if(empty(prop("Cost")), 0, prop("Cost")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  lastA, prop("Last Active Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  lastCharge, if(empty(lastA), today, dateAdd(lastA, max(0, dateBetween(today, lastA, unit)), unit)),
  atCharge, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= lastCharge).sort(current.prop("Date")).last(),
  activeAtCharge, not empty(atCharge) and atCharge.prop("Status") == "Active",
  chargePrice, if(empty(atCharge), 0, if(empty(atCharge.prop("Price")), defaultCost, atCharge.prop("Price"))),
  if(not empty(lastA) and lastCharge <= today and activeAtCharge, chargePrice, 0)
)

This Year Spent · number

This is one of the real ones. It walks every charge from your start date up to today and keeps only the ones that landed this year. For each charge, it checks the log to see whether you were active back then and what the price was at that exact moment.

That is how a pause or a mid-year price change actually changes the number instead of faking it.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  defaultCost, if(empty(prop("Cost")), 0, prop("Cost")),
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  wStart, parseDate(formatDate(now(), "YYYY") + "-01-01"),
  wEnd, parseDate(formatDate(now(), "YYYY") + "-12-31"),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  allStarts.map(lets(
    si, index,
    startDate, parseDate(current),
    startEnd, if(si + 1 < allStarts.length(), dateSubtract(parseDate(allStarts.at(si + 1)), 1, "days"), today),
    capEnd, if(startEnd > today, today, startEnd),
    numBills, if(capEnd < startDate, 0, dateBetween(capEnd, startDate, unit) + 1),
    indices, if(numBills <= 0, [], repeat("x", numBills).split("x").slice(0, numBills)),
    indices.map(lets(
      bi, index,
      billDate, dateAdd(startDate, bi, unit),
      atBill, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= billDate).sort(current.prop("Date")).last(),
      price, if(empty(atBill), defaultCost, if(empty(atBill.prop("Price")), defaultCost, atBill.prop("Price"))),
      if(billDate >= wStart and billDate <= wEnd and not empty(atBill) and atBill.prop("Status") == "Active", price, 0)
    )).sum()
  )).sum()
)

All Time Spent · number

Same engine, no year filter. Every real charge since day one. It rebuilds your whole history from the log, skips the months you were paused, and uses the right price for each period.

This is the true "what has this thing cost me" number.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  defaultCost, if(empty(prop("Cost")), 0, prop("Cost")),
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  allStarts.map(lets(
    si, index,
    startDate, parseDate(current),
    startEnd, if(si + 1 < allStarts.length(), dateSubtract(parseDate(allStarts.at(si + 1)), 1, "days"), today),
    capEnd, if(startEnd > today, today, startEnd),
    numBills, if(capEnd < startDate, 0, dateBetween(capEnd, startDate, unit) + 1),
    indices, if(numBills <= 0, [], repeat("x", numBills).split("x").slice(0, numBills)),
    indices.map(lets(
      bi, index,
      billDate, dateAdd(startDate, bi, unit),
      atBill, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= billDate).sort(current.prop("Date")).last(),
      price, if(empty(atBill), defaultCost, if(empty(atBill.prop("Price")), defaultCost, atBill.prop("Price"))),
      if(not empty(atBill) and atBill.prop("Status") == "Active", price, 0)
    )).sum()
  )).sum()
)

Total Payments · number

Counts how many times you have actually been charged. Real bills only, not the paused months. Same walk through your history, but instead of adding up money it just counts the hits.

Good for a quick "I have paid this eight times" stat.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  allStarts.map(lets(
    si, index,
    startDate, parseDate(current),
    startEnd, if(si + 1 < allStarts.length(), dateSubtract(parseDate(allStarts.at(si + 1)), 1, "days"), today),
    capEnd, if(startEnd > today, today, startEnd),
    numBills, if(capEnd < startDate, 0, dateBetween(capEnd, startDate, unit) + 1),
    indices, if(numBills <= 0, [], repeat("x", numBills).split("x").slice(0, numBills)),
    indices.map(lets(
      bi, index,
      billDate, dateAdd(startDate, bi, unit),
      atBill, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= billDate).sort(current.prop("Date")).last(),
      if(not empty(atBill) and atBill.prop("Status") == "Active", 1, 0)
    )).sum()
  )).sum()
)

The two display chips


These two do no real math. They take the numbers you already worked out and dress them up so the row reads at a glance, which is most of what makes the thing feel finished.

Cycle · text. The next charge date and the days left, or Not started / Stopped when that is the case.

lets(
  latest, prop("Logs").sort(current.prop("Date")).last(),
  isAlive, not empty(latest) and latest.prop("Status") == "Active",
  hasEver, prop("Logs").filter(current.prop("Status") == "Active").length() > 0,
  days, prop("Days to Next"),
  next, prop("Next Payment"),
  if(not hasEver, style("Not started", "c", "b", "gray"),
    if(not isAlive, style("Stopped", "c", "b", "gray"),
      style(formatDate(next, "MMM D"), "b", "white") + " " + style(format(days) + "d left", "c", "b", "blue")
    )
  )
)

Spent · text. All time, this year, and the current period, in one coloured line.

lets(
  all, if(empty(prop("All Time Spent")), 0, prop("All Time Spent")),
  yr, if(empty(prop("This Year Spent")), 0, prop("This Year Spent")),
  pd, if(empty(prop("Current Period Spent")), 0, prop("Current Period Spent")),
  pLabel, if(prop("Recurring") == "Monthly", "mo", if(prop("Recurring") == "Quarterly", "qtr", if(prop("Recurring") == "Yearly", "yr", "now"))),
  style("↻ ", "b", "green")
  + style(format(all), "c", "b", "green") + style(" all", "gray")
  + style("   ·   ", "gray")
  + style(format(yr), "c", "b", "blue") + style(" /year", "gray")
  + style("   ·   ", "gray")
  + style(format(pd), "b", "white") + style(" /" + pLabel, "gray")
)

3. Accounts

The easy one. Two properties and some rollups.

Property

Type

What it holds

Name

Title

"Personal card", "Business"

Subscriptions

Relation → Subscriptions

the subs charging this account

The account does not recalculate anything. It just collects what each sub already worked out, which is why this part is short.

This Year · number

prop("Subscriptions").map(current.prop("This Year Spent")).sum()

All Time · number

prop("Subscriptions").map(current.prop("All Time Spent")).sum()

This Month Spent · number

prop("Subscriptions").filter(current.prop("This Month") and current.prop("Status") == "Active").map(current.prop("Cost")).sum()

Active Subs · number. Small formula, but it is the pulse of the account.

prop("Subscriptions").filter(current.prop("Status") == "Active").length()

Next Sub This Month · text. Finds the active subs still due this month and shows the very next one coming, with its name, date and price. If everything is paid it just says so. This is the "what is about to hit my card" line.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  due, prop("Subscriptions").filter(
    current.prop("Status") == "Active"
    and not empty(current.prop("Next Payment"))
    and current.prop("Next Payment") >= today
    and month(current.prop("Next Payment")) == month(now())
    and year(current.prop("Next Payment")) == year(now())
  ),
  sorted, due.sort(current.prop("Next Payment")),
  nxt, sorted.first(),
  if(empty(nxt), style("All paid this month", "c", "gray"),
    style(nxt.prop("Name") + " · " + formatDate(nxt.prop("Next Payment"), "MMM D") + " · " + format(nxt.prop("Cost")), "c", "b", "orange"))
)

Spent · text. The account chip, same idea as the one on the subscription.

lets(
  all, if(empty(prop("All Time")), 0, prop("All Time")),
  yr, if(empty(prop("This Year")), 0, prop("This Year")),
  mo, if(empty(prop("This Month Spent")), 0, prop("This Month Spent")),
  style(format(all), "c", "b", "green") + style(" all", "gray")
  + style("   ·   ", "gray")
  + style(format(yr), "c", "b", "blue") + style(" year", "gray")
  + style("   ·   ", "gray")
  + style(format(mo), "b", "white") + style(" this mo", "gray")
)

4. The two automations

The formulas are useless without logs. These three keep the log filled so you never write history by hand.

  1. Status changes on a sub → create a Log with the new Status, Date set to now, and Price set to the current Cost.

First activation → set Original Date if it is empty, because that is your anchor.

if(empty(Trigger page.Logs), Trigger page.Original Date, parseDate(formatDate(now(), "YYYY-MM-DD")))

  1. Cost changes on a sub → create a Log with Price set to the new Cost, Price Changed ticked, Status Active.

That is the entire loop. You change the sub, a log gets written, the formulas re-read the history and every number moves on its own.

Test it before you trust it

Build one subscription, then add the logs one at a time and watch the totals move. If your numbers match these, your build is correct.

Set one up like this:

Field

Value

Name

Spotify Test

Recurring

Monthly

Cost

10

Original Date

Nov 16, 2025

Status

Planned

Step 1. Activate it. Set Status to Active. The automation writes the first log on its own: Nov 16, 2025 · Active · 10.

You should see All Time 100 · This Year 80 · Current Period 10.

Step 2. Pause it. Add a log: Mar 20, 2026 · Paused · 10.

Now All Time 40 · This Year 20 · Current Period 0. Paused means nothing right now, and every bill from Mar 20 onward drops off.

Step 3. Reactivate it. Add a log: May 1, 2026 · Active · 10.

Now All Time 90 · This Year 70 · Current Period 10. Notice it re-bills from May 1, not the old 16th. That is the log-driven restart, and it is the whole point of this build.

Step 4. Change the price. Add a log: Jul 5, 2026 · Active · 15 · Price Changed ticked.

Now All Time 100 · This Year 80 · Current Period 15. Only bills on or after Jul 5 use the new price. The old ones stay at 10, which is what actually happened.

Those four numbers moving in the right direction prove the pause logic, the restart anchor and the price history are all working.

One note on dates. Numbers assume you are testing around mid-September 2026. Shift your dates and the totals shift with them, but the relationships hold.

Two things I left out

This build runs in a single currency, and it does not handle switching a plan from monthly to yearly halfway through its life. That second one sounds small and it is actually the deepest part of the whole problem, because every bill before the switch and every bill after it have to be worked out on different rules.

Both are handled in FB7, along with the rest of your money: multi-currency accounts, budgets per category, debts, saving goals, lending, investments and a live net worth. The subscription engine you just built is a lighter version of the one running inside it.

You could build the rest yourself, you just proved that. The question is whether you want to spend the next six months on it.

→ Get FB7

Pause it, restart it, change the price. The math still works.

We all bleed money on subscriptions we forgot about, but the bigger problem is not knowing the real number, because life is never clean. You pause something for two months, you come back on a different day, prices move while you are not looking.

And you have probably built a tracker for this already. Name, price, renewal date. It worked fine until you paused one, and then the totals quietly stopped making sense. That is not you being bad at Notion, it is just what happens when a list has to describe something that moves.

So today we build one that reads your actual history instead of guessing, and reworks every total from what really happened. It is simpler than it looks.

If you just want the thing, duplicate it here, it is free.

→ Dynamic Subscription Tracker (free)

And if you want to build it yourself, everything is below. Three databases, every formula, in the order you should add them.

The one idea behind the whole thing

The subscription row holds nothing dynamic.

The Log holds the truth. Every status change and every price sits in there with a date on it. The subscription's formulas read those logs and rebuild the timeline from scratch every time.

Once that clicks the rest is mostly typing, because every formula below is just a different question asked of the same history.

Three databases, built in this order:

  1. Subscription Log — the helper, no formulas

  2. Subscriptions — the engine, all the formulas

  3. Accounts — simple rollups

Automations last.

1. Subscription Log

Build this first. One row per change: activated, paused, price went up, cancelled.

Property

Type

What it holds

Name

Title

"Framer · activated"

Date

Date

when this change happened

Status

Status

Active / Paused / Cancelled

Price

Number

the price from this date onward

Price Changed

Checkbox

on when this log is a price change

Subscription

Relation → Subscriptions

the parent sub

No formulas here at all. It is pure data, and it is the most important table in the build even though it looks like the most boring one.

2. Subscriptions

Properties first. Do not touch the formulas until the relation to the Log exists, because every formula below reads through it.

Property

Type

What it holds

Name

Title

"Spotify", "Framer"

Cost

Number

current price per cycle

Recurring

Select

Monthly / Quarterly / Yearly

Original Date

Date

first bill date, the cycle anchor

Status

Status

Planned / Active / Paused / Cancelled

Price Changed

Checkbox

signals a price hike

Logs

Relation → Subscription Log

the history

Account

Relation → Accounts

which account it charges

One thing that will save you a headache later: set Original Date as a plain date, no time. If it carries a time, today's bill only counts once that time of day has passed, and you will see one bill fewer than you expect.

Now the formulas. Add them in the order below, because some read the ones above them.

Last Active Date · date

The quiet one the whole engine leans on. It reads the log and finds the last time the sub actually went active, then treats that as the true anchor.

So if you paused and came back on a new date, every number counts from the restart instead of the original signup. You will rarely look at this property, and it is the reason pause and resume works at all.

lets(
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  if(allStarts.length() == 0, anchor, parseDate(allStarts.last()))
)

Next Payment · date

Works out the next time you get charged. It starts from Last Active Date, the real restart pulled from the log rather than the original date, and jumps forward by your cycle until it lands on the first date still ahead of today.

That is why pausing and coming back on a different day moves your renewal to the right place. Everything else hangs off this one date.

lets(
  lastA, prop("Last Active Date"),
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  if(empty(lastA), today,
    lets(
      elapsed, max(0, dateBetween(today, lastA, unit)),
      candidate, dateAdd(lastA, elapsed, unit),
      if(candidate >= today, candidate, dateAdd(lastA, elapsed + 1, unit))
    )
  )
)

Days to Next · number

Dead simple, just the days between now and that next payment. You need it so the tracker can show a live countdown instead of a flat date, and that is a big part of why it feels alive instead of static.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  next, prop("Next Payment"),
  if(empty(next), 0, max(0, dateBetween(next, today, "days")))
)

This Month · checkbox

A quick yes or no. Is this sub getting charged this calendar month, and is it still active?

It answers "how much am I actually paying this month" without counting paused or cancelled stuff. It is the little flag the account totals read.

lets(
  active, prop("Status") == "Active",
  anchor, prop("Last Active Date"),
  monthsDiff, if(empty(anchor), -1, (year(now()) - year(anchor)) * 12 + (month(now()) - month(anchor))),
  if(not active or empty(anchor) or monthsDiff < 0, false,
    ifs(
      prop("Recurring") == "Monthly",   true,
      prop("Recurring") == "Quarterly", mod(monthsDiff, 3) == 0,
      prop("Recurring") == "Yearly",    mod(monthsDiff, 12) == 0,
      false
    )
  )
)

Current Period Spent · number

What you are paying right now, this cycle. It reads your latest log, so if you bumped the plan last week it shows the new price and not the old one. And it only counts if the sub is active, because a paused sub costs you nothing right now.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  defaultCost, if(empty(prop("Cost")), 0, prop("Cost")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  lastA, prop("Last Active Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  lastCharge, if(empty(lastA), today, dateAdd(lastA, max(0, dateBetween(today, lastA, unit)), unit)),
  atCharge, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= lastCharge).sort(current.prop("Date")).last(),
  activeAtCharge, not empty(atCharge) and atCharge.prop("Status") == "Active",
  chargePrice, if(empty(atCharge), 0, if(empty(atCharge.prop("Price")), defaultCost, atCharge.prop("Price"))),
  if(not empty(lastA) and lastCharge <= today and activeAtCharge, chargePrice, 0)
)

This Year Spent · number

This is one of the real ones. It walks every charge from your start date up to today and keeps only the ones that landed this year. For each charge, it checks the log to see whether you were active back then and what the price was at that exact moment.

That is how a pause or a mid-year price change actually changes the number instead of faking it.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  defaultCost, if(empty(prop("Cost")), 0, prop("Cost")),
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  wStart, parseDate(formatDate(now(), "YYYY") + "-01-01"),
  wEnd, parseDate(formatDate(now(), "YYYY") + "-12-31"),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  allStarts.map(lets(
    si, index,
    startDate, parseDate(current),
    startEnd, if(si + 1 < allStarts.length(), dateSubtract(parseDate(allStarts.at(si + 1)), 1, "days"), today),
    capEnd, if(startEnd > today, today, startEnd),
    numBills, if(capEnd < startDate, 0, dateBetween(capEnd, startDate, unit) + 1),
    indices, if(numBills <= 0, [], repeat("x", numBills).split("x").slice(0, numBills)),
    indices.map(lets(
      bi, index,
      billDate, dateAdd(startDate, bi, unit),
      atBill, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= billDate).sort(current.prop("Date")).last(),
      price, if(empty(atBill), defaultCost, if(empty(atBill.prop("Price")), defaultCost, atBill.prop("Price"))),
      if(billDate >= wStart and billDate <= wEnd and not empty(atBill) and atBill.prop("Status") == "Active", price, 0)
    )).sum()
  )).sum()
)

All Time Spent · number

Same engine, no year filter. Every real charge since day one. It rebuilds your whole history from the log, skips the months you were paused, and uses the right price for each period.

This is the true "what has this thing cost me" number.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  defaultCost, if(empty(prop("Cost")), 0, prop("Cost")),
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  allStarts.map(lets(
    si, index,
    startDate, parseDate(current),
    startEnd, if(si + 1 < allStarts.length(), dateSubtract(parseDate(allStarts.at(si + 1)), 1, "days"), today),
    capEnd, if(startEnd > today, today, startEnd),
    numBills, if(capEnd < startDate, 0, dateBetween(capEnd, startDate, unit) + 1),
    indices, if(numBills <= 0, [], repeat("x", numBills).split("x").slice(0, numBills)),
    indices.map(lets(
      bi, index,
      billDate, dateAdd(startDate, bi, unit),
      atBill, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= billDate).sort(current.prop("Date")).last(),
      price, if(empty(atBill), defaultCost, if(empty(atBill.prop("Price")), defaultCost, atBill.prop("Price"))),
      if(not empty(atBill) and atBill.prop("Status") == "Active", price, 0)
    )).sum()
  )).sum()
)

Total Payments · number

Counts how many times you have actually been charged. Real bills only, not the paused months. Same walk through your history, but instead of adding up money it just counts the hits.

Good for a quick "I have paid this eight times" stat.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  allStarts.map(lets(
    si, index,
    startDate, parseDate(current),
    startEnd, if(si + 1 < allStarts.length(), dateSubtract(parseDate(allStarts.at(si + 1)), 1, "days"), today),
    capEnd, if(startEnd > today, today, startEnd),
    numBills, if(capEnd < startDate, 0, dateBetween(capEnd, startDate, unit) + 1),
    indices, if(numBills <= 0, [], repeat("x", numBills).split("x").slice(0, numBills)),
    indices.map(lets(
      bi, index,
      billDate, dateAdd(startDate, bi, unit),
      atBill, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= billDate).sort(current.prop("Date")).last(),
      if(not empty(atBill) and atBill.prop("Status") == "Active", 1, 0)
    )).sum()
  )).sum()
)

The two display chips


These two do no real math. They take the numbers you already worked out and dress them up so the row reads at a glance, which is most of what makes the thing feel finished.

Cycle · text. The next charge date and the days left, or Not started / Stopped when that is the case.

lets(
  latest, prop("Logs").sort(current.prop("Date")).last(),
  isAlive, not empty(latest) and latest.prop("Status") == "Active",
  hasEver, prop("Logs").filter(current.prop("Status") == "Active").length() > 0,
  days, prop("Days to Next"),
  next, prop("Next Payment"),
  if(not hasEver, style("Not started", "c", "b", "gray"),
    if(not isAlive, style("Stopped", "c", "b", "gray"),
      style(formatDate(next, "MMM D"), "b", "white") + " " + style(format(days) + "d left", "c", "b", "blue")
    )
  )
)

Spent · text. All time, this year, and the current period, in one coloured line.

lets(
  all, if(empty(prop("All Time Spent")), 0, prop("All Time Spent")),
  yr, if(empty(prop("This Year Spent")), 0, prop("This Year Spent")),
  pd, if(empty(prop("Current Period Spent")), 0, prop("Current Period Spent")),
  pLabel, if(prop("Recurring") == "Monthly", "mo", if(prop("Recurring") == "Quarterly", "qtr", if(prop("Recurring") == "Yearly", "yr", "now"))),
  style("↻ ", "b", "green")
  + style(format(all), "c", "b", "green") + style(" all", "gray")
  + style("   ·   ", "gray")
  + style(format(yr), "c", "b", "blue") + style(" /year", "gray")
  + style("   ·   ", "gray")
  + style(format(pd), "b", "white") + style(" /" + pLabel, "gray")
)

3. Accounts

The easy one. Two properties and some rollups.

Property

Type

What it holds

Name

Title

"Personal card", "Business"

Subscriptions

Relation → Subscriptions

the subs charging this account

The account does not recalculate anything. It just collects what each sub already worked out, which is why this part is short.

This Year · number

prop("Subscriptions").map(current.prop("This Year Spent")).sum()

All Time · number

prop("Subscriptions").map(current.prop("All Time Spent")).sum()

This Month Spent · number

prop("Subscriptions").filter(current.prop("This Month") and current.prop("Status") == "Active").map(current.prop("Cost")).sum()

Active Subs · number. Small formula, but it is the pulse of the account.

prop("Subscriptions").filter(current.prop("Status") == "Active").length()

Next Sub This Month · text. Finds the active subs still due this month and shows the very next one coming, with its name, date and price. If everything is paid it just says so. This is the "what is about to hit my card" line.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  due, prop("Subscriptions").filter(
    current.prop("Status") == "Active"
    and not empty(current.prop("Next Payment"))
    and current.prop("Next Payment") >= today
    and month(current.prop("Next Payment")) == month(now())
    and year(current.prop("Next Payment")) == year(now())
  ),
  sorted, due.sort(current.prop("Next Payment")),
  nxt, sorted.first(),
  if(empty(nxt), style("All paid this month", "c", "gray"),
    style(nxt.prop("Name") + " · " + formatDate(nxt.prop("Next Payment"), "MMM D") + " · " + format(nxt.prop("Cost")), "c", "b", "orange"))
)

Spent · text. The account chip, same idea as the one on the subscription.

lets(
  all, if(empty(prop("All Time")), 0, prop("All Time")),
  yr, if(empty(prop("This Year")), 0, prop("This Year")),
  mo, if(empty(prop("This Month Spent")), 0, prop("This Month Spent")),
  style(format(all), "c", "b", "green") + style(" all", "gray")
  + style("   ·   ", "gray")
  + style(format(yr), "c", "b", "blue") + style(" year", "gray")
  + style("   ·   ", "gray")
  + style(format(mo), "b", "white") + style(" this mo", "gray")
)

4. The two automations

The formulas are useless without logs. These three keep the log filled so you never write history by hand.

  1. Status changes on a sub → create a Log with the new Status, Date set to now, and Price set to the current Cost.

First activation → set Original Date if it is empty, because that is your anchor.

if(empty(Trigger page.Logs), Trigger page.Original Date, parseDate(formatDate(now(), "YYYY-MM-DD")))

  1. Cost changes on a sub → create a Log with Price set to the new Cost, Price Changed ticked, Status Active.

That is the entire loop. You change the sub, a log gets written, the formulas re-read the history and every number moves on its own.

Test it before you trust it

Build one subscription, then add the logs one at a time and watch the totals move. If your numbers match these, your build is correct.

Set one up like this:

Field

Value

Name

Spotify Test

Recurring

Monthly

Cost

10

Original Date

Nov 16, 2025

Status

Planned

Step 1. Activate it. Set Status to Active. The automation writes the first log on its own: Nov 16, 2025 · Active · 10.

You should see All Time 100 · This Year 80 · Current Period 10.

Step 2. Pause it. Add a log: Mar 20, 2026 · Paused · 10.

Now All Time 40 · This Year 20 · Current Period 0. Paused means nothing right now, and every bill from Mar 20 onward drops off.

Step 3. Reactivate it. Add a log: May 1, 2026 · Active · 10.

Now All Time 90 · This Year 70 · Current Period 10. Notice it re-bills from May 1, not the old 16th. That is the log-driven restart, and it is the whole point of this build.

Step 4. Change the price. Add a log: Jul 5, 2026 · Active · 15 · Price Changed ticked.

Now All Time 100 · This Year 80 · Current Period 15. Only bills on or after Jul 5 use the new price. The old ones stay at 10, which is what actually happened.

Those four numbers moving in the right direction prove the pause logic, the restart anchor and the price history are all working.

One note on dates. Numbers assume you are testing around mid-September 2026. Shift your dates and the totals shift with them, but the relationships hold.

Two things I left out

This build runs in a single currency, and it does not handle switching a plan from monthly to yearly halfway through its life. That second one sounds small and it is actually the deepest part of the whole problem, because every bill before the switch and every bill after it have to be worked out on different rules.

Both are handled in FB7, along with the rest of your money: multi-currency accounts, budgets per category, debts, saving goals, lending, investments and a live net worth. The subscription engine you just built is a lighter version of the one running inside it.

You could build the rest yourself, you just proved that. The question is whether you want to spend the next six months on it.

→ Get FB7

Pause it, restart it, change the price. The math still works.

We all bleed money on subscriptions we forgot about, but the bigger problem is not knowing the real number, because life is never clean. You pause something for two months, you come back on a different day, prices move while you are not looking.

And you have probably built a tracker for this already. Name, price, renewal date. It worked fine until you paused one, and then the totals quietly stopped making sense. That is not you being bad at Notion, it is just what happens when a list has to describe something that moves.

So today we build one that reads your actual history instead of guessing, and reworks every total from what really happened. It is simpler than it looks.

If you just want the thing, duplicate it here, it is free.

→ Dynamic Subscription Tracker (free)

And if you want to build it yourself, everything is below. Three databases, every formula, in the order you should add them.

The one idea behind the whole thing

The subscription row holds nothing dynamic.

The Log holds the truth. Every status change and every price sits in there with a date on it. The subscription's formulas read those logs and rebuild the timeline from scratch every time.

Once that clicks the rest is mostly typing, because every formula below is just a different question asked of the same history.

Three databases, built in this order:

  1. Subscription Log — the helper, no formulas

  2. Subscriptions — the engine, all the formulas

  3. Accounts — simple rollups

Automations last.

1. Subscription Log

Build this first. One row per change: activated, paused, price went up, cancelled.

Property

Type

What it holds

Name

Title

"Framer · activated"

Date

Date

when this change happened

Status

Status

Active / Paused / Cancelled

Price

Number

the price from this date onward

Price Changed

Checkbox

on when this log is a price change

Subscription

Relation → Subscriptions

the parent sub

No formulas here at all. It is pure data, and it is the most important table in the build even though it looks like the most boring one.

2. Subscriptions

Properties first. Do not touch the formulas until the relation to the Log exists, because every formula below reads through it.

Property

Type

What it holds

Name

Title

"Spotify", "Framer"

Cost

Number

current price per cycle

Recurring

Select

Monthly / Quarterly / Yearly

Original Date

Date

first bill date, the cycle anchor

Status

Status

Planned / Active / Paused / Cancelled

Price Changed

Checkbox

signals a price hike

Logs

Relation → Subscription Log

the history

Account

Relation → Accounts

which account it charges

One thing that will save you a headache later: set Original Date as a plain date, no time. If it carries a time, today's bill only counts once that time of day has passed, and you will see one bill fewer than you expect.

Now the formulas. Add them in the order below, because some read the ones above them.

Last Active Date · date

The quiet one the whole engine leans on. It reads the log and finds the last time the sub actually went active, then treats that as the true anchor.

So if you paused and came back on a new date, every number counts from the restart instead of the original signup. You will rarely look at this property, and it is the reason pause and resume works at all.

lets(
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  if(allStarts.length() == 0, anchor, parseDate(allStarts.last()))
)

Next Payment · date

Works out the next time you get charged. It starts from Last Active Date, the real restart pulled from the log rather than the original date, and jumps forward by your cycle until it lands on the first date still ahead of today.

That is why pausing and coming back on a different day moves your renewal to the right place. Everything else hangs off this one date.

lets(
  lastA, prop("Last Active Date"),
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  if(empty(lastA), today,
    lets(
      elapsed, max(0, dateBetween(today, lastA, unit)),
      candidate, dateAdd(lastA, elapsed, unit),
      if(candidate >= today, candidate, dateAdd(lastA, elapsed + 1, unit))
    )
  )
)

Days to Next · number

Dead simple, just the days between now and that next payment. You need it so the tracker can show a live countdown instead of a flat date, and that is a big part of why it feels alive instead of static.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  next, prop("Next Payment"),
  if(empty(next), 0, max(0, dateBetween(next, today, "days")))
)

This Month · checkbox

A quick yes or no. Is this sub getting charged this calendar month, and is it still active?

It answers "how much am I actually paying this month" without counting paused or cancelled stuff. It is the little flag the account totals read.

lets(
  active, prop("Status") == "Active",
  anchor, prop("Last Active Date"),
  monthsDiff, if(empty(anchor), -1, (year(now()) - year(anchor)) * 12 + (month(now()) - month(anchor))),
  if(not active or empty(anchor) or monthsDiff < 0, false,
    ifs(
      prop("Recurring") == "Monthly",   true,
      prop("Recurring") == "Quarterly", mod(monthsDiff, 3) == 0,
      prop("Recurring") == "Yearly",    mod(monthsDiff, 12) == 0,
      false
    )
  )
)

Current Period Spent · number

What you are paying right now, this cycle. It reads your latest log, so if you bumped the plan last week it shows the new price and not the old one. And it only counts if the sub is active, because a paused sub costs you nothing right now.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  defaultCost, if(empty(prop("Cost")), 0, prop("Cost")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  lastA, prop("Last Active Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  lastCharge, if(empty(lastA), today, dateAdd(lastA, max(0, dateBetween(today, lastA, unit)), unit)),
  atCharge, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= lastCharge).sort(current.prop("Date")).last(),
  activeAtCharge, not empty(atCharge) and atCharge.prop("Status") == "Active",
  chargePrice, if(empty(atCharge), 0, if(empty(atCharge.prop("Price")), defaultCost, atCharge.prop("Price"))),
  if(not empty(lastA) and lastCharge <= today and activeAtCharge, chargePrice, 0)
)

This Year Spent · number

This is one of the real ones. It walks every charge from your start date up to today and keeps only the ones that landed this year. For each charge, it checks the log to see whether you were active back then and what the price was at that exact moment.

That is how a pause or a mid-year price change actually changes the number instead of faking it.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  defaultCost, if(empty(prop("Cost")), 0, prop("Cost")),
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  wStart, parseDate(formatDate(now(), "YYYY") + "-01-01"),
  wEnd, parseDate(formatDate(now(), "YYYY") + "-12-31"),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  allStarts.map(lets(
    si, index,
    startDate, parseDate(current),
    startEnd, if(si + 1 < allStarts.length(), dateSubtract(parseDate(allStarts.at(si + 1)), 1, "days"), today),
    capEnd, if(startEnd > today, today, startEnd),
    numBills, if(capEnd < startDate, 0, dateBetween(capEnd, startDate, unit) + 1),
    indices, if(numBills <= 0, [], repeat("x", numBills).split("x").slice(0, numBills)),
    indices.map(lets(
      bi, index,
      billDate, dateAdd(startDate, bi, unit),
      atBill, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= billDate).sort(current.prop("Date")).last(),
      price, if(empty(atBill), defaultCost, if(empty(atBill.prop("Price")), defaultCost, atBill.prop("Price"))),
      if(billDate >= wStart and billDate <= wEnd and not empty(atBill) and atBill.prop("Status") == "Active", price, 0)
    )).sum()
  )).sum()
)

All Time Spent · number

Same engine, no year filter. Every real charge since day one. It rebuilds your whole history from the log, skips the months you were paused, and uses the right price for each period.

This is the true "what has this thing cost me" number.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  defaultCost, if(empty(prop("Cost")), 0, prop("Cost")),
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  allStarts.map(lets(
    si, index,
    startDate, parseDate(current),
    startEnd, if(si + 1 < allStarts.length(), dateSubtract(parseDate(allStarts.at(si + 1)), 1, "days"), today),
    capEnd, if(startEnd > today, today, startEnd),
    numBills, if(capEnd < startDate, 0, dateBetween(capEnd, startDate, unit) + 1),
    indices, if(numBills <= 0, [], repeat("x", numBills).split("x").slice(0, numBills)),
    indices.map(lets(
      bi, index,
      billDate, dateAdd(startDate, bi, unit),
      atBill, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= billDate).sort(current.prop("Date")).last(),
      price, if(empty(atBill), defaultCost, if(empty(atBill.prop("Price")), defaultCost, atBill.prop("Price"))),
      if(not empty(atBill) and atBill.prop("Status") == "Active", price, 0)
    )).sum()
  )).sum()
)

Total Payments · number

Counts how many times you have actually been charged. Real bills only, not the paused months. Same walk through your history, but instead of adding up money it just counts the hits.

Good for a quick "I have paid this eight times" stat.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  anchor, prop("Original Date"),
  unit, if(prop("Recurring") == "Yearly", "years", if(prop("Recurring") == "Quarterly", "quarters", "months")),
  sortedLogs, prop("Logs").sort(current.prop("Date")),
  statusStarts, sortedLogs.map(lets(
    i, index,
    isActive, current.prop("Status") == "Active",
    prevActive, i > 0 and sortedLogs.at(i - 1).prop("Status") == "Active",
    rStr, formatDate(current.prop("Date"), "YYYY-MM-DD"),
    keep, if(not isActive or prevActive, false, if(i == 0, true, if(empty(anchor), true, lets(
      pauseD, parseDate(formatDate(sortedLogs.at(i - 1).prop("Date"), "YYYY-MM-DD")),
      pa, parseDate(formatDate(anchor, "YYYY-MM-DD")),
      elapsed, dateBetween(pauseD, pa, unit),
      lastBillRaw, dateAdd(pa, elapsed, unit),
      lastBill, if(lastBillRaw > pauseD, dateAdd(pa, elapsed - 1, unit), lastBillRaw),
      nextBill, dateAdd(lastBill, 1, unit),
      parseDate(rStr) >= nextBill
    )))),
    if(keep, rStr, "")
  )).filter(current != ""),
  allStartsRaw, statusStarts.sort(current),
  allStarts, allStartsRaw.filter(if(index == 0, true, current != allStartsRaw.at(index - 1))),
  allStarts.map(lets(
    si, index,
    startDate, parseDate(current),
    startEnd, if(si + 1 < allStarts.length(), dateSubtract(parseDate(allStarts.at(si + 1)), 1, "days"), today),
    capEnd, if(startEnd > today, today, startEnd),
    numBills, if(capEnd < startDate, 0, dateBetween(capEnd, startDate, unit) + 1),
    indices, if(numBills <= 0, [], repeat("x", numBills).split("x").slice(0, numBills)),
    indices.map(lets(
      bi, index,
      billDate, dateAdd(startDate, bi, unit),
      atBill, sortedLogs.filter(parseDate(formatDate(current.prop("Date"), "YYYY-MM-DD")) <= billDate).sort(current.prop("Date")).last(),
      if(not empty(atBill) and atBill.prop("Status") == "Active", 1, 0)
    )).sum()
  )).sum()
)

The two display chips


These two do no real math. They take the numbers you already worked out and dress them up so the row reads at a glance, which is most of what makes the thing feel finished.

Cycle · text. The next charge date and the days left, or Not started / Stopped when that is the case.

lets(
  latest, prop("Logs").sort(current.prop("Date")).last(),
  isAlive, not empty(latest) and latest.prop("Status") == "Active",
  hasEver, prop("Logs").filter(current.prop("Status") == "Active").length() > 0,
  days, prop("Days to Next"),
  next, prop("Next Payment"),
  if(not hasEver, style("Not started", "c", "b", "gray"),
    if(not isAlive, style("Stopped", "c", "b", "gray"),
      style(formatDate(next, "MMM D"), "b", "white") + " " + style(format(days) + "d left", "c", "b", "blue")
    )
  )
)

Spent · text. All time, this year, and the current period, in one coloured line.

lets(
  all, if(empty(prop("All Time Spent")), 0, prop("All Time Spent")),
  yr, if(empty(prop("This Year Spent")), 0, prop("This Year Spent")),
  pd, if(empty(prop("Current Period Spent")), 0, prop("Current Period Spent")),
  pLabel, if(prop("Recurring") == "Monthly", "mo", if(prop("Recurring") == "Quarterly", "qtr", if(prop("Recurring") == "Yearly", "yr", "now"))),
  style("↻ ", "b", "green")
  + style(format(all), "c", "b", "green") + style(" all", "gray")
  + style("   ·   ", "gray")
  + style(format(yr), "c", "b", "blue") + style(" /year", "gray")
  + style("   ·   ", "gray")
  + style(format(pd), "b", "white") + style(" /" + pLabel, "gray")
)

3. Accounts

The easy one. Two properties and some rollups.

Property

Type

What it holds

Name

Title

"Personal card", "Business"

Subscriptions

Relation → Subscriptions

the subs charging this account

The account does not recalculate anything. It just collects what each sub already worked out, which is why this part is short.

This Year · number

prop("Subscriptions").map(current.prop("This Year Spent")).sum()

All Time · number

prop("Subscriptions").map(current.prop("All Time Spent")).sum()

This Month Spent · number

prop("Subscriptions").filter(current.prop("This Month") and current.prop("Status") == "Active").map(current.prop("Cost")).sum()

Active Subs · number. Small formula, but it is the pulse of the account.

prop("Subscriptions").filter(current.prop("Status") == "Active").length()

Next Sub This Month · text. Finds the active subs still due this month and shows the very next one coming, with its name, date and price. If everything is paid it just says so. This is the "what is about to hit my card" line.

lets(
  today, parseDate(formatDate(now(), "YYYY-MM-DD")),
  due, prop("Subscriptions").filter(
    current.prop("Status") == "Active"
    and not empty(current.prop("Next Payment"))
    and current.prop("Next Payment") >= today
    and month(current.prop("Next Payment")) == month(now())
    and year(current.prop("Next Payment")) == year(now())
  ),
  sorted, due.sort(current.prop("Next Payment")),
  nxt, sorted.first(),
  if(empty(nxt), style("All paid this month", "c", "gray"),
    style(nxt.prop("Name") + " · " + formatDate(nxt.prop("Next Payment"), "MMM D") + " · " + format(nxt.prop("Cost")), "c", "b", "orange"))
)

Spent · text. The account chip, same idea as the one on the subscription.

lets(
  all, if(empty(prop("All Time")), 0, prop("All Time")),
  yr, if(empty(prop("This Year")), 0, prop("This Year")),
  mo, if(empty(prop("This Month Spent")), 0, prop("This Month Spent")),
  style(format(all), "c", "b", "green") + style(" all", "gray")
  + style("   ·   ", "gray")
  + style(format(yr), "c", "b", "blue") + style(" year", "gray")
  + style("   ·   ", "gray")
  + style(format(mo), "b", "white") + style(" this mo", "gray")
)

4. The two automations

The formulas are useless without logs. These three keep the log filled so you never write history by hand.

  1. Status changes on a sub → create a Log with the new Status, Date set to now, and Price set to the current Cost.

First activation → set Original Date if it is empty, because that is your anchor.

if(empty(Trigger page.Logs), Trigger page.Original Date, parseDate(formatDate(now(), "YYYY-MM-DD")))

  1. Cost changes on a sub → create a Log with Price set to the new Cost, Price Changed ticked, Status Active.

That is the entire loop. You change the sub, a log gets written, the formulas re-read the history and every number moves on its own.

Test it before you trust it

Build one subscription, then add the logs one at a time and watch the totals move. If your numbers match these, your build is correct.

Set one up like this:

Field

Value

Name

Spotify Test

Recurring

Monthly

Cost

10

Original Date

Nov 16, 2025

Status

Planned

Step 1. Activate it. Set Status to Active. The automation writes the first log on its own: Nov 16, 2025 · Active · 10.

You should see All Time 100 · This Year 80 · Current Period 10.

Step 2. Pause it. Add a log: Mar 20, 2026 · Paused · 10.

Now All Time 40 · This Year 20 · Current Period 0. Paused means nothing right now, and every bill from Mar 20 onward drops off.

Step 3. Reactivate it. Add a log: May 1, 2026 · Active · 10.

Now All Time 90 · This Year 70 · Current Period 10. Notice it re-bills from May 1, not the old 16th. That is the log-driven restart, and it is the whole point of this build.

Step 4. Change the price. Add a log: Jul 5, 2026 · Active · 15 · Price Changed ticked.

Now All Time 100 · This Year 80 · Current Period 15. Only bills on or after Jul 5 use the new price. The old ones stay at 10, which is what actually happened.

Those four numbers moving in the right direction prove the pause logic, the restart anchor and the price history are all working.

One note on dates. Numbers assume you are testing around mid-September 2026. Shift your dates and the totals shift with them, but the relationships hold.

Two things I left out

This build runs in a single currency, and it does not handle switching a plan from monthly to yearly halfway through its life. That second one sounds small and it is actually the deepest part of the whole problem, because every bill before the switch and every bill after it have to be worked out on different rules.

Both are handled in FB7, along with the rest of your money: multi-currency accounts, budgets per category, debts, saving goals, lending, investments and a live net worth. The subscription engine you just built is a lighter version of the one running inside it.

You could build the rest yourself, you just proved that. The question is whether you want to spend the next six months on it.

→ Get FB7

You may also want to read

You may also want to read

OLSNOTION

When you join my email list, it’s not just about free products or premium offers. Every time my email pops up, you know it’s packed with value, something to elevate your day beyond anything else

© 2025 OLSNOTION. All rights reserved.

OLSNOTION

When you join my email list, it’s not just about free products or premium offers. Every time my email pops up, you know it’s packed with value, something to elevate your day beyond anything else

© 2025 OLSNOTION. All rights reserved.

OLSNOTION

When you join my email list, it’s not just about free products or premium offers. Every time my email pops up, you know it’s packed with value, something to elevate your day beyond anything else

© 2025 OLSNOTION. All rights reserved.