Skip to content

feat: personalized billing cycles anchored to user subscription/creation date#907

Merged
AchoArnold merged 14 commits into
mainfrom
feat/personalized-billing-cycle
May 31, 2026
Merged

feat: personalized billing cycles anchored to user subscription/creation date#907
AchoArnold merged 14 commits into
mainfrom
feat/personalized-billing-cycle

Conversation

@AchoArnold
Copy link
Copy Markdown
Member

Summary

  • Replace calendar-month billing windows with personalized cycles anchored to each user's subscription renewal date (paid) or account creation date (free)
  • Billing cycle boundaries are computed dynamically at runtime — no new database columns needed
  • Usage history now displays full date ranges (e.g. "May 15, 2026 – June 14, 2026") and removes the Total Cost column

Changes

API

  • �pi/pkg/entities/billing_cycle.go — New ComputeBillingCycle() pure function with dynamic day-of-month clamping for short months
  • �pi/pkg/entities/user.go — New GetBillingAnchorDay() method (derives anchor from SubscriptionRenewsAt or CreatedAt)
  • �pi/pkg/repositories/gorm_billing_usage_repository.go — Range-based queries (start_timestamp <= ? AND end_timestamp >= ?) replace exact calendar-month matching; user fetch only on new cycle creation
  • �pi/pkg/di/container.go — Inject UserRepository into billing usage repository

Web

  • web/plugins/filters.ts — New �illingPeriodDate filter; updated �illingPeriod filter to show date ranges
  • web/pages/billing/index.vue — Usage history shows full billing period; Total Cost column removed

Test Plan

  • 14 unit tests for ComputeBillingCycle (edge cases: Feb, leap year, day 31, year boundary)
  • 5 unit tests for GetBillingAnchorDay (free/paid/nil scenarios)
  • Full API build passes
  • All repository and service tests pass

AchoArnold and others added 5 commits May 29, 2026 22:26
Implement billing cycle computation that:
- Accepts a timestamp and anchor day (1-31)
- Dynamically clamps anchor day to month's actual days
- Handles edge cases: February short months, leap years, year boundaries
- Returns (start, end) tuple representing full billing cycle window

Includes comprehensive test suite with 9 test cases covering:
- Calendar month alignment (anchor=1)
- Mid-month anchors (anchor=15)
- Edge cases (leap years, month boundaries)
- Year boundaries

All tests pass. Implementation is tested and verified.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This method returns the day-of-month that anchors a user's billing cycle.
For paid users with SubscriptionRenewsAt set, it uses the renewal date day.
For free users or when SubscriptionRenewsAt is nil, it falls back to CreatedAt day.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tests cover free users, empty subscriptions, paid users with/without renewal dates, and day 31 edge cases.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… queries

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Usage history now shows 'May 12, 2026 - June 13, 2026' format
- Removed Total Cost column from usage history table
- Added billingPeriodDate filter for long date format
- Updated billingPeriod filter to show date range

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 29, 2026

Not up to standards ⛔

🔴 Issues 1 high

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
Security 1 high

View in Codacy

🟢 Metrics 25 complexity · -2 duplication

Metric Results
Complexity 25
Duplication -2

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 29, 2026

Greptile Summary

This PR replaces calendar-month billing windows with personalized cycles anchored to each user's subscription renewal date or account creation date, computing boundaries dynamically at runtime via ComputeBillingCycle. No schema changes are required; the API, repository queries, and billing UI are all updated to reflect the new cycle model.

  • API: ComputeBillingCycle correctly clamps anchor days to short months; repository queries switch to start_timestamp <= / end_timestamp >= range filters; UserRepository is injected to derive each user's anchor day on first-cycle creation.
  • Web: The history table is updated to display full date ranges using the actual end_timestamp from the API, and the Total Cost column is removed.
  • Gap: The Overview paragraph (start_timestamp | billingPeriod) still recomputes the end date via JavaScript arithmetic rather than using the end_timestamp already present in the store, producing incorrect displayed ranges for users whose anchor day falls near the end of a short month.

Confidence Score: 3/5

The backend is correct and well-tested, but the frontend Overview section displays a wrong billing period end date for users with anchor days near the end of short months, affecting every billing page load for those users.

The Go implementation is well-tested and the range-based DB queries are correct. The frontend history table uses the actual end_timestamp from the API and is fine. The problem is in web/pages/billing/index.vue line 236, where the Overview paragraph applies the billingPeriod filter to start_timestamp only. For a user with anchor day 31 whose cycle runs March 31 to April 29, the filter outputs Apr 30 rather than Apr 29. This affects every user with an anchor day of 29, 30, or 31 during months shorter than that anchor.

web/plugins/filters.ts and web/pages/billing/index.vue — the billingPeriod filter's JS date arithmetic diverges from the server's clamping, and the template still uses it for the Overview section instead of the end_timestamp already available in the store.

Important Files Changed

Filename Overview
api/pkg/entities/billing_cycle.go New pure function ComputeBillingCycle with correct anchor-day clamping logic; edge cases covered thoroughly by tests.
api/pkg/entities/billing_cycle_test.go 14 test cases covering Feb, leap year, day 31, year boundary, and exact-anchor-day scenarios.
api/pkg/entities/user.go GetBillingAnchorDay correctly prefers SubscriptionRenewsAt for paid users and falls back to CreatedAt.
api/pkg/repositories/gorm_billing_usage_repository.go Range-based queries replace calendar-month matching correctly, but user load inside the CockroachDB transaction closure is non-transactional and can produce inconsistent cycle bounds on retries.
api/pkg/di/container.go UserRepository correctly injected into BillingUsageRepository; removal of jinzhu/now global config is clean.
web/plugins/filters.ts New billingPeriodDate filter is correct, but the updated billingPeriod filter recomputes end date via JS arithmetic that diverges from the server's anchor-day clamping and is timezone-sensitive.
web/pages/billing/index.vue History table correctly uses actual end_timestamp via billingPeriodDate, but the Overview paragraph still pipes only start_timestamp through billingPeriod, producing incorrect end dates for short-month anchor days.
api/go.mod jinzhu/now correctly moved from direct to indirect dependency.

Sequence Diagram

sequenceDiagram
    participant Client
    participant BillingUsageRepo
    participant UserRepo
    participant DB

    Note over Client,DB: RegisterSentMessage
    Client->>BillingUsageRepo: RegisterSentMessage(ctx, timestamp, userID)
    BillingUsageRepo->>DB: "UPDATE WHERE user_id=? AND start<=timestamp AND end>=timestamp"
    alt "RowsAffected > 0"
        DB-->>BillingUsageRepo: ok
    else "RowsAffected == 0"
        BillingUsageRepo->>UserRepo: Load(ctx, userID)
        UserRepo-->>BillingUsageRepo: user
        BillingUsageRepo->>BillingUsageRepo: ComputeBillingCycle(timestamp, anchorDay)
        BillingUsageRepo->>DB: INSERT new BillingUsage
    end
    BillingUsageRepo-->>Client: nil or error

    Note over Client,DB: GetCurrent
    Client->>BillingUsageRepo: GetCurrent(ctx, userID)
    BillingUsageRepo->>DB: "SELECT WHERE user_id=? AND start<=now AND end>=now"
    alt Found
        DB-->>BillingUsageRepo: BillingUsage
    else Not Found
        BillingUsageRepo->>UserRepo: Load(ctx, userID)
        UserRepo-->>BillingUsageRepo: user
        BillingUsageRepo->>BillingUsageRepo: ComputeBillingCycle(now, anchorDay)
        BillingUsageRepo->>DB: INSERT new BillingUsage
    end
    BillingUsageRepo-->>Client: BillingUsage pointer
Loading

Comments Outside Diff (2)

  1. web/pages/billing/index.vue, line 232-239 (link)

    P1 Overview date range diverges from actual cycle for short-month anchor days

    The Overview paragraph pipes only start_timestamp through the billingPeriod filter, which reconstructs the end date in JavaScript via +1 month −1 day. This arithmetic does not reproduce the server's anchor-day clamping: for a user with anchor day 31 and a current cycle that starts March 31, the server stores end_timestamp = April 29 (clamped), but the filter produces April 30 (JS: March 31 + 1 month overflows to May 1, then −1 day = April 30). The store's getBillingUsage already includes end_timestamp, so the template can render both boundaries directly — the same way the history table does — instead of recomputing the end.

  2. api/pkg/repositories/gorm_billing_usage_repository.go, line 58-76 (link)

    P2 User load inside transaction closure may cause retried transaction to use stale anchor

    createBillingUsageForUser calls repository.userRepository.Load(ctx, userID) using the outer context, which routes through the repository's own db connection — outside the tx passed into the CockroachDB transaction closure. CockroachDB's ExecuteTx can transparently retry the closure on serialization conflicts, and each retry calls Load anew. If SubscriptionRenewsAt is updated concurrently (e.g., a payment webhook fires while the retry loop runs), successive retries could compute different start/end boundaries, resulting in a BillingUsage row with cycle bounds that are inconsistent with the final committed user state. Passing tx explicitly into createBillingUsageForUser would make the user read part of the same snapshot.

Reviews (1): Last reviewed commit: "feat(web): show full billing period rang..." | Re-trigger Greptile

Comment thread web/plugins/filters.ts
Comment on lines 47 to +64
Vue.filter('billingPeriod', (value: string): string => {
const options = {
const startDate = new Date(value)
const options: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
}
const optionsWithYear: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
}
const start = startDate.toLocaleDateString('en-US', options)
const endDate = new Date(startDate)
endDate.setMonth(endDate.getMonth() + 1)
endDate.setDate(endDate.getDate() - 1)
const end = endDate.toLocaleDateString('en-US', optionsWithYear)
return `${start} – ${end}`
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 billingPeriod filter recomputes end date with JS arithmetic that diverges from server logic

The updated billingPeriod filter adds one calendar month and subtracts one day in the browser to reconstruct an end date. This diverges from ComputeBillingCycle's anchor-day clamping: for startDate = 2026-03-31, setMonth(getMonth()+1) overflows April 31 to May 1, then setDate(0) gives April 30 — but the server stores end_timestamp = April 29. Additionally, new Date(value) parses an ISO string in UTC but toLocaleDateString formats in the user's local timezone, so the displayed start date can silently shift by one day for users west of UTC. Since billingUsage.end_timestamp is available in the store, the filter should not recompute the end at all; use billingPeriodDate for both boundaries in the template instead.

Suggested change
Vue.filter('billingPeriod', (value: string): string => {
const options = {
const startDate = new Date(value)
const options: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
}
const optionsWithYear: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
}
const start = startDate.toLocaleDateString('en-US', options)
const endDate = new Date(startDate)
endDate.setMonth(endDate.getMonth() + 1)
endDate.setDate(endDate.getDate() - 1)
const end = endDate.toLocaleDateString('en-US', optionsWithYear)
return `${start}${end}`
})
Vue.filter('billingPeriod', (value: string): string => {
// Deprecated: prefer billingPeriodDate applied to both start_timestamp and
// end_timestamp directly, so the displayed range always matches the
// server-computed anchor-day-clamped cycle stored in end_timestamp.
const date = new Date(value)
const options: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
}
return date.toLocaleDateString('en-US', options)
})

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d17aba7. The billingPeriod filter now simply formats a single date. The Overview section uses billingPeriodDate applied to both start_timestamp and end_timestamp directly from the store, matching the server-computed cycle exactly. Also removed the UserRepository dependency - user is now loaded directly within the CockroachDB transaction via tx to ensure consistent snapshot reads on retries.

AchoArnold and others added 2 commits May 29, 2026 22:49
…method

The function is only used within gormBillingUsageRepository, so it
belongs there as an unexported function rather than in the entities
package.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…user within transaction

- Overview section now uses actual end_timestamp from store instead of
  recomputing via JS arithmetic that diverges from server clamping
- Simplified billingPeriod filter to format a single date
- createBillingUsageForUser now accepts tx parameter to keep user read
  within the same CockroachDB transaction snapshot
- Removed UserRepository dependency from BillingUsageRepository since
  user is loaded directly via the transaction

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@AchoArnold
Copy link
Copy Markdown
Member Author

Both issues raised by Greptile are fixed in d17aba7:

P1 (Overview date range diverges from server): The Overview section now uses \�illingPeriodDate\ applied to both \start_timestamp\ and \�nd_timestamp\ directly from the store, eliminating the JS arithmetic that diverged from server clamping.

P2 (User load outside transaction): \createBillingUsageForUser\ now accepts the \ x *gorm.DB\ parameter and loads the user directly via the transaction, ensuring consistent snapshot reads across CockroachDB retries. The \UserRepository\ dependency has been removed from \BillingUsageRepository\ entirely.

AchoArnold and others added 7 commits May 29, 2026 23:41
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The TestBulkSMS_Excel test had a race condition where message 2
might not have transitioned from pending to scheduled by the time
the bulk history endpoint was checked. Now we explicitly poll for
message 2 to reach scheduled status before verifying counts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@AchoArnold AchoArnold merged commit 12f7e84 into main May 31, 2026
6 of 9 checks passed
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