Skip to content

Commit

Permalink
Backend proration without amendment support (#818)
Browse files Browse the repository at this point in the history
* Initial backend proration test

* Missing types

* Split phases on initial order if it is backend prorated

* Some documentation improvements

* Helper for determining if initial order is prorated

* Note about better helper to use

* Unrelated script for queue management

* Docs todo

* Minor doc update

* Extract out initial order phase generation

* Check for one-time invoice before extracting subscription params

* Fixing sync record link failure
  • Loading branch information
mbianco-stripe authored Oct 3, 2022
1 parent 9275c05 commit 1bff76f
Show file tree
Hide file tree
Showing 11 changed files with 277 additions and 61 deletions.
2 changes: 2 additions & 0 deletions TODO
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
- Prices which are not created directly from pricebooks (i.e. from order lines, or duplicated because of the one-price-per-subscription) are archived (active = false) after they are used
- Some good test clock docs https://groups.google.com/a/stripe.com/g/cloudflare/c/VjNW1Q4KD-0
- fyi if the filter is changed we don't retroactively pick up orders. the orders have to be updated in some fashion (just updating a note/description will do just fine) for it to be reprocessed
- On a backend prorated order amendment the same metadata is used as the initial order
- Tiered prices cannot be prorated

Apex / checks:

Expand Down
179 changes: 129 additions & 50 deletions lib/stripe-force/translate/order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,12 @@ def create_stripe_transaction_from_sf_order(sf_order)
end

sf_account = cache_service.get_record_from_cache(SF_ACCOUNT, sf_order[SF_ORDER_ACCOUNT])

stripe_customer = translate_account(sf_account)

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?

is_recurring_order = !subscription_items.empty?
if !is_recurring_order
return create_stripe_invoice_from_order(stripe_customer, invoice_items, sf_order)
end
Expand All @@ -100,19 +99,92 @@ def create_stripe_transaction_from_sf_order(sf_order)

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

# TODO should file an API papercut for this
subscription_schedule = Stripe::SubscriptionSchedule.construct_from({
# TODO this should be specified in the defaults hash... we should create a defaults hash https://jira.corp.stripe.com/browse/PLATINT-1501
end_behavior: 'cancel',
metadata: Metadata.stripe_metadata_for_sf_object(@user, 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
)

# TODO refactor once caching is stable, more notes in the `generate_phases_for_initial_rder`
if subscription_schedule_phases.is_a?(Stripe::Invoice)
return subscription_schedule_phases
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 updating it, it is specified on the individual phase object
string_start_date_from_salesforce = subscription_params['start_date']

subscription_start_date_as_timestamp = StripeForce::Utilities::SalesforceUtil.salesforce_date_to_unix_timestamp(subscription_schedule.start_date)
subscription_schedule.start_date = subscription_start_date_as_timestamp

Integrations::Utilities::StripeUtil.delete_field_from_stripe_object(
subscription_schedule,
:iterations
)

# TODO add mapping support against the subscription schedule phase

subscription_schedule.customer = stripe_customer.id
subscription_schedule.phases = subscription_schedule_phases.compact

# 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")
end

# https://jira.corp.stripe.com/browse/PLATINT-1731
days_until_due = subscription_schedule.[](:default_settings)&.[](:invoice_settings)&.[](:days_until_due)
if days_until_due
subscription_schedule.default_settings.invoice_settings.days_until_due = OrderHelpers.transform_payment_terms_to_days_until_due(days_until_due)
end

subscription_schedule = Stripe::SubscriptionSchedule.create(
subscription_schedule.to_hash,
StripeForce::Utilities::StripeUtil.generate_idempotency_key_with_credentials(@user, sf_order)
)

log.info 'stripe subscription schedule created', stripe_subscription_schedule_id: subscription_schedule.id

update_sf_stripe_id(sf_order, subscription_schedule)

PriceHelpers.auto_archive_prices_on_subscription_schedule(@user, subscription_schedule)

stripe_transaction
end

# it is important that the subscription schedule is passed in before the start_date is transformed
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.any(Stripe::Invoice, [Hash, T.nilable(Hash)]))
end
def generate_phases_for_initial_order(sf_order:, invoice_items:, subscription_items:, subscription_schedule:, stripe_customer:)
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)
subscription_start_date_as_timestamp = StripeForce::Utilities::SalesforceUtil.salesforce_date_to_unix_timestamp(string_start_date_from_salesforce)
subscription_params['start_date'] = subscription_start_date_as_timestamp

subscription_term_from_sales_force = subscription_params.delete('iterations').to_i
subscription_term_from_sales_force = 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(
DateTime.parse(string_start_date_from_salesforce) +
+ subscription_term_from_sales_force.months
start_date_from_salesforce + subscription_term_from_sales_force.months
)

# TODO we should have a check to ensure all quantities are positive
Expand All @@ -122,10 +194,13 @@ def create_stripe_transaction_from_sf_order(sf_order)
initial_phase = {
add_invoice_items: invoice_items.map(&:stripe_params),
items: subscription_items.map(&:stripe_params),
# TODO so annoying we cannot put the start_date here
end_date: initial_phase_end_date,
metadata: Metadata.stripe_metadata_for_sf_object(@user, sf_order),
}

prorated_phase = nil

# TODO this needs to be gated and synced with the specific flag that CF is using
if subscription_start_date_as_timestamp >= OrderAmendment.determine_current_time(@user, stripe_customer.id)
# when `sub_sched_backdating_anchors_on_backdate` is enabled prorating the initial phase
Expand All @@ -135,59 +210,54 @@ def create_stripe_transaction_from_sf_order(sf_order)
initial_phase[:proration_behavior] = 'none'
end

# TODO backend order prorations!
# is order prorated? we'll need a separate helper for this
# if so, iterate through phase items items and generate proration items
# create a new phase to store these items, this will be custom for the initial order
# we can release this, and then support amendments next
# pull this '2nd phase creation' out into a separate method so we can use it on the amendment side of things for amendments
# make sure the initial state on the order amendments includes the second phase
# we may need to do some weird phase merging because the initial order phase could be after the order amendment phase

# TODO add mapping support against the subscription schedule phase
billing_frequency = OrderAmendment.calculate_billing_frequency_from_phase_items(@user, subscription_items)

# TODO subs in SF must always have an end date
stripe_transaction = Stripe::SubscriptionSchedule.construct_from({
customer: stripe_customer.id,
is_initial_order_prorated = OrderHelpers.prorated_initial_order?(
phase_items: subscription_items,
subscription_term: subscription_term_from_sales_force,
billing_frequency: billing_frequency
)

# TODO this should be specified in the defaults hash... we should create a defaults hash https://jira.corp.stripe.com/browse/PLATINT-1501
end_behavior: 'cancel',
if is_initial_order_prorated
# create a new phase to store these items, this will be custom for the initial order
# pull this '2nd phase creation' out into a separate method so we can use it on the amendment side of things for amendments

# initial order will only ever contain a single phase
phases: [initial_phase],
# TODO next, we need to support prorated order amendments
# make sure the initial state on the order amendments includes the second phase
# we may need to do some weird phase merging because the initial order phase could be after the order amendment phase

metadata: Metadata.stripe_metadata_for_sf_object(@user, sf_order),
})
invoice_items_for_prorations = OrderAmendment.generate_proration_items_from_phase_items(
user: @user,
sf_order_amendment: sf_order,
phase_items: subscription_items,
subscription_term: subscription_term_from_sales_force,
billing_frequency: billing_frequency
)

mapper.assign_values_from_hash(stripe_transaction, subscription_params)
apply_mapping(stripe_transaction, sf_order)
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_start_date_timestamp = StripeForce::Utilities::SalesforceUtil.datetime_to_unix_timestamp(backend_proration_start_date)

# 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(stripe_transaction).map {|l| l[:quantity] || 1 }.any?(&:zero?)
Integrations::ErrorContext.report_edge_case("quantity is zero on initial subscription schedule")
end
prorated_phase = {
# we do not want to bill the user for a entire billing cycle, so we turn off prorations
proration_behavior: 'none',

# https://jira.corp.stripe.com/browse/PLATINT-1731
days_until_due = stripe_transaction.[](:default_settings)&.[](:invoice_settings)&.[](:days_until_due)
if days_until_due
stripe_transaction.default_settings.invoice_settings.days_until_due = OrderHelpers.transform_payment_terms_to_days_until_due(days_until_due)
end
# in the prorated phase, the item list will be exactly the same as the initial phase
items: subscription_items.map(&:stripe_params),

stripe_transaction = Stripe::SubscriptionSchedule.create(
stripe_transaction.to_hash,
StripeForce::Utilities::StripeUtil.generate_idempotency_key_with_credentials(@user, sf_order)
)
# on initial creation, you cannot specify a start_date!
end_date: initial_phase_end_date,

log.info 'stripe subscription or invoice created', stripe_resource_id: stripe_transaction.id
add_invoice_items: invoice_items_for_prorations,

update_sf_stripe_id(sf_order, stripe_transaction)
# TODO think about adding a special metadata field to indicate that this is a split phase
metadata: Metadata.stripe_metadata_for_sf_object(@user, sf_order),
}

if stripe_transaction.is_a?(Stripe::SubscriptionSchedule)
PriceHelpers.auto_archive_prices_on_subscription_schedule(@user, stripe_transaction)
initial_phase['end_date'] = backend_proration_start_date_timestamp
end

stripe_transaction
[initial_phase, prorated_phase]
end

sig do
Expand Down Expand Up @@ -272,6 +342,16 @@ def update_subscription_phases_from_order_amendments(contract_structure)
sf_initial_order_items = order_lines_from_order(contract_structure.initial)
_, aggregate_phase_items = phase_items_from_order_lines(sf_initial_order_items)

is_initial_order_prorated = OrderHelpers.prorated_initial_order?(
phase_items: aggregate_phase_items,
subscription_term: StripeForce::Utilities::SalesforceUtil.extract_subscription_term_from_order!(@mapper, contract_structure.initial),
billing_frequency: OrderAmendment.calculate_billing_frequency_from_phase_items(@user, aggregate_phase_items)
)

if is_initial_order_prorated
raise StripeForce::Errors::RawUserError.new("Amending prorated initial orders is not yet supported")
end

subscription_phases = subscription_schedule.phases

# SF does not enforce mutation restrictions. It's possible to go in and modify anything you want in Salesforce
Expand Down Expand Up @@ -320,8 +400,7 @@ def update_subscription_phases_from_order_amendments(contract_structure)

# originally `iterations` was used, but this fails when subscription term is less than a single billing cycle
phase_params['end_date'] = StripeForce::Utilities::SalesforceUtil.datetime_to_unix_timestamp(
DateTime.parse(string_start_date_from_salesforce) +
+ subscription_term_from_sales_force.months
DateTime.parse(string_start_date_from_salesforce) + subscription_term_from_sales_force.months
)

# TODO should we validate the end date vs the subscription schedule?
Expand Down
18 changes: 18 additions & 0 deletions lib/stripe-force/translate/order/amendments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ def self.contract_co_terminated?(mapper, contract_structure)

sig { params(mapper: StripeForce::Mapper, sf_order: Restforce::SObject).returns(Integer) }
def self.calculate_order_end_date(mapper, sf_order)
# TODO should use a helper instead, which respects custom mapping
# salesforce_subscription_term = StripeForce::Utilities::SalesforceUtil.extract_subscription_term_from_order!(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
Expand All @@ -34,6 +37,14 @@ def self.calculate_order_end_date(mapper, sf_order)
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,
subscription_term: Integer,
billing_frequency: Integer
).returns(Stripe::Price)
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
Expand Down Expand Up @@ -90,6 +101,8 @@ def self.generate_proration_items_from_phase_items(user:, sf_order_amendment:, p
invoice_items_for_prorations = []

phase_items.each do |phase_item|
# metered prices are calculated based on user-provided usage information and therefore cannot be prorated because
# we do not know the full billing amount ahead of time.
# TODO I don't like passing the user here, maybe pass in the user with the contract item? Going to see how ugly this feels...
if PriceHelpers.metered_price?(phase_item.price(user))
log.info 'metered price, not prorating',
Expand All @@ -98,13 +111,16 @@ def self.generate_proration_items_from_phase_items(user:, sf_order_amendment:, p
next
end

# right now, you can only have tiered pricing specified through consumption schedules, which are only used if the
# price is metered billed. We may need to support prorated tiered prices in the future.
if PriceHelpers.tiered_price?(phase_item.price)
log.info 'tiered price, not prorating',
prorated_order_item_id: phase_item.order_line_id,
price_id: phase_item.price
next
end

# if the price is not recurring, there is no need to prorate it since it will be fully billed up front
if !PriceHelpers.recurring_price?(phase_item.price)
log.info 'one time price, not prorating',
prorated_order_item_id: phase_item.order_line_id,
Expand All @@ -113,6 +129,8 @@ def self.generate_proration_items_from_phase_items(user:, sf_order_amendment:, p
end

# we only want to prorate the items that are unique to this order
# in other words, if the item needed to be prorated it would have already happened when processing the
# order that it was originally included on.
if !phase_item.from_order?(sf_order_amendment)
log.info 'line item not originated from this amendment, not prorating',
prorated_order_item_id: phase_item.order_line_id,
Expand Down
26 changes: 26 additions & 0 deletions lib/stripe-force/translate/order/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,32 @@ module OrderHelpers
include StripeForce::Constants
extend SimpleStructuredLogger

sig do
params(
phase_items: T::Array[ContractItemStructure],
subscription_term: Integer,
billing_frequency: Integer,
).returns(T::Boolean)
end
def self.prorated_initial_order?(phase_items:, subscription_term:, billing_frequency:)
log.info 'determining if initial order is prorated'

if phase_items.empty?
log.info 'no subscription items, cannot be prorated order'
return false
end

# if the subscription term does not match the billing frequency of the stripe item, then there will be some proration
if (subscription_term % billing_frequency) != 0
log.info 'billing frequency is not divisible by subscription term, assuming prorated initial order',
subscription_term: subscription_term,
billing_frequency: billing_frequency
return true
end

false
end

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 +
Expand Down
2 changes: 1 addition & 1 deletion lib/stripe-force/translate/translate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ def construct_stripe_object(stripe_class:, salesforce_object:, additional_stripe

stripe_object = stripe_class.construct_from(additional_stripe_params)

# the fields in the resulting hash could be dot-paths, so let's assign them using the mapper
# the keys (stripe references) in the resulting hash could be dot-paths, so let's assign them using the mapper
mapper.assign_values_from_hash(stripe_object, stripe_fields)

stripe_object.metadata = Metadata.stripe_metadata_for_sf_object(@user, salesforce_object)
Expand Down
5 changes: 5 additions & 0 deletions scripts/remove_delayed_jobs.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Resque.remove_delayed_selection(SalesforceTranslateRecordJob) do |args|
puts args.inspect
username = args[0]
args.size == 5
end
4 changes: 4 additions & 0 deletions sorbet/custom/stripe.rbi
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ class Stripe::SubscriptionSchedulePhase < Stripe::StripeObject
sig { params(arg: Integer).void }
def start_date=(arg); end

sig { returns(String) }
def proration_behavior; end


sig { returns(Integer)}
def end_date; end

Expand Down
2 changes: 1 addition & 1 deletion test/integration/amendments/test_amendments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ class Critic::OrderAmendmentTranslation < Critic::OrderAmendmentFunctionalTest

# remove metered billing item completely
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_START_DATE] = format_date_for_salesforce(amendment_start_date)
amendment_data["record"][CPQ_QUOTE_SUBSCRIPTION_TERM] = amendment_term
sf_quote_id = calculate_and_save_cpq_quote(amendment_data)

Expand Down
Loading

0 comments on commit 1bff76f

Please sign in to comment.