Skip to content

Commit

Permalink
[CF #80] Clean up stacked amendment processing (#1110)
Browse files Browse the repository at this point in the history
  • Loading branch information
nadaismail-stripe authored May 24, 2023
1 parent 840ac36 commit b8ea388
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 94 deletions.
109 changes: 54 additions & 55 deletions lib/stripe-force/translate/order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def create_stripe_transaction_from_sf_order(sf_order)
stripe_transaction ||= retrieve_from_stripe(Stripe::Invoice, sf_order)

if !stripe_transaction.nil?
log.info 'order already translated',
log.info 'initial order is already translated',
stripe_transaction_id: stripe_transaction.id,
salesforce_order_id: sf_order.Id
return
Expand Down Expand Up @@ -417,6 +417,13 @@ def update_subscription_phases_from_order_amendments(contract_structure)
contract_structure.amendments.each_with_index do |sf_order_amendment, index|
locker.lock_salesforce_record(sf_order_amendment)

# TODO replace with local sync record call in the future
order_amendment_subscription_id = sf_order_amendment[prefixed_stripe_field(GENERIC_STRIPE_ID)]
if order_amendment_subscription_id.present?
log.info "order amendment already translated, skipping", sf_order_amendment_id: sf_order_amendment.Id, index: index
next
end

log.info 'processing amendment', sf_order_amendment_id: sf_order_amendment.Id, index: index

invoice_items_in_order, aggregate_phase_items = build_phase_items_from_order_amendment(
Expand All @@ -425,8 +432,12 @@ def update_subscription_phases_from_order_amendments(contract_structure)
)

is_order_terminated = aggregate_phase_items.all?(&:fully_terminated?)
if is_order_terminated && contract_structure.amendments.size - 1 != index
raise Integrations::Errors::UnhandledEdgeCase.new("order terminated, but there's more amendments")
if is_order_terminated
log.info 'amendment is termination order', sf_order_amendment_id: sf_order_amendment.Id

if contract_structure.amendments.count - 1 != index
raise StripeForce::Errors::RawUserError.new("Processing a termination order, but there's more amendments.")
end
end

# this loop excludes the initial phase, which is why we are subtracting by 1
Expand All @@ -435,16 +446,6 @@ def update_subscription_phases_from_order_amendments(contract_structure)
log.info 'number of subscription phase count is greater than this amendment index', subscription_phase_count: subscription_phases.count, amendment_index: index
end

# it's possible for users to mutate the subscription schedule phases themselves
# if they do, we can't rely on the naive phase count logic above. This provides us another
# layer of protection, although this is not something we can fully trust right away, which is
# why we are only soft asserting at this point.
# TODO replace with local sync record call in the future
order_amendment_subscription_id = sf_order_amendment[prefixed_stripe_field(GENERIC_STRIPE_ID)]
if order_amendment_subscription_id.present?
Integrations::ErrorContext.report_edge_case("order amendment already marked as processed")
end

# TODO price ID dup check on the invoice items
# make sure to run this *after* checking if the amendment is already processed, otherwise
# we'll create price IDs which will never be archived since they won't be used in an active phase
Expand Down Expand Up @@ -474,11 +475,38 @@ def update_subscription_phases_from_order_amendments(contract_structure)

aggregate_phase_items, terminated_phase_items = OrderHelpers.remove_terminated_lines(aggregate_phase_items)

# determine if this is a backdated order since this has implications on how we prorate
stripe_customer_id = T.cast(subscription_schedule.customer, String)
current_time = OrderAmendment.determine_current_time(@user, stripe_customer_id)
normalized_current_time = StripeForce::Utilities::SalesforceUtil.datetime_to_unix_timestamp(Time.at(current_time))
is_order_backdated = sf_order_amendment_start_date_as_timestamp < normalized_current_time && @user.feature_enabled?(StripeForce::Constants::FeatureFlags::BACKDATED_AMENDMENTS)

backdated_billing_cycles = nil
next_billing_timestamp = nil
if is_order_backdated
log.info 'processing a backdated amendment order'
backdated_billing_cycles = 0
subscription_schedule_start = T.must(subscription_schedule.phases.first).start_date
next_billing_timestamp = StripeForce::Utilities::SalesforceUtil.datetime_to_unix_timestamp(Time.at(subscription_schedule_start))

# determine if a billing cycle has passed between the amendment start date and current time
while next_billing_timestamp <= normalized_current_time
if next_billing_timestamp > sf_order_amendment_start_date_as_timestamp
backdated_billing_cycles += 1
end
next_billing_timestamp = (Time.at(next_billing_timestamp).utc.beginning_of_day + billing_frequency.months).to_i
end

# If a subscription is backdated by 3 months and began on January 30th, our next_billing_timestamp will be March 27th instead of March 30th.
# ie January 30th, + 1 billing cycle (1 month) will result in Febuary 27th, the second iteration will result in March 27th (instead of March 30th).
next_billing_datetime = Time.at(next_billing_timestamp).utc
sf_order_amendment_start_date_datetime = Time.at(sf_order_amendment_start_date_as_timestamp).utc

if next_billing_datetime.day != sf_order_amendment_start_date_datetime.day
next_billing_timestamp = StripeForce::Translate::OrderHelpers.anchor_time_to_day_of_month(base_time: next_billing_datetime, anchor_day_of_month: sf_order_amendment_start_date_datetime.day).to_i
end
end

negative_invoice_items = []
if @user.feature_enabled?(FeatureFlags::TERMINATED_ORDER_ITEM_CREDIT)
# https://jira.corp.stripe.com/browse/PLATINT-2092
Expand All @@ -493,7 +521,8 @@ def update_subscription_phases_from_order_amendments(contract_structure)
terminated_phase_items: terminated_phase_items,
subscription_term: subscription_term_from_salesforce,
billing_frequency: billing_frequency,
is_order_backdated: is_order_backdated
is_order_backdated: is_order_backdated,
next_billing_timestamp: next_billing_timestamp,
)
end

Expand All @@ -520,34 +549,6 @@ def update_subscription_phases_from_order_amendments(contract_structure)
)
end

# determine if this is a backdated order since this has implications on how we prorate
backdated_billing_cycles = nil
next_billing_timestamp = nil
if is_order_backdated
log.info 'processing backdated amendment order'
backdated_billing_cycles = 0
subscription_schedule_start = T.must(subscription_schedule.phases.first).start_date
next_billing_timestamp = StripeForce::Utilities::SalesforceUtil.datetime_to_unix_timestamp(Time.at(subscription_schedule_start))

# determine if a billing cycle has passed between the amendment start date and current time
while next_billing_timestamp <= normalized_current_time
if next_billing_timestamp > sf_order_amendment_start_date_as_timestamp
backdated_billing_cycles += 1
end
next_billing_timestamp = (Time.at(next_billing_timestamp).utc.beginning_of_day + billing_frequency.months).to_i
end

# If a subscription is backdated by 3 months and began on January 30th, our next_billing_timestamp will be March 27th instead of March 30th.
# ie January 30th, + 1 billing cycle (1 month) will result in Febuary 27th, the second iteration will result in March 27th (instead of March 30th).

next_billing_datetime = Time.at(next_billing_timestamp).utc
sf_order_amendment_start_date_datetime = Time.at(sf_order_amendment_start_date_as_timestamp).utc

if next_billing_datetime.day != sf_order_amendment_start_date_datetime.day
next_billing_timestamp = StripeForce::Translate::OrderHelpers.anchor_time_to_day_of_month(base_time: next_billing_datetime, anchor_day_of_month: sf_order_amendment_start_date_datetime.day).to_i
end
end

invoice_items_for_prorations = []
if !is_order_terminated && is_prorated
log.info 'amendment order is prorated', sf_order_amendment_id: sf_order_amendment.Id, index: index
Expand Down Expand Up @@ -618,25 +619,26 @@ def update_subscription_phases_from_order_amendments(contract_structure)
new_phase["discounts"] = stripe_discounts_for_sf_object(sf_object: sf_order_amendment)
end

# if the time ranges are identical, then the previous phase should be removed
# the previous phases subscription items should be overwritten by the latest phase calculation
# but any one-off items would be lost without "merging" these items
previous_phase = T.must(subscription_phases.last)

# it's important to check this before setting that start date to 'now' below
is_identical_to_previous_phase_time_range = previous_phase.start_date == new_phase.start_date &&
previous_phase.end_date == new_phase.end_date

# if the current day is the same day as the start day, then use 'now'
is_same_day = normalized_current_time == new_phase.start_date
if is_same_day
log.info 'phase starts on the current day, using now'
log.info 'amendment starts on the current day, using now'
new_phase.start_date = 'now'
elsif @user.feature_enabled?(FeatureFlags::BACKDATED_AMENDMENTS) && is_order_backdated
# if this is a backdated amendment, then use the current time to update the subscription schedule
log.info 'backdated amendment, using now'
new_phase.start_date = 'now'
end

# if the time ranges are identical, then the previous phase should be removed
# the previous phases subscription items should be overwritten by the latest phase calculation
# but any one-off items would be lost without "merging" these items
previous_phase = T.must(subscription_phases.last)

is_identical_to_previous_phase_time_range = previous_phase.end_date == new_phase.end_date &&
previous_phase.start_date == new_phase.start_date

should_merge_phases = is_identical_to_previous_phase_time_range && !is_same_day && !is_order_backdated
if should_merge_phases && !previous_phase.add_invoice_items.empty?
log.info 'previous phase is identical, merging invoice items'
Expand All @@ -648,10 +650,7 @@ def update_subscription_phases_from_order_amendments(contract_structure)

# TODO I wonder if we can do something smarter here: if the invoice has not been paid/billed, do XYZ?
# this is a special case: subscription is cancelled on the same day, the intention here is to not bill the user at all
is_subscription_schedule_cancelled = is_order_terminated &&
# use `start_date_as_timestamp` since `previous_phase.end_date` could be `now`
previous_phase.start_date == sf_order_amendment_start_date_as_timestamp &&
contract_structure.amendments.count == 1
is_subscription_schedule_cancelled = is_order_terminated && (is_same_day || is_order_backdated)

# if the order is terminated, updating the last phase end date and NOT adding another phase is all that needs to be done
if !is_order_terminated
Expand Down
20 changes: 14 additions & 6 deletions lib/stripe-force/translate/order/amendments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,12 @@ def self.create_credit_price_data_from_terminated_phase_item(user:, phase_item:,
terminated_phase_items: T::Array[ContractItemStructure],
subscription_term: Integer,
billing_frequency: Integer,
is_order_backdated: T::Boolean
is_order_backdated: T::Boolean,
next_billing_timestamp: T.nilable(Integer),
).returns(T::Array[T::Hash[Symbol, T.untyped]])
end
# creating one-time invoice items for terminated lines for the unused prorated amount (which has already been billed)
def self.generate_proration_credits_from_terminated_phase_items(user:, mapper:, sf_order_amendment:, terminated_phase_items:, subscription_term:, billing_frequency:, is_order_backdated:)
def self.generate_proration_credits_from_terminated_phase_items(user:, mapper:, sf_order_amendment:, terminated_phase_items:, subscription_term:, billing_frequency:, is_order_backdated:, next_billing_timestamp:)
negative_invoice_items_for_prorations = []

terminated_phase_items.each do |phase_item|
Expand Down Expand Up @@ -292,18 +293,25 @@ def self.generate_proration_credits_from_terminated_phase_items(user:, mapper:,
mapper.apply_mapping(credit_stripe_item, phase_item.order_line)

proration_period_start = {type: 'phase_start'}
if is_order_backdated
proration_period_end = {type: 'subscription_period_end'}
if is_order_backdated && next_billing_timestamp.present?
amendment_start_date = StripeForce::Utilities::SalesforceUtil.extract_subscription_start_date_from_order(mapper, sf_order_amendment)
proration_period_start = {type: 'timestamp', timestamp: amendment_start_date.to_i}
proration_period_end = {type: 'timestamp', timestamp: next_billing_timestamp}

# https://admin.corp.stripe.com/gates/billing_subscriptions_open_invoicing_interval
# https://jira.corp.stripe.com/browse/PLATINT-2450
if user.feature_enabled?(StripeForce::Constants::FeatureFlags::BILLING_GATE_OPEN_INVOICING_INTERVAL)
# https://livegrep.corp.stripe.com/view/stripe-internal/pay-server/lib/subscriptions/command/invoicing_period.rb#L26
proration_period_end[:timestamp] = proration_period_end[:timestamp] - 1 > proration_period_start[:timestamp] ? proration_period_end[:timestamp] - 1 : proration_period_end[:timestamp]
end
end

negative_invoice_items_for_prorations << credit_stripe_item.to_hash.merge({
quantity: phase_item.reduced_by,
price_data: price_data,
period: {
end: {
type: 'subscription_period_end',
},
end: proration_period_end,
start: proration_period_start,
},
})
Expand Down
4 changes: 4 additions & 0 deletions scripts/console.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ def example_sf_order
sf_get(@sf.query("SELECT Id FROM #{SF_ORDER} ORDER BY CreatedDate DESC LIMIT 1").first.Id)
end

def get_terminated_items_from_order(order_id)
@user.sf_client.query("SELECT Id FROM OrderItem WHERE OrderId = '#{order_id}' AND SBQQ__OrderedQuantity__c < 0")
end

def example_sf_customer
sf_get(@sf.query("SELECT Id FROM #{SF_ACCOUNT} ORDER BY CreatedDate DESC LIMIT 1").first.Id)
end
Expand Down
Loading

0 comments on commit b8ea388

Please sign in to comment.