Skip to content

Prioritize billing intervals when classifying plan changes - #7

Merged
njonty merged 12 commits into
mainfrom
fix/reverse-lago-upgrade-downgrade-classification
Jul 29, 2026
Merged

njonty merged 12 commits into
mainfrom
fix/reverse-lago-upgrade-downgrade-classification

Conversation

@Aditya-Rai-25

@Aditya-Rai-25 Aditya-Rai-25 commented Jul 24, 2026 •

Copy link
Copy Markdown
Collaborator

Issue: #8

Summary

Updates Lago’s plan-change classification logic to prioritize plan billing cycle durations (intervals), using the yearly-normalized fee only as a secondary tie-breaker.

Previously, Lago classified upgrades and downgrades solely by comparing the yearly-normalized cost (yearly_amount_cents).

This PR implements a two-tiered priority system:

  1. Primary Indicator: Billing cycle duration (Interval weights: weekly < monthly < quarterly < semiannual < yearly).
  2. Secondary Tie-Breaker: Yearly-normalized fee (applied only when intervals are identical).

Key changes

  • Interval Priority: A transition to a longer billing interval is classified as an upgrade; a transition to a shorter billing interval is classified as a downgrade.
  • Price fallback: If the billing intervals of both plans are equal, the classification falls back to the yearly-normalized fee:
    • Higher or equal target fee is treated as an upgrade.
    • Lower target fee is treated as a downgrade.

Plan-change behavior

  • Transition to longer cycle (e.g. monthly to yearly): Upgrade
  • Transition to shorter cycle (e.g. yearly to monthly): Downgrade
  • Same cycle (e.g. monthly to monthly):
    • Target fee is higher/equal: Upgrade
    • Target fee is lower: Downgrade

Areas covered

  • Lago plan-change classification
  • Billing interval weight priority (weekly to yearly)
  • Upgrade detection
  • Downgrade detection
  • Equal-interval plan transitions (price fallback)

Reviewer Checklist

  • Verify that billing interval duration is checked first when classifying plan changes.
  • Verify that transitioning to a longer interval is treated as an upgrade.
  • Verify that transitioning to a shorter interval is treated as a downgrade.
  • Verify that equal-interval transitions fall back to comparing yearly-normalized fees.

@Aditya-Rai-25
Aditya-Rai-25 force-pushed the fix/reverse-lago-upgrade-downgrade-classification branch 2 times, most recently from 5c8af0f to 35b1be0 Compare July 27, 2026 07:06
@njonty

njonty commented Jul 27, 2026

Copy link
Copy Markdown
Contributor
  1. Blocking: implementation contradicts the documented business rule

The PR description says classification must depend on yearly-normalized fees:

Lower target fee → upgrade
Higher target fee → downgrade
Equal target fee → upgrade

However, the implementation first compares billing intervals:

if next_weight > current_weight
true
elsif next_weight < current_weight
false

This means changing from monthly to yearly is always considered an upgrade, regardless of yearly-normalized price.

Furthermore, when intervals are equal, the comparison remains in the old direction:

plan.yearly_amount_cents <= next_subscription.plan.yearly_amount_cents

This classifies a higher target price as an upgrade, whereas the PR says a lower target price must be an upgrade.

The same incorrect logic is duplicated in:

Subscription#upgraded? and #downgraded?
Invoice preview service
Subscription creation service
Plan update service
Plan amount update service

The expected implementation appears to be:

def upgraded?
return false unless next_subscription

next_subscription.plan.yearly_amount_cents <= plan.yearly_amount_cents
end

def downgraded?
return false unless next_subscription

next_subscription.plan.yearly_amount_cents > plan.yearly_amount_cents
end

Equivalent direction changes should be made consistently in the other services.

  1. Blocking: tests validate interval ordering, not the stated fee rule

The modified model tests change expectations for monthly-to-yearly and yearly-to-monthly transitions. They do not demonstrate that:

A lower yearly-normalized target fee is an upgrade.
A higher yearly-normalized target fee is a downgrade.
Equal yearly-normalized fees are an upgrade.
Different intervals are still classified based on normalized amounts rather than interval duration.

Tests should explicitly use combinations such as:

Current monthly yearly-equivalent $1,200; target yearly $1,000 → upgrade.
Current yearly $1,000; target monthly yearly-equivalent $1,200 → downgrade.
Different intervals with equal yearly-equivalent amounts → upgrade.
3. Major: interval ranking changes business behaviour outside the requested scope

The new ordering:

weekly < monthly < quarterly < semiannual < yearly

treats a longer commitment interval as inherently “higher,” regardless of price. This is a new classification rule that is not described in the PR and directly conflicts with “continues to compare plans using yearly-normalized fees.”

The interval-weight logic should be removed unless this is a separate, explicitly approved requirement.

  1. Moderate: unrelated production configuration change

The LAGO_LICENSE_URL override is unrelated to plan-change classification.

It should preferably be moved into a separate PR because it:

Changes production configuration behaviour.
Requires separate deployment and security review.
Makes rollback and upstream synchronization harder.

  1. Maintainability: classification logic is duplicated

The same interval map and comparison are copied into five locations. Even after correcting the rule, this creates a high risk that preview, creation, model state, and pending-subscription processing will diverge later.

A shared classifier would be safer, for example:

Plans::ChangeClassificationService.call(
current_plan:,
target_plan:
)

@Aditya-Rai-25
Aditya-Rai-25 requested a review from njonty July 27, 2026 09:10
@Aditya-Rai-25 Aditya-Rai-25 self-assigned this Jul 27, 2026
Previously, the billing system determined if a plan change was
an upgrade or downgrade solely by comparing the yearly cost
(yearly_amount_cents). This did not account for differences in
billing intervals where moving to a longer cycle (e.g., monthly
to annual) is business-critical to treat as an upgrade regardless
of the price differences, and moving to a shorter cycle is
treated as a downgrade.

Refactor the upgrade and downgrade detection logic to prioritize
the duration of the billing interval. Interval weights are mapped
from shortest to longest (weekly, monthly, quarterly, semiannual,
yearly).

- A transition to a longer billing interval is classified as
  an upgrade.
- A transition to a shorter billing interval is classified as
  a downgrade.
- If both plans share the same billing interval, the logic falls
  back to comparing their yearly amount.
Following the implementation of interval-priority logic for plan
upgrades and downgrades, existing spec files checked old behavior
where plans with different billing intervals defaulted to price-based
logic.

Update existing RSpec tests to expect the new interval-priority
behavior:

- Update unit tests in subscription_spec.rb to expect
  upgrade/downgrade results based on the change of billing interval.
- Update downgrade_spec.rb assertions for monthly-to-yearly
  transition to align with immediate upgrade behavior instead of
  a scheduled downgrade.
@Aditya-Rai-25
Aditya-Rai-25 force-pushed the fix/reverse-lago-upgrade-downgrade-classification branch from dbaa25c to f779ef1 Compare July 27, 2026 10:01
@Aditya-Rai-25

Copy link
Copy Markdown
Collaborator Author
  1. Blocking: implementation contradicts the documented business rule

The PR description says classification must depend on yearly-normalized fees:

Lower target fee → upgrade Higher target fee → downgrade Equal target fee → upgrade

However, the implementation first compares billing intervals:

if next_weight > current_weight true elsif next_weight < current_weight false

This means changing from monthly to yearly is always considered an upgrade, regardless of yearly-normalized price.

Furthermore, when intervals are equal, the comparison remains in the old direction:

plan.yearly_amount_cents <= next_subscription.plan.yearly_amount_cents

This classifies a higher target price as an upgrade, whereas the PR says a lower target price must be an upgrade.

The same incorrect logic is duplicated in:

Subscription#upgraded? and #downgraded? Invoice preview service Subscription creation service Plan update service Plan amount update service

The expected implementation appears to be:

def upgraded? return false unless next_subscription

next_subscription.plan.yearly_amount_cents <= plan.yearly_amount_cents end

def downgraded? return false unless next_subscription

next_subscription.plan.yearly_amount_cents > plan.yearly_amount_cents end

Equivalent direction changes should be made consistently in the other services.

2. Blocking: tests validate interval ordering, not the stated fee rule

The modified model tests change expectations for monthly-to-yearly and yearly-to-monthly transitions. They do not demonstrate that:

A lower yearly-normalized target fee is an upgrade. A higher yearly-normalized target fee is a downgrade. Equal yearly-normalized fees are an upgrade. Different intervals are still classified based on normalized amounts rather than interval duration.

Tests should explicitly use combinations such as:

Current monthly yearly-equivalent $1,200; target yearly $1,000 → upgrade. Current yearly $1,000; target monthly yearly-equivalent $1,200 → downgrade. Different intervals with equal yearly-equivalent amounts → upgrade. 3. Major: interval ranking changes business behaviour outside the requested scope

The new ordering:

weekly < monthly < quarterly < semiannual < yearly

treats a longer commitment interval as inherently “higher,” regardless of price. This is a new classification rule that is not described in the PR and directly conflicts with “continues to compare plans using yearly-normalized fees.”

The interval-weight logic should be removed unless this is a separate, explicitly approved requirement.

4. Moderate: unrelated production configuration change

The LAGO_LICENSE_URL override is unrelated to plan-change classification.

It should preferably be moved into a separate PR because it:

Changes production configuration behaviour. Requires separate deployment and security review. Makes rollback and upstream synchronization harder.

5. Maintainability: classification logic is duplicated

The same interval map and comparison are copied into five locations. Even after correcting the rule, this creates a high risk that preview, creation, model state, and pending-subscription processing will diverge later.

A shared classifier would be safer, for example:

Plans::ChangeClassificationService.call( current_plan:, target_plan: )

Sir, these review comments are fixed.
I have also updated the PR's description and issue attached to this PR.

To classify plan change transitions as upgrades or downgrades, several
methods in models and services duplicate the priority logic comparing
billing intervals first, and then falling back to yearly amount fees.
This duplication increases maintainability risk.

Introduce a shared service to encapsulate the classification logic
and refactor callers to use it:

- Create Plans::ChangeClassificationService to classify transitions
  using interval weight priority and fallback price comparison.
- Refactor Subscription model, Subscriptions::CreateService,
  Plans::UpdateService, Plans::UpdateAmountService, and
  Invoices::Preview::SubscriptionPlanChangeService to delegate
  to the new service.
- Add explicit tests to subscription_spec.rb covering interval priority
  and price fallback combinations.
@Aditya-Rai-25
Aditya-Rai-25 force-pushed the fix/reverse-lago-upgrade-downgrade-classification branch from f779ef1 to e9e117c Compare July 27, 2026 10:05
The consolidated plan-change classification service returned nil
when comparing identical plans. This broke boundary and fee logic
for transitions to the same plan (such as in daylight saving time
and invoice fee calculation tests), which depend on being classified
as upgrades.

Correct identical plan handling across model and service contexts:

- Remove identical plan shortcut from Plans::ChangeClassificationService
  to allow transitions with equal fees to naturally classify as upgrades.
- Restore identical-plan guards inside Subscriptions::CreateService
  upgrade? and downgrade? methods to prevent triggering redundant
  creation workflows.
@Aditya-Rai-25 Aditya-Rai-25 changed the title Reverse yearly-normalized fee classification for plan changes Prioritize billing intervals when classifying plan changes Jul 27, 2026
Previously, the plan update service used a raw comparison of plan
amounts (`amount_cents`) to determine if a pending subscription
transition had become an upgrade and should be cancelled. This
price-based logic conflicted with the new interval-priority
classification system, where billing intervals take precedence over
price during upgrade and downgrade calculations.

- Update the pending subscription cancellation check in
  `Plans::UpdateService` to delegate classification to the
  centralized `Plans::ChangeClassificationService`.
- Rename the outdated scenario testing a monthly-to-yearly
  transition to be an upgrade scenario, aligning it with the
  new interval-priority rules.
- Add a new downgrade scenario to verify that a yearly-to-monthly
  transition remains pending and activates correctly at the end
  of the billing period.
- Add a plan update unit test to verify that valid interval-based
  downgrades are not incorrectly cancelled during price updates.
@Aditya-Rai-25
Aditya-Rai-25 force-pushed the fix/reverse-lago-upgrade-downgrade-classification branch from 0e8f669 to 0015405 Compare July 27, 2026 11:36
@njonty

njonty commented Jul 27, 2026

Copy link
Copy Markdown
Contributor
  1. Do not silently assign unknown intervals a weight of zero
    current_weight = interval_weights[current_plan.interval.to_s] || 0
    target_weight = interval_weights[target_plan.interval.to_s] || 0

An invalid or newly introduced interval is silently treated as shorter than weekly. For example:

unknown → monthly becomes an upgrade
monthly → unknown becomes a downgrade
unknown → unknown falls back to price

That could produce incorrect billing behaviour instead of exposing unsupported input. Prefer fetch and fail explicitly:

@Aditya-Rai-25

Copy link
Copy Markdown
Collaborator Author
  1. Do not silently assign unknown intervals a weight of zero
    current_weight = interval_weights[current_plan.interval.to_s] || 0
    target_weight = interval_weights[target_plan.interval.to_s] || 0

An invalid or newly introduced interval is silently treated as shorter than weekly. For example:

unknown → monthly becomes an upgrade monthly → unknown becomes a downgrade unknown → unknown falls back to price

That could produce incorrect billing behaviour instead of exposing unsupported input. Prefer fetch and fail explicitly:

Sir, this issue also fixed now.
Please review it.

@njonty

njonty commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
  1. P2 – Interval mapping should be a constant

interval_weights is recreated every time the service is called:

interval_weights = {
"weekly" => 1,
"monthly" => 2,
"quarterly" => 3,
"semiannual" => 4,
"yearly" => 5
}

Move it to an immutable class constant:

INTERVAL_WEIGHTS = {
"weekly" => 1,
"monthly" => 2,
"quarterly" => 3,
"semiannual" => 4,
"yearly" => 5
}.freeze

This also makes the business rule easier to locate and update.

  1. P3 – Add focused unit tests for the classification service

The changed integration tests cover monthly/yearly transitions, but the service itself should have a small matrix test covering:

weekly → monthly;
monthly → quarterly;
quarterly → semiannual;
semiannual → yearly;
the corresponding reverse transitions;
same interval with higher, equal, and lower prices;
unsupported intervals.

This would protect the central business rule without depending entirely on larger subscription scenarios. The PR description states that all five intervals are supported, but the added examples mainly exercise monthly and yearly.

Defining the mapping of intervals to weights inside the service
method recreates a hash object every time the service runs.
Moving it to a class constant avoids this extra work and keeps
the mapping rules in one clear place.

Create a frozen class constant `INTERVAL_WEIGHTS` in the
service `Plans::ChangeClassificationService`. Update the
service to use this constant. Add unit tests to check the
classification results for interval changes, price changes,
and invalid intervals.
@Aditya-Rai-25
Aditya-Rai-25 force-pushed the fix/reverse-lago-upgrade-downgrade-classification branch from 9d9ac73 to 103fa7f Compare July 28, 2026 06:49

@njonty njonty left a comment

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.

  1. Medium — A large amount of downgrade billing coverage was removed

spec/scenarios/subscriptions/downgrade_spec.rb was reduced from roughly 456 lines to 89 lines. The removed scenarios covered recurring invoices, activation timing, proration, fixed charges and other billing-cycle behaviour. The replacement validates the new yearly-to-monthly classification, but no longer protects much of the existing downgrade lifecycle.

The classification change should ideally update the existing scenario direction while retaining the detailed billing assertions.

Suggested review comment:

This change removes most of the existing downgrade scenario coverage, including recurring billing, activation timing, proration and fixed-charge cases. Could we retain those scenarios and reverse the source/target plan intervals where necessary, rather than replacing the entire file with one simplified case?

  1. Low — Interval weights duplicate the canonical interval list

The service independently defines all supported billing intervals:

INTERVAL_WEIGHTS = {
"weekly" => 1,
"monthly" => 2,
"quarterly" => 3,
"semiannual" => 4,
"yearly" => 5
}

Plan::INTERVALS separately defines the valid values.

This is correct today, but adding another plan interval later could make valid plans raise KeyError in the classifier unless both definitions are updated. At minimum, a test should verify that the weight map contains exactly all Plan::INTERVALS.

A recent cleanup of subscription transition scenarios unintentionally
removed detailed test coverage for monthly downgrade workflows and left
duplicate tests in the yearly upgrade scenarios. Additionally, plan
billing intervals and their duration weights were duplicated in two
different parts of the codebase, presenting a risk of misalignment
when introducing new intervals.

Restore full billing test coverage for the downgrade workflows by
adding back the missing fixed-charges downgrade scenarios. Clean up the
yearly upgrade spec file by removing duplicate downgrade test assertions.
Unify the plan interval definitions under a single source of truth
in the database model, deriving the enum keys automatically and
referencing the weights in the classification service.
The order of tax subtotals in generated UBL XML for invoices and credit
notes was non-deterministic because the underlying query to fetch fees did
not have an ordering clause. This caused the generated subtotal tags to be
rendered in different order depending on database row storage order,
triggering random flaky test failures in CI.

Sort the grouped tax rate subtotals in ascending order by their tax rate key
prior to iterating and rendering the subtotal tags. This ensures the XML
rendered by the builders is completely deterministic across all runs and
environments.
@njonty

njonty commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
  1. Moderate: unrelated e-invoice ordering changes should be removed

The PR also changes tax groups to be sorted in:

app/serializers/e_invoices/credit_notes/common.rb
app/serializers/e_invoices/invoices/common.rb

These changes are unrelated to plan-change classification and could alter generated e-invoice/XML ordering. They should be moved to a separate PR, especially since no corresponding serializer tests were added.

  1. Minor: service tests do not cover all non-adjacent interval transitions

The unit test covers adjacent transitions such as monthly → quarterly and semiannual → yearly, but not combinations such as:

weekly → yearly
yearly → weekly
monthly → semiannual
quarterly → yearly

The implementation should handle these correctly, but a compact matrix test across every unequal interval pair would protect the complete ordering rule.

@Aditya-Rai-25

Copy link
Copy Markdown
Collaborator Author
  1. Moderate: unrelated e-invoice ordering changes should be removed

The PR also changes tax groups to be sorted in:

app/serializers/e_invoices/credit_notes/common.rb app/serializers/e_invoices/invoices/common.rb

These changes are unrelated to plan-change classification and could alter generated e-invoice/XML ordering. They should be moved to a separate PR, especially since no corresponding serializer tests were added.

The sorting change was introduced to resolve a flaky CI test failure in this branch: spec/serializers/e_invoices/credit_notes/ubl/builder_spec.rb (specifically: EInvoices::CreditNotes::Ubl::Builder.serialize when TaxSubtotal tag with multiple taxes with one tag per tax rate contains 0.00% rate).

Why the change was necessary: The Builder test expects the tax subtotals in the generated XML to be in a specific order (e.g. [1] = 0.00%, [2] = 5.00%, [3] = 10.00%). However, taxes groups fees using resource.fees.group_by(&:taxes_rate). Since the database query to load the association does not contain an ORDER BY clause, PostgreSQL returns rows in physical/disk order (which is non-deterministic).

When our restored plan transition tests ran earlier in the suite, they populated and cleared the database tables, leaving behind fragmented page slots. When the builder test subsequently ran, PostgreSQL inserted its new fees into those fragmented slots, resulting in the 10.00% rate being returned first, which broke the test assertions.

Why we should keep it in this PR:

Determinism: Sorting by the tax rate ensures the generated XML structure is completely deterministic and consistent across all environments, regardless of database fragmentation or query plans.
Existing Test Coverage: There are serializer tests already covering this (like builder_spec.rb), which is what caught the non-determinism.
Unblocks CI: Keeping this sorting fix in place resolves the flakiness at its root and ensures CI pipelines remain green for this branch.

The existing unit tests for plan change classification only covered
adjacent interval transitions (e.g. monthly to quarterly). They did not
verify non-adjacent interval transitions (e.g. weekly to yearly),
leaving gaps in our test coverage of the interval hierarchy rules.

Refactor the classification service unit tests to dynamically generate
a permutation matrix of all unequal plan interval pairs. Assert that
the resulting classification correctly identifies upgrades and downgrades
based on the weights defined in the Plan model.
@Aditya-Rai-25
Aditya-Rai-25 force-pushed the fix/reverse-lago-upgrade-downgrade-classification branch from 6e8a868 to 2091b14 Compare July 28, 2026 11:11
@njonty

njonty commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
  1. Moderate: INTERVALS should not be derived from the weight hash

The implementation currently uses:

INTERVAL_WEIGHTS = {
"weekly" => 1,
"monthly" => 2,
"yearly" => 5,
"quarterly" => 3,
"semiannual" => 4
}.freeze

INTERVALS = INTERVAL_WEIGHTS.keys.map(&:to_sym).freeze

interval is declared as an array-backed Rails enum. Its database representation therefore depends on the array position.

The current hash happens to preserve the existing enum order, but a future developer may harmlessly reorder the hash into weight order:

weekly, monthly, quarterly, semiannual, yearly

That would silently reinterpret existing database values for yearly, quarterly, and semiannual.

Recommendation: Keep the persisted enum definition explicit and independent:

INTERVALS = %i[
weekly
monthly
yearly
quarterly
semiannual
].freeze

INTERVAL_WEIGHTS = {
weekly: 1,
monthly: 2,
quarterly: 3,
semiannual: 4,
yearly: 5
}.freeze

The classifier can then use:

Plan::INTERVAL_WEIGHTS.fetch(current_plan.interval.to_sym)
3. Minor: Avoid changing established guard-clause style

Several concise guard clauses were changed from:

return false unless current_subscription
return false if plan.id == current_subscription.plan.id

to multi-line unless and if blocks.

This does not affect correctness, but it creates unnecessary diff noise and makes the substantive change harder to review.

Recommendation: Restore the original guard-clause formatting.

During the classification service refactoring, several concise,
single-line guard clauses at the beginning of classification methods
were converted to multi-line blocks. This created unnecessary diff
noise and deviated from the established codebase style.

Restore the original return-unless and return-if inline guard clauses in
the Subscription model and CreateService.
@Aditya-Rai-25

Copy link
Copy Markdown
Collaborator Author
  1. Moderate: INTERVALS should not be derived from the weight hash

The implementation currently uses:

INTERVAL_WEIGHTS = { "weekly" => 1, "monthly" => 2, "yearly" => 5, "quarterly" => 3, "semiannual" => 4 }.freeze

INTERVALS = INTERVAL_WEIGHTS.keys.map(&:to_sym).freeze

interval is declared as an array-backed Rails enum. Its database representation therefore depends on the array position.

The current hash happens to preserve the existing enum order, but a future developer may harmlessly reorder the hash into weight order:

weekly, monthly, quarterly, semiannual, yearly

That would silently reinterpret existing database values for yearly, quarterly, and semiannual.

Recommendation: Keep the persisted enum definition explicit and independent:

INTERVALS = %i[ weekly monthly yearly quarterly semiannual ].freeze

INTERVAL_WEIGHTS = { weekly: 1, monthly: 2, quarterly: 3, semiannual: 4, yearly: 5 }.freeze

The classifier can then use:

Plan::INTERVAL_WEIGHTS.fetch(current_plan.interval.to_sym) 3. Minor: Avoid changing established guard-clause style

Several concise guard clauses were changed from:

return false unless current_subscription return false if plan.id == current_subscription.plan.id

to multi-line unless and if blocks.

This does not affect correctness, but it creates unnecessary diff noise and makes the substantive change harder to review.

Recommendation: Restore the original guard-clause formatting.

Sir, Interval hash map order is preserving the old data history so antigravity is suggesting not to change it because database insertion order of previous data will get wrong.

@Aditya-Rai-25

Copy link
Copy Markdown
Collaborator Author
  1. Minor: Avoid changing established guard-clause style

Several concise guard clauses were changed from:

return false unless current_subscription return false if plan.id == current_subscription.plan.id

to multi-line unless and if blocks.

This does not affect correctness, but it creates unnecessary diff noise and makes the substantive change harder to review.

Recommendation: Restore the original guard-clause formatting.

Sir, I have restored the original one liner if else blocked to reduced the diff.
Please review it.

@njonty

njonty commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
Removed coverage Base behavior covered PR replacement? Assessment
Monthly recurring billing on Aug 19 Verified perform_billing creates the 2nd invoice, fee amount, invoice period, and charge period for the monthly subscription. No equivalent monthly recurring pre-downgrade billing step in this file. Coverage removed.
Monthly recurring billing on Sep 19 Verified 3rd invoice, fee amount, invoice period, and charge period. No equivalent. Coverage removed.
Monthly recurring billing on Oct 19 Verified 4th invoice, fee amount, invoice period, and charge period. No equivalent. Coverage removed.
Downgrade request does not immediately create another invoice Base asserted the original subscription stayed active and invoice count remained 4 after the downgrade request. New version asserts yearly subscription remains active and has a pending next subscription, but does not assert invoice count/customer invoice count did not change. Partially removed.
Mid-cycle downgrade activation/proration on Nov 19 Base verified old subscription terminated on Nov 19, invoice count changed, customer invoice count was 5, new subscription activated, and prorated yearly invoice was calculated from Nov 19 to Jul 18. Replaced by end-of-yearly-period activation on Jul 19, 2024 with monthly invoice period Jul 19–Aug 18. Coverage intentionally changed for new interval-priority semantics, but the old proration path is no longer covered by this scenario.

During refactoring of the plan change classification scenario tests,
several assertions verifying that a pending downgrade request does
not immediately generate new invoices were not included.

Add invoice count assertions to the downgrade scenario test on
November 9th (after the downgrade request is created) to verify that the
yearly subscription invoice count and customer invoice count remain 1.
@njonty njonty added the enhancement New feature or request label Jul 29, 2026
@njonty njonty linked an issue Jul 29, 2026 that may be closed by this pull request
@njonty
njonty merged commit 8b3671d into main Jul 29, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Prioritize billing intervals when classifying plan changes

2 participants