diff --git a/.circleci/config.yml b/.circleci/config.yml index 7bab140c96..fef4e9c61f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -28,7 +28,7 @@ workflows: # unpackaged source test, we do not want a package installed here - mbianco+standardcpq@stripe.com # QA package test, all of the fields/rest endpoints/etc have a prefix - - platform-integrations-bots+cpqqapackage@stripe.com + # - platform-integrations-bots+cpqqapackage@stripe.com # production package test, has a different prefix from QA # - brennen+prodtest@stripe.com diff --git a/lib/stripe-force/constants.rb b/lib/stripe-force/constants.rb index 4664ed9513..fbf555e76c 100644 --- a/lib/stripe-force/constants.rb +++ b/lib/stripe-force/constants.rb @@ -20,6 +20,8 @@ module Constants SF_PRODUCT_CONSUMPTION_SCHEDULE = 'ProductConsumptionSchedule' SF_CONSUMPTION_RATE = 'ConsumptionRate' SF_CONTRACT = 'Contract' + SF_STRIPE_COUPON = 'Stripe_Coupon_Beta__c' + SF_STRIPE_COUPON_QUOTE_LINE_ASSOCIATION = 'Stripe_Coupon_Beta_Quote_Line_Associatio__c' SF_ID = 'Id' SF_LAST_MODIFIED_DATE = 'LastModifiedDate' @@ -33,6 +35,7 @@ module Constants SF_CONTRACT_QUOTE_ID = 'SBQQ__Quote__c' CPQ_QUOTE = 'SBQQ__Quote__c' + CPQ_QUOTE_LINE = 'SBQQ__QuoteLine__c' CPQ_CONSUMPTION_SCHEDULE = 'SBQQ__OrderItemConsumptionSchedule__c' CPQ_CONSUMPTION_RATE = 'SBQQ__OrderItemConsumptionRate__c' @@ -92,6 +95,18 @@ class CPQProductBillingTypeOptions < T::Enum ORDER_SUBSCRIPTION_PAYMENT_LINK = 'Stripe_Subscription_Payment_Link__c' SYNC_RECORD = 'Sync_Record__c' + + class SalesforceStripeCouponFields < T::Enum + enums do + NAME = new('Name__c') + PERCENT_OFF = new('Percent_Off__c') + AMOUNT_OFF = new('Amount_Off__c') + DURATION = new('Duration__c') + DURATION_IN_MONTHS = new('Duration_In_Months__c') + MAX_REDEMPTIONS = new('Max_Redemptions__c') + end + end + class SyncRecordFields < T::Enum enums do COMPOUND_ID = new('Compound_ID__c') diff --git a/lib/stripe-force/db/user.rb b/lib/stripe-force/db/user.rb index 423b3b35c7..28ba7afc33 100644 --- a/lib/stripe-force/db/user.rb +++ b/lib/stripe-force/db/user.rb @@ -160,6 +160,7 @@ def required_mappings "price_order_item" => { "unit_amount_decimal" => 'UnitPrice', }, + "coupon" => {}, } end @@ -206,6 +207,15 @@ def default_mappings }, "invoice" => {}, + + "coupon" => { + "name" => "Name__c", + "amount_off" => "Amount_Off__c", + "percent_off" => "Percent_Off__c", + "duration" => "Duration__c", + "duration_in_months" => "Duration_In_Months__c", + "max_redemptions" => "Max_Redemptions__c", + }, } end diff --git a/lib/stripe-force/translate/coupon.rb b/lib/stripe-force/translate/coupon.rb new file mode 100644 index 0000000000..5dfff16ecf --- /dev/null +++ b/lib/stripe-force/translate/coupon.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# typed: true + +class StripeForce::Translate + def translate_coupon(sf_coupon) + locker.lock_salesforce_record(sf_coupon) + + log.info 'translating coupon', salesforce_object: sf_coupon + + coupon = create_stripe_object(Stripe::Coupon, sf_coupon) + + update_sf_stripe_id(sf_coupon, coupon) + + coupon + end +end diff --git a/lib/stripe-force/translate/translate.rb b/lib/stripe-force/translate/translate.rb index 036c01e456..0f9fae1fe5 100644 --- a/lib/stripe-force/translate/translate.rb +++ b/lib/stripe-force/translate/translate.rb @@ -80,6 +80,8 @@ def translate(sf_object) translate_pricebook(sf_object) when SF_ACCOUNT translate_account(sf_object) + when prefixed_stripe_field(SF_STRIPE_COUPON) + translate_coupon(sf_object) else raise "unsupported translation type #{sf_object.sobject_type}" end @@ -301,6 +303,7 @@ def throw_user_failure!(salesforce_object:, message:, error_class: nil) sig { params(sf_object: T.untyped, stripe_object: Stripe::APIResource, additional_salesforce_updates: Hash).void } def update_sf_stripe_id(sf_object, stripe_object, additional_salesforce_updates: {}) stripe_id_field = prefixed_stripe_field(GENERIC_STRIPE_ID) + stripe_object_id = stripe_object.id if sf_object[stripe_id_field] diff --git a/salesforce_devtips.md b/salesforce_devtips.md index 33165a99e1..1468e0598d 100644 --- a/salesforce_devtips.md +++ b/salesforce_devtips.md @@ -10,6 +10,7 @@ - `sfdx force:mdapi:listmetadata -m Layout` to get all layouts on an account. If you want to pull a namespaced layout: `sfdx force:source:retrieve -m "Layout:Account-SBQQ__CPQ Account Layout"` - Pull custom field from an account: `sfdx force:source:retrieve -m CustomField:Order.Stripe_Transaction_ID__c` - Lots of debugging info `sfdx force:source:retrieve -m "Layout:Contract-CPQ Contract Layout" --verbose -u cpq-dev --apiversion=55.0 --dev-debug --loglevel=trace` +- Pull a custom object `sfdx force:source:retrieve -m 'CustomObject:Stripe_Coupon_Beta_Quote_Line_Associatio__c` ## Lighting Web Componetns diff --git a/sorbet/custom/stripe.rbi b/sorbet/custom/stripe.rbi index 22200c4269..9ae0ea3b66 100644 --- a/sorbet/custom/stripe.rbi +++ b/sorbet/custom/stripe.rbi @@ -259,4 +259,27 @@ class Stripe::TestHelpers::TestClock sig { returns(Integer)} def frozen_time; end +end + +class Stripe::Coupon + sig { returns(String)} + def id; end + + sig { returns(Integer)} + def percent_off; end + + sig { returns(Integer)} + def amount_off; end + + sig { returns(String)} + def duration; end + + sig { returns(String)} + def duration_in_months; end + + sig { returns(String)} + def max_redemptions; end + + sig { returns(String)} + def currency; end end \ No newline at end of file diff --git a/test/integration/translate/test_coupon.rb b/test/integration/translate/test_coupon.rb new file mode 100644 index 0000000000..124b162ad8 --- /dev/null +++ b/test/integration/translate/test_coupon.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true +# typed: true + +require_relative '../../test_helper' + +class Critic::CouponTranslation < Critic::FunctionalTest + before do + @user = make_user(save: true) + end + + it 'translate an SF coupon with percent off set' do + # setup + COUPON_NAME = '100% off coupon' + COUPON_PERCENT_OFF = 100 + COUPON_MAX_REDEMPTIONS = 5 + + # create the SF Stripe coupon + sf_coupon_id = create_salesforce_stripe_coupon(additional_fields: { + SalesforceStripeCouponFields::NAME => COUPON_NAME, + SalesforceStripeCouponFields::PERCENT_OFF => COUPON_PERCENT_OFF, + }) + + # translate the SF coupon + StripeForce::Translate.perform_inline(@user, sf_coupon_id) + + # confirm the Stripe ID was written back into the SF coupon obj + sf_coupon = sf.find(SF_STRIPE_COUPON, sf_coupon_id) + stripe_coupon_id = sf_coupon[prefixed_stripe_field(GENERIC_STRIPE_ID)] + assert(stripe_coupon_id) + + # retrieve the created Stripe coupon + stripe_coupon = Stripe::Coupon.retrieve(stripe_coupon_id, @user.stripe_credentials) + + # compare the created Stripe coupon with the SF coupon + assert_equal(COUPON_NAME, stripe_coupon.name) + assert_equal(COUPON_PERCENT_OFF, stripe_coupon.percent_off) + assert_equal('once', stripe_coupon.duration) + assert_nil(stripe_coupon.currency) + assert_equal(sf_coupon.Id, stripe_coupon.metadata['salesforce_stripe_coupon_beta_id']) + assert_match(sf_coupon.Id, stripe_coupon.metadata['salesforce_stripe_coupon_beta_link']) + end + + it 'verify multiple SF coupons are attached to a quote line' do + # setup + PRODUCT_PRICE = 100 + sf_account_id = create_salesforce_account + sf_product_id, _sf_pricebook_id = salesforce_recurring_product_with_price(price: PRODUCT_PRICE) + + # create a SF CPQ quote + sf_quote_id = create_salesforce_quote(sf_account_id: sf_account_id, additional_quote_fields: { + CPQ_QUOTE_SUBSCRIPTION_START_DATE => now_time_formatted_for_salesforce, + CPQ_QUOTE_SUBSCRIPTION_TERM => 12.0, + }) + + # create a sf quote with a product + quote_with_product = add_product_to_cpq_quote(sf_quote_id, sf_product_id: sf_product_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 + + # create a coupon and attach to the quote line + sf_coupon_id_1 = create_salesforce_stripe_coupon(additional_fields: { + SalesforceStripeCouponFields::NAME => '25% off coupon', + SalesforceStripeCouponFields::PERCENT_OFF => 25, + }) + + sf_coupon_id_2 = create_salesforce_stripe_coupon(additional_fields: { + SalesforceStripeCouponFields::NAME => '$10 off coupon', + SalesforceStripeCouponFields::AMOUNT_OFF => 10, + }) + + # create the association object to map the coupon to the quote line + create_salesforce_stripe_coupon_quote_line_association(sf_quote_line_id: quote_line_id, sf_stripe_coupon_id: sf_coupon_id_1) + create_salesforce_stripe_coupon_quote_line_association(sf_quote_line_id: quote_line_id, sf_stripe_coupon_id: sf_coupon_id_2) + + # note: the quote line does not have a reference to the stripe coupon quote line association object + # so we query for the association objects that have a reference to this quote line + associated_coupons = get_salesforce_stripe_coupons_associated_to_quote_line(quote_line_id: quote_line_id) + assert(2, associated_coupons.size) + + coupon_1 = associated_coupons.first.Name_c == '25% off coupon' ? associated_coupons[0] : associated_coupons[1] + assert(25, coupon_1.Percent_Off__c) + end + + it 'translate order with coupon' do + # TODO https://jira.corp.stripe.com/browse/PLATINT-1952 + end +end diff --git a/test/support/salesforce_factory.rb b/test/support/salesforce_factory.rb index d68ee59ba1..9e8a93d763 100644 --- a/test/support/salesforce_factory.rb +++ b/test/support/salesforce_factory.rb @@ -120,6 +120,47 @@ def create_salesforce_product(additional_fields: {}) }.merge(additional_fields)) end + def create_salesforce_stripe_coupon_quote_line_association(sf_quote_line_id:, sf_stripe_coupon_id:) + sf_stripe_coupon_id ||= create_salesforce_stripe_coupon + + sf.create!(prefixed_stripe_field(SF_STRIPE_COUPON_QUOTE_LINE_ASSOCIATION), { + "Quote_Line__c" => sf_quote_line_id, + "Stripe_Coupon__c" => sf_stripe_coupon_id, + }.transform_keys(&method(:prefixed_stripe_field))) + end + + def create_salesforce_stripe_coupon(additional_fields: {}) + # we want to ensure that either amount_off or percent_off is set and not both + amount_off = nil + if additional_fields[SalesforceStripeCouponFields::PERCENT_OFF].nil? + amount_off = TEST_DEFAULT_PRICE / 2 + end + + sf.create!(prefixed_stripe_field(SF_STRIPE_COUPON), { + SalesforceStripeCouponFields::NAME => sf_randomized_name(SF_STRIPE_COUPON), + SalesforceStripeCouponFields::AMOUNT_OFF => amount_off, + SalesforceStripeCouponFields::DURATION => 'once', + }.merge(additional_fields).transform_keys(&:serialize).transform_keys(&method(:prefixed_stripe_field))) + end + + def get_salesforce_stripe_coupons_associated_to_quote_line(quote_line_id:) + quote_line_associations = sf.query("Select Id from #{prefixed_stripe_field(SF_STRIPE_COUPON_QUOTE_LINE_ASSOCIATION)} where #{prefixed_stripe_field('Quote_Line__c')} = '#{quote_line_id}'") + + if !quote_line_associations + raise "could not find any stripe coupon quote line associations related to this quote line" + end + + # there could be multiple coupons associated with a quote line + coupons = quote_line_associations.map do |quote_line_association| + association = sf.find(prefixed_stripe_field(SF_STRIPE_COUPON_QUOTE_LINE_ASSOCIATION), quote_line_association.Id) + coupon_id = association[prefixed_stripe_field('Stripe_Coupon__c')] + + # return the coupon object + sf.find(prefixed_stripe_field(SF_STRIPE_COUPON), coupon_id) + end + coupons + end + def salesforce_recurring_metered_produce_with_price(price_in_cents: nil) salesforce_recurring_product_with_price( price: price_in_cents,