From 0e08a5be4830a4fb3eff481dffc0499ce6118d40 Mon Sep 17 00:00:00 2001 From: Anna Velentsevich Date: Thu, 16 Oct 2025 20:05:37 +0200 Subject: [PATCH 1/3] squashed commit --- app/models/fixed_charge.rb | 11 +- app/models/subscription.rb | 5 +- .../subscriptions/dates/semiannual_service.rb | 26 ++ app/services/subscriptions/dates_service.rb | 18 +- spec/models/fixed_charge_spec.rb | 67 ++++- spec/models/subscription_spec.rb | 4 + spec/requests/api/v1/plans_controller_spec.rb | 4 +- .../dates/semiannual_service_spec.rb | 276 ++++++++++++++++++ .../subscriptions/dates_service_spec.rb | 76 +++++ 9 files changed, 477 insertions(+), 10 deletions(-) diff --git a/app/models/fixed_charge.rb b/app/models/fixed_charge.rb index b55177f7867..307f09a3cc1 100644 --- a/app/models/fixed_charge.rb +++ b/app/models/fixed_charge.rb @@ -21,6 +21,7 @@ class FixedCharge < ApplicationRecord has_many :taxes, through: :applied_taxes scope :pay_in_advance, -> { where(pay_in_advance: true) } + scope :pay_in_arrears, -> { where(pay_in_advance: false) } CHARGE_MODELS = { standard: "standard", @@ -33,13 +34,19 @@ class FixedCharge < ApplicationRecord validates :units, numericality: {greater_than_or_equal_to: 0} validates :charge_model, presence: true - validates :pay_in_advance, inclusion: {in: [true, false]} - validates :prorated, inclusion: {in: [true, false]} + validates :pay_in_advance, exclusion: [nil] + validates :prorated, exclusion: [nil] validates :properties, presence: true def equal_properties?(fixed_charge) charge_model == fixed_charge.charge_model && properties == fixed_charge.properties end + + def included_in_next_subscription?(subscription) + return false if subscription.next_subscription.nil? + + subscription.next_subscription.plan.fixed_charges.exists?(add_on_id:) + end end # == Schema Information diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 9636c65cdf4..5733a95f853 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -238,8 +238,11 @@ def adjusted_boundaries(datetime, boundaries) to_datetime: dates_service.to_datetime, charges_from_datetime: dates_service.charges_from_datetime, charges_to_datetime: dates_service.charges_to_datetime, + fixed_charges_from_datetime: dates_service.fixed_charges_from_datetime, + fixed_charges_to_datetime: dates_service.fixed_charges_to_datetime, timestamp: datetime, - charges_duration: dates_service.charges_duration_in_days + charges_duration: dates_service.charges_duration_in_days, + fixed_charges_duration: dates_service.fixed_charges_duration_in_days ) InvoiceSubscription.matching?(self, previous_period_boundaries) ? boundaries : previous_period_boundaries diff --git a/app/services/subscriptions/dates/semiannual_service.rb b/app/services/subscriptions/dates/semiannual_service.rb index d2df471f179..c76be52a63a 100644 --- a/app/services/subscriptions/dates/semiannual_service.rb +++ b/app/services/subscriptions/dates/semiannual_service.rb @@ -55,6 +55,32 @@ def compute_charges_to_date compute_to_date(compute_charges_from_date) end + def compute_fixed_charges_from_date + return monthly_service.compute_fixed_charges_from_date if plan.bill_fixed_charges_monthly + + if terminated? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_half_year + end + + return compute_from_date if plan.pay_in_arrears? + return base_date.beginning_of_half_year if calendar? + + previous_anniversary_day(base_date) + end + + def compute_fixed_charges_to_date + return monthly_service.compute_fixed_charges_to_date if plan.bill_fixed_charges_monthly + return compute_fixed_charges_from_date.end_of_half_year if calendar? + + compute_to_date(compute_fixed_charges_from_date) + end + + def compute_fixed_charges_duration(from_date:) + return monthly_service.compute_fixed_charges_duration(from_date:) if plan.bill_fixed_charges_monthly + + compute_duration(from_date:) + end + def compute_duration(from_date:) next_to_date = compute_to_date(from_date) diff --git a/app/services/subscriptions/dates_service.rb b/app/services/subscriptions/dates_service.rb index 3451f531a41..d10178352b4 100644 --- a/app/services/subscriptions/dates_service.rb +++ b/app/services/subscriptions/dates_service.rb @@ -34,9 +34,21 @@ def self.charge_pay_in_advance_interval(timestamp, subscription) { charges_from_date: date_service.charges_from_datetime&.to_date, - charges_to_date: date_service.charges_to_datetime&.to_date, - fixed_charges_from_date: date_service.fixed_charges_from_datetime&.to_date, - fixed_charges_to_date: date_service.fixed_charges_to_datetime&.to_date + charges_to_date: date_service.charges_to_datetime&.to_date + } + end + + def self.fixed_charge_pay_in_advance_interval(timestamp, subscription) + date_service = new_instance( + subscription, + Time.zone.at(timestamp), + current_usage: true + ) + + { + fixed_charges_from_datetime: date_service.fixed_charges_from_datetime, + fixed_charges_to_datetime: date_service.fixed_charges_to_datetime, + fixed_charges_duration: date_service.fixed_charges_duration_in_days } end diff --git a/spec/models/fixed_charge_spec.rb b/spec/models/fixed_charge_spec.rb index 0ea9ce7dec4..fb1cb5b48d8 100644 --- a/spec/models/fixed_charge_spec.rb +++ b/spec/models/fixed_charge_spec.rb @@ -20,10 +20,42 @@ it { is_expected.to validate_numericality_of(:units).is_greater_than_or_equal_to(0) } it { is_expected.to validate_presence_of(:charge_model) } - it { is_expected.to validate_inclusion_of(:pay_in_advance).in_array([true, false]) } - it { is_expected.to validate_inclusion_of(:prorated).in_array([true, false]) } + it { is_expected.to validate_exclusion_of(:pay_in_advance).in_array([nil]) } + it { is_expected.to validate_exclusion_of(:prorated).in_array([nil]) } it { is_expected.to validate_presence_of(:properties) } + describe "scopes" do + let(:scoped) { create(:fixed_charge) } + let(:deleted) { create(:fixed_charge, :deleted) } + let(:pay_in_advance) { create(:fixed_charge, pay_in_advance: true) } + let(:pay_in_arrears) { create(:fixed_charge, pay_in_advance: false) } + + before do + scoped + deleted + pay_in_advance + pay_in_arrears + end + + describe ".all" do + it "returns all not deleted fixed charges" do + expect(described_class.all).to match_array([scoped, pay_in_advance, pay_in_arrears]) + end + end + + describe ".pay_in_advance" do + it "returns only pay_in_advance fixed charges" do + expect(described_class.pay_in_advance).to match_array([pay_in_advance]) + end + end + + describe ".pay_in_arrears" do + it "returns only pay_in_arrears fixed charges" do + expect(described_class.pay_in_arrears).to match_array([pay_in_arrears, scoped]) + end + end + end + describe "#equal_properties?" do let(:fixed_charge1) { build(:fixed_charge, properties: {amount: 100}) } @@ -51,4 +83,35 @@ end end end + + describe "#included_in_next_subscription?" do + let(:add_on) { build(:add_on) } + let(:fixed_charge) { build(:fixed_charge, add_on:) } + let(:subscription) { create(:subscription, plan: fixed_charge.plan) } + let(:next_subscription) { create(:subscription, :with_previous_subscription, previous_subscription: subscription) } + + context "when the fixed charge is included in the next subscription" do + before { next_subscription.plan.fixed_charges = [fixed_charge] } + + it "returns true" do + expect(fixed_charge.included_in_next_subscription?(subscription)).to be true + end + end + + context "when the fixed charge is not included in the next subscription" do + before { next_subscription.plan.fixed_charges = [] } + + it "returns false" do + expect(fixed_charge.included_in_next_subscription?(subscription)).to be false + end + end + + context "when there is no next subscription" do + let(:next_subscription) { nil } + + it "returns false" do + expect(fixed_charge.included_in_next_subscription?(subscription)).to be false + end + end + end end diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb index a5d354f6e79..5ad28d6211a 100644 --- a/spec/models/subscription_spec.rb +++ b/spec/models/subscription_spec.rb @@ -662,6 +662,8 @@ to_datetime: date_service.to_datetime, charges_from_datetime: date_service.charges_from_datetime, charges_to_datetime: date_service.charges_to_datetime, + fixed_charges_from_datetime: date_service.fixed_charges_from_datetime, + fixed_charges_to_datetime: date_service.fixed_charges_to_datetime, timestamp: billing_date } end @@ -696,6 +698,8 @@ expect(new_boundaries.to_datetime.iso8601).to eq("2024-05-31T23:59:59Z") expect(new_boundaries.charges_from_datetime.iso8601).to eq("2024-05-01T00:00:00Z") expect(new_boundaries.charges_to_datetime.iso8601).to eq("2024-05-31T23:59:59Z") + expect(new_boundaries.fixed_charges_from_datetime.iso8601).to eq("2024-05-01T00:00:00Z") + expect(new_boundaries.fixed_charges_to_datetime.iso8601).to eq("2024-05-31T23:59:59Z") end end end diff --git a/spec/requests/api/v1/plans_controller_spec.rb b/spec/requests/api/v1/plans_controller_spec.rb index 52d1cf2f19e..993b25ff667 100644 --- a/spec/requests/api/v1/plans_controller_spec.rb +++ b/spec/requests/api/v1/plans_controller_spec.rb @@ -768,7 +768,7 @@ context "when editing a fixed charge" do let(:plan) { create(:plan, organization:, interval: :weekly) } - let(:subscription) { create(:subscription, :active, :anniversary, plan:, started_at:, subscription_at: started_at) } + let(:subscription) { create(:subscription, :active, :calendar, plan:, started_at: started_at) } let(:fixed_charge) { create(:fixed_charge, plan:, add_on:, units: 1) } let(:started_at) { 3.days.ago } @@ -833,7 +833,7 @@ subscription:, fixed_charge:, units: 25, - timestamp: be_within(1.second).of((started_at + 1.week).beginning_of_day) + timestamp: be_within(1.second).of((started_at + 1.week).beginning_of_week) ) end end diff --git a/spec/services/subscriptions/dates/semiannual_service_spec.rb b/spec/services/subscriptions/dates/semiannual_service_spec.rb index eaeb6e9157e..c44cc457911 100644 --- a/spec/services/subscriptions/dates/semiannual_service_spec.rb +++ b/spec/services/subscriptions/dates/semiannual_service_spec.rb @@ -538,6 +538,225 @@ end end + describe "fixed_charges_from_datetime" do + let(:result) { date_service.fixed_charges_from_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.fixed_charges_from_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when timezone has changed" do + let(:billing_at) { Time.zone.parse("02 Jul 2022") } + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + fixed_charges_to_datetime: "2021-12-31T23:59:59Z" + ) + end + + before do + previous_invoice_subscription + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes previous invoice into account" do + expect(result).to match_datetime("2022-01-01 00:00:00") + end + end + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Apr 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("01 Jan 2020") } + + it "returns the start of the previous period" do + expect(result).to eq("2022-01-01 00:00:00 UTC") + end + end + + context "when billing fixed charges monthly" do + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns the begining of the previous month" do + expect(result).to eq("2022-06-01 00:00:00 UTC") + end + + context "when subscription started in the middle of a period" do + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Aug 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 May 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + end + end + end + + describe "fixed_charges_to_datetime" do + let(:result) { date_service.fixed_charges_to_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.fixed_charges_to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.to_datetime.to_s) + end + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("15 Jun 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + + context "when billing fixed charges monthly" do + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("05 Mar 2022") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + + before { subscription.mark_as_terminated!(terminated_at) } + + it "returns the terminated_at date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + + it "returns the end of the current period" do + expect(result).to eq("2022-02-28 23:59:59 UTC") + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 May 2022") } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("15 Apr 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + end + end + describe "next_end_of_period" do let(:result) { date_service.next_end_of_period.to_s } @@ -745,6 +964,63 @@ end end + describe "fixed_charges_duration_in_days" do + let(:result) { date_service.fixed_charges_duration_in_days } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the quarter duration" do + expect(result).to eq(181) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("28 Feb 2019") } + let(:billing_at) { Time.zone.parse("01 Jul 2020") } + + it "returns the duration in days" do + expect(result).to eq(182) + end + end + + context "when billing charge monthly" do + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns the month duration" do + expect(result).to eq(30) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:subscription_at) { Time.zone.parse("01 Jan 2024") } + let(:billing_at) { Time.zone.parse("01 Jul 2024") } + + it "returns the month duration" do + expect(result).to eq(182) + end + + context "when not on a leap year" do + let(:subscription_at) { Time.zone.parse("01 Jan 2023") } + let(:billing_at) { Time.zone.parse("01 Jul 2023") } + + it "returns the duration in days" do + expect(result).to eq(181) + end + end + + context "when billing charge monthly" do + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns the month duration" do + expect(result).to eq(30) + end + end + end + end + describe "first_month_in_semiannual_period?" do let(:result) { date_service.first_month_in_semiannual_period? } diff --git a/spec/services/subscriptions/dates_service_spec.rb b/spec/services/subscriptions/dates_service_spec.rb index a415df8603a..6e835e48328 100644 --- a/spec/services/subscriptions/dates_service_spec.rb +++ b/spec/services/subscriptions/dates_service_spec.rb @@ -150,4 +150,80 @@ .to raise_error(NotImplementedError) end end + + describe ".fixed_charge_pay_in_advance_interval" do + let(:timestamp) { Time.zone.parse("2022-03-07 04:20:46.011").to_i } + let(:result) { described_class.fixed_charge_pay_in_advance_interval(timestamp, subscription) } + # subscription is anniversary, subscription_at is 02 Feb 2021, Tuesday + + context "when interval is monthly" do + let(:interval) { :monthly } + + it "returns the correct fixed charge interval data" do + expect(result).to include( + fixed_charges_from_datetime: Time.parse("2022-03-02").utc.beginning_of_day, + fixed_charges_to_datetime: Time.parse("2022-04-01").utc.end_of_day, + fixed_charges_duration: 31 + ) + end + + it "creates a date service instance with current_usage: true" do + allow(described_class).to receive(:new_instance).and_call_original + + result + + expect(described_class).to have_received(:new_instance) + .with(subscription, Time.zone.at(timestamp), current_usage: true) + end + end + + context "when interval is yearly" do + let(:interval) { :yearly } + + it "returns the correct fixed charge interval data" do + expect(result).to include( + fixed_charges_from_datetime: Time.parse("2022-02-02").utc.beginning_of_day, + fixed_charges_to_datetime: Time.parse("2023-02-01").utc.end_of_day, + fixed_charges_duration: 365 + ) + end + end + + context "when interval is semiannual" do + let(:interval) { :semiannual } + + it "returns the correct fixed charge interval data" do + expect(result).to include( + fixed_charges_from_datetime: Time.parse("2022-02-02").utc.beginning_of_day, + fixed_charges_to_datetime: Time.parse("2022-08-01").utc.end_of_day, + fixed_charges_duration: 181 + ) + end + end + + context "when interval is quarterly" do + let(:interval) { :quarterly } + + it "returns the correct fixed charge interval data" do + expect(result).to include( + fixed_charges_from_datetime: Time.parse("2022-02-02").utc.beginning_of_day, + fixed_charges_to_datetime: Time.parse("2022-05-01").utc.end_of_day, + fixed_charges_duration: 89 + ) + end + end + + context "when interval is weekly" do + let(:interval) { :weekly } + + # 2022-03-01 is Tuesday + it "returns the correct fixed charge interval data" do + expect(result).to include( + fixed_charges_from_datetime: Time.parse("2022-03-01").utc.beginning_of_day, + fixed_charges_to_datetime: Time.parse("2022-03-07").utc.end_of_day, + fixed_charges_duration: 7 + ) + end + end + end end From c1cfdadd61abb49768609d38b673ca91251d0d04 Mon Sep 17 00:00:00 2001 From: Anna Velentsevich Date: Thu, 23 Oct 2025 10:53:07 +0200 Subject: [PATCH 2/3] small fixes --- app/models/subscription.rb | 3 ++- spec/requests/api/v1/plans_controller_spec.rb | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 5733a95f853..c8b8f06ed25 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -245,7 +245,8 @@ def adjusted_boundaries(datetime, boundaries) fixed_charges_duration: dates_service.fixed_charges_duration_in_days ) - InvoiceSubscription.matching?(self, previous_period_boundaries) ? boundaries : previous_period_boundaries + # TODO: remove except when fixed charges are fully life + InvoiceSubscription.matching?(self, previous_period_boundaries.except(:fixed_charges_from_datetime, :fixed_charges_to_datetime, :fixed_charges_duration)) ? boundaries : previous_period_boundaries end end diff --git a/spec/requests/api/v1/plans_controller_spec.rb b/spec/requests/api/v1/plans_controller_spec.rb index 993b25ff667..52d1cf2f19e 100644 --- a/spec/requests/api/v1/plans_controller_spec.rb +++ b/spec/requests/api/v1/plans_controller_spec.rb @@ -768,7 +768,7 @@ context "when editing a fixed charge" do let(:plan) { create(:plan, organization:, interval: :weekly) } - let(:subscription) { create(:subscription, :active, :calendar, plan:, started_at: started_at) } + let(:subscription) { create(:subscription, :active, :anniversary, plan:, started_at:, subscription_at: started_at) } let(:fixed_charge) { create(:fixed_charge, plan:, add_on:, units: 1) } let(:started_at) { 3.days.ago } @@ -833,7 +833,7 @@ subscription:, fixed_charge:, units: 25, - timestamp: be_within(1.second).of((started_at + 1.week).beginning_of_week) + timestamp: be_within(1.second).of((started_at + 1.week).beginning_of_day) ) end end From 0d3a7e17bd1e507b33e1afd1be1c3ae51fc3450d Mon Sep 17 00:00:00 2001 From: Anna Velentsevich Date: Thu, 23 Oct 2025 11:11:52 +0200 Subject: [PATCH 3/3] remove doubts about adding fixed_charge boundaries into invoice subscription matching --- app/models/subscription.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/models/subscription.rb b/app/models/subscription.rb index c8b8f06ed25..5733a95f853 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -245,8 +245,7 @@ def adjusted_boundaries(datetime, boundaries) fixed_charges_duration: dates_service.fixed_charges_duration_in_days ) - # TODO: remove except when fixed charges are fully life - InvoiceSubscription.matching?(self, previous_period_boundaries.except(:fixed_charges_from_datetime, :fixed_charges_to_datetime, :fixed_charges_duration)) ? boundaries : previous_period_boundaries + InvoiceSubscription.matching?(self, previous_period_boundaries) ? boundaries : previous_period_boundaries end end