Skip to content

Commit

Permalink
Ensure amendments co-terminate with initial order (#795)
Browse files Browse the repository at this point in the history
  • Loading branch information
nadaismail-stripe authored Sep 23, 2022
1 parent ae8610f commit 68108e4
Show file tree
Hide file tree
Showing 12 changed files with 129 additions and 66 deletions.
1 change: 1 addition & 0 deletions lib/stripe-force/constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ module Constants
CPQ_QUOTE_SUBSCRIPTION_START_DATE = 'SBQQ__StartDate__c'
CPQ_QUOTE_SUBSCRIPTION_TERM = 'SBQQ__SubscriptionTerm__c'
CPQ_QUOTE_SUBSCRIPTION_PRICING = 'SBQQ__SubscriptionPricing__c'
CPQ_QUOTE_QUANTITY = 'SBQQ__Quantity__c'

CPQ_QUOTE_BILLING_FREQUENCY = 'SBQQ__BillingFrequency__c'
class CPQBillingFrequencyOptions < T::Enum
Expand Down
10 changes: 7 additions & 3 deletions lib/stripe-force/translate/order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def create_stripe_transaction_from_sf_order(sf_order)

log.info 'recurring items found, creating subscription schedule'

subscription_params = extract_salesforce_params!(sf_order, Stripe::SubscriptionSchedule)
subscription_params = StripeForce::Utilities::SalesforceUtil.extract_salesforce_params!(mapper, sf_order, Stripe::SubscriptionSchedule)

# TODO should file an API papercut for this
# when creating the subscription schedule the start_date must be specified on the heaer
Expand Down Expand Up @@ -250,6 +250,10 @@ def update_subscription_phases_from_order_amendments(contract_structure)

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

if !OrderAmendment.contract_co_terminated?(mapper, contract_structure)
raise StripeForce::Errors::RawUserError.new("order amendments must coterminate with the initial order")
end

# Order amendments contain a negative item if they are adjusting a previous line item.
# If they are adjusting a previous line item

Expand Down Expand Up @@ -304,7 +308,7 @@ def update_subscription_phases_from_order_amendments(contract_structure)
aggregate_phase_items = OrderHelpers.ensure_unique_phase_item_prices(@user, aggregate_phase_items)

# TODO should probably use a completely different key/mapping for the phase items
phase_params = extract_salesforce_params!(sf_order_amendment, Stripe::SubscriptionSchedule)
phase_params = StripeForce::Utilities::SalesforceUtil.extract_salesforce_params!(mapper, sf_order_amendment, Stripe::SubscriptionSchedule)

string_start_date_from_salesforce = phase_params['start_date']
start_date_as_timestamp = StripeForce::Utilities::SalesforceUtil.salesforce_date_to_unix_timestamp(string_start_date_from_salesforce)
Expand Down Expand Up @@ -608,7 +612,7 @@ def phase_items_from_order_lines(sf_order_lines)
metadata: Metadata.stripe_metadata_for_sf_object(@user, sf_order_item),
})

phase_item_params = extract_salesforce_params!(sf_order_item, Stripe::SubscriptionItem)
phase_item_params = StripeForce::Utilities::SalesforceUtil.extract_salesforce_params!(mapper, sf_order_item, Stripe::SubscriptionItem)
mapper.assign_values_from_hash(phase_item, phase_item_params)
apply_mapping(phase_item, sf_order_item)

Expand Down
27 changes: 17 additions & 10 deletions lib/stripe-force/translate/order/amendments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,26 @@ module OrderAmendment
include StripeForce::Constants
extend SimpleStructuredLogger

# for now, let's not support non-co-terminated contracts
sig { params(contract_structure: ContractStructure).returns(T::Boolean) }
def self.contract_co_terminated?(contract_structure)
target_end_date = calculate_order_end_date(contract_structure.initial)

contract_structure.amendments.all? do |sf_order|
calculate_order_end_date(sf_order) == target_end_date
# determines if order amendments coterminate with the initial order,
# this is required by our integration but is allowed by CPQ in some situations
sig { params(mapper: StripeForce::Mapper, contract_structure: ContractStructure).returns(T::Boolean) }
def self.contract_co_terminated?(mapper, contract_structure)
initial_order_end_date = calculate_order_end_date(mapper, contract_structure.initial)

# the end date must be the same for all order amendments
contract_structure.amendments.all? do |sf_order_amendment|
calculate_order_end_date(mapper, sf_order_amendment) == initial_order_end_date
end
end

def self.calculate_order_end_date(sf_order)
# get start date
# add subscription term
sig { params(mapper: StripeForce::Mapper, sf_order: Restforce::SObject).returns(Integer) }
def self.calculate_order_end_date(mapper, sf_order)
subscription_params = StripeForce::Utilities::SalesforceUtil.extract_salesforce_params!(mapper, sf_order, Stripe::SubscriptionSchedule)
salesforce_start_date_as_string = subscription_params['start_date']
salesforce_subscription_term = subscription_params['iterations'].to_i

# end date = start date + subscription term
(StripeForce::Utilities::SalesforceUtil.salesforce_date_to_beginning_of_day(salesforce_start_date_as_string) + salesforce_subscription_term.months).to_i
end

# NOTE at this point it's assumed that the price is NOT a metered billing item or tiered price
Expand Down
2 changes: 1 addition & 1 deletion lib/stripe-force/translate/price.rb
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ def generate_price_params_from_sf_object(sf_object, sf_product)

# the params are extracted here *and* we apply custom mappings because this enables the user to setup custom mappings for
# *everything* we sent to the price API including UnitPrice and other fields which go through custom transformations
price_params = extract_salesforce_params!(sf_object, Stripe::Price)
price_params = StripeForce::Utilities::SalesforceUtil.extract_salesforce_params!(mapper, sf_object, Stripe::Price)
mapper.assign_values_from_hash(stripe_price, price_params)

if sf_object.sobject_type == SF_PRICEBOOK_ENTRY
Expand Down
35 changes: 1 addition & 34 deletions lib/stripe-force/translate/translate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ def create_stripe_object(stripe_class, sf_object, additional_stripe_params: {},
stripe_fields = if skip_field_extraction
{}
else
extract_salesforce_params!(sf_object, stripe_class)
StripeForce::Utilities::SalesforceUtil.extract_salesforce_params!(mapper, sf_object, stripe_class)
end

stripe_object = stripe_class.construct_from(additional_stripe_params)
Expand Down Expand Up @@ -479,39 +479,6 @@ def transform_iterations_by_billing_frequency(iterations, subscription_price_id)
determine_subscription_term_multiplier_for_billing_frequency(iterations, billing_frequency_in_months)
end

# param_mapping: { stripe_key_name => salesforce_field_name }
sig { params(sf_record: Restforce::SObject, stripe_record_or_class: T.any(Class, Stripe::APIResource)).returns(Hash) }
def extract_salesforce_params!(sf_record, stripe_record_or_class)
stripe_mapping_key = StripeForce::Mapper.mapping_key_for_record(stripe_record_or_class, sf_record)
required_mappings = @user.required_mappings[stripe_mapping_key]

if required_mappings.nil?
raise "expected mappings for #{stripe_mapping_key} but they were nil"
end

# first, let's pull required mappings and check if there's anything missing
required_data = mapper.build_dynamic_mapping_values(sf_record, required_mappings)

missing_stripe_fields = required_mappings.select {|k, _v| required_data[k].nil? }

if missing_stripe_fields.present?
missing_salesforce_fields = missing_stripe_fields.keys.map {|k| required_mappings[k] }

raise Integrations::Errors::MissingRequiredFields.new(
salesforce_object: sf_record,
missing_salesforce_fields: missing_salesforce_fields
)
end

# then, let's extract optional fields and then merge them in
default_mappings = @user.default_mappings[stripe_mapping_key]
return required_data if default_mappings.blank?

optional_data = mapper.build_dynamic_mapping_values(sf_record, default_mappings)

required_data.merge(optional_data)
end

# TODO allow for multiple records to be linked?
sig { params(record_to_map: Stripe::APIResource, source_record: Restforce::SObject, compound_key: T.nilable(T::Boolean)).void }
def apply_mapping(record_to_map, source_record, compound_key: false)
Expand Down
43 changes: 42 additions & 1 deletion lib/stripe-force/translate/utilities/salesforce_util.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,20 @@ module SalesforceUtil
# Stripe APIs speak UTC, so we convert to UTC + unix timestamp
sig { params(date_string: String).returns(Integer) }
def self.salesforce_date_to_unix_timestamp(date_string)
DateTime.parse(date_string).utc.beginning_of_day.to_i
salesforce_date_to_beginning_of_day(date_string).to_i
end

sig { params(datetime: T.any(Time, DateTime)).returns(Integer) }
def self.datetime_to_unix_timestamp(datetime)
datetime.utc.beginning_of_day.to_i
end

sig { params(date_string: String).returns(Time) }
def self.salesforce_date_to_beginning_of_day(date_string)
DateTime.parse(date_string).utc.beginning_of_day
end


sig { params(user: StripeForce::User, sf_id: String).returns(String) }
def self.salesforce_type_from_id(user, sf_id)
case sf_id
Expand Down Expand Up @@ -159,5 +165,40 @@ def backoff(options={})
retry
end
end

# param_mapping: { stripe_key_name => salesforce_field_name }
sig { params(mapper: StripeForce::Mapper, sf_record: Restforce::SObject, stripe_record_or_class: T.any(Class, Stripe::APIResource)).returns(Hash) }
def self.extract_salesforce_params!(mapper, sf_record, stripe_record_or_class)
stripe_mapping_key = StripeForce::Mapper.mapping_key_for_record(stripe_record_or_class, sf_record)

user = mapper.user
required_mappings = user.required_mappings[stripe_mapping_key]

if required_mappings.nil?
raise "expected mappings for #{stripe_mapping_key} but they were nil"
end

# first, let's pull required mappings and check if there's anything missing
required_data = mapper.build_dynamic_mapping_values(sf_record, required_mappings)

missing_stripe_fields = required_mappings.select {|k, _v| required_data[k].nil? }

if missing_stripe_fields.present?
missing_salesforce_fields = missing_stripe_fields.keys.map {|k| required_mappings[k] }

raise Integrations::Errors::MissingRequiredFields.new(
salesforce_object: sf_record,
missing_salesforce_fields: missing_salesforce_fields
)
end

# then, let's extract optional fields and then merge them in
default_mappings = user.default_mappings[stripe_mapping_key]
return required_data if default_mappings.blank?

optional_data = mapper.build_dynamic_mapping_values(sf_record, default_mappings)

required_data.merge(optional_data)
end
end
end
53 changes: 48 additions & 5 deletions test/integration/amendments/test_amendments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class Critic::OrderAmendmentTranslation < Critic::OrderAmendmentFunctionalTest
amendment_data = create_quote_data_from_contract_amendment(sf_contract)

# increase quantity by 2
amendment_data["lineItems"].first["record"]["SBQQ__Quantity__c"] = 3
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 3

amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_START_DATE] = format_date_for_salesforce(start_date)
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_TERM] = amendment_term
Expand Down Expand Up @@ -137,7 +137,7 @@ class Critic::OrderAmendmentTranslation < Critic::OrderAmendmentFunctionalTest
amendment_data = create_quote_data_from_contract_amendment(sf_contract)

# remove metered billing item completely
amendment_data["lineItems"].first["record"]["SBQQ__Quantity__c"] = 0
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 0
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_START_DATE] = amendment_start_date.strftime("%Y-%m-%d")
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_TERM] = amendment_term
sf_quote_id = calculate_and_save_cpq_quote(amendment_data)
Expand Down Expand Up @@ -266,7 +266,7 @@ class Critic::OrderAmendmentTranslation < Critic::OrderAmendmentFunctionalTest
amendment_data = create_quote_data_from_contract_amendment(sf_contract)

# increase quantity
amendment_data["lineItems"].first["record"]["SBQQ__Quantity__c"] = 3
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 3

amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_START_DATE] = start_date.strftime("%Y-%m-%d")
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_TERM] = amendment_term
Expand Down Expand Up @@ -381,7 +381,7 @@ class Critic::OrderAmendmentTranslation < Critic::OrderAmendmentFunctionalTest
amendment_data = create_quote_data_from_contract_amendment(sf_contract)

# increase quantity by 1
amendment_data["lineItems"].first["record"]["SBQQ__Quantity__c"] = 2
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 2
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_START_DATE] = format_date_for_salesforce(start_date)
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_TERM] = amendment_term

Expand Down Expand Up @@ -484,7 +484,7 @@ class Critic::OrderAmendmentTranslation < Critic::OrderAmendmentFunctionalTest
amendment_data = create_quote_data_from_contract_amendment(sf_contract)

# increase quantity by 2
amendment_data["lineItems"].first["record"]["SBQQ__Quantity__c"] = 2
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 2
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_START_DATE] = format_date_for_salesforce(amendment_start_date)
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_TERM] = amendment_term

Expand Down Expand Up @@ -529,9 +529,52 @@ class Critic::OrderAmendmentTranslation < Critic::OrderAmendmentFunctionalTest
end
end

describe '#contract_co_terminated' do
it 'test order amendments not coterminating with initial order' do
# initial order: starts now, billed yearly
# amendment: starts in 6 months, ends 7 months later (so one month after initial order)

# setup
initial_order_term = TEST_DEFAULT_CONTRACT_TERM
initial_order_start_date = now_time
# term is intentionally longer than the original end date
amendment_term = 7
amendment_start_date = initial_order_start_date + 6.months

sf_order = create_subscription_order(
additional_fields: {
CPQ_QUOTE_SUBSCRIPTION_START_DATE => format_date_for_salesforce(initial_order_start_date),
CPQ_QUOTE_BILLING_FREQUENCY => CPQBillingFrequencyOptions::MONTHLY.serialize,
}
)

sf_contract = create_contract_from_order(sf_order)
sf_order.refresh

# the contract should reference the initial order that was created
assert_equal(sf_order[SF_ID], sf_contract[SF_CONTRACT_ORDER_ID])

# quote is generated by CPQ API, so set these fields manually
amendment_data = create_quote_data_from_contract_amendment(sf_contract)
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 2
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_START_DATE] = format_date_for_salesforce(amendment_start_date)
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_TERM] = amendment_term
sf_order_amendment = create_order_from_quote_data(amendment_data)

assert_equal(sf_order_amendment.Type, OrderTypeOptions::AMENDMENT.serialize)

# translate order
contracts_not_coterminating_error = assert_raises(StripeForce::Errors::UserError) do
StripeForce::Translate.perform_inline(@user, sf_order_amendment.Id)
end
assert_match("order amendments must coterminate with the initial order", contracts_not_coterminating_error.message.downcase)
end
end

describe 'metadata' do
it 'pulls metadata from each order amendment to the phase of each subscription'
it 'uses metadata on the original line item if an item is not removed'
it 'uses the latest metadata on an order line represented in a previous subscription schedule phase'
end

end
4 changes: 2 additions & 2 deletions test/integration/amendments/test_proration_amendments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class Critic::ProratedAmendmentTranslation < Critic::OrderAmendmentFunctionalTes
amendment_data = create_quote_data_from_contract_amendment(sf_contract)

# increase quantity
amendment_data["lineItems"].first["record"]["SBQQ__Quantity__c"] = 3
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 3

amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_START_DATE] = format_date_for_salesforce(amendment_start_date)
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_TERM] = amendment_term
Expand Down Expand Up @@ -183,7 +183,7 @@ class Critic::ProratedAmendmentTranslation < Critic::OrderAmendmentFunctionalTes
amendment_data = create_quote_data_from_contract_amendment(sf_contract)

# increase quantity
amendment_data["lineItems"].first["record"]["SBQQ__Quantity__c"] = 3
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 3

amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_START_DATE] = format_date_for_salesforce(amendment_start_date)
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_TERM] = amendment_term
Expand Down
4 changes: 2 additions & 2 deletions test/integration/amendments/test_same_day_amendments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def get_proration_invoice_item(stripe_customer_id)
amendment_data = create_quote_data_from_contract_amendment(sf_contract)

# increase quantity by 1
amendment_data["lineItems"].first["record"]["SBQQ__Quantity__c"] = 2
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 2

# midnight of the current day!
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_START_DATE] = format_date_for_salesforce(initial_start_date)
Expand Down Expand Up @@ -156,7 +156,7 @@ def get_proration_invoice_item(stripe_customer_id)
amendment_data = create_quote_data_from_contract_amendment(sf_contract)

# increase quantity by 1
amendment_data["lineItems"].first["record"]["SBQQ__Quantity__c"] = 2
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 2

# midnight of the current day!
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_START_DATE] = format_date_for_salesforce(initial_start_date)
Expand Down
8 changes: 4 additions & 4 deletions test/integration/amendments/test_termination.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def create_amendment_and_adjust_quantity(sf_contract:, quantity:)
).count

# wipe out the product
amendment_data["lineItems"].first["record"]["SBQQ__Quantity__c"] += quantity
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] += quantity

# the quote is generated by the contract CPQ API, so we need to set these fields manually
# let's have the second phase start in 9mo
Expand Down Expand Up @@ -61,7 +61,7 @@ def create_amendment_and_adjust_quantity(sf_contract:, quantity:)
amendment_data = create_quote_data_from_contract_amendment(sf_contract)

# wipe out the product
amendment_data["lineItems"].first["record"]["SBQQ__Quantity__c"] = 0
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 0

# the quote is generated by the contract CPQ API, so we need to set these fields manually
# let's have the second phase start in 9mo
Expand Down Expand Up @@ -138,7 +138,7 @@ def create_amendment_and_adjust_quantity(sf_contract:, quantity:)
amendment_data = create_quote_data_from_contract_amendment(sf_contract)

# remove the second product
amendment_data["lineItems"].detect {|i| i["record"]["SBQQ__Product__c"] == sf_product_id_2 }["record"]["SBQQ__Quantity__c"] = 0
amendment_data["lineItems"].detect {|i| i["record"]["SBQQ__Product__c"] == sf_product_id_2 }["record"][CPQ_QUOTE_QUANTITY] = 0

amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_START_DATE] = (initial_start_date + 1.month).strftime("%Y-%m-%d")
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_TERM] = standard_term - 1
Expand Down Expand Up @@ -182,7 +182,7 @@ def create_amendment_and_adjust_quantity(sf_contract:, quantity:)
amendment_data = create_quote_data_from_contract_amendment(sf_contract)

# remove the product
amendment_data["lineItems"].first["record"]["SBQQ__Quantity__c"] = 0
amendment_data["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 0

# the quote is generated by the contract CPQ API, so we need to set these fields manually
# let's have the second phase start in 9mo
Expand Down
2 changes: 1 addition & 1 deletion test/integration/test_translate_order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ class Critic::OrderTranslation < Critic::FunctionalTest

# set first product quantity to 5
quote_with_product = add_product_to_cpq_quote(quote_id, sf_product_id: sf_product_id_1)
quote_with_product["lineItems"].first["record"]["SBQQ__Quantity__c"] = 5.0
quote_with_product["lineItems"].first["record"][CPQ_QUOTE_QUANTITY] = 5.0
calculate_and_save_cpq_quote(quote_with_product)

# recurring and arrears, should not have a quantity set when passed to stripe
Expand Down
Loading

0 comments on commit 68108e4

Please sign in to comment.