mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 13:23:34 +00:00
[PM-25463] Work towards complete usage of Payments domain (#16532)
* Use payment domain * Fixing lint and test issue * Fix organization plans tax issue * PM-26297: Use existing billing address for tax calculation if it exists * PM-26344: Check existing payment method on submit
This commit is contained in:
@@ -44,9 +44,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
@@ -239,7 +237,6 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
private totpService: TotpService,
|
||||
private apiService: ApiService,
|
||||
private toastService: ToastService,
|
||||
private configService: ConfigService,
|
||||
private cipherFormConfigService: CipherFormConfigService,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
@@ -710,14 +707,13 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async navigateToPaymentMethod() {
|
||||
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
|
||||
);
|
||||
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
|
||||
const organizationId = await firstValueFrom(this.organizationId$);
|
||||
await this.router.navigate(["organizations", `${organizationId}`, "billing", route], {
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
});
|
||||
await this.router.navigate(
|
||||
["organizations", `${organizationId}`, "billing", "payment-details"],
|
||||
{
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
addAccessToggle(e: AddAccessStatusType) {
|
||||
|
||||
@@ -71,10 +71,9 @@
|
||||
>
|
||||
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item>
|
||||
<ng-container *ngIf="(showPaymentAndHistory$ | async) && (organizationIsUnmanaged$ | async)">
|
||||
@let paymentDetailsPageData = paymentDetailsPageData$ | async;
|
||||
<bit-nav-item
|
||||
[text]="paymentDetailsPageData.textKey | i18n"
|
||||
[route]="paymentDetailsPageData.route"
|
||||
[text]="'paymentDetails' | i18n"
|
||||
route="billing/payment-details"
|
||||
></bit-nav-item>
|
||||
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
|
||||
</ng-container>
|
||||
|
||||
@@ -23,9 +23,6 @@ import { PolicyType, ProviderStatusType } from "@bitwarden/common/admin-console/
|
||||
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 { 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { BannerModule, IconModule } from "@bitwarden/components";
|
||||
@@ -70,11 +67,6 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
|
||||
protected showSponsoredFamiliesDropdown$: Observable<boolean>;
|
||||
|
||||
protected paymentDetailsPageData$: Observable<{
|
||||
route: string;
|
||||
textKey: string;
|
||||
}>;
|
||||
|
||||
protected subscriber$: Observable<NonIndividualSubscriber>;
|
||||
protected getTaxIdWarning$: () => Observable<TaxIdWarningType | null>;
|
||||
|
||||
@@ -82,12 +74,10 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
private route: ActivatedRoute,
|
||||
private organizationService: OrganizationService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private configService: ConfigService,
|
||||
private policyService: PolicyService,
|
||||
private providerService: ProviderService,
|
||||
private accountService: AccountService,
|
||||
private freeFamiliesPolicyService: FreeFamiliesPolicyService,
|
||||
private organizationBillingService: OrganizationBillingServiceAbstraction,
|
||||
private organizationWarningsService: OrganizationWarningsService,
|
||||
) {}
|
||||
|
||||
@@ -141,16 +131,6 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
|
||||
this.integrationPageEnabled$ = this.organization$.pipe(map((org) => org.canAccessIntegrations));
|
||||
|
||||
this.paymentDetailsPageData$ = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
|
||||
.pipe(
|
||||
map((managePaymentDetailsOutsideCheckout) =>
|
||||
managePaymentDetailsOutsideCheckout
|
||||
? { route: "billing/payment-details", textKey: "paymentDetails" }
|
||||
: { route: "billing/payment-method", textKey: "paymentMethod" },
|
||||
),
|
||||
);
|
||||
|
||||
this.subscriber$ = this.organization$.pipe(
|
||||
map((organization) => ({
|
||||
type: "organization",
|
||||
|
||||
@@ -975,12 +975,11 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
}
|
||||
|
||||
async navigateToPaymentMethod(organization: Organization) {
|
||||
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
|
||||
await this.router.navigate(
|
||||
["organizations", `${organization.id}`, "billing", "payment-details"],
|
||||
{
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
},
|
||||
);
|
||||
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
|
||||
await this.router.navigate(["organizations", `${organization.id}`, "billing", route], {
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
<ng-container *ngIf="loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
<form
|
||||
#form
|
||||
[formGroup]="formGroup"
|
||||
[appApiAction]="formPromise"
|
||||
(ngSubmit)="submit()"
|
||||
*ngIf="!loading"
|
||||
>
|
||||
<div class="tw-container tw-mb-3">
|
||||
<div class="tw-mb-6">
|
||||
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "billingPlanLabel" | i18n }}</h2>
|
||||
<div class="tw-mb-1 tw-items-center" *ngIf="annualPlan !== null">
|
||||
<label class="tw- tw-block tw-text-main" for="annual">
|
||||
<input
|
||||
class="tw-size-4 tw-align-middle"
|
||||
id="annual"
|
||||
name="cadence"
|
||||
type="radio"
|
||||
[value]="annualCadence"
|
||||
formControlName="cadence"
|
||||
/>
|
||||
{{ "annual" | i18n }} -
|
||||
{{ getPriceFor(annualCadence) | currency: "$" }}
|
||||
/{{ "yr" | i18n }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="tw-mb-1 tw-items-center" *ngIf="monthlyPlan !== null">
|
||||
<label class="tw- tw-block tw-text-main" for="monthly">
|
||||
<input
|
||||
class="tw-size-4 tw-align-middle"
|
||||
id="monthly"
|
||||
name="cadence"
|
||||
type="radio"
|
||||
[value]="monthlyCadence"
|
||||
formControlName="cadence"
|
||||
/>
|
||||
{{ "monthly" | i18n }} -
|
||||
{{ getPriceFor(monthlyCadence) | currency: "$" }}
|
||||
/{{ "monthAbbr" | i18n }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-mb-4">
|
||||
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2>
|
||||
<app-payment [showAccountCredit]="false"></app-payment>
|
||||
<app-manage-tax-information
|
||||
[showTaxIdField]="showTaxIdField"
|
||||
(taxInformationChanged)="onTaxInformationChanged()"
|
||||
></app-manage-tax-information>
|
||||
|
||||
@if (trialLength === 0) {
|
||||
@let priceLabel =
|
||||
subscriptionProduct === SubscriptionProduct.PasswordManager
|
||||
? "passwordManagerPlanPrice"
|
||||
: "secretsManagerPlanPrice";
|
||||
|
||||
<div id="price" class="tw-my-4">
|
||||
<div class="tw-text-muted tw-text-base">
|
||||
{{ priceLabel | i18n }}: {{ getPriceFor(formGroup.value.cadence) | currency: "USD $" }}
|
||||
<div>
|
||||
{{ "estimatedTax" | i18n }}:
|
||||
@if (fetchingTaxAmount) {
|
||||
<ng-container *ngTemplateOutlet="loadingSpinner" />
|
||||
} @else {
|
||||
{{ taxAmount | currency: "USD $" }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<hr class="tw-my-1 tw-grid tw-grid-cols-3 tw-ml-0" />
|
||||
<p class="tw-text-lg">
|
||||
<strong>{{ "total" | i18n }}: </strong>
|
||||
@if (fetchingTaxAmount) {
|
||||
<ng-container *ngTemplateOutlet="loadingSpinner" />
|
||||
} @else {
|
||||
{{ total | currency: "USD $" }}/{{ interval | i18n }}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="tw-flex tw-space-x-2">
|
||||
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
|
||||
{{ (trialLength > 0 ? "startTrial" : "submit") | i18n }}
|
||||
</button>
|
||||
<button bitButton type="button" buttonType="secondary" (click)="stepBack()">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ng-template #loadingSpinner>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-template>
|
||||
@@ -1,360 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild,
|
||||
} from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { firstValueFrom, from, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import {
|
||||
BillingInformation,
|
||||
OrganizationBillingServiceAbstraction as OrganizationBillingService,
|
||||
OrganizationInformation,
|
||||
PaymentInformation,
|
||||
PlanInformation,
|
||||
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
|
||||
import {
|
||||
PaymentMethodType,
|
||||
PlanType,
|
||||
ProductTierType,
|
||||
ProductType,
|
||||
} from "@bitwarden/common/billing/enums";
|
||||
import { PreviewTaxAmountForOrganizationTrialRequest } from "@bitwarden/common/billing/models/request/tax";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { BillingSharedModule } from "../../shared";
|
||||
import { PaymentComponent } from "../../shared/payment/payment.component";
|
||||
|
||||
export type TrialOrganizationType = Exclude<ProductTierType, ProductTierType.Free>;
|
||||
|
||||
export interface OrganizationInfo {
|
||||
name: string;
|
||||
email: string;
|
||||
type: TrialOrganizationType | null;
|
||||
}
|
||||
|
||||
export interface OrganizationCreatedEvent {
|
||||
organizationId: string;
|
||||
planDescription: string;
|
||||
}
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
enum SubscriptionCadence {
|
||||
Annual,
|
||||
Monthly,
|
||||
}
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum SubscriptionProduct {
|
||||
PasswordManager,
|
||||
SecretsManager,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-trial-billing-step",
|
||||
templateUrl: "trial-billing-step.component.html",
|
||||
imports: [BillingSharedModule],
|
||||
})
|
||||
export class TrialBillingStepComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
|
||||
@ViewChild(ManageTaxInformationComponent) taxInfoComponent: ManageTaxInformationComponent;
|
||||
@Input() organizationInfo: OrganizationInfo;
|
||||
@Input() subscriptionProduct: SubscriptionProduct = SubscriptionProduct.PasswordManager;
|
||||
@Input() trialLength: number;
|
||||
@Output() steppedBack = new EventEmitter();
|
||||
@Output() organizationCreated = new EventEmitter<OrganizationCreatedEvent>();
|
||||
|
||||
loading = true;
|
||||
fetchingTaxAmount = false;
|
||||
|
||||
annualCadence = SubscriptionCadence.Annual;
|
||||
monthlyCadence = SubscriptionCadence.Monthly;
|
||||
|
||||
formGroup = this.formBuilder.group({
|
||||
cadence: [SubscriptionCadence.Annual, Validators.required],
|
||||
});
|
||||
formPromise: Promise<string>;
|
||||
|
||||
applicablePlans: PlanResponse[];
|
||||
annualPlan?: PlanResponse;
|
||||
monthlyPlan?: PlanResponse;
|
||||
|
||||
taxAmount = 0;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
protected readonly SubscriptionProduct = SubscriptionProduct;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
private formBuilder: FormBuilder,
|
||||
private messagingService: MessagingService,
|
||||
private organizationBillingService: OrganizationBillingService,
|
||||
private toastService: ToastService,
|
||||
private taxService: TaxServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const plans = await this.apiService.getPlans();
|
||||
this.applicablePlans = plans.data.filter(this.isApplicable);
|
||||
this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual);
|
||||
this.monthlyPlan = this.findPlanFor(SubscriptionCadence.Monthly);
|
||||
|
||||
if (this.trialLength === 0) {
|
||||
this.formGroup.controls.cadence.valueChanges
|
||||
.pipe(
|
||||
switchMap((cadence) => from(this.previewTaxAmount(cadence))),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe((taxAmount) => {
|
||||
this.taxAmount = taxAmount;
|
||||
});
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
if (!this.taxInfoComponent.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.formPromise = this.createOrganization();
|
||||
|
||||
const organizationId = await this.formPromise;
|
||||
const planDescription = this.getPlanDescription();
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: this.i18nService.t("organizationCreated"),
|
||||
message: this.i18nService.t("organizationReadyToGo"),
|
||||
});
|
||||
|
||||
this.organizationCreated.emit({
|
||||
organizationId,
|
||||
planDescription,
|
||||
});
|
||||
|
||||
// TODO: No one actually listening to this?
|
||||
this.messagingService.send("organizationCreated", { organizationId });
|
||||
}
|
||||
|
||||
async onTaxInformationChanged() {
|
||||
if (this.trialLength === 0) {
|
||||
this.taxAmount = await this.previewTaxAmount(this.formGroup.value.cadence);
|
||||
}
|
||||
|
||||
this.paymentComponent.showBankAccount =
|
||||
this.taxInfoComponent.getTaxInformation().country === "US";
|
||||
if (
|
||||
!this.paymentComponent.showBankAccount &&
|
||||
this.paymentComponent.selected === PaymentMethodType.BankAccount
|
||||
) {
|
||||
this.paymentComponent.select(PaymentMethodType.Card);
|
||||
}
|
||||
}
|
||||
|
||||
protected getPriceFor(cadence: SubscriptionCadence): number {
|
||||
const plan = this.findPlanFor(cadence);
|
||||
return this.subscriptionProduct === SubscriptionProduct.PasswordManager
|
||||
? plan.PasswordManager.basePrice === 0
|
||||
? plan.PasswordManager.seatPrice
|
||||
: plan.PasswordManager.basePrice
|
||||
: plan.SecretsManager.basePrice === 0
|
||||
? plan.SecretsManager.seatPrice
|
||||
: plan.SecretsManager.basePrice;
|
||||
}
|
||||
|
||||
protected stepBack() {
|
||||
this.steppedBack.emit();
|
||||
}
|
||||
|
||||
private async createOrganization(): Promise<string> {
|
||||
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
const planResponse = this.findPlanFor(this.formGroup.value.cadence);
|
||||
|
||||
const { type, token } = await this.paymentComponent.tokenize();
|
||||
const paymentMethod: [string, PaymentMethodType] = [token, type];
|
||||
|
||||
const organization: OrganizationInformation = {
|
||||
name: this.organizationInfo.name,
|
||||
billingEmail: this.organizationInfo.email,
|
||||
initiationPath:
|
||||
this.subscriptionProduct === SubscriptionProduct.PasswordManager
|
||||
? "Password Manager trial from marketing website"
|
||||
: "Secrets Manager trial from marketing website",
|
||||
};
|
||||
|
||||
const plan: PlanInformation = {
|
||||
type: planResponse.type,
|
||||
passwordManagerSeats: 1,
|
||||
};
|
||||
|
||||
if (this.subscriptionProduct === SubscriptionProduct.SecretsManager) {
|
||||
plan.subscribeToSecretsManager = true;
|
||||
plan.isFromSecretsManagerTrial = true;
|
||||
plan.secretsManagerSeats = 1;
|
||||
}
|
||||
|
||||
const payment: PaymentInformation = {
|
||||
paymentMethod,
|
||||
billing: this.getBillingInformationFromTaxInfoComponent(),
|
||||
skipTrial: this.trialLength === 0,
|
||||
};
|
||||
|
||||
const response = await this.organizationBillingService.purchaseSubscription(
|
||||
{
|
||||
organization,
|
||||
plan,
|
||||
payment,
|
||||
},
|
||||
activeUserId,
|
||||
);
|
||||
|
||||
return response.id;
|
||||
}
|
||||
|
||||
private productTypeToPlanTypeMap: {
|
||||
[productType in TrialOrganizationType]: {
|
||||
[cadence in SubscriptionCadence]?: PlanType;
|
||||
};
|
||||
} = {
|
||||
[ProductTierType.Enterprise]: {
|
||||
[SubscriptionCadence.Annual]: PlanType.EnterpriseAnnually,
|
||||
[SubscriptionCadence.Monthly]: PlanType.EnterpriseMonthly,
|
||||
},
|
||||
[ProductTierType.Families]: {
|
||||
[SubscriptionCadence.Annual]: PlanType.FamiliesAnnually,
|
||||
// No monthly option for Families plan
|
||||
},
|
||||
[ProductTierType.Teams]: {
|
||||
[SubscriptionCadence.Annual]: PlanType.TeamsAnnually,
|
||||
[SubscriptionCadence.Monthly]: PlanType.TeamsMonthly,
|
||||
},
|
||||
[ProductTierType.TeamsStarter]: {
|
||||
// No annual option for Teams Starter plan
|
||||
[SubscriptionCadence.Monthly]: PlanType.TeamsStarter,
|
||||
},
|
||||
};
|
||||
|
||||
private findPlanFor(cadence: SubscriptionCadence): PlanResponse | null {
|
||||
const productType = this.organizationInfo.type;
|
||||
const planType = this.productTypeToPlanTypeMap[productType]?.[cadence];
|
||||
return planType ? this.applicablePlans.find((plan) => plan.type === planType) : null;
|
||||
}
|
||||
|
||||
protected get showTaxIdField(): boolean {
|
||||
switch (this.organizationInfo.type) {
|
||||
case ProductTierType.Families:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private getBillingInformationFromTaxInfoComponent(): BillingInformation {
|
||||
return {
|
||||
postalCode: this.taxInfoComponent.getTaxInformation()?.postalCode,
|
||||
country: this.taxInfoComponent.getTaxInformation()?.country,
|
||||
taxId: this.taxInfoComponent.getTaxInformation()?.taxId,
|
||||
addressLine1: this.taxInfoComponent.getTaxInformation()?.line1,
|
||||
addressLine2: this.taxInfoComponent.getTaxInformation()?.line2,
|
||||
city: this.taxInfoComponent.getTaxInformation()?.city,
|
||||
state: this.taxInfoComponent.getTaxInformation()?.state,
|
||||
};
|
||||
}
|
||||
|
||||
private getPlanDescription(): string {
|
||||
const plan = this.findPlanFor(this.formGroup.value.cadence);
|
||||
const price =
|
||||
this.subscriptionProduct === SubscriptionProduct.PasswordManager
|
||||
? plan.PasswordManager.basePrice === 0
|
||||
? plan.PasswordManager.seatPrice
|
||||
: plan.PasswordManager.basePrice
|
||||
: plan.SecretsManager.basePrice === 0
|
||||
? plan.SecretsManager.seatPrice
|
||||
: plan.SecretsManager.basePrice;
|
||||
|
||||
switch (this.formGroup.value.cadence) {
|
||||
case SubscriptionCadence.Annual:
|
||||
return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`;
|
||||
case SubscriptionCadence.Monthly:
|
||||
return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`;
|
||||
}
|
||||
}
|
||||
|
||||
private isApplicable(plan: PlanResponse): boolean {
|
||||
const hasCorrectProductType =
|
||||
plan.productTier === ProductTierType.Enterprise ||
|
||||
plan.productTier === ProductTierType.Families ||
|
||||
plan.productTier === ProductTierType.Teams ||
|
||||
plan.productTier === ProductTierType.TeamsStarter;
|
||||
const notDisabledOrLegacy = !plan.disabled && !plan.legacyYear;
|
||||
return hasCorrectProductType && notDisabledOrLegacy;
|
||||
}
|
||||
|
||||
private previewTaxAmount = async (cadence: SubscriptionCadence): Promise<number> => {
|
||||
this.fetchingTaxAmount = true;
|
||||
|
||||
if (!this.taxInfoComponent.validate()) {
|
||||
this.fetchingTaxAmount = false;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const plan = this.findPlanFor(cadence);
|
||||
|
||||
const productType =
|
||||
this.subscriptionProduct === SubscriptionProduct.PasswordManager
|
||||
? ProductType.PasswordManager
|
||||
: ProductType.SecretsManager;
|
||||
|
||||
const taxInformation = this.taxInfoComponent.getTaxInformation();
|
||||
|
||||
const request: PreviewTaxAmountForOrganizationTrialRequest = {
|
||||
planType: plan.type,
|
||||
productType,
|
||||
taxInformation: {
|
||||
...taxInformation,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await this.taxService.previewTaxAmountForOrganizationTrial(request);
|
||||
this.fetchingTaxAmount = false;
|
||||
return response;
|
||||
};
|
||||
|
||||
get price() {
|
||||
return this.getPriceFor(this.formGroup.value.cadence);
|
||||
}
|
||||
|
||||
get total() {
|
||||
return this.price + this.taxAmount;
|
||||
}
|
||||
|
||||
get interval() {
|
||||
return this.formGroup.value.cadence === SubscriptionCadence.Annual ? "year" : "month";
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./organization-billing.client";
|
||||
export * from "./subscriber-billing.client";
|
||||
export * from "./tax.client";
|
||||
|
||||
@@ -82,6 +82,24 @@ export class SubscriberBillingClient {
|
||||
return data ? new MaskedPaymentMethodResponse(data).value : null;
|
||||
};
|
||||
|
||||
restartSubscription = async (
|
||||
subscriber: BitwardenSubscriber,
|
||||
paymentMethod: TokenizedPaymentMethod,
|
||||
billingAddress: BillingAddress,
|
||||
): Promise<void> => {
|
||||
const path = `${this.getEndpoint(subscriber)}/subscription/restart`;
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
path,
|
||||
{
|
||||
paymentMethod,
|
||||
billingAddress,
|
||||
},
|
||||
true,
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
updateBillingAddress = async (
|
||||
subscriber: BitwardenSubscriber,
|
||||
billingAddress: BillingAddress,
|
||||
|
||||
131
apps/web/src/app/billing/clients/tax.client.ts
Normal file
131
apps/web/src/app/billing/clients/tax.client.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
|
||||
class TaxAmountResponse extends BaseResponse implements TaxAmounts {
|
||||
tax: number;
|
||||
total: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.tax = this.getResponseProperty("Tax");
|
||||
this.total = this.getResponseProperty("Total");
|
||||
}
|
||||
}
|
||||
|
||||
export type OrganizationSubscriptionPlan = {
|
||||
tier: "families" | "teams" | "enterprise";
|
||||
cadence: "annually" | "monthly";
|
||||
};
|
||||
|
||||
export type OrganizationSubscriptionPurchase = OrganizationSubscriptionPlan & {
|
||||
passwordManager: {
|
||||
seats: number;
|
||||
additionalStorage: number;
|
||||
sponsored: boolean;
|
||||
};
|
||||
secretsManager?: {
|
||||
seats: number;
|
||||
additionalServiceAccounts: number;
|
||||
standalone: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type OrganizationSubscriptionUpdate = {
|
||||
passwordManager?: {
|
||||
seats?: number;
|
||||
additionalStorage?: number;
|
||||
};
|
||||
secretsManager?: {
|
||||
seats?: number;
|
||||
additionalServiceAccounts?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export interface TaxAmounts {
|
||||
tax: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TaxClient {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
previewTaxForOrganizationSubscriptionPurchase = async (
|
||||
purchase: OrganizationSubscriptionPurchase,
|
||||
billingAddress: BillingAddress,
|
||||
): Promise<TaxAmounts> => {
|
||||
const json = await this.apiService.send(
|
||||
"POST",
|
||||
"/billing/tax/organizations/subscriptions/purchase",
|
||||
{
|
||||
purchase,
|
||||
billingAddress,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return new TaxAmountResponse(json);
|
||||
};
|
||||
|
||||
previewTaxForOrganizationSubscriptionPlanChange = async (
|
||||
organizationId: string,
|
||||
plan: {
|
||||
tier: "families" | "teams" | "enterprise";
|
||||
cadence: "annually" | "monthly";
|
||||
},
|
||||
billingAddress: BillingAddress | null,
|
||||
): Promise<TaxAmounts> => {
|
||||
const json = await this.apiService.send(
|
||||
"POST",
|
||||
`/billing/tax/organizations/${organizationId}/subscription/plan-change`,
|
||||
{
|
||||
plan,
|
||||
billingAddress,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return new TaxAmountResponse(json);
|
||||
};
|
||||
|
||||
previewTaxForOrganizationSubscriptionUpdate = async (
|
||||
organizationId: string,
|
||||
update: OrganizationSubscriptionUpdate,
|
||||
): Promise<TaxAmounts> => {
|
||||
const json = await this.apiService.send(
|
||||
"POST",
|
||||
`/billing/tax/organizations/${organizationId}/subscription/update`,
|
||||
{
|
||||
update,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return new TaxAmountResponse(json);
|
||||
};
|
||||
|
||||
previewTaxForPremiumSubscriptionPurchase = async (
|
||||
additionalStorage: number,
|
||||
billingAddress: BillingAddress,
|
||||
): Promise<TaxAmounts> => {
|
||||
const json = await this.apiService.send(
|
||||
"POST",
|
||||
`/billing/tax/premium/subscriptions/purchase`,
|
||||
{
|
||||
additionalStorage,
|
||||
billingAddress,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return new TaxAmountResponse(json);
|
||||
};
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export { OrganizationPlansComponent } from "./organizations";
|
||||
export { TaxInfoComponent } from "./shared";
|
||||
|
||||
@@ -3,8 +3,6 @@ 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";
|
||||
import { PremiumComponent } from "./premium/premium.component";
|
||||
import { SubscriptionComponent } from "./subscription.component";
|
||||
@@ -27,11 +25,6 @@ const routes: Routes = [
|
||||
component: PremiumComponent,
|
||||
data: { titleId: "goPremium" },
|
||||
},
|
||||
{
|
||||
path: "payment-method",
|
||||
component: PaymentMethodComponent,
|
||||
data: { titleId: "paymentMethod" },
|
||||
},
|
||||
{
|
||||
path: "payment-details",
|
||||
component: AccountPaymentDetailsComponent,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import {
|
||||
EnterBillingAddressComponent,
|
||||
EnterPaymentMethodComponent,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||
|
||||
import { HeaderModule } from "../../layouts/header/header.module";
|
||||
import { BillingSharedModule } from "../shared";
|
||||
|
||||
@@ -10,7 +15,13 @@ import { SubscriptionComponent } from "./subscription.component";
|
||||
import { UserSubscriptionComponent } from "./user-subscription.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [IndividualBillingRoutingModule, BillingSharedModule, HeaderModule],
|
||||
imports: [
|
||||
IndividualBillingRoutingModule,
|
||||
BillingSharedModule,
|
||||
HeaderModule,
|
||||
EnterPaymentMethodComponent,
|
||||
EnterBillingAddressComponent,
|
||||
],
|
||||
declarations: [
|
||||
SubscriptionComponent,
|
||||
BillingHistoryViewComponent,
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
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 { BehaviorSubject, filter, merge, Observable, shareReplay, switchMap, tap } 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 { HeaderModule } from "../../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../../shared";
|
||||
@@ -28,13 +13,6 @@ import {
|
||||
import { MaskedPaymentMethod } from "../../payment/types";
|
||||
import { mapAccountToSubscriber, BitwardenSubscriber } from "../../types";
|
||||
|
||||
class RedirectError {
|
||||
constructor(
|
||||
public path: string[],
|
||||
public relativeTo: ActivatedRoute,
|
||||
) {}
|
||||
}
|
||||
|
||||
type View = {
|
||||
account: BitwardenSubscriber;
|
||||
paymentMethod: MaskedPaymentMethod | null;
|
||||
@@ -56,23 +34,11 @@ export class AccountPaymentDetailsComponent {
|
||||
private viewState$ = new BehaviorSubject<View | null>(null);
|
||||
|
||||
private load$: Observable<View> = 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;
|
||||
}),
|
||||
),
|
||||
),
|
||||
mapAccountToSubscriber,
|
||||
switchMap(async (account) => {
|
||||
const [paymentMethod, credit] = await Promise.all([
|
||||
this.billingClient.getPaymentMethod(account),
|
||||
this.billingClient.getCredit(account),
|
||||
this.subscriberBillingClient.getPaymentMethod(account),
|
||||
this.subscriberBillingClient.getCredit(account),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -82,14 +48,6 @@ export class AccountPaymentDetailsComponent {
|
||||
};
|
||||
}),
|
||||
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<View> = merge(
|
||||
@@ -99,10 +57,7 @@ export class AccountPaymentDetailsComponent {
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private billingClient: SubscriberBillingClient,
|
||||
private configService: ConfigService,
|
||||
private router: Router,
|
||||
private subscriberBillingClient: SubscriberBillingClient,
|
||||
) {}
|
||||
|
||||
setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => {
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
|
||||
/>
|
||||
</bit-section>
|
||||
<form *ngIf="!isSelfHost" [formGroup]="addOnFormGroup" [bitSubmit]="submitPayment">
|
||||
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
@@ -93,15 +93,25 @@
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
|
||||
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
|
||||
{{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB ×
|
||||
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB ×
|
||||
{{ storageGBPrice | currency: "$" }} =
|
||||
{{ additionalStorageCost | currency: "$" }}
|
||||
<hr class="tw-my-3" />
|
||||
</bit-section>
|
||||
<bit-section>
|
||||
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
|
||||
<app-payment [showBankAccount]="false"></app-payment>
|
||||
<app-tax-info (taxInformationChanged)="onTaxInformationChanged()"></app-tax-info>
|
||||
<div class="tw-mb-4">
|
||||
<app-enter-payment-method
|
||||
[group]="formGroup.controls.paymentMethod"
|
||||
[showBankAccount]="false"
|
||||
>
|
||||
</app-enter-payment-method>
|
||||
<app-enter-billing-address
|
||||
[group]="formGroup.controls.billingAddress"
|
||||
[scenario]="{ type: 'checkout', supportsTaxId: false }"
|
||||
>
|
||||
</app-enter-billing-address>
|
||||
</div>
|
||||
<div class="tw-mb-4">
|
||||
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
|
||||
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>
|
||||
|
||||
@@ -9,36 +9,34 @@ import { debounceTime } from "rxjs/operators";
|
||||
|
||||
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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
|
||||
import { PreviewIndividualInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-individual-invoice.request";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.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";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { PaymentComponent } from "../../shared/payment/payment.component";
|
||||
import { TaxInfoComponent } from "../../shared/tax-info.component";
|
||||
import { TaxClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
import {
|
||||
EnterBillingAddressComponent,
|
||||
EnterPaymentMethodComponent,
|
||||
getBillingAddressFromForm,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||
import { tokenizablePaymentMethodToLegacyEnum } from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./premium.component.html",
|
||||
standalone: false,
|
||||
providers: [TaxClient],
|
||||
})
|
||||
export class PremiumComponent {
|
||||
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
|
||||
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
|
||||
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
|
||||
|
||||
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
|
||||
|
||||
protected addOnFormGroup = new FormGroup({
|
||||
protected formGroup = new FormGroup({
|
||||
additionalStorage: new FormControl<number>(0, [Validators.min(0), Validators.max(99)]),
|
||||
});
|
||||
|
||||
protected licenseFormGroup = new FormGroup({
|
||||
file: new FormControl<File>(null, [Validators.required]),
|
||||
paymentMethod: EnterPaymentMethodComponent.getFormGroup(),
|
||||
billingAddress: EnterBillingAddressComponent.getFormGroup(),
|
||||
});
|
||||
|
||||
protected cloudWebVaultURL: string;
|
||||
@@ -53,16 +51,14 @@ export class PremiumComponent {
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private apiService: ApiService,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private configService: ConfigService,
|
||||
private environmentService: EnvironmentService,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private router: Router,
|
||||
private syncService: SyncService,
|
||||
private toastService: ToastService,
|
||||
private tokenService: TokenService,
|
||||
private taxService: TaxServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private taxClient: TaxClient,
|
||||
) {
|
||||
this.isSelfHost = this.platformUtilsService.isSelfHost();
|
||||
|
||||
@@ -93,11 +89,13 @@ export class PremiumComponent {
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.addOnFormGroup.controls.additionalStorage.valueChanges
|
||||
.pipe(debounceTime(1000), takeUntilDestroyed())
|
||||
.subscribe(() => {
|
||||
this.refreshSalesTax();
|
||||
});
|
||||
this.formGroup.valueChanges
|
||||
.pipe(
|
||||
debounceTime(1000),
|
||||
switchMap(async () => await this.refreshSalesTax()),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
finalizeUpgrade = async () => {
|
||||
@@ -117,53 +115,21 @@ export class PremiumComponent {
|
||||
navigateToSubscriptionPage = (): Promise<boolean> =>
|
||||
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
|
||||
|
||||
onLicenseFileSelected = (event: Event): void => {
|
||||
const element = event.target as HTMLInputElement;
|
||||
this.licenseFormGroup.value.file = element.files.length > 0 ? element.files[0] : null;
|
||||
};
|
||||
|
||||
submitPremiumLicense = async (): Promise<void> => {
|
||||
this.licenseFormGroup.markAllAsTouched();
|
||||
|
||||
if (this.licenseFormGroup.invalid) {
|
||||
return this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("selectFile"),
|
||||
});
|
||||
}
|
||||
|
||||
const emailVerified = await this.tokenService.getEmailVerified();
|
||||
if (!emailVerified) {
|
||||
return this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("verifyEmailFirst"),
|
||||
});
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("license", this.licenseFormGroup.value.file);
|
||||
|
||||
await this.apiService.postAccountLicense(formData);
|
||||
await this.finalizeUpgrade();
|
||||
await this.postFinalizeUpgrade();
|
||||
};
|
||||
|
||||
submitPayment = async (): Promise<void> => {
|
||||
this.taxInfoComponent.taxFormGroup.markAllAsTouched();
|
||||
if (this.taxInfoComponent.taxFormGroup.invalid) {
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, token } = await this.paymentComponent.tokenize();
|
||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
||||
|
||||
const legacyEnum = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("paymentMethodType", type.toString());
|
||||
formData.append("paymentToken", token);
|
||||
formData.append("additionalStorageGb", this.addOnFormGroup.value.additionalStorage.toString());
|
||||
formData.append("country", this.taxInfoComponent.country);
|
||||
formData.append("postalCode", this.taxInfoComponent.postalCode);
|
||||
formData.append("paymentMethodType", legacyEnum.toString());
|
||||
formData.append("paymentToken", paymentMethod.token);
|
||||
formData.append("additionalStorageGb", this.formGroup.value.additionalStorage.toString());
|
||||
formData.append("country", this.formGroup.value.billingAddress.country);
|
||||
formData.append("postalCode", this.formGroup.value.billingAddress.postalCode);
|
||||
|
||||
await this.apiService.postPremium(formData);
|
||||
await this.finalizeUpgrade();
|
||||
@@ -171,7 +137,7 @@ export class PremiumComponent {
|
||||
};
|
||||
|
||||
protected get additionalStorageCost(): number {
|
||||
return this.storageGBPrice * this.addOnFormGroup.value.additionalStorage;
|
||||
return this.storageGBPrice * this.formGroup.value.additionalStorage;
|
||||
}
|
||||
|
||||
protected get premiumURL(): string {
|
||||
@@ -190,35 +156,18 @@ export class PremiumComponent {
|
||||
await this.postFinalizeUpgrade();
|
||||
}
|
||||
|
||||
private refreshSalesTax(): void {
|
||||
if (!this.taxInfoComponent.country || !this.taxInfoComponent.postalCode) {
|
||||
private async refreshSalesTax(): Promise<void> {
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
const request: PreviewIndividualInvoiceRequest = {
|
||||
passwordManager: {
|
||||
additionalStorage: this.addOnFormGroup.value.additionalStorage,
|
||||
},
|
||||
taxInformation: {
|
||||
postalCode: this.taxInfoComponent.postalCode,
|
||||
country: this.taxInfoComponent.country,
|
||||
},
|
||||
};
|
||||
|
||||
this.taxService
|
||||
.previewIndividualInvoice(request)
|
||||
.then((invoice) => {
|
||||
this.estimatedTax = invoice.taxAmount;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastService.showToast({
|
||||
title: "",
|
||||
variant: "error",
|
||||
message: this.i18nService.t(error.message),
|
||||
});
|
||||
});
|
||||
}
|
||||
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
|
||||
|
||||
protected onTaxInformationChanged(): void {
|
||||
this.refreshSalesTax();
|
||||
const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase(
|
||||
this.formGroup.value.additionalStorage,
|
||||
billingAddress,
|
||||
);
|
||||
|
||||
this.estimatedTax = taxAmounts.tax;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
<bit-tab-link [route]="(hasPremium$ | async) ? 'user-subscription' : 'premium'">{{
|
||||
"subscription" | i18n
|
||||
}}</bit-tab-link>
|
||||
@let paymentMethodPageData = paymentDetailsPageData$ | async;
|
||||
<bit-tab-link [route]="paymentMethodPageData.route">{{
|
||||
paymentMethodPageData.textKey | i18n
|
||||
}}</bit-tab-link>
|
||||
<bit-tab-link route="payment-details">{{ "paymentDetails" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link route="billing-history">{{ "billingHistory" | i18n }}</bit-tab-link>
|
||||
</bit-tab-nav-bar>
|
||||
</app-header>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { map, Observable, switchMap } from "rxjs";
|
||||
import { 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({
|
||||
@@ -15,32 +13,16 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
})
|
||||
export class SubscriptionComponent implements OnInit {
|
||||
hasPremium$: Observable<boolean>;
|
||||
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() {
|
||||
|
||||
@@ -328,24 +328,60 @@
|
||||
*ngIf="formGroup.value.productTier !== productTypes.Free || isSubscriptionCanceled"
|
||||
>
|
||||
<h2 bitTypography="h4">{{ "paymentMethod" | i18n }}</h2>
|
||||
<p
|
||||
*ngIf="
|
||||
!showPayment && (paymentSource || billing?.paymentSource) && !isSubscriptionCanceled
|
||||
"
|
||||
>
|
||||
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
|
||||
{{ paymentSource?.description }}
|
||||
<span class="tw-ml-2 tw-text-primary-600 tw-cursor-pointer" (click)="toggleShowPayment()">
|
||||
{{ "changePaymentMethod" | i18n }}
|
||||
</span>
|
||||
<p *ngIf="!showPayment && !!paymentMethod && !isSubscriptionCanceled">
|
||||
@switch (paymentMethod.type) {
|
||||
@case ("bankAccount") {
|
||||
<i class="bwi bwi-fw bwi-billing"></i>
|
||||
{{ paymentMethod.bankName }}, *{{ paymentMethod.last4 }}
|
||||
@if (paymentMethod.hostedVerificationUrl) {
|
||||
<span>- {{ "unverified" | i18n }}</span>
|
||||
}
|
||||
<span
|
||||
class="tw-ml-2 tw-text-primary-600 tw-cursor-pointer"
|
||||
(click)="toggleShowPayment()"
|
||||
>
|
||||
{{ "changePaymentMethod" | i18n }}
|
||||
</span>
|
||||
}
|
||||
@case ("card") {
|
||||
<p class="tw-flex tw-items-center tw-gap-2">
|
||||
@let cardBrandIcon = getCardBrandIcon();
|
||||
@if (cardBrandIcon !== null) {
|
||||
<i class="bwi bwi-fw credit-card-icon {{ cardBrandIcon }}"></i>
|
||||
} @else {
|
||||
<i class="bwi bwi-fw bwi-credit-card"></i>
|
||||
}
|
||||
{{ paymentMethod.brand | titlecase }}, *{{ paymentMethod.last4 }},
|
||||
{{ paymentMethod.expiration }}
|
||||
<span
|
||||
class="tw-ml-2 tw-text-primary-600 tw-cursor-pointer"
|
||||
(click)="toggleShowPayment()"
|
||||
>
|
||||
{{ "changePaymentMethod" | i18n }}
|
||||
</span>
|
||||
</p>
|
||||
}
|
||||
@case ("payPal") {
|
||||
<i class="bwi bwi-fw bwi-paypal tw-text-primary-600"></i>
|
||||
{{ paymentMethod.email }}
|
||||
<span
|
||||
class="tw-ml-2 tw-text-primary-600 tw-cursor-pointer"
|
||||
(click)="toggleShowPayment()"
|
||||
>
|
||||
{{ "changePaymentMethod" | i18n }}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
<a></a>
|
||||
</p>
|
||||
<ng-container *ngIf="canUpdatePaymentInformation()">
|
||||
<app-payment [showAccountCredit]="false" />
|
||||
<app-manage-tax-information
|
||||
[startWith]="taxInformation"
|
||||
(taxInformationChanged)="taxInformationChanged($event)"
|
||||
></app-manage-tax-information>
|
||||
<app-enter-payment-method [group]="billingFormGroup.controls.paymentMethod">
|
||||
</app-enter-payment-method>
|
||||
<app-enter-billing-address
|
||||
[group]="billingFormGroup.controls.billingAddress"
|
||||
[scenario]="{ type: 'checkout', supportsTaxId }"
|
||||
>
|
||||
</app-enter-billing-address>
|
||||
</ng-container>
|
||||
<div class="tw-mt-4">
|
||||
<p class="tw-text-lg tw-mb-1">
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
} from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs";
|
||||
import { debounceTime } from "rxjs/operators";
|
||||
|
||||
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import {
|
||||
@@ -28,28 +28,8 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/
|
||||
import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import {
|
||||
BillingApiServiceAbstraction,
|
||||
BillingInformation,
|
||||
OrganizationBillingServiceAbstraction as OrganizationBillingService,
|
||||
OrganizationInformation,
|
||||
PaymentInformation,
|
||||
PlanInformation,
|
||||
} from "@bitwarden/common/billing/abstractions";
|
||||
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
|
||||
import {
|
||||
PaymentMethodType,
|
||||
PlanInterval,
|
||||
PlanType,
|
||||
ProductTierType,
|
||||
} from "@bitwarden/common/billing/enums";
|
||||
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
|
||||
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
|
||||
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
|
||||
import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response";
|
||||
import { PlanInterval, PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -57,6 +37,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import {
|
||||
CardComponent,
|
||||
DIALOG_DATA,
|
||||
DialogConfig,
|
||||
DialogRef,
|
||||
@@ -64,11 +45,25 @@ import {
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
import {
|
||||
OrganizationSubscriptionPlan,
|
||||
SubscriberBillingClient,
|
||||
TaxClient,
|
||||
} from "@bitwarden/web-vault/app/billing/clients";
|
||||
import {
|
||||
EnterBillingAddressComponent,
|
||||
EnterPaymentMethodComponent,
|
||||
getBillingAddressFromForm,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||
import {
|
||||
BillingAddress,
|
||||
getCardBrandIcon,
|
||||
MaskedPaymentMethod,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types";
|
||||
|
||||
import { BillingNotificationService } from "../services/billing-notification.service";
|
||||
import { BillingSharedModule } from "../shared/billing-shared.module";
|
||||
import { PaymentComponent } from "../shared/payment/payment.component";
|
||||
|
||||
type ChangePlanDialogParams = {
|
||||
organizationId: string;
|
||||
@@ -111,11 +106,16 @@ interface OnSuccessArgs {
|
||||
|
||||
@Component({
|
||||
templateUrl: "./change-plan-dialog.component.html",
|
||||
imports: [BillingSharedModule],
|
||||
imports: [
|
||||
BillingSharedModule,
|
||||
EnterPaymentMethodComponent,
|
||||
EnterBillingAddressComponent,
|
||||
CardComponent,
|
||||
],
|
||||
providers: [SubscriberBillingClient, TaxClient],
|
||||
})
|
||||
export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
|
||||
@ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent;
|
||||
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent: EnterPaymentMethodComponent;
|
||||
|
||||
@Input() acceptingSponsorship = false;
|
||||
@Input() organizationId: string;
|
||||
@@ -172,7 +172,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
clientOwnerEmail: ["", [Validators.email]],
|
||||
plan: [this.plan],
|
||||
productTier: [this.productTier],
|
||||
// planInterval: [1],
|
||||
});
|
||||
|
||||
billingFormGroup = this.formBuilder.group({
|
||||
paymentMethod: EnterPaymentMethodComponent.getFormGroup(),
|
||||
billingAddress: EnterBillingAddressComponent.getFormGroup(),
|
||||
});
|
||||
|
||||
planType: string;
|
||||
@@ -183,7 +187,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
secretsManagerPlans: PlanResponse[];
|
||||
organization: Organization;
|
||||
sub: OrganizationSubscriptionResponse;
|
||||
billing: BillingResponse;
|
||||
dialogHeaderName: string;
|
||||
currentPlanName: string;
|
||||
showPayment: boolean = false;
|
||||
@@ -191,15 +194,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
currentPlan: PlanResponse;
|
||||
isCardStateDisabled = false;
|
||||
focusedIndex: number | null = null;
|
||||
accountCredit: number;
|
||||
paymentSource?: PaymentSourceResponse;
|
||||
plans: ListResponse<PlanResponse>;
|
||||
isSubscriptionCanceled: boolean = false;
|
||||
secretsManagerTotal: number;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
paymentMethod: MaskedPaymentMethod | null;
|
||||
billingAddress: BillingAddress | null;
|
||||
|
||||
protected taxInformation: TaxInformation;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) private dialogParams: ChangePlanDialogParams,
|
||||
@@ -215,11 +217,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
private messagingService: MessagingService,
|
||||
private formBuilder: FormBuilder,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private taxService: TaxServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private organizationBillingService: OrganizationBillingService,
|
||||
private billingNotificationService: BillingNotificationService,
|
||||
private subscriberBillingClient: SubscriberBillingClient,
|
||||
private taxClient: TaxClient,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@@ -242,10 +243,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
if (this.sub?.subscription?.status !== "canceled") {
|
||||
try {
|
||||
const { accountCredit, paymentSource } =
|
||||
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
|
||||
this.accountCredit = accountCredit;
|
||||
this.paymentSource = paymentSource;
|
||||
const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization };
|
||||
const [paymentMethod, billingAddress] = await Promise.all([
|
||||
this.subscriberBillingClient.getPaymentMethod(subscriber),
|
||||
this.subscriberBillingClient.getBillingAddress(subscriber),
|
||||
]);
|
||||
|
||||
this.paymentMethod = paymentMethod;
|
||||
this.billingAddress = billingAddress;
|
||||
} catch (error) {
|
||||
this.billingNotificationService.handleError(error);
|
||||
}
|
||||
@@ -307,15 +312,24 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
? 0
|
||||
: (this.sub?.customerDiscount?.percentOff ?? 0);
|
||||
|
||||
this.setInitialPlanSelection();
|
||||
this.loading = false;
|
||||
|
||||
const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId);
|
||||
this.taxInformation = TaxInformation.from(taxInfo);
|
||||
|
||||
await this.setInitialPlanSelection();
|
||||
if (!this.isSubscriptionCanceled) {
|
||||
this.refreshSalesTax();
|
||||
await this.refreshSalesTax();
|
||||
}
|
||||
|
||||
combineLatest([
|
||||
this.billingFormGroup.controls.billingAddress.controls.country.valueChanges,
|
||||
this.billingFormGroup.controls.billingAddress.controls.postalCode.valueChanges,
|
||||
this.billingFormGroup.controls.billingAddress.controls.taxId.valueChanges,
|
||||
])
|
||||
.pipe(
|
||||
debounceTime(1000),
|
||||
switchMap(async () => await this.refreshSalesTax()),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
resolveHeaderName(subscription: OrganizationSubscriptionResponse): string {
|
||||
@@ -333,10 +347,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
setInitialPlanSelection() {
|
||||
async setInitialPlanSelection() {
|
||||
this.focusedIndex = this.selectableProducts.length - 1;
|
||||
if (!this.isSubscriptionCanceled) {
|
||||
this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
|
||||
await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,10 +358,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
return this.selectableProducts.find((product) => product.productTier === productTier);
|
||||
}
|
||||
|
||||
isPaymentSourceEmpty() {
|
||||
return this.paymentSource === null || this.paymentSource === undefined;
|
||||
}
|
||||
|
||||
isSecretsManagerTrial(): boolean {
|
||||
return (
|
||||
this.sub?.subscription?.items?.some((item) =>
|
||||
@@ -356,13 +366,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
planTypeChanged() {
|
||||
this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
|
||||
async planTypeChanged() {
|
||||
await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
|
||||
}
|
||||
|
||||
updateInterval(event: number) {
|
||||
async updateInterval(event: number) {
|
||||
this.selectedInterval = event;
|
||||
this.planTypeChanged();
|
||||
await this.planTypeChanged();
|
||||
}
|
||||
|
||||
protected getPlanIntervals() {
|
||||
@@ -460,7 +470,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
protected selectPlan(plan: PlanResponse) {
|
||||
protected async selectPlan(plan: PlanResponse) {
|
||||
if (
|
||||
this.selectedInterval === PlanInterval.Monthly &&
|
||||
plan.productTier == ProductTierType.Families
|
||||
@@ -475,7 +485,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
this.formGroup.patchValue({ productTier: plan.productTier });
|
||||
|
||||
try {
|
||||
this.refreshSalesTax();
|
||||
await this.refreshSalesTax();
|
||||
} catch {
|
||||
this.estimatedTax = 0;
|
||||
}
|
||||
@@ -489,19 +499,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
get upgradeRequiresPaymentMethod() {
|
||||
const isFreeTier = this.organization?.productTierType === ProductTierType.Free;
|
||||
const shouldHideFree = !this.showFree;
|
||||
const hasNoPaymentSource = !this.paymentSource;
|
||||
const hasNoPaymentSource = !this.paymentMethod;
|
||||
|
||||
return isFreeTier && shouldHideFree && hasNoPaymentSource;
|
||||
}
|
||||
|
||||
get selectedSecretsManagerPlan() {
|
||||
let planResponse: PlanResponse;
|
||||
if (this.secretsManagerPlans) {
|
||||
return this.secretsManagerPlans.find((plan) => plan.type === this.selectedPlan.type);
|
||||
}
|
||||
return planResponse;
|
||||
}
|
||||
|
||||
get selectedPlanInterval() {
|
||||
if (this.isSubscriptionCanceled) {
|
||||
return this.currentPlan.isAnnual ? "year" : "month";
|
||||
@@ -591,8 +593,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = plan.PasswordManager.seatPrice * Math.abs(this.sub?.seats || 0);
|
||||
return result;
|
||||
return plan.PasswordManager.seatPrice * Math.abs(this.sub?.seats || 0);
|
||||
}
|
||||
|
||||
secretsManagerSeatTotal(plan: PlanResponse, seats: number): number {
|
||||
@@ -746,39 +747,22 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
this.formGroup.controls.additionalSeats.setValue(1);
|
||||
}
|
||||
|
||||
changedCountry() {
|
||||
this.paymentComponent.showBankAccount = this.taxInformation.country === "US";
|
||||
|
||||
if (
|
||||
!this.paymentComponent.showBankAccount &&
|
||||
this.paymentComponent.selected === PaymentMethodType.BankAccount
|
||||
) {
|
||||
this.paymentComponent.select(PaymentMethodType.Card);
|
||||
}
|
||||
}
|
||||
|
||||
protected taxInformationChanged(event: TaxInformation): void {
|
||||
this.taxInformation = event;
|
||||
this.changedCountry();
|
||||
this.refreshSalesTax();
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
if (this.taxComponent !== undefined && !this.taxComponent.validate()) {
|
||||
this.taxComponent.markAllAsTouched();
|
||||
this.formGroup.markAllAsTouched();
|
||||
this.billingFormGroup.markAllAsTouched();
|
||||
if (this.formGroup.invalid || (this.billingFormGroup.invalid && !this.paymentMethod)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doSubmit = async (): Promise<string> => {
|
||||
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
let orgId: string = null;
|
||||
let orgId: string;
|
||||
const sub = this.sub?.subscription;
|
||||
const isCanceled = sub?.status === "canceled";
|
||||
const isCancelledDowngradedToFreeOrg =
|
||||
sub?.cancelled && this.organization.productTierType === ProductTierType.Free;
|
||||
|
||||
if (isCanceled || isCancelledDowngradedToFreeOrg) {
|
||||
await this.restartSubscription(activeUserId);
|
||||
await this.restartSubscription();
|
||||
orgId = this.organizationId;
|
||||
} else {
|
||||
orgId = await this.updateOrganization();
|
||||
@@ -795,9 +779,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
if (!this.acceptingSponsorship && !this.isInTrialFlow) {
|
||||
// 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
|
||||
this.router.navigate(["/organizations/" + orgId + "/billing/subscription"]);
|
||||
await this.router.navigate(["/organizations/" + orgId + "/billing/subscription"]);
|
||||
}
|
||||
|
||||
if (this.isInTrialFlow) {
|
||||
@@ -818,46 +800,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
this.dialogRef.close();
|
||||
};
|
||||
|
||||
private async restartSubscription(activeUserId: UserId) {
|
||||
const org = await this.organizationApiService.get(this.organizationId);
|
||||
const organization: OrganizationInformation = {
|
||||
name: org.name,
|
||||
billingEmail: org.billingEmail,
|
||||
};
|
||||
|
||||
const filteredPlan = this.plans.data
|
||||
.filter((plan) => plan.productTier === this.selectedPlan.productTier && !plan.legacyYear)
|
||||
.find((plan) => {
|
||||
const isSameBillingCycle = plan.isAnnual === this.selectedPlan.isAnnual;
|
||||
return isSameBillingCycle;
|
||||
});
|
||||
|
||||
const plan: PlanInformation = {
|
||||
type: filteredPlan.type,
|
||||
passwordManagerSeats: org.seats,
|
||||
};
|
||||
|
||||
if (org.useSecretsManager) {
|
||||
plan.subscribeToSecretsManager = true;
|
||||
plan.secretsManagerSeats = org.smSeats;
|
||||
}
|
||||
|
||||
const { type, token } = await this.paymentComponent.tokenize();
|
||||
const paymentMethod: [string, PaymentMethodType] = [token, type];
|
||||
|
||||
const payment: PaymentInformation = {
|
||||
private async restartSubscription() {
|
||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
||||
const billingAddress = getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress);
|
||||
await this.subscriberBillingClient.restartSubscription(
|
||||
{ type: "organization", data: this.organization },
|
||||
paymentMethod,
|
||||
billing: this.getBillingInformationFromTaxInfoComponent(),
|
||||
};
|
||||
|
||||
await this.organizationBillingService.restartSubscription(
|
||||
this.organization.id,
|
||||
{
|
||||
organization,
|
||||
plan,
|
||||
payment,
|
||||
},
|
||||
activeUserId,
|
||||
billingAddress,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -875,25 +824,25 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
this.formGroup.controls.premiumAccessAddon.value;
|
||||
request.planType = this.selectedPlan.type;
|
||||
if (this.showPayment) {
|
||||
request.billingAddressCountry = this.taxInformation.country;
|
||||
request.billingAddressPostalCode = this.taxInformation.postalCode;
|
||||
request.billingAddressCountry = this.billingFormGroup.controls.billingAddress.value.country;
|
||||
request.billingAddressPostalCode =
|
||||
this.billingFormGroup.controls.billingAddress.value.postalCode;
|
||||
}
|
||||
|
||||
// Secrets Manager
|
||||
this.buildSecretsManagerRequest(request);
|
||||
|
||||
if (this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty()) {
|
||||
const tokenizedPaymentSource = await this.paymentComponent.tokenize();
|
||||
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
|
||||
updatePaymentMethodRequest.paymentSource = tokenizedPaymentSource;
|
||||
updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From(
|
||||
this.taxInformation,
|
||||
if (this.upgradeRequiresPaymentMethod || this.showPayment || !this.paymentMethod) {
|
||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
||||
const billingAddress = getBillingAddressFromForm(
|
||||
this.billingFormGroup.controls.billingAddress,
|
||||
);
|
||||
|
||||
await this.billingApiService.updateOrganizationPaymentMethod(
|
||||
this.organizationId,
|
||||
updatePaymentMethodRequest,
|
||||
);
|
||||
const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization };
|
||||
await Promise.all([
|
||||
this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null),
|
||||
this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress),
|
||||
]);
|
||||
}
|
||||
|
||||
// Backfill pub/priv key if necessary
|
||||
@@ -931,18 +880,6 @@ 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) {
|
||||
@@ -1002,25 +939,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
calculateTotalAppliedDiscount(total: number) {
|
||||
const discountedTotal = total * (this.discountPercentageFromSub / 100);
|
||||
return discountedTotal;
|
||||
}
|
||||
|
||||
get paymentSourceClasses() {
|
||||
if (this.paymentSource == null) {
|
||||
return [];
|
||||
}
|
||||
switch (this.paymentSource.type) {
|
||||
case PaymentMethodType.Card:
|
||||
return ["bwi-credit-card"];
|
||||
case PaymentMethodType.BankAccount:
|
||||
case PaymentMethodType.Check:
|
||||
return ["bwi-billing"];
|
||||
case PaymentMethodType.PayPal:
|
||||
return ["bwi-paypal text-primary"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
return total * (this.discountPercentageFromSub / 100);
|
||||
}
|
||||
|
||||
resolvePlanName(productTier: ProductTierType) {
|
||||
@@ -1064,9 +983,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onFocus(index: number) {
|
||||
async onFocus(index: number) {
|
||||
this.focusedIndex = index;
|
||||
this.selectPlan(this.selectableProducts[index]);
|
||||
await this.selectPlan(this.selectableProducts[index]);
|
||||
}
|
||||
|
||||
isCardDisabled(index: number): boolean {
|
||||
@@ -1078,58 +997,44 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
return index;
|
||||
}
|
||||
|
||||
private refreshSalesTax(): void {
|
||||
if (
|
||||
this.taxInformation === undefined ||
|
||||
!this.taxInformation.country ||
|
||||
!this.taxInformation.postalCode
|
||||
) {
|
||||
private async refreshSalesTax(): Promise<void> {
|
||||
if (this.billingFormGroup.controls.billingAddress.invalid && !this.billingAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request: PreviewOrganizationInvoiceRequest = {
|
||||
organizationId: this.organizationId,
|
||||
passwordManager: {
|
||||
additionalStorage: 0,
|
||||
plan: this.selectedPlan?.type,
|
||||
seats: this.sub.seats,
|
||||
},
|
||||
taxInformation: {
|
||||
postalCode: this.taxInformation.postalCode,
|
||||
country: this.taxInformation.country,
|
||||
taxId: this.taxInformation.taxId,
|
||||
},
|
||||
const getPlanFromLegacyEnum = (planType: PlanType): OrganizationSubscriptionPlan => {
|
||||
switch (planType) {
|
||||
case PlanType.FamiliesAnnually:
|
||||
return { tier: "families", cadence: "annually" };
|
||||
case PlanType.TeamsMonthly:
|
||||
return { tier: "teams", cadence: "monthly" };
|
||||
case PlanType.TeamsAnnually:
|
||||
return { tier: "teams", cadence: "annually" };
|
||||
case PlanType.EnterpriseMonthly:
|
||||
return { tier: "enterprise", cadence: "monthly" };
|
||||
case PlanType.EnterpriseAnnually:
|
||||
return { tier: "enterprise", cadence: "annually" };
|
||||
}
|
||||
};
|
||||
|
||||
if (this.organization.useSecretsManager) {
|
||||
request.secretsManager = {
|
||||
seats: this.sub.smSeats,
|
||||
additionalMachineAccounts:
|
||||
this.sub.smServiceAccounts - this.sub.plan.SecretsManager.baseServiceAccount,
|
||||
};
|
||||
}
|
||||
const billingAddress = this.billingFormGroup.controls.billingAddress.valid
|
||||
? getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress)
|
||||
: this.billingAddress;
|
||||
|
||||
this.taxService
|
||||
.previewOrganizationInvoice(request)
|
||||
.then((invoice) => {
|
||||
this.estimatedTax = invoice.taxAmount;
|
||||
})
|
||||
.catch((error) => {
|
||||
const translatedMessage = this.i18nService.t(error.message);
|
||||
this.toastService.showToast({
|
||||
title: "",
|
||||
variant: "error",
|
||||
message:
|
||||
!translatedMessage || translatedMessage === "" ? error.message : translatedMessage,
|
||||
});
|
||||
});
|
||||
const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange(
|
||||
this.organizationId,
|
||||
getPlanFromLegacyEnum(this.selectedPlan.type),
|
||||
billingAddress,
|
||||
);
|
||||
|
||||
this.estimatedTax = taxAmounts.tax;
|
||||
}
|
||||
|
||||
protected canUpdatePaymentInformation(): boolean {
|
||||
return (
|
||||
this.upgradeRequiresPaymentMethod ||
|
||||
this.showPayment ||
|
||||
this.isPaymentSourceEmpty() ||
|
||||
!this.paymentMethod ||
|
||||
this.isSubscriptionCanceled
|
||||
);
|
||||
}
|
||||
@@ -1146,4 +1051,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
return this.i18nService.t("upgrade");
|
||||
}
|
||||
}
|
||||
|
||||
get supportsTaxId() {
|
||||
return this.formGroup.value.productTier !== ProductTierType.Families;
|
||||
}
|
||||
|
||||
getCardBrandIcon = () => getCardBrandIcon(this.paymentMethod);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { WebPlatformUtilsService } from "../../core/web-platform-utils.service";
|
||||
import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component";
|
||||
import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component";
|
||||
import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component";
|
||||
import { OrganizationPaymentMethodComponent } from "./payment-method/organization-payment-method.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@@ -26,17 +25,6 @@ const routes: Routes = [
|
||||
: OrganizationSubscriptionCloudComponent,
|
||||
data: { titleId: "subscription" },
|
||||
},
|
||||
{
|
||||
path: "payment-method",
|
||||
component: OrganizationPaymentMethodComponent,
|
||||
canActivate: [
|
||||
organizationPermissionsGuard((org) => org.canEditPaymentMethods),
|
||||
organizationIsUnmanaged,
|
||||
],
|
||||
data: {
|
||||
titleId: "paymentMethod",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "payment-details",
|
||||
component: OrganizationPaymentDetailsComponent,
|
||||
|
||||
@@ -17,7 +17,6 @@ import { OrganizationBillingRoutingModule } from "./organization-billing-routing
|
||||
import { OrganizationPlansComponent } from "./organization-plans.component";
|
||||
import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component";
|
||||
import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component";
|
||||
import { OrganizationPaymentMethodComponent } from "./payment-method/organization-payment-method.component";
|
||||
import { SecretsManagerAdjustSubscriptionComponent } from "./sm-adjust-subscription.component";
|
||||
import { SecretsManagerSubscribeStandaloneComponent } from "./sm-subscribe-standalone.component";
|
||||
import { SubscriptionHiddenComponent } from "./subscription-hidden.component";
|
||||
@@ -45,7 +44,6 @@ import { SubscriptionStatusComponent } from "./subscription-status.component";
|
||||
SecretsManagerSubscribeStandaloneComponent,
|
||||
SubscriptionHiddenComponent,
|
||||
SubscriptionStatusComponent,
|
||||
OrganizationPaymentMethodComponent,
|
||||
],
|
||||
})
|
||||
export class OrganizationBillingModule {}
|
||||
|
||||
@@ -404,17 +404,16 @@
|
||||
<p class="tw-text-muted tw-italic tw-mb-3 tw-block" bitTypography="body2">
|
||||
{{ paymentDesc }}
|
||||
</p>
|
||||
<app-payment
|
||||
*ngIf="createOrganization || upgradeRequiresPaymentMethod"
|
||||
[showAccountCredit]="false"
|
||||
>
|
||||
</app-payment>
|
||||
<app-manage-tax-information
|
||||
@if (createOrganization || upgradeRequiresPaymentMethod) {
|
||||
<app-enter-payment-method [group]="billingFormGroup.controls.paymentMethod">
|
||||
</app-enter-payment-method>
|
||||
}
|
||||
<app-enter-billing-address
|
||||
[group]="billingFormGroup.controls.billingAddress"
|
||||
[scenario]="{ type: 'checkout', supportsTaxId: showTaxIdField }"
|
||||
class="tw-my-4"
|
||||
[showTaxIdField]="showTaxIdField"
|
||||
[startWith]="taxInformation"
|
||||
(taxInformationChanged)="onTaxInformationChanged($event)"
|
||||
/>
|
||||
>
|
||||
</app-enter-billing-address>
|
||||
<div id="price" class="tw-my-4">
|
||||
<div class="tw-text-muted tw-text-base">
|
||||
{{ "passwordManagerPlanPrice" | i18n }}: {{ passwordManagerSubtotal | currency: "USD $" }}
|
||||
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
} from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { Subject, firstValueFrom, takeUntil } from "rxjs";
|
||||
import { firstValueFrom, merge, Subject, takeUntil } from "rxjs";
|
||||
import { debounceTime, map, switchMap } from "rxjs/operators";
|
||||
|
||||
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import {
|
||||
@@ -32,24 +31,12 @@ import { ProviderOrganizationCreateRequest } from "@bitwarden/common/admin-conso
|
||||
import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
|
||||
import {
|
||||
PaymentMethodType,
|
||||
PlanSponsorshipType,
|
||||
PlanType,
|
||||
ProductTierType,
|
||||
} from "@bitwarden/common/billing/enums";
|
||||
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
|
||||
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
|
||||
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
|
||||
import { PlanSponsorshipType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response";
|
||||
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -59,10 +46,20 @@ import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import {
|
||||
OrganizationSubscriptionPlan,
|
||||
SubscriberBillingClient,
|
||||
TaxClient,
|
||||
} from "@bitwarden/web-vault/app/billing/clients";
|
||||
import {
|
||||
EnterBillingAddressComponent,
|
||||
EnterPaymentMethodComponent,
|
||||
getBillingAddressFromForm,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||
import { tokenizablePaymentMethodToLegacyEnum } from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
|
||||
import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module";
|
||||
import { BillingSharedModule, secretsManagerSubscribeFormFactory } from "../shared";
|
||||
import { PaymentComponent } from "../shared/payment/payment.component";
|
||||
|
||||
interface OnSuccessArgs {
|
||||
organizationId: string;
|
||||
@@ -78,11 +75,16 @@ const Allowed2020PlansForLegacyProviders = [
|
||||
@Component({
|
||||
selector: "app-organization-plans",
|
||||
templateUrl: "organization-plans.component.html",
|
||||
imports: [BillingSharedModule, OrganizationCreateModule],
|
||||
imports: [
|
||||
BillingSharedModule,
|
||||
OrganizationCreateModule,
|
||||
EnterPaymentMethodComponent,
|
||||
EnterBillingAddressComponent,
|
||||
],
|
||||
providers: [SubscriberBillingClient, TaxClient],
|
||||
})
|
||||
export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
|
||||
@ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent;
|
||||
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
|
||||
|
||||
@Input() organizationId?: string;
|
||||
@Input() showFree = true;
|
||||
@@ -105,8 +107,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
|
||||
private _productTier = ProductTierType.Free;
|
||||
|
||||
protected taxInformation: TaxInformation;
|
||||
|
||||
@Input()
|
||||
get plan(): PlanType {
|
||||
return this._plan;
|
||||
@@ -135,10 +135,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
|
||||
secretsManagerSubscription = secretsManagerSubscribeFormFactory(this.formBuilder);
|
||||
|
||||
selfHostedForm = this.formBuilder.group({
|
||||
file: [null, [Validators.required]],
|
||||
});
|
||||
|
||||
formGroup = this.formBuilder.group({
|
||||
name: [""],
|
||||
billingEmail: ["", [Validators.email]],
|
||||
@@ -152,6 +148,11 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
secretsManager: this.secretsManagerSubscription,
|
||||
});
|
||||
|
||||
billingFormGroup = this.formBuilder.group({
|
||||
paymentMethod: EnterPaymentMethodComponent.getFormGroup(),
|
||||
billingAddress: EnterBillingAddressComponent.getFormGroup(),
|
||||
});
|
||||
|
||||
passwordManagerPlans: PlanResponse[];
|
||||
secretsManagerPlans: PlanResponse[];
|
||||
organization: Organization;
|
||||
@@ -179,10 +180,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private providerApiService: ProviderApiServiceAbstraction,
|
||||
private toastService: ToastService,
|
||||
private configService: ConfigService,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private taxService: TaxServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private subscriberBillingClient: SubscriberBillingClient,
|
||||
private taxClient: TaxClient,
|
||||
) {
|
||||
this.selfHosted = this.platformUtilsService.isSelfHost();
|
||||
}
|
||||
@@ -199,9 +199,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
this.billing = await this.organizationApiService.getBilling(this.organizationId);
|
||||
this.sub = await this.organizationApiService.getSubscription(this.organizationId);
|
||||
this.taxInformation = await this.organizationApiService.getTaxInfo(this.organizationId);
|
||||
} else if (!this.selfHosted) {
|
||||
this.taxInformation = await this.apiService.getTaxInfo();
|
||||
const billingAddress = await this.subscriberBillingClient.getBillingAddress({
|
||||
type: "organization",
|
||||
data: this.organization,
|
||||
});
|
||||
this.billingFormGroup.controls.billingAddress.patchValue({
|
||||
...billingAddress,
|
||||
taxId: billingAddress?.taxId?.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.selfHosted) {
|
||||
@@ -268,15 +273,17 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.formGroup.valueChanges.pipe(debounceTime(1000), takeUntil(this.destroy$)).subscribe(() => {
|
||||
this.refreshSalesTax();
|
||||
});
|
||||
|
||||
this.secretsManagerForm.valueChanges
|
||||
.pipe(debounceTime(1000), takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.refreshSalesTax();
|
||||
});
|
||||
merge(
|
||||
this.formGroup.valueChanges,
|
||||
this.billingFormGroup.valueChanges,
|
||||
this.secretsManagerForm.valueChanges,
|
||||
)
|
||||
.pipe(
|
||||
debounceTime(1000),
|
||||
switchMap(async () => await this.refreshSalesTax()),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
if (this.enableSecretsManagerByDefault && this.selectedSecretsManagerPlan) {
|
||||
this.secretsManagerSubscription.patchValue({
|
||||
@@ -587,34 +594,13 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
this.changedProduct();
|
||||
}
|
||||
|
||||
protected changedCountry(): void {
|
||||
this.paymentComponent.showBankAccount = this.taxInformation?.country === "US";
|
||||
if (
|
||||
!this.paymentComponent.showBankAccount &&
|
||||
this.paymentComponent.selected === PaymentMethodType.BankAccount
|
||||
) {
|
||||
this.paymentComponent.select(PaymentMethodType.Card);
|
||||
}
|
||||
}
|
||||
|
||||
protected onTaxInformationChanged(event: TaxInformation): void {
|
||||
this.taxInformation = event;
|
||||
this.changedCountry();
|
||||
this.refreshSalesTax();
|
||||
}
|
||||
|
||||
protected cancel(): void {
|
||||
this.onCanceled.emit();
|
||||
}
|
||||
|
||||
protected setSelectedFile(event: Event): void {
|
||||
const fileInputEl = <HTMLInputElement>event.target;
|
||||
this.selectedFile = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null;
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
if (this.taxComponent && !this.taxComponent.validate()) {
|
||||
this.taxComponent.markAllAsTouched();
|
||||
this.formGroup.markAllAsTouched();
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -688,46 +674,54 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private refreshSalesTax(): void {
|
||||
if (!this.taxComponent.validate()) {
|
||||
private async refreshSalesTax(): Promise<void> {
|
||||
if (this.billingFormGroup.controls.billingAddress.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request: PreviewOrganizationInvoiceRequest = {
|
||||
organizationId: this.organizationId,
|
||||
passwordManager: {
|
||||
additionalStorage: this.formGroup.controls.additionalStorage.value,
|
||||
plan: this.formGroup.controls.plan.value,
|
||||
sponsoredPlan: this.planSponsorshipType,
|
||||
seats: this.formGroup.controls.additionalSeats.value,
|
||||
},
|
||||
taxInformation: {
|
||||
postalCode: this.taxInformation.postalCode,
|
||||
country: this.taxInformation.country,
|
||||
taxId: this.taxInformation.taxId,
|
||||
},
|
||||
const getPlanFromLegacyEnum = (): OrganizationSubscriptionPlan => {
|
||||
switch (this.formGroup.value.plan) {
|
||||
case PlanType.FamiliesAnnually:
|
||||
return { tier: "families", cadence: "annually" };
|
||||
case PlanType.TeamsMonthly:
|
||||
return { tier: "teams", cadence: "monthly" };
|
||||
case PlanType.TeamsAnnually:
|
||||
return { tier: "teams", cadence: "annually" };
|
||||
case PlanType.EnterpriseMonthly:
|
||||
return { tier: "enterprise", cadence: "monthly" };
|
||||
case PlanType.EnterpriseAnnually:
|
||||
return { tier: "enterprise", cadence: "annually" };
|
||||
}
|
||||
};
|
||||
|
||||
if (this.secretsManagerForm.controls.enabled.value === true) {
|
||||
request.secretsManager = {
|
||||
seats: this.secretsManagerForm.controls.userSeats.value,
|
||||
additionalMachineAccounts: this.secretsManagerForm.controls.additionalServiceAccounts.value,
|
||||
};
|
||||
}
|
||||
const billingAddress = getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress);
|
||||
|
||||
this.taxService
|
||||
.previewOrganizationInvoice(request)
|
||||
.then((invoice) => {
|
||||
this.estimatedTax = invoice.taxAmount;
|
||||
this.total = invoice.totalAmount;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastService.showToast({
|
||||
title: "",
|
||||
variant: "error",
|
||||
message: this.i18nService.t(error.message),
|
||||
});
|
||||
});
|
||||
const passwordManagerSeats =
|
||||
this.formGroup.value.productTier === ProductTierType.Families
|
||||
? 1
|
||||
: this.formGroup.value.additionalSeats;
|
||||
|
||||
const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
|
||||
{
|
||||
...getPlanFromLegacyEnum(),
|
||||
passwordManager: {
|
||||
seats: passwordManagerSeats,
|
||||
additionalStorage: this.formGroup.value.additionalStorage,
|
||||
sponsored: false,
|
||||
},
|
||||
secretsManager: this.formGroup.value.secretsManager.enabled
|
||||
? {
|
||||
seats: this.secretsManagerForm.value.userSeats,
|
||||
additionalServiceAccounts: this.secretsManagerForm.value.additionalServiceAccounts,
|
||||
standalone: false,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
billingAddress,
|
||||
);
|
||||
|
||||
this.estimatedTax = taxAmounts.tax;
|
||||
this.total = taxAmounts.total;
|
||||
}
|
||||
|
||||
private async updateOrganization() {
|
||||
@@ -738,21 +732,24 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
this.selectedPlan.PasswordManager.hasPremiumAccessOption &&
|
||||
this.formGroup.controls.premiumAccessAddon.value;
|
||||
request.planType = this.selectedPlan.type;
|
||||
request.billingAddressCountry = this.taxInformation?.country;
|
||||
request.billingAddressPostalCode = this.taxInformation?.postalCode;
|
||||
request.billingAddressCountry = this.billingFormGroup.value.billingAddress.country;
|
||||
request.billingAddressPostalCode = this.billingFormGroup.value.billingAddress.postalCode;
|
||||
|
||||
// Secrets Manager
|
||||
this.buildSecretsManagerRequest(request);
|
||||
|
||||
if (this.upgradeRequiresPaymentMethod) {
|
||||
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
|
||||
updatePaymentMethodRequest.paymentSource = await this.paymentComponent.tokenize();
|
||||
updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From(
|
||||
this.taxInformation,
|
||||
);
|
||||
await this.billingApiService.updateOrganizationPaymentMethod(
|
||||
this.organizationId,
|
||||
updatePaymentMethodRequest,
|
||||
if (this.billingFormGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
||||
await this.subscriberBillingClient.updatePaymentMethod(
|
||||
{ type: "organization", data: this.organization },
|
||||
paymentMethod,
|
||||
{
|
||||
country: this.billingFormGroup.value.billingAddress.country,
|
||||
postalCode: this.billingFormGroup.value.billingAddress.postalCode,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -791,23 +788,31 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
if (this.selectedPlan.type === PlanType.Free) {
|
||||
request.planType = PlanType.Free;
|
||||
} else {
|
||||
const { type, token } = await this.paymentComponent.tokenize();
|
||||
if (this.billingFormGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
request.paymentToken = token;
|
||||
request.paymentMethodType = type;
|
||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
||||
|
||||
const billingAddress = getBillingAddressFromForm(
|
||||
this.billingFormGroup.controls.billingAddress,
|
||||
);
|
||||
|
||||
request.paymentToken = paymentMethod.token;
|
||||
request.paymentMethodType = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type);
|
||||
request.additionalSeats = this.formGroup.controls.additionalSeats.value;
|
||||
request.additionalStorageGb = this.formGroup.controls.additionalStorage.value;
|
||||
request.premiumAccessAddon =
|
||||
this.selectedPlan.PasswordManager.hasPremiumAccessOption &&
|
||||
this.formGroup.controls.premiumAccessAddon.value;
|
||||
request.planType = this.selectedPlan.type;
|
||||
request.billingAddressPostalCode = this.taxInformation?.postalCode;
|
||||
request.billingAddressCountry = this.taxInformation?.country;
|
||||
request.taxIdNumber = this.taxInformation?.taxId;
|
||||
request.billingAddressLine1 = this.taxInformation?.line1;
|
||||
request.billingAddressLine2 = this.taxInformation?.line2;
|
||||
request.billingAddressCity = this.taxInformation?.city;
|
||||
request.billingAddressState = this.taxInformation?.state;
|
||||
request.billingAddressPostalCode = billingAddress.postalCode;
|
||||
request.billingAddressCountry = billingAddress.country;
|
||||
request.taxIdNumber = billingAddress.taxId?.value;
|
||||
request.billingAddressLine1 = billingAddress.line1;
|
||||
request.billingAddressLine2 = billingAddress.line2;
|
||||
request.billingAddressCity = billingAddress.city;
|
||||
request.billingAddressState = billingAddress.state;
|
||||
}
|
||||
|
||||
// Secrets Manager
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
combineLatest,
|
||||
EMPTY,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
lastValueFrom,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
of,
|
||||
@@ -22,15 +18,13 @@ import {
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.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 { 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 { getById } from "@bitwarden/common/platform/misc";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
|
||||
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
@@ -54,13 +48,6 @@ import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/type
|
||||
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 = {
|
||||
organization: BitwardenSubscriber;
|
||||
paymentMethod: MaskedPaymentMethod | null;
|
||||
@@ -93,24 +80,12 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy {
|
||||
switchMap((userId) =>
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)),
|
||||
.pipe(getById(this.activatedRoute.snapshot.params.organizationId)),
|
||||
),
|
||||
filter((organization): organization is Organization => !!organization),
|
||||
);
|
||||
|
||||
private load$: Observable<View> = this.organization$.pipe(
|
||||
switchMap((organization) =>
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
|
||||
.pipe(
|
||||
map((managePaymentDetailsOutsideCheckout) => {
|
||||
if (!managePaymentDetailsOutsideCheckout) {
|
||||
throw new RedirectError(["../payment-method"], this.activatedRoute);
|
||||
}
|
||||
return organization;
|
||||
}),
|
||||
),
|
||||
),
|
||||
mapOrganizationToSubscriber,
|
||||
switchMap(async (organization) => {
|
||||
const getTaxIdWarning = firstValueFrom(
|
||||
@@ -132,14 +107,6 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy {
|
||||
taxIdWarning,
|
||||
};
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof RedirectError) {
|
||||
return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe(
|
||||
switchMap(() => EMPTY),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}),
|
||||
);
|
||||
|
||||
view$: Observable<View> = merge(
|
||||
@@ -159,7 +126,6 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy {
|
||||
private messageListener: MessageListener,
|
||||
private organizationService: OrganizationService,
|
||||
private organizationWarningsService: OrganizationWarningsService,
|
||||
private router: Router,
|
||||
private subscriberBillingClient: SubscriberBillingClient,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<app-header></app-header>
|
||||
<bit-container>
|
||||
<ng-container *ngIf="loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!loading">
|
||||
<!-- Account Credit -->
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">
|
||||
{{ accountCreditHeaderText }}
|
||||
</h2>
|
||||
<p class="tw-text-lg tw-font-bold">{{ Math.abs(accountCredit) | currency: "$" }}</p>
|
||||
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="addAccountCredit">
|
||||
{{ "addCredit" | i18n }}
|
||||
</button>
|
||||
</bit-section>
|
||||
<!-- Payment Method -->
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
|
||||
<p *ngIf="!paymentSource" bitTypography="body1">{{ "noPaymentMethod" | i18n }}</p>
|
||||
<ng-container *ngIf="paymentSource">
|
||||
<app-verify-bank-account
|
||||
*ngIf="paymentSource.needsVerification"
|
||||
[onSubmit]="verifyBankAccount"
|
||||
(submitted)="load()"
|
||||
>
|
||||
</app-verify-bank-account>
|
||||
<p>
|
||||
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
|
||||
{{ paymentSource.description }}
|
||||
<span *ngIf="paymentSource.needsVerification">- {{ "unverified" | i18n }}</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="updatePaymentMethod">
|
||||
{{ updatePaymentSourceButtonText }}
|
||||
</button>
|
||||
<p *ngIf="subscriptionIsUnpaid" bitTypography="body1">
|
||||
{{ "paymentChargedWithUnpaidSubscription" | i18n }}
|
||||
</p>
|
||||
</bit-section>
|
||||
</ng-container>
|
||||
</bit-container>
|
||||
@@ -1,288 +0,0 @@
|
||||
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 { combineLatest, firstValueFrom, from, lastValueFrom, map, switchMap } from "rxjs";
|
||||
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import {
|
||||
OrganizationService,
|
||||
getOrganizationById,
|
||||
} 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 { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
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";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { BillingNotificationService } from "../../services/billing-notification.service";
|
||||
import {
|
||||
AddCreditDialogResult,
|
||||
openAddCreditDialog,
|
||||
} from "../../shared/add-credit-dialog.component";
|
||||
import {
|
||||
AdjustPaymentDialogComponent,
|
||||
AdjustPaymentDialogResultType,
|
||||
} from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component";
|
||||
import {
|
||||
TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE,
|
||||
TrialPaymentDialogComponent,
|
||||
} from "../../shared/trial-payment-dialog/trial-payment-dialog.component";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./organization-payment-method.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
organizationId!: string;
|
||||
isUnpaid = false;
|
||||
accountCredit?: number;
|
||||
paymentSource?: PaymentSourceResponse;
|
||||
subscriptionStatus?: string;
|
||||
organization?: Organization;
|
||||
organizationSubscriptionResponse?: OrganizationSubscriptionResponse;
|
||||
|
||||
loading = true;
|
||||
|
||||
protected readonly Math = Math;
|
||||
launchPaymentModalAutomatically = false;
|
||||
|
||||
protected taxInformation?: TaxInformation;
|
||||
|
||||
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 organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
protected syncService: SyncService,
|
||||
private billingNotificationService: BillingNotificationService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
combineLatest([
|
||||
this.activatedRoute.params,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout),
|
||||
])
|
||||
.pipe(
|
||||
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();
|
||||
|
||||
const state = this.router.getCurrentNavigation()?.extras?.state;
|
||||
// In case the above state is undefined or null, we use redundantState
|
||||
const redundantState: any = location.getState();
|
||||
const queryParam = this.activatedRoute.snapshot.queryParamMap.get(
|
||||
"launchPaymentModalAutomatically",
|
||||
);
|
||||
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 = queryParam === "true";
|
||||
}
|
||||
}
|
||||
ngOnDestroy(): void {
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
}
|
||||
|
||||
protected addAccountCredit = async (): Promise<void> => {
|
||||
if (this.subscriptionStatus === "trialing") {
|
||||
const hasValidBillingAddress = await this.checkBillingAddressForTrialingOrg();
|
||||
if (!hasValidBillingAddress) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const dialogRef = openAddCreditDialog(this.dialogService, {
|
||||
data: {
|
||||
organizationId: this.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
if (result === AddCreditDialogResult.Added) {
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
|
||||
protected load = async (): Promise<void> => {
|
||||
this.loading = true;
|
||||
try {
|
||||
const { accountCredit, paymentSource, subscriptionStatus, taxInformation } =
|
||||
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
|
||||
this.accountCredit = accountCredit;
|
||||
this.paymentSource = paymentSource;
|
||||
this.subscriptionStatus = subscriptionStatus;
|
||||
this.taxInformation = taxInformation;
|
||||
this.isUnpaid = this.subscriptionStatus === "unpaid";
|
||||
|
||||
if (this.organizationId) {
|
||||
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
|
||||
this.organizationId,
|
||||
);
|
||||
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("User ID is not found");
|
||||
}
|
||||
|
||||
const organizationPromise = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(this.organizationId)),
|
||||
);
|
||||
|
||||
[this.organizationSubscriptionResponse, this.organization] = await Promise.all([
|
||||
organizationSubscriptionPromise,
|
||||
organizationPromise,
|
||||
]);
|
||||
|
||||
if (!this.organization) {
|
||||
throw new Error("Organization is not found");
|
||||
}
|
||||
if (!this.paymentSource) {
|
||||
throw new Error("Payment source is not found");
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
} catch (error) {
|
||||
this.billingNotificationService.handleError(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
protected updatePaymentMethod = async (): Promise<void> => {
|
||||
const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
initialPaymentMethod: this.paymentSource?.type,
|
||||
organizationId: this.organizationId,
|
||||
productTier: this.organization?.productTierType,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
if (result === AdjustPaymentDialogResultType.Submitted) {
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
|
||||
changePayment = async () => {
|
||||
const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
organizationId: this.organizationId,
|
||||
subscription: this.organizationSubscriptionResponse!,
|
||||
productTierType: this.organization!.productTierType,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.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 verifyBankAccount = async (request: VerifyBankAccountRequest): Promise<void> => {
|
||||
await this.billingApiService.verifyOrganizationBankAccount(this.organizationId, request);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("verifiedBankAccount"),
|
||||
});
|
||||
};
|
||||
|
||||
protected get accountCreditHeaderText(): string {
|
||||
const hasAccountCredit = this.accountCredit && this.accountCredit > 0;
|
||||
const key = hasAccountCredit ? "accountCredit" : "accountBalance";
|
||||
return this.i18nService.t(key);
|
||||
}
|
||||
|
||||
protected get paymentSourceClasses() {
|
||||
if (this.paymentSource == null) {
|
||||
return [];
|
||||
}
|
||||
switch (this.paymentSource.type) {
|
||||
case PaymentMethodType.Card:
|
||||
return ["bwi-credit-card"];
|
||||
case PaymentMethodType.BankAccount:
|
||||
case PaymentMethodType.Check:
|
||||
return ["bwi-billing"];
|
||||
case PaymentMethodType.PayPal:
|
||||
return ["bwi-paypal text-primary"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
protected get subscriptionIsUnpaid(): boolean {
|
||||
return this.subscriptionStatus === "unpaid";
|
||||
}
|
||||
|
||||
protected get updatePaymentSourceButtonText(): string {
|
||||
const key = this.paymentSource == null ? "addPaymentMethod" : "changePaymentMethod";
|
||||
return this.i18nService.t(key);
|
||||
}
|
||||
|
||||
private async checkBillingAddressForTrialingOrg(): Promise<boolean> {
|
||||
const hasBillingAddress = this.taxInformation != null;
|
||||
if (!hasBillingAddress) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("billingAddressRequiredToAddCredit"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-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 { DialogRef, DialogService } from "@bitwarden/components";
|
||||
import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
@@ -35,7 +33,6 @@ import { TaxIdWarningTypes } from "@bitwarden/web-vault/app/billing/warnings/typ
|
||||
|
||||
describe("OrganizationWarningsService", () => {
|
||||
let service: OrganizationWarningsService;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
|
||||
@@ -57,7 +54,6 @@ describe("OrganizationWarningsService", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
configService = mock<ConfigService>();
|
||||
dialogService = mock<DialogService>();
|
||||
i18nService = mock<I18nService>();
|
||||
organizationApiService = mock<OrganizationApiServiceAbstraction>();
|
||||
@@ -94,7 +90,6 @@ describe("OrganizationWarningsService", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
OrganizationWarningsService,
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
{ provide: DialogService, useValue: dialogService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{ provide: OrganizationApiServiceAbstraction, useValue: organizationApiService },
|
||||
@@ -466,7 +461,6 @@ describe("OrganizationWarningsService", () => {
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
router.navigate.mockResolvedValue(true);
|
||||
|
||||
service.showInactiveSubscriptionDialog$(organization).subscribe({
|
||||
@@ -478,11 +472,8 @@ describe("OrganizationWarningsService", () => {
|
||||
acceptButtonText: "Continue",
|
||||
cancelButtonText: "Close",
|
||||
});
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
["organizations", "org-id-123", "billing", "payment-method"],
|
||||
["organizations", "org-id-123", "billing", "payment-details"],
|
||||
{ state: { launchPaymentModalAutomatically: true } },
|
||||
);
|
||||
done();
|
||||
@@ -497,7 +488,6 @@ describe("OrganizationWarningsService", () => {
|
||||
} as OrganizationWarningsResponse);
|
||||
|
||||
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
router.navigate.mockResolvedValue(true);
|
||||
|
||||
service.showInactiveSubscriptionDialog$(organization).subscribe({
|
||||
@@ -522,7 +512,6 @@ describe("OrganizationWarningsService", () => {
|
||||
service.showInactiveSubscriptionDialog$(organization).subscribe({
|
||||
complete: () => {
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalled();
|
||||
expect(configService.getFeatureFlag).not.toHaveBeenCalled();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
|
||||
@@ -16,8 +16,6 @@ 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 { 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";
|
||||
@@ -53,7 +51,6 @@ export class OrganizationWarningsService {
|
||||
taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable();
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
@@ -196,14 +193,8 @@ 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", route],
|
||||
["organizations", `${organization.id}`, "billing", "payment-details"],
|
||||
{
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BitwardenSubscriber } from "../../types";
|
||||
import { MaskedPaymentMethod } from "../types";
|
||||
import { getCardBrandIcon, MaskedPaymentMethod } from "../types";
|
||||
|
||||
import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component";
|
||||
|
||||
@@ -40,9 +40,9 @@ import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dial
|
||||
}
|
||||
@case ("card") {
|
||||
<p class="tw-flex tw-items-center tw-gap-2">
|
||||
@let brandIcon = getBrandIconForCard();
|
||||
@if (brandIcon !== null) {
|
||||
<i class="bwi bwi-fw credit-card-icon {{ brandIcon }}"></i>
|
||||
@let cardBrandIcon = getCardBrandIcon();
|
||||
@if (cardBrandIcon !== null) {
|
||||
<i class="bwi bwi-fw credit-card-icon {{ cardBrandIcon }}"></i>
|
||||
} @else {
|
||||
<i class="bwi bwi-fw bwi-credit-card"></i>
|
||||
}
|
||||
@@ -74,16 +74,6 @@ export class DisplayPaymentMethodComponent {
|
||||
@Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null;
|
||||
@Output() updated = new EventEmitter<MaskedPaymentMethod>();
|
||||
|
||||
protected availableCardIcons: Record<string, string> = {
|
||||
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<void> => {
|
||||
@@ -100,13 +90,5 @@ export class DisplayPaymentMethodComponent {
|
||||
}
|
||||
};
|
||||
|
||||
protected getBrandIconForCard = (): string | null => {
|
||||
if (this.paymentMethod?.type !== "card") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.paymentMethod.brand in this.availableCardIcons
|
||||
? this.availableCardIcons[this.paymentMethod.brand]
|
||||
: null;
|
||||
};
|
||||
protected getCardBrandIcon = () => getCardBrandIcon(this.paymentMethod);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
import {
|
||||
BillingAddress,
|
||||
getTaxIdTypeForCountry,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types";
|
||||
import {
|
||||
TaxIdWarningType,
|
||||
@@ -22,7 +19,10 @@ import {
|
||||
} from "@bitwarden/web-vault/app/billing/warnings/types";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { EnterBillingAddressComponent } from "./enter-billing-address.component";
|
||||
import {
|
||||
EnterBillingAddressComponent,
|
||||
getBillingAddressFromForm,
|
||||
} from "./enter-billing-address.component";
|
||||
|
||||
type DialogParams = {
|
||||
subscriber: BitwardenSubscriber;
|
||||
@@ -104,13 +104,7 @@ export class EditBillingAddressDialogComponent {
|
||||
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 billingAddress = getBillingAddressFromForm(this.formGroup);
|
||||
|
||||
const result = await this.billingClient.updateBillingAddress(
|
||||
this.dialogParams.subscriber,
|
||||
|
||||
@@ -24,6 +24,17 @@ export interface BillingAddressControls {
|
||||
|
||||
export type BillingAddressFormGroup = FormGroup<ControlsOf<BillingAddressControls>>;
|
||||
|
||||
export const getBillingAddressFromForm = (formGroup: BillingAddressFormGroup): BillingAddress =>
|
||||
getBillingAddressFromControls(formGroup.getRawValue());
|
||||
|
||||
export const getBillingAddressFromControls = (controls: BillingAddressControls) => {
|
||||
const { taxId, ...addressFields } = controls;
|
||||
const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country) : null;
|
||||
return taxIdType
|
||||
? { ...addressFields, taxId: { code: taxIdType.code, value: taxId! } }
|
||||
: { ...addressFields, taxId: null };
|
||||
};
|
||||
|
||||
type Scenario =
|
||||
| {
|
||||
type: "checkout";
|
||||
@@ -67,54 +78,56 @@ type Scenario =
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "address1" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.line1"
|
||||
autocomplete="address-line1"
|
||||
data-testid="address-line1"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "address2" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.line2"
|
||||
autocomplete="address-line2"
|
||||
data-testid="address-line2"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "cityTown" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.city"
|
||||
autocomplete="address-level2"
|
||||
data-testid="city"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.state"
|
||||
autocomplete="address-level1"
|
||||
data-testid="state"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
@if (scenario.type === "update") {
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "address1" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.line1"
|
||||
autocomplete="address-line1"
|
||||
data-testid="address-line1"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "address2" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.line2"
|
||||
autocomplete="address-line2"
|
||||
data-testid="address-line2"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "cityTown" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.city"
|
||||
autocomplete="address-level2"
|
||||
data-testid="city"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.state"
|
||||
autocomplete="address-level1"
|
||||
data-testid="state"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
}
|
||||
@if (supportsTaxId$ | async) {
|
||||
<div class="tw-col-span-12">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
@@ -175,7 +188,7 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy {
|
||||
this.supportsTaxId$ = this.group.controls.country.valueChanges.pipe(
|
||||
startWith(this.group.value.country ?? this.selectableCountries[0].value),
|
||||
map((country) => {
|
||||
if (!this.scenario.supportsTaxId) {
|
||||
if (!this.scenario.supportsTaxId || country === "US") {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ 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,
|
||||
@@ -16,6 +15,8 @@ import {
|
||||
TokenizedPaymentMethod,
|
||||
} from "../types";
|
||||
|
||||
import { PaymentLabelComponent } from "./payment-label.component";
|
||||
|
||||
type PaymentMethodOption = TokenizablePaymentMethod | "accountCredit";
|
||||
|
||||
type PaymentMethodFormGroup = FormGroup<{
|
||||
@@ -102,7 +103,7 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
<button
|
||||
[bitPopoverTriggerFor]="cardSecurityCodePopover"
|
||||
type="button"
|
||||
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-p-0"
|
||||
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-pr-1"
|
||||
[position]="'above-end'"
|
||||
>
|
||||
<i class="bwi bwi-question-circle tw-text-lg" aria-hidden="true"></i>
|
||||
@@ -310,7 +311,7 @@ export class EnterPaymentMethodComponent implements OnInit {
|
||||
select = (paymentMethod: PaymentMethodOption) =>
|
||||
this.group.controls.type.patchValue(paymentMethod);
|
||||
|
||||
tokenize = async (): Promise<TokenizedPaymentMethod> => {
|
||||
tokenize = async (): Promise<TokenizedPaymentMethod | null> => {
|
||||
const exchange = async (paymentMethod: TokenizablePaymentMethod) => {
|
||||
switch (paymentMethod) {
|
||||
case "bankAccount": {
|
||||
@@ -351,13 +352,37 @@ export class EnterPaymentMethodComponent implements OnInit {
|
||||
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;
|
||||
if (error) {
|
||||
this.logService.error(error);
|
||||
switch (this.selected) {
|
||||
case "card": {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
"message" in error &&
|
||||
typeof error.message === "string"
|
||||
) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "payPal": {
|
||||
if (typeof error === "string" && error === "No payment method is available.") {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("clickPayWithPayPal"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 "./payment-label.component";
|
||||
export * from "./require-payment-method-dialog.component";
|
||||
export * from "./submit-payment-method-dialog.component";
|
||||
export * from "./verify-bank-account.component";
|
||||
|
||||
@@ -13,7 +13,21 @@ import { SharedModule } from "../../../shared";
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-payment-label",
|
||||
templateUrl: "./payment-label.component.html",
|
||||
template: `
|
||||
<ng-template #defaultContent>
|
||||
<ng-content></ng-content>
|
||||
</ng-template>
|
||||
|
||||
<div class="tw-relative tw-mt-2">
|
||||
<bit-label
|
||||
[attr.for]="for"
|
||||
class="tw-absolute tw-bg-background tw-px-1 tw-text-sm tw-text-muted -tw-top-2.5 tw-left-3 tw-mb-0 tw-max-w-full tw-pointer-events-auto"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
|
||||
<span class="tw-text-xs tw-font-normal">({{ "required" | i18n }})</span>
|
||||
</bit-label>
|
||||
</div>
|
||||
`,
|
||||
imports: [FormFieldModule, SharedModule],
|
||||
})
|
||||
export class PaymentLabelComponent {
|
||||
@@ -37,6 +37,10 @@ export abstract class SubmitPaymentMethodDialogComponent {
|
||||
}
|
||||
|
||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
||||
if (!paymentMethod) {
|
||||
return;
|
||||
}
|
||||
|
||||
const billingAddress =
|
||||
this.formGroup.value.type !== "payPal"
|
||||
? this.formGroup.controls.billingAddress.getRawValue()
|
||||
|
||||
@@ -21,6 +21,24 @@ export const StripeCardBrands = {
|
||||
|
||||
export type StripeCardBrand = (typeof StripeCardBrands)[keyof typeof StripeCardBrands];
|
||||
|
||||
export const cardBrandIcons: Record<string, string> = {
|
||||
amex: "card-amex",
|
||||
diners: "card-diners-club",
|
||||
discover: "card-discover",
|
||||
jcb: "card-jcb",
|
||||
mastercard: "card-mastercard",
|
||||
unionpay: "card-unionpay",
|
||||
visa: "card-visa",
|
||||
};
|
||||
|
||||
export const getCardBrandIcon = (paymentMethod: MaskedPaymentMethod | null): string | null => {
|
||||
if (paymentMethod?.type !== "card") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return paymentMethod.brand in cardBrandIcons ? cardBrandIcons[paymentMethod.brand] : null;
|
||||
};
|
||||
|
||||
type MaskedBankAccount = {
|
||||
type: BankAccountPaymentMethod;
|
||||
bankName: string;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
|
||||
export const TokenizablePaymentMethods = {
|
||||
bankAccount: "bankAccount",
|
||||
card: "card",
|
||||
@@ -16,6 +18,34 @@ export const isTokenizablePaymentMethod = (value: string): value is TokenizableP
|
||||
return valid.includes(value);
|
||||
};
|
||||
|
||||
export const tokenizablePaymentMethodFromLegacyEnum = (
|
||||
legacyEnum: PaymentMethodType,
|
||||
): TokenizablePaymentMethod | null => {
|
||||
switch (legacyEnum) {
|
||||
case PaymentMethodType.BankAccount:
|
||||
return "bankAccount";
|
||||
case PaymentMethodType.Card:
|
||||
return "card";
|
||||
case PaymentMethodType.PayPal:
|
||||
return "payPal";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const tokenizablePaymentMethodToLegacyEnum = (
|
||||
paymentMethod: TokenizablePaymentMethod,
|
||||
): PaymentMethodType => {
|
||||
switch (paymentMethod) {
|
||||
case "bankAccount":
|
||||
return PaymentMethodType.BankAccount;
|
||||
case "card":
|
||||
return PaymentMethodType.Card;
|
||||
case "payPal":
|
||||
return PaymentMethodType.PayPal;
|
||||
}
|
||||
};
|
||||
|
||||
export type TokenizedPaymentMethod = {
|
||||
type: TokenizablePaymentMethod;
|
||||
token: string;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
|
||||
import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { TaxInformation } from "@bitwarden/common/billing/models/domain/tax-information";
|
||||
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
|
||||
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
|
||||
@@ -14,17 +11,13 @@ import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.co
|
||||
providedIn: "root",
|
||||
})
|
||||
export class PricingSummaryService {
|
||||
private estimatedTax: number = 0;
|
||||
|
||||
constructor(private taxService: TaxServiceAbstraction) {}
|
||||
|
||||
async getPricingSummaryData(
|
||||
plan: PlanResponse,
|
||||
sub: OrganizationSubscriptionResponse,
|
||||
organization: Organization,
|
||||
selectedInterval: PlanInterval,
|
||||
taxInformation: TaxInformation,
|
||||
isSecretsManagerTrial: boolean,
|
||||
estimatedTax: number,
|
||||
): Promise<PricingSummaryData> {
|
||||
// Calculation helpers
|
||||
const passwordManagerSeatTotal =
|
||||
@@ -72,14 +65,9 @@ export class PricingSummaryService {
|
||||
const acceptingSponsorship = false;
|
||||
const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0;
|
||||
|
||||
this.estimatedTax = await this.getEstimatedTax(organization, plan, sub, taxInformation);
|
||||
|
||||
const total = organization?.useSecretsManager
|
||||
? passwordManagerSubtotal +
|
||||
additionalStorageTotal +
|
||||
secretsManagerSubtotal +
|
||||
this.estimatedTax
|
||||
: passwordManagerSubtotal + additionalStorageTotal + this.estimatedTax;
|
||||
? passwordManagerSubtotal + additionalStorageTotal + secretsManagerSubtotal + estimatedTax
|
||||
: passwordManagerSubtotal + additionalStorageTotal + estimatedTax;
|
||||
|
||||
return {
|
||||
selectedPlanInterval: selectedInterval === PlanInterval.Annually ? "year" : "month",
|
||||
@@ -104,45 +92,10 @@ export class PricingSummaryService {
|
||||
additionalServiceAccount,
|
||||
storageGb,
|
||||
isSecretsManagerTrial,
|
||||
estimatedTax: this.estimatedTax,
|
||||
estimatedTax,
|
||||
};
|
||||
}
|
||||
|
||||
async getEstimatedTax(
|
||||
organization: Organization,
|
||||
currentPlan: PlanResponse,
|
||||
sub: OrganizationSubscriptionResponse,
|
||||
taxInformation: TaxInformation,
|
||||
) {
|
||||
if (!taxInformation || !taxInformation.country || !taxInformation.postalCode) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const request: PreviewOrganizationInvoiceRequest = {
|
||||
organizationId: organization.id,
|
||||
passwordManager: {
|
||||
additionalStorage: 0,
|
||||
plan: currentPlan?.type,
|
||||
seats: sub.seats,
|
||||
},
|
||||
taxInformation: {
|
||||
postalCode: taxInformation.postalCode,
|
||||
country: taxInformation.country,
|
||||
taxId: taxInformation.taxId,
|
||||
},
|
||||
};
|
||||
|
||||
if (organization.useSecretsManager) {
|
||||
request.secretsManager = {
|
||||
seats: sub.smSeats ?? 0,
|
||||
additionalMachineAccounts:
|
||||
(sub.smServiceAccounts ?? 0) - (sub.plan.SecretsManager?.baseServiceAccount ?? 0),
|
||||
};
|
||||
}
|
||||
const invoiceResponse = await this.taxService.previewOrganizationInvoice(request);
|
||||
return invoiceResponse.taxAmount;
|
||||
}
|
||||
|
||||
getAdditionalServiceAccount(plan: PlanResponse, sub: OrganizationSubscriptionResponse): number {
|
||||
if (!plan || !plan.SecretsManager) {
|
||||
return 0;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog dialogSize="default" [title]="'addCredit' | i18n">
|
||||
<ng-container bitDialogContent>
|
||||
<p bitTypography="body1">{{ "creditDelayed" | i18n }}</p>
|
||||
<div class="tw-grid tw-grid-cols-2">
|
||||
<bit-radio-group formControlName="method">
|
||||
<bit-radio-button id="credit-method-paypal" [value]="paymentMethodType.PayPal">
|
||||
<bit-label> <i class="bwi bwi-paypal"></i>PayPal</bit-label>
|
||||
</bit-radio-button>
|
||||
<bit-radio-button id="credit-method-bitcoin" [value]="paymentMethodType.BitPay">
|
||||
<bit-label> <i class="bwi bwi-bitcoin"></i>Bitcoin</bit-label>
|
||||
</bit-radio-button>
|
||||
</bit-radio-group>
|
||||
</div>
|
||||
<div class="tw-grid tw-grid-cols-2">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "amount" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="creditAmount"
|
||||
(blur)="formatAmount()"
|
||||
required
|
||||
/>
|
||||
<span bitPrefix>$USD</span>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary">
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
[bitDialogClose]="DialogResult.Cancelled"
|
||||
>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
<form #ppButtonForm action="{{ ppButtonFormAction }}" method="post" target="_top">
|
||||
<input type="hidden" name="cmd" value="_xclick" />
|
||||
<input type="hidden" name="business" value="{{ ppButtonBusinessId }}" />
|
||||
<input type="hidden" name="button_subtype" value="services" />
|
||||
<input type="hidden" name="no_note" value="1" />
|
||||
<input type="hidden" name="no_shipping" value="1" />
|
||||
<input type="hidden" name="rm" value="1" />
|
||||
<input type="hidden" name="return" value="{{ returnUrl }}" />
|
||||
<input type="hidden" name="cancel_return" value="{{ returnUrl }}" />
|
||||
<input type="hidden" name="currency_code" value="USD" />
|
||||
<input type="hidden" name="image_url" value="https://bitwarden.com/images/paypal-banner.png" />
|
||||
<input type="hidden" name="bn" value="PP-BuyNowBF:btn_buynow_LG.gif:NonHosted" />
|
||||
<input type="hidden" name="amount" value="{{ formGroup.get('creditAmount').value }}" />
|
||||
<input type="hidden" name="custom" value="{{ ppButtonCustomField }}" />
|
||||
<input type="hidden" name="item_name" value="Bitwarden Account Credit" />
|
||||
<input type="hidden" name="item_number" value="{{ subject }}" />
|
||||
</form>
|
||||
@@ -1,191 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
|
||||
|
||||
export interface AddCreditDialogData {
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum AddCreditDialogResult {
|
||||
Added = "added",
|
||||
Cancelled = "cancelled",
|
||||
}
|
||||
|
||||
export type PayPalConfig = {
|
||||
businessId?: string;
|
||||
buttonAction?: string;
|
||||
};
|
||||
|
||||
@Component({
|
||||
templateUrl: "add-credit-dialog.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class AddCreditDialogComponent implements OnInit {
|
||||
@ViewChild("ppButtonForm", { read: ElementRef, static: true }) ppButtonFormRef: ElementRef;
|
||||
|
||||
paymentMethodType = PaymentMethodType;
|
||||
ppButtonFormAction: string;
|
||||
ppButtonBusinessId: string;
|
||||
ppButtonCustomField: string;
|
||||
ppLoading = false;
|
||||
subject: string;
|
||||
returnUrl: string;
|
||||
organizationId: string;
|
||||
|
||||
private userId: string;
|
||||
private name: string;
|
||||
private email: string;
|
||||
private region: string;
|
||||
|
||||
protected DialogResult = AddCreditDialogResult;
|
||||
protected formGroup = new FormGroup({
|
||||
method: new FormControl(PaymentMethodType.PayPal),
|
||||
creditAmount: new FormControl(null, [Validators.required]),
|
||||
});
|
||||
|
||||
constructor(
|
||||
private dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) protected data: AddCreditDialogData,
|
||||
private accountService: AccountService,
|
||||
private apiService: ApiService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private organizationService: OrganizationService,
|
||||
private logService: LogService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.organizationId = data.organizationId;
|
||||
const payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig;
|
||||
this.ppButtonFormAction = payPalConfig.buttonAction;
|
||||
this.ppButtonBusinessId = payPalConfig.businessId;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
if (this.organizationId != null) {
|
||||
if (this.creditAmount == null) {
|
||||
this.creditAmount = "0.00";
|
||||
}
|
||||
this.ppButtonCustomField = "organization_id:" + this.organizationId;
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
const org = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(this.organizationId)),
|
||||
);
|
||||
if (org != null) {
|
||||
this.subject = org.name;
|
||||
this.name = org.name;
|
||||
}
|
||||
} else {
|
||||
if (this.creditAmount == null) {
|
||||
this.creditAmount = "0.00";
|
||||
}
|
||||
const [userId, email] = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
|
||||
);
|
||||
this.userId = userId;
|
||||
this.subject = email;
|
||||
this.email = this.subject;
|
||||
this.ppButtonCustomField = "user_id:" + this.userId;
|
||||
}
|
||||
this.region = await firstValueFrom(this.configService.cloudRegion$);
|
||||
this.ppButtonCustomField += ",account_credit:1";
|
||||
this.ppButtonCustomField += `,region:${this.region}`;
|
||||
this.returnUrl = window.location.href;
|
||||
}
|
||||
|
||||
get creditAmount() {
|
||||
return this.formGroup.value.creditAmount;
|
||||
}
|
||||
set creditAmount(value: string) {
|
||||
this.formGroup.get("creditAmount").setValue(value);
|
||||
}
|
||||
|
||||
get method() {
|
||||
return this.formGroup.value.method;
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
if (this.creditAmount == null || this.creditAmount === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.method === PaymentMethodType.PayPal) {
|
||||
this.ppButtonFormRef.nativeElement.submit();
|
||||
this.ppLoading = true;
|
||||
return;
|
||||
}
|
||||
if (this.method === PaymentMethodType.BitPay) {
|
||||
const req = new BitPayInvoiceRequest();
|
||||
req.email = this.email;
|
||||
req.name = this.name;
|
||||
req.credit = true;
|
||||
req.amount = this.creditAmountNumber;
|
||||
req.organizationId = this.organizationId;
|
||||
req.userId = this.userId;
|
||||
req.returnUrl = this.returnUrl;
|
||||
const bitPayUrl: string = await this.apiService.postBitPayInvoice(req);
|
||||
this.platformUtilsService.launchUri(bitPayUrl);
|
||||
return;
|
||||
}
|
||||
this.dialogRef.close(AddCreditDialogResult.Added);
|
||||
};
|
||||
|
||||
formatAmount() {
|
||||
try {
|
||||
if (this.creditAmount != null && this.creditAmount !== "") {
|
||||
const floatAmount = Math.abs(parseFloat(this.creditAmount));
|
||||
if (floatAmount > 0) {
|
||||
this.creditAmount = parseFloat((Math.round(floatAmount * 100) / 100).toString())
|
||||
.toFixed(2)
|
||||
.toString();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
this.creditAmount = "";
|
||||
}
|
||||
|
||||
get creditAmountNumber(): number {
|
||||
if (this.creditAmount != null && this.creditAmount !== "") {
|
||||
try {
|
||||
return parseFloat(this.creditAmount);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strongly typed helper to open a AddCreditDialog
|
||||
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||
* @param config Configuration for the dialog
|
||||
*/
|
||||
export function openAddCreditDialog(
|
||||
dialogService: DialogService,
|
||||
config: DialogConfig<AddCreditDialogData>,
|
||||
) {
|
||||
return dialogService.open<AddCreditDialogResult>(AddCreditDialogComponent, config);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<bit-dialog dialogSize="large" [title]="dialogHeader" [loading]="loading">
|
||||
<ng-container bitDialogContent>
|
||||
<app-payment
|
||||
[showAccountCredit]="false"
|
||||
[showBankAccount]="!!organizationId || !!providerId"
|
||||
[initialPaymentMethod]="initialPaymentMethod"
|
||||
></app-payment>
|
||||
<app-manage-tax-information
|
||||
*ngIf="taxInformation"
|
||||
[showTaxIdField]="showTaxIdField"
|
||||
[startWith]="taxInformation"
|
||||
(taxInformationChanged)="taxInformationChanged($event)"
|
||||
/>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary" [bitAction]="submit">
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
[bitDialogClose]="ResultType.Closed"
|
||||
>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -1,225 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, forwardRef, Inject, OnInit, ViewChild } from "@angular/core";
|
||||
|
||||
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
|
||||
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
|
||||
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
|
||||
import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
DialogConfig,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { PaymentComponent } from "../payment/payment.component";
|
||||
|
||||
export interface AdjustPaymentDialogParams {
|
||||
initialPaymentMethod?: PaymentMethodType | null;
|
||||
organizationId?: string;
|
||||
productTier?: ProductTierType;
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum AdjustPaymentDialogResultType {
|
||||
Closed = "closed",
|
||||
Submitted = "submitted",
|
||||
}
|
||||
|
||||
@Component({
|
||||
templateUrl: "./adjust-payment-dialog.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class AdjustPaymentDialogComponent implements OnInit {
|
||||
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
|
||||
@ViewChild(forwardRef(() => ManageTaxInformationComponent))
|
||||
taxInfoComponent: ManageTaxInformationComponent;
|
||||
|
||||
protected readonly PaymentMethodType = PaymentMethodType;
|
||||
protected readonly ResultType = AdjustPaymentDialogResultType;
|
||||
|
||||
protected dialogHeader: string;
|
||||
protected initialPaymentMethod: PaymentMethodType;
|
||||
protected organizationId?: string;
|
||||
protected productTier?: ProductTierType;
|
||||
protected providerId?: string;
|
||||
|
||||
protected loading = true;
|
||||
|
||||
protected taxInformation: TaxInformation;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
@Inject(DIALOG_DATA) protected dialogParams: AdjustPaymentDialogParams,
|
||||
private dialogRef: DialogRef<AdjustPaymentDialogResultType>,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
) {
|
||||
const key = this.dialogParams.initialPaymentMethod ? "changePaymentMethod" : "addPaymentMethod";
|
||||
this.dialogHeader = this.i18nService.t(key);
|
||||
this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card;
|
||||
this.organizationId = this.dialogParams.organizationId;
|
||||
this.productTier = this.dialogParams.productTier;
|
||||
this.providerId = this.dialogParams.providerId;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.organizationId) {
|
||||
this.organizationApiService
|
||||
.getTaxInfo(this.organizationId)
|
||||
.then((response: TaxInfoResponse) => {
|
||||
this.taxInformation = TaxInformation.from(response);
|
||||
this.toggleBankAccount();
|
||||
})
|
||||
.catch(() => {
|
||||
this.taxInformation = new TaxInformation();
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
} else if (this.providerId) {
|
||||
this.billingApiService
|
||||
.getProviderTaxInformation(this.providerId)
|
||||
.then((response) => {
|
||||
this.taxInformation = TaxInformation.from(response);
|
||||
this.toggleBankAccount();
|
||||
})
|
||||
.catch(() => {
|
||||
this.taxInformation = new TaxInformation();
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
} else {
|
||||
this.apiService
|
||||
.getTaxInfo()
|
||||
.then((response: TaxInfoResponse) => {
|
||||
this.taxInformation = TaxInformation.from(response);
|
||||
})
|
||||
.catch(() => {
|
||||
this.taxInformation = new TaxInformation();
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
taxInformationChanged(event: TaxInformation) {
|
||||
this.taxInformation = event;
|
||||
this.toggleBankAccount();
|
||||
}
|
||||
|
||||
toggleBankAccount = () => {
|
||||
if (this.taxInformation.country === "US") {
|
||||
this.paymentComponent.showBankAccount = !!this.organizationId || !!this.providerId;
|
||||
} else {
|
||||
this.paymentComponent.showBankAccount = false;
|
||||
if (this.paymentComponent.selected === PaymentMethodType.BankAccount) {
|
||||
this.paymentComponent.select(PaymentMethodType.Card);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
submit = async (): Promise<void> => {
|
||||
if (!this.taxInfoComponent.validate()) {
|
||||
this.taxInfoComponent.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.organizationId) {
|
||||
await this.updateOrganizationPaymentMethod();
|
||||
} else if (this.providerId) {
|
||||
await this.updateProviderPaymentMethod();
|
||||
} else {
|
||||
await this.updatePremiumUserPaymentMethod();
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("updatedPaymentMethod"),
|
||||
});
|
||||
|
||||
this.dialogRef.close(AdjustPaymentDialogResultType.Submitted);
|
||||
} catch (error) {
|
||||
const msg = typeof error == "object" ? error.message : error;
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t(msg) || msg,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private updateOrganizationPaymentMethod = async () => {
|
||||
const paymentSource = await this.paymentComponent.tokenize();
|
||||
|
||||
const request = new UpdatePaymentMethodRequest();
|
||||
request.paymentSource = paymentSource;
|
||||
request.taxInformation = ExpandedTaxInfoUpdateRequest.From(this.taxInformation);
|
||||
|
||||
await this.billingApiService.updateOrganizationPaymentMethod(this.organizationId, request);
|
||||
};
|
||||
|
||||
private updatePremiumUserPaymentMethod = async () => {
|
||||
const { type, token } = await this.paymentComponent.tokenize();
|
||||
|
||||
const request = new PaymentRequest();
|
||||
request.paymentMethodType = type;
|
||||
request.paymentToken = token;
|
||||
request.country = this.taxInformation.country;
|
||||
request.postalCode = this.taxInformation.postalCode;
|
||||
request.taxId = this.taxInformation.taxId;
|
||||
request.state = this.taxInformation.state;
|
||||
request.line1 = this.taxInformation.line1;
|
||||
request.line2 = this.taxInformation.line2;
|
||||
request.city = this.taxInformation.city;
|
||||
request.state = this.taxInformation.state;
|
||||
await this.apiService.postAccountPayment(request);
|
||||
};
|
||||
|
||||
private updateProviderPaymentMethod = async () => {
|
||||
const paymentSource = await this.paymentComponent.tokenize();
|
||||
|
||||
const request = new UpdatePaymentMethodRequest();
|
||||
request.paymentSource = paymentSource;
|
||||
request.taxInformation = ExpandedTaxInfoUpdateRequest.From(this.taxInformation);
|
||||
|
||||
await this.billingApiService.updateProviderPaymentMethod(this.providerId, request);
|
||||
};
|
||||
|
||||
protected get showTaxIdField(): boolean {
|
||||
if (this.organizationId) {
|
||||
switch (this.productTier) {
|
||||
case ProductTierType.Free:
|
||||
case ProductTierType.Families:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return !!this.providerId;
|
||||
}
|
||||
}
|
||||
|
||||
static open = (
|
||||
dialogService: DialogService,
|
||||
dialogConfig: DialogConfig<AdjustPaymentDialogParams>,
|
||||
) =>
|
||||
dialogService.open<AdjustPaymentDialogResultType>(AdjustPaymentDialogComponent, dialogConfig);
|
||||
}
|
||||
@@ -1,46 +1,40 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { BannerModule } from "@bitwarden/components";
|
||||
import {
|
||||
EnterBillingAddressComponent,
|
||||
EnterPaymentMethodComponent,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||
|
||||
import { HeaderModule } from "../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
import { AddCreditDialogComponent } from "./add-credit-dialog.component";
|
||||
import { AdjustPaymentDialogComponent } from "./adjust-payment-dialog/adjust-payment-dialog.component";
|
||||
import { AdjustStorageDialogComponent } from "./adjust-storage-dialog/adjust-storage-dialog.component";
|
||||
import { BillingHistoryComponent } from "./billing-history.component";
|
||||
import { OffboardingSurveyComponent } from "./offboarding-survey.component";
|
||||
import { PaymentComponent } from "./payment/payment.component";
|
||||
import { PaymentMethodComponent } from "./payment-method.component";
|
||||
import { PlanCardComponent } from "./plan-card/plan-card.component";
|
||||
import { PricingSummaryComponent } from "./pricing-summary/pricing-summary.component";
|
||||
import { IndividualSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/individual-self-hosting-license-uploader.component";
|
||||
import { OrganizationSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/organization-self-hosting-license-uploader.component";
|
||||
import { SecretsManagerSubscribeComponent } from "./sm-subscribe.component";
|
||||
import { TaxInfoComponent } from "./tax-info.component";
|
||||
import { TrialPaymentDialogComponent } from "./trial-payment-dialog/trial-payment-dialog.component";
|
||||
import { UpdateLicenseDialogComponent } from "./update-license-dialog.component";
|
||||
import { UpdateLicenseComponent } from "./update-license.component";
|
||||
import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-account.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
TaxInfoComponent,
|
||||
HeaderModule,
|
||||
BannerModule,
|
||||
PaymentComponent,
|
||||
VerifyBankAccountComponent,
|
||||
EnterPaymentMethodComponent,
|
||||
EnterBillingAddressComponent,
|
||||
],
|
||||
declarations: [
|
||||
AddCreditDialogComponent,
|
||||
BillingHistoryComponent,
|
||||
PaymentMethodComponent,
|
||||
SecretsManagerSubscribeComponent,
|
||||
UpdateLicenseComponent,
|
||||
UpdateLicenseDialogComponent,
|
||||
OffboardingSurveyComponent,
|
||||
AdjustPaymentDialogComponent,
|
||||
AdjustStorageDialogComponent,
|
||||
IndividualSelfHostingLicenseUploaderComponent,
|
||||
OrganizationSelfHostingLicenseUploaderComponent,
|
||||
@@ -50,14 +44,11 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
|
||||
],
|
||||
exports: [
|
||||
SharedModule,
|
||||
TaxInfoComponent,
|
||||
BillingHistoryComponent,
|
||||
SecretsManagerSubscribeComponent,
|
||||
UpdateLicenseComponent,
|
||||
UpdateLicenseDialogComponent,
|
||||
OffboardingSurveyComponent,
|
||||
VerifyBankAccountComponent,
|
||||
PaymentComponent,
|
||||
IndividualSelfHostingLicenseUploaderComponent,
|
||||
OrganizationSelfHostingLicenseUploaderComponent,
|
||||
],
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
export * from "./billing-shared.module";
|
||||
export * from "./payment-method.component";
|
||||
export * from "./sm-subscribe.component";
|
||||
export * from "./tax-info.component";
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<app-header *ngIf="organizationId">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
[bitAction]="load"
|
||||
class="tw-ml-auto"
|
||||
*ngIf="firstLoaded"
|
||||
[disabled]="loading"
|
||||
>
|
||||
<i class="bwi bwi-refresh bwi-fw" [ngClass]="{ 'bwi-spin': loading }" aria-hidden="true"></i>
|
||||
{{ "refresh" | i18n }}
|
||||
</button>
|
||||
</app-header>
|
||||
|
||||
<bit-container>
|
||||
<!-- TODO: Organization and individual should use different "page" components -->
|
||||
<h2 bitTypography="h1" *ngIf="!organizationId">{{ "paymentMethod" | i18n }}</h2>
|
||||
|
||||
<ng-container *ngIf="!firstLoaded && loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="billing">
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">
|
||||
{{ (isCreditBalance ? "accountCredit" : "accountBalance") | i18n }}
|
||||
</h2>
|
||||
<p class="tw-text-lg tw-font-bold">{{ creditOrBalance | currency: "$" }}</p>
|
||||
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="addCredit">
|
||||
{{ "addCredit" | i18n }}
|
||||
</button>
|
||||
</bit-section>
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
|
||||
<p *ngIf="!paymentSource" bitTypography="body1">{{ "noPaymentMethod" | i18n }}</p>
|
||||
<ng-container *ngIf="paymentSource">
|
||||
<bit-callout
|
||||
type="warning"
|
||||
title="{{ 'verifyBankAccount' | i18n }}"
|
||||
*ngIf="
|
||||
forOrganization &&
|
||||
paymentSource.type === paymentMethodType.BankAccount &&
|
||||
paymentSource.needsVerification
|
||||
"
|
||||
>
|
||||
<p bitTypography="body1">
|
||||
{{ "verifyBankAccountDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}
|
||||
</p>
|
||||
<form
|
||||
[formGroup]="verifyBankForm"
|
||||
[bitSubmit]="verifyBank"
|
||||
class="tw-flex tw-flex-wrap tw-items-center tw-space-x-2"
|
||||
>
|
||||
<bit-form-field class="tw-w-40">
|
||||
<bit-label>{{ "amountX" | i18n: "1" }}</bit-label>
|
||||
<input bitInput type="number" step="1" placeholder="xx" formControlName="amount1" />
|
||||
<span bitPrefix>$0.</span>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-w-40">
|
||||
<bit-label>{{ "amountX" | i18n: "2" }}</bit-label>
|
||||
<input bitInput type="number" step="1" placeholder="xx" formControlName="amount2" />
|
||||
<span bitPrefix>$0.</span>
|
||||
</bit-form-field>
|
||||
<button bitButton bitFormButton buttonType="primary" type="submit">
|
||||
{{ "verifyBankAccount" | i18n }}
|
||||
</button>
|
||||
</form>
|
||||
</bit-callout>
|
||||
<p>
|
||||
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
|
||||
{{ paymentSource.description }}
|
||||
</p>
|
||||
</ng-container>
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="changePayment">
|
||||
{{ (paymentSource ? "changePaymentMethod" : "addPaymentMethod") | i18n }}
|
||||
</button>
|
||||
<p *ngIf="isUnpaid" bitTypography="body1">
|
||||
{{ "paymentChargedWithUnpaidSubscription" | i18n }}
|
||||
</p>
|
||||
</bit-section>
|
||||
</ng-container>
|
||||
</bit-container>
|
||||
@@ -1,261 +0,0 @@
|
||||
import { Location } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, FormControl, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom, lastValueFrom, map } 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,
|
||||
getOrganizationById,
|
||||
} 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 { 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";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component";
|
||||
import {
|
||||
AdjustPaymentDialogComponent,
|
||||
AdjustPaymentDialogResultType,
|
||||
} from "./adjust-payment-dialog/adjust-payment-dialog.component";
|
||||
|
||||
@Component({
|
||||
templateUrl: "payment-method.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class PaymentMethodComponent implements OnInit, OnDestroy {
|
||||
loading = false;
|
||||
firstLoaded = false;
|
||||
billing?: BillingPaymentResponse;
|
||||
org?: OrganizationSubscriptionResponse;
|
||||
sub?: SubscriptionResponse;
|
||||
paymentMethodType = PaymentMethodType;
|
||||
organizationId?: string;
|
||||
isUnpaid = false;
|
||||
organization?: Organization;
|
||||
|
||||
verifyBankForm = this.formBuilder.group({
|
||||
amount1: new FormControl<number>(0, [
|
||||
Validators.required,
|
||||
Validators.max(99),
|
||||
Validators.min(0),
|
||||
]),
|
||||
amount2: new FormControl<number>(0, [
|
||||
Validators.required,
|
||||
Validators.max(99),
|
||||
Validators.min(0),
|
||||
]),
|
||||
});
|
||||
|
||||
launchPaymentModalAutomatically = false;
|
||||
constructor(
|
||||
protected apiService: ApiService,
|
||||
protected organizationApiService: OrganizationApiServiceAbstraction,
|
||||
protected i18nService: I18nService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
private router: Router,
|
||||
private location: Location,
|
||||
private route: ActivatedRoute,
|
||||
private formBuilder: FormBuilder,
|
||||
private dialogService: DialogService,
|
||||
private toastService: ToastService,
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
protected syncService: SyncService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
const state = this.router.getCurrentNavigation()?.extras?.state;
|
||||
// In case 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
|
||||
this.route.params.subscribe(async (params) => {
|
||||
if (params.organizationId) {
|
||||
this.organizationId = params.organizationId;
|
||||
} else if (this.platformUtilsService.isSelfHost()) {
|
||||
// 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
|
||||
this.router.navigate(["/settings/subscription"]);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
load = async () => {
|
||||
if (this.loading) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
if (this.forOrganization) {
|
||||
const billingPromise = this.organizationApiService.getBilling(this.organizationId!);
|
||||
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
|
||||
this.organizationId!,
|
||||
);
|
||||
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("User ID is not found");
|
||||
}
|
||||
|
||||
const organizationPromise = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(this.organizationId!)),
|
||||
);
|
||||
|
||||
[this.billing, this.org, this.organization] = await Promise.all([
|
||||
billingPromise,
|
||||
organizationSubscriptionPromise,
|
||||
organizationPromise,
|
||||
]);
|
||||
} else {
|
||||
const billingPromise = this.apiService.getUserBillingPayment();
|
||||
const subPromise = this.apiService.getUserSubscription();
|
||||
|
||||
[this.billing, this.sub] = await Promise.all([billingPromise, subPromise]);
|
||||
}
|
||||
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
|
||||
// eslint-disable-next-line no-constant-binary-expression
|
||||
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 () => {
|
||||
if (this.forOrganization) {
|
||||
const dialogRef = openAddCreditDialog(this.dialogService, {
|
||||
data: {
|
||||
organizationId: this.organizationId!,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result === AddCreditDialogResult.Added) {
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
changePayment = async () => {
|
||||
const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
organizationId: this.organizationId,
|
||||
initialPaymentMethod: this.paymentSource !== null ? this.paymentSource.type : null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
if (result === AdjustPaymentDialogResultType.Submitted) {
|
||||
this.location.replaceState(this.location.path(), "", {});
|
||||
if (this.launchPaymentModalAutomatically && !this.organization?.enabled) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
|
||||
verifyBank = async () => {
|
||||
if (this.loading || !this.forOrganization) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new VerifyBankRequest();
|
||||
request.amount1 = this.verifyBankForm.value.amount1!;
|
||||
request.amount2 = this.verifyBankForm.value.amount2!;
|
||||
await this.organizationApiService.verifyBank(this.organizationId!, request);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("verifiedBankAccount"),
|
||||
});
|
||||
await this.load();
|
||||
};
|
||||
|
||||
get isCreditBalance() {
|
||||
return this.billing == null || this.billing.balance <= 0;
|
||||
}
|
||||
|
||||
get creditOrBalance() {
|
||||
return Math.abs(this.billing != null ? this.billing.balance : 0);
|
||||
}
|
||||
|
||||
get paymentSource() {
|
||||
return this.billing != null ? this.billing.paymentSource : null;
|
||||
}
|
||||
|
||||
get forOrganization() {
|
||||
return this.organizationId != null;
|
||||
}
|
||||
|
||||
get paymentSourceClasses() {
|
||||
if (this.paymentSource == null) {
|
||||
return [];
|
||||
}
|
||||
switch (this.paymentSource.type) {
|
||||
case PaymentMethodType.Card:
|
||||
return ["bwi-credit-card"];
|
||||
case PaymentMethodType.BankAccount:
|
||||
case PaymentMethodType.Check:
|
||||
return ["bwi-billing"];
|
||||
case PaymentMethodType.PayPal:
|
||||
return ["bwi-paypal text-primary"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
get subscription() {
|
||||
return this.sub?.subscription ?? this.org?.subscription ?? null;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<ng-template #defaultContent>
|
||||
<ng-content></ng-content>
|
||||
</ng-template>
|
||||
|
||||
<div class="tw-relative tw-mt-2">
|
||||
<bit-label
|
||||
[attr.for]="for"
|
||||
class="tw-absolute tw-bg-background tw-px-1 tw-text-sm tw-text-muted -tw-top-2.5 tw-left-3 tw-mb-0 tw-max-w-full tw-pointer-events-auto"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
|
||||
<span class="tw-text-xs tw-font-normal">({{ "required" | i18n }})</span>
|
||||
</bit-label>
|
||||
</div>
|
||||
@@ -1,149 +0,0 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<div class="tw-mb-4 tw-text-lg">
|
||||
<bit-radio-group formControlName="paymentMethod">
|
||||
<bit-radio-button id="card-payment-method" [value]="PaymentMethodType.Card">
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-credit-card" aria-hidden="true"></i>
|
||||
{{ "creditCard" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
<bit-radio-button
|
||||
id="bank-payment-method"
|
||||
[value]="PaymentMethodType.BankAccount"
|
||||
*ngIf="showBankAccount"
|
||||
>
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-billing" aria-hidden="true"></i>
|
||||
{{ "bankAccount" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
<bit-radio-button
|
||||
id="paypal-payment-method"
|
||||
[value]="PaymentMethodType.PayPal"
|
||||
*ngIf="showPayPal"
|
||||
>
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-paypal" aria-hidden="true"></i>
|
||||
{{ "payPal" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
<bit-radio-button
|
||||
id="credit-payment-method"
|
||||
[value]="PaymentMethodType.Credit"
|
||||
*ngIf="showAccountCredit"
|
||||
>
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-dollar" aria-hidden="true"></i>
|
||||
{{ "accountCredit" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
</bit-radio-group>
|
||||
</div>
|
||||
<!-- Card -->
|
||||
<ng-container *ngIf="usingCard">
|
||||
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4">
|
||||
<div class="tw-col-span-1">
|
||||
<app-payment-label for="stripe-card-number" required>
|
||||
{{ "number" | i18n }}
|
||||
</app-payment-label>
|
||||
<div id="stripe-card-number" class="tw-stripe-form-control"></div>
|
||||
</div>
|
||||
<div class="tw-col-span-1 tw-flex tw-items-end">
|
||||
<img
|
||||
src="../../../images/cards.png"
|
||||
alt="Visa, MasterCard, Discover, AmEx, JCB, Diners Club, UnionPay"
|
||||
class="tw-max-w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="tw-col-span-1">
|
||||
<app-payment-label for="stripe-card-expiry" required>
|
||||
{{ "expiration" | i18n }}
|
||||
</app-payment-label>
|
||||
<div id="stripe-card-expiry" class="tw-stripe-form-control"></div>
|
||||
</div>
|
||||
<div class="tw-col-span-1">
|
||||
<app-payment-label for="stripe-card-cvc" required>
|
||||
{{ "securityCodeSlashCVV" | i18n }}
|
||||
<a
|
||||
href="https://www.cvvnumber.com/cvv.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
appA11yTitle="{{ 'learnMore' | i18n }}"
|
||||
class="hover:tw-no-underline"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
</app-payment-label>
|
||||
<div id="stripe-card-cvc" class="tw-stripe-form-control"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- Bank Account -->
|
||||
<ng-container *ngIf="showBankAccount && usingBankAccount">
|
||||
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
|
||||
{{ "requiredToVerifyBankAccountWithStripe" | i18n }}
|
||||
</bit-callout>
|
||||
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4" formGroupName="bankInformation">
|
||||
<bit-form-field class="tw-col-span-1" disableMargin>
|
||||
<bit-label>{{ "routingNumber" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
id="routingNumber"
|
||||
type="text"
|
||||
formControlName="routingNumber"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-1" disableMargin>
|
||||
<bit-label>{{ "accountNumber" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
id="accountNumber"
|
||||
type="text"
|
||||
formControlName="accountNumber"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-1" disableMargin>
|
||||
<bit-label>{{ "accountHolderName" | i18n }}</bit-label>
|
||||
<input
|
||||
id="accountHolderName"
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="accountHolderName"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-1" disableMargin>
|
||||
<bit-label>{{ "bankAccountType" | i18n }}</bit-label>
|
||||
<bit-select id="accountHolderType" formControlName="accountHolderType" required>
|
||||
<bit-option value="" label="-- {{ 'select' | i18n }} --"></bit-option>
|
||||
<bit-option value="company" label="{{ 'bankAccountTypeCompany' | i18n }}"></bit-option>
|
||||
<bit-option
|
||||
value="individual"
|
||||
label="{{ 'bankAccountTypeIndividual' | i18n }}"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- PayPal -->
|
||||
<ng-container *ngIf="showPayPal && usingPayPal">
|
||||
<div class="tw-mb-3">
|
||||
<div id="braintree-container" class="tw-mb-1 tw-content-center"></div>
|
||||
<small class="tw-text-muted">{{ "paypalClickSubmit" | i18n }}</small>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- Account Credit -->
|
||||
<ng-container *ngIf="showAccountCredit && usingAccountCredit">
|
||||
<app-callout type="info">
|
||||
{{ "makeSureEnoughCredit" | i18n }}
|
||||
</app-callout>
|
||||
</ng-container>
|
||||
<button *ngIf="!!onSubmit" bitButton bitFormButton buttonType="primary" type="submit">
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -1,215 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { Subject } from "rxjs";
|
||||
import { takeUntil } from "rxjs/operators";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { TokenizedPaymentSourceRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-source.request";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BillingServicesModule, BraintreeService, StripeService } from "../../services";
|
||||
|
||||
import { PaymentLabelComponent } from "./payment-label.component";
|
||||
|
||||
/**
|
||||
* Render a form that allows the user to enter their payment method, tokenize it against one of our payment providers and,
|
||||
* optionally, submit it using the {@link onSubmit} function if it is provided.
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-payment",
|
||||
templateUrl: "./payment.component.html",
|
||||
imports: [BillingServicesModule, SharedModule, PaymentLabelComponent],
|
||||
})
|
||||
export class PaymentComponent implements OnInit, OnDestroy {
|
||||
/** Show account credit as a payment option. */
|
||||
@Input() showAccountCredit: boolean = true;
|
||||
/** Show bank account as a payment option. */
|
||||
@Input() showBankAccount: boolean = true;
|
||||
/** Show PayPal as a payment option. */
|
||||
@Input() showPayPal: boolean = true;
|
||||
|
||||
/** The payment method selected by default when the component renders. */
|
||||
@Input() private initialPaymentMethod: PaymentMethodType = PaymentMethodType.Card;
|
||||
/** If provided, will be invoked with the tokenized payment source during form submission. */
|
||||
@Input() protected onSubmit?: (request: TokenizedPaymentSourceRequest) => Promise<void>;
|
||||
|
||||
@Input() private bankAccountWarningOverride?: string;
|
||||
|
||||
@Output() submitted = new EventEmitter<PaymentMethodType>();
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
protected formGroup = new FormGroup({
|
||||
paymentMethod: new FormControl<PaymentMethodType>(null),
|
||||
bankInformation: new FormGroup({
|
||||
routingNumber: new FormControl<string>("", [Validators.required]),
|
||||
accountNumber: new FormControl<string>("", [Validators.required]),
|
||||
accountHolderName: new FormControl<string>("", [Validators.required]),
|
||||
accountHolderType: new FormControl<string>("", [Validators.required]),
|
||||
}),
|
||||
});
|
||||
|
||||
protected PaymentMethodType = PaymentMethodType;
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private braintreeService: BraintreeService,
|
||||
private i18nService: I18nService,
|
||||
private stripeService: StripeService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.formGroup.controls.paymentMethod.patchValue(this.initialPaymentMethod);
|
||||
|
||||
this.stripeService.loadStripe(
|
||||
{
|
||||
cardNumber: "#stripe-card-number",
|
||||
cardExpiry: "#stripe-card-expiry",
|
||||
cardCvc: "#stripe-card-cvc",
|
||||
},
|
||||
this.initialPaymentMethod === PaymentMethodType.Card,
|
||||
);
|
||||
|
||||
if (this.showPayPal) {
|
||||
this.braintreeService.loadBraintree(
|
||||
"#braintree-container",
|
||||
this.initialPaymentMethod === PaymentMethodType.PayPal,
|
||||
);
|
||||
}
|
||||
|
||||
this.formGroup
|
||||
.get("paymentMethod")
|
||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((type) => {
|
||||
this.onPaymentMethodChange(type);
|
||||
});
|
||||
}
|
||||
|
||||
/** Programmatically select the provided payment method. */
|
||||
select = (paymentMethod: PaymentMethodType) => {
|
||||
this.formGroup.get("paymentMethod").patchValue(paymentMethod);
|
||||
};
|
||||
|
||||
protected submit = async () => {
|
||||
const { type, token } = await this.tokenize();
|
||||
await this.onSubmit?.({ type, token });
|
||||
this.submitted.emit(type);
|
||||
};
|
||||
|
||||
validate = () => {
|
||||
if (!this.usingBankAccount) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.formGroup.controls.bankInformation.markAllAsTouched();
|
||||
return this.formGroup.controls.bankInformation.valid;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tokenize the payment method information entered by the user against one of our payment providers.
|
||||
*
|
||||
* - {@link PaymentMethodType.Card} => [Stripe.confirmCardSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_card_setup}
|
||||
* - {@link PaymentMethodType.BankAccount} => [Stripe.confirmUsBankAccountSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_us_bank_account_setup}
|
||||
* - {@link PaymentMethodType.PayPal} => [Braintree.requestPaymentMethod]{@link https://braintree.github.io/braintree-web-drop-in/docs/current/Dropin.html#requestPaymentMethod}
|
||||
* */
|
||||
async tokenize(): Promise<{ type: PaymentMethodType; token: string }> {
|
||||
const type = this.selected;
|
||||
|
||||
if (this.usingStripe) {
|
||||
const clientSecret = await this.billingApiService.createSetupIntent(type);
|
||||
|
||||
if (this.usingBankAccount) {
|
||||
this.formGroup.markAllAsTouched();
|
||||
if (this.formGroup.valid) {
|
||||
const token = await this.stripeService.setupBankAccountPaymentMethod(clientSecret, {
|
||||
accountHolderName: this.formGroup.value.bankInformation.accountHolderName,
|
||||
routingNumber: this.formGroup.value.bankInformation.routingNumber,
|
||||
accountNumber: this.formGroup.value.bankInformation.accountNumber,
|
||||
accountHolderType: this.formGroup.value.bankInformation.accountHolderType,
|
||||
});
|
||||
return {
|
||||
type,
|
||||
token,
|
||||
};
|
||||
} else {
|
||||
throw "Invalid input provided. Please ensure all required fields are filled out correctly and try again.";
|
||||
}
|
||||
}
|
||||
|
||||
if (this.usingCard) {
|
||||
const token = await this.stripeService.setupCardPaymentMethod(clientSecret);
|
||||
return {
|
||||
type,
|
||||
token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (this.usingPayPal) {
|
||||
const token = await this.braintreeService.requestPaymentMethod();
|
||||
return {
|
||||
type,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.usingAccountCredit) {
|
||||
return {
|
||||
type: PaymentMethodType.Credit,
|
||||
token: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
this.stripeService.unloadStripe();
|
||||
if (this.showPayPal) {
|
||||
this.braintreeService.unloadBraintree();
|
||||
}
|
||||
}
|
||||
|
||||
private onPaymentMethodChange(type: PaymentMethodType): void {
|
||||
switch (type) {
|
||||
case PaymentMethodType.Card: {
|
||||
this.stripeService.mountElements();
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal: {
|
||||
this.braintreeService.createDropin();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get selected(): PaymentMethodType {
|
||||
return this.formGroup.value.paymentMethod;
|
||||
}
|
||||
|
||||
protected get usingAccountCredit(): boolean {
|
||||
return this.selected === PaymentMethodType.Credit;
|
||||
}
|
||||
|
||||
protected get usingBankAccount(): boolean {
|
||||
return this.selected === PaymentMethodType.BankAccount;
|
||||
}
|
||||
|
||||
protected get usingCard(): boolean {
|
||||
return this.selected === PaymentMethodType.Card;
|
||||
}
|
||||
|
||||
protected get usingPayPal(): boolean {
|
||||
return this.selected === PaymentMethodType.PayPal;
|
||||
}
|
||||
|
||||
private get usingStripe(): boolean {
|
||||
return this.usingBankAccount || this.usingCard;
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<form [formGroup]="taxFormGroup">
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "country" | i18n }}</bit-label>
|
||||
<bit-select formControlName="country" autocomplete="country" data-testid="country">
|
||||
<bit-option
|
||||
*ngFor="let country of countryList"
|
||||
[value]="country.value"
|
||||
[disabled]="country.disabled"
|
||||
[label]="country.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="postalCode"
|
||||
autocomplete="postal-code"
|
||||
data-testid="postal-code"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6" *ngIf="isTaxSupported">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "address1" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="line1"
|
||||
autocomplete="address-line1"
|
||||
data-testid="address-line1"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6" *ngIf="isTaxSupported">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "address2" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="line2"
|
||||
autocomplete="address-line2"
|
||||
data-testid="address-line2"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6" *ngIf="isTaxSupported">
|
||||
<bit-form-field>
|
||||
<bit-label for="addressCity">{{ "cityTown" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="city"
|
||||
autocomplete="address-level2"
|
||||
data-testid="city"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6" *ngIf="isTaxSupported">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="state"
|
||||
autocomplete="address-level1"
|
||||
data-testid="state"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6" *ngIf="isTaxSupported && showTaxIdField">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="taxId" data-testid="tax-id" />
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,199 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { debounceTime } from "rxjs/operators";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
|
||||
import { CountryListItem } from "@bitwarden/common/billing/models/domain";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
/**
|
||||
* @deprecated Use `ManageTaxInformationComponent` instead.
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-tax-info",
|
||||
templateUrl: "tax-info.component.html",
|
||||
imports: [SharedModule],
|
||||
})
|
||||
export class TaxInfoComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@Input() trialFlow = false;
|
||||
@Output() countryChanged = new EventEmitter();
|
||||
@Output() taxInformationChanged: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
taxFormGroup = new FormGroup({
|
||||
country: new FormControl<string>(null, [Validators.required]),
|
||||
postalCode: new FormControl<string>(null, [Validators.required]),
|
||||
taxId: new FormControl<string>(null),
|
||||
line1: new FormControl<string>(null),
|
||||
line2: new FormControl<string>(null),
|
||||
city: new FormControl<string>(null),
|
||||
state: new FormControl<string>(null),
|
||||
});
|
||||
|
||||
protected isTaxSupported: boolean;
|
||||
|
||||
loading = true;
|
||||
organizationId: string;
|
||||
providerId: string;
|
||||
countryList: CountryListItem[] = this.taxService.getCountries();
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private route: ActivatedRoute,
|
||||
private logService: LogService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private taxService: TaxServiceAbstraction,
|
||||
) {}
|
||||
|
||||
get country(): string {
|
||||
return this.taxFormGroup.controls.country.value;
|
||||
}
|
||||
|
||||
get postalCode(): string {
|
||||
return this.taxFormGroup.controls.postalCode.value;
|
||||
}
|
||||
|
||||
get taxId(): string {
|
||||
return this.taxFormGroup.controls.taxId.value;
|
||||
}
|
||||
|
||||
get line1(): string {
|
||||
return this.taxFormGroup.controls.line1.value;
|
||||
}
|
||||
|
||||
get line2(): string {
|
||||
return this.taxFormGroup.controls.line2.value;
|
||||
}
|
||||
|
||||
get city(): string {
|
||||
return this.taxFormGroup.controls.city.value;
|
||||
}
|
||||
|
||||
get state(): string {
|
||||
return this.taxFormGroup.controls.state.value;
|
||||
}
|
||||
|
||||
get showTaxIdField(): boolean {
|
||||
return !!this.organizationId;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
// Provider setup
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
this.route.queryParams.subscribe((params) => {
|
||||
this.providerId = params.providerId;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent?.parent?.params.subscribe(async (params) => {
|
||||
this.organizationId = params.organizationId;
|
||||
if (this.organizationId) {
|
||||
try {
|
||||
const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId);
|
||||
if (taxInfo) {
|
||||
this.taxFormGroup.controls.taxId.setValue(taxInfo.taxId);
|
||||
this.taxFormGroup.controls.state.setValue(taxInfo.state);
|
||||
this.taxFormGroup.controls.line1.setValue(taxInfo.line1);
|
||||
this.taxFormGroup.controls.line2.setValue(taxInfo.line2);
|
||||
this.taxFormGroup.controls.city.setValue(taxInfo.city);
|
||||
this.taxFormGroup.controls.postalCode.setValue(taxInfo.postalCode);
|
||||
this.taxFormGroup.controls.country.setValue(taxInfo.country);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const taxInfo = await this.apiService.getTaxInfo();
|
||||
if (taxInfo) {
|
||||
this.taxFormGroup.controls.postalCode.setValue(taxInfo.postalCode);
|
||||
this.taxFormGroup.controls.country.setValue(taxInfo.country);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
this.isTaxSupported = await this.taxService.isCountrySupported(
|
||||
this.taxFormGroup.controls.country.value,
|
||||
);
|
||||
|
||||
this.countryChanged.emit();
|
||||
});
|
||||
|
||||
this.taxFormGroup.controls.country.valueChanges
|
||||
.pipe(debounceTime(1000), takeUntil(this.destroy$))
|
||||
.subscribe((value) => {
|
||||
this.taxService
|
||||
.isCountrySupported(this.taxFormGroup.controls.country.value)
|
||||
.then((isSupported) => {
|
||||
this.isTaxSupported = isSupported;
|
||||
})
|
||||
.catch(() => {
|
||||
this.isTaxSupported = false;
|
||||
})
|
||||
.finally(() => {
|
||||
if (!this.isTaxSupported) {
|
||||
this.taxFormGroup.controls.taxId.setValue(null);
|
||||
this.taxFormGroup.controls.line1.setValue(null);
|
||||
this.taxFormGroup.controls.line2.setValue(null);
|
||||
this.taxFormGroup.controls.city.setValue(null);
|
||||
this.taxFormGroup.controls.state.setValue(null);
|
||||
}
|
||||
|
||||
this.countryChanged.emit();
|
||||
});
|
||||
this.taxInformationChanged.emit();
|
||||
});
|
||||
|
||||
this.taxFormGroup.controls.postalCode.valueChanges
|
||||
.pipe(debounceTime(1000), takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.taxInformationChanged.emit();
|
||||
});
|
||||
|
||||
this.taxFormGroup.controls.taxId.valueChanges
|
||||
.pipe(debounceTime(1000), takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.taxInformationChanged.emit();
|
||||
});
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
submitTaxInfo(): Promise<any> {
|
||||
this.taxFormGroup.updateValueAndValidity();
|
||||
this.taxFormGroup.markAllAsTouched();
|
||||
|
||||
const request = new ExpandedTaxInfoUpdateRequest();
|
||||
request.country = this.country;
|
||||
request.postalCode = this.postalCode;
|
||||
request.taxId = this.taxId;
|
||||
request.line1 = this.line1;
|
||||
request.line2 = this.line2;
|
||||
request.city = this.city;
|
||||
request.state = this.state;
|
||||
|
||||
return this.organizationId
|
||||
? this.organizationApiService.updateTaxInfo(
|
||||
this.organizationId,
|
||||
request as ExpandedTaxInfoUpdateRequest,
|
||||
)
|
||||
: this.apiService.putTaxInfo(request);
|
||||
}
|
||||
}
|
||||
@@ -86,17 +86,13 @@
|
||||
<ng-container>
|
||||
<h2 bitTypography="h4">{{ "paymentMethod" | i18n }}</h2>
|
||||
<ng-container bitDialogContent>
|
||||
<app-payment
|
||||
[showAccountCredit]="false"
|
||||
[showBankAccount]="!!organizationId"
|
||||
[initialPaymentMethod]="initialPaymentMethod"
|
||||
></app-payment>
|
||||
<app-manage-tax-information
|
||||
*ngIf="taxInformation"
|
||||
[showTaxIdField]="showTaxIdField"
|
||||
[startWith]="taxInformation"
|
||||
(taxInformationChanged)="taxInformationChanged($event)"
|
||||
/>
|
||||
<app-enter-payment-method [group]="formGroup.controls.paymentMethod">
|
||||
</app-enter-payment-method>
|
||||
<app-enter-billing-address
|
||||
[group]="formGroup.controls.billingAddress"
|
||||
[scenario]="{ type: 'checkout', supportsTaxId }"
|
||||
>
|
||||
</app-enter-billing-address>
|
||||
</ng-container>
|
||||
<!-- Pricing Breakdown -->
|
||||
<app-pricing-summary
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Component, EventEmitter, Inject, OnInit, Output, signal, ViewChild } from "@angular/core";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
signal,
|
||||
ViewChild,
|
||||
} from "@angular/core";
|
||||
import { FormGroup } from "@angular/forms";
|
||||
import { combineLatest, firstValueFrom, map, Subject, takeUntil } from "rxjs";
|
||||
import { debounceTime, startWith, switchMap } from "rxjs/operators";
|
||||
|
||||
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import {
|
||||
@@ -10,14 +20,9 @@ import {
|
||||
} 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 { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
|
||||
import { PaymentMethodType, PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
|
||||
import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
|
||||
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
|
||||
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
|
||||
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
@@ -29,9 +34,15 @@ import {
|
||||
DialogService,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
import {
|
||||
EnterBillingAddressComponent,
|
||||
EnterPaymentMethodComponent,
|
||||
getBillingAddressFromForm,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||
import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types";
|
||||
|
||||
import { PlanCardService } from "../../services/plan-card.service";
|
||||
import { PaymentComponent } from "../payment/payment.component";
|
||||
import { PlanCard } from "../plan-card/plan-card.component";
|
||||
import { PricingSummaryData } from "../pricing-summary/pricing-summary.component";
|
||||
|
||||
@@ -60,10 +71,10 @@ interface OnSuccessArgs {
|
||||
selector: "app-trial-payment-dialog",
|
||||
templateUrl: "./trial-payment-dialog.component.html",
|
||||
standalone: false,
|
||||
providers: [SubscriberBillingClient, TaxClient],
|
||||
})
|
||||
export class TrialPaymentDialogComponent implements OnInit {
|
||||
@ViewChild(PaymentComponent) paymentComponent!: PaymentComponent;
|
||||
@ViewChild(ManageTaxInformationComponent) taxComponent!: ManageTaxInformationComponent;
|
||||
export class TrialPaymentDialogComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
|
||||
|
||||
currentPlan!: PlanResponse;
|
||||
currentPlanName!: string;
|
||||
@@ -78,10 +89,16 @@ export class TrialPaymentDialogComponent implements OnInit {
|
||||
|
||||
@Output() onSuccess = new EventEmitter<OnSuccessArgs>();
|
||||
protected initialPaymentMethod: PaymentMethodType;
|
||||
protected taxInformation!: TaxInformation;
|
||||
protected readonly ResultType = TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE;
|
||||
pricingSummaryData!: PricingSummaryData;
|
||||
|
||||
formGroup = new FormGroup({
|
||||
paymentMethod: EnterPaymentMethodComponent.getFormGroup(),
|
||||
billingAddress: EnterBillingAddressComponent.getFormGroup(),
|
||||
});
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) private dialogParams: TrialPaymentDialogParams,
|
||||
private dialogRef: DialogRef<TrialPaymentDialogResultType>,
|
||||
@@ -93,8 +110,9 @@ export class TrialPaymentDialogComponent implements OnInit {
|
||||
private pricingSummaryService: PricingSummaryService,
|
||||
private apiService: ApiService,
|
||||
private toastService: ToastService,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private organizationBillingApiServiceAbstraction: OrganizationBillingApiServiceAbstraction,
|
||||
private subscriberBillingClient: SubscriberBillingClient,
|
||||
private taxClient: TaxClient,
|
||||
) {
|
||||
this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card;
|
||||
}
|
||||
@@ -134,19 +152,48 @@ export class TrialPaymentDialogComponent implements OnInit {
|
||||
: PlanInterval.Monthly;
|
||||
}
|
||||
|
||||
const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId);
|
||||
this.taxInformation = TaxInformation.from(taxInfo);
|
||||
const billingAddress = await this.subscriberBillingClient.getBillingAddress({
|
||||
type: "organization",
|
||||
data: this.organization,
|
||||
});
|
||||
|
||||
this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData(
|
||||
this.currentPlan,
|
||||
this.sub,
|
||||
this.organization,
|
||||
this.selectedInterval,
|
||||
this.taxInformation,
|
||||
this.isSecretsManagerTrial(),
|
||||
);
|
||||
if (billingAddress) {
|
||||
const { taxId, ...location } = billingAddress;
|
||||
|
||||
this.formGroup.controls.billingAddress.patchValue({
|
||||
...location,
|
||||
taxId: taxId ? taxId.value : null,
|
||||
});
|
||||
}
|
||||
|
||||
await this.refreshPricingSummary();
|
||||
|
||||
this.plans = await this.apiService.getPlans();
|
||||
|
||||
combineLatest([
|
||||
this.formGroup.controls.billingAddress.controls.country.valueChanges.pipe(
|
||||
startWith(this.formGroup.controls.billingAddress.controls.country.value),
|
||||
),
|
||||
this.formGroup.controls.billingAddress.controls.postalCode.valueChanges.pipe(
|
||||
startWith(this.formGroup.controls.billingAddress.controls.postalCode.value),
|
||||
),
|
||||
this.formGroup.controls.billingAddress.controls.taxId.valueChanges.pipe(
|
||||
startWith(this.formGroup.controls.billingAddress.controls.taxId.value),
|
||||
),
|
||||
])
|
||||
.pipe(
|
||||
debounceTime(500),
|
||||
switchMap(() => {
|
||||
return this.refreshPricingSummary();
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
static open = (
|
||||
@@ -175,14 +222,7 @@ export class TrialPaymentDialogComponent implements OnInit {
|
||||
|
||||
await this.selectPlan();
|
||||
|
||||
this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData(
|
||||
this.currentPlan,
|
||||
this.sub,
|
||||
this.organization,
|
||||
this.selectedInterval,
|
||||
this.taxInformation,
|
||||
this.isSecretsManagerTrial(),
|
||||
);
|
||||
await this.refreshPricingSummary();
|
||||
}
|
||||
|
||||
protected async selectPlan() {
|
||||
@@ -202,7 +242,7 @@ export class TrialPaymentDialogComponent implements OnInit {
|
||||
this.currentPlan = filteredPlans[0];
|
||||
}
|
||||
try {
|
||||
await this.refreshSalesTax();
|
||||
await this.refreshPricingSummary();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const translatedMessage = this.i18nService.t(errorMessage);
|
||||
@@ -214,72 +254,57 @@ export class TrialPaymentDialogComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
protected get showTaxIdField(): boolean {
|
||||
switch (this.currentPlan.productTier) {
|
||||
case ProductTierType.Free:
|
||||
case ProductTierType.Families:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshSalesTax(): Promise<void> {
|
||||
if (
|
||||
this.taxInformation === undefined ||
|
||||
!this.taxInformation.country ||
|
||||
!this.taxInformation.postalCode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request: PreviewOrganizationInvoiceRequest = {
|
||||
organizationId: this.organizationId,
|
||||
passwordManager: {
|
||||
additionalStorage: 0,
|
||||
plan: this.currentPlan?.type,
|
||||
seats: this.sub.seats,
|
||||
},
|
||||
taxInformation: {
|
||||
postalCode: this.taxInformation.postalCode,
|
||||
country: this.taxInformation.country,
|
||||
taxId: this.taxInformation.taxId,
|
||||
},
|
||||
};
|
||||
|
||||
if (this.organization.useSecretsManager) {
|
||||
request.secretsManager = {
|
||||
seats: this.sub.smSeats ?? 0,
|
||||
additionalMachineAccounts:
|
||||
(this.sub.smServiceAccounts ?? 0) -
|
||||
(this.sub.plan.SecretsManager?.baseServiceAccount ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
private refreshPricingSummary = async () => {
|
||||
const estimatedTax = await this.getEstimatedTax();
|
||||
this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData(
|
||||
this.currentPlan,
|
||||
this.sub,
|
||||
this.organization,
|
||||
this.selectedInterval,
|
||||
this.taxInformation,
|
||||
this.isSecretsManagerTrial(),
|
||||
estimatedTax,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
async taxInformationChanged(event: TaxInformation) {
|
||||
this.taxInformation = event;
|
||||
this.toggleBankAccount();
|
||||
await this.refreshSalesTax();
|
||||
}
|
||||
private getEstimatedTax = async () => {
|
||||
if (this.formGroup.controls.billingAddress.invalid) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
toggleBankAccount = () => {
|
||||
this.paymentComponent.showBankAccount = this.taxInformation.country === "US";
|
||||
const cadence =
|
||||
this.currentPlan.productTier !== ProductTierType.Families
|
||||
? this.currentPlan.isAnnual
|
||||
? "annually"
|
||||
: "monthly"
|
||||
: null;
|
||||
|
||||
if (
|
||||
!this.paymentComponent.showBankAccount &&
|
||||
this.paymentComponent.selected === PaymentMethodType.BankAccount
|
||||
) {
|
||||
this.paymentComponent.select(PaymentMethodType.Card);
|
||||
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
|
||||
|
||||
const getTierFromLegacyEnum = (organization: Organization) => {
|
||||
switch (organization.productTierType) {
|
||||
case ProductTierType.Families:
|
||||
return "families";
|
||||
case ProductTierType.Teams:
|
||||
return "teams";
|
||||
case ProductTierType.Enterprise:
|
||||
return "enterprise";
|
||||
}
|
||||
};
|
||||
|
||||
const tier = getTierFromLegacyEnum(this.organization);
|
||||
|
||||
if (tier && cadence) {
|
||||
const costs = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange(
|
||||
this.organization.id,
|
||||
{
|
||||
tier,
|
||||
cadence,
|
||||
},
|
||||
billingAddress,
|
||||
);
|
||||
return costs.tax;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -292,15 +317,24 @@ export class TrialPaymentDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
async onSubscribe(): Promise<void> {
|
||||
if (!this.taxComponent.validate()) {
|
||||
this.taxComponent.markAllAsTouched();
|
||||
this.formGroup.markAllAsTouched();
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.updateOrganizationPaymentMethod(
|
||||
this.organizationId,
|
||||
this.paymentComponent,
|
||||
this.taxInformation,
|
||||
);
|
||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
||||
if (!paymentMethod) {
|
||||
return;
|
||||
}
|
||||
|
||||
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
|
||||
|
||||
const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization };
|
||||
await Promise.all([
|
||||
this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null),
|
||||
this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress),
|
||||
]);
|
||||
|
||||
if (this.currentPlan.type !== this.sub.planType) {
|
||||
const changePlanRequest = new ChangePlanFrequencyRequest();
|
||||
@@ -332,20 +366,6 @@ export class TrialPaymentDialogComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private async updateOrganizationPaymentMethod(
|
||||
organizationId: string,
|
||||
paymentComponent: PaymentComponent,
|
||||
taxInformation: TaxInformation,
|
||||
): Promise<void> {
|
||||
const paymentSource = await paymentComponent.tokenize();
|
||||
|
||||
const request = new UpdatePaymentMethodRequest();
|
||||
request.paymentSource = paymentSource;
|
||||
request.taxInformation = ExpandedTaxInfoUpdateRequest.From(taxInformation);
|
||||
|
||||
await this.billingApiService.updateOrganizationPaymentMethod(organizationId, request);
|
||||
}
|
||||
|
||||
resolvePlanName(productTier: ProductTierType): string {
|
||||
switch (productTier) {
|
||||
case ProductTierType.Enterprise:
|
||||
@@ -362,4 +382,11 @@ export class TrialPaymentDialogComponent implements OnInit {
|
||||
return this.i18nService.t("planNameFree");
|
||||
}
|
||||
}
|
||||
|
||||
get supportsTaxId() {
|
||||
if (!this.organization) {
|
||||
return false;
|
||||
}
|
||||
return this.organization.productTierType !== ProductTierType.Families;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
|
||||
<p>{{ "verifyBankAccountWithStatementDescriptorInstructions" | i18n }}</p>
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-form-field class="tw-mr-2 tw-w-48">
|
||||
<bit-label>{{ "descriptorCode" | i18n }}</bit-label>
|
||||
<input bitInput type="text" placeholder="SMAB12" formControlName="descriptorCode" />
|
||||
</bit-form-field>
|
||||
<button *ngIf="onSubmit" type="submit" bitButton bitFormButton buttonType="primary">
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
</form>
|
||||
</bit-callout>
|
||||
@@ -1,34 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { FormBuilder, FormControl, Validators } from "@angular/forms";
|
||||
|
||||
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
@Component({
|
||||
selector: "app-verify-bank-account",
|
||||
templateUrl: "./verify-bank-account.component.html",
|
||||
imports: [SharedModule],
|
||||
})
|
||||
export class VerifyBankAccountComponent {
|
||||
@Input() onSubmit?: (request: VerifyBankAccountRequest) => Promise<void>;
|
||||
@Output() submitted = new EventEmitter();
|
||||
|
||||
protected formGroup = this.formBuilder.group({
|
||||
descriptorCode: new FormControl<string>(null, [
|
||||
Validators.required,
|
||||
Validators.minLength(6),
|
||||
Validators.maxLength(6),
|
||||
]),
|
||||
});
|
||||
|
||||
constructor(private formBuilder: FormBuilder) {}
|
||||
|
||||
submit = async () => {
|
||||
const request = new VerifyBankAccountRequest(this.formGroup.value.descriptorCode);
|
||||
await this.onSubmit?.(request);
|
||||
this.submitted.emit();
|
||||
};
|
||||
}
|
||||
@@ -54,17 +54,7 @@
|
||||
>
|
||||
<app-trial-billing-step
|
||||
*ngIf="stepper.selectedIndex === 2"
|
||||
[organizationInfo]="{
|
||||
name: orgInfoFormGroup.value.name!,
|
||||
email: orgInfoFormGroup.value.billingEmail!,
|
||||
type: trialOrganizationType,
|
||||
}"
|
||||
[subscriptionProduct]="
|
||||
product === ProductType.SecretsManager
|
||||
? SubscriptionProduct.SecretsManager
|
||||
: SubscriptionProduct.PasswordManager
|
||||
"
|
||||
[trialLength]="trialLength"
|
||||
[trial]="trial"
|
||||
(steppedBack)="previousStep()"
|
||||
(organizationCreated)="createdOrganization($event)"
|
||||
>
|
||||
|
||||
@@ -30,13 +30,10 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
import { Trial } from "@bitwarden/web-vault/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service";
|
||||
|
||||
import {
|
||||
OrganizationCreatedEvent,
|
||||
SubscriptionProduct,
|
||||
TrialOrganizationType,
|
||||
} from "../../../billing/accounts/trial-initiation/trial-billing-step.component";
|
||||
import { RouterService } from "../../../core/router.service";
|
||||
import { OrganizationCreatedEvent } from "../trial-billing-step/trial-billing-step.component";
|
||||
import { VerticalStepperComponent } from "../vertical-stepper/vertical-stepper.component";
|
||||
|
||||
export type InitiationPath =
|
||||
@@ -95,7 +92,6 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
protected readonly SubscriptionProduct = SubscriptionProduct;
|
||||
protected readonly ProductType = ProductType;
|
||||
protected trialPaymentOptional$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.TrialPaymentOptional,
|
||||
@@ -338,14 +334,6 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
get trialOrganizationType(): TrialOrganizationType | null {
|
||||
if (this.productTier === ProductTierType.Free) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.productTier;
|
||||
}
|
||||
|
||||
readonly showBillingStep$ = this.trialPaymentOptional$.pipe(
|
||||
map((trialPaymentOptional) => {
|
||||
return (
|
||||
@@ -434,4 +422,26 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
get trial(): Trial {
|
||||
const product =
|
||||
this.product === ProductType.PasswordManager ? "passwordManager" : "secretsManager";
|
||||
|
||||
const tier =
|
||||
this.productTier === ProductTierType.Families
|
||||
? "families"
|
||||
: this.productTier === ProductTierType.Teams
|
||||
? "teams"
|
||||
: "enterprise";
|
||||
|
||||
return {
|
||||
organization: {
|
||||
name: this.orgInfoFormGroup.value.name!,
|
||||
email: this.orgInfoFormGroup.value.billingEmail!,
|
||||
},
|
||||
product,
|
||||
tier,
|
||||
length: this.trialLength,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
@if (!(prices$ | async)) {
|
||||
<ng-container *ngTemplateOutlet="loadingSpinner" />
|
||||
} @else {
|
||||
@let prices = prices$ | async;
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<div class="tw-container tw-mb-3">
|
||||
<!-- Cadence -->
|
||||
<div class="tw-mb-6">
|
||||
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "billingPlanLabel" | i18n }}</h2>
|
||||
<bit-radio-group [formControl]="formGroup.controls.cadence">
|
||||
<div class="tw-mb-1 tw-items-center">
|
||||
<bit-radio-button id="annual-cadence-button" [value]="'annually'">
|
||||
<bit-label>
|
||||
{{ "annual" | i18n }} -
|
||||
{{ prices.annually | currency: "$" }}
|
||||
/{{ "yr" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
</div>
|
||||
@if (prices.monthly) {
|
||||
<div class="tw-mb-1 tw-items-center">
|
||||
<bit-radio-button id="monthly-cadence-button" [value]="'monthly'">
|
||||
<bit-label>
|
||||
{{ "monthly" | i18n }} -
|
||||
{{ prices.monthly | currency: "$" }}
|
||||
/{{ "monthAbbr" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
</div>
|
||||
}
|
||||
</bit-radio-group>
|
||||
</div>
|
||||
<!-- Payment -->
|
||||
<div class="tw-mb-4">
|
||||
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2>
|
||||
<app-enter-payment-method
|
||||
[group]="formGroup.controls.paymentMethod"
|
||||
></app-enter-payment-method>
|
||||
<app-enter-billing-address
|
||||
[group]="formGroup.controls.billingAddress"
|
||||
[scenario]="{ type: 'checkout', supportsTaxId: trial().tier !== 'families' }"
|
||||
></app-enter-billing-address>
|
||||
|
||||
@if (trial().length === 0) {
|
||||
@let label =
|
||||
trial().product === "passwordManager"
|
||||
? "passwordManagerPlanPrice"
|
||||
: "secretsManagerPlanPrice";
|
||||
<div id="price" class="tw-my-4">
|
||||
@let selectionTaxAmounts = selectionCosts$ | async;
|
||||
<div class="tw-text-muted tw-text-base">
|
||||
{{ label | i18n }}: {{ selectionPrice$ | async | currency: "USD $" }}
|
||||
<div>
|
||||
{{ "estimatedTax" | i18n }}:
|
||||
{{ selectionTaxAmounts.tax | currency: "USD $" }}
|
||||
</div>
|
||||
</div>
|
||||
<hr class="tw-my-1 tw-grid tw-grid-cols-3 tw-ml-0" />
|
||||
<p class="tw-text-lg">
|
||||
<strong>{{ "total" | i18n }}: </strong>
|
||||
@let interval = formGroup.value.cadence === "annually" ? "year" : "month";
|
||||
{{ selectionTaxAmounts.total | currency: "USD $" }}/{{ interval | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Submit -->
|
||||
<div class="tw-flex tw-space-x-2">
|
||||
<button bitButton bitFormButton buttonType="primary" type="submit">
|
||||
{{ (trial().length > 0 ? "startTrial" : "submit") | i18n }}
|
||||
</button>
|
||||
<button bitButton type="button" buttonType="secondary" (click)="stepBack()">
|
||||
{{ "back" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<ng-template #loadingSpinner>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Component, input, OnDestroy, OnInit, output, ViewChild } from "@angular/core";
|
||||
import { FormControl, FormGroup } from "@angular/forms";
|
||||
import {
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
filter,
|
||||
map,
|
||||
Observable,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
Subject,
|
||||
firstValueFrom,
|
||||
} from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { TaxClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
import {
|
||||
BillingAddressControls,
|
||||
EnterBillingAddressComponent,
|
||||
EnterPaymentMethodComponent,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||
import {
|
||||
Cadence,
|
||||
Cadences,
|
||||
Prices,
|
||||
Trial,
|
||||
TrialBillingStepService,
|
||||
} from "@bitwarden/web-vault/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
export interface OrganizationCreatedEvent {
|
||||
organizationId: string;
|
||||
planDescription: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-trial-billing-step",
|
||||
templateUrl: "./trial-billing-step.component.html",
|
||||
imports: [EnterPaymentMethodComponent, EnterBillingAddressComponent, SharedModule],
|
||||
providers: [TaxClient, TrialBillingStepService],
|
||||
})
|
||||
export class TrialBillingStepComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
|
||||
|
||||
protected trial = input.required<Trial>();
|
||||
protected steppedBack = output<void>();
|
||||
protected organizationCreated = output<OrganizationCreatedEvent>();
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
protected prices$!: Observable<Prices>;
|
||||
|
||||
protected selectionPrice$!: Observable<number>;
|
||||
protected selectionCosts$!: Observable<{
|
||||
tax: number;
|
||||
total: number;
|
||||
}>;
|
||||
protected selectionDescription$!: Observable<string>;
|
||||
|
||||
protected formGroup = new FormGroup({
|
||||
cadence: new FormControl<Cadence>(Cadences.Annually, {
|
||||
nonNullable: true,
|
||||
}),
|
||||
paymentMethod: EnterPaymentMethodComponent.getFormGroup(),
|
||||
billingAddress: EnterBillingAddressComponent.getFormGroup(),
|
||||
});
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
private trialBillingStepService: TrialBillingStepService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
const { product, tier } = this.trial();
|
||||
this.prices$ = this.trialBillingStepService.getPrices$(product, tier);
|
||||
|
||||
const cadenceChanged = this.formGroup.controls.cadence.valueChanges.pipe(
|
||||
startWith(Cadences.Annually),
|
||||
);
|
||||
|
||||
this.selectionPrice$ = combineLatest([this.prices$, cadenceChanged]).pipe(
|
||||
map(([prices, cadence]) => prices[cadence]),
|
||||
filter((price): price is number => !!price),
|
||||
);
|
||||
|
||||
this.selectionCosts$ = combineLatest([
|
||||
cadenceChanged,
|
||||
this.formGroup.controls.billingAddress.valueChanges.pipe(
|
||||
startWith(this.formGroup.controls.billingAddress.value),
|
||||
filter(
|
||||
(billingAddress): billingAddress is BillingAddressControls =>
|
||||
!!billingAddress.country && !!billingAddress.postalCode,
|
||||
),
|
||||
),
|
||||
]).pipe(
|
||||
debounceTime(500),
|
||||
switchMap(([cadence, billingAddress]) =>
|
||||
this.trialBillingStepService.getCosts(product, tier, cadence, billingAddress),
|
||||
),
|
||||
startWith({
|
||||
tax: 0,
|
||||
total: 0,
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
this.selectionDescription$ = combineLatest([this.selectionPrice$, cadenceChanged]).pipe(
|
||||
map(([price, cadence]) => {
|
||||
switch (cadence) {
|
||||
case Cadences.Annually:
|
||||
return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`;
|
||||
case Cadences.Monthly:
|
||||
return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
submit = async (): Promise<void> => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
||||
if (!paymentMethod) {
|
||||
return;
|
||||
}
|
||||
|
||||
const billingAddress = this.formGroup.controls.billingAddress.getRawValue();
|
||||
|
||||
const organization = await this.trialBillingStepService.startTrial(
|
||||
this.trial(),
|
||||
this.formGroup.value.cadence!,
|
||||
billingAddress,
|
||||
paymentMethod,
|
||||
);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: this.i18nService.t("organizationCreated"),
|
||||
message: this.i18nService.t("organizationReadyToGo"),
|
||||
});
|
||||
|
||||
this.organizationCreated.emit({
|
||||
organizationId: organization.id,
|
||||
planDescription: await firstValueFrom(this.selectionDescription$),
|
||||
});
|
||||
};
|
||||
|
||||
protected stepBack = () => this.steppedBack.emit();
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom, from, map, shareReplay } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import {
|
||||
OrganizationBillingServiceAbstraction,
|
||||
SubscriptionInformation,
|
||||
} from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { TaxClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
import {
|
||||
BillingAddressControls,
|
||||
getBillingAddressFromControls,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||
import {
|
||||
tokenizablePaymentMethodToLegacyEnum,
|
||||
TokenizedPaymentMethod,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
|
||||
export const Tiers = {
|
||||
Families: "families",
|
||||
Teams: "teams",
|
||||
Enterprise: "enterprise",
|
||||
} as const;
|
||||
|
||||
export const Cadences = {
|
||||
Annually: "annually",
|
||||
Monthly: "monthly",
|
||||
} as const;
|
||||
|
||||
export const Products = {
|
||||
PasswordManager: "passwordManager",
|
||||
SecretsManager: "secretsManager",
|
||||
} as const;
|
||||
|
||||
export type Tier = (typeof Tiers)[keyof typeof Tiers];
|
||||
export type Cadence = (typeof Cadences)[keyof typeof Cadences];
|
||||
export type Product = (typeof Products)[keyof typeof Products];
|
||||
|
||||
export type Prices = {
|
||||
[Cadences.Annually]: number;
|
||||
[Cadences.Monthly]?: number;
|
||||
};
|
||||
|
||||
export interface Trial {
|
||||
organization: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
product: Product;
|
||||
tier: Tier;
|
||||
length: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TrialBillingStepService {
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private apiService: ApiService,
|
||||
private organizationBillingService: OrganizationBillingServiceAbstraction,
|
||||
private taxClient: TaxClient,
|
||||
) {}
|
||||
|
||||
private plans$ = from(this.apiService.getPlans()).pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
getPrices$ = (product: Product, tier: Tier) =>
|
||||
this.plans$.pipe(
|
||||
map((plans) => {
|
||||
switch (tier) {
|
||||
case "families": {
|
||||
const annually = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually);
|
||||
return {
|
||||
annually: annually!.PasswordManager.basePrice,
|
||||
};
|
||||
}
|
||||
case "teams":
|
||||
case "enterprise": {
|
||||
const annually = plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(tier === "teams" ? PlanType.TeamsAnnually : PlanType.EnterpriseAnnually),
|
||||
);
|
||||
const monthly = plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(tier === "teams" ? PlanType.TeamsMonthly : PlanType.EnterpriseMonthly),
|
||||
);
|
||||
switch (product) {
|
||||
case "passwordManager": {
|
||||
return {
|
||||
annually: annually!.PasswordManager.seatPrice,
|
||||
monthly: monthly!.PasswordManager.seatPrice,
|
||||
};
|
||||
}
|
||||
case "secretsManager": {
|
||||
return {
|
||||
annually: annually!.SecretsManager.seatPrice,
|
||||
monthly: monthly!.SecretsManager.seatPrice,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
getCosts = async (
|
||||
product: Product,
|
||||
tier: Tier,
|
||||
cadence: Cadence,
|
||||
billingAddressControls: BillingAddressControls,
|
||||
): Promise<{
|
||||
tax: number;
|
||||
total: number;
|
||||
}> => {
|
||||
const billingAddress = getBillingAddressFromControls(billingAddressControls);
|
||||
return await this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
|
||||
{
|
||||
tier,
|
||||
cadence,
|
||||
passwordManager: {
|
||||
seats: 1,
|
||||
additionalStorage: 0,
|
||||
sponsored: false,
|
||||
},
|
||||
secretsManager:
|
||||
product === "secretsManager"
|
||||
? {
|
||||
seats: 1,
|
||||
additionalServiceAccounts: 0,
|
||||
standalone: true,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
billingAddress,
|
||||
);
|
||||
};
|
||||
|
||||
startTrial = async (
|
||||
trial: Trial,
|
||||
cadence: Cadence,
|
||||
billingAddress: BillingAddressControls,
|
||||
paymentMethod: TokenizedPaymentMethod,
|
||||
): Promise<OrganizationResponse> => {
|
||||
const getPlanType = async (tier: Tier, cadence: Cadence) => {
|
||||
const plans = await firstValueFrom(this.plans$);
|
||||
switch (tier) {
|
||||
case "families":
|
||||
return plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!.type;
|
||||
case "teams":
|
||||
return plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(cadence === "annually" ? PlanType.TeamsAnnually : PlanType.TeamsMonthly),
|
||||
)!.type;
|
||||
case "enterprise":
|
||||
return plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(cadence === "annually" ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly),
|
||||
)!.type;
|
||||
}
|
||||
};
|
||||
|
||||
const legacyPaymentMethod: [string, PaymentMethodType] = [
|
||||
paymentMethod.token,
|
||||
tokenizablePaymentMethodToLegacyEnum(paymentMethod.type),
|
||||
];
|
||||
const planType = await getPlanType(trial.tier, cadence);
|
||||
|
||||
const request: SubscriptionInformation = {
|
||||
organization: {
|
||||
name: trial.organization.name,
|
||||
billingEmail: trial.organization.email,
|
||||
initiationPath:
|
||||
trial.product === "passwordManager"
|
||||
? "Password Manager trial from marketing website"
|
||||
: "Secrets Manager trial from marketing website",
|
||||
},
|
||||
plan:
|
||||
trial.product === "passwordManager"
|
||||
? { type: planType, passwordManagerSeats: 1 }
|
||||
: {
|
||||
type: planType,
|
||||
passwordManagerSeats: 1,
|
||||
subscribeToSecretsManager: true,
|
||||
isFromSecretsManagerTrial: true,
|
||||
secretsManagerSeats: 1,
|
||||
},
|
||||
payment: {
|
||||
paymentMethod: legacyPaymentMethod,
|
||||
billing: {
|
||||
country: billingAddress.country,
|
||||
postalCode: billingAddress.postalCode,
|
||||
taxId: billingAddress.taxId ?? undefined,
|
||||
},
|
||||
skipTrial: trial.length === 0,
|
||||
},
|
||||
};
|
||||
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
return await this.organizationBillingService.purchaseSubscription(request, activeUserId);
|
||||
};
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import { InputPasswordComponent } from "@bitwarden/auth/angular";
|
||||
import { FormFieldModule } from "@bitwarden/components";
|
||||
|
||||
import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module";
|
||||
import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiation/trial-billing-step.component";
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
import { CompleteTrialInitiationComponent } from "./complete-trial-initiation/complete-trial-initiation.component";
|
||||
import { ConfirmationDetailsComponent } from "./confirmation-details.component";
|
||||
import { TrialBillingStepComponent } from "./trial-billing-step/trial-billing-step.component";
|
||||
import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module";
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -5,8 +5,6 @@ import { filter, firstValueFrom, map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
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 { MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BannerModule } from "@bitwarden/components";
|
||||
@@ -41,7 +39,6 @@ export class VaultBannersComponent implements OnInit {
|
||||
private router: Router,
|
||||
private accountService: AccountService,
|
||||
private messageListener: MessageListener,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.premiumBannerVisible$ = this.activeUserId$.pipe(
|
||||
filter((userId): userId is UserId => userId != null),
|
||||
@@ -75,16 +72,12 @@ export class VaultBannersComponent implements OnInit {
|
||||
}
|
||||
|
||||
async navigateToPaymentMethod(organizationId: string): Promise<void> {
|
||||
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", route],
|
||||
["organizations", organizationId, "billing", "payment-details"],
|
||||
navigationExtras,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user