Skip to content

ADR-24: Subscription Period Pro-rata Calculation

🇰🇷 한국어 버전

DateAuthorRepos
2026-01-16@specvitalweb

Context

Calendar month-based billing periods created unfair quota allocation for users who signed up late in the month. For example, a user signing up on January 29th would receive a full month's quota but have it reset on February 1st—effectively getting 2 months worth of quota for only 3 days of actual usage.

This issue was identified as a consequence of the billing architecture and required a fair period calculation strategy.

Problem Scenario

Signup DateCalendar ResetDays of Actual UsageQuota Received
January 1stFebruary 1st31 days1 month
January 29thFebruary 1st3 days1 month

Users signing up on the 29th receive the same quota as users signing up on the 1st, despite having only 10% of the usage period.

Relationship to ADR-13

This ADR extends ADR-13: Billing and Quota Architecture, which identified "Rolling period from signup" as the selected strategy but deferred implementation specifics. ADR-13 also noted "Proration Complexity: Mid-cycle plan changes require quota adjustment" as a negative consequence that remains future work.

Decision

Implement rolling period calculation based on each user's signup timestamp.

Each user's billing period starts from their actual signup date and ends exactly one calendar month later, ensuring every user receives a full month of quota regardless of when they sign up.

Implementation

go
// src/backend/modules/subscription/usecase/assign_default_plan.go
now := time.Now().UTC()
periodStart := now                                       // Signup timestamp
periodEnd := now.AddDate(0, 1, 0).Add(-time.Nanosecond)  // Exactly 1 month later

Key Design Decisions

DecisionRationale
UTC normalizationPrevents timezone-related edge cases
AddDate(0, 1, 0)Uses Go's calendar month semantics (handles Feb 28, leap years correctly)
Nanosecond subtractionEnsures exclusive upper bound for SQL range queries
Per-user period trackingPeriod boundaries stored in user_subscriptions table

Usage Query Pattern

sql
SELECT COALESCE(SUM(quota_amount), 0)::bigint AS total
FROM usage_events
WHERE user_id = $1
    AND event_type = $2
    AND created_at >= $3    -- current_period_start (inclusive)
    AND created_at < $4     -- current_period_end (exclusive)

Options Considered

Option A: Rolling Period from Signup Date (Selected)

Period starts from user's actual signup timestamp; ends exactly 1 calendar month later.

ProsCons
Fair to all users regardless of signup timingDifferent renewal dates per user
Predictable renewal dates (user-specific)Complicates batch reporting
No late-signup penaltyNo centralized processing window
Cannot game system by signing up on month-end

Option B: Calendar Month Period

Period always runs from 1st to last day of the month for all users.

ProsCons
Simple implementationUnfair to late-month signups (up to 97% value loss)
Easy batch processingGaming potential (signup on 28th, reset on 1st)
Cohort analysis alignmentUser complaints about unfairness

Rejected: Unacceptable user fairness trade-off.

Option C: Prorated Calendar Month

Calendar month alignment with prorated quota for partial first month.

ProsCons
Maintains calendar alignmentComplex partial calculations
Mathematically fairHard to communicate to users
Fractional quota UX friction

Rejected: Complexity outweighs calendar alignment benefits.

Option D: Fixed Day Anchor (1st or 15th)

Round signup to nearest anchor date for predictable batch windows.

ProsCons
Predictable batch processing windowsUp to 14 days unfairness
Simpler reportingArbitrary cutoffs feel unfair
Users may delay signup to maximize value

Rejected: Arbitrary unfairness creates user dissatisfaction.

Consequences

Positive

AreaBenefit
User FairnessEvery user receives exactly 1 month of quota regardless of signup timing
Predictable RenewalUser knows exact renewal date (visible in UI as specific calendar date)
Abuse PreventionCannot game system by signing up on month-end for immediate reset
Clear ExpectationsUI shows "Resets on January 16" rather than ambiguous "monthly"
ADR-13 AlignmentFulfills selected strategy documented in parent ADR

Negative

AreaTrade-offMitigation
No Batch WindowDifferent users reset on different daysAcceptable for current scale
Invoice ComplexityPayment integration needs per-user billing datesFuture consideration when payment processing required
Month Length VarianceJan 31 rolls to Feb 28 (loses 3 days)Go's AddDate handles correctly; acceptable variance
Mid-cycle ChangesPlan upgrades/downgrades need proration logicExplicitly deferred as future work
Reporting ComplexityRolling periods complicate cohort analysisAccept for user fairness prioritization

Edge Cases Handled

Edge CaseHandling
Month boundary (Jan 31 → Feb 28)Go AddDate calendar semantics
Leap year (Feb 29)Go standard library handles correctly
Timezone varianceUTC normalization at storage layer
Boundary precisionNanosecond-precision exclusive upper bound

Deferred Decisions

The following mid-cycle scenarios remain unimplemented per ADR-13's noted consequences:

ScenarioProposed ApproachStatus
Upgrade mid-cycleAdd prorated difference: (newQuota - oldQuota) * remainingDays / totalDaysFuture work
Downgrade mid-cycleOptions: carry over unused OR reset to new limitFuture work
Plan expirationGraceful degradation to free tierFuture work

References

Open-source test coverage insights