mirror of
https://github.com/tutao/tutanota.git
synced 2025-12-07 13:49:47 +00:00
wip: initial payment page implementation
This commit is contained in:
parent
f336c3da23
commit
b62040d99d
6 changed files with 488 additions and 414 deletions
|
|
@ -1,20 +1,45 @@
|
|||
import m, { ClassComponent, Vnode } from "mithril"
|
||||
import { WizardStepComponentAttrs } from "../gui/base/wizard/WizardStep"
|
||||
import { SignupViewModel } from "./SignupView"
|
||||
import { InvoiceAndPaymentDataPageNew } from "../subscription/InvoiceAndPaymentDataPageNew"
|
||||
import { InvoiceAndPaymentDataPageNew } from "./components/InvoiceAndPaymentDataPageNew"
|
||||
import { px, size } from "../gui/size"
|
||||
import { theme } from "../gui/theme"
|
||||
import { RadioSelectorOption } from "../gui/base/RadioSelectorItem"
|
||||
import { RadioSelector, RadioSelectorAttrs } from "../gui/base/RadioSelector"
|
||||
|
||||
export class SignupPaymentPage implements ClassComponent<WizardStepComponentAttrs<SignupViewModel>> {
|
||||
private currentOption = 0
|
||||
view(vnode: Vnode<WizardStepComponentAttrs<SignupViewModel>>) {
|
||||
const { ctx } = vnode.attrs
|
||||
|
||||
return m(
|
||||
".flex.col.justify-center",
|
||||
const boxAttr = { style: { width: px(500), height: px(500), background: theme.primary_container, padding: size.spacing_16 } }
|
||||
const options: ReadonlyArray<RadioSelectorOption<number>> = [
|
||||
{
|
||||
style: {
|
||||
"max-width": "50%",
|
||||
},
|
||||
name: "paymentMethodCreditCard_label",
|
||||
value: 0,
|
||||
renderChild: () =>
|
||||
m(
|
||||
"div.flex.flex-column",
|
||||
{
|
||||
style: {
|
||||
width: px(500),
|
||||
},
|
||||
},
|
||||
|
||||
m(InvoiceAndPaymentDataPageNew, ctx),
|
||||
),
|
||||
},
|
||||
m(InvoiceAndPaymentDataPageNew, ctx),
|
||||
)
|
||||
{ name: "credit_label", value: 1, renderChild: () => m("div", boxAttr, "2") },
|
||||
]
|
||||
return m(".flex.items-center.flex-center", [
|
||||
m(RadioSelector, {
|
||||
groupName: "credentialsEncryptionMode_label",
|
||||
options,
|
||||
selectedOption: this.currentOption,
|
||||
onOptionSelected: (mode: number) => {
|
||||
this.currentOption = mode
|
||||
},
|
||||
} satisfies RadioSelectorAttrs<number>),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
122
src/common/signup/components/CreditCardInputForm.ts
Normal file
122
src/common/signup/components/CreditCardInputForm.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import m, { Children, Component, Vnode } from "mithril"
|
||||
import { SimplifiedCreditCardViewModel } from "../../subscription/SimplifiedCreditCardInputModel"
|
||||
import { lang, TranslationKey } from "../../misc/LanguageViewModel"
|
||||
import { CreditCard } from "../../api/entities/sys/TypeRefs"
|
||||
import { Autocomplete } from "../../gui/base/TextField"
|
||||
import { LoginTextField } from "../../gui/base/LoginTextField"
|
||||
import { Icons } from "../../gui/base/icons/Icons"
|
||||
import { theme } from "../../gui/theme"
|
||||
|
||||
export type SimplifiedCreditCardAttrs = {
|
||||
viewModel: SimplifiedCreditCardViewModel
|
||||
}
|
||||
|
||||
export interface CCViewModel {
|
||||
validateCreditCardPaymentData(): TranslationKey | null
|
||||
|
||||
setCreditCardData(data: CreditCard | null): void
|
||||
|
||||
getCreditCardData(): CreditCard
|
||||
}
|
||||
|
||||
// changing the content (ie grouping) sets selection to the end, this restores it after the next redraw.
|
||||
function restoreSelection(domInput: HTMLInputElement) {
|
||||
const { selectionStart, selectionEnd, selectionDirection } = domInput
|
||||
const isAtEnd = domInput.value.length === selectionStart
|
||||
setTimeout(() => {
|
||||
const currentLength = domInput.value.length
|
||||
// we're adding characters, so just re-using the index fails because at the time we set the selection, the string is longer than it was.
|
||||
// this mostly works, but fails in cases where we're adding stuff in the middle of the string.
|
||||
domInput.setSelectionRange(isAtEnd ? currentLength : selectionStart, isAtEnd ? currentLength : selectionEnd, selectionDirection ?? undefined)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export class CreditCardInputForm implements Component<SimplifiedCreditCardAttrs> {
|
||||
dateFieldLeft: boolean = false
|
||||
numberFieldLeft: boolean = false
|
||||
cvvFieldLeft: boolean = false
|
||||
ccNumberDom: HTMLInputElement | null = null
|
||||
expDateDom: HTMLInputElement | null = null
|
||||
|
||||
view(vnode: Vnode<SimplifiedCreditCardAttrs>): Children {
|
||||
let { viewModel } = vnode.attrs
|
||||
|
||||
return [
|
||||
m(LoginTextField, {
|
||||
label: "creditCardNumber_label",
|
||||
leadingIcon: {
|
||||
icon: Icons.Reddit,
|
||||
color: theme.on_surface_variant,
|
||||
},
|
||||
helpLabel: () => this.renderCcNumberHelpLabel(viewModel),
|
||||
value: viewModel.creditCardNumber,
|
||||
oninput: (newValue) => {
|
||||
viewModel.creditCardNumber = newValue
|
||||
restoreSelection(this.ccNumberDom!)
|
||||
},
|
||||
onblur: () => (this.numberFieldLeft = true),
|
||||
autocompleteAs: Autocomplete.ccNumber,
|
||||
onDomInputCreated: (dom) => (this.ccNumberDom = dom),
|
||||
}),
|
||||
m(".flex.justify-between.gap-8", [
|
||||
m(LoginTextField, {
|
||||
label: "creditCardExpirationDateWithFormat_label",
|
||||
value: viewModel.expirationDate,
|
||||
// we only show the hint if the field is not empty and not selected to avoid showing errors while the user is typing.
|
||||
helpLabel: () => (this.dateFieldLeft ? lang.get(viewModel.getExpirationDateErrorHint() ?? "emptyString_msg") : lang.get("emptyString_msg")),
|
||||
onblur: () => (this.dateFieldLeft = true),
|
||||
oninput: (newValue) => {
|
||||
viewModel.expirationDate = newValue
|
||||
restoreSelection(this.expDateDom!)
|
||||
},
|
||||
onDomInputCreated: (dom) => (this.expDateDom = dom),
|
||||
autocompleteAs: Autocomplete.ccExp,
|
||||
}),
|
||||
m(LoginTextField, {
|
||||
label: lang.makeTranslation("cvv", viewModel.getCvvLabel()),
|
||||
value: viewModel.cvv,
|
||||
helpLabel: () => this.renderCvvNumberHelpLabel(viewModel),
|
||||
oninput: (newValue) => {
|
||||
viewModel.cvv = newValue
|
||||
},
|
||||
onblur: () => (this.cvvFieldLeft = true),
|
||||
autocompleteAs: Autocomplete.ccCsc,
|
||||
}),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
private renderCcNumberHelpLabel(model: SimplifiedCreditCardViewModel): Children {
|
||||
const hint = model.getCreditCardNumberHint()
|
||||
const error = model.getCreditCardNumberErrorHint()
|
||||
// we only draw the hint if the number field was entered & exited before
|
||||
if (this.numberFieldLeft) {
|
||||
if (hint) {
|
||||
return error ? lang.get("creditCardHintWithError_msg", { "{hint}": hint, "{errorText}": error }) : hint
|
||||
} else {
|
||||
return error ? error : lang.get("emptyString_msg")
|
||||
}
|
||||
} else {
|
||||
return hint ?? lang.get("emptyString_msg")
|
||||
}
|
||||
}
|
||||
|
||||
private renderCvvNumberHelpLabel(model: SimplifiedCreditCardViewModel): Children {
|
||||
const cvvHint = model.getCvvHint()
|
||||
const cvvError = model.getCvvErrorHint()
|
||||
if (this.cvvFieldLeft) {
|
||||
if (cvvHint) {
|
||||
return cvvError
|
||||
? lang.get("creditCardHintWithError_msg", {
|
||||
"{hint}": cvvHint,
|
||||
"{errorText}": cvvError,
|
||||
})
|
||||
: cvvHint
|
||||
} else {
|
||||
return cvvError ? cvvError : lang.get("emptyString_msg")
|
||||
}
|
||||
} else {
|
||||
return cvvHint ?? lang.get("emptyString_msg")
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/common/signup/components/InvoiceAndPaymentDataPageNew.ts
Normal file
122
src/common/signup/components/InvoiceAndPaymentDataPageNew.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import m, { Children, Component, Vnode, VnodeDOM } from "mithril"
|
||||
import { Dialog } from "../../gui/base/Dialog"
|
||||
import { lang } from "../../misc/LanguageViewModel"
|
||||
import { InvoiceDataInput, InvoiceDataInputLocation } from "../../subscription/InvoiceDataInput"
|
||||
import stream from "mithril/stream"
|
||||
import { AvailablePlanType, PaymentMethodType } from "../../api/common/TutanotaConstants"
|
||||
import { showProgressDialog } from "../../gui/dialogs/ProgressDialog"
|
||||
import { assertNotNull, neverNull } from "@tutao/tutanota-utils"
|
||||
import { UpgradeType } from "../../subscription/utils/SubscriptionUtils"
|
||||
import { locator } from "../../api/main/CommonLocator"
|
||||
import { LoginButton } from "../../gui/base/buttons/LoginButton.js"
|
||||
import { updatePaymentData, validateInvoiceData, validatePaymentData } from "../../subscription/utils/PaymentUtils"
|
||||
import { SimplifiedCreditCardViewModel } from "../../subscription/SimplifiedCreditCardInputModel"
|
||||
import { SignupViewModel } from "../SignupView"
|
||||
import { WizardStepContext } from "../../gui/base/wizard/WizardController"
|
||||
import { CreditCardInputForm } from "./CreditCardInputForm"
|
||||
|
||||
export interface InvoiceAndPaymentDataPageNewAttrs {
|
||||
ctx: WizardStepContext<SignupViewModel>
|
||||
}
|
||||
/**
|
||||
* Wizard page for editing invoice and payment data.
|
||||
*/
|
||||
export class InvoiceAndPaymentDataPageNew implements Component<WizardStepContext<SignupViewModel>> {
|
||||
private _invoiceDataInput: InvoiceDataInput | null = null
|
||||
private _hasClickedNext: boolean = false
|
||||
private ccViewModel: SimplifiedCreditCardViewModel
|
||||
private isCreditCardValid: stream<boolean> = stream(false)
|
||||
private ctx: WizardStepContext<SignupViewModel>
|
||||
|
||||
constructor(vnode: Vnode<WizardStepContext<SignupViewModel>>) {
|
||||
this.ccViewModel = new SimplifiedCreditCardViewModel(lang)
|
||||
this.ctx = vnode.attrs
|
||||
}
|
||||
|
||||
oncreate(vnode: VnodeDOM<WizardStepContext<SignupViewModel>>) {
|
||||
const data = vnode.attrs.viewModel
|
||||
|
||||
if (!data.acceptedPlans.includes(data.targetPlanType as AvailablePlanType)) {
|
||||
throw new Error("Invalid plan is selected")
|
||||
}
|
||||
this._invoiceDataInput = new InvoiceDataInput(data.options.businessUse(), data.invoiceData, InvoiceDataInputLocation.InWizard)
|
||||
this.ccViewModel.setCreditCardData(data.paymentData.creditCardData)
|
||||
this.isCreditCardValid(!this.ccViewModel.validateCreditCardPaymentData())
|
||||
m.redraw()
|
||||
}
|
||||
|
||||
view({ attrs: { viewModel: data } }: Vnode<WizardStepContext<SignupViewModel>>): Children {
|
||||
return m(
|
||||
".mt-16.mb-16.mlr-16",
|
||||
m(CreditCardInputForm, {
|
||||
viewModel: this.ccViewModel,
|
||||
}),
|
||||
m(
|
||||
".flex.justify-end",
|
||||
m(LoginButton, {
|
||||
width: "flex",
|
||||
label: "next_action",
|
||||
onclick: async () => {
|
||||
this.onAddPaymentData(data)
|
||||
},
|
||||
disabled: this.ccViewModel.validateCreditCardPaymentData() != null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private onAddPaymentData = async (data: SignupViewModel) => {
|
||||
const invoiceDataInput = assertNotNull(this._invoiceDataInput)
|
||||
|
||||
const error =
|
||||
validateInvoiceData({
|
||||
address: invoiceDataInput.getAddress(),
|
||||
isBusiness: data.options.businessUse(),
|
||||
}) ||
|
||||
validatePaymentData({
|
||||
country: invoiceDataInput.selectedCountry()!,
|
||||
isBusiness: data.options.businessUse(),
|
||||
paymentMethod: PaymentMethodType.CreditCard,
|
||||
accountingInfo: assertNotNull(data.accountingInfo),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
await Dialog.message(error)
|
||||
return
|
||||
}
|
||||
|
||||
data.invoiceData = invoiceDataInput.getInvoiceData()
|
||||
data.paymentData = {
|
||||
paymentMethod: PaymentMethodType.CreditCard,
|
||||
creditCardData: this.ccViewModel.getCreditCardData(),
|
||||
}
|
||||
|
||||
const progress = (async () => {
|
||||
const customer = neverNull(data.customer)
|
||||
const businessUse = data.options.businessUse()
|
||||
|
||||
if (customer.businessUse !== businessUse) {
|
||||
customer.businessUse = businessUse
|
||||
await locator.entityClient.update(customer)
|
||||
}
|
||||
|
||||
const success = await updatePaymentData(
|
||||
data.options.paymentInterval(),
|
||||
data.invoiceData,
|
||||
data.paymentData,
|
||||
null,
|
||||
data.upgradeType === UpgradeType.Signup,
|
||||
neverNull(data.price?.rawPrice),
|
||||
neverNull(data.accountingInfo),
|
||||
)
|
||||
|
||||
if (success && !this._hasClickedNext) {
|
||||
this._hasClickedNext = true
|
||||
|
||||
this.ctx.goNext()
|
||||
}
|
||||
})()
|
||||
|
||||
void showProgressDialog("updatePaymentDataBusy_msg", progress)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,399 +0,0 @@
|
|||
import m, { Children, Component, Vnode, VnodeDOM } from "mithril"
|
||||
import { Dialog, DialogType } from "../gui/base/Dialog"
|
||||
import { lang } from "../misc/LanguageViewModel"
|
||||
import { InvoiceDataInput, InvoiceDataInputLocation } from "./InvoiceDataInput"
|
||||
import stream from "mithril/stream"
|
||||
import Stream from "mithril/stream"
|
||||
import { AvailablePlanType, getClientType, InvoiceData, Keys, PaymentData, PaymentDataResultType, PaymentMethodType } from "../api/common/TutanotaConstants"
|
||||
import { showProgressDialog } from "../gui/dialogs/ProgressDialog"
|
||||
import { AccountingInfo, Braintree3ds2Request, InvoiceInfoTypeRef } from "../api/entities/sys/TypeRefs.js"
|
||||
import { assertNotNull, LazyLoaded, neverNull, newPromise, noOp, promiseMap } from "@tutao/tutanota-utils"
|
||||
import { getLazyLoadedPayPalUrl, getPreconditionFailedPaymentMsg, PaymentErrorCode, UpgradeType } from "./utils/SubscriptionUtils"
|
||||
import { Button, ButtonType } from "../gui/base/Button.js"
|
||||
import type { SegmentControlItem } from "../gui/base/SegmentControl"
|
||||
import { SegmentControl } from "../gui/base/SegmentControl"
|
||||
import { emitWizardEvent, WizardEventType } from "../gui/base/WizardDialog.js"
|
||||
import type { Country } from "../api/common/CountryList"
|
||||
import { DefaultAnimationTime } from "../gui/animation/Animations"
|
||||
import { locator } from "../api/main/CommonLocator"
|
||||
import { PaymentInterval } from "./utils/PriceUtils.js"
|
||||
import { EntityUpdateData, isUpdateForTypeRef } from "../api/common/utils/EntityUpdateUtils.js"
|
||||
import { EntityEventsListener } from "../api/main/EventController.js"
|
||||
import { LoginButton } from "../gui/base/buttons/LoginButton.js"
|
||||
import { client } from "../misc/ClientDetector.js"
|
||||
import { createAccount, getVisiblePaymentMethods, validateInvoiceData, validatePaymentData } from "./utils/PaymentUtils"
|
||||
import { SimplifiedCreditCardViewModel } from "./SimplifiedCreditCardInputModel"
|
||||
import { SimplifiedCreditCardInput } from "./SimplifiedCreditCardInput"
|
||||
import { PaypalButton } from "./PaypalButton"
|
||||
import { SignupViewModel } from "../signup/SignupView"
|
||||
import { WizardStepContext } from "../gui/base/wizard/WizardController"
|
||||
|
||||
export interface InvoiceAndPaymentDataPageNewAttrs {
|
||||
ctx: WizardStepContext<SignupViewModel>
|
||||
}
|
||||
/**
|
||||
* Wizard page for editing invoice and payment data.
|
||||
*/
|
||||
export class InvoiceAndPaymentDataPageNew implements Component<WizardStepContext<SignupViewModel>> {
|
||||
private _invoiceDataInput: InvoiceDataInput | null = null
|
||||
private _availablePaymentMethods: Array<SegmentControlItem<PaymentMethodType>> | null = null
|
||||
private _selectedPaymentMethod: Stream<PaymentMethodType> = stream(PaymentMethodType.CreditCard)
|
||||
private dom!: HTMLElement
|
||||
private _hasClickedNext: boolean = false
|
||||
private ccViewModel: SimplifiedCreditCardViewModel
|
||||
// private _entityEventListener: EntityEventsListener
|
||||
private paypalRequestUrl: LazyLoaded<string>
|
||||
private isCreditCardValid: stream<boolean> = stream(false)
|
||||
private isPaypalLinked: stream<boolean> = stream(false)
|
||||
private ctx: WizardStepContext<SignupViewModel>
|
||||
|
||||
constructor(vnode: Vnode<WizardStepContext<SignupViewModel>>) {
|
||||
this.ccViewModel = new SimplifiedCreditCardViewModel(lang)
|
||||
this.paypalRequestUrl = getLazyLoadedPayPalUrl()
|
||||
this.ctx = vnode.attrs
|
||||
}
|
||||
|
||||
oncreate(vnode: VnodeDOM<WizardStepContext<SignupViewModel>>) {
|
||||
this.dom = vnode.dom as HTMLElement
|
||||
const data = vnode.attrs.viewModel
|
||||
|
||||
if (!data.acceptedPlans.includes(data.targetPlanType as AvailablePlanType)) {
|
||||
throw new Error("Invalid plan is selected")
|
||||
}
|
||||
|
||||
this._invoiceDataInput = new InvoiceDataInput(data.options.businessUse(), data.invoiceData, InvoiceDataInputLocation.InWizard)
|
||||
this._availablePaymentMethods = getVisiblePaymentMethods({
|
||||
isBusiness: data.options.businessUse(),
|
||||
accountingInfo: data.accountingInfo,
|
||||
isBankTransferAllowed: !data.firstMonthForFreeOfferActive,
|
||||
})
|
||||
|
||||
this._selectedPaymentMethod(data.paymentData.paymentMethod)
|
||||
|
||||
this.ccViewModel.setCreditCardData(data.paymentData.creditCardData)
|
||||
this.isPaypalLinked(data.accountingInfo?.paypalBillingAgreement != null)
|
||||
this.isCreditCardValid(!this.ccViewModel.validateCreditCardPaymentData())
|
||||
m.redraw()
|
||||
}
|
||||
|
||||
view({ attrs: { viewModel: data } }: Vnode<WizardStepContext<SignupViewModel>>): Children {
|
||||
return m(
|
||||
".pt-16",
|
||||
this._availablePaymentMethods
|
||||
? [
|
||||
m(SegmentControl, {
|
||||
items: this._availablePaymentMethods,
|
||||
selectedValue: this._selectedPaymentMethod(),
|
||||
onValueSelected: this._selectedPaymentMethod,
|
||||
}),
|
||||
m(".flex-space-around.flex-wrap.pt-16", [
|
||||
m(
|
||||
".flex-grow-shrink-half.plr-24",
|
||||
{
|
||||
style: {
|
||||
minWidth: "260px",
|
||||
},
|
||||
},
|
||||
m(neverNull(this._invoiceDataInput)),
|
||||
),
|
||||
m(
|
||||
".flex-grow-shrink-half.plr-24",
|
||||
{
|
||||
style: {
|
||||
minWidth: "260px",
|
||||
},
|
||||
},
|
||||
this._selectedPaymentMethod() === PaymentMethodType.Paypal &&
|
||||
m(PaypalButton, {
|
||||
data,
|
||||
onclick: () => this.onPaypalButtonClick(),
|
||||
oncomplete: () => this.onAddPaymentData(data),
|
||||
}),
|
||||
this._selectedPaymentMethod() === PaymentMethodType.CreditCard &&
|
||||
m(SimplifiedCreditCardInput, {
|
||||
viewModel: this.ccViewModel,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
m(
|
||||
".flex-center.full-width.pt-32",
|
||||
m(LoginButton, {
|
||||
label: "next_action",
|
||||
class: "small-login-button",
|
||||
onclick: async () => {
|
||||
await createAccount(data, () => {
|
||||
emitWizardEvent(this.dom, WizardEventType.CLOSE_DIALOG)
|
||||
})
|
||||
this.onAddPaymentData(data)
|
||||
},
|
||||
disabled:
|
||||
(this._selectedPaymentMethod() === PaymentMethodType.CreditCard &&
|
||||
this.ccViewModel.validateCreditCardPaymentData() != null) ||
|
||||
(this._selectedPaymentMethod() === PaymentMethodType.Paypal && !this.isPaypalLinked()),
|
||||
}),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
)
|
||||
}
|
||||
|
||||
private onAddPaymentData = async (data: SignupViewModel) => {
|
||||
const invoiceDataInput = assertNotNull(this._invoiceDataInput)
|
||||
|
||||
const error =
|
||||
validateInvoiceData({
|
||||
address: invoiceDataInput.getAddress(),
|
||||
isBusiness: data.options.businessUse(),
|
||||
}) ||
|
||||
validatePaymentData({
|
||||
country: invoiceDataInput.selectedCountry()!,
|
||||
isBusiness: data.options.businessUse(),
|
||||
paymentMethod: this._selectedPaymentMethod(),
|
||||
accountingInfo: assertNotNull(data.accountingInfo),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
await Dialog.message(error)
|
||||
return
|
||||
}
|
||||
|
||||
data.invoiceData = invoiceDataInput.getInvoiceData()
|
||||
data.paymentData = {
|
||||
paymentMethod: this._selectedPaymentMethod(),
|
||||
creditCardData: this._selectedPaymentMethod() === PaymentMethodType.CreditCard ? this.ccViewModel.getCreditCardData() : null,
|
||||
}
|
||||
|
||||
const progress = (async () => {
|
||||
const customer = neverNull(data.customer)
|
||||
const businessUse = data.options.businessUse()
|
||||
|
||||
if (customer.businessUse !== businessUse) {
|
||||
customer.businessUse = businessUse
|
||||
await locator.entityClient.update(customer)
|
||||
}
|
||||
|
||||
const success = await updatePaymentData(
|
||||
data.options.paymentInterval(),
|
||||
data.invoiceData,
|
||||
data.paymentData,
|
||||
null,
|
||||
data.upgradeType === UpgradeType.Signup,
|
||||
neverNull(data.price?.rawPrice),
|
||||
neverNull(data.accountingInfo),
|
||||
)
|
||||
|
||||
if (success && !this._hasClickedNext) {
|
||||
this._hasClickedNext = true
|
||||
|
||||
this.ctx.goNext()
|
||||
}
|
||||
})()
|
||||
|
||||
void showProgressDialog("updatePaymentDataBusy_msg", progress)
|
||||
}
|
||||
private onPaypalButtonClick = async () => {
|
||||
if (this.paypalRequestUrl.isLoaded()) {
|
||||
window.open(this.paypalRequestUrl.getLoaded())
|
||||
} else {
|
||||
showProgressDialog("payPalRedirect_msg", this.paypalRequestUrl.getAsync()).then((url) => window.open(url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePaymentData(
|
||||
paymentInterval: PaymentInterval,
|
||||
invoiceData: InvoiceData,
|
||||
paymentData: PaymentData | null,
|
||||
confirmedCountry: Country | null,
|
||||
isSignup: boolean,
|
||||
price: string | null,
|
||||
accountingInfo: AccountingInfo,
|
||||
): Promise<boolean> {
|
||||
const paymentResult = await locator.customerFacade.updatePaymentData(paymentInterval, invoiceData, paymentData, confirmedCountry)
|
||||
const statusCode = paymentResult.result
|
||||
|
||||
if (statusCode === PaymentDataResultType.OK) {
|
||||
// show dialog
|
||||
let braintree3ds = paymentResult.braintree3dsRequest
|
||||
if (braintree3ds) {
|
||||
return verifyCreditCard(accountingInfo, braintree3ds, price!)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
} else if (statusCode === PaymentDataResultType.COUNTRY_MISMATCH) {
|
||||
const countryName = invoiceData.country ? invoiceData.country.n : ""
|
||||
const confirmMessage = lang.getTranslation("confirmCountry_msg", {
|
||||
"{1}": countryName,
|
||||
})
|
||||
const confirmed = await Dialog.confirm(confirmMessage)
|
||||
if (confirmed) {
|
||||
return updatePaymentData(paymentInterval, invoiceData, paymentData, invoiceData.country, isSignup, price, accountingInfo) // add confirmed invoice country
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else if (statusCode === PaymentDataResultType.INVALID_VATID_NUMBER) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation("invalidVatIdNumber_msg", lang.get("invalidVatIdNumber_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : "")),
|
||||
)
|
||||
} else if (statusCode === PaymentDataResultType.CREDIT_CARD_DECLINED) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation("creditCardDeclined_msg", lang.get("creditCardDeclined_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : "")),
|
||||
)
|
||||
} else if (statusCode === PaymentDataResultType.CREDIT_CARD_CVV_INVALID) {
|
||||
await Dialog.message("creditCardCVVInvalid_msg")
|
||||
} else if (statusCode === PaymentDataResultType.PAYMENT_PROVIDER_NOT_AVAILABLE) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation(
|
||||
"paymentProviderNotAvailableError_msg",
|
||||
lang.get("paymentProviderNotAvailableError_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : ""),
|
||||
),
|
||||
)
|
||||
} else if (statusCode === PaymentDataResultType.OTHER_PAYMENT_ACCOUNT_REJECTED) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation(
|
||||
"paymentAccountRejected_msg",
|
||||
lang.get("paymentAccountRejected_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : ""),
|
||||
),
|
||||
)
|
||||
} else if (statusCode === PaymentDataResultType.CREDIT_CARD_DATE_INVALID) {
|
||||
await Dialog.message("creditCardExprationDateInvalid_msg")
|
||||
} else if (statusCode === PaymentDataResultType.CREDIT_CARD_NUMBER_INVALID) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation(
|
||||
"creditCardNumberInvalid_msg",
|
||||
lang.get("creditCardNumberInvalid_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : ""),
|
||||
),
|
||||
)
|
||||
} else if (statusCode === PaymentDataResultType.COULD_NOT_VERIFY_VATID) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation(
|
||||
"invalidVatIdValidationFailed_msg",
|
||||
lang.get("invalidVatIdValidationFailed_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : ""),
|
||||
),
|
||||
)
|
||||
} else if (statusCode === PaymentDataResultType.CREDIT_CARD_VERIFICATION_LIMIT_REACHED) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation(
|
||||
"creditCardVerificationLimitReached_msg",
|
||||
lang.get("creditCardVerificationLimitReached_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : ""),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation(
|
||||
"otherPaymentProviderError_msg",
|
||||
lang.get("otherPaymentProviderError_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : ""),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a progress dialog that allows to cancel the verification and opens a new window to do the actual verification with the bank.
|
||||
*/
|
||||
function verifyCreditCard(accountingInfo: AccountingInfo, braintree3ds: Braintree3ds2Request, price: string): Promise<boolean> {
|
||||
return locator.entityClient.load(InvoiceInfoTypeRef, neverNull(accountingInfo.invoiceInfo)).then((invoiceInfo) => {
|
||||
let invoiceInfoWrapper = {
|
||||
invoiceInfo,
|
||||
}
|
||||
let resolve: (arg0: boolean) => void
|
||||
let progressDialogPromise: Promise<boolean> = newPromise((res) => (resolve = res))
|
||||
let progressDialog: Dialog
|
||||
|
||||
const closeAction = () => {
|
||||
// user did not complete the 3ds dialog and PaymentDataService.POST was not invoked
|
||||
progressDialog.close()
|
||||
setTimeout(() => resolve(false), DefaultAnimationTime)
|
||||
}
|
||||
|
||||
progressDialog = new Dialog(DialogType.Alert, {
|
||||
view: () => [
|
||||
m(".dialog-contentButtonsBottom.text-break.selectable", lang.get("creditCardPendingVerification_msg")),
|
||||
m(
|
||||
".flex-center.dialog-buttons",
|
||||
m(Button, {
|
||||
label: "cancel_action",
|
||||
click: closeAction,
|
||||
type: ButtonType.Primary,
|
||||
}),
|
||||
),
|
||||
],
|
||||
})
|
||||
.setCloseHandler(closeAction)
|
||||
.addShortcut({
|
||||
key: Keys.RETURN,
|
||||
shift: false,
|
||||
exec: closeAction,
|
||||
help: "close_alt",
|
||||
})
|
||||
.addShortcut({
|
||||
key: Keys.ESC,
|
||||
shift: false,
|
||||
exec: closeAction,
|
||||
help: "close_alt",
|
||||
})
|
||||
let entityEventListener: EntityEventsListener = (updates: ReadonlyArray<EntityUpdateData>, eventOwnerGroupId: Id) => {
|
||||
return promiseMap(updates, (update) => {
|
||||
if (isUpdateForTypeRef(InvoiceInfoTypeRef, update)) {
|
||||
return locator.entityClient.load(InvoiceInfoTypeRef, update.instanceId).then((invoiceInfo) => {
|
||||
invoiceInfoWrapper.invoiceInfo = invoiceInfo
|
||||
if (!invoiceInfo.paymentErrorInfo) {
|
||||
// user successfully verified the card
|
||||
progressDialog.close()
|
||||
resolve(true)
|
||||
} else if (invoiceInfo.paymentErrorInfo && invoiceInfo.paymentErrorInfo.errorCode === "card.3ds2_pending") {
|
||||
// keep waiting. this error code is set before starting the 3DS2 verification and we just received the event very late
|
||||
} else if (invoiceInfo.paymentErrorInfo && invoiceInfo.paymentErrorInfo.errorCode !== null) {
|
||||
// verification error during 3ds verification
|
||||
let error = "3dsFailedOther"
|
||||
|
||||
switch (invoiceInfo.paymentErrorInfo.errorCode as PaymentErrorCode) {
|
||||
case "card.cvv_invalid":
|
||||
error = "cvvInvalid"
|
||||
break
|
||||
case "card.number_invalid":
|
||||
error = "ccNumberInvalid"
|
||||
break
|
||||
|
||||
case "card.date_invalid":
|
||||
error = "expirationDate"
|
||||
break
|
||||
case "card.insufficient_funds":
|
||||
error = "insufficientFunds"
|
||||
break
|
||||
case "card.expired_card":
|
||||
error = "cardExpired"
|
||||
break
|
||||
case "card.3ds2_failed":
|
||||
error = "3dsFailed"
|
||||
break
|
||||
}
|
||||
|
||||
Dialog.message(getPreconditionFailedPaymentMsg(invoiceInfo.paymentErrorInfo.errorCode))
|
||||
resolve(false)
|
||||
progressDialog.close()
|
||||
}
|
||||
|
||||
m.redraw()
|
||||
})
|
||||
}
|
||||
}).then(noOp)
|
||||
}
|
||||
|
||||
locator.eventController.addEntityListener(entityEventListener)
|
||||
const app = client.isCalendarApp() ? "calendar" : "mail"
|
||||
let params = `clientToken=${encodeURIComponent(braintree3ds.clientToken)}&nonce=${encodeURIComponent(braintree3ds.nonce)}&bin=${encodeURIComponent(
|
||||
braintree3ds.bin,
|
||||
)}&price=${encodeURIComponent(price)}&message=${encodeURIComponent(lang.get("creditCardVerification_msg"))}&clientType=${getClientType()}&app=${app}`
|
||||
Dialog.message("creditCardVerificationNeededPopup_msg").then(() => {
|
||||
const paymentUrlString = locator.domainConfigProvider().getCurrentDomainConfig().paymentUrl
|
||||
const paymentUrl = new URL(paymentUrlString)
|
||||
paymentUrl.hash += params
|
||||
window.open(paymentUrl)
|
||||
progressDialog.show()
|
||||
})
|
||||
return progressDialogPromise.finally(() => locator.eventController.removeEntityListener(entityEventListener))
|
||||
})
|
||||
}
|
||||
|
|
@ -1,19 +1,25 @@
|
|||
import { AccountingInfo, AccountingInfoTypeRef } from "../../api/entities/sys/TypeRefs"
|
||||
import { AccountingInfo, AccountingInfoTypeRef, Braintree3ds2Request, InvoiceInfoTypeRef } from "../../api/entities/sys/TypeRefs"
|
||||
import { lang, TranslationKey } from "../../misc/LanguageViewModel"
|
||||
import { type InvoiceData, PaymentMethodType, PlanType } from "../../api/common/TutanotaConstants"
|
||||
import { getClientType, type InvoiceData, Keys, PaymentData, PaymentDataResultType, PaymentMethodType, PlanType } from "../../api/common/TutanotaConstants"
|
||||
import { Country, CountryType } from "../../api/common/CountryList"
|
||||
import { PowSolution } from "../../api/common/pow-worker"
|
||||
import { NewAccountData, type UpgradeSubscriptionData } from "../UpgradeSubscriptionWizard"
|
||||
import { locator } from "../../api/main/CommonLocator"
|
||||
import { runCaptchaFlow } from "../captcha/Captcha"
|
||||
import { client } from "../../misc/ClientDetector"
|
||||
import { SubscriptionApp } from "./SubscriptionUtils"
|
||||
import { getPreconditionFailedPaymentMsg, PaymentErrorCode, SubscriptionApp } from "./SubscriptionUtils"
|
||||
import { SessionType } from "../../api/common/SessionType"
|
||||
import { showProgressDialog } from "../../gui/dialogs/ProgressDialog"
|
||||
import { InvalidDataError, PreconditionFailedError } from "../../api/common/error/RestError"
|
||||
import { assertNotNull, ofClass } from "@tutao/tutanota-utils"
|
||||
import { Dialog } from "../../gui/base/Dialog"
|
||||
import { assertNotNull, neverNull, newPromise, ofClass, promiseMap } from "@tutao/tutanota-utils"
|
||||
import { Dialog, DialogType } from "../../gui/base/Dialog"
|
||||
import { SignupViewModel } from "../../signup/SignupView"
|
||||
import { PaymentInterval } from "./PriceUtils"
|
||||
import { DefaultAnimationTime } from "../../gui/animation/Animations"
|
||||
import m from "mithril"
|
||||
import { Button, ButtonType } from "../../gui/base/Button"
|
||||
import { EntityEventsListener } from "../../api/main/EventController"
|
||||
import { EntityUpdateData, isUpdateForTypeRef } from "../../api/common/utils/EntityUpdateUtils"
|
||||
|
||||
export function isOnAccountAllowed(country: Country | null, accountingInfo: AccountingInfo, isBusiness: boolean): boolean {
|
||||
if (!country) {
|
||||
|
|
@ -25,6 +31,204 @@ export function isOnAccountAllowed(country: Country | null, accountingInfo: Acco
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a progress dialog that allows to cancel the verification and opens a new window to do the actual verification with the bank.
|
||||
*/
|
||||
function verifyCreditCard(accountingInfo: AccountingInfo, braintree3ds: Braintree3ds2Request, price: string): Promise<boolean> {
|
||||
return locator.entityClient.load(InvoiceInfoTypeRef, neverNull(accountingInfo.invoiceInfo)).then((invoiceInfo) => {
|
||||
let invoiceInfoWrapper = {
|
||||
invoiceInfo,
|
||||
}
|
||||
let resolve: (arg0: boolean) => void
|
||||
let progressDialogPromise: Promise<boolean> = newPromise((res) => (resolve = res))
|
||||
let progressDialog: Dialog
|
||||
|
||||
const closeAction = () => {
|
||||
// user did not complete the 3ds dialog and PaymentDataService.POST was not invoked
|
||||
progressDialog.close()
|
||||
setTimeout(() => resolve(false), DefaultAnimationTime)
|
||||
}
|
||||
|
||||
progressDialog = new Dialog(DialogType.Alert, {
|
||||
view: () => [
|
||||
m(".dialog-contentButtonsBottom.text-break.selectable", lang.get("creditCardPendingVerification_msg")),
|
||||
m(
|
||||
".flex-center.dialog-buttons",
|
||||
m(Button, {
|
||||
label: "cancel_action",
|
||||
click: closeAction,
|
||||
type: ButtonType.Primary,
|
||||
}),
|
||||
),
|
||||
],
|
||||
})
|
||||
.setCloseHandler(closeAction)
|
||||
.addShortcut({
|
||||
key: Keys.RETURN,
|
||||
shift: false,
|
||||
exec: closeAction,
|
||||
help: "close_alt",
|
||||
})
|
||||
.addShortcut({
|
||||
key: Keys.ESC,
|
||||
shift: false,
|
||||
exec: closeAction,
|
||||
help: "close_alt",
|
||||
})
|
||||
let entityEventListener: EntityEventsListener = (updates: ReadonlyArray<EntityUpdateData>, eventOwnerGroupId: Id) => {
|
||||
return promiseMap(updates, (update) => {
|
||||
if (isUpdateForTypeRef(InvoiceInfoTypeRef, update)) {
|
||||
return locator.entityClient.load(InvoiceInfoTypeRef, update.instanceId).then((invoiceInfo) => {
|
||||
invoiceInfoWrapper.invoiceInfo = invoiceInfo
|
||||
if (!invoiceInfo.paymentErrorInfo) {
|
||||
// user successfully verified the card
|
||||
progressDialog.close()
|
||||
resolve(true)
|
||||
} else if (invoiceInfo.paymentErrorInfo && invoiceInfo.paymentErrorInfo.errorCode === "card.3ds2_pending") {
|
||||
// keep waiting. this error code is set before starting the 3DS2 verification and we just received the event very late
|
||||
} else if (invoiceInfo.paymentErrorInfo && invoiceInfo.paymentErrorInfo.errorCode !== null) {
|
||||
// verification error during 3ds verification
|
||||
let error = "3dsFailedOther"
|
||||
|
||||
switch (invoiceInfo.paymentErrorInfo.errorCode as PaymentErrorCode) {
|
||||
case "card.cvv_invalid":
|
||||
error = "cvvInvalid"
|
||||
break
|
||||
case "card.number_invalid":
|
||||
error = "ccNumberInvalid"
|
||||
break
|
||||
|
||||
case "card.date_invalid":
|
||||
error = "expirationDate"
|
||||
break
|
||||
case "card.insufficient_funds":
|
||||
error = "insufficientFunds"
|
||||
break
|
||||
case "card.expired_card":
|
||||
error = "cardExpired"
|
||||
break
|
||||
case "card.3ds2_failed":
|
||||
error = "3dsFailed"
|
||||
break
|
||||
}
|
||||
|
||||
Dialog.message(getPreconditionFailedPaymentMsg(invoiceInfo.paymentErrorInfo.errorCode))
|
||||
resolve(false)
|
||||
progressDialog.close()
|
||||
}
|
||||
|
||||
m.redraw()
|
||||
})
|
||||
}
|
||||
}).then(noOp)
|
||||
}
|
||||
|
||||
locator.eventController.addEntityListener(entityEventListener)
|
||||
const app = client.isCalendarApp() ? "calendar" : "mail"
|
||||
let params = `clientToken=${encodeURIComponent(braintree3ds.clientToken)}&nonce=${encodeURIComponent(braintree3ds.nonce)}&bin=${encodeURIComponent(
|
||||
braintree3ds.bin,
|
||||
)}&price=${encodeURIComponent(price)}&message=${encodeURIComponent(lang.get("creditCardVerification_msg"))}&clientType=${getClientType()}&app=${app}`
|
||||
Dialog.message("creditCardVerificationNeededPopup_msg").then(() => {
|
||||
const paymentUrlString = locator.domainConfigProvider().getCurrentDomainConfig().paymentUrl
|
||||
const paymentUrl = new URL(paymentUrlString)
|
||||
paymentUrl.hash += params
|
||||
window.open(paymentUrl)
|
||||
progressDialog.show()
|
||||
})
|
||||
return progressDialogPromise.finally(() => locator.eventController.removeEntityListener(entityEventListener))
|
||||
})
|
||||
}
|
||||
|
||||
export async function updatePaymentData(
|
||||
paymentInterval: PaymentInterval,
|
||||
invoiceData: InvoiceData,
|
||||
paymentData: PaymentData | null,
|
||||
confirmedCountry: Country | null,
|
||||
isSignup: boolean,
|
||||
price: string | null,
|
||||
accountingInfo: AccountingInfo,
|
||||
): Promise<boolean> {
|
||||
const paymentResult = await locator.customerFacade.updatePaymentData(paymentInterval, invoiceData, paymentData, confirmedCountry)
|
||||
const statusCode = paymentResult.result
|
||||
|
||||
if (statusCode === PaymentDataResultType.OK) {
|
||||
// show dialog
|
||||
let braintree3ds = paymentResult.braintree3dsRequest
|
||||
if (braintree3ds) {
|
||||
return verifyCreditCard(accountingInfo, braintree3ds, price!)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
} else if (statusCode === PaymentDataResultType.COUNTRY_MISMATCH) {
|
||||
const countryName = invoiceData.country ? invoiceData.country.n : ""
|
||||
const confirmMessage = lang.getTranslation("confirmCountry_msg", {
|
||||
"{1}": countryName,
|
||||
})
|
||||
const confirmed = await Dialog.confirm(confirmMessage)
|
||||
if (confirmed) {
|
||||
return updatePaymentData(paymentInterval, invoiceData, paymentData, invoiceData.country, isSignup, price, accountingInfo) // add confirmed invoice country
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else if (statusCode === PaymentDataResultType.INVALID_VATID_NUMBER) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation("invalidVatIdNumber_msg", lang.get("invalidVatIdNumber_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : "")),
|
||||
)
|
||||
} else if (statusCode === PaymentDataResultType.CREDIT_CARD_DECLINED) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation("creditCardDeclined_msg", lang.get("creditCardDeclined_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : "")),
|
||||
)
|
||||
} else if (statusCode === PaymentDataResultType.CREDIT_CARD_CVV_INVALID) {
|
||||
await Dialog.message("creditCardCVVInvalid_msg")
|
||||
} else if (statusCode === PaymentDataResultType.PAYMENT_PROVIDER_NOT_AVAILABLE) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation(
|
||||
"paymentProviderNotAvailableError_msg",
|
||||
lang.get("paymentProviderNotAvailableError_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : ""),
|
||||
),
|
||||
)
|
||||
} else if (statusCode === PaymentDataResultType.OTHER_PAYMENT_ACCOUNT_REJECTED) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation(
|
||||
"paymentAccountRejected_msg",
|
||||
lang.get("paymentAccountRejected_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : ""),
|
||||
),
|
||||
)
|
||||
} else if (statusCode === PaymentDataResultType.CREDIT_CARD_DATE_INVALID) {
|
||||
await Dialog.message("creditCardExprationDateInvalid_msg")
|
||||
} else if (statusCode === PaymentDataResultType.CREDIT_CARD_NUMBER_INVALID) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation(
|
||||
"creditCardNumberInvalid_msg",
|
||||
lang.get("creditCardNumberInvalid_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : ""),
|
||||
),
|
||||
)
|
||||
} else if (statusCode === PaymentDataResultType.COULD_NOT_VERIFY_VATID) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation(
|
||||
"invalidVatIdValidationFailed_msg",
|
||||
lang.get("invalidVatIdValidationFailed_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : ""),
|
||||
),
|
||||
)
|
||||
} else if (statusCode === PaymentDataResultType.CREDIT_CARD_VERIFICATION_LIMIT_REACHED) {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation(
|
||||
"creditCardVerificationLimitReached_msg",
|
||||
lang.get("creditCardVerificationLimitReached_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : ""),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
await Dialog.message(
|
||||
lang.makeTranslation(
|
||||
"otherPaymentProviderError_msg",
|
||||
lang.get("otherPaymentProviderError_msg") + (isSignup ? " " + lang.get("accountWasStillCreated_msg") : ""),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function validatePaymentData({
|
||||
paymentMethod,
|
||||
country,
|
||||
|
|
|
|||
|
|
@ -391,7 +391,7 @@ export default {
|
|||
"credit_label": "Credit",
|
||||
"creditCardCardHolderName_label": "Name of the cardholder",
|
||||
"creditCardCardHolderName_msg": "Please enter the name of the cardholder.",
|
||||
"creditCardCVV_label": "Security code (CVV)",
|
||||
"creditCardCVV_label": "CVV",
|
||||
"creditCardCVVFormat_label": "Please enter the 3 or 4 digit security code (CVV).",
|
||||
"creditCardCvvHint_msg": "{currentDigits}/{totalDigits} digits",
|
||||
"creditCardCVVInvalid_msg": "Security code (CVV) is invalid.",
|
||||
|
|
@ -399,7 +399,7 @@ export default {
|
|||
"creditCardDeclined_msg": "Unfortunately, your credit card was declined. Please verify that all entered information is correct, get in contact with your bank or select a different payment method.",
|
||||
"creditCardExpirationDate_label": "Expiration date",
|
||||
"creditCardExpirationDateFormat_msg": "Format: MM/YYYY",
|
||||
"creditCardExpirationDateWithFormat_label": "Expiration date (MM/YY or MM/YYYY)",
|
||||
"creditCardExpirationDateWithFormat_label": "MM/YY",
|
||||
"creditCardExpired_msg": "This credit card is expired",
|
||||
"creditCardExprationDateInvalid_msg": "Expiration date is invalid.",
|
||||
"creditCardHintWithError_msg": "{hint} - {errorText}",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue