diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts index 2c8b579b994..bc354009775 100644 --- a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts @@ -46,7 +46,7 @@ export class SecretsManagerTrialFreeStepperComponent implements OnInit { protected formBuilder: UntypedFormBuilder, protected i18nService: I18nService, protected organizationBillingService: OrganizationBillingService, - private router: Router, + protected router: Router, ) {} ngOnInit(): void { diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html index 1acf4c32097..aeec49e5276 100644 --- a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html @@ -22,12 +22,29 @@ bitButton buttonType="primary" [disabled]="formGroup.get('name').invalid" + [loading]="createOrganizationLoading" + (click)="createOrganizationOnTrial()" + *ngIf="enableTrialPayment$ | async" + > + {{ "startTrial" | i18n }} + + - + (); + protected enableTrialPayment$ = this.configService.getFeatureFlag$( + FeatureFlag.TrialPaymentOptional, + ); + + constructor( + private route: ActivatedRoute, + private configService: ConfigService, + protected formBuilder: UntypedFormBuilder, + protected i18nService: I18nService, + protected organizationBillingService: OrganizationBillingService, + protected router: Router, + ) { + super(formBuilder, i18nService, organizationBillingService, router); + } + + async ngOnInit(): Promise { + this.referenceEventRequest = new ReferenceEventRequest(); + this.referenceEventRequest.initiationPath = "Secrets Manager trial from marketing website"; + + this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => { + if (trialFlowOrgs.includes(qParams.org)) { + if (qParams.org === ValidOrgParams.teamsStarter) { + this.plan = PlanType.TeamsStarter; + } else if (qParams.org === ValidOrgParams.teams) { + this.plan = PlanType.TeamsAnnually; + } else if (qParams.org === ValidOrgParams.enterprise) { + this.plan = PlanType.EnterpriseAnnually; + } + } + }); + } + organizationCreated(event: OrganizationCreatedEvent) { this.organizationId = event.organizationId; this.billingSubLabel = event.planDescription; @@ -31,6 +85,29 @@ export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrial this.verticalStepper.previous(); } + async createOrganizationOnTrial(): Promise { + this.createOrganizationLoading = true; + const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({ + organization: { + name: this.formGroup.get("name").value, + billingEmail: this.formGroup.get("email").value, + initiationPath: "Secrets Manager trial from marketing website", + }, + plan: { + type: this.plan, + subscribeToSecretsManager: true, + isFromSecretsManagerTrial: true, + passwordManagerSeats: 1, + secretsManagerSeats: 1, + }, + }); + + this.organizationId = response?.id; + this.subLabels.organizationInfo = response?.name; + this.createOrganizationLoading = false; + this.verticalStepper.next(); + } + get createAccountLabel() { const organizationType = this.productType === ProductTierType.TeamsStarter diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html index ed1dc6cda9b..077836a7634 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html @@ -91,12 +91,17 @@

bitButton buttonType="primary" [disabled]="orgInfoFormGroup.get('name').invalid" - cdkStepperNext + [loading]="loading" + (click)="createOrganizationOnTrial()" > - {{ "next" | i18n }} + {{ (enableTrialPayment$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }} - + { let policyServiceMock: MockProxy; let routerServiceMock: MockProxy; let acceptOrgInviteServiceMock: MockProxy; + let organizationBillingServiceMock: MockProxy; + let configServiceMock: MockProxy; beforeEach(() => { // only define services directly that we want to mock return values in this component @@ -47,6 +51,8 @@ describe("TrialInitiationComponent", () => { policyServiceMock = mock(); routerServiceMock = mock(); acceptOrgInviteServiceMock = mock(); + organizationBillingServiceMock = mock(); + configServiceMock = mock(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -92,6 +98,14 @@ describe("TrialInitiationComponent", () => { provide: AcceptOrganizationInviteService, useValue: acceptOrgInviteServiceMock, }, + { + provide: OrganizationBillingService, + useValue: organizationBillingServiceMock, + }, + { + provide: ConfigService, + useValue: configServiceMock, + }, ], schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component) }).compileComponents(); diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts index f8718b0a420..7892283a387 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts @@ -9,8 +9,15 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { + OrganizationInformation, + PlanInformation, + OrganizationBillingServiceAbstraction as OrganizationBillingService, +} from "@bitwarden/common/billing/abstractions/organization-billing.service"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request"; +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"; @@ -25,7 +32,7 @@ import { OrganizationInvite } from "../organization-invite/organization-invite"; import { RouterService } from "./../../core/router.service"; import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component"; -enum ValidOrgParams { +export enum ValidOrgParams { families = "families", enterprise = "enterprise", teams = "teams", @@ -69,6 +76,7 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { productTier: ProductTierType; accountCreateOnly = true; useTrialStepper = false; + loading = false; policies: Policy[]; enforcedPolicyOptions: MasterPasswordPolicyOptions; trialFlowOrgs: string[] = [ @@ -115,6 +123,9 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { } private destroy$ = new Subject(); + protected enableTrialPayment$ = this.configService.getFeatureFlag$( + FeatureFlag.TrialPaymentOptional, + ); constructor( private route: ActivatedRoute, @@ -127,6 +138,8 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { private i18nService: I18nService, private routerService: RouterService, private acceptOrgInviteService: AcceptOrganizationInviteService, + private organizationBillingService: OrganizationBillingService, + private configService: ConfigService, ) {} async ngOnInit(): Promise { @@ -215,6 +228,30 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { } } + async createOrganizationOnTrial() { + this.loading = true; + const organization: OrganizationInformation = { + name: this.orgInfoFormGroup.get("name").value, + billingEmail: this.orgInfoFormGroup.get("email").value, + initiationPath: "Password Manager trial from marketing website", + }; + + const plan: PlanInformation = { + type: this.plan, + passwordManagerSeats: 1, + }; + + const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({ + organization, + plan, + }); + + this.orgId = response?.id; + this.billingSubLabel = `${this.i18nService.t("annual")} ($0/${this.i18nService.t("yr")})`; + this.loading = false; + this.verticalStepper.next(); + } + createdAccount(email: string) { this.email = email; this.orgInfoFormGroup.get("email")?.setValue(email); 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 e6ed6475c4a..878672a1fb9 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 @@ -345,16 +345,22 @@

{{ "paymentMethod" | i18n }}

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 0ba4829c7c8..5a6ac8c896a 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 @@ -282,6 +282,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { : this.discountPercentageFromSub + this.discountPercentage; } + isPaymentSourceEmpty() { + return this.deprecateStripeSourcesAPI + ? this.paymentSource === null || this.paymentSource === undefined + : this.billing?.paymentSource === null || this.billing?.paymentSource === undefined; + } + isSecretsManagerTrial(): boolean { return ( this.sub?.subscription?.items?.some((item) => @@ -723,7 +729,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { // Secrets Manager this.buildSecretsManagerRequest(request); - if (this.upgradeRequiresPaymentMethod || this.showPayment) { + if (this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty()) { if (this.deprecateStripeSourcesAPI) { const tokenizedPaymentSource = await this.paymentV2Component.tokenize(); const updatePaymentMethodRequest = new UpdatePaymentMethodRequest(); 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 ccfe12b2e59..b25cda662f2 100644 --- a/apps/web/src/app/billing/organizations/organization-billing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from "@angular/core"; +import { BannerModule } from "../../../../../../libs/components/src/banner/banner.module"; import { UserVerificationModule } from "../../auth/shared/components/user-verification"; import { LooseComponentsModule } from "../../shared"; import { BillingSharedModule } from "../shared"; @@ -28,6 +29,7 @@ import { SubscriptionStatusComponent } from "./subscription-status.component"; BillingSharedModule, OrganizationPlansComponent, LooseComponentsModule, + BannerModule, ], declarations: [ AdjustSubscription, diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html index 9f9cb9efc65..7a6e8558bae 100644 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html +++ b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html @@ -1,3 +1,22 @@ + + {{ freeTrialData.message }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + 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 0756a6c314c..e2178e7c02c 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 @@ -1,17 +1,25 @@ -import { Component, ViewChild } from "@angular/core"; +import { Location } from "@angular/common"; +import { Component, OnDestroy, ViewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { from, lastValueFrom, switchMap } from "rxjs"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; 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 { 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"; import { DialogService, ToastService } from "@bitwarden/components"; +import { FreeTrial } from "../../../core/types/free-trial"; +import { TrialFlowService } from "../../services/trial-flow.service"; import { TaxInfoComponent } from "../../shared"; import { AddCreditDialogResult, @@ -25,26 +33,36 @@ import { @Component({ templateUrl: "./organization-payment-method.component.html", }) -export class OrganizationPaymentMethodComponent { +export class OrganizationPaymentMethodComponent implements OnDestroy { @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; organizationId: string; + isUnpaid = false; accountCredit: number; paymentSource?: PaymentSourceResponse; subscriptionStatus?: string; + protected freeTrialData: FreeTrial; + organization: Organization; + organizationSubscriptionResponse: OrganizationSubscriptionResponse; loading = true; protected readonly Math = Math; + launchPaymentModalAutomatically = false; constructor( private activatedRoute: ActivatedRoute, private billingApiService: BillingApiServiceAbstraction, + protected organizationApiService: OrganizationApiServiceAbstraction, private dialogService: DialogService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private router: Router, private toastService: ToastService, + private location: Location, + private trialFlowService: TrialFlowService, + private organizationService: OrganizationService, + protected syncService: SyncService, ) { this.activatedRoute.params .pipe( @@ -59,6 +77,23 @@ export class OrganizationPaymentMethodComponent { }), ) .subscribe(); + + const state = this.router.getCurrentNavigation()?.extras?.state; + // incase the above state is undefined or null we use redundantState + const redundantState: any = location.getState(); + if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { + this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; + } else if ( + redundantState && + Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically") + ) { + this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically; + } else { + this.launchPaymentModalAutomatically = false; + } + } + ngOnDestroy(): void { + this.launchPaymentModalAutomatically = false; } protected addAccountCredit = async (): Promise => { @@ -82,6 +117,34 @@ export class OrganizationPaymentMethodComponent { this.accountCredit = accountCredit; this.paymentSource = paymentSource; this.subscriptionStatus = subscriptionStatus; + + if (this.organizationId) { + const organizationSubscriptionPromise = this.organizationApiService.getSubscription( + this.organizationId, + ); + const organizationPromise = this.organizationService.get(this.organizationId); + + [this.organizationSubscriptionResponse, this.organization] = await Promise.all([ + organizationSubscriptionPromise, + organizationPromise, + ]); + this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + this.organization, + this.organizationSubscriptionResponse, + paymentSource, + ); + } + this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false; + // If the flag `launchPaymentModalAutomatically` is set to true, + // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. + // This delay ensures that any prior UI/rendering operations complete before triggering the modal. + if (this.launchPaymentModalAutomatically) { + window.setTimeout(async () => { + await this.changePayment(); + this.launchPaymentModalAutomatically = false; + this.location.replaceState(this.location.path(), "", {}); + }, 800); + } this.loading = false; }; @@ -100,6 +163,24 @@ export class OrganizationPaymentMethodComponent { } }; + changePayment = async () => { + const dialogRef = AdjustPaymentDialogV2Component.open(this.dialogService, { + data: { + initialPaymentMethod: this.paymentSource?.type, + organizationId: this.organizationId, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === AdjustPaymentDialogV2ResultType.Submitted) { + this.location.replaceState(this.location.path(), "", {}); + if (this.launchPaymentModalAutomatically && !this.organization.enabled) { + await this.syncService.fullSync(true); + } + this.launchPaymentModalAutomatically = false; + await this.load(); + } + }; + protected updateTaxInformation = async (): Promise => { this.taxInfoComponent.taxFormGroup.updateValueAndValidity(); this.taxInfoComponent.taxFormGroup.markAllAsTouched(); diff --git a/apps/web/src/app/billing/services/trial-flow.service.ts b/apps/web/src/app/billing/services/trial-flow.service.ts new file mode 100644 index 00000000000..3135a811665 --- /dev/null +++ b/apps/web/src/app/billing/services/trial-flow.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from "@angular/core"; +import { Router } from "@angular/router"; + +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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService } from "@bitwarden/components"; + +import { FreeTrial } from "../../core/types/free-trial"; + +@Injectable({ providedIn: "root" }) +export class TrialFlowService { + constructor( + private i18nService: I18nService, + protected dialogService: DialogService, + private router: Router, + protected billingApiService: BillingApiServiceAbstraction, + ) {} + checkForOrgsWithUpcomingPaymentIssues( + organization: Organization, + organizationSubscription: OrganizationSubscriptionResponse, + paymentSource: BillingSourceResponse | PaymentSourceResponse, + ): FreeTrial { + const trialEndDate = organizationSubscription?.subscription?.trialEndDate; + const displayBanner = + !paymentSource && + organization?.isOwner && + organizationSubscription?.subscription?.status === "trialing"; + const trialRemainingDays = trialEndDate ? this.calculateTrialRemainingDays(trialEndDate) : 0; + const freeTrialMessage = this.getFreeTrialMessage(trialRemainingDays); + + return { + remainingDays: trialRemainingDays, + message: freeTrialMessage, + shownBanner: displayBanner, + organizationId: organization.id, + organizationName: organization.name, + }; + } + + calculateTrialRemainingDays(trialEndDate: string): number | undefined { + const today = new Date(); + const trialEnd = new Date(trialEndDate); + const timeDifference = trialEnd.getTime() - today.getTime(); + + return Math.ceil(timeDifference / (1000 * 60 * 60 * 24)); + } + + getFreeTrialMessage(trialRemainingDays: number): string { + if (trialRemainingDays >= 2) { + return this.i18nService.t("freeTrialEndPrompt", trialRemainingDays); + } else if (trialRemainingDays === 1) { + return this.i18nService.t("freeTrialEndPromptForOneDayNoOrgName"); + } else { + return this.i18nService.t("freeTrialEndingSoonWithoutOrgName"); + } + } + + async handleUnpaidSubscriptionDialog( + org: Organization, + organizationBillingMetadata: OrganizationBillingMetadataResponse, + ): Promise { + if (organizationBillingMetadata.isSubscriptionUnpaid) { + const confirmed = await this.promptForPaymentNavigation(org); + if (confirmed) { + await this.navigateToPaymentMethod(org?.id); + } + } + } + + 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 }, + }); + } +} diff --git a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts index 450c1234567..0c8e93531ee 100644 --- a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts +++ b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts @@ -74,6 +74,7 @@ export class AdjustPaymentDialogComponent { } }); await response; + await new Promise((resolve) => setTimeout(resolve, 10000)); this.toastService.showToast({ variant: "success", title: null, diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index 57491a73e6d..b9c235943ad 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -1,5 +1,7 @@ import { NgModule } from "@angular/core"; +import { BannerModule } from "@bitwarden/components"; + import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; @@ -27,6 +29,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac PaymentComponent, TaxInfoComponent, HeaderModule, + BannerModule, PaymentV2Component, VerifyBankAccountComponent, ], diff --git a/apps/web/src/app/billing/shared/payment-method.component.html b/apps/web/src/app/billing/shared/payment-method.component.html index 495785af45f..1d4675847a1 100644 --- a/apps/web/src/app/billing/shared/payment-method.component.html +++ b/apps/web/src/app/billing/shared/payment-method.component.html @@ -1,3 +1,23 @@ + + {{ freeTrialData?.message }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + +

{{ paymentSource.description }}

-

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 06acf2142a5..98e6efcd8bd 100644 --- a/apps/web/src/app/billing/shared/payment-method.component.ts +++ b/apps/web/src/app/billing/shared/payment-method.component.ts @@ -1,10 +1,13 @@ -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Location } from "@angular/common"; +import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, FormControl, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { lastValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; 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"; @@ -13,8 +16,12 @@ import { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank. 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"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { DialogService, ToastService } from "@bitwarden/components"; +import { FreeTrial } from "../../core/types/free-trial"; +import { TrialFlowService } from "../services/trial-flow.service"; + import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component"; import { AdjustPaymentDialogResult, @@ -26,7 +33,7 @@ import { TaxInfoComponent } from "./tax-info.component"; templateUrl: "payment-method.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class PaymentMethodComponent implements OnInit { +export class PaymentMethodComponent implements OnInit, OnDestroy { @ViewChild(TaxInfoComponent) taxInfo: TaxInfoComponent; loading = false; @@ -37,6 +44,7 @@ export class PaymentMethodComponent implements OnInit { paymentMethodType = PaymentMethodType; organizationId: string; isUnpaid = false; + organization: Organization; verifyBankForm = this.formBuilder.group({ amount1: new FormControl(null, [ @@ -52,6 +60,8 @@ export class PaymentMethodComponent implements OnInit { }); taxForm = this.formBuilder.group({}); + launchPaymentModalAutomatically = false; + protected freeTrialData: FreeTrial; constructor( protected apiService: ApiService, @@ -59,12 +69,30 @@ export class PaymentMethodComponent implements OnInit { protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, private router: Router, + private location: Location, private logService: LogService, private route: ActivatedRoute, private formBuilder: FormBuilder, private dialogService: DialogService, private toastService: ToastService, - ) {} + private trialFlowService: TrialFlowService, + private organizationService: OrganizationService, + protected syncService: SyncService, + ) { + const state = this.router.getCurrentNavigation()?.extras?.state; + // incase the above state is undefined or null we use redundantState + const redundantState: any = location.getState(); + if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { + this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; + } else if ( + redundantState && + Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically") + ) { + this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically; + } else { + this.launchPaymentModalAutomatically = false; + } + } async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe @@ -88,27 +116,37 @@ export class PaymentMethodComponent implements OnInit { return; } this.loading = true; - if (this.forOrganization) { const billingPromise = this.organizationApiService.getBilling(this.organizationId); const organizationSubscriptionPromise = this.organizationApiService.getSubscription( this.organizationId, ); + const organizationPromise = this.organizationService.get(this.organizationId); - [this.billing, this.org] = await Promise.all([ + [this.billing, this.org, this.organization] = await Promise.all([ billingPromise, organizationSubscriptionPromise, + organizationPromise, ]); + this.determineOrgsWithUpcomingPaymentIssues(); } else { const billingPromise = this.apiService.getUserBillingPayment(); const subPromise = this.apiService.getUserSubscription(); [this.billing, this.sub] = await Promise.all([billingPromise, subPromise]); } - this.isUnpaid = this.subscription?.status === "unpaid" ?? false; - this.loading = false; + // If the flag `launchPaymentModalAutomatically` is set to true, + // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. + // This delay ensures that any prior UI/rendering operations complete before triggering the modal. + if (this.launchPaymentModalAutomatically) { + window.setTimeout(async () => { + await this.changePayment(); + this.launchPaymentModalAutomatically = false; + this.location.replaceState(this.location.path(), "", {}); + }, 800); + } }; addCredit = async () => { @@ -132,6 +170,11 @@ export class PaymentMethodComponent implements OnInit { }); const result = await lastValueFrom(dialogRef.closed); if (result === AdjustPaymentDialogResult.Adjusted) { + this.location.replaceState(this.location.path(), "", {}); + if (this.launchPaymentModalAutomatically && !this.organization.enabled) { + await this.syncService.fullSync(true); + } + this.launchPaymentModalAutomatically = false; await this.load(); } }; @@ -162,6 +205,14 @@ export class PaymentMethodComponent implements OnInit { }); }; + determineOrgsWithUpcomingPaymentIssues() { + this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + this.organization, + this.org, + this.billing?.paymentSource, + ); + } + get isCreditBalance() { return this.billing == null || this.billing.balance <= 0; } @@ -203,4 +254,8 @@ export class PaymentMethodComponent implements OnInit { get subscription() { return this.sub?.subscription ?? this.org?.subscription ?? null; } + + ngOnDestroy(): void { + this.launchPaymentModalAutomatically = false; + } } diff --git a/apps/web/src/app/core/types/free-trial.ts b/apps/web/src/app/core/types/free-trial.ts new file mode 100644 index 00000000000..ee5fb921621 --- /dev/null +++ b/apps/web/src/app/core/types/free-trial.ts @@ -0,0 +1,7 @@ +export type FreeTrial = { + remainingDays: number; + message: string; + shownBanner: boolean; + organizationId: string; + organizationName: string; +}; diff --git a/apps/web/src/app/layouts/org-switcher/org-switcher.component.html b/apps/web/src/app/layouts/org-switcher/org-switcher.component.html index 1dd03d03230..f34d32f5983 100644 --- a/apps/web/src/app/layouts/org-switcher/org-switcher.component.html +++ b/apps/web/src/app/layouts/org-switcher/org-switcher.component.html @@ -22,6 +22,7 @@ [route]="['../', org.id]" (mainContentClicked)="toggle()" [routerLinkActiveOptions]="{ exact: true }" + (click)="handleUnpaidSubscription(org)" > + {{ freeTrialMessage(organization) }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + ; VisibleVaultBanner = VisibleVaultBanner; + @Input() organizationsPaymentStatus: FreeTrial[] = []; - constructor(private vaultBannerService: VaultBannersService) { + constructor( + private vaultBannerService: VaultBannersService, + private router: Router, + private i18nService: I18nService, + ) { this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$; } @@ -34,6 +42,17 @@ export class VaultBannersComponent implements OnInit { await this.determineVisibleBanners(); } + async navigateToPaymentMethod(organizationId: string): Promise { + const navigationExtras = { + state: { launchPaymentModalAutomatically: true }, + }; + + await this.router.navigate( + ["organizations", organizationId, "billing", "payment-method"], + navigationExtras, + ); + } + /** Determine which banners should be present */ private async determineVisibleBanners(): Promise { const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner(); @@ -46,4 +65,22 @@ export class VaultBannersComponent implements OnInit { showLowKdf ? VisibleVaultBanner.KDFSettings : null, ].filter(Boolean); // remove all falsy values, i.e. null } + + freeTrialMessage(organization: FreeTrial) { + if (organization.remainingDays >= 2) { + return this.i18nService.t( + "freeTrialEndPromptAboveTwoDays", + organization.organizationName, + organization.remainingDays.toString(), + ); + } else if (organization.remainingDays === 1) { + return this.i18nService.t("freeTrialEndPromptForOneDay", organization.organizationName); + } else { + return this.i18nService.t("freeTrialEndPromptForLessThanADay", organization.organizationName); + } + } + + trackBy(index: number) { + return index; + } } 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 92b9034fa35..09a7356c452 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 @@ -1,12 +1,16 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { Router } from "@angular/router"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { DialogService } from "@bitwarden/components"; import { VaultFilterService } from "../services/abstractions/vault-filter.service"; import { @@ -40,7 +44,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { isLoaded = false; protected destroy$: Subject = new Subject(); - + private router = inject(Router); get filtersList() { return this.filters ? Object.values(this.filters) : []; } @@ -85,6 +89,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected policyService: PolicyService, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, + protected billingApiService: BillingApiServiceAbstraction, + protected dialogService: DialogService, ) {} async ngOnInit(): Promise { @@ -111,6 +117,13 @@ export class VaultFilterComponent implements OnInit, OnDestroy { null, 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; } const filter = this.activeFilter; @@ -123,6 +136,32 @@ 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/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index b2c4fda57d0..679d2ce6f7e 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -1,4 +1,4 @@ - + ; private activeUserId: UserId; + protected organizationsPaymentStatus: FreeTrial[] = []; private searchText$ = new Subject(); private refresh$ = new BehaviorSubject(null); private destroy$ = new Subject(); private extensionRefreshEnabled: boolean; private vaultItemDialogRef?: DialogRef | undefined; + private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe( + filter((organizations) => organizations.length === 1), + switchMap(([organization]) => + from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe( + switchMap((organizationMetaData) => + from( + this.trialFlowService.handleUnpaidSubscriptionDialog( + organization, + organizationMetaData, + ), + ), + ), + ), + ), + ); constructor( private syncService: SyncService, @@ -211,6 +232,9 @@ export class VaultComponent implements OnInit, OnDestroy { private toastService: ToastService, private accountService: AccountService, private cipherFormConfigService: DefaultCipherFormConfigService, + private organizationApiService: OrganizationApiServiceAbstraction, + protected billingApiService: BillingApiServiceAbstraction, + private trialFlowService: TrialFlowService, ) {} async ngOnInit() { @@ -309,7 +333,6 @@ export class VaultComponent implements OnInit, OnDestroy { if (filter.collectionId === undefined || filter.collectionId === Unassigned) { return []; } - let collectionsToReturn = []; if (filter.organizationId !== undefined && filter.collectionId === All) { collectionsToReturn = collections @@ -362,7 +385,6 @@ export class VaultComponent implements OnInit, OnDestroy { filter(() => this.vaultItemDialogRef == undefined || !this.extensionRefreshEnabled), switchMap(async (params) => { const cipherId = getCipherIdFromParams(params); - if (cipherId) { if (await this.cipherService.get(cipherId)) { let action = params.action; @@ -393,6 +415,32 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe(); + + const organizationsPaymentStatus$ = this.organizationService.organizations$.pipe( + switchMap((allOrganizations) => { + return combineLatest( + allOrganizations + .filter((org) => org.isOwner) + .map((org) => + combineLatest([ + this.organizationApiService.getSubscription(org.id), + this.organizationApiService.getBilling(org.id), + ]).pipe( + map(([subscription, billing]) => { + return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + org, + subscription, + billing?.paymentSource, + ); + }), + ), + ), + ); + }), + map((results) => results.filter((result) => result.shownBanner)), + ); + firstSetup$ .pipe( switchMap(() => this.refresh$), @@ -406,6 +454,7 @@ export class VaultComponent implements OnInit, OnDestroy { ciphers$, collections$, selectedCollection$, + organizationsPaymentStatus$, ]), ), takeUntil(this.destroy$), @@ -419,6 +468,7 @@ export class VaultComponent implements OnInit, OnDestroy { ciphers, collections, selectedCollection, + organizationsPaymentStatus, ]) => { this.filter = filter; this.canAccessPremium = canAccessPremium; @@ -434,7 +484,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.showBulkMove = filter.type !== "trash"; this.isEmpty = collections?.length === 0 && ciphers?.length === 0; - + this.organizationsPaymentStatus = organizationsPaymentStatus; this.performingInitialLoad = false; this.refreshing = false; }, diff --git a/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts b/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts index 8a3f25ab2c7..211d2346230 100644 --- a/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts @@ -3,9 +3,11 @@ import { firstValueFrom, Subject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { DialogService } from "@bitwarden/components"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../individual-vault/vault-filter/components/vault-filter.component"; //../../vault/vault-filter/components/vault-filter.component"; import { VaultFilterService } from "../../individual-vault/vault-filter/services/abstractions/vault-filter.service"; @@ -38,8 +40,17 @@ export class VaultFilterComponent protected policyService: PolicyService, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, + protected billingApiService: BillingApiServiceAbstraction, + protected dialogService: DialogService, ) { - super(vaultFilterService, policyService, i18nService, platformUtilsService); + super( + vaultFilterService, + policyService, + i18nService, + platformUtilsService, + billingApiService, + dialogService, + ); } async ngOnInit() { diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html index 0bcdc52eaeb..9e9264e77cd 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.html +++ b/apps/web/src/app/vault/org-vault/vault.component.html @@ -1,3 +1,25 @@ + + + {{ freeTrial.message }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + + ; + protected freeTrial$: Observable; /** * A list of collections that the user can assign items to and edit those items within. * @protected @@ -183,6 +197,21 @@ export class VaultComponent implements OnInit, OnDestroy { protected addAccessStatus$ = new BehaviorSubject(0); private extensionRefreshEnabled: boolean; private vaultItemDialogRef?: DialogRef | undefined; + private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe( + filter((organizations) => organizations.length === 1), + switchMap(([organization]) => + from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe( + switchMap((organizationMetaData) => + from( + this.trialFlowService.handleUnpaidSubscriptionDialog( + organization, + organizationMetaData, + ), + ), + ), + ), + ), + ); constructor( private route: ActivatedRoute, @@ -214,6 +243,9 @@ export class VaultComponent implements OnInit, OnDestroy { private toastService: ToastService, private configService: ConfigService, private cipherFormConfigService: CipherFormConfigService, + private organizationApiService: OrganizationApiServiceAbstraction, + private trialFlowService: TrialFlowService, + protected billingApiService: BillingApiServiceAbstraction, ) {} async ngOnInit() { @@ -546,6 +578,26 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe(); + + this.freeTrial$ = organization$.pipe( + filter((org) => org.isOwner), + switchMap((org) => + combineLatest([ + of(org), + this.organizationApiService.getSubscription(org.id), + this.organizationApiService.getBilling(org.id), + ]), + ), + map(([org, sub, billing]) => { + return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + org, + sub, + billing?.paymentSource, + ); + }), + ); + firstSetup$ .pipe( switchMap(() => this.refresh$), @@ -596,6 +648,13 @@ export class VaultComponent implements OnInit, OnDestroy { ); } + async navigateToPaymentMethod() { + await this.router.navigate( + ["organizations", `${this.organization?.id}`, "billing", "payment-method"], + { state: { launchPaymentModalAutomatically: true } }, + ); + } + addAccessToggle(e: AddAccessStatusType) { this.addAccessStatus$.next(e); } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index f4baf8273dc..dba55dc3d24 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3837,6 +3837,55 @@ "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, + "freeTrialEndPrompt": { + "message": "Your free trial ends in $COUNT$ days. To maintain your subscription,", + "placeholders": { + "count": { + "content": "$1", + "example": "You must set up 2FA on your user account before you can join this organization." + } + } + }, + "freeTrialEndPromptAboveTwoDays": { + "message": "$ORGANIZATION$, your free trial ends in $COUNT$ days. To maintain your subscription,", + "placeholders": { + "count": { + "content": "$2", + "example": "organization name" + }, + "organization": { + "content": "$1", + "example": "remaining days" + } + } + }, + "freeTrialEndPromptForOneDay": { + "message": "$ORGANIZATION$, your free trial ends tomorrow. To maintain your subscription,", + "placeholders": { + "organization": { + "content": "$1", + "example": "organization name" + } + } + }, + "freeTrialEndPromptForOneDayNoOrgName": { + "message": "Your free trial ends tomorrow. To maintain your subscription," + }, + "freeTrialEndPromptForLessThanADay": { + "message": "$ORGANIZATION$, your free trial ends today. To maintain your subscription,", + "placeholders": { + "organization": { + "content": "$1", + "example": "organization name" + } + } + }, + "freeTrialEndingSoonWithoutOrgName": { + "message": "Your free trial ends today. To maintain your subscription," + }, + "routeToPaymentMethodTrigger": { + "message": "add a payment method." + }, "joinOrganization": { "message": "Join organization" }, @@ -8444,7 +8493,7 @@ }, "addAPaymentMethod": { "message": "add a payment method", - "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method.'" + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method'" }, "organizationInformation": { "message": "Organization information" @@ -9631,5 +9680,20 @@ "example": "First 8 Character of a GUID" } } + }, + "suspendedOrganizationTitle": { + "message": "The $ORGANIZATION$ is suspended", + "placeholders": { + "organization": { + "content": "$1", + "example": "Acme c" + } + } + }, + "suspendedUserOrgMessage": { + "message": "Contact your organization owner for assistance." + }, + "suspendedOwnerOrgMessage": { + "message": "To regain access to your organization, add a payment method." } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html index a82e35afb60..31746e7601c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html @@ -1,3 +1,24 @@ + + + {{ freeTrial.message }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + 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 7073b4c289f..bf2dbb76ad3 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 @@ -1,5 +1,5 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { map, Observable, @@ -12,14 +12,20 @@ import { take, share, firstValueFrom, - concatMap, + of, + filter, } from "rxjs"; +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; 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"; import { DialogService } from "@bitwarden/components"; +import { TrialFlowService } from "@bitwarden/web-vault/app/billing/services/trial-flow.service"; +import { FreeTrial } from "@bitwarden/web-vault/app/core/types/free-trial"; import { OrganizationCounts } from "../models/view/counts.view"; import { ProjectListView } from "../models/view/project-list.view"; @@ -81,6 +87,8 @@ export class OverviewComponent implements OnInit, OnDestroy { protected showOnboarding = false; protected loading = true; protected organizationEnabled = false; + protected organization: Organization; + protected i18n: I18nPipe; protected onboardingTasks$: Observable; protected view$: Observable<{ @@ -91,6 +99,7 @@ export class OverviewComponent implements OnInit, OnDestroy { tasks: OrganizationTasks; counts: OrganizationCounts; }>; + protected freeTrial$: Observable; constructor( private route: ActivatedRoute, @@ -104,6 +113,10 @@ export class OverviewComponent implements OnInit, OnDestroy { private i18nService: I18nService, private smOnboardingTasksService: SMOnboardingTasksService, private logService: LogService, + private router: Router, + + private organizationApiService: OrganizationApiServiceAbstraction, + private trialFlowService: TrialFlowService, ) {} ngOnInit() { @@ -114,18 +127,35 @@ export class OverviewComponent implements OnInit, OnDestroy { distinctUntilChanged(), ); - orgId$ - .pipe( - concatMap(async (orgId) => await this.organizationService.get(orgId)), - takeUntil(this.destroy$), - ) - .subscribe((org) => { - this.organizationId = org.id; - this.organizationName = org.name; - this.userIsAdmin = org.isAdmin; - this.loading = true; - this.organizationEnabled = org.enabled; - }); + const org$ = orgId$.pipe(switchMap((orgId) => this.organizationService.get(orgId))); + + org$.pipe(takeUntil(this.destroy$)).subscribe((org) => { + this.organizationId = org.id; + this.organization = org; + this.organizationName = org.name; + this.userIsAdmin = org.isAdmin; + this.loading = true; + this.organizationEnabled = org.enabled; + }); + + this.freeTrial$ = org$.pipe( + filter((org) => org.isOwner), + switchMap((org) => + combineLatest([ + of(org), + this.organizationApiService.getSubscription(org.id), + this.organizationApiService.getBilling(org.id), + ]), + ), + map(([org, sub, billing]) => { + return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + org, + sub, + billing?.paymentSource, + ); + }), + takeUntil(this.destroy$), + ); const projects$ = combineLatest([ orgId$, @@ -197,6 +227,15 @@ export class OverviewComponent implements OnInit, OnDestroy { }); } + async navigateToPaymentMethod() { + await this.router.navigate( + ["organizations", `${this.organizationId}`, "billing", "payment-method"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts index 72039f532ae..b9c09a0d671 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts @@ -1,5 +1,7 @@ import { NgModule } from "@angular/core"; +import { BannerModule } from "@bitwarden/components"; + import { OnboardingModule } from "../../../../../../apps/web/src/app/shared/components/onboarding/onboarding.module"; import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; @@ -8,7 +10,7 @@ import { OverviewComponent } from "./overview.component"; import { SectionComponent } from "./section.component"; @NgModule({ - imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule], + imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule, BannerModule], declarations: [OverviewComponent, SectionComponent], providers: [], }) diff --git a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts index e66fc0cf12a..051275f7945 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts @@ -6,6 +6,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; +import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; @@ -40,6 +41,9 @@ export class OrganizationApiServiceAbstraction { getLicense: (id: string, installationId: string) => Promise; getAutoEnrollStatus: (identifier: string) => Promise; create: (request: OrganizationCreateRequest) => Promise; + createWithoutPayment: ( + request: OrganizationNoPaymentMethodCreateRequest, + ) => Promise; createLicense: (data: FormData) => Promise; save: (id: string, request: OrganizationUpdateRequest) => Promise; updatePayment: (id: string, request: PaymentRequest) => Promise; diff --git a/libs/common/src/admin-console/models/request/organization-create.request.ts b/libs/common/src/admin-console/models/request/organization-create.request.ts index 9f0441c4340..98f19bebaf4 100644 --- a/libs/common/src/admin-console/models/request/organization-create.request.ts +++ b/libs/common/src/admin-console/models/request/organization-create.request.ts @@ -1,32 +1,7 @@ -import { PaymentMethodType, PlanType } from "../../../billing/enums"; -import { InitiationPath } from "../../../models/request/reference-event.request"; +import { PaymentMethodType } from "../../../billing/enums"; +import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; -import { OrganizationKeysRequest } from "./organization-keys.request"; - -export class OrganizationCreateRequest { - name: string; - businessName: string; - billingEmail: string; - planType: PlanType; - key: string; - keys: OrganizationKeysRequest; +export class OrganizationCreateRequest extends OrganizationNoPaymentMethodCreateRequest { paymentMethodType: PaymentMethodType; paymentToken: string; - additionalSeats: number; - maxAutoscaleSeats: number; - additionalStorageGb: number; - premiumAccessAddon: boolean; - collectionName: string; - taxIdNumber: string; - billingAddressLine1: string; - billingAddressLine2: string; - billingAddressCity: string; - billingAddressState: string; - billingAddressPostalCode: string; - billingAddressCountry: string; - useSecretsManager: boolean; - additionalSmSeats: number; - additionalServiceAccounts: number; - isFromSecretsManagerTrial: boolean; - initiationPath: InitiationPath; } diff --git a/libs/common/src/admin-console/services/organization/organization-api.service.ts b/libs/common/src/admin-console/services/organization/organization-api.service.ts index 2ff4f2321a3..a2259d73cc5 100644 --- a/libs/common/src/admin-console/services/organization/organization-api.service.ts +++ b/libs/common/src/admin-console/services/organization/organization-api.service.ts @@ -7,6 +7,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; +import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; @@ -107,6 +108,21 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return new OrganizationResponse(r); } + async createWithoutPayment( + request: OrganizationNoPaymentMethodCreateRequest, + ): Promise { + const r = await this.apiService.send( + "POST", + "/organizations/create-without-payment", + request, + true, + true, + ); + // Forcing a sync will notify organization service that they need to repull + await this.syncService.fullSync(true); + return new OrganizationResponse(r); + } + async createLicense(data: FormData): Promise { const r = await this.apiService.send( "POST", diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts index d19724b600a..72902baa30e 100644 --- a/libs/common/src/billing/abstractions/organization-billing.service.ts +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -44,4 +44,8 @@ export abstract class OrganizationBillingServiceAbstraction { purchaseSubscription: (subscription: SubscriptionInformation) => Promise; startFree: (subscription: SubscriptionInformation) => Promise; + + purchaseSubscriptionNoPaymentMethod: ( + subscription: SubscriptionInformation, + ) => Promise; } diff --git a/libs/common/src/billing/models/request/organization-no-payment-method-create-request.ts b/libs/common/src/billing/models/request/organization-no-payment-method-create-request.ts new file mode 100644 index 00000000000..b48caec8dfc --- /dev/null +++ b/libs/common/src/billing/models/request/organization-no-payment-method-create-request.ts @@ -0,0 +1,29 @@ +import { OrganizationKeysRequest } from "../../../admin-console/models/request/organization-keys.request"; +import { InitiationPath } from "../../../models/request/reference-event.request"; +import { PlanType } from "../../enums"; + +export class OrganizationNoPaymentMethodCreateRequest { + name: string; + businessName: string; + billingEmail: string; + planType: PlanType; + key: string; + keys: OrganizationKeysRequest; + additionalSeats: number; + maxAutoscaleSeats: number; + additionalStorageGb: number; + premiumAccessAddon: boolean; + collectionName: string; + taxIdNumber: string; + billingAddressLine1: string; + billingAddressLine2: string; + billingAddressCity: string; + billingAddressState: string; + billingAddressPostalCode: string; + billingAddressCountry: string; + useSecretsManager: boolean; + additionalSmSeats: number; + additionalServiceAccounts: number; + isFromSecretsManagerTrial: boolean; + initiationPath: InitiationPath; +} 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 3d846e6c987..ae6d1ac92c1 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 @@ -4,11 +4,13 @@ export class OrganizationBillingMetadataResponse extends BaseResponse { isEligibleForSelfHost: boolean; isManaged: boolean; isOnSecretsManagerStandalone: boolean; + isSubscriptionUnpaid: boolean; constructor(response: any) { super(response); this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost"); this.isManaged = this.getResponseProperty("IsManaged"); this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone"); + this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid"); } } diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index eebea0ca74e..efc36278532 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -17,6 +17,7 @@ import { SubscriptionInformation, } from "../abstractions/organization-billing.service"; import { PlanType } from "../enums"; +import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request"; interface OrganizationKeys { encryptedKey: EncString; @@ -77,6 +78,28 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs return response; } + async purchaseSubscriptionNoPaymentMethod( + subscription: SubscriptionInformation, + ): Promise { + const request = new OrganizationNoPaymentMethodCreateRequest(); + + const organizationKeys = await this.makeOrganizationKeys(); + + this.setOrganizationKeys(request, organizationKeys); + + this.setOrganizationInformation(request, subscription.organization); + + this.setPlanInformation(request, subscription.plan); + + const response = await this.organizationApiService.createWithoutPayment(request); + + await this.apiService.refreshIdentityToken(); + + await this.syncService.fullSync(true); + + return response; + } + private async makeOrganizationKeys(): Promise { const [encryptedKey, key] = await this.keyService.makeOrgKey(); const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(key); @@ -106,7 +129,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs } private setOrganizationInformation( - request: OrganizationCreateRequest, + request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest, information: OrganizationInformation, ): void { request.name = information.name; @@ -115,7 +138,10 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs request.initiationPath = information.initiationPath; } - private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void { + private setOrganizationKeys( + request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest, + keys: OrganizationKeys, + ): void { request.key = keys.encryptedKey.encryptedString; request.keys = new OrganizationKeysRequest( keys.publicKey, @@ -146,7 +172,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs } private setPlanInformation( - request: OrganizationCreateRequest, + request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest, information: PlanInformation, ): void { request.planType = information.type; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 47cdbc90cfd..bb765185a28 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -38,6 +38,7 @@ export enum FeatureFlag { Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions", LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split", CriticalApps = "pm-14466-risk-insights-critical-application", + TrialPaymentOptional = "PM-8163-trial-payment", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -86,6 +87,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE, [FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE, [FeatureFlag.CriticalApps]: FALSE, + [FeatureFlag.TrialPaymentOptional]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;