Skip to content

fix: count billing cycles instead of pricing them in days (1000/month showed 12,167/year) - #180

Merged
rghvgrv merged 1 commit into
mainfrom
fix/burn-rate-calendar-multiples
Aug 11, 2026
Merged

fix: count billing cycles instead of pricing them in days (1000/month showed 12,167/year)#180
rghvgrv merged 1 commit into
mainfrom
fix/burn-rate-calendar-multiples

Conversation

@rghvgrv

@rghvgrv rghvgrv commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Reported: a single ₹1000/month subscription showed 12,167 a year instead of 12,000.

Reproduced exactly.

Root cause

BurnRateCalculator normalised every subscription to a daily rate and projected that back out:

private const int MonthlyDays = 30;
private const int YearlyDays  = 365;

var subscriptionDailyRate = convertedCost / cycleDays;   // 1000 / 30 = 33.333…
...
Monthly = dailyRateSum * MonthlyDays,   // 33.333 × 30  = 1000.00  ✅
Yearly  = dailyRateSum * YearlyDays,    // 33.333 × 365 = 12166.67 ❌

Those two constants disagree with each other: 365 / 30 = 12.17, so the calculator's "year" was 12.17 months long. Every annual figure was inflated by ~1.4%.

12166.67 then rendered as 12,167 because the dashboard's Yearly tile formats with {0:N0}.

The bug hid well: the monthly figure was always exactly right (30 × cost/30 cancels), so only the yearly number was wrong, and only by an amount that looks like rounding until the totals get large.

The fix

A calendar month is a count, not a span of days. Each subscription's annual cost is now its price times how often it is billed:

Cadence Charges/year
Weekly 52
Monthly 12
Quarterly 4
Yearly 1
var subscriptionAnnual = convertedCost * ChargesPerYear(subscription.CycleCadence);
annualSum += subscriptionAnnual;
...
Weekly  = annualSum / 52,
Monthly = annualSum / 12,
Yearly  = annualSum,

Monthly and Weekly are now derived from the annual sum rather than computed independently, so the three horizons agree with each other, and the ByCategory / ByPaymentSource breakdowns add back up to the headline instead of being a parallel projection.

Input Before After
1000 / month 12166.67 /yr 12000.00 /yr
400 / quarter 1604.40 /yr 1600.00 /yr
100 / week 5214.29 /yr 5200.00 /yr
30 / month 7.00 /wk 6.92 /wk

That last row is the fix, not a regression: 30/month is 360/year, and 360/52 = 6.92. The old 7.00 came from the 30-day-month fiction.

Also gone: the QuarterlyDays = 91 constant added in #171, which existed only to make a quarterly subscription's daily rate project to roughly four charges a year. Four is now exactly four.

Rounding, stated honestly

Weekly is rounded to the cent before it leaves the server, so multiplying it back by 52 amplifies that rounding by up to ~26 cents. Monthly × 12 == Yearly exactly; Weekly × 52 lands within half a unit. The test asserts that tolerance rather than false equality — I had it as exact equality first and it correctly failed.

Architectural rule updated

CLAUDE.md previously specified the broken approach:

Normalize every active subscription to a daily rate (cost / cycle_days), sum, then project to weekly/monthly/yearly.

Rewritten to describe cycle counting, with an explicit "do not reintroduce a daily rate" and the reason. TECHNICAL_REQUIREMENTS.md §6 item 7 updated to match. Without this, the next person follows the doc straight back into the bug.

Tests

Suite Result
SubVora.Application.Tests 53 passed (was 47)
SubVora.Infrastructure.Tests 108 passed
SubVora.Api.Tests 153 passed
SubVora.Mobile.Tests 273 passed

New coverage:

  • A [Theory] pinning cost × charges for all four cadences, including 1000/month → 12000 as the regression case.
  • A monthly subscription reads back as its own cost per month; a weekly one as its own cost per week.
  • The three horizons agree with each other.
  • The category breakdown sums to the monthly headline.

Two existing assertions encoded the old math and were updated with the reason inline — the 7.00/week one above, and the API-level quarterly test (1604.401600).

Scope

Backend only — BurnRateCalculator plus tests and docs. No API contract, schema or client change; the mobile app displays whatever the endpoint returns, which is the point of the server-side rule.

Independent of #179 (mobile-only), so the two can merge in either order.

1000 a month reported 12166.67 a year.

BurnRateCalculator normalised every subscription to a daily rate
(cost / cycle_days) with MonthlyDays = 30 and YearlyDays = 365, then
projected that rate back out. Those two constants disagree: 365/30 is
12.17, so a "year" was 12.17 months long and every annual figure was
inflated by 1.4%. 1000/30 = 33.33/day, x365 = 12166.67, which the
dashboard's N0 format then showed as 12,167.

A calendar month is a count, not a span of days. Each subscription's
annual cost is now cost x charges_per_year (Weekly 52, Monthly 12,
Quarterly 4, Yearly 1); Monthly and Weekly are derived from that sum
rather than computed independently, so the three horizons agree with each
other and the category/payment-source breakdowns add back up to the
headline. The quarterly divisor added for #171 - 91 days, chosen to make
the daily rate come out at roughly four charges a year - is gone with the
rest of it; four is now exactly four.

A monthly subscription reads back as its own cost per month, and a weekly
one as its own cost per week, which is the first thing anyone checks.

Weekly is still rounded to the cent before it leaves the server, so
Weekly x 52 lands within about 26 cents of Yearly rather than exactly on
it. The test asserts that tolerance rather than equality.

Updates the architectural rule in CLAUDE.md, which specified the daily
rate, and TECHNICAL_REQUIREMENTS.md §6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rghvgrv rghvgrv self-assigned this Aug 11, 2026
@rghvgrv
rghvgrv merged commit bfa586e into main Aug 11, 2026
4 checks passed
@rghvgrv
rghvgrv deleted the fix/burn-rate-calendar-multiples branch August 11, 2026 11:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant