Skip to content

Commit

Permalink
Support syncing Orders with MDQ products (#1190)
Browse files Browse the repository at this point in the history
  • Loading branch information
nadaismail-stripe authored Oct 16, 2023
1 parent 7e12755 commit ad70f73
Show file tree
Hide file tree
Showing 20 changed files with 36,658 additions and 5,127 deletions.
21 changes: 21 additions & 0 deletions lib/stripe-force/constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,27 @@ class CPQQuoteTypeOptions < T::Enum
CPQ_DEFAULT_SUBSCRIPTION_TERM = 'SBQQ__DefaultSubscriptionTerm__c'
CPQ_QUOTE_LINE_DEFAULT_SUBSCRIPTION_TERM = 12

# CPQ fields used to create a PriceDimension
CPQ_PRICE_DIMENSION = "SBQQ__Dimension__c"
CPQ_PRICE_DIMENSION_TYPE = "SBQQ__Type__c"

# CPQ fields on the OrderItem that are related to PriceDimension
CPQ_ORDER_ITEM_SEGMENT_KEY = "SBQQ__SegmentKey__c"
CPQ_ORDER_ITEM_SEGMENT_INDEX = "SBQQ__SegmentIndex__c"
CPQ_ORDER_ITEM_SEGMENT_LABEL = "SBQQ__SegmentLabel__c"
CPQ_ORDER_ITEM_PRICE_DIMENSION_ID = "SBQQ__PriceDimension__c"
CPQ_ORDER_ITEM_PRICE_DIMENSION_TYPE = "SBQQ__DimensionType__c"
class CPQPriceDimensionTypeOptions < T::Enum
enums do
MONTH = new("Month")
QUARTER = new("Quarter")
YEAR = new("Year")
# not supported yet
# CUSTOM = new("Custom")
# ONE_TIME = new("One Time")
end
end

CPQ_QUOTE_BILLING_FREQUENCY = 'SBQQ__BillingFrequency__c'
class CPQBillingFrequencyOptions < T::Enum
enums do
Expand Down
1 change: 1 addition & 0 deletions lib/stripe-force/translate/mapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def self.mapping_key_for_record(stripe_record, sf_record)
compound_key = stripe_record_class == Stripe::Price && sf_record&.sobject_type == SF_ORDER_ITEM

if compound_key
log.info 'using compound key for price order item',
sf_record = T.must(sf_record)
stripe_record_key + "_" + Translate::Metadata.sf_object_metadata_name(sf_record)
else
Expand Down
123 changes: 109 additions & 14 deletions lib/stripe-force/translate/order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ def migrate_pre_integration_order(contract_structure)
# this should never happen, but we are still learning about CPQ
# if metered billing, quantity is not set, so we set to 1
if subscription.items.map {|l| l[:quantity] || 1 }.any?(&:zero?)
Integrations::ErrorContext.report_edge_case("quantity is zero on initial subscription")
Integrations::ErrorContext.report_edge_case("Quantity is zero on initial subscription")
end

# https://jira.corp.stripe.com/browse/PLATINT-1731
Expand Down Expand Up @@ -423,7 +423,6 @@ def create_stripe_transaction_from_sf_order(sf_order)

sf_order_items = order_lines_from_order(sf_order)
invoice_items, subscription_items = phase_items_from_order_lines(sf_order_items)

is_recurring_order = !subscription_items.empty?
if !is_recurring_order
invoice = create_stripe_invoice_from_order(stripe_customer, invoice_items, sf_order)
Expand All @@ -434,8 +433,8 @@ def create_stripe_transaction_from_sf_order(sf_order)
return invoice
end

order_is_evergreen = is_salesforce_order_evergreen(sf_order)
if order_is_evergreen
has_evergreen_products = is_salesforce_order_evergreen(sf_order)
if has_evergreen_products
log.info 'order has evergreen products, creating subscription'

validate_evergreen_order(sf_order_items)
Expand All @@ -454,13 +453,24 @@ def create_stripe_transaction_from_sf_order(sf_order)
mapper.assign_values_from_hash(subscription_schedule, subscription_params)
apply_mapping(subscription_schedule, sf_order)

subscription_schedule_phases = generate_phases_for_initial_order(
sf_order: sf_order,
invoice_items: invoice_items,
subscription_items: subscription_items,
subscription_schedule: subscription_schedule,
stripe_customer: stripe_customer
)
has_mdq_products = sf_order_contains_mdq_order_items(sf_order_items)
if has_mdq_products
log.info 'sf order contains mdq product'
subscription_schedule_phases = generate_phases_for_initial_order_with_mdq(
sf_order: sf_order,
subscription_items: subscription_items,
invoice_items: invoice_items,
subscription_schedule: subscription_schedule,
stripe_customer: stripe_customer)
else
subscription_schedule_phases = generate_phases_for_initial_order(
sf_order: sf_order,
invoice_items: invoice_items,
subscription_items: subscription_items,
subscription_schedule: subscription_schedule,
stripe_customer: stripe_customer
)
end

# TODO refactor once caching is stable, more notes in the `generate_phases_for_initial_rder`
if subscription_schedule_phases.is_a?(Stripe::Invoice)
Expand All @@ -484,7 +494,7 @@ def create_stripe_transaction_from_sf_order(sf_order)
# this should never happen, but we are still learning about CPQ
# if metered billing, quantity is not set, so we set to 1
if OrderHelpers.extract_all_items_from_subscription_schedule(subscription_schedule).map {|l| l[:quantity] || 1 }.any?(&:zero?)
Integrations::ErrorContext.report_edge_case("quantity is zero on initial subscription schedule")
Integrations::ErrorContext.report_edge_case("Quantity is zero on initial subscription schedule")
end

# https://jira.corp.stripe.com/browse/PLATINT-1731
Expand All @@ -511,6 +521,91 @@ def create_stripe_transaction_from_sf_order(sf_order)
stripe_transaction
end

sig do
params(
sf_order: T.untyped,
invoice_items: T::Array[ContractItemStructure],
subscription_items: T::Array[ContractItemStructure],
subscription_schedule: Stripe::SubscriptionSchedule,
stripe_customer: Stripe::Customer
).returns(T::Array[Stripe::SubscriptionSchedulePhase])
end
def generate_phases_for_initial_order_with_mdq(sf_order:, invoice_items:, subscription_items:, subscription_schedule:, stripe_customer:)
sub_schedule_phases = []

# extract the quote start date and subscription term from the order
# to calculate the end date
string_start_date_from_salesforce = subscription_schedule['start_date']
start_date_from_salesforce = DateTime.parse(string_start_date_from_salesforce)
subscription_term_from_salesforce = StripeForce::Utilities::SalesforceUtil.extract_subscription_term_from_order!(mapper, sf_order)

order_end_date = StripeForce::Utilities::SalesforceUtil.datetime_to_unix_timestamp(start_date_from_salesforce + subscription_term_from_salesforce.months)

# split the subscription items by mdq and non-mdq products
# non segmented items are for the entire contact so they will be added to each phase
non_segmented_items = []
segmented_subscription_items = []

subscription_items.each do |item|
if item.is_mdq_segment?
segmented_subscription_items << item
else
# if it's not an mdq product, there will be no segment index, and the sub item will be across all phases
non_segmented_items << item.stripe_params
end
end

# sort the mdq products by segment index
segmented_subscription_items = segmented_subscription_items.sort_by do |item|
if item.mdq_dimension_type == 'Custom'
raise StripeForce::Errors::RawUserError.new("MDQ products with custom segments are not yet supported.")
end

item.mdq_segment_index
end

# CPQ Quote discounts will apply across each phase / segment
phase_discounts = stripe_discounts_for_sf_object(sf_object: sf_order)

# for each subscription item, add it to the corresponding sub phase
segmented_subscription_items.each do |sub_item|
# many of the mdq fields live on the corresponding quote line
quote_line_data = backoff { @user.sf_client.find(CPQ_QUOTE_LINE, sub_item.order_line[CPQ_QUOTE_LINE]) }
sub_item_end_date = StripeForce::Utilities::SalesforceUtil.salesforce_date_to_unix_timestamp(quote_line_data[CPQ_QUOTE_SUBSCRIPTION_END_DATE]) + 1.day.to_i

# adds a new phase for the newest segment
mdq_segment_index = sub_item.mdq_segment_index
if sub_schedule_phases.empty? || sub_schedule_phases.count < mdq_segment_index
# create a new phase for this segment index
sub_schedule_phases << {
add_invoice_items: [],
items: non_segmented_items.dup,
end_date: sub_item_end_date,
metadata: Metadata.stripe_metadata_for_sf_object(@user, sf_order).merge({salesforce_segment_key: sub_item.order_line[CPQ_ORDER_ITEM_SEGMENT_KEY], salesforce_segment_label: quote_line_data[CPQ_ORDER_ITEM_SEGMENT_LABEL]}),
discounts: phase_discounts,
}
end

# add the subscription item to the existing segment index phase
last_index = sub_schedule_phases.count - 1

# let's be defensive and throw a error if the order items in the same segment have different end dates
if sub_schedule_phases[last_index][:end_date] != sub_item_end_date
log.error 'sf order items in the same segment have different end dates', sf_order_item: sub_item.order_line
raise Integrations::Errors::ImpossibleState.new('Salesforce Order Items in the same MDQ segment has different end date than the other segments.', salesforce_object: sub_item.order_line)
end

# add the item to the last phase / segment
sub_schedule_phases[last_index][:items] << sub_item.stripe_params
end

# Salesforce EndDate is the end of the last day of the subscription while the calculated EndDate we send to Stripe
# is the day after the last day of the subscription. This is because Stripe's subscription schedule end date is exclusive.
sub_schedule_phases[sub_schedule_phases.count - 1][:end_date] = order_end_date
sub_schedule_phases
end


# it is important that the subscription schedule is passed in before the start_date is transformed
sig do
params(
Expand All @@ -522,9 +617,9 @@ def create_stripe_transaction_from_sf_order(sf_order)
).returns(T.any(Stripe::Invoice, [Hash, T.nilable(Hash)]))
end
def generate_phases_for_initial_order(sf_order:, invoice_items:, subscription_items:, subscription_schedule:, stripe_customer:)
# extract the quote start date from the order
string_start_date_from_salesforce = subscription_schedule['start_date']
# TODO should avoid using blind parse and instead enforce a strict SF datetime format
start_date_from_salesforce = DateTime.parse(string_start_date_from_salesforce).utc
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)

# the user maps to this to determine the subscription schedule end date
Expand Down
16 changes: 16 additions & 0 deletions lib/stripe-force/translate/order/contract_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,21 @@ def new_order_line?
def from_order?(sf_order)
self.order_line['OrderId'] == sf_order.Id
end

sig { returns(T::Boolean) }
def is_mdq_segment?
self.order_line[CPQ_ORDER_ITEM_SEGMENT_KEY].present?
end

# this starts at 1 index
sig { returns(T.nilable(Integer)) }
def mdq_segment_index
self.order_line[CPQ_ORDER_ITEM_SEGMENT_INDEX]
end

sig { returns(T.nilable(String)) }
def mdq_dimension_type
self.order_line[CPQ_ORDER_ITEM_PRICE_DIMENSION_TYPE]
end
end
end
3 changes: 1 addition & 2 deletions lib/stripe-force/translate/order/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,7 @@ def self.prorated_order?(subscription_term:, billing_frequency:)

sig { params(subscription_schedule: Stripe::SubscriptionSchedule).returns(T::Array[T.any(Stripe::SubscriptionSchedulePhaseSubscriptionItem, Stripe::SubscriptionSchedulePhaseInvoiceItem)]) }
def self.extract_all_items_from_subscription_schedule(subscription_schedule)
subscription_schedule.phases.map(&:items).flatten +
subscription_schedule.phases.map(&:add_invoice_items).flatten
subscription_schedule.phases.map(&:items).flatten + subscription_schedule.phases.map(&:add_invoice_items).flatten
end

# after lines have been adjusted with termination line, they should be removed
Expand Down
13 changes: 10 additions & 3 deletions lib/stripe-force/translate/price.rb
Original file line number Diff line number Diff line change
Expand Up @@ -424,8 +424,8 @@ def generate_price_params_from_sf_object(sf_object, sf_product)
end

# TODO it's possible that a custom mapping is defined for this value and it's an integer, we should support this case in the helper method
# this represents how often the price is billed: i.e. if `interval` is month and `interval_count`
# is 2, then this price is billed every two months.
# this represents how often the price is billed:
# i.e. if `interval` is month and `interval_count` is 2, then this price is billed every two months.
stripe_price.recurring[:interval_count] = PriceHelpers.transform_salesforce_billing_frequency_to_recurring_interval(stripe_price.recurring[:interval_count])

# frequency: monthly or daily, defined on the CPQ
Expand Down Expand Up @@ -497,7 +497,13 @@ def calculate_price_multiplier(mapper, sf_order, sf_order_item, billing_frequenc

# TODO should we adjust based on the quantity? Most likely, let's wait until tests fail
price_multiplier = BigDecimal(T.must(quote_subscription_term)) / BigDecimal(billing_frequency)
validate_price_multipliers(price_multiplier, cpq_price_multiplier, false)

# TODO should test this further with proration amendments
# For MDQ orders, the quote subscription term is not the effective subscription term
if !validate_price_multipliers(price_multiplier, cpq_price_multiplier, false) && quote_subscription_term != effective_subscription_term
price_multiplier = BigDecimal(T.must(effective_subscription_term)) / BigDecimal(billing_frequency)
end

price_multiplier
end

Expand All @@ -514,6 +520,7 @@ def validate_price_multipliers(calculated_price_multiplier, cpq_price_multiplier
if throw_error
raise Integrations::Errors::TranslatorError.new("calculated price multiplier differs from cpq price multiplier")
end
return false
end
end

Expand Down
6 changes: 3 additions & 3 deletions lib/stripe-force/translate/utilities/demo_util.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ def create_salesforce_account(additional_fields: {})
}.merge(additional_fields))
end

def create_salesforce_contact(contact_email: sf_randomized_id, static_id: true)
def create_salesforce_contact(contact_email:, static_id: true)
_ = sf.create!(SF_CONTACT, {
LastName: 'Bianco',
Email: static_id ? create_static_email(email: contact_email) : create_random_email,
Email: static_id && contact_email.present? ? create_static_email(email: contact_email) : create_random_email,
})
end

Expand Down Expand Up @@ -390,7 +390,7 @@ def create_order_from_cpq_quote(sf_quote_id, additional_order_fields: {})
sf_order
end

def create_salesforce_quote(sf_pricebook_id: nil, sf_account_id:, currency_iso_code: nil, contact_email: sf_randomized_id, additional_quote_fields: {})
def create_salesforce_quote(sf_pricebook_id: nil, sf_account_id:, currency_iso_code: nil, contact_email:, additional_quote_fields: {})
sf_pricebook_id ||= default_pricebook_id
opportunity_id = create_salesforce_opportunity(sf_account_id: sf_account_id, currency_iso_code: currency_iso_code)
contact_id = create_salesforce_contact(contact_email: contact_email)
Expand Down
7 changes: 6 additions & 1 deletion lib/stripe-force/translate/utilities/salesforce_util.rb
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ def self.normalize_sf_order_amendment_end_date(mapper:, sf_order_amendment:, sf_
log.error 'amendment order does not start on the same day of month as the initial order',
initial_order_start_date: sf_initial_order_start_date,
amendment_order_start_date: amendment_start_date
raise StripeForce::Errors::RawUserError.new("Amendment orders must start on the same day of month as the initial order.")
raise StripeForce::Errors::RawUserError.new("Amendment orders must start on the same day of month as the initial order. Enable feature non-anniversary amendments to sync amendments on any day of the month.")
end

amendment_end_date = get_non_anniversary_amendment_order_end_date(mapper, sf_order_amendment)
Expand Down Expand Up @@ -412,6 +412,11 @@ def self.calculate_month_plus_day_price_multiplier(whole_months:, partial_month_
(whole_months + (BigDecimal(partial_month_days) / (BigDecimal(DAYS_IN_YEAR) / BigDecimal(MONTHS_IN_YEAR)))) / BigDecimal(product_subscription_term)
end

sig { params(sf_order_items: T::Array[T.untyped]).returns(T::Boolean) }
def sf_order_contains_mdq_order_items(sf_order_items)
sf_order_items.any? {|sf_order_item| sf_order_item[CPQ_ORDER_ITEM_PRICE_DIMENSION_ID].present? }
end

sig { params(user: StripeForce::User, sf_order_amendment: Restforce::SObject).returns(T.nilable(String)) }
def self.get_effective_termination_date(user, sf_order_amendment)
# Salesforce CPQ generates an amendment opportunity with a close date equal to your contract’s start date which
Expand Down
2 changes: 1 addition & 1 deletion test/integration/amendments/test_proration_amendments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1797,7 +1797,7 @@ class Critic::ProratedAmendmentTranslation < Critic::OrderAmendmentFunctionalTes

sf_order = create_subscription_order(
sf_product_id: sf_product_id,
contact_email: "semi_annual_day_proration",
contact_email: "semi_annual_day_proration_2",
additional_fields: {
CPQ_QUOTE_SUBSCRIPTION_START_DATE => format_date_for_salesforce(initial_order_start_date),
CPQ_QUOTE_SUBSCRIPTION_TERM => contract_term,
Expand Down
2 changes: 0 additions & 2 deletions test/integration/test_one_time_order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,9 @@ class Critic::OneTimeOrderTranslation < Critic::VCRTest

invoice = Stripe::Invoice.retrieve(stripe_invoice_id, @user.stripe_credentials)
customer = Stripe::Customer.retrieve(invoice.customer, @user.stripe_credentials)

refute_empty(customer.email)

assert_equal(1, invoice.lines.count)

line = invoice.lines.first
assert_equal("one_time", line.price.type)
end
Expand Down
1 change: 0 additions & 1 deletion test/integration/test_translate_order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ class Critic::OrderTranslation < Critic::VCRTest

# TODO add `refresh` to salesforce library
sf_order = sf.find(SF_ORDER, sf_order.Id)
sf_product = sf.find(SF_PRODUCT, sf_product_id)
sf_pricebook_entry = sf.find(SF_PRICEBOOK_ENTRY, sf_pricebook_entry_id)

stripe_id = sf_order[prefixed_stripe_field(GENERIC_STRIPE_ID)]
Expand Down
Loading

0 comments on commit ad70f73

Please sign in to comment.