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
This commit is contained in:
Mark Reeves 2023-05-05 13:20:35 -04:00 committed by GitHub
parent 2babb43ffb
commit 803bba7938
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 629 additions and 17 deletions

View File

@ -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

View File

@ -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] };
});
}),
});

View File

@ -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] };
});
}),
});

View File

@ -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] };
});
}),
});

View File

@ -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

View File

@ -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 });

View File

@ -30,6 +30,72 @@
{{else if isAnonymous}}
{{login-required}}
{{else}}
<Input
@type="text"
name="cardholder_name"
placeholder={{i18n
"discourse_subscriptions.subscribe.cardholder_name"
}}
@value={{cardholderName}}
class="subscribe-name"
/>
<div class="address-fields">
{{subscribe-country-select
value=cardholderAddress.country
onChange=(action "changeCountry")
}}
<Input
@type="text"
name="cardholder_postal_code"
placeholder={{i18n
"discourse_subscriptions.subscribe.cardholder_address.postal_code"
}}
@value={{cardholderAddress.postalCode}}
class="subscribe-address-postal-code"
/>
</div>
<Input
@type="text"
name="cardholder_line1"
placeholder={{i18n
"discourse_subscriptions.subscribe.cardholder_address.line1"
}}
@value={{cardholderAddress.line1}}
class="subscribe-address-line1"
/>
<div class="address-fields">
<Input
@type="text"
name="cardholder_city"
placeholder={{i18n
"discourse_subscriptions.subscribe.cardholder_address.city"
}}
@value={{cardholderAddress.city}}
class="subscribe-address-city"
/>
{{#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}}
<Input
@type="text"
name="cardholder_state"
placeholder={{i18n
"discourse_subscriptions.subscribe.cardholder_address.state"
}}
@value={{cardholderAddress.state}}
class="subscribe-address-state"
/>
{{/if}}
</div>
<Input
@type="text"
name="promo_code"

View File

@ -49,6 +49,29 @@
color: var(--quaternary);
}
.subscribe-promo-code {
.subscribe-promo-code,
.subscribe-name,
.subscribe-address-line1,
.subscribe-address-city,
.subscribe-address-state,
.subscribe-address-country-select,
.subscribe-address-state-select,
.subscribe-address-postal-code {
width: 100%;
}
.subscribe-address-country-select,
.subscribe-address-state-select {
margin-bottom: 9px;
}
@media all and (min-width: 1350px) {
.address-fields {
display: flex;
justify-content: space-between;
& > input,
& > .select-kit {
width: calc(50% - 4.5px);
}
}
}

View File

@ -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

View File

@ -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