From 0490b8fa2fe616dcc6c895ca87b5fb8304370376 Mon Sep 17 00:00:00 2001
From: brennen-stripe <86444598+brennen-stripe@users.noreply.github.com>
Date: Wed, 22 Feb 2023 16:24:26 -0800
Subject: [PATCH] Adding MultiCurrency Support to Coupons (#1010)
* added coupon currency and test
* added failure test case
* wipe currency on non amount off coupons
* fixed coupon match
* added overrides
* added comment
* addressed PR feedback
---
README.md | 6 +
lib/stripe-force/constants.rb | 1 +
lib/stripe-force/translate/coupon.rb | 9 +-
lib/stripe-force/translate/translate.rb | 5 +
sfdx/config/project-scratch-def.json | 5 +-
...om Price Book Entry Layout.layout-meta.xml | 4 +
test/integration/test_multicurrency.rb | 203 ++++++++++++++++++
test/support/common_helpers.rb | 13 +-
test/support/salesforce_factory.rb | 21 +-
9 files changed, 255 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
index a1ad491f28..d49d427ab1 100644
--- a/README.md
+++ b/README.md
@@ -213,6 +213,12 @@ sfdx/bin/sfdx-wipe-account mbianco+standardcpq@stripe.com
- Then use `sfdx force:org:create -s -f config/project-scratch-def.json -a cool-alias -d 30` to create a stratch org. Lasts for a max of 30d.
`alias sf=sfdx` is going to make your CLI-life easier.
+- FOR MULTICURRENCY SCRATCH ORGS:
+ - Prior to running `generate-scratch-org`, do the following:
+ - Go to `config/project-scratch-def.json` and set `enableMultiCurrency` to true
+ - Go to `sfdx/force-app/main/scratchSetup/layouts/PricebookEntry-Custom Price Book Entry Layout.layout-meta.xml` and make sure the `CurrencyIsoCode` layout item is uncommented.
+ - After running `generate-scratch-org` and following it's manual set up, go to `/lightning/setup/CompanyCurrency/home` and set up `GBP` as a new currency (exchange rate doesn't matter).
+
Apex triggers run synchronously, TODO add to notes
## Finding an existing package
diff --git a/lib/stripe-force/constants.rb b/lib/stripe-force/constants.rb
index f87fd7af08..793641e923 100644
--- a/lib/stripe-force/constants.rb
+++ b/lib/stripe-force/constants.rb
@@ -107,6 +107,7 @@ class SalesforceStripeCouponFields < T::Enum
DURATION = new('Duration__c')
DURATION_IN_MONTHS = new('Duration_In_Months__c')
MAX_REDEMPTIONS = new('Max_Redemptions__c')
+ CURRENCY_ISO_CODE = new(StripeForce::Constants::SF_CURRENCY_ISO_CODE)
end
end
diff --git a/lib/stripe-force/translate/coupon.rb b/lib/stripe-force/translate/coupon.rb
index 5bb4593113..e37ee89116 100644
--- a/lib/stripe-force/translate/coupon.rb
+++ b/lib/stripe-force/translate/coupon.rb
@@ -65,11 +65,12 @@ def coupons_are_equal?(existing_stripe_coupon:, generated_stripe_coupon:)
end
def sanitize_stripe_coupon(stripe_coupon)
- # Stripe expects amount_off to be specified in cents
- if !stripe_coupon["amount_off"].nil?
- stripe_coupon["currency"] = 'usd'
+ if stripe_coupon["amount_off"].nil?
+ # Non-amount_off coupons should not have a currency set (auto-set in construct_stripe_object)
+ stripe_coupon["currency"] = nil
+ else
+ # Stripe expects amount_off to be specified in cents
stripe_coupon["amount_off"] = normalize_float_amount_for_stripe(stripe_coupon.currency, stripe_coupon["amount_off"].to_s, @user)
-
end
# Prevents Stripe API error 'Stripe::InvalidRequestError: Invalid integer: 1.0'
diff --git a/lib/stripe-force/translate/translate.rb b/lib/stripe-force/translate/translate.rb
index 81d88d2af9..0de3ee1b5a 100644
--- a/lib/stripe-force/translate/translate.rb
+++ b/lib/stripe-force/translate/translate.rb
@@ -353,6 +353,11 @@ def construct_stripe_object(stripe_class:, salesforce_object:, additional_stripe
stripe_object.metadata = Metadata.stripe_metadata_for_sf_object(@user, salesforce_object)
+ # Prices and Coupons require currency fields
+ if [Stripe::Price, Stripe::Coupon].include?(stripe_object.class)
+ stripe_object['currency'] = Integrations::Utilities::Currency.currency_for_sf_object(@user, salesforce_object)
+ end
+
apply_mapping(stripe_object, salesforce_object)
sanitize(stripe_object)
diff --git a/sfdx/config/project-scratch-def.json b/sfdx/config/project-scratch-def.json
index f6d2f58341..a1ae830f80 100644
--- a/sfdx/config/project-scratch-def.json
+++ b/sfdx/config/project-scratch-def.json
@@ -1,10 +1,11 @@
+
{
"orgName": "pbo+billing@stripe.com",
"adminEmail": "pbo+billing-scratch-admin@stripe.com",
"edition": "Developer",
"settings": {
- "currencySettings":{
- "enableMultiCurrency": true
+ "currencySettings":{
+ "enableMultiCurrency": false
},
"mobileSettings": {
"enableS1EncryptedStoragePref2": false
diff --git a/sfdx/force-app/main/scratchSetup/layouts/PricebookEntry-Custom Price Book Entry Layout.layout-meta.xml b/sfdx/force-app/main/scratchSetup/layouts/PricebookEntry-Custom Price Book Entry Layout.layout-meta.xml
index 141af3ef52..2d61f71592 100644
--- a/sfdx/force-app/main/scratchSetup/layouts/PricebookEntry-Custom Price Book Entry Layout.layout-meta.xml
+++ b/sfdx/force-app/main/scratchSetup/layouts/PricebookEntry-Custom Price Book Entry Layout.layout-meta.xml
@@ -18,6 +18,10 @@
Required
UnitPrice
+
Edit
UseStandardPrice
diff --git a/test/integration/test_multicurrency.rb b/test/integration/test_multicurrency.rb
index 9789bd1315..e33b40143b 100644
--- a/test/integration/test_multicurrency.rb
+++ b/test/integration/test_multicurrency.rb
@@ -7,6 +7,8 @@ class Critic::OrderTranslation < Critic::FunctionalTest
before do
@user = make_user(save: true)
# Only run on Multi-Currency Enabled CI Accounts
+ # If you are running this locally on a multi-currency scratch org, please go into make_user
+ # and add your scratch org ID similar to brennen's
unless @user.is_multicurrency_org?
skip("Skipping multicurrency test on non-multicurrency org")
end
@@ -200,6 +202,158 @@ class Critic::OrderTranslation < Critic::FunctionalTest
assert_equal(SF_ORDER, sync_records.first[prefixed_stripe_field(SyncRecordFields::PRIMARY_OBJECT_TYPE.serialize)])
assert_equal(SyncRecordResolutionStatuses::SUCCESS.serialize, sync_records.first[prefixed_stripe_field(SyncRecordFields::RESOLUTION_STATUS.serialize)])
end
+
+ it 'translates an order with a coupon in a currency other than USD' do
+ @user.enable_feature(FeatureFlags::COUPONS)
+
+ # add a custom metadata field mapping
+ @user.field_mappings['coupon'] = {
+ 'metadata.sf_coupon_mapped_metadata_field' => prefixed_stripe_field('Name__c'),
+ 'metadata.sf_order_mapped_metadata_field' => prefixed_stripe_field('Order_Item__c.OrderId'),
+ }
+ @user.save
+
+ currency_iso_code = 'GBP'
+
+ # setup
+ sf_account_id = create_salesforce_account
+ sf_product_id, _sf_pricebook_id = salesforce_recurring_product_with_price(currency_iso_code: currency_iso_code)
+
+ # create a CPQ quote
+ sf_quote_id = create_salesforce_quote(sf_account_id: sf_account_id, currency_iso_code: currency_iso_code, additional_quote_fields: {
+ CPQ_QUOTE_SUBSCRIPTION_START_DATE => now_time_formatted_for_salesforce,
+ CPQ_QUOTE_SUBSCRIPTION_TERM => TEST_DEFAULT_CONTRACT_TERM,
+ })
+
+ # create a quote with a product
+ quote_with_product = add_product_to_cpq_quote(sf_quote_id, sf_product_id: sf_product_id)
+ sf_quote_id = calculate_and_save_cpq_quote(quote_with_product)
+
+ # retrieve the quote line
+ quote_lines = sf_get_related(sf_quote_id, CPQ_QUOTE_LINE)
+ assert_equal(1, quote_lines.size)
+ quote_line_id = quote_lines.first.Id
+
+ sf_amount_off_coupon_id = create_salesforce_stripe_coupon(additional_fields: {
+ SalesforceStripeCouponFields::NAME => '£10 off coupon',
+ SalesforceStripeCouponFields::AMOUNT_OFF => 10,
+ SalesforceStripeCouponFields::CURRENCY_ISO_CODE => currency_iso_code,
+ })
+
+ # create the quote line coupon association object to map the coupons to the quote line
+ create_salesforce_stripe_coupon_quote_line_association(sf_quote_line_id: quote_line_id, sf_stripe_coupon_id: sf_amount_off_coupon_id)
+
+ # create and translate the SF order
+ sf_order = create_order_from_cpq_quote(sf_quote_id)
+ StripeForce::Translate.perform_inline(@user, sf_order.Id)
+
+ # fetch the stripe subscription schedule
+ sf_order.refresh
+ stripe_id = sf_order[prefixed_stripe_field(GENERIC_STRIPE_ID)]
+ subscription_schedule = Stripe::SubscriptionSchedule.retrieve(stripe_id, @user.stripe_credentials)
+ assert_equal(1, subscription_schedule.phases.count)
+
+ # first phase should have one item
+ first_phase = T.must(subscription_schedule.phases.first)
+ assert_equal(1, first_phase.items.count)
+
+ # the first phase item should have two coupons
+ first_phase_item = T.must(first_phase.items.first)
+ discounts = first_phase_item.discounts
+ assert_equal(1, discounts.count)
+
+ # retrieve the two stripe coupons
+ stripe_coupon = Stripe::Coupon.retrieve(T.must(discounts.first).coupon, @user.stripe_credentials)
+
+ # sanity check both stripe coupons have the right data
+ assert_equal(10 * 100, stripe_coupon.amount_off)
+ assert_equal(currency_iso_code.downcase, stripe_coupon.currency)
+ assert_equal("once", stripe_coupon.duration)
+
+ sf_amount_off_order_coupon = get_sf_order_coupon_from_quote_coupon_id(sf_amount_off_coupon_id)
+ assert_equal(sf_amount_off_order_coupon.Id, stripe_coupon.metadata['salesforce_order_stripe_coupon_id'])
+ assert_equal('£10 off coupon', stripe_coupon.metadata['sf_coupon_mapped_metadata_field'])
+ assert_equal(sf_order.Id, stripe_coupon.metadata['sf_order_mapped_metadata_field'])
+
+ # confirm the stripe coupon ids were written back to the quote coupons in salesforce
+ sf_amount_off_coupon = sf_get(sf_amount_off_coupon_id)
+ assert_equal(stripe_coupon.id, sf_amount_off_coupon[prefixed_stripe_field(GENERIC_STRIPE_ID)])
+ end
+
+ it 'allows association of a percent off coupon with an quote in a different currency' do
+ @user.enable_feature(FeatureFlags::COUPONS)
+
+ # add a custom metadata field mapping
+ @user.field_mappings['coupon'] = {
+ 'metadata.sf_coupon_mapped_metadata_field' => prefixed_stripe_field('Name__c'),
+ 'metadata.sf_order_mapped_metadata_field' => prefixed_stripe_field('Order_Item__c.OrderId'),
+ }
+ @user.save
+
+ quote_currency_iso_code = 'GBP'
+ coupon_currency_iso_code = 'USD'
+
+ # setup
+ sf_account_id = create_salesforce_account
+ sf_product_id, _sf_pricebook_id = salesforce_recurring_product_with_price(currency_iso_code: quote_currency_iso_code)
+
+ # create a CPQ quote
+ sf_quote_id = create_salesforce_quote(sf_account_id: sf_account_id, currency_iso_code: quote_currency_iso_code, additional_quote_fields: {
+ CPQ_QUOTE_SUBSCRIPTION_START_DATE => now_time_formatted_for_salesforce,
+ CPQ_QUOTE_SUBSCRIPTION_TERM => TEST_DEFAULT_CONTRACT_TERM,
+ })
+
+ # create a quote with a product
+ quote_with_product = add_product_to_cpq_quote(sf_quote_id, sf_product_id: sf_product_id)
+ sf_quote_id = calculate_and_save_cpq_quote(quote_with_product)
+
+ # retrieve the quote line
+ quote_lines = sf_get_related(sf_quote_id, CPQ_QUOTE_LINE)
+ assert_equal(1, quote_lines.size)
+ quote_line_id = quote_lines.first.Id
+
+ sf_percent_off_coupon_id = create_salesforce_stripe_coupon(additional_fields: {
+ SalesforceStripeCouponFields::NAME => '20% off coupon',
+ SalesforceStripeCouponFields::PERCENT_OFF => 20,
+ SalesforceStripeCouponFields::CURRENCY_ISO_CODE => coupon_currency_iso_code,
+ })
+
+ # create the quote line coupon association object to map the coupons to the quote line
+ create_salesforce_stripe_coupon_quote_line_association(sf_quote_line_id: quote_line_id, sf_stripe_coupon_id: sf_percent_off_coupon_id)
+
+ # create and translate the SF order
+ sf_order = create_order_from_cpq_quote(sf_quote_id)
+ StripeForce::Translate.perform_inline(@user, sf_order.Id)
+
+ # fetch the stripe subscription schedule
+ sf_order.refresh
+ stripe_id = sf_order[prefixed_stripe_field(GENERIC_STRIPE_ID)]
+ subscription_schedule = Stripe::SubscriptionSchedule.retrieve(stripe_id, @user.stripe_credentials)
+ assert_equal(1, subscription_schedule.phases.count)
+
+ # first phase should have one item
+ first_phase = T.must(subscription_schedule.phases.first)
+ assert_equal(1, first_phase.items.count)
+
+ # the first phase item should have two coupons
+ first_phase_item = T.must(first_phase.items.first)
+ discounts = first_phase_item.discounts
+ assert_equal(1, discounts.count)
+
+ # retrieve the two stripe coupons
+ stripe_coupon = Stripe::Coupon.retrieve(T.must(discounts.first).coupon, @user.stripe_credentials)
+
+ assert_equal(20, stripe_coupon.percent_off)
+ assert_equal("once", stripe_coupon.duration)
+
+ sf_percent_off_order_coupon = get_sf_order_coupon_from_quote_coupon_id(sf_percent_off_coupon_id)
+ assert_equal(sf_percent_off_order_coupon.Id, stripe_coupon.metadata['salesforce_order_stripe_coupon_id'])
+ assert_equal('20% off coupon', stripe_coupon.metadata['sf_coupon_mapped_metadata_field'])
+ assert_equal(sf_order.Id, stripe_coupon.metadata['sf_order_mapped_metadata_field'])
+
+ sf_percent_off_coupon = sf_get(sf_percent_off_coupon_id)
+ assert_equal(stripe_coupon.id, sf_percent_off_coupon[prefixed_stripe_field(GENERIC_STRIPE_ID)])
+ end
end
describe 'failure cases' do
@@ -256,6 +410,55 @@ class Critic::OrderTranslation < Critic::FunctionalTest
assert_equal(SF_ORDER, sync_records.first[prefixed_stripe_field(SyncRecordFields::PRIMARY_OBJECT_TYPE.serialize)])
assert_equal(SyncRecordResolutionStatuses::ERROR.serialize, sync_records.first[prefixed_stripe_field(SyncRecordFields::RESOLUTION_STATUS.serialize)])
end
+
+ it 'fails to create associate a coupon with an quote in a different currency' do
+ @user.enable_feature(FeatureFlags::COUPONS)
+
+ # add a custom metadata field mapping
+ @user.field_mappings['coupon'] = {
+ 'metadata.sf_coupon_mapped_metadata_field' => prefixed_stripe_field('Name__c'),
+ 'metadata.sf_order_mapped_metadata_field' => prefixed_stripe_field('Order_Item__c.OrderId'),
+ }
+ @user.save
+
+ quote_currency_iso_code = 'GBP'
+ coupon_currency_iso_code = 'USD'
+
+ # setup
+ sf_account_id = create_salesforce_account
+ sf_product_id, _sf_pricebook_id = salesforce_recurring_product_with_price(currency_iso_code: quote_currency_iso_code)
+
+ # create a CPQ quote
+ sf_quote_id = create_salesforce_quote(sf_account_id: sf_account_id, currency_iso_code: quote_currency_iso_code, additional_quote_fields: {
+ CPQ_QUOTE_SUBSCRIPTION_START_DATE => now_time_formatted_for_salesforce,
+ CPQ_QUOTE_SUBSCRIPTION_TERM => TEST_DEFAULT_CONTRACT_TERM,
+ })
+
+ # create a quote with a product
+ quote_with_product = add_product_to_cpq_quote(sf_quote_id, sf_product_id: sf_product_id)
+ sf_quote_id = calculate_and_save_cpq_quote(quote_with_product)
+
+ # retrieve the quote line
+ quote_lines = sf_get_related(sf_quote_id, CPQ_QUOTE_LINE)
+ assert_equal(1, quote_lines.size)
+ quote_line_id = quote_lines.first.Id
+
+ sf_amount_off_coupon_id = create_salesforce_stripe_coupon(additional_fields: {
+ SalesforceStripeCouponFields::NAME => '£10 off coupon',
+ SalesforceStripeCouponFields::AMOUNT_OFF => 10,
+ SalesforceStripeCouponFields::CURRENCY_ISO_CODE => coupon_currency_iso_code,
+ })
+
+ exception = assert_raises(Restforce::ErrorCode::FieldCustomValidationException) do
+ # create the quote line coupon association object to map the coupons to the quote line
+ create_salesforce_stripe_coupon_quote_line_association(sf_quote_line_id: quote_line_id, sf_stripe_coupon_id: sf_amount_off_coupon_id)
+ end
+
+ assert_equal(
+ "FIELD_CUSTOM_VALIDATION_EXCEPTION: The Currency of the Quote (GBP) does not match the Currency of the Quote Stripe Coupon (USD).",
+ exception.message
+ )
+ end
end
end
diff --git a/test/support/common_helpers.rb b/test/support/common_helpers.rb
index d3ae739977..e44e7855de 100644
--- a/test/support/common_helpers.rb
+++ b/test/support/common_helpers.rb
@@ -83,10 +83,21 @@ def make_user(sandbox: false, save: false, random_user_id: false, livemode: fals
user.connector_settings[CONNECTOR_SETTING_SALESFORCE_NAMESPACE] = SalesforceNamespaceOptions::NONE.serialize
end
+ # MULTI_CURRENCY ORG OVERRIDES -------------------------------------------------------------------------
+ # If you are adding an account here, make sure you have added 'GBP' in Setup -> Manage Currencies
+
+ # Default to False
+ user.connector_settings[CONNECTOR_SETTING_MULTICURRENCY_ENABLED] = false
+
+ # mbianco+cpqmulticurrency@stripe.com
+ if user.salesforce_account_id == "00D5f000006O9HAEA0"
+ user.connector_settings[CONNECTOR_SETTING_MULTICURRENCY_ENABLED] = true
+ end
# brennen-multi-curr-scratch
- if user.salesforce_account_id == "00DDM000003rZgW2AU"
+ if user.salesforce_account_id == "00D52000000YKbHEAW"
user.connector_settings[CONNECTOR_SETTING_MULTICURRENCY_ENABLED] = true
end
+ # ------------------------------------------------------------------------------------------------------
# clocks won't be enabled in prod, so we want to mimic this
user.disable_feature(FeatureFlags::TEST_CLOCKS)
diff --git a/test/support/salesforce_factory.rb b/test/support/salesforce_factory.rb
index a47d515054..a08bfa36c6 100644
--- a/test/support/salesforce_factory.rb
+++ b/test/support/salesforce_factory.rb
@@ -81,8 +81,8 @@ def create_salesforce_opportunity(sf_account_id:, currency_iso_code: nil, additi
currency_iso_code ||= @user.connector_settings['default_currency']
if @user.is_multicurrency_org?
- additional_fields.merge({
- SF_CURRENCY_ISO_CODE => currency_iso_code,
+ additional_fields = additional_fields.merge({
+ StripeForce::Constants::SF_CURRENCY_ISO_CODE => currency_iso_code,
})
end
@@ -100,8 +100,8 @@ def create_salesforce_price(sf_product_id: nil, price: nil, currency_iso_code: n
currency_iso_code ||= @user.connector_settings['default_currency']
if @user.is_multicurrency_org?
- additional_fields.merge({
- SF_CURRENCY_ISO_CODE => currency_iso_code,
+ additional_fields = additional_fields.merge({
+ StripeForce::Constants::SF_CURRENCY_ISO_CODE => currency_iso_code,
})
end
@@ -146,10 +146,21 @@ def create_salesforce_stripe_coupon_quote_association(sf_quote_id:, sf_stripe_co
def create_salesforce_stripe_coupon_quote_line_association(sf_quote_line_id:, sf_stripe_coupon_id:)
sf_stripe_coupon_id ||= create_salesforce_stripe_coupon
+ # Our association objects unfortunately have currency fields in multicurrency orgs, so we must pass
+ # the coupon's currency to the association here.
+ # This is error checked in the UI with this PR: https://github.com/stripe/stripe-salesforce/pull/1006
+ currency_field = {}
+ if @user.is_multicurrency_org?
+ sf_stripe_coupon = sf_get(sf_stripe_coupon_id)
+ currency_field = {
+ StripeForce::Constants::SF_CURRENCY_ISO_CODE => sf_stripe_coupon[StripeForce::Constants::SF_CURRENCY_ISO_CODE],
+ }
+ end
+
sf.create!(prefixed_stripe_field(QUOTE_LINE_SF_STRIPE_COUPON_ASSOCIATION), {
"Quote_Line__c" => sf_quote_line_id,
QUOTE_SF_STRIPE_COUPON => sf_stripe_coupon_id,
- }.transform_keys(&method(:prefixed_stripe_field)))
+ }.merge(currency_field).transform_keys(&method(:prefixed_stripe_field)))
end
def create_salesforce_stripe_coupon(additional_fields: {})