From a53b1e9ffb38cedd71ae90a0fe61eb814e84b152 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 10 Jul 2025 08:32:40 -0500 Subject: [PATCH] [PM-21881] Manage payment details outside of checkout (#15458) * Add billable-entity * Add payment types * Add billing.client * Update stripe.service * Add payment method components * Add address.pipe * Add billing address components * Add account credit components * Add component index * Add feature flag * Re-work organization warnings code * Add organization-payment-details.component * Backfill translations * Set up organization FF routing * Add account-payment-details.component * Set up account FF routing * Add provider-payment-details.component * Set up provider FF routing * Use inline component templates for re-usable payment components * Remove errant rebase file * Removed public accessibility modifier * Fix failing test --- .../collections/vault.component.html | 8 +- .../collections/vault.component.ts | 21 +- .../organization-layout.component.html | 6 +- .../layouts/organization-layout.component.ts | 15 + .../individual-billing-routing.module.ts | 7 + .../account-payment-details.component.html | 26 + .../account-payment-details.component.ts | 116 ++ .../individual/subscription.component.html | 5 +- .../individual/subscription.component.ts | 20 +- .../organization-billing-routing.module.ts | 12 + ...rganization-payment-details.component.html | 41 + .../organization-payment-details.component.ts | 187 +++ .../organization-payment-method.component.ts | 20 +- .../add-account-credit-dialog.component.ts | 241 ++++ .../change-payment-method-dialog.component.ts | 113 ++ .../display-account-credit.component.ts | 63 + .../display-billing-address.component.ts | 56 + .../display-payment-method.component.ts | 107 ++ .../edit-billing-address-dialog.component.ts | 147 +++ .../enter-billing-address.component.ts | 194 +++ .../enter-payment-method.component.ts | 408 ++++++ .../app/billing/payment/components/index.ts | 9 + .../verify-bank-account.component.ts | 86 ++ .../payment/pipes/address.pipe.spec.ts | 65 + .../app/billing/payment/pipes/address.pipe.ts | 32 + .../src/app/billing/payment/pipes/index.ts | 1 + .../billing/payment/types/billing-address.ts | 37 + .../src/app/billing/payment/types/index.ts | 6 + .../payment/types/masked-payment-method.ts | 114 ++ .../payment/types/selectable-country.ts | 259 ++++ .../app/billing/payment/types/tax-id-type.ts | 1123 +++++++++++++++++ .../src/app/billing/payment/types/tax-id.ts | 18 + .../payment/types/tokenized-payment-method.ts | 22 + .../app/billing/services/billing.client.ts | 153 +++ apps/web/src/app/billing/services/index.ts | 1 + .../app/billing/services/stripe.service.ts | 124 +- .../billing/services/trial-flow.service.ts | 9 +- .../shared/payment-method.component.ts | 11 + .../src/app/billing/types/billable-entity.ts | 42 + apps/web/src/app/billing/types/index.ts | 2 + .../app/billing/warnings/components/index.ts | 2 + ...anization-free-trial-warning.component.ts} | 22 +- ...ion-reseller-renewal-warning.component.ts} | 18 +- .../app/billing/warnings/services/index.ts | 1 + .../organization-warnings.service.spec.ts | 0 .../services/organization-warnings.service.ts | 73 +- .../src/app/billing/warnings/types/index.ts | 1 + .../warnings/types/organization-warnings.ts | 11 + .../vault-banners.component.spec.ts | 5 + .../vault-banners/vault-banners.component.ts | 11 +- apps/web/src/locales/en/messages.json | 48 + .../providers/providers-layout.component.html | 6 + .../providers/providers-layout.component.ts | 8 + .../providers/providers-routing.module.ts | 9 + .../provider-payment-details.component.html | 33 + .../provider-payment-details.component.ts | 133 ++ .../provider-subscription.component.html | 88 +- .../provider-subscription.component.ts | 7 + .../overview/overview.component.ts | 14 +- libs/common/src/enums/feature-flag.enum.ts | 2 + 60 files changed, 4268 insertions(+), 151 deletions(-) create mode 100644 apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html create mode 100644 apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts create mode 100644 apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html create mode 100644 apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts create mode 100644 apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts create mode 100644 apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts create mode 100644 apps/web/src/app/billing/payment/components/display-account-credit.component.ts create mode 100644 apps/web/src/app/billing/payment/components/display-billing-address.component.ts create mode 100644 apps/web/src/app/billing/payment/components/display-payment-method.component.ts create mode 100644 apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts create mode 100644 apps/web/src/app/billing/payment/components/enter-billing-address.component.ts create mode 100644 apps/web/src/app/billing/payment/components/enter-payment-method.component.ts create mode 100644 apps/web/src/app/billing/payment/components/index.ts create mode 100644 apps/web/src/app/billing/payment/components/verify-bank-account.component.ts create mode 100644 apps/web/src/app/billing/payment/pipes/address.pipe.spec.ts create mode 100644 apps/web/src/app/billing/payment/pipes/address.pipe.ts create mode 100644 apps/web/src/app/billing/payment/pipes/index.ts create mode 100644 apps/web/src/app/billing/payment/types/billing-address.ts create mode 100644 apps/web/src/app/billing/payment/types/index.ts create mode 100644 apps/web/src/app/billing/payment/types/masked-payment-method.ts create mode 100644 apps/web/src/app/billing/payment/types/selectable-country.ts create mode 100644 apps/web/src/app/billing/payment/types/tax-id-type.ts create mode 100644 apps/web/src/app/billing/payment/types/tax-id.ts create mode 100644 apps/web/src/app/billing/payment/types/tokenized-payment-method.ts create mode 100644 apps/web/src/app/billing/services/billing.client.ts create mode 100644 apps/web/src/app/billing/types/billable-entity.ts create mode 100644 apps/web/src/app/billing/types/index.ts create mode 100644 apps/web/src/app/billing/warnings/components/index.ts rename apps/web/src/app/billing/warnings/{free-trial-warning.component.ts => components/organization-free-trial-warning.component.ts} (68%) rename apps/web/src/app/billing/warnings/{reseller-renewal-warning.component.ts => components/organization-reseller-renewal-warning.component.ts} (63%) create mode 100644 apps/web/src/app/billing/warnings/services/index.ts rename apps/web/src/app/billing/{ => warnings}/services/organization-warnings.service.spec.ts (100%) rename apps/web/src/app/billing/{ => warnings}/services/organization-warnings.service.ts (78%) create mode 100644 apps/web/src/app/billing/warnings/types/index.ts create mode 100644 apps/web/src/app/billing/warnings/types/organization-warnings.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.html create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.html b/apps/web/src/app/admin-console/organizations/collections/vault.component.html index e8782ca0f2d..ddfcda04c76 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.html +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.html @@ -1,14 +1,14 @@ - - - + - + - + @let paymentDetailsPageData = paymentDetailsPageData$ | async; + diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index dc1913a5336..89f62ed8975 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -64,6 +64,11 @@ export class OrganizationLayoutComponent implements OnInit { protected showSponsoredFamiliesDropdown$: Observable; protected canShowPoliciesTab$: Observable; + protected paymentDetailsPageData$: Observable<{ + route: string; + textKey: string; + }>; + constructor( private route: ActivatedRoute, private organizationService: OrganizationService, @@ -135,6 +140,16 @@ export class OrganizationLayoutComponent implements OnInit { ), ), ); + + this.paymentDetailsPageData$ = this.configService + .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) + .pipe( + map((managePaymentDetailsOutsideCheckout) => + managePaymentDetailsOutsideCheckout + ? { route: "billing/payment-details", textKey: "paymentDetails" } + : { route: "billing/payment-method", textKey: "paymentMethod" }, + ), + ); } canShowVaultTab(organization: Organization): boolean { diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index bb1ada0b719..87b342ed997 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -1,6 +1,8 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component"; + import { PaymentMethodComponent } from "../shared"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; @@ -30,6 +32,11 @@ const routes: Routes = [ component: PaymentMethodComponent, data: { titleId: "paymentMethod" }, }, + { + path: "payment-details", + component: AccountPaymentDetailsComponent, + data: { titleId: "paymentDetails" }, + }, { path: "billing-history", component: BillingHistoryViewComponent, diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html new file mode 100644 index 00000000000..c10590d8b1b --- /dev/null +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html @@ -0,0 +1,26 @@ + + @let view = view$ | async; + @if (!view) { + + + {{ "loading" | i18n }} + + } @else { + + + + + + } + diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts new file mode 100644 index 00000000000..4a4d0f60c0b --- /dev/null +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts @@ -0,0 +1,116 @@ +import { Component } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + BehaviorSubject, + EMPTY, + filter, + from, + map, + merge, + Observable, + shareReplay, + switchMap, + tap, +} from "rxjs"; +import { catchError } from "rxjs/operators"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +import { HeaderModule } from "../../../layouts/header/header.module"; +import { SharedModule } from "../../../shared"; +import { + DisplayAccountCreditComponent, + DisplayPaymentMethodComponent, +} from "../../payment/components"; +import { MaskedPaymentMethod } from "../../payment/types"; +import { BillingClient } from "../../services"; +import { accountToBillableEntity, BillableEntity } from "../../types"; + +class RedirectError { + constructor( + public path: string[], + public relativeTo: ActivatedRoute, + ) {} +} + +type View = { + account: BillableEntity; + paymentMethod: MaskedPaymentMethod | null; + credit: number | null; +}; + +@Component({ + templateUrl: "./account-payment-details.component.html", + standalone: true, + imports: [ + DisplayAccountCreditComponent, + DisplayPaymentMethodComponent, + HeaderModule, + SharedModule, + ], + providers: [BillingClient], +}) +export class AccountPaymentDetailsComponent { + private viewState$ = new BehaviorSubject(null); + + private load$: Observable = this.accountService.activeAccount$.pipe( + switchMap((account) => + this.configService + .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) + .pipe( + map((managePaymentDetailsOutsideCheckout) => { + if (!managePaymentDetailsOutsideCheckout) { + throw new RedirectError(["../payment-method"], this.activatedRoute); + } + return account; + }), + ), + ), + accountToBillableEntity, + switchMap(async (account) => { + const [paymentMethod, credit] = await Promise.all([ + this.billingClient.getPaymentMethod(account), + this.billingClient.getCredit(account), + ]); + + return { + account, + paymentMethod, + credit, + }; + }), + shareReplay({ bufferSize: 1, refCount: false }), + catchError((error: unknown) => { + if (error instanceof RedirectError) { + return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( + switchMap(() => EMPTY), + ); + } + throw error; + }), + ); + + view$: Observable = merge( + this.load$.pipe(tap((view) => this.viewState$.next(view))), + this.viewState$.pipe(filter((view): view is View => view !== null)), + ).pipe(shareReplay({ bufferSize: 1, refCount: true })); + + constructor( + private accountService: AccountService, + private activatedRoute: ActivatedRoute, + private billingClient: BillingClient, + private configService: ConfigService, + private router: Router, + ) {} + + setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => { + if (this.viewState$.value) { + this.viewState$.next({ + ...this.viewState$.value, + paymentMethod, + }); + } + }; +} diff --git a/apps/web/src/app/billing/individual/subscription.component.html b/apps/web/src/app/billing/individual/subscription.component.html index 934a24570f4..fa2eb0412a9 100644 --- a/apps/web/src/app/billing/individual/subscription.component.html +++ b/apps/web/src/app/billing/individual/subscription.component.html @@ -3,7 +3,10 @@ {{ "subscription" | i18n }} - {{ "paymentMethod" | i18n }} + @let paymentMethodPageData = paymentDetailsPageData$ | async; + {{ + paymentMethodPageData.textKey | i18n + }} {{ "billingHistory" | i18n }} diff --git a/apps/web/src/app/billing/individual/subscription.component.ts b/apps/web/src/app/billing/individual/subscription.component.ts index 2a08ec85127..c6a20a9f6a3 100644 --- a/apps/web/src/app/billing/individual/subscription.component.ts +++ b/apps/web/src/app/billing/individual/subscription.component.ts @@ -1,10 +1,12 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; -import { Observable, switchMap } from "rxjs"; +import { map, Observable, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @Component({ @@ -13,16 +15,32 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl }) export class SubscriptionComponent implements OnInit { hasPremium$: Observable; + paymentDetailsPageData$: Observable<{ + route: string; + textKey: string; + }>; + selfHosted: boolean; constructor( private platformUtilsService: PlatformUtilsService, billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, + private configService: ConfigService, ) { this.hasPremium$ = accountService.activeAccount$.pipe( switchMap((account) => billingAccountProfileStateService.hasPremiumPersonally$(account.id)), ); + + this.paymentDetailsPageData$ = this.configService + .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) + .pipe( + map((managePaymentDetailsOutsideCheckout) => + managePaymentDetailsOutsideCheckout + ? { route: "payment-details", textKey: "paymentDetails" } + : { route: "payment-method", textKey: "paymentMethod" }, + ), + ); } ngOnInit() { diff --git a/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts b/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts index 1bfb9fc4912..692791db855 100644 --- a/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { canAccessBillingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/organizations/payment-details/organization-payment-details.component"; import { organizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard"; import { organizationIsUnmanaged } from "../../billing/guards/organization-is-unmanaged.guard"; @@ -36,6 +37,17 @@ const routes: Routes = [ titleId: "paymentMethod", }, }, + { + path: "payment-details", + component: OrganizationPaymentDetailsComponent, + canActivate: [ + organizationPermissionsGuard((org) => org.canEditPaymentMethods), + organizationIsUnmanaged, + ], + data: { + titleId: "paymentDetails", + }, + }, { path: "history", component: OrgBillingHistoryViewComponent, diff --git a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html new file mode 100644 index 00000000000..17f4349fdd5 --- /dev/null +++ b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html @@ -0,0 +1,41 @@ +@let organization = organization$ | async; +@if (organization) { + + +} + + + @let view = view$ | async; + @if (!view) { + + + {{ "loading" | i18n }} + + } @else { + + + + + + + + } + diff --git a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts new file mode 100644 index 00000000000..3618696f697 --- /dev/null +++ b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts @@ -0,0 +1,187 @@ +import { Component, OnInit, ViewChild } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + BehaviorSubject, + catchError, + EMPTY, + filter, + firstValueFrom, + from, + lastValueFrom, + map, + merge, + Observable, + shareReplay, + switchMap, + tap, +} from "rxjs"; + +import { + getOrganizationById, + OrganizationService, +} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { DialogService } from "@bitwarden/components"; + +import { HeaderModule } from "../../../layouts/header/header.module"; +import { SharedModule } from "../../../shared"; +import { + ChangePaymentMethodDialogComponent, + DisplayAccountCreditComponent, + DisplayBillingAddressComponent, + DisplayPaymentMethodComponent, +} from "../../payment/components"; +import { BillingAddress, MaskedPaymentMethod } from "../../payment/types"; +import { BillingClient } from "../../services"; +import { BillableEntity, organizationToBillableEntity } from "../../types"; +import { OrganizationFreeTrialWarningComponent } from "../../warnings/components"; + +class RedirectError { + constructor( + public path: string[], + public relativeTo: ActivatedRoute, + ) {} +} + +type View = { + organization: BillableEntity; + paymentMethod: MaskedPaymentMethod | null; + billingAddress: BillingAddress | null; + credit: number | null; +}; + +@Component({ + templateUrl: "./organization-payment-details.component.html", + standalone: true, + imports: [ + DisplayBillingAddressComponent, + DisplayAccountCreditComponent, + DisplayPaymentMethodComponent, + HeaderModule, + OrganizationFreeTrialWarningComponent, + SharedModule, + ], + providers: [BillingClient], +}) +export class OrganizationPaymentDetailsComponent implements OnInit { + @ViewChild(OrganizationFreeTrialWarningComponent) + organizationFreeTrialWarningComponent!: OrganizationFreeTrialWarningComponent; + + private viewState$ = new BehaviorSubject(null); + + private load$: Observable = this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + this.organizationService + .organizations$(userId) + .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), + ), + ) + .pipe( + switchMap((organization) => + this.configService + .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) + .pipe( + map((managePaymentDetailsOutsideCheckout) => { + if (!managePaymentDetailsOutsideCheckout) { + throw new RedirectError(["../payment-method"], this.activatedRoute); + } + return organization; + }), + ), + ), + organizationToBillableEntity, + switchMap(async (organization) => { + const [paymentMethod, billingAddress, credit] = await Promise.all([ + this.billingClient.getPaymentMethod(organization), + this.billingClient.getBillingAddress(organization), + this.billingClient.getCredit(organization), + ]); + + return { + organization, + paymentMethod, + billingAddress, + credit, + }; + }), + catchError((error: unknown) => { + if (error instanceof RedirectError) { + return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( + switchMap(() => EMPTY), + ); + } + throw error; + }), + ); + + view$: Observable = merge( + this.load$.pipe(tap((view) => this.viewState$.next(view))), + this.viewState$.pipe(filter((view): view is View => view !== null)), + ).pipe(shareReplay({ bufferSize: 1, refCount: true })); + + organization$ = this.view$.pipe(map((view) => view.organization.data as Organization)); + + constructor( + private accountService: AccountService, + private activatedRoute: ActivatedRoute, + private billingClient: BillingClient, + private configService: ConfigService, + private dialogService: DialogService, + private organizationService: OrganizationService, + private router: Router, + ) {} + + async ngOnInit() { + const openChangePaymentMethodDialogOnStart = + (history.state?.launchPaymentModalAutomatically as boolean) ?? false; + + if (openChangePaymentMethodDialogOnStart) { + history.replaceState({ ...history.state, launchPaymentModalAutomatically: false }, ""); + await this.changePaymentMethod(); + } + } + + changePaymentMethod = async () => { + const view = await firstValueFrom(this.view$); + const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { + data: { + owner: view.organization, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result?.type === "success") { + this.setPaymentMethod(result.paymentMethod); + if (!view.billingAddress && result.paymentMethod.type !== "payPal") { + const billingAddress = await this.billingClient.getBillingAddress(view.organization); + if (billingAddress) { + this.setBillingAddress(billingAddress); + } + } + this.organizationFreeTrialWarningComponent.refresh(); + } + }; + + setBillingAddress = (billingAddress: BillingAddress) => { + if (this.viewState$.value) { + this.viewState$.next({ + ...this.viewState$.value, + billingAddress, + }); + } + }; + + setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => { + if (this.viewState$.value) { + this.viewState$.next({ + ...this.viewState$.value, + paymentMethod, + }); + } + }; +} diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts index 36ac7debae2..9b144fe59a7 100644 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts +++ b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts @@ -4,7 +4,7 @@ import { Location } from "@angular/common"; import { Component, OnDestroy } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom, from, lastValueFrom, map, switchMap } from "rxjs"; +import { combineLatest, firstValueFrom, from, lastValueFrom, map, switchMap } from "rxjs"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { @@ -19,6 +19,8 @@ import { TaxInformation } from "@bitwarden/common/billing/models/domain"; import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; @@ -72,18 +74,28 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { private accountService: AccountService, protected syncService: SyncService, private billingNotificationService: BillingNotificationService, + private configService: ConfigService, ) { - this.activatedRoute.params + combineLatest([ + this.activatedRoute.params, + this.configService.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout), + ]) .pipe( - takeUntilDestroyed(), - switchMap(({ organizationId }) => { + switchMap(([{ organizationId }, managePaymentDetailsOutsideCheckout]) => { if (this.platformUtilsService.isSelfHost()) { return from(this.router.navigate(["/settings/subscription"])); } + if (managePaymentDetailsOutsideCheckout) { + return from( + this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute }), + ); + } + this.organizationId = organizationId; return from(this.load()); }), + takeUntilDestroyed(), ) .subscribe(); diff --git a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts new file mode 100644 index 00000000000..2030d0e73ec --- /dev/null +++ b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts @@ -0,0 +1,241 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, ElementRef, Inject, ViewChild } from "@angular/core"; +import { + AbstractControl, + FormControl, + FormGroup, + ValidationErrors, + ValidatorFn, + Validators, +} from "@angular/forms"; +import { map } from "rxjs"; + +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; +import { BillingClient } from "../../services"; +import { BillableEntity } from "../../types"; + +type DialogParams = { + owner: BillableEntity; +}; + +type DialogResult = "cancelled" | "error" | "launched"; + +type PayPalConfig = { + businessId: string; + buttonAction: string; +}; + +declare const process: { + env: { + PAYPAL_CONFIG: PayPalConfig; + }; +}; + +const positiveNumberValidator = + (message: string): ValidatorFn => + (control: AbstractControl): ValidationErrors | null => { + if (!control.value) { + return null; + } + + const value = parseFloat(control.value); + + if (isNaN(value) || value <= 0) { + return { notPositiveNumber: { message } }; + } + + return null; + }; + +@Component({ + template: ` +
+ + + {{ "addCredit" | i18n }} + +
+

{{ "creditDelayed" | i18n }}

+
+ + + PayPal + + + Bitcoin + + +
+
+ + {{ "amount" | i18n }} + + $USD + +
+
+ + + + +
+
+
+ + + + + + + + + + + + + + + +
+ `, + standalone: true, + imports: [SharedModule], + providers: [BillingClient], +}) +export class AddAccountCreditDialogComponent { + @ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm!: ElementRef; + + protected payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig; + protected redirectUrl = window.location.href; + + protected formGroup = new FormGroup({ + paymentMethod: new FormControl<"payPal" | "bitPay">("payPal"), + amount: new FormControl("0.00", [ + Validators.required, + positiveNumberValidator(this.i18nService.t("mustBePositiveNumber")), + ]), + }); + + protected payPalCustom$ = this.configService.cloudRegion$.pipe( + map((cloudRegion) => { + switch (this.dialogParams.owner.type) { + case "account": { + return `user_id=${this.dialogParams.owner.data.id},account_credit=1,region=${cloudRegion}`; + } + case "organization": { + return `organization_id=${this.dialogParams.owner.data.id},account_credit=1,region=${cloudRegion}`; + } + case "provider": { + return `provider_id=${this.dialogParams.owner.data.id},account_credit=1,region=${cloudRegion}`; + } + } + }), + ); + + constructor( + private billingClient: BillingClient, + private configService: ConfigService, + @Inject(DIALOG_DATA) private dialogParams: DialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, + ) {} + + submit = async (): Promise => { + this.formGroup.markAllAsTouched(); + + if (!this.formGroup.valid) { + return; + } + + if (this.formGroup.value.paymentMethod === "bitPay") { + const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.owner, { + amount: this.amount!, + redirectUrl: this.redirectUrl, + }); + + switch (result.type) { + case "success": { + this.platformUtilsService.launchUri(result.value); + this.dialogRef.close("launched"); + break; + } + case "error": { + this.toastService.showToast({ + variant: "error", + title: "", + message: result.message, + }); + this.dialogRef.close("error"); + break; + } + } + } + + this.payPalForm.nativeElement.submit(); + this.dialogRef.close("launched"); + }; + + formatAmount = (): void => { + if (this.formGroup.value.amount) { + const amount = parseFloat(this.formGroup.value.amount); + if (isNaN(amount)) { + this.formGroup.controls.amount.setValue(null); + } else { + this.formGroup.controls.amount.setValue(amount.toFixed(2).toString()); + } + } + }; + + get amount(): number | null { + if (this.formGroup.value.amount) { + const amount = parseFloat(this.formGroup.value.amount); + if (isNaN(amount)) { + return null; + } + return amount; + } + return null; + } + + get payPalSubject(): string { + switch (this.dialogParams.owner.type) { + case "account": { + return this.dialogParams.owner.data.email; + } + case "organization": + case "provider": { + return this.dialogParams.owner.data.name; + } + } + } + + static open = (dialogService: DialogService, dialogConfig: DialogConfig) => + dialogService.open(AddAccountCreditDialogComponent, dialogConfig); +} diff --git a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts new file mode 100644 index 00000000000..efd0055fb95 --- /dev/null +++ b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts @@ -0,0 +1,113 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject, ViewChild } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; +import { BillingClient } from "../../services"; +import { BillableEntity } from "../../types"; +import { MaskedPaymentMethod } from "../types"; + +import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; + +type DialogParams = { + owner: BillableEntity; +}; + +type DialogResult = + | { type: "cancelled" } + | { type: "error" } + | { type: "success"; paymentMethod: MaskedPaymentMethod }; + +@Component({ + template: ` +
+ + + {{ "changePaymentMethod" | i18n }} + +
+ + +
+ + + + +
+
+ `, + standalone: true, + imports: [EnterPaymentMethodComponent, SharedModule], + providers: [BillingClient], +}) +export class ChangePaymentMethodDialogComponent { + @ViewChild(EnterPaymentMethodComponent) + private enterPaymentMethodComponent!: EnterPaymentMethodComponent; + protected formGroup = EnterPaymentMethodComponent.getFormGroup(); + + constructor( + private billingClient: BillingClient, + @Inject(DIALOG_DATA) protected dialogParams: DialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + private toastService: ToastService, + ) {} + + submit = async () => { + this.formGroup.markAllAsTouched(); + + if (!this.formGroup.valid) { + return; + } + + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + const billingAddress = + this.formGroup.value.type !== "payPal" + ? this.formGroup.controls.billingAddress.getRawValue() + : null; + + const result = await this.billingClient.updatePaymentMethod( + this.dialogParams.owner, + paymentMethod, + billingAddress, + ); + + switch (result.type) { + case "success": { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("paymentMethodUpdated"), + }); + this.dialogRef.close({ + type: "success", + paymentMethod: result.value, + }); + break; + } + case "error": { + this.toastService.showToast({ + variant: "error", + title: "", + message: result.message, + }); + this.dialogRef.close({ type: "error" }); + break; + } + } + }; + + static open = (dialogService: DialogService, dialogConfig: DialogConfig) => + dialogService.open(ChangePaymentMethodDialogComponent, dialogConfig); +} diff --git a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts new file mode 100644 index 00000000000..7cbe3a27f30 --- /dev/null +++ b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts @@ -0,0 +1,63 @@ +import { CurrencyPipe } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; +import { BillingClient } from "../../services"; +import { BillableEntity } from "../../types"; + +import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.component"; + +@Component({ + selector: "app-display-account-credit", + template: ` + +

{{ "accountCredit" | i18n }}: {{ formattedCredit }}

+

{{ "availableCreditAppliedToInvoice" | i18n }}

+ +
+ `, + standalone: true, + imports: [SharedModule], + providers: [BillingClient, CurrencyPipe], +}) +export class DisplayAccountCreditComponent { + @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) credit!: number | null; + + constructor( + private billingClient: BillingClient, + private currencyPipe: CurrencyPipe, + private dialogService: DialogService, + private i18nService: I18nService, + private toastService: ToastService, + ) {} + + addAccountCredit = async () => { + if (this.owner.type !== "account") { + const billingAddress = await this.billingClient.getBillingAddress(this.owner); + if (!billingAddress) { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("billingAddressRequiredToAddCredit"), + }); + } + } + + AddAccountCreditDialogComponent.open(this.dialogService, { + data: { + owner: this.owner, + }, + }); + }; + + get formattedCredit(): string | null { + const credit = this.credit ?? 0; + return this.currencyPipe.transform(credit, "$"); + } +} diff --git a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts new file mode 100644 index 00000000000..f0a11321e5d --- /dev/null +++ b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts @@ -0,0 +1,56 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { lastValueFrom } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; +import { BillableEntity } from "../../types"; +import { AddressPipe } from "../pipes"; +import { BillingAddress } from "../types"; + +import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog.component"; + +@Component({ + selector: "app-display-billing-address", + template: ` + +

{{ "billingAddress" | i18n }}

+ @if (billingAddress) { +

{{ billingAddress | address }}

+ @if (billingAddress.taxId) { +

{{ "taxId" | i18n: billingAddress.taxId.value }}

+ } + } @else { +

{{ "noBillingAddress" | i18n }}

+ } + @let key = billingAddress ? "editBillingAddress" : "addBillingAddress"; + +
+ `, + standalone: true, + imports: [AddressPipe, SharedModule], +}) +export class DisplayBillingAddressComponent { + @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) billingAddress!: BillingAddress | null; + @Output() updated = new EventEmitter(); + + constructor(private dialogService: DialogService) {} + + editBillingAddress = async (): Promise => { + const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, { + data: { + owner: this.owner, + billingAddress: this.billingAddress, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if (result?.type === "success") { + this.updated.emit(result.billingAddress); + } + }; +} diff --git a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts new file mode 100644 index 00000000000..769472bcfcf --- /dev/null +++ b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts @@ -0,0 +1,107 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { lastValueFrom } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; +import { BillableEntity } from "../../types"; +import { MaskedPaymentMethod } from "../types"; + +import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component"; +import { VerifyBankAccountComponent } from "./verify-bank-account.component"; + +@Component({ + selector: "app-display-payment-method", + template: ` + +

{{ "paymentMethod" | i18n }}

+ @if (paymentMethod) { + @switch (paymentMethod.type) { + @case ("bankAccount") { + @if (!paymentMethod.verified) { + + + } + +

+ + {{ paymentMethod.bankName }}, *{{ paymentMethod.last4 }} + @if (!paymentMethod.verified) { + - {{ "unverified" | i18n }} + } +

+ } + @case ("card") { +

+ @let brandIcon = getBrandIconForCard(); + @if (brandIcon !== null) { + + } @else { + + } + {{ paymentMethod.brand | titlecase }}, *{{ paymentMethod.last4 }}, + {{ paymentMethod.expiration }} +

+ } + @case ("payPal") { +

+ + {{ paymentMethod.email }} +

+ } + } + } @else { +

{{ "noPaymentMethod" | i18n }}

+ } + @let key = paymentMethod ? "changePaymentMethod" : "addPaymentMethod"; + +
+ `, + standalone: true, + imports: [SharedModule, VerifyBankAccountComponent], +}) +export class DisplayPaymentMethodComponent { + @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null; + @Output() updated = new EventEmitter(); + + protected availableCardIcons: Record = { + amex: "card-amex", + diners: "card-diners-club", + discover: "card-discover", + jcb: "card-jcb", + mastercard: "card-mastercard", + unionpay: "card-unionpay", + visa: "card-visa", + }; + + constructor(private dialogService: DialogService) {} + + changePaymentMethod = async (): Promise => { + const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { + data: { + owner: this.owner, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if (result?.type === "success") { + this.updated.emit(result.paymentMethod); + } + }; + + onBankAccountVerified = (paymentMethod: MaskedPaymentMethod) => this.updated.emit(paymentMethod); + + protected getBrandIconForCard = (): string | null => { + if (this.paymentMethod?.type !== "card") { + return null; + } + + return this.paymentMethod.brand in this.availableCardIcons + ? this.availableCardIcons[this.paymentMethod.brand] + : null; + }; +} diff --git a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts new file mode 100644 index 00000000000..c844d08df58 --- /dev/null +++ b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts @@ -0,0 +1,147 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; + +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; +import { BillingClient } from "../../services"; +import { BillableEntity } from "../../types"; +import { BillingAddress, getTaxIdTypeForCountry } from "../types"; + +import { EnterBillingAddressComponent } from "./enter-billing-address.component"; + +type DialogParams = { + owner: BillableEntity; + billingAddress: BillingAddress | null; +}; + +type DialogResult = + | { type: "cancelled" } + | { type: "error" } + | { type: "success"; billingAddress: BillingAddress }; + +@Component({ + template: ` +
+ + + {{ "editBillingAddress" | i18n }} + +
+ +
+ + + + +
+
+ `, + standalone: true, + imports: [EnterBillingAddressComponent, SharedModule], + providers: [BillingClient], +}) +export class EditBillingAddressDialogComponent { + protected formGroup = EnterBillingAddressComponent.getFormGroup(); + + constructor( + private billingClient: BillingClient, + @Inject(DIALOG_DATA) protected dialogParams: DialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + private toastService: ToastService, + ) { + if (dialogParams.billingAddress) { + this.formGroup.patchValue({ + ...dialogParams.billingAddress, + taxId: dialogParams.billingAddress.taxId?.value, + }); + } + } + + submit = async (): Promise => { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + return; + } + + const { taxId, ...addressFields } = this.formGroup.getRawValue(); + + const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country) : null; + + const billingAddress = taxIdType + ? { ...addressFields, taxId: { code: taxIdType.code, value: taxId! } } + : { ...addressFields, taxId: null }; + + const result = await this.billingClient.updateBillingAddress( + this.dialogParams.owner, + billingAddress, + ); + + switch (result.type) { + case "success": { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("billingAddressUpdated"), + }); + this.dialogRef.close({ + type: "success", + billingAddress: result.value, + }); + break; + } + case "error": { + this.toastService.showToast({ + variant: "error", + title: "", + message: result.message, + }); + this.dialogRef.close({ + type: "error", + }); + break; + } + } + }; + + get supportsTaxId(): boolean { + switch (this.dialogParams.owner.type) { + case "account": { + return false; + } + case "organization": { + return [ + ProductTierType.TeamsStarter, + ProductTierType.Teams, + ProductTierType.Enterprise, + ].includes(this.dialogParams.owner.data.productTierType); + } + case "provider": { + return true; + } + } + } + + static open = (dialogService: DialogService, dialogConfig: DialogConfig) => + dialogService.open(EditBillingAddressDialogComponent, dialogConfig); +} diff --git a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts new file mode 100644 index 00000000000..0419828d8ba --- /dev/null +++ b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts @@ -0,0 +1,194 @@ +import { Component, Input, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { map, Observable, startWith, Subject, takeUntil } from "rxjs"; + +import { ControlsOf } from "@bitwarden/angular/types/controls-of"; + +import { SharedModule } from "../../../shared"; +import { BillingAddress, selectableCountries, taxIdTypes } from "../types"; + +export interface BillingAddressControls { + country: string; + postalCode: string; + line1: string | null; + line2: string | null; + city: string | null; + state: string | null; + taxId: string | null; +} + +export type BillingAddressFormGroup = FormGroup>; + +type Scenario = + | { + type: "checkout"; + supportsTaxId: boolean; + } + | { + type: "update"; + existing?: BillingAddress; + supportsTaxId: boolean; + }; + +@Component({ + selector: "app-enter-billing-address", + template: ` +
+
+
+ + {{ "country" | i18n }} + + @for (selectableCountry of selectableCountries; track selectableCountry.value) { + + } + + +
+
+ + {{ "zipPostalCode" | i18n }} + + +
+
+ + {{ "address1" | i18n }} + + +
+
+ + {{ "address2" | i18n }} + + +
+
+ + {{ "cityTown" | i18n }} + + +
+
+ + {{ "stateProvince" | i18n }} + + +
+ @if (supportsTaxId$ | async) { +
+ + {{ "taxIdNumber" | i18n }} + + +
+ } +
+
+ `, + standalone: true, + imports: [SharedModule], +}) +export class EnterBillingAddressComponent implements OnInit, OnDestroy { + @Input({ required: true }) scenario!: Scenario; + @Input({ required: true }) group!: BillingAddressFormGroup; + + protected selectableCountries = selectableCountries; + protected supportsTaxId$!: Observable; + + private destroy$ = new Subject(); + + ngOnInit() { + switch (this.scenario.type) { + case "checkout": { + this.disableAddressControls(); + break; + } + case "update": { + if (this.scenario.existing) { + this.group.patchValue({ + ...this.scenario.existing, + taxId: this.scenario.existing.taxId?.value, + }); + } + } + } + + this.supportsTaxId$ = this.group.controls.country.valueChanges.pipe( + startWith(this.group.value.country ?? this.selectableCountries[0].value), + map((country) => { + if (!this.scenario.supportsTaxId) { + return false; + } + + return taxIdTypes.filter((taxIdType) => taxIdType.iso === country).length > 0; + }), + ); + + this.supportsTaxId$.pipe(takeUntil(this.destroy$)).subscribe((supportsTaxId) => { + if (supportsTaxId) { + this.group.controls.taxId.enable(); + } else { + this.group.controls.taxId.disable(); + } + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + disableAddressControls = () => { + this.group.controls.line1.disable(); + this.group.controls.line2.disable(); + this.group.controls.city.disable(); + this.group.controls.state.disable(); + }; + + static getFormGroup = (): BillingAddressFormGroup => + new FormGroup({ + country: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + postalCode: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + line1: new FormControl(null), + line2: new FormControl(null), + city: new FormControl(null), + state: new FormControl(null), + taxId: new FormControl(null), + }); +} diff --git a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts new file mode 100644 index 00000000000..4f5b2e3b15c --- /dev/null +++ b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts @@ -0,0 +1,408 @@ +import { Component, Input, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { BehaviorSubject, startWith, Subject, takeUntil } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PopoverModule, ToastService } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; +import { BillingServicesModule, BraintreeService, StripeService } from "../../services"; +import { PaymentLabelComponent } from "../../shared/payment/payment-label.component"; +import { + isTokenizablePaymentMethod, + selectableCountries, + TokenizablePaymentMethod, + TokenizedPaymentMethod, +} from "../types"; + +type PaymentMethodOption = TokenizablePaymentMethod | "accountCredit"; + +type PaymentMethodFormGroup = FormGroup<{ + type: FormControl; + bankAccount: FormGroup<{ + routingNumber: FormControl; + accountNumber: FormControl; + accountHolderName: FormControl; + accountHolderType: FormControl<"" | "company" | "individual">; + }>; + billingAddress: FormGroup<{ + country: FormControl; + postalCode: FormControl; + }>; +}>; + +@Component({ + selector: "app-enter-payment-method", + template: ` + @let showBillingDetails = includeBillingAddress && selected !== "payPal"; +
+ @if (showBillingDetails) { +
{{ "paymentMethod" | i18n }}
+ } +
+ + + + + {{ "creditCard" | i18n }} + + + @if (showBankAccount) { + + + + {{ "bankAccount" | i18n }} + + + } + @if (showPayPal) { + + + + {{ "payPal" | i18n }} + + + } + @if (showAccountCredit) { + + + + {{ "accountCredit" | i18n }} + + + } + +
+ @switch (selected) { + @case ("card") { +
+
+ + {{ "number" | i18n }} + +
+
+
+ Visa, MasterCard, Discover, AmEx, JCB, Diners Club, UnionPay +
+
+ + {{ "expiration" | i18n }} + +
+
+
+ + {{ "securityCodeSlashCVV" | i18n }} + + +

{{ "cardSecurityCodeDescription" | i18n }}

+
+
+
+
+
+ } + @case ("bankAccount") { + + + {{ "verifyBankAccountWarning" | i18n }} + +
+ + {{ "routingNumber" | i18n }} + + + + {{ "accountNumber" | i18n }} + + + + {{ "accountHolderName" | i18n }} + + + + {{ "bankAccountType" | i18n }} + + + + + + +
+
+ } + @case ("payPal") { + +
+
+ {{ "paypalClickSubmit" | i18n }} +
+
+ } + @case ("accountCredit") { + + + {{ "makeSureEnoughCredit" | i18n }} + + + } + } + @if (showBillingDetails) { +
{{ "billingAddress" | i18n }}
+
+
+ + {{ "country" | i18n }} + + @for (selectableCountry of selectableCountries; track selectableCountry.value) { + + } + + +
+
+ + {{ "zipPostalCode" | i18n }} + + +
+
+ } +
+ `, + standalone: true, + imports: [BillingServicesModule, PaymentLabelComponent, PopoverModule, SharedModule], +}) +export class EnterPaymentMethodComponent implements OnInit { + @Input({ required: true }) group!: PaymentMethodFormGroup; + + private showBankAccountSubject = new BehaviorSubject(true); + showBankAccount$ = this.showBankAccountSubject.asObservable(); + @Input() + set showBankAccount(value: boolean) { + this.showBankAccountSubject.next(value); + } + get showBankAccount(): boolean { + return this.showBankAccountSubject.value; + } + + @Input() showPayPal: boolean = true; + @Input() showAccountCredit: boolean = false; + @Input() includeBillingAddress: boolean = false; + + protected selectableCountries = selectableCountries; + + private destroy$ = new Subject(); + + constructor( + private braintreeService: BraintreeService, + private i18nService: I18nService, + private logService: LogService, + private stripeService: StripeService, + private toastService: ToastService, + ) {} + + ngOnInit() { + this.stripeService.loadStripe( + { + cardNumber: "#stripe-card-number", + cardExpiry: "#stripe-card-expiry", + cardCvc: "#stripe-card-cvc", + }, + true, + ); + + if (this.showPayPal) { + this.braintreeService.loadBraintree("#braintree-container", false); + } + + if (!this.includeBillingAddress) { + this.group.controls.billingAddress.disable(); + } + + this.group.controls.type.valueChanges + .pipe(startWith(this.group.controls.type.value), takeUntil(this.destroy$)) + .subscribe((selected) => { + if (selected === "bankAccount") { + this.group.controls.bankAccount.enable(); + if (this.includeBillingAddress) { + this.group.controls.billingAddress.enable(); + } + } else { + switch (selected) { + case "card": { + this.stripeService.mountElements(); + if (this.includeBillingAddress) { + this.group.controls.billingAddress.enable(); + } + break; + } + case "payPal": { + this.braintreeService.createDropin(); + if (this.includeBillingAddress) { + this.group.controls.billingAddress.disable(); + } + break; + } + } + this.group.controls.bankAccount.disable(); + } + }); + + this.showBankAccount$.pipe(takeUntil(this.destroy$)).subscribe((showBankAccount) => { + if (!showBankAccount && this.selected === "bankAccount") { + this.select("card"); + } + }); + } + + select = (paymentMethod: PaymentMethodOption) => + this.group.controls.type.patchValue(paymentMethod); + + tokenize = async (): Promise => { + const exchange = async (paymentMethod: TokenizablePaymentMethod) => { + switch (paymentMethod) { + case "bankAccount": { + this.group.controls.bankAccount.markAllAsTouched(); + if (!this.group.controls.bankAccount.valid) { + throw new Error("Attempted to tokenize invalid bank account information."); + } + + const bankAccount = this.group.controls.bankAccount.getRawValue(); + const clientSecret = await this.stripeService.createSetupIntent("bankAccount"); + const billingDetails = this.group.controls.billingAddress.enabled + ? this.group.controls.billingAddress.getRawValue() + : undefined; + return await this.stripeService.setupBankAccountPaymentMethod( + clientSecret, + bankAccount, + billingDetails, + ); + } + case "card": { + const clientSecret = await this.stripeService.createSetupIntent("card"); + const billingDetails = this.group.controls.billingAddress.enabled + ? this.group.controls.billingAddress.getRawValue() + : undefined; + return this.stripeService.setupCardPaymentMethod(clientSecret, billingDetails); + } + case "payPal": { + return this.braintreeService.requestPaymentMethod(); + } + } + }; + + if (!isTokenizablePaymentMethod(this.selected)) { + throw new Error(`Attempted to tokenize a non-tokenizable payment method: ${this.selected}`); + } + + try { + const token = await exchange(this.selected); + return { type: this.selected, token }; + } catch (error: unknown) { + this.logService.error(error); + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("problemSubmittingPaymentMethod"), + }); + throw error; + } + }; + + validate = (): boolean => { + if (this.selected === "bankAccount") { + this.group.controls.bankAccount.markAllAsTouched(); + return this.group.controls.bankAccount.valid; + } + + return true; + }; + + get selected(): PaymentMethodOption { + return this.group.value.type!; + } + + static getFormGroup = (): PaymentMethodFormGroup => + new FormGroup({ + type: new FormControl("card", { nonNullable: true }), + bankAccount: new FormGroup({ + routingNumber: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + accountNumber: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + accountHolderName: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + accountHolderType: new FormControl<"" | "company" | "individual">("", { + nonNullable: true, + validators: [Validators.required], + }), + }), + billingAddress: new FormGroup({ + country: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + postalCode: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + }), + }); +} diff --git a/apps/web/src/app/billing/payment/components/index.ts b/apps/web/src/app/billing/payment/components/index.ts new file mode 100644 index 00000000000..3bf7f5ecd36 --- /dev/null +++ b/apps/web/src/app/billing/payment/components/index.ts @@ -0,0 +1,9 @@ +export * from "./add-account-credit-dialog.component"; +export * from "./change-payment-method-dialog.component"; +export * from "./display-account-credit.component"; +export * from "./display-billing-address.component"; +export * from "./display-payment-method.component"; +export * from "./edit-billing-address-dialog.component"; +export * from "./enter-billing-address.component"; +export * from "./enter-payment-method.component"; +export * from "./verify-bank-account.component"; diff --git a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts new file mode 100644 index 00000000000..f79e9a1b5fc --- /dev/null +++ b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts @@ -0,0 +1,86 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ToastService } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; +import { BillingClient } from "../../services"; +import { BillableEntity } from "../../types"; +import { MaskedPaymentMethod } from "../types"; + +@Component({ + selector: "app-verify-bank-account", + template: ` + +

{{ "verifyBankAccountWithStatementDescriptorInstructions" | i18n }}

+
+ + {{ "descriptorCode" | i18n }} + + + +
+
+ `, + standalone: true, + imports: [SharedModule], + providers: [BillingClient], +}) +export class VerifyBankAccountComponent { + @Input({ required: true }) owner!: BillableEntity; + @Output() verified = new EventEmitter(); + + protected formGroup = new FormGroup({ + descriptorCode: new FormControl("", [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + ]), + }); + + constructor( + private billingClient: BillingClient, + private i18nService: I18nService, + private toastService: ToastService, + ) {} + + submit = async (): Promise => { + this.formGroup.markAllAsTouched(); + + if (!this.formGroup.valid) { + return; + } + + const result = await this.billingClient.verifyBankAccount( + this.owner, + this.formGroup.value.descriptorCode!, + ); + + switch (result.type) { + case "success": { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("bankAccountVerified"), + }); + this.verified.emit(result.value); + break; + } + case "error": { + this.toastService.showToast({ + variant: "error", + title: "", + message: result.message, + }); + } + } + }; +} diff --git a/apps/web/src/app/billing/payment/pipes/address.pipe.spec.ts b/apps/web/src/app/billing/payment/pipes/address.pipe.spec.ts new file mode 100644 index 00000000000..c497bbf2f0f --- /dev/null +++ b/apps/web/src/app/billing/payment/pipes/address.pipe.spec.ts @@ -0,0 +1,65 @@ +import { AddressPipe } from "./address.pipe"; + +describe("AddressPipe", () => { + let pipe: AddressPipe; + + beforeEach(() => { + pipe = new AddressPipe(); + }); + + it("should format a complete address with all fields", () => { + const address = { + country: "United States", + postalCode: "10001", + line1: "123 Main St", + line2: "Apt 4B", + city: "New York", + state: "NY", + }; + + const result = pipe.transform(address); + expect(result).toBe("123 Main St, Apt 4B, New York, NY, 10001, United States"); + }); + + it("should format address without line2", () => { + const address = { + country: "United States", + postalCode: "10001", + line1: "123 Main St", + line2: null, + city: "New York", + state: "NY", + }; + + const result = pipe.transform(address); + expect(result).toBe("123 Main St, New York, NY, 10001, United States"); + }); + + it("should format address without state", () => { + const address = { + country: "United Kingdom", + postalCode: "SW1A 1AA", + line1: "123 Main St", + line2: "Apt 4B", + city: "London", + state: null, + }; + + const result = pipe.transform(address); + expect(result).toBe("123 Main St, Apt 4B, London, SW1A 1AA, United Kingdom"); + }); + + it("should format minimal address with only required fields", () => { + const address = { + country: "United States", + postalCode: "10001", + line1: null, + line2: null, + city: null, + state: null, + }; + + const result = pipe.transform(address); + expect(result).toBe("10001, United States"); + }); +}); diff --git a/apps/web/src/app/billing/payment/pipes/address.pipe.ts b/apps/web/src/app/billing/payment/pipes/address.pipe.ts new file mode 100644 index 00000000000..da612950a27 --- /dev/null +++ b/apps/web/src/app/billing/payment/pipes/address.pipe.ts @@ -0,0 +1,32 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +import { BillingAddress } from "../types"; + +@Pipe({ + name: "address", +}) +export class AddressPipe implements PipeTransform { + transform(address: Omit): string { + const parts: string[] = []; + + if (address.line1) { + parts.push(address.line1); + } + + if (address.line2) { + parts.push(address.line2); + } + + if (address.city) { + parts.push(address.city); + } + + if (address.state) { + parts.push(address.state); + } + + parts.push(address.postalCode, address.country); + + return parts.join(", "); + } +} diff --git a/apps/web/src/app/billing/payment/pipes/index.ts b/apps/web/src/app/billing/payment/pipes/index.ts new file mode 100644 index 00000000000..d95cff6b6f8 --- /dev/null +++ b/apps/web/src/app/billing/payment/pipes/index.ts @@ -0,0 +1 @@ +export * from "./address.pipe"; diff --git a/apps/web/src/app/billing/payment/types/billing-address.ts b/apps/web/src/app/billing/payment/types/billing-address.ts new file mode 100644 index 00000000000..eddb24673f5 --- /dev/null +++ b/apps/web/src/app/billing/payment/types/billing-address.ts @@ -0,0 +1,37 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +import { TaxId, TaxIdResponse } from "./tax-id"; + +export type BillingAddress = { + country: string; + postalCode: string; + line1: string | null; + line2: string | null; + city: string | null; + state: string | null; + taxId: TaxId | null; +}; + +export class BillingAddressResponse extends BaseResponse implements BillingAddress { + country: string; + postalCode: string; + line1: string | null; + line2: string | null; + city: string | null; + state: string | null; + taxId: TaxId | null; + + constructor(response: any) { + super(response); + + this.country = this.getResponseProperty("Country"); + this.postalCode = this.getResponseProperty("PostalCode"); + this.line1 = this.getResponseProperty("Line1"); + this.line2 = this.getResponseProperty("Line2"); + this.city = this.getResponseProperty("City"); + this.state = this.getResponseProperty("State"); + + const taxId = this.getResponseProperty("TaxId"); + this.taxId = taxId ? new TaxIdResponse(taxId) : null; + } +} diff --git a/apps/web/src/app/billing/payment/types/index.ts b/apps/web/src/app/billing/payment/types/index.ts new file mode 100644 index 00000000000..a8534c5aba4 --- /dev/null +++ b/apps/web/src/app/billing/payment/types/index.ts @@ -0,0 +1,6 @@ +export * from "./billing-address"; +export * from "./masked-payment-method"; +export * from "./selectable-country"; +export * from "./tax-id"; +export * from "./tax-id-type"; +export * from "./tokenized-payment-method"; diff --git a/apps/web/src/app/billing/payment/types/masked-payment-method.ts b/apps/web/src/app/billing/payment/types/masked-payment-method.ts new file mode 100644 index 00000000000..8d07706b14c --- /dev/null +++ b/apps/web/src/app/billing/payment/types/masked-payment-method.ts @@ -0,0 +1,114 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +import { + BankAccountPaymentMethod, + CardPaymentMethod, + PayPalPaymentMethod, +} from "./tokenized-payment-method"; + +export const StripeCardBrands = { + amex: "amex", + diners: "diners", + discover: "discover", + eftpos_au: "eftpos_au", + jcb: "jcb", + link: "link", + mastercard: "mastercard", + unionpay: "unionpay", + visa: "visa", + unknown: "unknown", +} as const; + +export type StripeCardBrand = (typeof StripeCardBrands)[keyof typeof StripeCardBrands]; + +type MaskedBankAccount = { + type: BankAccountPaymentMethod; + bankName: string; + last4: string; + verified: boolean; +}; + +type MaskedCard = { + type: CardPaymentMethod; + brand: StripeCardBrand; + last4: string; + expiration: string; +}; + +type MaskedPayPalAccount = { + type: PayPalPaymentMethod; + email: string; +}; + +export type MaskedPaymentMethod = MaskedBankAccount | MaskedCard | MaskedPayPalAccount; + +export class MaskedPaymentMethodResponse extends BaseResponse { + value: MaskedPaymentMethod; + + constructor(response: any) { + super(response); + + const type = this.getResponseProperty("Type"); + switch (type) { + case "card": { + this.value = new MaskedCardResponse(response); + break; + } + case "bankAccount": { + this.value = new MaskedBankAccountResponse(response); + break; + } + case "payPal": { + this.value = new MaskedPayPalAccountResponse(response); + break; + } + default: { + throw new Error(`Cannot deserialize unsupported payment method type: ${type}`); + } + } + } +} + +class MaskedBankAccountResponse extends BaseResponse implements MaskedBankAccount { + type: BankAccountPaymentMethod; + bankName: string; + last4: string; + verified: boolean; + + constructor(response: any) { + super(response); + + this.type = "bankAccount"; + this.bankName = this.getResponseProperty("BankName"); + this.last4 = this.getResponseProperty("Last4"); + this.verified = this.getResponseProperty("Verified"); + } +} + +class MaskedCardResponse extends BaseResponse implements MaskedCard { + type: CardPaymentMethod; + brand: StripeCardBrand; + last4: string; + expiration: string; + + constructor(response: any) { + super(response); + + this.type = "card"; + this.brand = this.getResponseProperty("Brand"); + this.last4 = this.getResponseProperty("Last4"); + this.expiration = this.getResponseProperty("Expiration"); + } +} + +class MaskedPayPalAccountResponse extends BaseResponse implements MaskedPayPalAccount { + type: PayPalPaymentMethod; + email: string; + + constructor(response: any) { + super(response); + + this.type = "payPal"; + this.email = this.getResponseProperty("Email"); + } +} diff --git a/apps/web/src/app/billing/payment/types/selectable-country.ts b/apps/web/src/app/billing/payment/types/selectable-country.ts new file mode 100644 index 00000000000..71d6af95cc7 --- /dev/null +++ b/apps/web/src/app/billing/payment/types/selectable-country.ts @@ -0,0 +1,259 @@ +type SelectableCountry = Readonly<{ + name: string; + value: string; + disabled: boolean; +}>; + +export const selectableCountries: ReadonlyArray = [ + { name: "-- Select --", value: "", disabled: false }, + { name: "United States", value: "US", disabled: false }, + { name: "China", value: "CN", disabled: false }, + { name: "France", value: "FR", disabled: false }, + { name: "Germany", value: "DE", disabled: false }, + { name: "Canada", value: "CA", disabled: false }, + { name: "United Kingdom", value: "GB", disabled: false }, + { name: "Australia", value: "AU", disabled: false }, + { name: "India", value: "IN", disabled: false }, + { name: "", value: "-", disabled: true }, + { name: "Afghanistan", value: "AF", disabled: false }, + { name: "Åland Islands", value: "AX", disabled: false }, + { name: "Albania", value: "AL", disabled: false }, + { name: "Algeria", value: "DZ", disabled: false }, + { name: "American Samoa", value: "AS", disabled: false }, + { name: "Andorra", value: "AD", disabled: false }, + { name: "Angola", value: "AO", disabled: false }, + { name: "Anguilla", value: "AI", disabled: false }, + { name: "Antarctica", value: "AQ", disabled: false }, + { name: "Antigua and Barbuda", value: "AG", disabled: false }, + { name: "Argentina", value: "AR", disabled: false }, + { name: "Armenia", value: "AM", disabled: false }, + { name: "Aruba", value: "AW", disabled: false }, + { name: "Austria", value: "AT", disabled: false }, + { name: "Azerbaijan", value: "AZ", disabled: false }, + { name: "Bahamas", value: "BS", disabled: false }, + { name: "Bahrain", value: "BH", disabled: false }, + { name: "Bangladesh", value: "BD", disabled: false }, + { name: "Barbados", value: "BB", disabled: false }, + { name: "Belarus", value: "BY", disabled: false }, + { name: "Belgium", value: "BE", disabled: false }, + { name: "Belize", value: "BZ", disabled: false }, + { name: "Benin", value: "BJ", disabled: false }, + { name: "Bermuda", value: "BM", disabled: false }, + { name: "Bhutan", value: "BT", disabled: false }, + { name: "Bolivia, Plurinational State of", value: "BO", disabled: false }, + { name: "Bonaire, Sint Eustatius and Saba", value: "BQ", disabled: false }, + { name: "Bosnia and Herzegovina", value: "BA", disabled: false }, + { name: "Botswana", value: "BW", disabled: false }, + { name: "Bouvet Island", value: "BV", disabled: false }, + { name: "Brazil", value: "BR", disabled: false }, + { name: "British Indian Ocean Territory", value: "IO", disabled: false }, + { name: "Brunei Darussalam", value: "BN", disabled: false }, + { name: "Bulgaria", value: "BG", disabled: false }, + { name: "Burkina Faso", value: "BF", disabled: false }, + { name: "Burundi", value: "BI", disabled: false }, + { name: "Cambodia", value: "KH", disabled: false }, + { name: "Cameroon", value: "CM", disabled: false }, + { name: "Cape Verde", value: "CV", disabled: false }, + { name: "Cayman Islands", value: "KY", disabled: false }, + { name: "Central African Republic", value: "CF", disabled: false }, + { name: "Chad", value: "TD", disabled: false }, + { name: "Chile", value: "CL", disabled: false }, + { name: "Christmas Island", value: "CX", disabled: false }, + { name: "Cocos (Keeling) Islands", value: "CC", disabled: false }, + { name: "Colombia", value: "CO", disabled: false }, + { name: "Comoros", value: "KM", disabled: false }, + { name: "Congo", value: "CG", disabled: false }, + { name: "Congo, the Democratic Republic of the", value: "CD", disabled: false }, + { name: "Cook Islands", value: "CK", disabled: false }, + { name: "Costa Rica", value: "CR", disabled: false }, + { name: "Côte d'Ivoire", value: "CI", disabled: false }, + { name: "Croatia", value: "HR", disabled: false }, + { name: "Cuba", value: "CU", disabled: false }, + { name: "Curaçao", value: "CW", disabled: false }, + { name: "Cyprus", value: "CY", disabled: false }, + { name: "Czech Republic", value: "CZ", disabled: false }, + { name: "Denmark", value: "DK", disabled: false }, + { name: "Djibouti", value: "DJ", disabled: false }, + { name: "Dominica", value: "DM", disabled: false }, + { name: "Dominican Republic", value: "DO", disabled: false }, + { name: "Ecuador", value: "EC", disabled: false }, + { name: "Egypt", value: "EG", disabled: false }, + { name: "El Salvador", value: "SV", disabled: false }, + { name: "Equatorial Guinea", value: "GQ", disabled: false }, + { name: "Eritrea", value: "ER", disabled: false }, + { name: "Estonia", value: "EE", disabled: false }, + { name: "Ethiopia", value: "ET", disabled: false }, + { name: "Falkland Islands (Malvinas)", value: "FK", disabled: false }, + { name: "Faroe Islands", value: "FO", disabled: false }, + { name: "Fiji", value: "FJ", disabled: false }, + { name: "Finland", value: "FI", disabled: false }, + { name: "French Guiana", value: "GF", disabled: false }, + { name: "French Polynesia", value: "PF", disabled: false }, + { name: "French Southern Territories", value: "TF", disabled: false }, + { name: "Gabon", value: "GA", disabled: false }, + { name: "Gambia", value: "GM", disabled: false }, + { name: "Georgia", value: "GE", disabled: false }, + { name: "Ghana", value: "GH", disabled: false }, + { name: "Gibraltar", value: "GI", disabled: false }, + { name: "Greece", value: "GR", disabled: false }, + { name: "Greenland", value: "GL", disabled: false }, + { name: "Grenada", value: "GD", disabled: false }, + { name: "Guadeloupe", value: "GP", disabled: false }, + { name: "Guam", value: "GU", disabled: false }, + { name: "Guatemala", value: "GT", disabled: false }, + { name: "Guernsey", value: "GG", disabled: false }, + { name: "Guinea", value: "GN", disabled: false }, + { name: "Guinea-Bissau", value: "GW", disabled: false }, + { name: "Guyana", value: "GY", disabled: false }, + { name: "Haiti", value: "HT", disabled: false }, + { name: "Heard Island and McDonald Islands", value: "HM", disabled: false }, + { name: "Holy See (Vatican City State)", value: "VA", disabled: false }, + { name: "Honduras", value: "HN", disabled: false }, + { name: "Hong Kong", value: "HK", disabled: false }, + { name: "Hungary", value: "HU", disabled: false }, + { name: "Iceland", value: "IS", disabled: false }, + { name: "Indonesia", value: "ID", disabled: false }, + { name: "Iran, Islamic Republic of", value: "IR", disabled: false }, + { name: "Iraq", value: "IQ", disabled: false }, + { name: "Ireland", value: "IE", disabled: false }, + { name: "Isle of Man", value: "IM", disabled: false }, + { name: "Israel", value: "IL", disabled: false }, + { name: "Italy", value: "IT", disabled: false }, + { name: "Jamaica", value: "JM", disabled: false }, + { name: "Japan", value: "JP", disabled: false }, + { name: "Jersey", value: "JE", disabled: false }, + { name: "Jordan", value: "JO", disabled: false }, + { name: "Kazakhstan", value: "KZ", disabled: false }, + { name: "Kenya", value: "KE", disabled: false }, + { name: "Kiribati", value: "KI", disabled: false }, + { name: "Korea, Democratic People's Republic of", value: "KP", disabled: false }, + { name: "Korea, Republic of", value: "KR", disabled: false }, + { name: "Kuwait", value: "KW", disabled: false }, + { name: "Kyrgyzstan", value: "KG", disabled: false }, + { name: "Lao People's Democratic Republic", value: "LA", disabled: false }, + { name: "Latvia", value: "LV", disabled: false }, + { name: "Lebanon", value: "LB", disabled: false }, + { name: "Lesotho", value: "LS", disabled: false }, + { name: "Liberia", value: "LR", disabled: false }, + { name: "Libya", value: "LY", disabled: false }, + { name: "Liechtenstein", value: "LI", disabled: false }, + { name: "Lithuania", value: "LT", disabled: false }, + { name: "Luxembourg", value: "LU", disabled: false }, + { name: "Macao", value: "MO", disabled: false }, + { name: "Macedonia, the former Yugoslav Republic of", value: "MK", disabled: false }, + { name: "Madagascar", value: "MG", disabled: false }, + { name: "Malawi", value: "MW", disabled: false }, + { name: "Malaysia", value: "MY", disabled: false }, + { name: "Maldives", value: "MV", disabled: false }, + { name: "Mali", value: "ML", disabled: false }, + { name: "Malta", value: "MT", disabled: false }, + { name: "Marshall Islands", value: "MH", disabled: false }, + { name: "Martinique", value: "MQ", disabled: false }, + { name: "Mauritania", value: "MR", disabled: false }, + { name: "Mauritius", value: "MU", disabled: false }, + { name: "Mayotte", value: "YT", disabled: false }, + { name: "Mexico", value: "MX", disabled: false }, + { name: "Micronesia, Federated States of", value: "FM", disabled: false }, + { name: "Moldova, Republic of", value: "MD", disabled: false }, + { name: "Monaco", value: "MC", disabled: false }, + { name: "Mongolia", value: "MN", disabled: false }, + { name: "Montenegro", value: "ME", disabled: false }, + { name: "Montserrat", value: "MS", disabled: false }, + { name: "Morocco", value: "MA", disabled: false }, + { name: "Mozambique", value: "MZ", disabled: false }, + { name: "Myanmar", value: "MM", disabled: false }, + { name: "Namibia", value: "NA", disabled: false }, + { name: "Nauru", value: "NR", disabled: false }, + { name: "Nepal", value: "NP", disabled: false }, + { name: "Netherlands", value: "NL", disabled: false }, + { name: "New Caledonia", value: "NC", disabled: false }, + { name: "New Zealand", value: "NZ", disabled: false }, + { name: "Nicaragua", value: "NI", disabled: false }, + { name: "Niger", value: "NE", disabled: false }, + { name: "Nigeria", value: "NG", disabled: false }, + { name: "Niue", value: "NU", disabled: false }, + { name: "Norfolk Island", value: "NF", disabled: false }, + { name: "Northern Mariana Islands", value: "MP", disabled: false }, + { name: "Norway", value: "NO", disabled: false }, + { name: "Oman", value: "OM", disabled: false }, + { name: "Pakistan", value: "PK", disabled: false }, + { name: "Palau", value: "PW", disabled: false }, + { name: "Palestinian Territory, Occupied", value: "PS", disabled: false }, + { name: "Panama", value: "PA", disabled: false }, + { name: "Papua New Guinea", value: "PG", disabled: false }, + { name: "Paraguay", value: "PY", disabled: false }, + { name: "Peru", value: "PE", disabled: false }, + { name: "Philippines", value: "PH", disabled: false }, + { name: "Pitcairn", value: "PN", disabled: false }, + { name: "Poland", value: "PL", disabled: false }, + { name: "Portugal", value: "PT", disabled: false }, + { name: "Puerto Rico", value: "PR", disabled: false }, + { name: "Qatar", value: "QA", disabled: false }, + { name: "Réunion", value: "RE", disabled: false }, + { name: "Romania", value: "RO", disabled: false }, + { name: "Russian Federation", value: "RU", disabled: false }, + { name: "Rwanda", value: "RW", disabled: false }, + { name: "Saint Barthélemy", value: "BL", disabled: false }, + { name: "Saint Helena, Ascension and Tristan da Cunha", value: "SH", disabled: false }, + { name: "Saint Kitts and Nevis", value: "KN", disabled: false }, + { name: "Saint Lucia", value: "LC", disabled: false }, + { name: "Saint Martin (French part)", value: "MF", disabled: false }, + { name: "Saint Pierre and Miquelon", value: "PM", disabled: false }, + { name: "Saint Vincent and the Grenadines", value: "VC", disabled: false }, + { name: "Samoa", value: "WS", disabled: false }, + { name: "San Marino", value: "SM", disabled: false }, + { name: "Sao Tome and Principe", value: "ST", disabled: false }, + { name: "Saudi Arabia", value: "SA", disabled: false }, + { name: "Senegal", value: "SN", disabled: false }, + { name: "Serbia", value: "RS", disabled: false }, + { name: "Seychelles", value: "SC", disabled: false }, + { name: "Sierra Leone", value: "SL", disabled: false }, + { name: "Singapore", value: "SG", disabled: false }, + { name: "Sint Maarten (Dutch part)", value: "SX", disabled: false }, + { name: "Slovakia", value: "SK", disabled: false }, + { name: "Slovenia", value: "SI", disabled: false }, + { name: "Solomon Islands", value: "SB", disabled: false }, + { name: "Somalia", value: "SO", disabled: false }, + { name: "South Africa", value: "ZA", disabled: false }, + { name: "South Georgia and the South Sandwich Islands", value: "GS", disabled: false }, + { name: "South Sudan", value: "SS", disabled: false }, + { name: "Spain", value: "ES", disabled: false }, + { name: "Sri Lanka", value: "LK", disabled: false }, + { name: "Sudan", value: "SD", disabled: false }, + { name: "Suriname", value: "SR", disabled: false }, + { name: "Svalbard and Jan Mayen", value: "SJ", disabled: false }, + { name: "Swaziland", value: "SZ", disabled: false }, + { name: "Sweden", value: "SE", disabled: false }, + { name: "Switzerland", value: "CH", disabled: false }, + { name: "Syrian Arab Republic", value: "SY", disabled: false }, + { name: "Taiwan", value: "TW", disabled: false }, + { name: "Tajikistan", value: "TJ", disabled: false }, + { name: "Tanzania, United Republic of", value: "TZ", disabled: false }, + { name: "Thailand", value: "TH", disabled: false }, + { name: "Timor-Leste", value: "TL", disabled: false }, + { name: "Togo", value: "TG", disabled: false }, + { name: "Tokelau", value: "TK", disabled: false }, + { name: "Tonga", value: "TO", disabled: false }, + { name: "Trinidad and Tobago", value: "TT", disabled: false }, + { name: "Tunisia", value: "TN", disabled: false }, + { name: "Turkey", value: "TR", disabled: false }, + { name: "Turkmenistan", value: "TM", disabled: false }, + { name: "Turks and Caicos Islands", value: "TC", disabled: false }, + { name: "Tuvalu", value: "TV", disabled: false }, + { name: "Uganda", value: "UG", disabled: false }, + { name: "Ukraine", value: "UA", disabled: false }, + { name: "United Arab Emirates", value: "AE", disabled: false }, + { name: "United States Minor Outlying Islands", value: "UM", disabled: false }, + { name: "Uruguay", value: "UY", disabled: false }, + { name: "Uzbekistan", value: "UZ", disabled: false }, + { name: "Vanuatu", value: "VU", disabled: false }, + { name: "Venezuela, Bolivarian Republic of", value: "VE", disabled: false }, + { name: "Viet Nam", value: "VN", disabled: false }, + { name: "Virgin Islands, British", value: "VG", disabled: false }, + { name: "Virgin Islands, U.S.", value: "VI", disabled: false }, + { name: "Wallis and Futuna", value: "WF", disabled: false }, + { name: "Western Sahara", value: "EH", disabled: false }, + { name: "Yemen", value: "YE", disabled: false }, + { name: "Zambia", value: "ZM", disabled: false }, + { name: "Zimbabwe", value: "ZW", disabled: false }, +]; diff --git a/apps/web/src/app/billing/payment/types/tax-id-type.ts b/apps/web/src/app/billing/payment/types/tax-id-type.ts new file mode 100644 index 00000000000..8f6264e088c --- /dev/null +++ b/apps/web/src/app/billing/payment/types/tax-id-type.ts @@ -0,0 +1,1123 @@ +export type TaxIdType = Readonly<{ + country: string; + iso: string; + code: string; + description: string; + example: string; + impactsTaxCalculation: boolean; +}>; + +export const getTaxIdTypeForCountry = (country: string): TaxIdType | null => { + const types = taxIdTypes.filter((type) => type.iso === country); + if (types.length === 0) { + return null; + } else if (types.length === 1) { + return types[0]; + } else { + const impactful = types.find((taxIdType) => taxIdType.impactsTaxCalculation); + if (!impactful) { + return types[0]; + } + return impactful; + } +}; + +export const taxIdTypes: ReadonlyArray = [ + { + country: "Albania", + iso: "AL", + code: "al_tin", + description: "Albania Tax Identification Number", + example: "J12345678N", + impactsTaxCalculation: true, + }, + { + country: "Andorra", + iso: "AD", + code: "ad_nrt", + description: "Andorran NRT number", + example: "A-123456-Z", + impactsTaxCalculation: false, + }, + { + country: "Angola", + iso: "AO", + code: "ao_tin", + description: "Angola Tax Identification Number", + example: "5123456789", + impactsTaxCalculation: false, + }, + { + country: "Argentina", + iso: "AR", + code: "ar_cuit", + description: "Argentinian tax ID number", + example: "12-3456789-01", + impactsTaxCalculation: false, + }, + { + country: "Armenia", + iso: "AM", + code: "am_tin", + description: "Armenia Tax Identification Number", + example: "2538904", + impactsTaxCalculation: true, + }, + { + country: "Aruba", + iso: "AW", + code: "aw_tin", + description: "Aruba Tax Identification Number", + example: "12345678", + impactsTaxCalculation: true, + }, + { + country: "Australia", + iso: "AU", + code: "au_abn", + description: "Australian Business Number (AU ABN)", + example: "12345678912", + impactsTaxCalculation: true, + }, + { + country: "Australia", + iso: "AU", + code: "au_arn", + description: "Australian Taxation Office Reference Number", + example: "123456789123", + impactsTaxCalculation: false, + }, + { + country: "Austria", + iso: "AT", + code: "eu_vat", + description: "European VAT number", + example: "ATU12345678", + impactsTaxCalculation: true, + }, + { + country: "Azerbaijan", + iso: "AZ", + code: "az_tin", + description: "Azerbaijan Tax Identification Number", + example: "123456789", + impactsTaxCalculation: true, + }, + { + country: "Bahamas", + iso: "BS", + code: "bs_tin", + description: "Bahamas Tax Identification Number", + example: "123.456.789", + impactsTaxCalculation: false, + }, + { + country: "Bahrain", + iso: "BH", + code: "bh_vat", + description: "Bahraini VAT Number", + example: "123456789012345", + impactsTaxCalculation: true, + }, + { + country: "Bangladesh", + iso: "BD", + code: "bd_bin", + description: "Bangladesh Business Identification Number", + example: "123456789-0123", + impactsTaxCalculation: true, + }, + { + country: "Barbados", + iso: "BB", + code: "bb_tin", + description: "Barbados Tax Identification Number", + example: "1123456789012", + impactsTaxCalculation: false, + }, + { + country: "Belarus", + iso: "BY", + code: "by_tin", + description: "Belarus TIN Number", + example: "123456789", + impactsTaxCalculation: true, + }, + { + country: "Belgium", + iso: "BE", + code: "eu_vat", + description: "European VAT number", + example: "BE0123456789", + impactsTaxCalculation: true, + }, + { + country: "Benin", + iso: "BJ", + code: "bj_ifu", + description: "Benin Tax Identification Number (Identifiant Fiscal Unique)", + example: "1234567890123", + impactsTaxCalculation: true, + }, + { + country: "Bolivia", + iso: "BO", + code: "bo_tin", + description: "Bolivian tax ID", + example: "123456789", + impactsTaxCalculation: false, + }, + { + country: "Bosnia & Herzegovina", + iso: "BA", + code: "ba_tin", + description: "Bosnia and Herzegovina Tax Identification Number", + example: "123456789012", + impactsTaxCalculation: true, + }, + { + country: "Brazil", + iso: "BR", + code: "br_cnpj", + description: "Brazilian CNPJ number", + example: "01.234.456/5432-10", + impactsTaxCalculation: false, + }, + { + country: "Brazil", + iso: "BR", + code: "br_cpf", + description: "Brazilian CPF number", + example: "123.456.789-87", + impactsTaxCalculation: false, + }, + { + country: "Bulgaria", + iso: "BG", + code: "bg_uic", + description: "Bulgaria Unified Identification Code", + example: "123456789", + impactsTaxCalculation: false, + }, + { + country: "Bulgaria", + iso: "BG", + code: "eu_vat", + description: "European VAT number", + example: "BG0123456789", + impactsTaxCalculation: true, + }, + { + country: "Burkina Faso", + iso: "BF", + code: "bf_ifu", + description: "Burkina Faso Tax Identification Number (Numéro d'Identifiant Fiscal Unique)", + example: "12345678A", + impactsTaxCalculation: true, + }, + { + country: "Cambodia", + iso: "KH", + code: "kh_tin", + description: "Cambodia Tax Identification Number", + example: "1001-123456789", + impactsTaxCalculation: true, + }, + { + country: "Cameroon", + iso: "CM", + code: "cm_niu", + description: "Cameroon Tax Identification Number (Numéro d'Identifiant fiscal Unique)", + example: "M123456789000L", + impactsTaxCalculation: false, + }, + { + country: "Canada", + iso: "CA", + code: "ca_bn", + description: "Canadian BN", + example: "123456789", + impactsTaxCalculation: false, + }, + { + country: "Canada", + iso: "CA", + code: "ca_gst_hst", + description: "Canadian GST/HST number", + example: "123456789RT0002", + impactsTaxCalculation: true, + }, + { + country: "Canada", + iso: "CA", + code: "ca_pst_bc", + description: "Canadian PST number (British Columbia)", + example: "PST-1234-5678", + impactsTaxCalculation: false, + }, + { + country: "Canada", + iso: "CA", + code: "ca_pst_mb", + description: "Canadian PST number (Manitoba)", + example: "123456-7", + impactsTaxCalculation: false, + }, + { + country: "Canada", + iso: "CA", + code: "ca_pst_sk", + description: "Canadian PST number (Saskatchewan)", + example: "1234567", + impactsTaxCalculation: false, + }, + { + country: "Canada", + iso: "CA", + code: "ca_qst", + description: "Canadian QST number (Québec)", + example: "1234567890TQ1234", + impactsTaxCalculation: true, + }, + { + country: "Cape Verde", + iso: "CV", + code: "cv_nif", + description: "Cape Verde Tax Identification Number (Número de Identificação Fiscal)", + example: "213456789", + impactsTaxCalculation: false, + }, + { + country: "Chile", + iso: "CL", + code: "cl_tin", + description: "Chilean TIN", + example: "12.345.678-K", + impactsTaxCalculation: true, + }, + { + country: "China", + iso: "CN", + code: "cn_tin", + description: "Chinese tax ID", + example: "123456789012345678", + impactsTaxCalculation: false, + }, + { + country: "Colombia", + iso: "CO", + code: "co_nit", + description: "Colombian NIT number", + example: "123.456.789-0", + impactsTaxCalculation: false, + }, + { + country: "Congo - Kinshasa", + iso: "CD", + code: "cd_nif", + description: "Congo (DR) Tax Identification Number (Número de Identificação Fiscal)", + example: "A0123456M", + impactsTaxCalculation: false, + }, + { + country: "Costa Rica", + iso: "CR", + code: "cr_tin", + description: "Costa Rican tax ID", + example: "1-234-567890", + impactsTaxCalculation: false, + }, + { + country: "Croatia", + iso: "HR", + code: "eu_vat", + description: "European VAT number", + example: "HR12345678912", + impactsTaxCalculation: true, + }, + { + country: "Croatia", + iso: "HR", + code: "hr_oib", + description: "Croatian Personal Identification Number", + example: "12345678901", + impactsTaxCalculation: false, + }, + { + country: "Cyprus", + iso: "CY", + code: "eu_vat", + description: "European VAT number", + example: "CY12345678Z", + impactsTaxCalculation: true, + }, + { + country: "Czech Republic", + iso: "CZ", + code: "eu_vat", + description: "European VAT number", + example: "CZ1234567890", + impactsTaxCalculation: true, + }, + { + country: "Denmark", + iso: "DK", + code: "eu_vat", + description: "European VAT number", + example: "DK12345678", + impactsTaxCalculation: true, + }, + { + country: "Dominican Republic", + iso: "DO", + code: "do_rcn", + description: "Dominican RCN number", + example: "123-4567890-1", + impactsTaxCalculation: false, + }, + { + country: "Ecuador", + iso: "EC", + code: "ec_ruc", + description: "Ecuadorian RUC number", + example: "1234567890001", + impactsTaxCalculation: false, + }, + { + country: "Egypt", + iso: "EG", + code: "eg_tin", + description: "Egyptian Tax Identification Number", + example: "123456789", + impactsTaxCalculation: true, + }, + { + country: "El Salvador", + iso: "SV", + code: "sv_nit", + description: "El Salvadorian NIT number", + example: "1234-567890-123-4", + impactsTaxCalculation: false, + }, + { + country: "Estonia", + iso: "EE", + code: "eu_vat", + description: "European VAT number", + example: "EE123456789", + impactsTaxCalculation: true, + }, + { + country: "Ethiopia", + iso: "ET", + code: "et_tin", + description: "Ethiopia Tax Identification Number", + example: "1234567890", + impactsTaxCalculation: true, + }, + { + country: "EU", + iso: "EU", + code: "eu_oss_vat", + description: "European One Stop Shop VAT number for non-Union scheme", + example: "EU123456789", + impactsTaxCalculation: false, + }, + { + country: "Finland", + iso: "FI", + code: "eu_vat", + description: "European VAT number", + example: "FI12345678", + impactsTaxCalculation: true, + }, + { + country: "France", + iso: "FR", + code: "eu_vat", + description: "European VAT number", + example: "FRAB123456789", + impactsTaxCalculation: true, + }, + { + country: "Georgia", + iso: "GE", + code: "ge_vat", + description: "Georgian VAT", + example: "123456789", + impactsTaxCalculation: true, + }, + { + country: "Germany", + iso: "DE", + code: "de_stn", + description: "German Tax Number (Steuernummer)", + example: "1234567890", + impactsTaxCalculation: false, + }, + { + country: "Germany", + iso: "DE", + code: "eu_vat", + description: "European VAT number", + example: "DE123456789", + impactsTaxCalculation: true, + }, + { + country: "Greece", + iso: "GR", + code: "eu_vat", + description: "European VAT number", + example: "EL123456789", + impactsTaxCalculation: true, + }, + { + country: "Guinea", + iso: "GN", + code: "gn_nif", + description: "Guinea Tax Identification Number (Número de Identificação Fiscal)", + example: "123456789", + impactsTaxCalculation: true, + }, + { + country: "Hong Kong", + iso: "HK", + code: "hk_br", + description: "Hong Kong BR number", + example: "12345678", + impactsTaxCalculation: false, + }, + { + country: "Hungary", + iso: "HU", + code: "eu_vat", + description: "European VAT number", + example: "HU12345678", + impactsTaxCalculation: true, + }, + { + country: "Hungary", + iso: "HU", + code: "hu_tin", + description: "Hungary tax number (adószám)", + example: "12345678-1-23", + impactsTaxCalculation: false, + }, + { + country: "Iceland", + iso: "IS", + code: "is_vat", + description: "Icelandic VAT", + example: "123456", + impactsTaxCalculation: true, + }, + { + country: "India", + iso: "IN", + code: "in_gst", + description: "Indian GST number", + example: "12ABCDE3456FGZH", + impactsTaxCalculation: true, + }, + { + country: "Indonesia", + iso: "ID", + code: "id_npwp", + description: "Indonesian NPWP number", + example: "012.345.678.9-012.345", + impactsTaxCalculation: false, + }, + { + country: "Ireland", + iso: "IE", + code: "eu_vat", + description: "European VAT number", + example: "IE1234567AB", + impactsTaxCalculation: true, + }, + { + country: "Israel", + iso: "IL", + code: "il_vat", + description: "Israel VAT", + example: "12345", + impactsTaxCalculation: false, + }, + { + country: "Italy", + iso: "IT", + code: "eu_vat", + description: "European VAT number", + example: "IT12345678912", + impactsTaxCalculation: true, + }, + { + country: "Japan", + iso: "JP", + code: "jp_cn", + description: "Japanese Corporate Number (*Hōjin Bangō*)", + example: "1234567891234", + impactsTaxCalculation: false, + }, + { + country: "Japan", + iso: "JP", + code: "jp_rn", + description: + "Japanese Registered Foreign Businesses' Registration Number (*Tōroku Kokugai Jigyōsha no Tōroku Bangō*)", + example: "12345", + impactsTaxCalculation: false, + }, + { + country: "Japan", + iso: "JP", + code: "jp_trn", + description: "Japanese Tax Registration Number (*Tōroku Bangō*)", + example: "T1234567891234", + impactsTaxCalculation: true, + }, + { + country: "Kazakhstan", + iso: "KZ", + code: "kz_bin", + description: "Kazakhstani Business Identification Number", + example: "123456789012", + impactsTaxCalculation: true, + }, + { + country: "Kenya", + iso: "KE", + code: "ke_pin", + description: "Kenya Revenue Authority Personal Identification Number", + example: "P000111111A", + impactsTaxCalculation: false, + }, + { + country: "Kyrgyzstan", + iso: "KG", + code: "kg_tin", + description: "Kyrgyzstan Tax Identification Number", + example: "12345678901234", + impactsTaxCalculation: false, + }, + { + country: "Laos", + iso: "LA", + code: "la_tin", + description: "Laos Tax Identification Number", + example: "123456789-000", + impactsTaxCalculation: false, + }, + { + country: "Latvia", + iso: "LV", + code: "eu_vat", + description: "European VAT number", + example: "LV12345678912", + impactsTaxCalculation: true, + }, + { + country: "Liechtenstein", + iso: "LI", + code: "li_uid", + description: "Liechtensteinian UID number", + example: "CHE123456789", + impactsTaxCalculation: false, + }, + { + country: "Liechtenstein", + iso: "LI", + code: "li_vat", + description: "Liechtensteinian VAT number", + example: "12345", + impactsTaxCalculation: true, + }, + { + country: "Lithuania", + iso: "LT", + code: "eu_vat", + description: "European VAT number", + example: "LT123456789123", + impactsTaxCalculation: true, + }, + { + country: "Luxembourg", + iso: "LU", + code: "eu_vat", + description: "European VAT number", + example: "LU12345678", + impactsTaxCalculation: true, + }, + { + country: "Malaysia", + iso: "MY", + code: "my_frp", + description: "Malaysian FRP number", + example: "12345678", + impactsTaxCalculation: false, + }, + { + country: "Malaysia", + iso: "MY", + code: "my_itn", + description: "Malaysian ITN", + example: "C 1234567890", + impactsTaxCalculation: false, + }, + { + country: "Malaysia", + iso: "MY", + code: "my_sst", + description: "Malaysian SST number", + example: "A12-3456-78912345", + impactsTaxCalculation: false, + }, + { + country: "Malta", + iso: "MT", + code: "eu_vat", + description: "European VAT number", + example: "MT12345678", + impactsTaxCalculation: true, + }, + { + country: "Mauritania", + iso: "MR", + code: "mr_nif", + description: "Mauritania Tax Identification Number (Número de Identificação Fiscal)", + example: "12345678", + impactsTaxCalculation: false, + }, + { + country: "Mexico", + iso: "MX", + code: "mx_rfc", + description: "Mexican RFC number", + example: "ABC010203AB9", + impactsTaxCalculation: false, + }, + { + country: "Moldova", + iso: "MD", + code: "md_vat", + description: "Moldova VAT Number", + example: "1234567", + impactsTaxCalculation: true, + }, + { + country: "Montenegro", + iso: "ME", + code: "me_pib", + description: "Montenegro PIB Number", + example: "12345678", + impactsTaxCalculation: false, + }, + { + country: "Morocco", + iso: "MA", + code: "ma_vat", + description: "Morocco VAT Number", + example: "12345678", + impactsTaxCalculation: true, + }, + { + country: "Nepal", + iso: "NP", + code: "np_pan", + description: "Nepal PAN Number", + example: "123456789", + impactsTaxCalculation: true, + }, + { + country: "Netherlands", + iso: "NL", + code: "eu_vat", + description: "European VAT number", + example: "NL123456789B12", + impactsTaxCalculation: true, + }, + { + country: "New Zealand", + iso: "NZ", + code: "nz_gst", + description: "New Zealand GST number", + example: "123456789", + impactsTaxCalculation: true, + }, + { + country: "Nigeria", + iso: "NG", + code: "ng_tin", + description: "Nigerian Tax Identification Number", + example: "12345678-0001", + impactsTaxCalculation: false, + }, + { + country: "North Macedonia", + iso: "MK", + code: "mk_vat", + description: "North Macedonia VAT Number", + example: "MK1234567890123", + impactsTaxCalculation: true, + }, + { + country: "Norway", + iso: "NO", + code: "no_vat", + description: "Norwegian VAT number", + example: "123456789MVA", + impactsTaxCalculation: true, + }, + { + country: "Norway", + iso: "NO", + code: "no_voec", + description: "Norwegian VAT on e-commerce number", + example: "1234567", + impactsTaxCalculation: false, + }, + { + country: "Oman", + iso: "OM", + code: "om_vat", + description: "Omani VAT Number", + example: "OM1234567890", + impactsTaxCalculation: true, + }, + { + country: "Peru", + iso: "PE", + code: "pe_ruc", + description: "Peruvian RUC number", + example: "12345678901", + impactsTaxCalculation: true, + }, + { + country: "Philippines", + iso: "PH", + code: "ph_tin", + description: "Philippines Tax Identification Number", + example: "123456789012", + impactsTaxCalculation: true, + }, + { + country: "Poland", + iso: "PL", + code: "eu_vat", + description: "European VAT number", + example: "PL1234567890", + impactsTaxCalculation: true, + }, + { + country: "Portugal", + iso: "PT", + code: "eu_vat", + description: "European VAT number", + example: "PT123456789", + impactsTaxCalculation: true, + }, + { + country: "Romania", + iso: "RO", + code: "eu_vat", + description: "European VAT number", + example: "RO1234567891", + impactsTaxCalculation: true, + }, + { + country: "Romania", + iso: "RO", + code: "ro_tin", + description: "Romanian tax ID number", + example: "1234567890123", + impactsTaxCalculation: false, + }, + { + country: "Russia", + iso: "RU", + code: "ru_inn", + description: "Russian INN", + example: "1234567891", + impactsTaxCalculation: true, + }, + { + country: "Russia", + iso: "RU", + code: "ru_kpp", + description: "Russian KPP", + example: "123456789", + impactsTaxCalculation: true, + }, + { + country: "Saudi Arabia", + iso: "SA", + code: "sa_vat", + description: "Saudi Arabia VAT", + example: "123456789012345", + impactsTaxCalculation: true, + }, + { + country: "Senegal", + iso: "SN", + code: "sn_ninea", + description: "Senegal NINEA Number", + example: "12345672A2", + impactsTaxCalculation: false, + }, + { + country: "Serbia", + iso: "RS", + code: "rs_pib", + description: "Serbian PIB number", + example: "123456789", + impactsTaxCalculation: false, + }, + { + country: "Singapore", + iso: "SG", + code: "sg_gst", + description: "Singaporean GST", + example: "M12345678X", + impactsTaxCalculation: true, + }, + { + country: "Singapore", + iso: "SG", + code: "sg_uen", + description: "Singaporean UEN", + example: "123456789F", + impactsTaxCalculation: false, + }, + { + country: "Slovakia", + iso: "SK", + code: "eu_vat", + description: "European VAT number", + example: "SK1234567891", + impactsTaxCalculation: true, + }, + { + country: "Slovenia", + iso: "SI", + code: "eu_vat", + description: "European VAT number", + example: "SI12345678", + impactsTaxCalculation: true, + }, + { + country: "Slovenia", + iso: "SI", + code: "si_tin", + description: "Slovenia tax number (davčna številka)", + example: "12345678", + impactsTaxCalculation: false, + }, + { + country: "South Africa", + iso: "ZA", + code: "za_vat", + description: "South African VAT number", + example: "4123456789", + impactsTaxCalculation: true, + }, + { + country: "South Korea", + iso: "KR", + code: "kr_brn", + description: "Korean BRN", + example: "123-45-67890", + impactsTaxCalculation: true, + }, + { + country: "Spain", + iso: "ES", + code: "es_cif", + description: "Spanish NIF number (previously Spanish CIF number)", + example: "A12345678", + impactsTaxCalculation: false, + }, + { + country: "Spain", + iso: "ES", + code: "eu_vat", + description: "European VAT number", + example: "ESA1234567Z", + impactsTaxCalculation: true, + }, + { + country: "Suriname", + iso: "SR", + code: "sr_fin", + description: "Suriname FIN Number", + example: "1234567890", + impactsTaxCalculation: true, + }, + { + country: "Sweden", + iso: "SE", + code: "eu_vat", + description: "European VAT number", + example: "SE123456789123", + impactsTaxCalculation: true, + }, + { + country: "Switzerland", + iso: "CH", + code: "ch_uid", + description: "Switzerland UID number", + example: "CHE-123.456.789 HR", + impactsTaxCalculation: false, + }, + { + country: "Switzerland", + iso: "CH", + code: "ch_vat", + description: "Switzerland VAT number", + example: "CHE-123.456.789 MWST", + impactsTaxCalculation: true, + }, + { + country: "Taiwan", + iso: "TW", + code: "tw_vat", + description: "Taiwanese VAT", + example: "12345678", + impactsTaxCalculation: true, + }, + { + country: "Tajikistan", + iso: "TJ", + code: "tj_tin", + description: "Tajikistan Tax Identification Number", + example: "123456789", + impactsTaxCalculation: true, + }, + { + country: "Tanzania", + iso: "TZ", + code: "tz_vat", + description: "Tanzania VAT Number", + example: "12345678A", + impactsTaxCalculation: true, + }, + { + country: "Thailand", + iso: "TH", + code: "th_vat", + description: "Thai VAT", + example: "1234567891234", + impactsTaxCalculation: true, + }, + { + country: "Turkey", + iso: "TR", + code: "tr_tin", + description: "Turkish Tax Identification Number", + example: "123456789", + impactsTaxCalculation: true, + }, + { + country: "Uganda", + iso: "UG", + code: "ug_tin", + description: "Uganda Tax Identification Number", + example: "1014751879", + impactsTaxCalculation: true, + }, + { + country: "Ukraine", + iso: "UA", + code: "ua_vat", + description: "Ukrainian VAT", + example: "123456789", + impactsTaxCalculation: true, + }, + { + country: "United Arab Emirates", + iso: "AE", + code: "ae_trn", + description: "United Arab Emirates TRN", + example: "123456789012345", + impactsTaxCalculation: true, + }, + { + country: "United Kingdom", + iso: "GB", + code: "eu_vat", + description: "Northern Ireland VAT number", + example: "XI123456789", + impactsTaxCalculation: true, + }, + { + country: "United Kingdom", + iso: "GB", + code: "gb_vat", + description: "United Kingdom VAT number", + example: "GB123456789", + impactsTaxCalculation: true, + }, + { + country: "United States", + iso: "US", + code: "us_ein", + description: "United States EIN", + example: "12-3456789", + impactsTaxCalculation: false, + }, + { + country: "Uruguay", + iso: "UY", + code: "uy_ruc", + description: "Uruguayan RUC number", + example: "123456789012", + impactsTaxCalculation: true, + }, + { + country: "Uzbekistan", + iso: "UZ", + code: "uz_tin", + description: "Uzbekistan TIN Number", + example: "123456789", + impactsTaxCalculation: false, + }, + { + country: "Uzbekistan", + iso: "UZ", + code: "uz_vat", + description: "Uzbekistan VAT Number", + example: "123456789012", + impactsTaxCalculation: true, + }, + { + country: "Venezuela", + iso: "VE", + code: "ve_rif", + description: "Venezuelan RIF number", + example: "A-12345678-9", + impactsTaxCalculation: false, + }, + { + country: "Vietnam", + iso: "VN", + code: "vn_tin", + description: "Vietnamese tax ID number", + example: "1234567890", + impactsTaxCalculation: false, + }, + { + country: "Zambia", + iso: "ZM", + code: "zm_tin", + description: "Zambia Tax Identification Number", + example: "1004751879", + impactsTaxCalculation: false, + }, + { + country: "Zimbabwe", + iso: "ZW", + code: "zw_tin", + description: "Zimbabwe Tax Identification Number", + example: "1234567890", + impactsTaxCalculation: false, + }, +]; diff --git a/apps/web/src/app/billing/payment/types/tax-id.ts b/apps/web/src/app/billing/payment/types/tax-id.ts new file mode 100644 index 00000000000..80df42a3436 --- /dev/null +++ b/apps/web/src/app/billing/payment/types/tax-id.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export interface TaxId { + code: string; + value: string; +} + +export class TaxIdResponse extends BaseResponse implements TaxId { + code: string; + value: string; + + constructor(response: any) { + super(response); + + this.code = this.getResponseProperty("Code"); + this.value = this.getResponseProperty("Value"); + } +} diff --git a/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts b/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts new file mode 100644 index 00000000000..def240f534b --- /dev/null +++ b/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts @@ -0,0 +1,22 @@ +export const TokenizablePaymentMethods = { + bankAccount: "bankAccount", + card: "card", + payPal: "payPal", +} as const; + +export type BankAccountPaymentMethod = typeof TokenizablePaymentMethods.bankAccount; +export type CardPaymentMethod = typeof TokenizablePaymentMethods.card; +export type PayPalPaymentMethod = typeof TokenizablePaymentMethods.payPal; + +export type TokenizablePaymentMethod = + (typeof TokenizablePaymentMethods)[keyof typeof TokenizablePaymentMethods]; + +export const isTokenizablePaymentMethod = (value: string): value is TokenizablePaymentMethod => { + const valid = Object.values(TokenizablePaymentMethods) as readonly string[]; + return valid.includes(value); +}; + +export type TokenizedPaymentMethod = { + type: TokenizablePaymentMethod; + token: string; +}; diff --git a/apps/web/src/app/billing/services/billing.client.ts b/apps/web/src/app/billing/services/billing.client.ts new file mode 100644 index 00000000000..69f82eab19a --- /dev/null +++ b/apps/web/src/app/billing/services/billing.client.ts @@ -0,0 +1,153 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; + +import { + BillingAddress, + BillingAddressResponse, + MaskedPaymentMethod, + MaskedPaymentMethodResponse, + TokenizedPaymentMethod, +} from "../payment/types"; +import { BillableEntity } from "../types"; + +type Result = + | { + type: "success"; + value: T; + } + | { + type: "error"; + message: string; + }; + +@Injectable() +export class BillingClient { + constructor(private apiService: ApiService) {} + + private getEndpoint = (entity: BillableEntity): string => { + switch (entity.type) { + case "account": { + return "/account/billing/vnext"; + } + case "organization": { + return `/organizations/${entity.data.id}/billing/vnext`; + } + case "provider": { + return `/providers/${entity.data.id}/billing/vnext`; + } + } + }; + + addCreditWithBitPay = async ( + owner: BillableEntity, + credit: { amount: number; redirectUrl: string }, + ): Promise> => { + const path = `${this.getEndpoint(owner)}/credit/bitpay`; + try { + const data = await this.apiService.send("POST", path, credit, true, true); + return { + type: "success", + value: data as string, + }; + } catch (error: any) { + if (error instanceof ErrorResponse) { + return { + type: "error", + message: error.message, + }; + } + throw error; + } + }; + + getBillingAddress = async (owner: BillableEntity): Promise => { + const path = `${this.getEndpoint(owner)}/address`; + const data = await this.apiService.send("GET", path, null, true, true); + return data ? new BillingAddressResponse(data) : null; + }; + + getCredit = async (owner: BillableEntity): Promise => { + const path = `${this.getEndpoint(owner)}/credit`; + const data = await this.apiService.send("GET", path, null, true, true); + return data ? (data as number) : null; + }; + + getPaymentMethod = async (owner: BillableEntity): Promise => { + const path = `${this.getEndpoint(owner)}/payment-method`; + const data = await this.apiService.send("GET", path, null, true, true); + return data ? new MaskedPaymentMethodResponse(data).value : null; + }; + + updateBillingAddress = async ( + owner: BillableEntity, + billingAddress: BillingAddress, + ): Promise> => { + const path = `${this.getEndpoint(owner)}/address`; + try { + const data = await this.apiService.send("PUT", path, billingAddress, true, true); + return { + type: "success", + value: new BillingAddressResponse(data), + }; + } catch (error: any) { + if (error instanceof ErrorResponse) { + return { + type: "error", + message: error.message, + }; + } + throw error; + } + }; + + updatePaymentMethod = async ( + owner: BillableEntity, + paymentMethod: TokenizedPaymentMethod, + billingAddress: Pick | null, + ): Promise> => { + const path = `${this.getEndpoint(owner)}/payment-method`; + try { + const request = { + ...paymentMethod, + billingAddress, + }; + const data = await this.apiService.send("PUT", path, request, true, true); + return { + type: "success", + value: new MaskedPaymentMethodResponse(data).value, + }; + } catch (error: any) { + if (error instanceof ErrorResponse) { + return { + type: "error", + message: error.message, + }; + } + throw error; + } + }; + + verifyBankAccount = async ( + owner: BillableEntity, + descriptorCode: string, + ): Promise> => { + const path = `${this.getEndpoint(owner)}/payment-method/verify-bank-account`; + try { + const data = await this.apiService.send("POST", path, { descriptorCode }, true, true); + return { + type: "success", + value: new MaskedPaymentMethodResponse(data).value, + }; + } catch (error: any) { + if (error instanceof ErrorResponse) { + return { + type: "error", + message: error.message, + }; + } + throw error; + } + }; +} diff --git a/apps/web/src/app/billing/services/index.ts b/apps/web/src/app/billing/services/index.ts index e291ca6a454..dcd2c05034a 100644 --- a/apps/web/src/app/billing/services/index.ts +++ b/apps/web/src/app/billing/services/index.ts @@ -1,3 +1,4 @@ +export * from "./billing.client"; export * from "./billing-services.module"; export * from "./braintree.service"; export * from "./stripe.service"; diff --git a/apps/web/src/app/billing/services/stripe.service.ts b/apps/web/src/app/billing/services/stripe.service.ts index 360187ecd1e..7ea0d7d52c8 100644 --- a/apps/web/src/app/billing/services/stripe.service.ts +++ b/apps/web/src/app/billing/services/stripe.service.ts @@ -2,11 +2,43 @@ // @ts-strict-ignore import { Injectable } from "@angular/core"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { BankAccount } from "@bitwarden/common/billing/models/domain"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { BankAccountPaymentMethod, CardPaymentMethod } from "../payment/types"; + import { BillingServicesModule } from "./billing-services.module"; +type SetupBankAccountRequest = { + payment_method: { + us_bank_account: { + routing_number: string; + account_number: string; + account_holder_type: string; + }; + billing_details: { + name: string; + address?: { + country: string; + postal_code: string; + }; + }; + }; +}; + +type SetupCardRequest = { + payment_method: { + card: string; + billing_details?: { + address: { + country: string; + postal_code: string; + }; + }; + }; +}; + @Injectable({ providedIn: BillingServicesModule }) export class StripeService { private stripe: any; @@ -17,7 +49,28 @@ export class StripeService { cardCvc: string; }; - constructor(private logService: LogService) {} + constructor( + private apiService: ApiService, + private logService: LogService, + ) {} + + createSetupIntent = async ( + paymentMethod: BankAccountPaymentMethod | CardPaymentMethod, + ): Promise => { + const getPath = () => { + switch (paymentMethod) { + case "bankAccount": { + return "/setup-intent/bank-account"; + } + case "card": { + return "/setup-intent/card"; + } + } + }; + + const response = await this.apiService.send("POST", getPath(), null, true, true); + return response as string; + }; /** * Loads [Stripe JS]{@link https://docs.stripe.com/js} in the element of the current page and mounts @@ -51,25 +104,28 @@ export class StripeService { window.document.head.appendChild(script); } - /** - * Re-mounts previously created Stripe credit card [elements]{@link https://docs.stripe.com/js/elements_object/create} into the HTML elements - * specified during the {@link loadStripe} call. This is useful for when those HTML elements are removed from the DOM by Angular. - */ - mountElements(i: number = 0) { + mountElements(attempt: number = 1) { setTimeout(() => { - if (!document.querySelector(this.elementIds.cardNumber) && i < 10) { - this.logService.warning("Stripe container missing, retrying..."); - this.mountElements(i + 1); - return; - } + if (!this.elements) { + this.logService.warning(`Stripe elements are missing, retrying for attempt ${attempt}...`); + this.mountElements(attempt + 1); + } else { + const cardNumber = this.elements.getElement("cardNumber"); + const cardExpiry = this.elements.getElement("cardExpiry"); + const cardCVC = this.elements.getElement("cardCvc"); - const cardNumber = this.elements.getElement("cardNumber"); - const cardExpiry = this.elements.getElement("cardExpiry"); - const cardCvc = this.elements.getElement("cardCvc"); - cardNumber.mount(this.elementIds.cardNumber); - cardExpiry.mount(this.elementIds.cardExpiry); - cardCvc.mount(this.elementIds.cardCvc); - }, 50); + if ([cardNumber, cardExpiry, cardCVC].some((element) => !element)) { + this.logService.warning( + `Some Stripe card elements are missing, retrying for attempt ${attempt}...`, + ); + this.mountElements(attempt + 1); + } else { + cardNumber.mount(this.elementIds.cardNumber); + cardExpiry.mount(this.elementIds.cardExpiry); + cardCVC.mount(this.elementIds.cardCvc); + } + } + }, 100); } /** @@ -81,8 +137,9 @@ export class StripeService { async setupBankAccountPaymentMethod( clientSecret: string, { accountHolderName, routingNumber, accountNumber, accountHolderType }: BankAccount, + billingDetails?: { country: string; postalCode: string }, ): Promise { - const result = await this.stripe.confirmUsBankAccountSetup(clientSecret, { + const request: SetupBankAccountRequest = { payment_method: { us_bank_account: { routing_number: routingNumber, @@ -93,7 +150,16 @@ export class StripeService { name: accountHolderName, }, }, - }); + }; + + if (billingDetails) { + request.payment_method.billing_details.address = { + country: billingDetails.country, + postal_code: billingDetails.postalCode, + }; + } + + const result = await this.stripe.confirmUsBankAccountSetup(clientSecret, request); if (result.error || (result.setupIntent && result.setupIntent.status !== "requires_action")) { this.logService.error(result.error); throw result.error; @@ -107,13 +173,25 @@ export class StripeService { * thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}. * @returns The ID of the newly created PaymentMethod. */ - async setupCardPaymentMethod(clientSecret: string): Promise { + async setupCardPaymentMethod( + clientSecret: string, + billingDetails?: { country: string; postalCode: string }, + ): Promise { const cardNumber = this.elements.getElement("cardNumber"); - const result = await this.stripe.confirmCardSetup(clientSecret, { + const request: SetupCardRequest = { payment_method: { card: cardNumber, }, - }); + }; + if (billingDetails) { + request.payment_method.billing_details = { + address: { + country: billingDetails.country, + postal_code: billingDetails.postalCode, + }, + }; + } + const result = await this.stripe.confirmCardSetup(clientSecret, request); if (result.error || (result.setupIntent && result.setupIntent.status !== "succeeded")) { this.logService.error(result.error); throw result.error; diff --git a/apps/web/src/app/billing/services/trial-flow.service.ts b/apps/web/src/app/billing/services/trial-flow.service.ts index 81bcf8dcabd..831cc129e60 100644 --- a/apps/web/src/app/billing/services/trial-flow.service.ts +++ b/apps/web/src/app/billing/services/trial-flow.service.ts @@ -11,6 +11,8 @@ import { BillingSourceResponse } from "@bitwarden/common/billing/models/response import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogService } from "@bitwarden/components"; @@ -28,6 +30,7 @@ export class TrialFlowService { private router: Router, protected billingApiService: BillingApiServiceAbstraction, private organizationApiService: OrganizationApiServiceAbstraction, + private configService: ConfigService, ) {} checkForOrgsWithUpcomingPaymentIssues( organization: Organization, @@ -131,7 +134,11 @@ export class TrialFlowService { } private async navigateToPaymentMethod(orgId: string) { - await this.router.navigate(["organizations", `${orgId}`, "billing", "payment-method"], { + const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + ); + const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; + await this.router.navigate(["organizations", `${orgId}`, "billing", route], { state: { launchPaymentModalAutomatically: true }, queryParams: { launchPaymentModalAutomatically: true }, }); diff --git a/apps/web/src/app/billing/shared/payment-method.component.ts b/apps/web/src/app/billing/shared/payment-method.component.ts index 74793bccc01..0e116b4f39a 100644 --- a/apps/web/src/app/billing/shared/payment-method.component.ts +++ b/apps/web/src/app/billing/shared/payment-method.component.ts @@ -18,7 +18,9 @@ import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank.request"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; @@ -79,6 +81,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { private organizationService: OrganizationService, private accountService: AccountService, protected syncService: SyncService, + private configService: ConfigService, ) { const state = this.router.getCurrentNavigation()?.extras?.state; // incase the above state is undefined or null we use redundantState @@ -107,6 +110,14 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { return; } + const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + ); + + if (managePaymentDetailsOutsideCheckout) { + await this.router.navigate(["../payment-details"], { relativeTo: this.route }); + } + await this.load(); this.firstLoaded = true; }); diff --git a/apps/web/src/app/billing/types/billable-entity.ts b/apps/web/src/app/billing/types/billable-entity.ts new file mode 100644 index 00000000000..79ed12a4161 --- /dev/null +++ b/apps/web/src/app/billing/types/billable-entity.ts @@ -0,0 +1,42 @@ +import { map } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; + +export type BillableEntity = + | { type: "account"; data: Account } + | { type: "organization"; data: Organization } + | { type: "provider"; data: Provider }; + +export const accountToBillableEntity = map((account) => { + if (!account) { + throw new Error("Account not found"); + } + return { + type: "account", + data: account, + }; +}); + +export const organizationToBillableEntity = map( + (organization) => { + if (!organization) { + throw new Error("Organization not found"); + } + return { + type: "organization", + data: organization, + }; + }, +); + +export const providerToBillableEntity = map((provider) => { + if (!provider) { + throw new Error("Organization not found"); + } + return { + type: "provider", + data: provider, + }; +}); diff --git a/apps/web/src/app/billing/types/index.ts b/apps/web/src/app/billing/types/index.ts new file mode 100644 index 00000000000..1278e0f2e14 --- /dev/null +++ b/apps/web/src/app/billing/types/index.ts @@ -0,0 +1,2 @@ +export * from "./billable-entity"; +export * from "./free-trial"; diff --git a/apps/web/src/app/billing/warnings/components/index.ts b/apps/web/src/app/billing/warnings/components/index.ts new file mode 100644 index 00000000000..1e1e0682e62 --- /dev/null +++ b/apps/web/src/app/billing/warnings/components/index.ts @@ -0,0 +1,2 @@ +export * from "./organization-free-trial-warning.component"; +export * from "./organization-reseller-renewal-warning.component"; diff --git a/apps/web/src/app/billing/warnings/free-trial-warning.component.ts b/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts similarity index 68% rename from apps/web/src/app/billing/warnings/free-trial-warning.component.ts rename to apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts index b000878bf66..074358537b6 100644 --- a/apps/web/src/app/billing/warnings/free-trial-warning.component.ts +++ b/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts @@ -6,15 +6,13 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -import { - FreeTrialWarning, - OrganizationWarningsService, -} from "../services/organization-warnings.service"; +import { OrganizationWarningsService } from "../services"; +import { OrganizationFreeTrialWarning } from "../types"; @Component({ - selector: "app-free-trial-warning", + selector: "app-organization-free-trial-warning", template: ` - @let warning = freeTrialWarning$ | async; + @let warning = warning$ | async; @if (warning) { (); - freeTrialWarning$!: Observable; + warning$!: Observable; constructor(private organizationWarningsService: OrganizationWarningsService) {} ngOnInit() { - this.freeTrialWarning$ = this.organizationWarningsService.getFreeTrialWarning$( - this.organization, - ); + this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization); } + + refresh = () => { + this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization, true); + }; } diff --git a/apps/web/src/app/billing/warnings/reseller-renewal-warning.component.ts b/apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts similarity index 63% rename from apps/web/src/app/billing/warnings/reseller-renewal-warning.component.ts rename to apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts index 6bcfba5ce6c..f45dd443dda 100644 --- a/apps/web/src/app/billing/warnings/reseller-renewal-warning.component.ts +++ b/apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts @@ -5,15 +5,13 @@ import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { BannerComponent } from "@bitwarden/components"; -import { - OrganizationWarningsService, - ResellerRenewalWarning, -} from "../services/organization-warnings.service"; +import { OrganizationWarningsService } from "../services"; +import { OrganizationResellerRenewalWarning } from "../types"; @Component({ - selector: "app-reseller-renewal-warning", + selector: "app-organization-reseller-renewal-warning", template: ` - @let warning = resellerRenewalWarning$ | async; + @let warning = warning$ | async; @if (warning) { ; + warning$!: Observable; constructor(private organizationWarningsService: OrganizationWarningsService) {} ngOnInit() { - this.resellerRenewalWarning$ = this.organizationWarningsService.getResellerRenewalWarning$( - this.organization, - ); + this.warning$ = this.organizationWarningsService.getResellerRenewalWarning$(this.organization); } } diff --git a/apps/web/src/app/billing/warnings/services/index.ts b/apps/web/src/app/billing/warnings/services/index.ts new file mode 100644 index 00000000000..fbd1c56f350 --- /dev/null +++ b/apps/web/src/app/billing/warnings/services/index.ts @@ -0,0 +1 @@ +export * from "./organization-warnings.service"; diff --git a/apps/web/src/app/billing/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts similarity index 100% rename from apps/web/src/app/billing/services/organization-warnings.service.spec.ts rename to apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts diff --git a/apps/web/src/app/billing/services/organization-warnings.service.ts b/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts similarity index 78% rename from apps/web/src/app/billing/services/organization-warnings.service.ts rename to apps/web/src/app/billing/warnings/services/organization-warnings.service.ts index f75220a7744..fa53992afe0 100644 --- a/apps/web/src/app/billing/services/organization-warnings.service.ts +++ b/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts @@ -1,25 +1,20 @@ import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; -import { - filter, - from, - lastValueFrom, - map, - Observable, - shareReplay, - switchMap, - takeWhile, -} from "rxjs"; +import { filter, from, lastValueFrom, map, Observable, switchMap, takeWhile } from "rxjs"; import { take } from "rxjs/operators"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; -import { openChangePlanDialog } from "@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component"; + +import { openChangePlanDialog } from "../../organizations/change-plan-dialog.component"; +import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning } from "../types"; const format = (date: Date) => date.toLocaleDateString("en-US", { @@ -28,21 +23,12 @@ const format = (date: Date) => year: "numeric", }); -export type FreeTrialWarning = { - organization: Pick; - message: string; -}; - -export type ResellerRenewalWarning = { - type: "info" | "warning"; - message: string; -}; - @Injectable({ providedIn: "root" }) export class OrganizationWarningsService { private cache$ = new Map>(); constructor( + private configService: ConfigService, private dialogService: DialogService, private i18nService: I18nService, private organizationApiService: OrganizationApiServiceAbstraction, @@ -50,8 +36,11 @@ export class OrganizationWarningsService { private router: Router, ) {} - getFreeTrialWarning$ = (organization: Organization): Observable => - this.getWarning$(organization, (response) => response.freeTrial).pipe( + getFreeTrialWarning$ = ( + organization: Organization, + bypassCache: boolean = false, + ): Observable => + this.getWarning$(organization, (response) => response.freeTrial, bypassCache).pipe( map((warning) => { const { remainingTrialDays } = warning; @@ -76,9 +65,12 @@ export class OrganizationWarningsService { }), ); - getResellerRenewalWarning$ = (organization: Organization): Observable => - this.getWarning$(organization, (response) => response.resellerRenewal).pipe( - map((warning): ResellerRenewalWarning | null => { + getResellerRenewalWarning$ = ( + organization: Organization, + bypassCache: boolean = false, + ): Observable => + this.getWarning$(organization, (response) => response.resellerRenewal, bypassCache).pipe( + map((warning): OrganizationResellerRenewalWarning | null => { switch (warning.type) { case "upcoming": { return { @@ -116,8 +108,11 @@ export class OrganizationWarningsService { filter((result): result is NonNullable => result !== null), ); - showInactiveSubscriptionDialog$ = (organization: Organization): Observable => - this.getWarning$(organization, (response) => response.inactiveSubscription).pipe( + showInactiveSubscriptionDialog$ = ( + organization: Organization, + bypassCache: boolean = false, + ): Observable => + this.getWarning$(organization, (response) => response.inactiveSubscription, bypassCache).pipe( switchMap(async (warning) => { switch (warning.resolution) { case "contact_provider": { @@ -142,8 +137,14 @@ export class OrganizationWarningsService { cancelButtonText: this.i18nService.t("close"), }); if (confirmed) { + const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + ); + const route = managePaymentDetailsOutsideCheckout + ? "payment-details" + : "payment-method"; await this.router.navigate( - ["organizations", `${organization.id}`, "billing", "payment-method"], + ["organizations", `${organization.id}`, "billing", route], { state: { launchPaymentModalAutomatically: true }, }, @@ -177,14 +178,15 @@ export class OrganizationWarningsService { }), ); - private getResponse$ = (organization: Organization): Observable => { + private getResponse$ = ( + organization: Organization, + bypassCache: boolean = false, + ): Observable => { const existing = this.cache$.get(organization.id as OrganizationId); - if (existing) { + if (existing && !bypassCache) { return existing; } - const response$ = from(this.organizationBillingApiService.getWarnings(organization.id)).pipe( - shareReplay({ bufferSize: 1, refCount: false }), - ); + const response$ = from(this.organizationBillingApiService.getWarnings(organization.id)); this.cache$.set(organization.id as OrganizationId, response$); return response$; }; @@ -192,8 +194,9 @@ export class OrganizationWarningsService { private getWarning$ = ( organization: Organization, extract: (response: OrganizationWarningsResponse) => T | null | undefined, + bypassCache: boolean = false, ): Observable => - this.getResponse$(organization).pipe( + this.getResponse$(organization, bypassCache).pipe( map(extract), takeWhile((warning): warning is T => !!warning), take(1), diff --git a/apps/web/src/app/billing/warnings/types/index.ts b/apps/web/src/app/billing/warnings/types/index.ts new file mode 100644 index 00000000000..fc0c7d278ed --- /dev/null +++ b/apps/web/src/app/billing/warnings/types/index.ts @@ -0,0 +1 @@ +export * from "./organization-warnings"; diff --git a/apps/web/src/app/billing/warnings/types/organization-warnings.ts b/apps/web/src/app/billing/warnings/types/organization-warnings.ts new file mode 100644 index 00000000000..96bf5aff6f1 --- /dev/null +++ b/apps/web/src/app/billing/warnings/types/organization-warnings.ts @@ -0,0 +1,11 @@ +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; + +export type OrganizationFreeTrialWarning = { + organization: Pick; + message: string; +}; + +export type OrganizationResellerRenewalWarning = { + type: "info" | "warning"; + message: string; +}; diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts index 997d9bc3fe3..197b6426468 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts @@ -8,6 +8,7 @@ import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; @@ -87,6 +88,10 @@ describe("VaultBannersComponent", () => { allMessages$: messageSubject.asObservable(), }), }, + { + provide: ConfigService, + useValue: mock(), + }, ], }) .overrideProvider(VaultBannersService, { useValue: bannerService }) diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts index 7eafaa50c18..4dd5bb7ff2d 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts @@ -1,9 +1,11 @@ import { Component, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { firstValueFrom, map, Observable, switchMap, filter } from "rxjs"; +import { filter, firstValueFrom, map, Observable, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { UserId } from "@bitwarden/common/types/guid"; @@ -35,6 +37,7 @@ export class VaultBannersComponent implements OnInit { private i18nService: I18nService, private accountService: AccountService, private messageListener: MessageListener, + private configService: ConfigService, ) { this.premiumBannerVisible$ = this.activeUserId$.pipe( filter((userId): userId is UserId => userId != null), @@ -68,12 +71,16 @@ export class VaultBannersComponent implements OnInit { } async navigateToPaymentMethod(organizationId: string): Promise { + const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + ); + const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; const navigationExtras = { state: { launchPaymentModalAutomatically: true }, }; await this.router.navigate( - ["organizations", organizationId, "billing", "payment-method"], + ["organizations", organizationId, "billing", route], navigationExtras, ); } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 50a2cdbc4a9..9150028f4d6 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10802,5 +10802,53 @@ "billingAddressRequiredToAddCredit": { "message": "Billing address required to add credit.", "description": "Error message shown when trying to add credit to a trialing organization without a billing address." + }, + "billingAddress": { + "message": "Billing address" + }, + "addBillingAddress": { + "message": "Add billing address" + }, + "editBillingAddress": { + "message": "Edit billing address" + }, + "noBillingAddress": { + "message": "No address on file." + }, + "billingAddressUpdated": { + "message": "Your billing address has been updated." + }, + "paymentDetails": { + "message": "Payment details" + }, + "paymentMethodUpdated": { + "message": "Your payment method has been updated." + }, + "bankAccountVerified": { + "message": "Your bank account has been verified." + }, + "availableCreditAppliedToInvoice": { + "message": "Any available credit will be automatically applied towards invoices generated for this account." + }, + "mustBePositiveNumber": { + "message": "Must be a positive number" + }, + "cardSecurityCode": { + "message": "Card security code" + }, + "cardSecurityCodeDescription": { + "message": "Card security code, also known as CVV or CVC, is typically a 3 digit number printed on the back of your credit card or 4 digit number printed on the front above your card number." + }, + "verifyBankAccountWarning": { + "message": "Payment with a bank account is only available to customers in the United States. You will be required to verify your bank account. We will make a micro-deposit within the next 1-2 business days. Enter the statement descriptor code from this deposit on the Payment Details page to verify the bank account. Failure to verify the bank account will result in a missed payment and your subscription being suspended." + }, + "taxId": { + "message": "Tax ID: $TAX_ID$", + "placeholders": { + "tax_id": { + "content": "$1", + "example": "12-3456789" + } + } } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index 8266b20b306..0a084848dbe 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -31,6 +31,12 @@ *ngIf="canAccessBilling$ | async" > + @if (managePaymentDetailsOutsideCheckout$ | async) { + + } ; protected clientsTranslationKey$: Observable; + protected managePaymentDetailsOutsideCheckout$: Observable; constructor( private route: ActivatedRoute, private providerService: ProviderService, + private configService: ConfigService, ) {} ngOnInit() { @@ -69,6 +73,10 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { provider.providerType === ProviderType.BusinessUnit ? "businessUnits" : "clients", ), ); + + this.managePaymentDetailsOutsideCheckout$ = this.configService.getFeatureFlag$( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + ); } ngOnDestroy() { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts index 482d2c881c1..7a554275f08 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts @@ -13,6 +13,7 @@ import { hasConsolidatedBilling, ProviderBillingHistoryComponent, } from "../../billing/providers"; +import { ProviderPaymentDetailsComponent } from "../../billing/providers/payment-details/provider-payment-details.component"; import { SetupBusinessUnitComponent } from "../../billing/providers/setup/setup-business-unit.component"; import { ClientsComponent } from "./clients/clients.component"; @@ -142,6 +143,14 @@ const routes: Routes = [ titleId: "subscription", }, }, + { + path: "payment-details", + component: ProviderPaymentDetailsComponent, + canActivate: [providerPermissionsGuard()], + data: { + titleId: "paymentDetails", + }, + }, { path: "history", component: ProviderBillingHistoryComponent, diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.html b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.html new file mode 100644 index 00000000000..375faab8d34 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.html @@ -0,0 +1,33 @@ + + + @let view = view$ | async; + @if (!view) { + + + {{ "loading" | i18n }} + + } @else { + + + + + + + + } + diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts new file mode 100644 index 00000000000..dbf948518a2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts @@ -0,0 +1,133 @@ +import { Component } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + BehaviorSubject, + EMPTY, + filter, + from, + map, + merge, + Observable, + shareReplay, + switchMap, + tap, +} from "rxjs"; +import { catchError } from "rxjs/operators"; + +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { + DisplayAccountCreditComponent, + DisplayBillingAddressComponent, + DisplayPaymentMethodComponent, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + BillingAddress, + MaskedPaymentMethod, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { BillingClient } from "@bitwarden/web-vault/app/billing/services"; +import { BillableEntity, providerToBillableEntity } from "@bitwarden/web-vault/app/billing/types"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +class RedirectError { + constructor( + public path: string[], + public relativeTo: ActivatedRoute, + ) {} +} + +type View = { + provider: BillableEntity; + paymentMethod: MaskedPaymentMethod | null; + billingAddress: BillingAddress | null; + credit: number | null; +}; + +@Component({ + templateUrl: "./provider-payment-details.component.html", + standalone: true, + imports: [ + DisplayBillingAddressComponent, + DisplayAccountCreditComponent, + DisplayPaymentMethodComponent, + HeaderModule, + SharedModule, + ], + providers: [BillingClient], +}) +export class ProviderPaymentDetailsComponent { + private viewState$ = new BehaviorSubject(null); + + private load$: Observable = this.activatedRoute.params.pipe( + switchMap(({ providerId }) => this.providerService.get$(providerId)), + switchMap((provider) => + this.configService + .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) + .pipe( + map((managePaymentDetailsOutsideCheckout) => { + if (!managePaymentDetailsOutsideCheckout) { + throw new RedirectError(["../subscription"], this.activatedRoute); + } + return provider; + }), + ), + ), + providerToBillableEntity, + switchMap(async (provider) => { + const [paymentMethod, billingAddress, credit] = await Promise.all([ + this.billingClient.getPaymentMethod(provider), + this.billingClient.getBillingAddress(provider), + this.billingClient.getCredit(provider), + ]); + + return { + provider, + paymentMethod, + billingAddress, + credit, + }; + }), + shareReplay({ bufferSize: 1, refCount: false }), + catchError((error: unknown) => { + if (error instanceof RedirectError) { + return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( + switchMap(() => EMPTY), + ); + } + throw error; + }), + ); + + view$: Observable = merge( + this.load$.pipe(tap((view) => this.viewState$.next(view))), + this.viewState$.pipe(filter((view): view is View => view !== null)), + ).pipe(shareReplay({ bufferSize: 1, refCount: true })); + + constructor( + private activatedRoute: ActivatedRoute, + private billingClient: BillingClient, + private configService: ConfigService, + private providerService: ProviderService, + private router: Router, + ) {} + + setBillingAddress = (billingAddress: BillingAddress) => { + if (this.viewState$.value) { + this.viewState$.next({ + ...this.viewState$.value, + billingAddress, + }); + } + }; + + setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => { + if (this.viewState$.value) { + this.viewState$.next({ + ...this.viewState$.value, + paymentMethod, + }); + } + }; +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html index 7f2b205fc22..0205d2838d1 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html @@ -62,49 +62,51 @@
- - -

- {{ "accountCredit" | i18n }} -

-

{{ subscription.accountCredit | currency: "$" }}

-

{{ "creditAppliedDesc" | i18n }}

-
- - -

{{ "paymentMethod" | i18n }}

-

- {{ "noPaymentMethod" | i18n }} -

- - - -

- - {{ subscription.paymentSource.description }} - - {{ "unverified" | i18n }} + @if (!managePaymentDetailsOutsideCheckout) { + + +

+ {{ "accountCredit" | i18n }} +

+

{{ subscription.accountCredit | currency: "$" }}

+

{{ "creditAppliedDesc" | i18n }}

+
+ + +

{{ "paymentMethod" | i18n }}

+

+ {{ "noPaymentMethod" | i18n }}

- - -
- - -

{{ "taxInformation" | i18n }}

-

{{ "taxInformationDesc" | i18n }}

- -
+ + + +

+ + {{ subscription.paymentSource.description }} + - {{ "unverified" | i18n }} +

+
+ + + + +

{{ "taxInformation" | i18n }}

+

{{ "taxInformationDesc" | i18n }}

+ +
+ } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts index cff2d8e63fe..83a23760d80 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts @@ -13,6 +13,8 @@ import { ProviderPlanResponse, ProviderSubscriptionResponse, } from "@bitwarden/common/billing/models/response/provider-subscription-response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service"; @@ -34,6 +36,7 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { protected loading: boolean; private destroy$ = new Subject(); protected totalCost: number; + protected managePaymentDetailsOutsideCheckout: boolean; protected readonly TaxInformation = TaxInformation; @@ -44,6 +47,7 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { private billingNotificationService: BillingNotificationService, private dialogService: DialogService, private toastService: ToastService, + private configService: ConfigService, ) {} async ngOnInit() { @@ -51,6 +55,9 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { .pipe( concatMap(async (params) => { this.providerId = params.providerId; + this.managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + ); await this.load(); this.firstLoaded = true; }), diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts index 1fd0afd3458..ca17ea3bc94 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -29,6 +29,8 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -129,6 +131,7 @@ export class OverviewComponent implements OnInit, OnDestroy { private trialFlowService: TrialFlowService, private organizationBillingService: OrganizationBillingServiceAbstraction, private billingNotificationService: BillingNotificationService, + private configService: ConfigService, ) {} ngOnInit() { @@ -250,12 +253,13 @@ export class OverviewComponent implements OnInit, OnDestroy { } async navigateToPaymentMethod() { - await this.router.navigate( - ["organizations", `${this.organizationId}`, "billing", "payment-method"], - { - state: { launchPaymentModalAutomatically: true }, - }, + const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, ); + const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; + await this.router.navigate(["organizations", `${this.organizationId}`, "billing", route], { + state: { launchPaymentModalAutomatically: true }, + }); } ngOnDestroy(): void { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 68228b63bea..8d9eebe6f9f 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -34,6 +34,7 @@ export enum FeatureFlag { PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup", UseOrganizationWarningsService = "use-organization-warnings-service", AllowTrialLengthZero = "pm-20322-allow-trial-length-0", + PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout", /* Data Insights and Reporting */ EnableRiskInsightsNotifications = "enable-risk-insights-notifications", @@ -116,6 +117,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM19956_RequireProviderPaymentMethodDuringSetup]: FALSE, [FeatureFlag.UseOrganizationWarningsService]: FALSE, [FeatureFlag.AllowTrialLengthZero]: FALSE, + [FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE,