wip: initial payment page implementation

This commit is contained in:
toj 2025-12-05 18:02:02 +01:00
parent f336c3da23
commit b62040d99d
6 changed files with 488 additions and 414 deletions

View file

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

View 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")
}
}
}

View 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)
}
}

View file

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

View file

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

View file

@ -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}",