Skip to content

Commit

Permalink
Non-anniversary amendments and CPQ 'Month + Day' prorations (#1058)
Browse files Browse the repository at this point in the history
  • Loading branch information
nadaismail-stripe authored Apr 5, 2023
1 parent 4417290 commit 935e95a
Show file tree
Hide file tree
Showing 12 changed files with 1,204 additions and 88 deletions.
12 changes: 12 additions & 0 deletions lib/stripe-force/constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,14 @@ module Constants
CPQ_QUOTE_ORDERED = 'SBQQ__Ordered__c'
CPQ_QUOTE_PRIMARY = 'SBQQ__Primary__c'
CPQ_QUOTE_SUBSCRIPTION_START_DATE = 'SBQQ__StartDate__c'
CPQ_QUOTE_SUBSCRIPTION_END_DATE = 'SBQQ__EndDate__c'
CPQ_QUOTE_SUBSCRIPTION_TERM = 'SBQQ__SubscriptionTerm__c'
CPQ_QUOTE_SUBSCRIPTION_PRICING = 'SBQQ__SubscriptionPricing__c'
CPQ_QUOTE_QUANTITY = 'SBQQ__Quantity__c'
CPQ_PRORATE_MULTIPLIER = 'SBQQ__ProrateMultiplier__c'

CPQ_DEFAULT_SUBSCRIPTION_TERM = 'SBQQ__DefaultSubscriptionTerm__c'
CPQ_QUOTE_LINE_DEFAULT_SUBSCRIPTION_TERM = 12

CPQ_QUOTE_BILLING_FREQUENCY = 'SBQQ__BillingFrequency__c'
class CPQBillingFrequencyOptions < T::Enum
Expand Down Expand Up @@ -204,6 +209,9 @@ class FeatureFlags < T::Enum
ACCOUNT_POLLING = new('account_polling')
COUPONS = new('coupons')
TERMINATED_ORDER_ITEM_CREDIT = new('terminated_order_item_credit')
NON_ANNIVERSARY_AMENDMENTS = new('non_anniversary_amendments')
# DAY_PRORATIONS should only be enabled if CPQ Subscription Prorate Precision = 'Month + Day'
DAY_PRORATIONS = new('day_prorations')
AUTO_ADVANCE_PRORATION_INVOICE = new('auto_advance_proration_invoices')
PREBILLING = new('prebilling')
end
Expand All @@ -221,5 +229,9 @@ class MetadataKeys < T::Enum
end
end

# time related constants
SECONDS_IN_DAY = 86400
DAYS_IN_YEAR = 365
MONTHS_IN_YEAR = 12
end
end
63 changes: 48 additions & 15 deletions lib/stripe-force/translate/order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ def create_stripe_transaction_from_sf_order(sf_order)
end

# TODO should file a Stripe API papercut for this, weird to have the start date on the header but the end date on the phase
# when creating the subscription schedule the start_date must be specified on the heaer
# when creating the subscription schedule the start_date must be specified on the header
# when updating it, it is specified on the individual phase object

subscription_start_date_as_timestamp = StripeForce::Utilities::SalesforceUtil.salesforce_date_to_unix_timestamp(subscription_schedule.start_date)
Expand Down Expand Up @@ -213,11 +213,11 @@ def generate_phases_for_initial_order(sf_order:, invoice_items:, subscription_it
start_date_from_salesforce = DateTime.parse(string_start_date_from_salesforce)
subscription_start_date_as_timestamp = StripeForce::Utilities::SalesforceUtil.salesforce_date_to_unix_timestamp(string_start_date_from_salesforce)

subscription_term_from_sales_force = subscription_schedule['iterations'].to_i
subscription_term_from_salesforce = subscription_schedule['iterations'].to_i

# originally `iterations` was used, but this fails when subscription term is less than a single billing cycle
initial_phase_end_date = StripeForce::Utilities::SalesforceUtil.datetime_to_unix_timestamp(
start_date_from_salesforce + subscription_term_from_sales_force.months
start_date_from_salesforce + subscription_term_from_salesforce.months
)

# TODO we should have a check to ensure all quantities are positive
Expand Down Expand Up @@ -251,7 +251,7 @@ def generate_phases_for_initial_order(sf_order:, invoice_items:, subscription_it

is_initial_order_prorated = OrderHelpers.prorated_initial_order?(
phase_items: subscription_items,
subscription_term: subscription_term_from_sales_force,
subscription_term: subscription_term_from_salesforce,
billing_frequency: billing_frequency
)

Expand All @@ -268,7 +268,7 @@ def generate_phases_for_initial_order(sf_order:, invoice_items:, subscription_it
mapper: mapper,
sf_order_amendment: sf_order,
phase_items: subscription_items,
subscription_term: subscription_term_from_sales_force,
subscription_term: subscription_term_from_salesforce,
billing_frequency: billing_frequency,
)

Expand All @@ -278,8 +278,8 @@ def generate_phases_for_initial_order(sf_order:, invoice_items:, subscription_it
invoice_item[:period][:end][:type] = 'phase_end'
end

backend_proration_term = subscription_term_from_sales_force % billing_frequency
backend_proration_start_date = start_date_from_salesforce + (subscription_term_from_sales_force - backend_proration_term).months
backend_proration_term = subscription_term_from_salesforce % billing_frequency
backend_proration_start_date = start_date_from_salesforce + (subscription_term_from_salesforce - backend_proration_term).months
backend_proration_start_date_timestamp = StripeForce::Utilities::SalesforceUtil.datetime_to_unix_timestamp(backend_proration_start_date)

prorated_phase = {
Expand Down Expand Up @@ -362,8 +362,7 @@ def update_subscription_phases_from_order_amendments(contract_structure)
# at this point, the initial order would have already been translated
# and a corresponding subscription schedule created.

subscription_schedule = T.cast(subscription_schedule, Stripe::SubscriptionSchedule)

# verify that all the amendment orders co-terminate with the initial order
if !OrderAmendment.contract_co_terminated?(mapper, contract_structure)
raise StripeForce::Errors::RawUserError.new("Order amendments must coterminate with the initial order")
end
Expand Down Expand Up @@ -457,13 +456,14 @@ def update_subscription_phases_from_order_amendments(contract_structure)

# TODO check for float value
# TODO should probably move iteration extraction to another helper
subscription_term_from_sales_force = phase_params.delete('iterations').to_i
subscription_term_from_salesforce = phase_params.delete('iterations').to_i

# originally `iterations` was used, but this fails when subscription term is less than a single billing cycle

# we need to normalize the amendment order end date to take into account a special cases
# we need to normalize the amendment order end date to take into account special cases
# that can occur when then initial order starts on a day of the month that doesn't exist in the amendment month
sf_order_amendment_end_date = StripeForce::Utilities::SalesforceUtil.normalize_sf_order_amendment_end_date(mapper: mapper, sf_order_amendment: sf_order_amendment, sf_initial_order: contract_structure.initial)
# at this point, we have verified the order amendment end date is equal to the initial order
phase_params['end_date'] = StripeForce::Utilities::SalesforceUtil.datetime_to_unix_timestamp(sf_order_amendment_end_date)

# TODO should we validate the end date vs the subscription schedule?
Expand All @@ -484,7 +484,7 @@ def update_subscription_phases_from_order_amendments(contract_structure)
mapper: mapper,
sf_order_amendment: sf_order_amendment,
terminated_phase_items: terminated_phase_items,
subscription_term: subscription_term_from_sales_force,
subscription_term: subscription_term_from_salesforce,
billing_frequency: billing_frequency,
)
end
Expand All @@ -506,22 +506,55 @@ def update_subscription_phases_from_order_amendments(contract_structure)

# these params in an ideal world should be pulled from the `subscription_schedule`, but
# they are tricky to extract without additional API calls
subscription_term: subscription_term_from_sales_force,
subscription_term: subscription_term_from_salesforce,
billing_frequency: billing_frequency,
amendment_start_date: sf_order_amendment_start_date_as_timestamp
amendment_start_date: sf_order_amendment_start_date_as_timestamp,
)
end

invoice_items_for_prorations = []

if !is_order_terminated && is_prorated
# the subscription term represents the number of whole months
# the days prorating represents the partial month (or days) remaining
subscription_term = subscription_term_from_salesforce
days_prorating = 0

if @user.feature_enabled?(FeatureFlags::NON_ANNIVERSARY_AMENDMENTS)
days = StripeForce::Utilities::SalesforceUtil.calculate_days_to_prorate(
sf_order_start_date: StripeForce::Utilities::SalesforceUtil.salesforce_date_to_beginning_of_day(string_start_date_from_salesforce),
sf_order_end_date: sf_order_amendment_end_date,
sf_order_subscription_term: subscription_term)

# CPQ Proration Calculations
# https://help.salesforce.com/s/articleView?id=sf.cpq_subscriptions_prorate_precision_1.htm&type=5
# in CPQ, the proration multiple is the number of whole months plus a decimal for any partial month at the end of the term
# which is represented by the subscription term and the days below
# depending on the CPQ setting <=> feature flag enabled, the calculation will differ
if days > 0
# if feature DAY_PRORATIONS is enabled, set the number of days to prorate
# else, a partial month equals a whole month so add one to the subscription term
if @user.feature_enabled?(FeatureFlags::DAY_PRORATIONS)
log.info 'prorating line items by days', days_prorating: days
days_prorating = days
else
# the subscription term represents the number of whole months
# plus a decimal for any partial month at the end of the term if your term contains a partial month (days_prorating > 0)
# so we round the number of months to the nearest whole number (if there are days) by adding one since the subscription_term
log.info 'prorating line items by months but accounting for partial month', days_prorating: days
subscription_term += 1
end
end
end

invoice_items_for_prorations = OrderAmendment.generate_proration_items_from_phase_items(
user: @user,
mapper: mapper,
sf_order_amendment: sf_order_amendment,
phase_items: aggregate_phase_items,
subscription_term: subscription_term_from_sales_force,
subscription_term: subscription_term,
billing_frequency: billing_frequency,
days_prorating: days_prorating
)
end

Expand Down
79 changes: 49 additions & 30 deletions lib/stripe-force/translate/order/amendments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,54 +82,76 @@ def self.contract_co_terminated?(mapper, contract_structure)
initial_order_end_date = StripeForce::Utilities::SalesforceUtil.calculate_order_end_date(mapper, contract_structure.initial)

# the end date must be the same for all order amendments
amendment_end_dates = contract_structure.amendments.map do |sf_order_amendment|
amendment_orders_end_dates = contract_structure.amendments.map do |sf_order_amendment|
StripeForce::Utilities::SalesforceUtil.normalize_sf_order_amendment_end_date(mapper: mapper, sf_order_amendment: sf_order_amendment, sf_initial_order: contract_structure.initial)
end

is_co_terminated = amendment_end_dates.all? do |sf_amendment_end_date|
is_co_terminated = amendment_orders_end_dates.all? do |sf_amendment_end_date|
sf_amendment_end_date.to_i == initial_order_end_date.to_i
end

if !is_co_terminated
log.info 'order is not coterminated',
log.info 'amendment order is not coterminated',
initial_end_date: initial_order_end_date,
amendment_end_dates: amendment_end_dates
amendment_end_dates: amendment_orders_end_dates
end

is_co_terminated
end

# NOTE at this point it's assumed that the price is NOT a metered billing item or tiered price
sig do
params(
user: StripeForce::User,
phase_item: ContractItemStructure,
stripe_price: Stripe::Price,
subscription_term: Integer,
billing_frequency: Integer
).returns(Stripe::Price)
product_subscription_term: Integer,
billing_frequency: Integer,
days_prorating: Integer,
).returns(BigDecimal)
end
def self.create_prorated_price_from_phase_item(user:, phase_item:, subscription_term:, billing_frequency:)
# at this point, we know what the billing amount is per billing cycle, since that has alread been defined
# on the price object at the order line. We calculate the percentage of the original line price that should
# be billed and multiply that against the decimal price on the stripe price.

# TODO validate that it isn't a tiered or metered billing price

# TODO we'll need to have some sort of logic for backend prorations to calculate the amount before
# a billing cycle that needs to be billed for, but let's deal with that later...

def self.calculate_prorated_billing_amount(stripe_price:, subscription_term:, product_subscription_term:, billing_frequency:, days_prorating:)
# prorated billing amount is months of subscription term that is not included
prorated_subscription_term = subscription_term % billing_frequency
proration_percentage = BigDecimal(prorated_subscription_term) / BigDecimal(billing_frequency)

if days_prorating > 0
# at this point, we know feature DAY_PRORATIONS is enabled since days_prorating is non-zero
# and we calculate the partial month value in our prorate multiplier like CPQ-based calculations
# https://help.salesforce.com/s/articleView?id=sf.cpq_subscriptions_prorate_precision_1.htm&type=5
proration_percentage = StripeForce::Utilities::SalesforceUtil.calculate_month_plus_day_price_multiple(whole_months: (subscription_term % billing_frequency), partial_month_days: days_prorating, product_subscription_term: product_subscription_term)
end

# https://jira.corp.stripe.com/browse/PLATINT-1808
if prorated_subscription_term.zero?
if prorated_subscription_term.zero? && days_prorating == 0
log.warn 'subscription term is equal to billing frequency, amendment is most likely happening on the same day'
proration_percentage = 1
end

stripe_price = phase_item.price(user)
unit_amount_decimal = BigDecimal(stripe_price.unit_amount_decimal)
prorated_billing_amount = unit_amount_decimal * proration_percentage
prorated_billing_amount
end

# NOTE at this point it's assumed that the price is NOT a metered billing item or tiered price
sig do
params(
mapper: StripeForce::Mapper,
phase_item: ContractItemStructure,
subscription_term: Integer,
billing_frequency: Integer,
days_prorating: Integer
).returns(Stripe::Price)
end
def self.create_prorated_price_from_phase_item(mapper:, phase_item:, subscription_term:, billing_frequency:, days_prorating:)
# at this point, we know what the billing amount is per billing cycle, since that has already been defined
# on the price object at the order line. We calculate the percentage of the original line price that should
# be billed and multiply that against the decimal price on the stripe price.
# we also have validated that it isn't a tiered or metered billing price
user = mapper.user
stripe_price = phase_item.price(user)
sf_order_item = phase_item.order_line
sf_order_amendment = user.sf_client.find(SF_ORDER, sf_order_item["OrderId"])
product_subscription_term = StripeForce::Utilities::SalesforceUtil.determine_quote_line_subscription_term(mapper, sf_order_item, sf_order_amendment)
prorated_billing_amount = calculate_prorated_billing_amount(stripe_price: stripe_price, subscription_term: subscription_term, product_subscription_term: product_subscription_term, billing_frequency: billing_frequency, days_prorating: days_prorating)

proration_price = OrderHelpers.duplicate_stripe_price(user, stripe_price) do |duplicated_stripe_price|
duplicated_stripe_price.metadata[StripeForce::Translate::Metadata.metadata_key(user, MetadataKeys::PRORATION)] = true
Expand Down Expand Up @@ -287,9 +309,10 @@ def self.generate_proration_credits_from_terminated_phase_items(user:, mapper:,
phase_items: T::Array[ContractItemStructure],
subscription_term: Integer,
billing_frequency: Integer,
days_prorating: Integer,
).returns(T::Array[T::Hash[Symbol, T.untyped]])
end
def self.generate_proration_items_from_phase_items(user:, mapper:, sf_order_amendment:, phase_items:, subscription_term:, billing_frequency:)
def self.generate_proration_items_from_phase_items(user:, mapper:, sf_order_amendment:, phase_items:, subscription_term:, billing_frequency:, days_prorating: 0)
invoice_items_for_prorations = []

phase_items.each do |phase_item|
Expand Down Expand Up @@ -333,12 +356,12 @@ def self.generate_proration_items_from_phase_items(user:, mapper:, sf_order_amen
log.info 'prorating order item', prorated_order_item_id: phase_item.order_line_id

proration_price = OrderAmendment.create_prorated_price_from_phase_item(
user: user,
mapper: mapper,
phase_item: phase_item,

# TODO is there a better way to source these variables? They are required in a couple
subscription_term: subscription_term,
billing_frequency: billing_frequency
billing_frequency: billing_frequency,
days_prorating: days_prorating
)

proration_stripe_item = Stripe::SubscriptionItem.construct_from({
Expand Down Expand Up @@ -467,7 +490,7 @@ def self.calculate_billing_cycle_dates(user, original_subscription_schedule, bil
).returns(T::Boolean)
end
def self.prorated_amendment?(user:, aggregate_phase_items:, subscription_schedule:, subscription_term:, billing_frequency:, amendment_start_date:)
log.info 'determining if order is prorated'
log.info 'determining if amendment order is prorated'

if aggregate_phase_items.empty?
log.info 'no subscription items, cannot be prorated order'
Expand All @@ -488,10 +511,6 @@ def self.prorated_amendment?(user:, aggregate_phase_items:, subscription_schedul
# we only care about the date, not the time
# TODO we need to be very careful about date comparison, there's a good chance a nuance here will cause us issues
if billing_dates.none? {|d| d == amendment_start_date }
# TODO need to do more thinking here, but I think this specific case may be impossible since we are enforcing coterm
# let's track this and then possibly remove this codepath in the future
Integrations::ErrorContext.report_edge_case("start date is not on the next or future billing dates")

log.info 'start date is not on the next or future billing dates',
amendment_start_date: amendment_start_date,
billing_dates: billing_dates
Expand Down
2 changes: 1 addition & 1 deletion lib/stripe-force/translate/order/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ def self.anchor_time_to_day_of_month(base_time:, anchor_day_of_month:)

sig { params(days: Integer).returns(Integer) }
def self.days_to_seconds(days)
days * 24 * 60 * 60
days * SECONDS_IN_DAY
end
end
end
Loading

0 comments on commit 935e95a

Please sign in to comment.