feat: personalized billing cycles anchored to user subscription/creation date#907
Conversation
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>
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| Security | 1 high |
🟢 Metrics 25 complexity · -2 duplication
Metric Results Complexity 25 Duplication -2
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 SummaryThis 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
Confidence Score: 3/5The 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
Sequence DiagramsequenceDiagram
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
|
| 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}` | ||
| }) |
There was a problem hiding this comment.
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.
| 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) | |
| }) |
There was a problem hiding this comment.
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.
…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>
|
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. |
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>
Summary
Changes
API
Web
Test Plan