Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/graphql/types/contract_applied_rate_cards/object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class Object < Types::BaseObject
field :billing_anchor_date, GraphQL::Types::ISO8601Date, null: false
field :effective_date, GraphQL::Types::ISO8601Date, null: false
field :ended_date, GraphQL::Types::ISO8601Date, null: true
field :next_billing_at, GraphQL::Types::ISO8601DateTime, null: false
field :next_billing_at, GraphQL::Types::ISO8601DateTime, null: true

field :rate_phases, [Types::RatePhases::Object], null: false
field :rate_phases_count, Integer, null: false
Expand Down
3 changes: 1 addition & 2 deletions app/models/contract_rate_card.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ class ContractRateCard < ApplicationRecord
has_many :billing_segments

validates :billing_anchor_date, presence: true
validates :next_billing_at, presence: true
validates :effective_date, presence: true
validates :units, numericality: {greater_than_or_equal_to: 0}, allow_nil: true
validates :rate_card_id, uniqueness: {scope: :contract_id, conditions: -> { where(deleted_at: nil, ended_date: nil) }}
Expand Down Expand Up @@ -68,7 +67,7 @@ def validate_effective_before_ended
# deleted_at :datetime
# effective_date :date not null
# ended_date :date
# next_billing_at :datetime not null
# next_billing_at :datetime
# units :decimal(, )
# created_at :datetime not null
# updated_at :datetime not null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def serialize
effective_date: model.effective_date.iso8601,
ended_date: model.ended_date&.iso8601,
billing_anchor_date: model.billing_anchor_date.iso8601,
next_billing_at: model.next_billing_at.iso8601,
next_billing_at: model.next_billing_at&.iso8601,
# size counts the loaded collection when the caller preloaded it.
rate_phases_count: model.rate_phases.size,
created_at: model.created_at.iso8601,
Expand Down
21 changes: 18 additions & 3 deletions app/services/billing/rate_cards/schedule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,26 @@ def initialize(rates:, terms:, phases:, starts_at:, anchor_date:, timezone:, end
end

# A rate change can make a segment due before its cycle ends.
def segments_due_by(timestamp)
def segments_due_by(timestamp, billing_from: nil)
cycles = walker.walk_to(timestamp, from: resume_at)

billable_segments_of(cycles).select do |segment|
segment.billing_at <= timestamp
cycles.flat_map do |cycle|
segments = billable_segments_of([cycle])

if billing_from
# A saved arrears clock can point to the original cycle end even
# after termination shortens it. Select against the unshortened
# pricing windows, then persist the actual service boundaries.
full_cycle = cycle.with(ended_at: cycle.calendar.interval_containing(cycle.started_at).end)
eligible_starts = billable_segments_of([full_cycle]).filter_map do |segment|

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.

convert this to a Set .to_set to avoid O(MxN) in the line 35

if segment.billing_at >= billing_from || (segment.started_at...segment.ended_at).cover?(billing_from)
segment.started_at
end
end
segments = segments.select { eligible_starts.include?(it.started_at) }
end

segments.select { it.billing_at <= timestamp }
end
end

Expand Down
100 changes: 100 additions & 0 deletions app/services/billing_segments/schedule_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# frozen_string_literal: true

module BillingSegments
# Persists the calendar's due slices. The clock is both the lower billing
# bound (backdated contracts can join the current period) and the next wake-up.
class ScheduleService < BaseService
Result = BaseResult[:billing_segments]

def initialize(customer:, timestamp: Time.current)

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.

better to use a range here, having a single timestamp we don't cover the option to generate segments for past dates/segments.

@customer = customer
@timestamp = timestamp
super
end

def call
result.billing_segments = []

if customer.nil?
return result.not_found_failure!(resource: "customer")
end

scheduled = []
ActiveRecord::Base.transaction do
Customers::LockService.call!(customer:, scope: :billing_schedule) do
due_cards.find_each do |card|
scheduled.concat(schedule_card(card))
end
end
end

result.billing_segments = scheduled
result
rescue ActiveRecord::RecordInvalid => error
result.record_validation_failure!(record: error.record)
rescue BaseService::FailedResult => error
result.fail_with_error!(error)
end

private

attr_reader :customer, :timestamp

def due_cards

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.

From AI:

The scheduler preloads the rate card and contract/customer, but the builder additionally loads rates, phases, overrides, and potentially plan-applied cards. Candidate segments each trigger an existence check; pricing-unit lookup repeats for each new segment.

With C cards, P phases per card, and S candidate segments, these paths can generate O(CP + S) read queries alongside required writes.

Consider preloading the builder's association graph, fetching existing starts in bulk, and resolving pricing units once per distinct code.

ContractRateCard.joins(:contract)
.where(organization_id: customer.organization_id, next_billing_at: ..timestamp)
.where(contracts: {customer_id: customer.id, status: %w[active terminated], started_at: ..timestamp})
.includes(:rate_card, contract: :customer)

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.

isn't missing to load the rate_phases here?

end

def schedule_card(card)
schedule = Billing::RateCards::BuildScheduleService.call!(contract_rate_card: card).schedule

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.

there is a small issue here. If a customer has multiple contract rate cards with configured rate_card in each of them plus rate card rates. But a single one does not have a single rate_card_rates, the entire schedule will fail due this error https://github.com/getlago/lago-api/blob/billing-segments-scheduler/app/services/billing/rate_cards/build_schedule_service.rb#L22

Basically, it's all or nothing for a single customer. We should evaluate this to ignore that missing one

scheduled = schedule.segments_due_by(timestamp, billing_from: card.next_billing_at).filter_map do |segment|
persist_segment(card, segment)
end

# nil means the schedule is exhausted, so this card is no longer due.
card.update!(next_billing_at: schedule.next_billing_at(after: timestamp))
scheduled
end

def persist_segment(card, segment)
# Resuming replays the last cycle, which can contain several segments.
# Keep existing snapshots and statuses intact, including on a stale-clock retry.
if card.billing_segments.exists?(started_at: segment.started_at)

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.

Could we handle the known billing-segment overlap constraint as a structured service failure?

persist_segment only skips existing rows with the same started_at. If an existing segment overlaps the candidate but starts at a different instant, create! raises ActiveRecord::StatementInvalid wrapping PG::ExclusionViolation. Neither rescue in call handles that exception.

The transaction still rolls back correctly, but the caller receives an exception instead of a failed Result. The legacy BillingCycles::ScheduleService explicitly handles its known period conflicts and returns overlapping_periods.

Could we rescue the specific billing_segments_no_overlapping_periods constraint violation, return a structured failure, and add a regression for a different-start overlap? Unrelated database errors should continue to propagate.

return
end

card.billing_segments.create!(

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.

I think we can use a import! here and hit the DB into a single shot. Using batches as well

organization: customer.organization,
customer:,
contract: card.contract,
cycle_started_at: segment.cycle_started_at,
started_at: segment.started_at,
ended_at: BillingSegment.inclusive_end(segment.ended_at),
billing_at: segment.billing_at,
rate_card_rate: segment.rate,
rate_override: segment.rate_override,
rate_properties: (segment.rate_override || segment.rate).properties,

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.

move this to segment.

segment.properties

currency: card.rate_card.currency,
pricing_unit: pricing_unit(card),

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.

pricing_unit(card) runs customer.organization.pricing_units.find_by(code:) once per persisted segment, but the result depends only on the card, not the segment. On a catch-up run that persists several segments for the same card (e.g. the delayed-run and phase-transition cases), this fires the same query repeatedly. Consider resolving the pricing unit once per card in schedule_card and passing it into persist_segment, so the lookup (and its pricing_unit_not_found failure) happens a single time per card.

proration_ratio: segment.proration_ratio,
status: :pending
)
end

def pricing_unit(card)
code = card.rate_card.applied_pricing_unit_code

if code.present?
unit = customer.organization.pricing_units.find_by(code:)

if unit.nil?
result.not_found_failure!(resource: "pricing_unit").raise_if_error!
end

unit
end
end
end
end
2 changes: 1 addition & 1 deletion app/services/customers/lock_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ module Customers
# locking (lock_version), concurrent updates will raise StaleObjectError.
#
class LockService < BaseLockService
VALID_SCOPES = %i[prepaid_credit payment_method credit_note coupon].freeze
VALID_SCOPES = %i[prepaid_credit payment_method credit_note coupon billing_schedule].freeze

def initialize(customer:, scope:, timeout_seconds: ACQUIRE_LOCK_TIMEOUT, transaction: true)
@customer = customer
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AllowExhaustedContractRateCardSchedules < ActiveRecord::Migration[8.0]
def change
change_column_null :contract_rate_cards, :next_billing_at, true
end
end
4 changes: 2 additions & 2 deletions db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2631,7 +2631,7 @@ CREATE TABLE public.contract_rate_cards (
contract_id uuid NOT NULL,
rate_card_id uuid NOT NULL,
billing_anchor_date date NOT NULL,
next_billing_at timestamp without time zone NOT NULL,
next_billing_at timestamp without time zone,
effective_date date NOT NULL,
ended_date date,
units numeric,
Expand Down Expand Up @@ -14877,6 +14877,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20260911144853'),
('20260910151708'),
('20260910095513'),
('20260909154904'),
('20260909103355'),
('20260908222044'),
('20260908211313'),
Expand Down Expand Up @@ -16007,4 +16008,3 @@ INSERT INTO "schema_migrations" (version) VALUES
('20220530091046'),
('20220526101535'),
('20220525122759');

2 changes: 1 addition & 1 deletion schema.graphql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 3 additions & 7 deletions schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions spec/graphql/resolvers/contracts_resolver_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,24 @@ def count_queries(table)
expect(counts.sum).to eq(2)
end
end

context "when a rate card has exhausted its billing schedule" do
let(:query) do
<<~GQL
query {
contracts(limit: 5, status: [active]) {
collection { id appliedRateCards { id nextBillingAt } }
}
}
GQL
end

it "returns the card with a null next billing date" do
card = create(:contract_rate_card, organization:, contract: active_contract, next_billing_at: nil)

expect(execution["errors"]).to be_nil
expect(execution["data"]["contracts"]["collection"].sole["appliedRateCards"])
.to eq([{"id" => card.id, "nextBillingAt" => nil}])
end
end
end
2 changes: 1 addition & 1 deletion spec/models/contract_rate_card_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@

describe "validations" do
it { is_expected.to validate_presence_of(:billing_anchor_date) }
it { is_expected.to validate_presence_of(:next_billing_at) }
it { is_expected.to allow_value(nil).for(:next_billing_at) }
it { is_expected.to validate_presence_of(:effective_date) }
it { is_expected.to validate_numericality_of(:units).is_greater_than_or_equal_to(0).allow_nil }

Expand Down
9 changes: 9 additions & 0 deletions spec/requests/api/v2/contract_rate_cards_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@
expect(response).to have_http_status(:success)
expect(json[:applied_rate_cards].map { it[:lago_id] }).to eq([contract_rate_card.id])
end

it "serializes an exhausted billing schedule" do
contract_rate_card.update!(next_billing_at: nil)

subject

expect(response).to have_http_status(:success)
expect(json[:applied_rate_cards].sole[:next_billing_at]).to be_nil
end
end

describe "GET /api/v2/contracts/:external_id/applied_rate_cards/:code" do
Expand Down
28 changes: 28 additions & 0 deletions spec/services/billing/rate_cards/schedule_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,34 @@ def windows(cycles)
end
end

describe "an initial billing bound on a shortened schedule" do
let(:ends_at) { Time.utc(2022, 1, 25, 12) }

it "includes the final arrears segment when the clock still points to its original end" do
segment = schedule.segments_due_by(Time.utc(2022, 2, 1), billing_from: Time.utc(2022, 2, 1)).sole

expect(segment.started_at).to eq(starts_at)
expect(segment.ended_at).to eq(ends_at)
expect(segment.billing_at).to eq(ends_at)
expect(segment.proration_ratio).to eq(11.fdiv(31))
end

it "does not resurrect historical cycles before the initial billing bound" do
expect(schedule.segments_due_by(Time.utc(2022, 4, 1), billing_from: Time.utc(2022, 4, 1))).to eq([])
end

context "with a price change inside the final cycle" do
let(:rates) { [card_rate(Time.utc(2000, 1, 1)), card_rate(Time.utc(2022, 1, 20))] }

it "keeps the bound at the pricing segment rather than reopening the entire cycle" do
segments = schedule.segments_due_by(Time.utc(2022, 2, 1), billing_from: Time.utc(2022, 2, 1))

expect(segments.map(&:started_at)).to eq([Time.utc(2022, 1, 20)])
expect(segments.sole.ended_at).to eq(ends_at)
end
end
end

describe "when a window falls due" do
it "bills at the end of the cycle in arrears" do
expect(schedule.segments_due_by(Time.utc(2022, 4, 1)).map(&:billing_at))
Expand Down
Loading
Loading