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: {})