From 803bba7938722e408905cb7988805c004e5f5d5f Mon Sep 17 00:00:00 2001 From: Mark Reeves Date: Fri, 5 May 2023 13:20:35 -0400 Subject: [PATCH] FEATURE: Capture cardholder address fields for Stripe customer (#161) - Adds the following fields to the subscription payment form: - Cardholder Name - Country - Postal Code - Address Line 1 - City - State or Province - Stripe recommends Cardholder Name & Country for verification; Cardholder Name, Country, and State/Province for US/Canada selections are required fields - All fields are passed to Stripe for verification on submit - Fields are also captured on the customer record in Stripe, under Billing Details --- .../subscribe_controller.rb | 30 +- .../subscribe-ca-province-select.js | 38 +++ .../components/subscribe-country-select.js | 274 ++++++++++++++++++ .../components/subscribe-us-state-select.js | 88 ++++++ .../discourse/controllers/subscribe-show.js | 86 +++++- .../discourse/models/subscription.js | 2 + .../discourse/templates/subscribe/show.hbs | 66 +++++ assets/stylesheets/common/subscribe.scss | 25 +- config/locales/client.en.yml | 12 + spec/requests/subscribe_controller_spec.rb | 25 ++ 10 files changed, 629 insertions(+), 17 deletions(-) create mode 100644 assets/javascripts/discourse/components/subscribe-ca-province-select.js create mode 100644 assets/javascripts/discourse/components/subscribe-country-select.js create mode 100644 assets/javascripts/discourse/components/subscribe-us-state-select.js diff --git a/app/controllers/discourse_subscriptions/subscribe_controller.rb b/app/controllers/discourse_subscriptions/subscribe_controller.rb index 1dcaf28..53834ca 100644 --- a/app/controllers/discourse_subscriptions/subscribe_controller.rb +++ b/app/controllers/discourse_subscriptions/subscribe_controller.rb @@ -57,7 +57,12 @@ module DiscourseSubscriptions def create params.require(%i[source plan]) begin - customer = find_or_create_customer(params[:source]) + customer = + find_or_create_customer( + params[:source], + params[:cardholder_name], + params[:cardholder_address], + ) plan = ::Stripe::Price.retrieve(params[:plan]) if params[:promo].present? @@ -170,13 +175,32 @@ module DiscourseSubscriptions .sort_by { |plan| plan[:amount] } end - def find_or_create_customer(source) + def find_or_create_customer(source, cardholder_name = nil, cardholder_address = nil) customer = Customer.find_by_user_id(current_user.id) + cardholder_address = + ( + if cardholder_address.present? + { + line1: cardholder_address[:line1], + city: cardholder_address[:city], + state: cardholder_address[:state], + country: cardholder_address[:country], + postal_code: cardholder_address[:postalCode], + } + else + nil + end + ) if customer.present? ::Stripe::Customer.retrieve(customer.customer_id) else - ::Stripe::Customer.create(email: current_user.email, source: source) + ::Stripe::Customer.create( + email: current_user.email, + source: source, + name: cardholder_name, + address: cardholder_address, + ) end end diff --git a/assets/javascripts/discourse/components/subscribe-ca-province-select.js b/assets/javascripts/discourse/components/subscribe-ca-province-select.js new file mode 100644 index 0000000..687f063 --- /dev/null +++ b/assets/javascripts/discourse/components/subscribe-ca-province-select.js @@ -0,0 +1,38 @@ +import ComboBoxComponent from "select-kit/components/combo-box"; +import { computed } from "@ember/object"; +import I18n from "I18n"; + +export default ComboBoxComponent.extend({ + pluginApiIdentifiers: ["subscribe-ca-province-select"], + classNames: ["subscribe-address-state-select"], + nameProperty: "name", + valueProperty: "value", + + selectKitOptions: { + filterable: true, + allowAny: false, + translatedNone: I18n.t( + "discourse_subscriptions.subscribe.cardholder_address.province" + ), + }, + + content: computed(function () { + return [ + ["AB", "Alberta"], + ["BC", "British Columbia"], + ["MB", "Manitoba"], + ["NB", "New Brunswick"], + ["NL", "Newfoundland and Labrador"], + ["NT", "Northwest Territories"], + ["NS", "Nova Scotia"], + ["NU", "Nunavut"], + ["ON", "Ontario"], + ["PE", "Prince Edward Island"], + ["QC", "Quebec"], + ["SK", "Saskatchewan"], + ["YT", "Yukon"], + ].map((arr) => { + return { value: arr[0], name: arr[1] }; + }); + }), +}); diff --git a/assets/javascripts/discourse/components/subscribe-country-select.js b/assets/javascripts/discourse/components/subscribe-country-select.js new file mode 100644 index 0000000..93f3913 --- /dev/null +++ b/assets/javascripts/discourse/components/subscribe-country-select.js @@ -0,0 +1,274 @@ +import ComboBoxComponent from "select-kit/components/combo-box"; +import { computed } from "@ember/object"; +import I18n from "I18n"; + +export default ComboBoxComponent.extend({ + pluginApiIdentifiers: ["subscribe-country-select"], + classNames: ["subscribe-address-country-select"], + nameProperty: "name", + valueProperty: "value", + + selectKitOptions: { + filterable: true, + allowAny: false, + translatedNone: I18n.t( + "discourse_subscriptions.subscribe.cardholder_address.country" + ), + }, + + content: computed(function () { + return [ + ["AF", "Afghanistan"], + ["AX", "Åland Islands"], + ["AL", "Albania"], + ["DZ", "Algeria"], + ["AS", "American Samoa"], + ["AD", "Andorra"], + ["AO", "Angola"], + ["AI", "Anguilla"], + ["AQ", "Antarctica"], + ["AG", "Antigua and Barbuda"], + ["AR", "Argentina"], + ["AM", "Armenia"], + ["AW", "Aruba"], + ["AU", "Australia"], + ["AT", "Austria"], + ["AZ", "Azerbaijan"], + ["BS", "Bahamas"], + ["BH", "Bahrain"], + ["BD", "Bangladesh"], + ["BB", "Barbados"], + ["BY", "Belarus"], + ["BE", "Belgium"], + ["BZ", "Belize"], + ["BJ", "Benin"], + ["BM", "Bermuda"], + ["BT", "Bhutan"], + ["BO", "Bolivia, Plurinational State of"], + ["BQ", "Bonaire, Sint Eustatius and Saba"], + ["BA", "Bosnia and Herzegovina"], + ["BW", "Botswana"], + ["BV", "Bouvet Island"], + ["BR", "Brazil"], + ["IO", "British Indian Ocean Territory"], + ["BN", "Brunei Darussalam"], + ["BG", "Bulgaria"], + ["BF", "Burkina Faso"], + ["BI", "Burundi"], + ["KH", "Cambodia"], + ["CM", "Cameroon"], + ["CA", "Canada"], + ["CV", "Cape Verde"], + ["KY", "Cayman Islands"], + ["CF", "Central African Republic"], + ["TD", "Chad"], + ["CL", "Chile"], + ["CN", "China"], + ["CX", "Christmas Island"], + ["CC", "Cocos (Keeling) Islands"], + ["CO", "Colombia"], + ["KM", "Comoros"], + ["CG", "Congo"], + ["CD", "Congo, the Democratic Republic of the"], + ["CK", "Cook Islands"], + ["CR", "Costa Rica"], + ["CI", "Côte d'Ivoire"], + ["HR", "Croatia"], + ["CU", "Cuba"], + ["CW", "Curaçao"], + ["CY", "Cyprus"], + ["CZ", "Czech Republic"], + ["DK", "Denmark"], + ["DJ", "Djibouti"], + ["DM", "Dominica"], + ["DO", "Dominican Republic"], + ["EC", "Ecuador"], + ["EG", "Egypt"], + ["SV", "El Salvador"], + ["GQ", "Equatorial Guinea"], + ["ER", "Eritrea"], + ["EE", "Estonia"], + ["ET", "Ethiopia"], + ["FK", "Falkland Islands (Malvinas)"], + ["FO", "Faroe Islands"], + ["FJ", "Fiji"], + ["FI", "Finland"], + ["FR", "France"], + ["GF", "French Guiana"], + ["PF", "French Polynesia"], + ["TF", "French Southern Territories"], + ["GA", "Gabon"], + ["GM", "Gambia"], + ["GE", "Georgia"], + ["DE", "Germany"], + ["GH", "Ghana"], + ["GI", "Gibraltar"], + ["GR", "Greece"], + ["GL", "Greenland"], + ["GD", "Grenada"], + ["GP", "Guadeloupe"], + ["GU", "Guam"], + ["GT", "Guatemala"], + ["GG", "Guernsey"], + ["GN", "Guinea"], + ["GW", "Guinea-Bissau"], + ["GY", "Guyana"], + ["HT", "Haiti"], + ["HM", "Heard Island and McDonald Islands"], + ["VA", "Holy See (Vatican City State)"], + ["HN", "Honduras"], + ["HK", "Hong Kong"], + ["HU", "Hungary"], + ["IS", "Iceland"], + ["IN", "India"], + ["ID", "Indonesia"], + ["IR", "Iran, Islamic Republic of"], + ["IQ", "Iraq"], + ["IE", "Ireland"], + ["IM", "Isle of Man"], + ["IL", "Israel"], + ["IT", "Italy"], + ["JM", "Jamaica"], + ["JP", "Japan"], + ["JE", "Jersey"], + ["JO", "Jordan"], + ["KZ", "Kazakhstan"], + ["KE", "Kenya"], + ["KI", "Kiribati"], + ["KP", "Korea, Democratic People's Republic of"], + ["KR", "Korea, Republic of"], + ["KW", "Kuwait"], + ["KG", "Kyrgyzstan"], + ["LA", "Lao People's Democratic Republic"], + ["LV", "Latvia"], + ["LB", "Lebanon"], + ["LS", "Lesotho"], + ["LR", "Liberia"], + ["LY", "Libya"], + ["LI", "Liechtenstein"], + ["LT", "Lithuania"], + ["LU", "Luxembourg"], + ["MO", "Macao"], + ["MK", "Macedonia, the former Yugoslav Republic of"], + ["MG", "Madagascar"], + ["MW", "Malawi"], + ["MY", "Malaysia"], + ["MV", "Maldives"], + ["ML", "Mali"], + ["MT", "Malta"], + ["MH", "Marshall Islands"], + ["MQ", "Martinique"], + ["MR", "Mauritania"], + ["MU", "Mauritius"], + ["YT", "Mayotte"], + ["MX", "Mexico"], + ["FM", "Micronesia, Federated States of"], + ["MD", "Moldova, Republic of"], + ["MC", "Monaco"], + ["MN", "Mongolia"], + ["ME", "Montenegro"], + ["MS", "Montserrat"], + ["MA", "Morocco"], + ["MZ", "Mozambique"], + ["MM", "Myanmar"], + ["NA", "Namibia"], + ["NR", "Nauru"], + ["NP", "Nepal"], + ["NL", "Netherlands"], + ["NC", "New Caledonia"], + ["NZ", "New Zealand"], + ["NI", "Nicaragua"], + ["NE", "Niger"], + ["NG", "Nigeria"], + ["NU", "Niue"], + ["NF", "Norfolk Island"], + ["MP", "Northern Mariana Islands"], + ["NO", "Norway"], + ["OM", "Oman"], + ["PK", "Pakistan"], + ["PW", "Palau"], + ["PS", "Palestinian Territory, Occupied"], + ["PA", "Panama"], + ["PG", "Papua New Guinea"], + ["PY", "Paraguay"], + ["PE", "Peru"], + ["PH", "Philippines"], + ["PN", "Pitcairn"], + ["PL", "Poland"], + ["PT", "Portugal"], + ["PR", "Puerto Rico"], + ["QA", "Qatar"], + ["RE", "Réunion"], + ["RO", "Romania"], + ["RU", "Russian Federation"], + ["RW", "Rwanda"], + ["BL", "Saint Barthélemy"], + ["SH", "Saint Helena, Ascension and Tristan da Cunha"], + ["KN", "Saint Kitts and Nevis"], + ["LC", "Saint Lucia"], + ["MF", "Saint Martin (French part)"], + ["PM", "Saint Pierre and Miquelon"], + ["VC", "Saint Vincent and the Grenadines"], + ["WS", "Samoa"], + ["SM", "San Marino"], + ["ST", "Sao Tome and Principe"], + ["SA", "Saudi Arabia"], + ["SN", "Senegal"], + ["RS", "Serbia"], + ["SC", "Seychelles"], + ["SL", "Sierra Leone"], + ["SG", "Singapore"], + ["SX", "Sint Maarten (Dutch part)"], + ["SK", "Slovakia"], + ["SI", "Slovenia"], + ["SB", "Solomon Islands"], + ["SO", "Somalia"], + ["ZA", "South Africa"], + ["GS", "South Georgia and the South Sandwich Islands"], + ["SS", "South Sudan"], + ["ES", "Spain"], + ["LK", "Sri Lanka"], + ["SD", "Sudan"], + ["SR", "Suriname"], + ["SJ", "Svalbard and Jan Mayen"], + ["SZ", "Swaziland"], + ["SE", "Sweden"], + ["CH", "Switzerland"], + ["SY", "Syrian Arab Republic"], + ["TW", "Taiwan, Province of China"], + ["TJ", "Tajikistan"], + ["TZ", "Tanzania, United Republic of"], + ["TH", "Thailand"], + ["TL", "Timor-Leste"], + ["TG", "Togo"], + ["TK", "Tokelau"], + ["TO", "Tonga"], + ["TT", "Trinidad and Tobago"], + ["TN", "Tunisia"], + ["TR", "Turkey"], + ["TM", "Turkmenistan"], + ["TC", "Turks and Caicos Islands"], + ["TV", "Tuvalu"], + ["UG", "Uganda"], + ["UA", "Ukraine"], + ["AE", "United Arab Emirates"], + ["GB", "United Kingdom"], + ["US", "United States"], + ["UM", "United States Minor Outlying Islands"], + ["UY", "Uruguay"], + ["UZ", "Uzbekistan"], + ["VU", "Vanuatu"], + ["VE", "Venezuela, Bolivarian Republic of"], + ["VN", "Viet Nam"], + ["VG", "Virgin Islands, British"], + ["VI", "Virgin Islands, U.S."], + ["WF", "Wallis and Futuna"], + ["EH", "Western Sahara"], + ["YE", "Yemen"], + ["ZM", "Zambia"], + ["ZW", "Zimbabwe"], + ].map((arr) => { + return { value: arr[0], name: arr[1] }; + }); + }), +}); diff --git a/assets/javascripts/discourse/components/subscribe-us-state-select.js b/assets/javascripts/discourse/components/subscribe-us-state-select.js new file mode 100644 index 0000000..fe7ab21 --- /dev/null +++ b/assets/javascripts/discourse/components/subscribe-us-state-select.js @@ -0,0 +1,88 @@ +import ComboBoxComponent from "select-kit/components/combo-box"; +import { computed } from "@ember/object"; +import I18n from "I18n"; + +export default ComboBoxComponent.extend({ + pluginApiIdentifiers: ["subscribe-us-state-select"], + classNames: ["subscribe-address-state-select"], + nameProperty: "name", + valueProperty: "value", + + selectKitOptions: { + filterable: true, + allowAny: false, + translatedNone: I18n.t( + "discourse_subscriptions.subscribe.cardholder_address.state" + ), + }, + + content: computed(function () { + return [ + ["AL", "Alabama"], + ["AK", "Alaska"], + ["AZ", "Arizona"], + ["AR", "Arkansas"], + ["CA", "California"], + ["CO", "Colorado"], + ["CT", "Connecticut"], + ["DE", "Delaware"], + ["US", "District"], + ["FL", "Florida"], + ["GA", "Georgia"], + ["HI", "Hawaii"], + ["ID", "Idaho"], + ["IL", "Illinois"], + ["IN", "Indiana"], + ["IA", "Iowa"], + ["KS", "Kansas"], + ["KY", "Kentucky"], + ["LA", "Louisiana"], + ["ME", "Maine"], + ["MD", "Maryland"], + ["MA", "Massachusetts"], + ["MI", "Michigan"], + ["MN", "Minnesota"], + ["MS", "Mississippi"], + ["MO", "Missouri"], + ["MT", "Montana"], + ["NE", "Nebraska"], + ["NV", "Nevada"], + ["NH", "New Hampshire"], + ["NJ", "New Jersey"], + ["NM", "New Mexico"], + ["NY", "New York"], + ["NC", "North Carolina"], + ["ND", "North Dakota"], + ["OH", "Ohio"], + ["OK", "Oklahoma"], + ["OR", "Oregon"], + ["PA", "Pennsylvania"], + ["RI", "Rhode"], + ["SC", "South"], + ["SD", "South"], + ["TN", "Tennessee"], + ["TX", "Texas"], + ["UT", "Utah"], + ["VT", "Vermont"], + ["VA", "Virginia"], + ["WA", "Washington"], + ["WV", "West"], + ["WI", "Wisconsin"], + ["WY", "Wyoming"], + ["AS", "American Samoa"], + ["GU", "Guam"], + ["MP", "Northern Mariana Islands"], + ["PR", "Puerto Rico"], + ["VI", "U.S. Virgin Islands"], + ["UM", "U.S. Minor Outlying Islands"], + ["MH", "Marshall Islands"], + ["FM", "Micronesia"], + ["PW", "Palau"], + ["AA", "U.S. Armed Forces – Americas"], + ["AE", "U.S. Armed Forces – Europe"], + ["AP", "U.S. Armed Forces – Pacific"], + ].map((arr) => { + return { value: arr[0], name: arr[1] }; + }); + }), +}); diff --git a/assets/javascripts/discourse/controllers/subscribe-show.js b/assets/javascripts/discourse/controllers/subscribe-show.js index e6ca002..1e22001 100644 --- a/assets/javascripts/discourse/controllers/subscribe-show.js +++ b/assets/javascripts/discourse/controllers/subscribe-show.js @@ -10,7 +10,17 @@ export default Controller.extend({ dialog: service(), selectedPlan: null, promoCode: null, + cardholderName: null, + cardholderAddress: { + line1: null, + city: null, + state: null, + country: null, + postalCode: null, + }, isAnonymous: not("currentUser"), + isCountryUS: false, + isCountryCA: false, init() { this._super(...arguments); @@ -21,6 +31,9 @@ export default Controller.extend({ const elements = this.get("stripe").elements(); this.set("cardElement", elements.create("card", { hidePostalCode: true })); + + this.set("isCountryUS", this.cardholderAddress.country === "US"); + this.set("isCountryCA", this.cardholderAddress.country === "CA"); }, alert(path) { @@ -37,20 +50,31 @@ export default Controller.extend({ }, createSubscription(plan) { - return this.stripe.createToken(this.get("cardElement")).then((result) => { - if (result.error) { - this.set("loading", false); - return result; - } else { - const subscription = Subscription.create({ - source: result.token.id, - plan: plan.get("id"), - promo: this.promoCode, - }); + return this.stripe + .createToken(this.get("cardElement"), { + name: this.cardholderName, // Recommended by Stripe + address_line1: this.cardholderAddress.line1 ?? "", + address_city: this.cardholderAddress.city ?? "", + address_state: this.cardholderAddress.state ?? "", + address_zip: this.cardholderAddress.postalCode ?? "", + address_country: this.cardholderAddress.country, // Recommended by Stripe + }) + .then((result) => { + if (result.error) { + this.set("loading", false); + return result; + } else { + const subscription = Subscription.create({ + source: result.token.id, + plan: plan.get("id"), + promo: this.promoCode, + cardholderName: this.cardholderName, + cardholderAddress: this.cardholderAddress, + }); - return subscription.save(); - } - }); + return subscription.save(); + } + }); }, handleAuthentication(plan, transaction) { @@ -83,11 +107,23 @@ export default Controller.extend({ }, actions: { + changeCountry(country) { + this.set("cardholderAddress.country", country); + this.set("isCountryUS", country === "US"); + this.set("isCountryCA", country === "CA"); + }, + + changeState(stateOrProvince) { + this.set("cardholderAddress.state", stateOrProvince); + }, + stripePaymentHandler() { this.set("loading", true); const plan = this.get("model.plans") .filterBy("id", this.selectedPlan) .get("firstObject"); + const cardholderAddress = this.cardholderAddress; + const cardholderName = this.cardholderName; if (!plan) { this.alert("plans.validate.payment_options.required"); @@ -95,6 +131,30 @@ export default Controller.extend({ return; } + if (!cardholderName) { + this.alert("subscribe.invalid_cardholder_name"); + this.set("loading", false); + return; + } + + if (!cardholderAddress.country) { + this.alert("subscribe.invalid_cardholder_country"); + this.set("loading", false); + return; + } + + if (cardholderAddress.country === "US" && !cardholderAddress.state) { + this.alert("subscribe.invalid_cardholder_state"); + this.set("loading", false); + return; + } + + if (cardholderAddress.country === "CA" && !cardholderAddress.state) { + this.alert("subscribe.invalid_cardholder_province"); + this.set("loading", false); + return; + } + let transaction = this.createSubscription(plan); transaction diff --git a/assets/javascripts/discourse/models/subscription.js b/assets/javascripts/discourse/models/subscription.js index f78ec93..11f310d 100644 --- a/assets/javascripts/discourse/models/subscription.js +++ b/assets/javascripts/discourse/models/subscription.js @@ -13,6 +13,8 @@ const Subscription = EmberObject.extend({ source: this.source, plan: this.plan, promo: this.promo, + cardholder_name: this.cardholderName, + cardholder_address: this.cardholderAddress, }; return ajax("/s/create", { method: "post", data }); diff --git a/assets/javascripts/discourse/templates/subscribe/show.hbs b/assets/javascripts/discourse/templates/subscribe/show.hbs index 9d45f23..354a8c0 100644 --- a/assets/javascripts/discourse/templates/subscribe/show.hbs +++ b/assets/javascripts/discourse/templates/subscribe/show.hbs @@ -30,6 +30,72 @@ {{else if isAnonymous}} {{login-required}} {{else}} + +
+ {{subscribe-country-select + value=cardholderAddress.country + onChange=(action "changeCountry") + }} + +
+ +
+ + {{#if isCountryUS}} + {{subscribe-us-state-select + value=cardholderAddress.state + onChange=(action "changeState") + }} + {{else if isCountryCA}} + {{subscribe-ca-province-select + value=cardholderAddress.state + onChange=(action "changeState") + }} + {{else}} + + {{/if}} +
+ input, + & > .select-kit { + width: calc(50% - 4.5px); + } + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 1a10a7c..9b7178d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -105,6 +105,10 @@ en: no_products: There are currently no products available. unauthenticated: Log in or create an account to subscribe. invalid_coupon: You entered an invalid coupon code. Please try again. + invalid_cardholder_name: Cardholder name is required. + invalid_cardholder_country: Country is required. + invalid_cardholder_state: State is required. + invalid_cardholder_province: Province is required. card: title: Payment customer: @@ -115,6 +119,14 @@ en: purchased: Purchased go_to_billing: Go to Billing already_purchased: Thanks so much for your prior purchase of this product! + cardholder_name: Cardholder Name + cardholder_address: + line1: Street Address + city: City + state: State + province: Province + country: Country + postal_code: Postal Code billing: name: Full name email: Email diff --git a/spec/requests/subscribe_controller_spec.rb b/spec/requests/subscribe_controller_spec.rb index eb1c595..ecb9bab 100644 --- a/spec/requests/subscribe_controller_spec.rb +++ b/spec/requests/subscribe_controller_spec.rb @@ -313,6 +313,31 @@ module DiscourseSubscriptions }.not_to change { DiscourseSubscriptions::Customer.count } end + context "with customer name & address" do + it "creates a customer & subscription when a customer address is provided" do + ::Stripe::Price.expects(:retrieve).returns(type: "recurring", metadata: {}) + ::Stripe::Subscription.expects(:create).returns( + status: "active", + customer: "cus_1234", + ) + expect { + post "/s/create.json", + params: { + plan: "plan_1234", + source: "tok_1234", + cardholder_name: "A. Customer", + cardholder_address: { + line1: "123 Main Street", + city: "Anywhere", + state: "VT", + country: "US", + postal_code: "12345", + }, + } + }.to change { DiscourseSubscriptions::Customer.count } + end + end + context "with promo code" do context "with invalid code" do it "prevents use of invalid coupon codes" do