From b62040d99d1a91a2245766e8013c79f91a3a7331 Mon Sep 17 00:00:00 2001 From: toj Date: Fri, 5 Dec 2025 18:02:02 +0100 Subject: [PATCH] wip: initial payment page implementation --- src/common/signup/SignupPaymentPage.ts | 41 +- .../signup/components/CreditCardInputForm.ts | 122 ++++++ .../InvoiceAndPaymentDataPageNew.ts | 122 ++++++ .../InvoiceAndPaymentDataPageNew.ts | 399 ------------------ src/common/subscription/utils/PaymentUtils.ts | 214 +++++++++- src/mail-app/translations/en.ts | 4 +- 6 files changed, 488 insertions(+), 414 deletions(-) create mode 100644 src/common/signup/components/CreditCardInputForm.ts create mode 100644 src/common/signup/components/InvoiceAndPaymentDataPageNew.ts delete mode 100644 src/common/subscription/InvoiceAndPaymentDataPageNew.ts diff --git a/src/common/signup/SignupPaymentPage.ts b/src/common/signup/SignupPaymentPage.ts index 479999ae8f..6b097476d1 100644 --- a/src/common/signup/SignupPaymentPage.ts +++ b/src/common/signup/SignupPaymentPage.ts @@ -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> { + private currentOption = 0 view(vnode: Vnode>) { 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> = [ { - 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), + ]) } } diff --git a/src/common/signup/components/CreditCardInputForm.ts b/src/common/signup/components/CreditCardInputForm.ts new file mode 100644 index 0000000000..c9a82765c4 --- /dev/null +++ b/src/common/signup/components/CreditCardInputForm.ts @@ -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 { + dateFieldLeft: boolean = false + numberFieldLeft: boolean = false + cvvFieldLeft: boolean = false + ccNumberDom: HTMLInputElement | null = null + expDateDom: HTMLInputElement | null = null + + view(vnode: Vnode): 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") + } + } +} diff --git a/src/common/signup/components/InvoiceAndPaymentDataPageNew.ts b/src/common/signup/components/InvoiceAndPaymentDataPageNew.ts new file mode 100644 index 0000000000..4ea7def3f0 --- /dev/null +++ b/src/common/signup/components/InvoiceAndPaymentDataPageNew.ts @@ -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 +} +/** + * Wizard page for editing invoice and payment data. + */ +export class InvoiceAndPaymentDataPageNew implements Component> { + private _invoiceDataInput: InvoiceDataInput | null = null + private _hasClickedNext: boolean = false + private ccViewModel: SimplifiedCreditCardViewModel + private isCreditCardValid: stream = stream(false) + private ctx: WizardStepContext + + constructor(vnode: Vnode>) { + this.ccViewModel = new SimplifiedCreditCardViewModel(lang) + this.ctx = vnode.attrs + } + + oncreate(vnode: VnodeDOM>) { + 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>): 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) + } +} diff --git a/src/common/subscription/InvoiceAndPaymentDataPageNew.ts b/src/common/subscription/InvoiceAndPaymentDataPageNew.ts deleted file mode 100644 index 1c7638b00b..0000000000 --- a/src/common/subscription/InvoiceAndPaymentDataPageNew.ts +++ /dev/null @@ -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 -} -/** - * Wizard page for editing invoice and payment data. - */ -export class InvoiceAndPaymentDataPageNew implements Component> { - private _invoiceDataInput: InvoiceDataInput | null = null - private _availablePaymentMethods: Array> | null = null - private _selectedPaymentMethod: Stream = stream(PaymentMethodType.CreditCard) - private dom!: HTMLElement - private _hasClickedNext: boolean = false - private ccViewModel: SimplifiedCreditCardViewModel - // private _entityEventListener: EntityEventsListener - private paypalRequestUrl: LazyLoaded - private isCreditCardValid: stream = stream(false) - private isPaypalLinked: stream = stream(false) - private ctx: WizardStepContext - - constructor(vnode: Vnode>) { - this.ccViewModel = new SimplifiedCreditCardViewModel(lang) - this.paypalRequestUrl = getLazyLoadedPayPalUrl() - this.ctx = vnode.attrs - } - - oncreate(vnode: VnodeDOM>) { - 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>): 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 { - 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 { - return locator.entityClient.load(InvoiceInfoTypeRef, neverNull(accountingInfo.invoiceInfo)).then((invoiceInfo) => { - let invoiceInfoWrapper = { - invoiceInfo, - } - let resolve: (arg0: boolean) => void - let progressDialogPromise: Promise = 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, 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)) - }) -} diff --git a/src/common/subscription/utils/PaymentUtils.ts b/src/common/subscription/utils/PaymentUtils.ts index 9482725c4b..33115b34d6 100644 --- a/src/common/subscription/utils/PaymentUtils.ts +++ b/src/common/subscription/utils/PaymentUtils.ts @@ -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 { + return locator.entityClient.load(InvoiceInfoTypeRef, neverNull(accountingInfo.invoiceInfo)).then((invoiceInfo) => { + let invoiceInfoWrapper = { + invoiceInfo, + } + let resolve: (arg0: boolean) => void + let progressDialogPromise: Promise = 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, 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 { + 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, diff --git a/src/mail-app/translations/en.ts b/src/mail-app/translations/en.ts index 6d2412d35f..ca3f39bf16 100644 --- a/src/mail-app/translations/en.ts +++ b/src/mail-app/translations/en.ts @@ -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}",