diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index 78005275f12..902cac9c771 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -1,16 +1,19 @@
- {{ "upgradeFreeOrganization" | i18n: currentPlanName }} + {{ dialogHeaderName }}

{{ "upgradePlans" | i18n }}

- {{ "selectAPlan" | i18n }} + {{ + "selectAPlan" | i18n + }}
+

{{ "paymentMethod" | i18n }}

-

+

{{ deprecateStripeSourcesAPI diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 9a80de555c6..d7ac442c40c 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -24,7 +24,14 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { + BillingApiServiceAbstraction, + BillingInformation, + OrganizationInformation, + PaymentInformation, + PlanInformation, + OrganizationBillingServiceAbstraction as OrganizationBillingService, +} from "@bitwarden/common/billing/abstractions"; import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; import { PaymentMethodType, @@ -49,6 +56,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv import { DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { BillingSharedModule } from "../shared/billing-shared.module"; import { PaymentV2Component } from "../shared/payment/payment-v2.component"; import { PaymentComponent } from "../shared/payment/payment.component"; @@ -89,6 +97,8 @@ interface OnSuccessArgs { @Component({ templateUrl: "./change-plan-dialog.component.html", + standalone: true, + imports: [BillingSharedModule], }) export class ChangePlanDialogComponent implements OnInit, OnDestroy { @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; @@ -163,6 +173,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { organization: Organization; sub: OrganizationSubscriptionResponse; billing: BillingResponse; + dialogHeaderName: string; currentPlanName: string; showPayment: boolean = false; totalOpened: boolean = false; @@ -174,6 +185,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { paymentSource?: PaymentSourceResponse; deprecateStripeSourcesAPI: boolean; + isSubscriptionCanceled: boolean = false; private destroy$ = new Subject(); @@ -196,6 +208,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { private configService: ConfigService, private billingApiService: BillingApiServiceAbstraction, private taxService: TaxServiceAbstraction, + private organizationBillingService: OrganizationBillingService, ) {} async ngOnInit(): Promise { @@ -208,6 +221,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.sub = this.dialogParams.subscription ?? (await this.organizationApiService.getSubscription(this.dialogParams.organizationId)); + this.dialogHeaderName = this.resolveHeaderName(this.sub); this.organizationId = this.dialogParams.organizationId; this.currentPlan = this.sub?.plan; this.selectedPlan = this.sub?.plan; @@ -281,6 +295,20 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.refreshSalesTax(); } + resolveHeaderName(subscription: OrganizationSubscriptionResponse): string { + if (subscription.subscription != null) { + this.isSubscriptionCanceled = subscription.subscription.cancelled; + if (subscription.subscription.cancelled) { + return this.i18nService.t("restartSubscription"); + } + } + + return this.i18nService.t( + "upgradeFreeOrganization", + this.resolvePlanName(this.dialogParams.productTierType), + ); + } + setInitialPlanSelection() { this.focusedIndex = this.selectableProducts.length - 1; this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); @@ -388,6 +416,19 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ]; } case PlanCardState.Disabled: { + if (this.isSubscriptionCanceled) { + return [ + "tw-cursor-not-allowed", + "tw-bg-secondary-100", + "tw-font-normal", + "tw-bg-blur", + "tw-text-muted", + "tw-block", + "tw-rounded", + "tw-w-80", + ]; + } + return [ "tw-cursor-not-allowed", "tw-bg-secondary-100", @@ -409,7 +450,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return; } - if (plan === this.currentPlan) { + if (plan === this.currentPlan && !this.isSubscriptionCanceled) { return; } this.selectedPlan = plan; @@ -446,6 +487,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } get selectableProducts() { + if (this.isSubscriptionCanceled) { + // Return only the current plan if the subscription is canceled + return [this.currentPlan]; + } + if (this.acceptingSponsorship) { const familyPlan = this.passwordManagerPlans.find( (plan) => plan.type === PlanType.FamiliesAnnually, @@ -692,11 +738,18 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { const doSubmit = async (): Promise => { let orgId: string = null; - orgId = await this.updateOrganization(); + if (this.isSubscriptionCanceled) { + await this.restartSubscription(); + orgId = this.organizationId; + } else { + orgId = await this.updateOrganization(); + } this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t("organizationUpgraded"), + message: this.isSubscriptionCanceled + ? this.i18nService.t("restartOrganizationSubscription") + : this.i18nService.t("organizationUpgraded"), }); await this.apiService.refreshIdentityToken(); @@ -726,6 +779,44 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.dialogRef.close(); }; + private async restartSubscription() { + const org = await this.organizationApiService.get(this.organizationId); + const organization: OrganizationInformation = { + name: org.name, + billingEmail: org.billingEmail, + }; + + const plan: PlanInformation = { + type: this.selectedPlan.type, + passwordManagerSeats: org.seats, + }; + + if (org.useSecretsManager) { + plan.subscribeToSecretsManager = true; + plan.secretsManagerSeats = org.smSeats; + } + + let paymentMethod: [string, PaymentMethodType]; + + if (this.deprecateStripeSourcesAPI) { + const { type, token } = await this.paymentV2Component.tokenize(); + paymentMethod = [token, type]; + } else { + paymentMethod = await this.paymentComponent.createPaymentToken(); + } + + const payment: PaymentInformation = { + paymentMethod, + billing: this.getBillingInformationFromTaxInfoComponent(), + }; + + await this.organizationBillingService.restartSubscription(this.organization.id, { + organization, + plan, + payment, + }); + } + private async updateOrganization() { const request = new OrganizationUpgradeRequest(); if (this.selectedPlan.productTier !== ProductTierType.Families) { @@ -802,6 +893,18 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return text; } + private getBillingInformationFromTaxInfoComponent(): BillingInformation { + return { + country: this.taxInformation.country, + postalCode: this.taxInformation.postalCode, + taxId: this.taxInformation.taxId, + addressLine1: this.taxInformation.line1, + addressLine2: this.taxInformation.line2, + city: this.taxInformation.city, + state: this.taxInformation.state, + }; + } + private buildSecretsManagerRequest(request: OrganizationUpgradeRequest): void { request.useSecretsManager = this.organization.useSecretsManager; if (!this.organization.useSecretsManager) { @@ -997,6 +1100,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } protected canUpdatePaymentInformation(): boolean { - return this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty(); + return ( + this.upgradeRequiresPaymentMethod || + this.showPayment || + this.isPaymentSourceEmpty() || + this.isSubscriptionCanceled + ); } } diff --git a/apps/web/src/app/billing/organizations/organization-billing.module.ts b/apps/web/src/app/billing/organizations/organization-billing.module.ts index b25cda662f2..48ac613711d 100644 --- a/apps/web/src/app/billing/organizations/organization-billing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing.module.ts @@ -8,7 +8,6 @@ import { BillingSharedModule } from "../shared"; import { AdjustSubscription } from "./adjust-subscription.component"; import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; import { BillingSyncKeyComponent } from "./billing-sync-key.component"; -import { ChangePlanDialogComponent } from "./change-plan-dialog.component"; import { ChangePlanComponent } from "./change-plan.component"; import { DownloadLicenceDialogComponent } from "./download-license.component"; import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component"; @@ -44,7 +43,6 @@ import { SubscriptionStatusComponent } from "./subscription-status.component"; SecretsManagerSubscribeStandaloneComponent, SubscriptionHiddenComponent, SubscriptionStatusComponent, - ChangePlanDialogComponent, OrganizationPaymentMethodComponent, ], }) 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 558851ad64c..a3a4ba6bba1 100644 --- a/apps/web/src/app/billing/services/trial-flow.service.ts +++ b/apps/web/src/app/billing/services/trial-flow.service.ts @@ -2,25 +2,37 @@ // @ts-strict-ignore import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; +import { lastValueFrom } from "rxjs"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { BillingSourceResponse } from "@bitwarden/common/billing/models/response/billing.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"; import { FreeTrial } from "../../core/types/free-trial"; +import { + ChangePlanDialogResultType, + openChangePlanDialog, +} from "../organizations/change-plan-dialog.component"; @Injectable({ providedIn: "root" }) export class TrialFlowService { + private resellerManagedOrgAlert: boolean; + constructor( private i18nService: I18nService, protected dialogService: DialogService, private router: Router, protected billingApiService: BillingApiServiceAbstraction, + private organizationApiService: OrganizationApiServiceAbstraction, + private configService: ConfigService, ) {} checkForOrgsWithUpcomingPaymentIssues( organization: Organization, @@ -66,16 +78,31 @@ export class TrialFlowService { org: Organization, organizationBillingMetadata: OrganizationBillingMetadataResponse, ): Promise { - if (organizationBillingMetadata.isSubscriptionUnpaid) { - const confirmed = await this.promptForPaymentNavigation(org); + if ( + organizationBillingMetadata.isSubscriptionUnpaid || + organizationBillingMetadata.isSubscriptionCanceled + ) { + const confirmed = await this.promptForPaymentNavigation( + org, + organizationBillingMetadata.isSubscriptionCanceled, + organizationBillingMetadata.isSubscriptionUnpaid, + ); if (confirmed) { await this.navigateToPaymentMethod(org?.id); } } } - private async promptForPaymentNavigation(org: Organization): Promise { - if (!org?.isOwner) { + private async promptForPaymentNavigation( + org: Organization, + isCanceled: boolean, + isUnpaid: boolean, + ): Promise { + this.resellerManagedOrgAlert = await this.configService.getFeatureFlag( + FeatureFlag.ResellerManagedOrgAlert, + ); + + if (!org?.isOwner && !org.providerId) { await this.dialogService.openSimpleDialog({ title: this.i18nService.t("suspendedOrganizationTitle", org?.name), content: { key: "suspendedUserOrgMessage" }, @@ -85,13 +112,31 @@ export class TrialFlowService { }); return false; } - return await this.dialogService.openSimpleDialog({ - title: this.i18nService.t("suspendedOrganizationTitle", org?.name), - content: { key: "suspendedOwnerOrgMessage" }, - type: "danger", - acceptButtonText: this.i18nService.t("continue"), - cancelButtonText: this.i18nService.t("close"), - }); + + if (org.providerId && this.resellerManagedOrgAlert) { + await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", org.name), + content: { key: "suspendedManagedOrgMessage", placeholders: [org.providerName] }, + type: "danger", + acceptButtonText: this.i18nService.t("close"), + cancelButtonText: null, + }); + return false; + } + + if (org.isOwner && isUnpaid) { + return await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", org.name), + content: { key: "suspendedOwnerOrgMessage" }, + type: "danger", + acceptButtonText: this.i18nService.t("continue"), + cancelButtonText: this.i18nService.t("close"), + }); + } + + if (org.isOwner && isCanceled && this.resellerManagedOrgAlert) { + await this.changePlan(org); + } } private async navigateToPaymentMethod(orgId: string) { @@ -99,4 +144,20 @@ export class TrialFlowService { state: { launchPaymentModalAutomatically: true }, }); } + + private async changePlan(org: Organization) { + const subscription = await this.organizationApiService.getSubscription(org.id); + const reference = openChangePlanDialog(this.dialogService, { + data: { + organizationId: org.id, + subscription: subscription, + productTierType: org.productTierType, + }, + }); + + const result = await lastValueFrom(reference.closed); + if (result === ChangePlanDialogResultType.Closed) { + return; + } + } } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index efb1754c811..f568ba159a6 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -6,7 +6,6 @@ import { firstValueFrom, Subject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -16,6 +15,7 @@ import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { DialogService } from "@bitwarden/components"; +import { TrialFlowService } from "../../../../billing/services/trial-flow.service"; import { VaultFilterService } from "../services/abstractions/vault-filter.service"; import { VaultFilterList, @@ -91,6 +91,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { return "searchVault"; } + private trialFlowService = inject(TrialFlowService); + constructor( protected vaultFilterService: VaultFilterService, protected policyService: PolicyService, @@ -126,13 +128,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { this.i18nService.t("disabledOrganizationFilterError"), ); const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id); - if (metadata.isSubscriptionUnpaid) { - const confirmed = await this.promptForPaymentNavigation(orgNode.node); - if (confirmed) { - await this.navigateToPaymentMethod(orgNode.node.id); - } - } - return; + await this.trialFlowService.handleUnpaidSubscriptionDialog(orgNode.node, metadata); } const filter = this.activeFilter; if (orgNode?.node.id === "AllVaults") { @@ -144,32 +140,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { await this.vaultFilterService.expandOrgFilter(); }; - private async promptForPaymentNavigation(org: Organization): Promise { - if (!org?.isOwner) { - await this.dialogService.openSimpleDialog({ - title: this.i18nService.t("suspendedOrganizationTitle", org?.name), - content: { key: "suspendedUserOrgMessage" }, - type: "danger", - acceptButtonText: this.i18nService.t("close"), - cancelButtonText: null, - }); - return false; - } - return await this.dialogService.openSimpleDialog({ - title: this.i18nService.t("suspendedOrganizationTitle", org?.name), - content: { key: "suspendedOwnerOrgMessage" }, - type: "danger", - acceptButtonText: this.i18nService.t("continue"), - cancelButtonText: this.i18nService.t("close"), - }); - } - - private async navigateToPaymentMethod(orgId: string) { - await this.router.navigate(["organizations", `${orgId}`, "billing", "payment-method"], { - state: { launchPaymentModalAutomatically: true }, - }); - } - applyTypeFilter = async (filterNode: TreeNode): Promise => { const filter = this.activeFilter; filter.resetFilter(); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 08e08ccad15..b0cbd050c13 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10066,5 +10066,20 @@ "example": "02/14/2024" } } + }, + "restartOrganizationSubscription": { + "message": "Organization subscription restarted" + }, + "restartSubscription": { + "message": "Restart your subscription" + }, + "suspendedManagedOrgMessage": { + "message": "Contact $PROVIDER$ for assistance.", + "placeholders": { + "provider": { + "content": "$1", + "example": "Acme c" + } + } } } diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts index 8b82795fb50..4b08b52a136 100644 --- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts @@ -8,6 +8,7 @@ import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/reque import { InvoicesResponse } from "@bitwarden/common/billing/models/response/invoices.response"; import { PaymentMethodResponse } from "@bitwarden/common/billing/models/response/payment-method.response"; +import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response"; import { PlanResponse } from "../../billing/models/response/plan.response"; @@ -74,4 +75,9 @@ export abstract class BillingApiServiceAbstraction { organizationId: string, request: VerifyBankAccountRequest, ) => Promise; + + restartSubscription: ( + organizationId: string, + request: OrganizationCreateRequest, + ) => Promise; } diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts index ddcd61573a6..7c4e0a39f8f 100644 --- a/libs/common/src/billing/abstractions/organization-billing.service.ts +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -57,4 +57,9 @@ export abstract class OrganizationBillingServiceAbstraction { ) => Promise; startFree: (subscription: SubscriptionInformation) => Promise; + + restartSubscription: ( + organizationId: string, + subscription: SubscriptionInformation, + ) => Promise; } diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts index c5023cb64c1..d30ad76a147 100644 --- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts +++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts @@ -10,6 +10,7 @@ export class OrganizationBillingMetadataResponse extends BaseResponse { invoiceDueDate: Date | null; invoiceCreatedDate: Date | null; subPeriodEndDate: Date | null; + isSubscriptionCanceled: boolean; constructor(response: any) { super(response); @@ -23,6 +24,7 @@ export class OrganizationBillingMetadataResponse extends BaseResponse { this.invoiceDueDate = this.parseDate(this.getResponseProperty("InvoiceDueDate")); this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate")); this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate")); + this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled"); } private parseDate(dateString: any): Date | null { diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index cb69f294409..7ce5602f3cc 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -10,6 +10,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { ToastService } from "@bitwarden/components"; import { ApiService } from "../../abstractions/api.service"; +import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { BillingApiServiceAbstraction } from "../../billing/abstractions"; import { PaymentMethodType } from "../../billing/enums"; import { ExpandedTaxInfoUpdateRequest } from "../../billing/models/request/expanded-tax-info-update.request"; @@ -214,6 +215,19 @@ export class BillingApiService implements BillingApiServiceAbstraction { ); } + async restartSubscription( + organizationId: string, + request: OrganizationCreateRequest, + ): Promise { + return await this.apiService.send( + "POST", + "/organizations/" + organizationId + "/billing/restart-subscription", + request, + true, + false, + ); + } + private async execute(request: () => Promise): Promise { try { return await request(); diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index a0b3782f1ad..ca10b368662 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -223,4 +223,17 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs request.additionalStorageGb = information.storage; } } + + async restartSubscription( + organizationId: string, + subscription: SubscriptionInformation, + ): Promise { + const request = new OrganizationCreateRequest(); + const organizationKeys = await this.makeOrganizationKeys(); + this.setOrganizationKeys(request, organizationKeys); + this.setOrganizationInformation(request, subscription.organization); + this.setPlanInformation(request, subscription.plan); + this.setPaymentInformation(request, subscription.payment); + await this.billingApiService.restartSubscription(organizationId, request); + } }