Hi @Alban_Bertolini
By “handlebars hurdles” I’m referring to the fact that the referenced approach utilizes a component library and FA smart views are based upon handlebars.
There are no logs/errors to share yet as I haven’t started developing this solution yet - I’m just trying to understand if it’s possible.
What would need to happen is that, within my smart view, we will need to call an external endpoint which creates a stripe setupIntent and returns a clientSecret value. We would then use the clientSecret to launch an modal flow from stripe’s SDK to collect and save the necessary account info and consents, as outlined in the linked Stripe documentation.
The more I read into it, the more possible it seems but it’s significantly different from what we are doing with our current implementation for stripe cards, so I wasn’t certain it was doable.
Existing smart action code below:
Component:
// v1.6.1: Update to new syntax for Smart Action call
import Component from "@glimmer/component";
import { scheduleOnce } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { loadExternalJavascript, triggerSmartAction } from 'client/utils/smart-view-utils';
const CARD_STYLE = {
base: {
color: "#54BD7E",
fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
fontSmoothing: "antialiased",
fontSize: "14px",
"::placeholder": {
color: "#aab7c4", // --color-beta-on-surface_light
},
},
invalid: {
color: "#FB6669",
iconColor: "#FB6669",
},
};
const ELEMENT_IDS = {
STRIPE_CARD_ELEMENT: "stripe-card-element",
STRIPE_CARD_ERROR: "stripe-card-errors",
STRIPE_CARD_HOLDER_NAME: "stripe-card-holder-name",
STRIPE_CARD_HOLDER_NAME_ERROR: "stripe-card-holder-name-error",
STRIPE_ACH_HOLDER_NAME: "stripe-ach-holder-name",
STRIPE_ACH_ACCOUNT_NUMBER: "stripe-ach-account-number",
STRIPE_ACH_ROUTING_NUMBER: "stripe-ach-routing-number",
STRIPE_ACH_ACCOUNT_TYPE: "stripe-ach-account-type",
STRIPE_ACH_HOLDER_NAME_ERROR: "stripe-ach-holder-name-error",
STRIPE_ACH_ACCOUNT_NUMBER_ERROR: "stripe-ach-account-number-error",
STRIPE_ACH_ROUTING_NUMBER_ERROR: "stripe-ach-routing-number-error",
};
const PAYMENT_METHOD = {
CARD: "CARD",
ACH: "ACH",
PAD: "PAD",
};
const PAYMENT_METHOD_VENDOR = {
STRIPE: "stripe",
PAYIX: "payix",
};
const STRIPE_ACH_DEFAULT_VALUE = {
BANK_COUNTRY_CODE: "US",
CURRENCY: "USD",
};
const obfuscateToken = (token) =>
token.includes("_")
? token.replace(/^(.*)_((.{3}).*(.{3}))$/, "$1_$3***$4")
: token.replace(/^(.{3})(.*)(.{3})$/, "$1***$3");
export default class extends Component {
@service() store;
@tracked selectedRecord = null;
@tracked resultToken = null;
@tracked stripe = null;
@tracked isLoadedStripe = false;
@tracked stripeCard = null;
@tracked isCreatingStripeCard = false;
@tracked isCreatingStripeACH = false;
@tracked isClickedToSync = false;
@tracked stripePublicKey = null;
@tracked payixIframeCardAchURL = null;
@tracked payixIframePadURL = null;
@tracked subscriptionId = null;
@tracked vendor = null;
@tracked loanNumber = null;
constructor(...args) {
super(...args);
console.log(`===== Smart view: v1.6.1: Update to new syntax for Smart Action call =====`);
}
async didInsertElement() {
await this.setDefaultSelectedRecord();
this.onCreatedPayixToken = this.onCreatedPayixToken.bind(this);
window.addEventListener("message", this.onCreatedPayixToken);
}
willRemoveElement() {
window.removeEventListener("message", this.onCreatedPayixToken);
}
@action
onRecordsChange() {
this.setDefaultSelectedRecord();
}
isSupportedStripe() {
return this.vendor === PAYMENT_METHOD_VENDOR.STRIPE;
}
setURLs(data) {
this.payixIframeCardAchURL = data["forest-payix_iframe_card_ach_url"];
this.payixIframePadURL = data["forest-payix_iframe_pad_url"];
this.stripePublicKey = data["forest-stripe_public_key"];
}
setSubscriptionIdAndVendor(record) {
if (this.args.records) {
this.subscriptionId = record["forest-subscription"]["id"];
this.vendor = record["forest-subscription"]["forest-vendor"];
this.loanNumber = record["forest-subscription"]["forest-loanNumber"];
}
if (!this.args.records) {
this.subscriptionId = record["forest-id"];
this.vendor = record["forest-vendor"];
this.loanNumber = record["forest-loanNumber"];
}
}
setDefaultSelectedRecord() {
// I'm pretty sure this if is useless because `this.args.records.recordData` is always undefined 🤔
if (this.args.records && this.args.records.recordData) {
this.subscriptionId = this.args.records.recordData.id;
this.vendor = PAYMENT_METHOD_VENDOR.STRIPE;
if (this.args.records.recordData.__data) {
this.setURLs(this.args.records.recordData.__data);
//Setting the vendor here again as if it's a Canadian User
//The Vendor should be Payix not Stripe
if (this.args.records.recordData.__data["forest-vendor"]) {
this.vendor = this.args.records.recordData.__data["forest-vendor"];
}
}
if (!this.stripe) this.loadPlugins();
}
if (!this.selectedRecord) {
if (this.args.records && this.args.records.firstObject) {
this.selectedRecord = this.args.records.firstObject;
this.setURLs(this.args.records.firstObject);
this.setSubscriptionIdAndVendor(this.args.records.firstObject);
if (!this.stripe) this.loadPlugins();
}
if (!this.args.records) {
const regex = /sub_[\w-]+/;
const url = window.location.href
const hasSubscriptionId = url.match(regex)
if (hasSubscriptionId) {
const subscriptionId = url.match(regex)[0]
this.store.findRecord('forest-subscription', subscriptionId)
.then((record) => {
this.setURLs(record)
this.setSubscriptionIdAndVendor(record)
if (!this.stripe) this.loadPlugins();
})
}
}
}
}
loadPlugins() {
scheduleOnce("afterRender", this, async () => {
try {
const stripePublicKey = this.stripePublicKey;
await loadExternalJavascript('//js.stripe.com/v2/');
await loadExternalJavascript('//js.stripe.com/v3/');
Stripe.setPublishableKey(stripePublicKey); // v2
const stripe = Stripe(stripePublicKey); // v3
this.stripe = stripe;
this.isLoadedStripe = true;
if (this.isSupportedStripe()) {
this.renderStripeElements();
}
} catch (error) {
console.error("Internal Error: Failed to load Stripe:\n", error);
}
});
}
renderStripeCard() {
const { stripe } = this;
const elements = stripe.elements();
const stripeCard = elements.create("card", { style: CARD_STYLE });
stripeCard.mount(`#${ELEMENT_IDS.STRIPE_CARD_ELEMENT}`);
stripeCard.on("change", function (event) {
let errorElement = document.getElementById(ELEMENT_IDS.STRIPE_CARD_ERROR);
if (event.error) {
errorElement.textContent = event.error.message;
} else {
errorElement.textContent = "";
}
});
this.stripeCard = stripeCard;
}
renderStripeElements() {
if (!this.stripe) return;
this.renderStripeCard();
}
onCreatedStripeCardToken(cardData, cardName) {
try {
const {
id,
billing_details: {
address: { postal_code },
},
card: { brand, last4, exp_month, exp_year },
} = cardData;
const expiry = `${exp_year}-${("0" + exp_month).slice(-2)}-01`;
const resultToken = {
paymentMethod: PAYMENT_METHOD.CARD,
name: cardName,
token: id,
last4,
type: brand,
expiry,
billingZip: postal_code,
};
console.log("===== Created Stripe Card Token =====\n", {
...resultToken,
token: obfuscateToken(resultToken.token),
});
this.resultToken = resultToken;
} catch (error) {
console.error(
"Internal Error: Failed to add new Card with Stripe:\n",
error
);
}
}
onCreatedStripeACHToken(achData, achValues) {
try {
const {
id,
bank_account: { last4, bank_name },
} = achData;
const resultToken = {
paymentMethod: PAYMENT_METHOD.ACH,
token: id,
last4,
bankName: bank_name,
name: achValues.achHolderName,
type: achValues.achAccountType,
};
console.log("===== Created Stripe ACH Token =====\n", {
...resultToken,
token: obfuscateToken(resultToken.token),
});
this.resultToken = resultToken;
} catch (error) {
console.error(
"Internal Error: Failed to add new ACH with Stripe:\n",
error
);
}
}
onCreatedPayixToken(event) {
try {
const isAddedWithPayixSuccessfully =
event.data &&
event.data.token !== "" &&
event.data.token !== null &&
event.data.token !== undefined;
if (isAddedWithPayixSuccessfully) {
const {
token,
nameOnCard,
nameOnAch,
nameOnPad,
cardBrand,
expiryDate,
lastFourDigits,
billingZip,
bankName,
} = event.data;
const isCard = !!nameOnCard;
const isACH = !isCard && !!nameOnAch;
const isPAD = !isCard && !isACH && !!nameOnPad;
let resultToken;
if (isCard) {
const expiry = expiryDate.replace(/(\d{2})\/(\d{4})/, "$2-$1-01");
resultToken = {
paymentMethod: PAYMENT_METHOD.CARD,
name: nameOnCard,
token,
last4: lastFourDigits,
type: cardBrand,
expiry,
billingZip,
};
} else if (isACH) {
resultToken = {
paymentMethod: PAYMENT_METHOD.ACH,
name: nameOnAch,
token,
last4: lastFourDigits,
bankName,
};
} else if (isPAD) {
resultToken = {
paymentMethod: PAYMENT_METHOD.PAD,
name: nameOnPad,
token,
last4: lastFourDigits,
bankName,
};
}
if (resultToken) {
console.log(
`===== Created Payix ${resultToken.paymentMethod} Token =====\n`,
{
...resultToken,
token: obfuscateToken(resultToken.token),
}
);
this.resultToken = resultToken;
}
}
} catch (error) {
console.error(
"Internal Error: Failed to add new payment method with Payix:\n",
error
);
}
}
validateStripeACHForm() {
const { value: achHolderName } = document.getElementById(
ELEMENT_IDS.STRIPE_ACH_HOLDER_NAME
);
const { value: achAccountNumber } = document.getElementById(
ELEMENT_IDS.STRIPE_ACH_ACCOUNT_NUMBER
);
const { value: achRoutingNumber } = document.getElementById(
ELEMENT_IDS.STRIPE_ACH_ROUTING_NUMBER
);
const { value: achAccountType } = document.getElementById(
ELEMENT_IDS.STRIPE_ACH_ACCOUNT_TYPE
);
const holderNameErrorElement = document.getElementById(
ELEMENT_IDS.STRIPE_ACH_HOLDER_NAME_ERROR
);
const accountNumberErrorElement = document.getElementById(
ELEMENT_IDS.STRIPE_ACH_ACCOUNT_NUMBER_ERROR
);
const routingNumberErrorElement = document.getElementById(
ELEMENT_IDS.STRIPE_ACH_ROUTING_NUMBER_ERROR
);
let isValidHolderName = false;
if (!achHolderName) {
holderNameErrorElement.textContent =
"Please input Account ownner's fullname";
} else {
holderNameErrorElement.textContent = "";
isValidHolderName = true;
}
let isValidAccountNumber = false;
if (!achAccountNumber) {
accountNumberErrorElement.textContent = "Please input Account number";
} else {
const isValidAccountNumberByStripe = Stripe.bankAccount.validateAccountNumber(
achAccountNumber,
STRIPE_ACH_DEFAULT_VALUE.BANK_COUNTRY_CODE
);
if (!isValidAccountNumberByStripe) {
accountNumberErrorElement.textContent =
"Your Account number is invalid";
} else {
accountNumberErrorElement.textContent = "";
isValidAccountNumber = true;
}
}
let isValidRoutingNumber = false;
if (!achRoutingNumber) {
routingNumberErrorElement.textContent = "Please input Routing number";
} else {
const isValidRoutingNumberByStripe = Stripe.bankAccount.validateRoutingNumber(
achRoutingNumber,
STRIPE_ACH_DEFAULT_VALUE.BANK_COUNTRY_CODE
);
if (!isValidRoutingNumberByStripe) {
routingNumberErrorElement.textContent =
"Your Routing number is invalid";
} else {
routingNumberErrorElement.textContent = "";
isValidRoutingNumber = true;
}
}
const isValid = [
isValidHolderName,
isValidAccountNumber,
isValidRoutingNumber,
].every((isValidValue) => isValidValue);
if (!isValid) {
return null;
}
return {
achHolderName,
achAccountNumber,
achRoutingNumber,
achAccountType,
};
}
@action
onSelectRecord(record) {
this.selectedRecord = record;
this.resultToken = null;
this.setURLs(record);
this.setSubscriptionIdAndVendor(record);
if (this.isSupportedStripe()) {
this.renderStripeElements();
}
}
@action
async onSyncToPaymentService() {
try {
const subscriptionId = this.subscriptionId;
if (!this.resultToken || !this.resultToken.token) {
throw new Error("Missing Token data");
}
const tokenData = {
...this.resultToken,
subscriptionId,
vendor: this.vendor,
};
console.log("===== Sync to Payment Service =====\n", {
...tokenData,
token: obfuscateToken(tokenData.token),
vendor: this.vendor,
});
this.isClickedToSync = true;
//Getting Add Token Custom Action
const customAction = this.store
.peekAll("custom-action")
.find((c) => c.name === "Add Token");
console.log("===== CustomAction is retreived =====\n");
if (!this.selectedRecord) {
this.selectedRecord = {};
}
//Triggering CustomAction
await triggerSmartAction(
this,
await customAction.collection,
customAction.name,
[this.selectedRecord],
() => {},
tokenData,
);
console.log("===== CustomAction is triggered =====\n");
} catch (error) {
console.error(
"Internal Error: Failed to sync to Payment Service:\n",
error
);
}
}
@action
async onSubmitToCreateStripeCard(event) {
event.preventDefault();
const { value: cardHolderName } = document.getElementById(
ELEMENT_IDS.STRIPE_CARD_HOLDER_NAME
);
let stripeCardNameErrorElement = document.getElementById(
ELEMENT_IDS.STRIPE_CARD_HOLDER_NAME_ERROR
);
if (!cardHolderName) {
stripeCardNameErrorElement.textContent =
"Please input card's owner name";
} else {
stripeCardNameErrorElement.textContent = "";
try {
this.isCreatingStripeCard = true;
const { value: cardName } = document.getElementById(
ELEMENT_IDS.STRIPE_CARD_HOLDER_NAME
);
const { stripe, stripeCard } = this;
const { paymentMethod: cardData } = await stripe.createPaymentMethod({
type: "card",
card: stripeCard,
billing_details: { name: cardName },
});
this.isCreatingStripeCard = false;
this.onCreatedStripeCardToken(cardData, cardName);
} catch (error) {
let stripeCardErrorElement = document.getElementById(
ELEMENT_IDS.STRIPE_CARD_ERROR
);
stripeCardErrorElement.textContent = error.message;
}
}
}
@action
onSubmitToCreateStripeACH(event) {
event.preventDefault();
const achValues = this.validateStripeACHForm();
if (!achValues) {
return;
}
try {
this.isCreatingStripeACH = true;
Stripe.bankAccount.createToken(
{
country: STRIPE_ACH_DEFAULT_VALUE.BANK_COUNTRY_CODE,
currency: STRIPE_ACH_DEFAULT_VALUE.CURRENCY,
account_holder_name: achValues.achHolderName,
account_number: achValues.achAccountNumber,
routing_number: achValues.achRoutingNumber,
account_holder_type: achValues.achAccountType,
},
(status, response) => {
if (response.error) {
throw error;
}
this.isCreatingStripeACH = false;
this.onCreatedStripeACHToken(response, achValues);
}
);
} catch (error) {
let stripeCardErrorElement = document.getElementById(
ELEMENT_IDS.STRIPE_CARD_ERROR
);
stripeCardErrorElement.textContent = error.message;
}
}
}
/
Template:
{{!-- v1.6.1: Update to new syntax for Smart Action call --}}
<div class="wrapper" id="smv_add_token" {{did-update this.onRecordsChange @records}} ...attributes>
<div class="wrapper-list">
<div class="sub-tok-list-table">
<div class="sub-tok-list-heading">
<div class="sub-tok-list-heading-row">
<div class="column sub-tok-ids">Subscription ID/Token ID</div>
<div class="column loan-number">Loan number</div>
<div class="column pm-info">Payment method info<br>(Vendor/Type/Last 4)</div>
</div>
</div>
<div class="sub-tok-list-body">
{{#each @records as |record|}}
<div
class="sub-tok-list-row {{if (eq this.selectedRecord record) 'selected'}}"
{{on 'click' (fn this.onSelectRecord record)}}
>
<div class="column sub-tok-ids">
<span class="value">
{{record.forest-subscription.id}}
<br>
{{record.forest-token.id}}
<br>
{{#if record.forest-isPrimary}}
<div class="primary-token">primary</div>
{{/if}}
</span>
</div>
<div class="column loan-number">
<span class="value">{{record.forest-subscription.forest-loanNumber}}</span>
</div>
<div class="column pm-info">
<span class="value">{{record.forest-subscription.forest-vendor}}</span>
<span class="value"> / {{record.forest-token.forest-paymentMethod}} / </span>
<span class="value">{{record.forest-token.forest-last4}}</span>
</div>
</div>
{{/each}}
</div>
</div>
<DataDisplay::Table::TableFooter
@collection={{@collection}}
@viewList={{@viewList}}
@records={{@records}}
@currentPage={{@currentPage}}
@numberOfPages={{@numberOfPages}}
@recordsCount={{@recordsCount}}
@isLoading={{@isLoading}}
@fetchRecords={{@fetchRecords}}
/>
</div>
<div class="wrapper-forms">
{{#if this.stripe}}
<h2 class="forms-title">New payment method for Loan: <b>{{this.loanNumber}}</b></h2>
{{else}}
<h2 class="forms-title">Getting ready ...</h2>
{{/if}}
{{#if this.resultToken}}
{{#if (eq this.resultToken.paymentMethod 'CARD')}}
<div class="wrapper-result">
<div class="result-row">
<div class="label">Name</div>
<div class="value">{{this.resultToken.name}}</div>
</div>
<div class="result-row">
<div class="label">Card brand</div>
<div class="value">{{this.resultToken.type}}</div>
</div>
<div class="result-row">
<div class="label">Last 4 digits</div>
<div class="value">{{this.resultToken.last4}}</div>
</div>
<div class="result-row">
<div class="label">Expiry date</div>
<div class="value">{{this.resultToken.expiry}}</div>
</div>
{{#if this.resultToken.billingZip}}
<div class="result-row">
<div class="label">Billing zip</div>
<div class="value">{{this.resultToken.billingZip}}</div>
</div>
{{/if}}
</div>
{{else}}
<div class="wrapper-result">
<div class="result-row">
<div class="label">Bank name</div>
<div class="value">{{this.resultToken.bankName}}</div>
</div>
<div class="result-row">
<div class="label">Account owner's full name</div>
<div class="value">{{this.resultToken.name}}</div>
</div>
<div class="result-row">
<div class="label">Last 4 digits</div>
<div class="value">{{this.resultToken.last4}}</div>
</div>
{{#if this.resultToken.type}}
<div class="result-row">
<div class="label">Account type</div>
<div class="value">{{this.resultToken.type}}</div>
</div>
{{/if}}
</div>
{{/if}}
{{#if this.isClickedToSync}}
<h2 class="warning">Please refresh your browser to see new update after finishing the synchronization successfully.</h2>
{{else}}
<button class="btn btn-sync" {{on 'click' (fn this.onSyncToPaymentService)}}>
Upload new Payment method to Payment service
</button>
{{/if}}
{{else}}
<div data-loaded="{{this.isLoadedStripe}}"
data-visibility="{{if (eq this.vendor 'stripe') true false}}"
class="wrapper-stripe-forms">
{{!-- STRIPE CARD --}}
<form id="stripe-form" data-visibility="{{if this.resultToken false true}}"
onsubmit={{fn this.onSubmitToCreateStripeCard}}>
<div class="stripe-form-row">
<input id="stripe-card-holder-name" class="form-input" placeholder="Name" />
<div id="stripe-card-holder-name-error" class="stripe-error" role="alert" />
</div>
<div class="stripe-form-row">
<div id="stripe-card-element" />
<div id="stripe-card-errors" class="stripe-error" role="alert" />
</div>
<div class="form-actions">
{{#if this.isCreatingStripeCard}}
<div class="wrapper-dots">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
{{else}}
<button type="submit" class="btn btn-stripe-add">Add Card</button>
{{/if}}
</div>
</form>
{{!-- STRIPE ACH --}}
<form class="stripe-ach-form" onsubmit={{fn this.onSubmitToCreateStripeACH }}>
<div class="stripe-ach-form-cols-wrapper">
<div class="stripe-ach-form-col col-inputs">
<div class="ach-form-input">
<input type="text" id="stripe-ach-holder-name" placeholder="Account owner full name">
<div id="stripe-ach-holder-name-error" class="stripe-error" role="alert" />
</div>
<div class="ach-form-input">
<input type="text" id="stripe-ach-account-number" placeholder="Account number">
<div id="stripe-ach-account-number-error" class="stripe-error" role="alert" />
</div>
<div class="ach-form-input">
<input type="text" id="stripe-ach-routing-number" placeholder="Routing number">
<div id="stripe-ach-routing-number-error" class="stripe-error" role="alert" />
</div>
</div>
<div class="stripe-ach-form-col col-selectors">
<div class="ach-form-selector">
<label for="bank_country">Bank country</label>
<select id="stripe-ach-bank-country" name="bank_country" disabled>
<option value="US" selected>United States</option>
</select>
</div>
<div class="ach-form-selector">
<label for="currency">Currency</label>
<select id="stripe-ach-currency" name="currency" disabled>
<option value="USD" selected>USD</option>
</select>
</div>
<div class="ach-form-selector">
<label for="account_type">Account type</label>
<select id="stripe-ach-account-type" name="account_type">
<option value="individual">Individual</option>
<option value="company">Company</option>
</select>
</div>
</div>
</div>
<div class="form-actions">
{{#if this.isCreatingStripeACH}}
<div class="wrapper-dots">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
{{else}}
<button disabled={{this.isCreatingStripeACH}} type="submit" class="btn btn-stripe-add">Add ACH</button>
{{/if}}
</div>
</form>
</div>
{{#if (eq this.vendor 'payix')}}
<div class="wrapper-payix-iframes">
<div class="warning-pad">PAD only supports for Canadian</div>
<div class="wrapper-iframes">
<iframe src="{{this.payixIframeCardAchURL}}" />
<iframe src="{{this.payixIframePadURL}}" />
</div>
</div>
{{/if}}
{{/if}}
</div>
</div>