[go: up one dir, main page]

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.balance is 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 unless allow_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_added transaction already exists for (charge_number, start_at, expires_at)no-op;
      • otherwise create that transaction.
  • After processing all charges for a wallet, recompute wallet.balance once.

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) using active_at(previous_period_end);
    • if > 0 → write a credits_deducted transaction at that timestamp (reason = monthly reset).
  • Then call EnsureBillingPeriodCredits (4) to ensure all new-period credits are allocated.
  • Finally, recompute wallet.balance once 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 at Time.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 uses active_at(event_timestamp), so late October events never see November credits. If "now" is still October, those credits also don't affect wallet.balance.
  • Normal monthly reset + mid-month top-ups: Reset clears previous month via a dated deduction, EnsureBillingPeriodCredits ensures 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.