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 def create
params.require(%i[source plan]) params.require(%i[source plan])
begin 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]) plan = ::Stripe::Price.retrieve(params[:plan])
if params[:promo].present? if params[:promo].present?
@ -170,13 +175,32 @@ module DiscourseSubscriptions
.sort_by { |plan| plan[:amount] } .sort_by { |plan| plan[:amount] }
end 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) 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? if customer.present?
::Stripe::Customer.retrieve(customer.customer_id) ::Stripe::Customer.retrieve(customer.customer_id)
else 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
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(), dialog: service(),
selectedPlan: null, selectedPlan: null,
promoCode: null, promoCode: null,
cardholderName: null,
cardholderAddress: {
line1: null,
city: null,
state: null,
country: null,
postalCode: null,
},
isAnonymous: not("currentUser"), isAnonymous: not("currentUser"),
isCountryUS: false,
isCountryCA: false,
init() { init() {
this._super(...arguments); this._super(...arguments);
@ -21,6 +31,9 @@ export default Controller.extend({
const elements = this.get("stripe").elements(); const elements = this.get("stripe").elements();
this.set("cardElement", elements.create("card", { hidePostalCode: true })); this.set("cardElement", elements.create("card", { hidePostalCode: true }));
this.set("isCountryUS", this.cardholderAddress.country === "US");
this.set("isCountryCA", this.cardholderAddress.country === "CA");
}, },
alert(path) { alert(path) {
@ -37,20 +50,31 @@ export default Controller.extend({
}, },
createSubscription(plan) { createSubscription(plan) {
return this.stripe.createToken(this.get("cardElement")).then((result) => { return this.stripe
if (result.error) { .createToken(this.get("cardElement"), {
this.set("loading", false); name: this.cardholderName, // Recommended by Stripe
return result; address_line1: this.cardholderAddress.line1 ?? "",
} else { address_city: this.cardholderAddress.city ?? "",
const subscription = Subscription.create({ address_state: this.cardholderAddress.state ?? "",
source: result.token.id, address_zip: this.cardholderAddress.postalCode ?? "",
plan: plan.get("id"), address_country: this.cardholderAddress.country, // Recommended by Stripe
promo: this.promoCode, })
}); .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) { handleAuthentication(plan, transaction) {
@ -83,11 +107,23 @@ export default Controller.extend({
}, },
actions: { 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() { stripePaymentHandler() {
this.set("loading", true); this.set("loading", true);
const plan = this.get("model.plans") const plan = this.get("model.plans")
.filterBy("id", this.selectedPlan) .filterBy("id", this.selectedPlan)
.get("firstObject"); .get("firstObject");
const cardholderAddress = this.cardholderAddress;
const cardholderName = this.cardholderName;
if (!plan) { if (!plan) {
this.alert("plans.validate.payment_options.required"); this.alert("plans.validate.payment_options.required");
@ -95,6 +131,30 @@ export default Controller.extend({
return; 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); let transaction = this.createSubscription(plan);
transaction transaction

View File

@ -13,6 +13,8 @@ const Subscription = EmberObject.extend({
source: this.source, source: this.source,
plan: this.plan, plan: this.plan,
promo: this.promo, promo: this.promo,
cardholder_name: this.cardholderName,
cardholder_address: this.cardholderAddress,
}; };
return ajax("/s/create", { method: "post", data }); return ajax("/s/create", { method: "post", data });

View File

@ -30,6 +30,72 @@
{{else if isAnonymous}} {{else if isAnonymous}}
{{login-required}} {{login-required}}
{{else}} {{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 <Input
@type="text" @type="text"
name="promo_code" name="promo_code"

View File

@ -49,6 +49,29 @@
color: var(--quaternary); 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%; 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. no_products: There are currently no products available.
unauthenticated: Log in or create an account to subscribe. unauthenticated: Log in or create an account to subscribe.
invalid_coupon: You entered an invalid coupon code. Please try again. 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: card:
title: Payment title: Payment
customer: customer:
@ -115,6 +119,14 @@ en:
purchased: Purchased purchased: Purchased
go_to_billing: Go to Billing go_to_billing: Go to Billing
already_purchased: Thanks so much for your prior purchase of this product! 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: billing:
name: Full name name: Full name
email: Email email: Email

View File

@ -313,6 +313,31 @@ module DiscourseSubscriptions
}.not_to change { DiscourseSubscriptions::Customer.count } }.not_to change { DiscourseSubscriptions::Customer.count }
end 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 promo code" do
context "with invalid code" do context "with invalid code" do
it "prevents use of invalid coupon codes" do it "prevents use of invalid coupon codes" do