Time-aware credits to fix rollover and drop transition hacks
Context
This issue captures the long-term solution proposed in https://gitlab.com/gitlab-org/customers-gitlab-com/-/issues/14600#note_2898681389 to address credit allocation edge cases during billing cycle transitions and improve the overall robustness of the usage billing system.
This solution would also address the issue described in https://gitlab.com/gitlab-org/customers-gitlab-com/-/issues/14977+, where expired bonus trial credits need to be excluded from wallet balance calculations.
Goal
Fix credit allocation bugs (such as expired subscriptions renewed on the 1st without credits), make credit handling robust, and remove the fragile "transition period" jobs that depend on a special 0–2 AM window.
Proposed changes
1. Add start_at to WalletTransactions
- Each transaction has a validity window:
start_at .. expires_at. - Monthly GU today →
start_at = beginning_of_month,expires_at = end_of_month. - This lets us provision next month's credits early while still making sure consumption only sees credits that are active at the event time.
2. Fix Wallets::Transactions scope for ConsumptionService
Replace "non-expired" with "active at time T":
- scope :non_expired, ->(at_time = Time.current) { where(expires_at: [nil, at_time..]) }
+ scope :non_expired, ->(at_time = Time.current) {
+ where(
+ "(start_at IS NULL OR start_at <= ?) AND (expires_at IS NULL OR expires_at >= ?)",
+ at_time,
+ at_time
+ )
+ }
Effect: ConsumptionService now always queries credits active at event_timestamp. Late October events processed on Nov 1 only see October-window credits; November credits (start_at >= Nov 1) are invisible to those events.
3. Make wallet.balance a cache of the current billing period
-
wallet.balanceis no longer used for correctness, only as a cache. -
It is recomputed as:
sum of all
WalletTransactions.active_at(Time.current)for that wallet, with the existing "no negative unlessallow_negative_balance?" rule preserved. -
After any change (add credits, deduct credits), we recompute the balance once per wallet at the end of the operation.
-
All business logic (allocation, consumption, clearing) uses transactions + timestamps, not
wallet.balance!
4. New shared service: UsageBilling::WalletCredits::EnsureBillingPeriodCredits
Introduce a service used by both:
- the monthly reset job, and
- Zuora callbacks (renewals, new charges, mid-month purchases).
Behaviour (per subscription + billing period):
- Iterate all relevant
Zuora::Local::RatePlanCharges. - For each charge:
- compute
(start_at, expires_at)for its billing period (Time.current.end_of_month-
Time.current.beginning_of_month;
- for the correct wallet (subscription / consumer):
- if a
credit_addedtransaction already exists for(charge_number, start_at, expires_at)→ no-op; - otherwise create that transaction.
- if a
- compute
- After processing all charges for a wallet, recompute
wallet.balanceonce.
This makes credit allocation idempotent per (charge_number, start_at, expires_at) and independent of "did the job run exactly between 0–2 AM".
5. Monthly reset still clears the previous billing period
- Reset job continues to clear unused credits, but in a time-aware way:
- for each wallet, compute remaining balance as of previous period end (e.g.
2025-10-31 23:59:59) usingactive_at(previous_period_end); - if > 0 → write a
credits_deductedtransaction at that timestamp (reason = monthly reset).
- for each wallet, compute remaining balance as of previous period end (e.g.
- Then call
EnsureBillingPeriodCredits(4) to ensure all new-period credits are allocated. - Finally, recompute
wallet.balanceonce per wallet (now reflecting only active credits for the current billing period).
6. Impact on key scenarios
-
Current bug (expired subscription renewed on the 1st): If the reset job skipped the expired subscription, the first Zuora callback after renewal runs
EnsureBillingPeriodCredits, sees missing(charge_number, start_at=new_month_start, expires_at=new_month_end)transactions, and creates them. Balance is recomputed from active credits atTime.current, so renewed subscriptions still get correct new-month credits. -
Transition window (0–2 AM): Zuora can provision next-month credits early with
start_at = new_month_start. ConsumptionService always usesactive_at(event_timestamp), so late October events never see November credits. If "now" is still October, those credits also don't affectwallet.balance. -
Normal monthly reset + mid-month top-ups: Reset clears previous month via a dated deduction,
EnsureBillingPeriodCreditsensures new-month base/commitment/waiver credits exist, and mid-month purchases are handled by the same service (idempotent per charge and billing window). Balance is always "sum of active credits for the current period", just cached. - Bonus trial credits expiration (https://gitlab.com/gitlab-org/customers-gitlab-com/-/issues/14977): With time-aware balance calculation, expired bonus trial credits will automatically be excluded from the wallet balance since they fall outside the active time window.
Related Issues
- https://gitlab.com/gitlab-org/customers-gitlab-com/-/issues/14600 - Credits not allocated for subscription renewed during month rollover
- https://gitlab.com/gitlab-org/customers-gitlab-com/-/issues/14977 - Exclude Expired Bonus Trial Credits from Wallet Balance Calculation
- https://gitlab.com/gitlab-org/customers-gitlab-com/-/issues/13940 - Related discussions on edge cases for billing cycle period