1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-02-06 23:51:28 +01:00

[PM-11730] Remove feature flag: AC-2476-deprecate-stripe-sources-api (#13032)

* Remove FF from trial-billing-step.component

* Remove FF from user-subscription.component

* Remove FF from individual-billing-routing.module

* Remove FF from organization-billing.service

* Remove FF from organization-subscription-cloud.component

* Remove FF from organization-billing-routing.mdoule

* Remove FF from organization-plans.component

* Remove FF from change-plan-dialog.component

* Remove FF

* Remove legacy payment.component

* Rename V2: adjust-payment-dialog.component

* Rename V2: adjust-storage-dialog.component

* Rename V2: payment-label.component

* Rename V2: payment.component

* Rename V2: premium.component

* Patrick's feedback
This commit is contained in:
Alex Morask 2025-01-24 13:38:44 -05:00 committed by GitHub
parent 315e1338d5
commit f630ee5f4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 802 additions and 2220 deletions

View File

@ -10,7 +10,7 @@ import { RegisterFormModule } from "../../auth/register-form/register-form.modul
import { SecretsManagerTrialFreeStepperComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component";
import { SecretsManagerTrialPaidStepperComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component";
import { SecretsManagerTrialComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial.component";
import { PaymentComponent, TaxInfoComponent } from "../../billing";
import { TaxInfoComponent } from "../../billing";
import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiation/trial-billing-step.component";
import { EnvironmentSelectorModule } from "../../components/environment-selector/environment-selector.module";
import { SharedModule } from "../../shared";
@ -51,7 +51,6 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
RegisterFormModule,
OrganizationCreateModule,
EnvironmentSelectorModule,
PaymentComponent,
TaxInfoComponent,
TrialBillingStepComponent,
InputPasswordComponent,

View File

@ -49,15 +49,7 @@
</div>
<div class="tw-mb-4">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2>
<app-payment
*ngIf="!deprecateStripeSourcesAPI"
[hideCredit]="true"
[trialFlow]="true"
></app-payment>
<app-payment-v2
*ngIf="deprecateStripeSourcesAPI"
[showAccountCredit]="false"
></app-payment-v2>
<app-payment [showAccountCredit]="false"></app-payment>
<app-tax-info [trialFlow]="true" (countryChanged)="changedCountry()"></app-tax-info>
</div>
<div class="tw-flex tw-space-x-2">

View File

@ -13,14 +13,12 @@ import {
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PaymentMethodType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ToastService } from "@bitwarden/components";
import { BillingSharedModule, PaymentComponent, TaxInfoComponent } from "../../shared";
import { PaymentV2Component } from "../../shared/payment/payment-v2.component";
import { BillingSharedModule, TaxInfoComponent } from "../../shared";
import { PaymentComponent } from "../../shared/payment/payment.component";
export type TrialOrganizationType = Exclude<ProductTierType, ProductTierType.Free>;
@ -53,7 +51,6 @@ export enum SubscriptionProduct {
})
export class TrialBillingStepComponent implements OnInit {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(PaymentV2Component) paymentV2Component: PaymentV2Component;
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
@Input() organizationInfo: OrganizationInfo;
@Input() subscriptionProduct: SubscriptionProduct = SubscriptionProduct.PasswordManager;
@ -74,11 +71,8 @@ export class TrialBillingStepComponent implements OnInit {
annualPlan?: PlanResponse;
monthlyPlan?: PlanResponse;
deprecateStripeSourcesAPI: boolean;
constructor(
private apiService: ApiService,
private configService: ConfigService,
private i18nService: I18nService,
private formBuilder: FormBuilder,
private messagingService: MessagingService,
@ -87,9 +81,6 @@ export class TrialBillingStepComponent implements OnInit {
) {}
async ngOnInit(): Promise<void> {
this.deprecateStripeSourcesAPI = await this.configService.getFeatureFlag(
FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
);
const plans = await this.apiService.getPlans();
this.applicablePlans = plans.data.filter(this.isApplicable);
this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual);
@ -124,23 +115,12 @@ export class TrialBillingStepComponent implements OnInit {
}
protected changedCountry() {
if (this.deprecateStripeSourcesAPI) {
this.paymentV2Component.showBankAccount = this.taxInfoComponent.country === "US";
if (
!this.paymentV2Component.showBankAccount &&
this.paymentV2Component.selected === PaymentMethodType.BankAccount
) {
this.paymentV2Component.select(PaymentMethodType.Card);
}
} else {
this.paymentComponent.hideBank = this.taxInfoComponent.taxFormGroup.value.country !== "US";
if (
this.paymentComponent.hideBank &&
this.paymentComponent.method === PaymentMethodType.BankAccount
) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
this.paymentComponent.showBankAccount = this.taxInfoComponent.country === "US";
if (
!this.paymentComponent.showBankAccount &&
this.paymentComponent.selected === PaymentMethodType.BankAccount
) {
this.paymentComponent.select(PaymentMethodType.Card);
}
}
@ -162,14 +142,8 @@ export class TrialBillingStepComponent implements OnInit {
private async createOrganization(): Promise<string> {
const planResponse = this.findPlanFor(this.formGroup.value.cadence);
let paymentMethod: [string, PaymentMethodType];
if (this.deprecateStripeSourcesAPI) {
const { type, token } = await this.paymentV2Component.tokenize();
paymentMethod = [token, type];
} else {
paymentMethod = await this.paymentComponent.createPaymentToken();
}
const { type, token } = await this.paymentComponent.tokenize();
const paymentMethod: [string, PaymentMethodType] = [token, type];
const organization: OrganizationInformation = {
name: this.organizationInfo.name,

View File

@ -1,2 +1,2 @@
export { OrganizationPlansComponent } from "./organizations";
export { PaymentComponent, TaxInfoComponent } from "./shared";
export { TaxInfoComponent } from "./shared";

View File

@ -1,13 +1,9 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { PaymentMethodComponent } from "../shared";
import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { PremiumV2Component } from "./premium/premium-v2.component";
import { PremiumComponent } from "./premium/premium.component";
import { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
@ -24,15 +20,11 @@ const routes: Routes = [
component: UserSubscriptionComponent,
data: { titleId: "premiumMembership" },
},
...featureFlaggedRoute({
defaultComponent: PremiumComponent,
flaggedComponent: PremiumV2Component,
featureFlag: FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
routeOptions: {
path: "premium",
data: { titleId: "goPremium" },
},
}),
{
path: "premium",
component: PremiumComponent,
data: { titleId: "goPremium" },
},
{
path: "payment-method",
component: PaymentMethodComponent,

View File

@ -5,7 +5,6 @@ import { BillingSharedModule } from "../shared";
import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { IndividualBillingRoutingModule } from "./individual-billing-routing.module";
import { PremiumV2Component } from "./premium/premium-v2.component";
import { PremiumComponent } from "./premium/premium.component";
import { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
@ -17,7 +16,6 @@ import { UserSubscriptionComponent } from "./user-subscription.component";
BillingHistoryViewComponent,
UserSubscriptionComponent,
PremiumComponent,
PremiumV2Component,
],
})
export class IndividualBillingModule {}

View File

@ -1,149 +0,0 @@
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<ng-container *ngIf="!(useLicenseUploaderComponent$ | async)">
<p bitTypography="body1">{{ "uploadLicenseFilePremium" | i18n }}</p>
<form [formGroup]="licenseFormGroup" [bitSubmit]="submitPremiumLicense">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div>
<button type="button" bitButton buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{
licenseFormGroup.value.file ? licenseFormGroup.value.file.name : ("noFileChosen" | i18n)
}}
</div>
<input
bitInput
#fileSelector
type="file"
formControlName="file"
(change)="onLicenseFileSelected($event)"
hidden
class="tw-hidden"
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }}</bit-hint>
</bit-form-field>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</form>
</ng-container>
<individual-self-hosting-license-uploader
*ngIf="useLicenseUploaderComponent$ | async"
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="addOnFormGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB &times;
{{ storageGBPrice | currency: "$" }} =
{{ additionalStorageCost | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<app-payment-v2 [showBankAccount]="false"></app-payment-v2>
<app-tax-info (taxInformationChanged)="onTaxInformationChanged()"></app-tax-info>
<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>
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>

View File

@ -1,228 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, ViewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, concatMap, from, Observable, of, switchMap } from "rxjs";
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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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 { PaymentV2Component } from "../../shared/payment/payment-v2.component";
import { TaxInfoComponent } from "../../shared/tax-info.component";
@Component({
templateUrl: "./premium-v2.component.html",
})
export class PremiumV2Component {
@ViewChild(PaymentV2Component) paymentComponent: PaymentV2Component;
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
protected addOnFormGroup = new FormGroup({
additionalStorage: new FormControl<number>(0, [Validators.min(0), Validators.max(99)]),
});
protected licenseFormGroup = new FormGroup({
file: new FormControl<File>(null, [Validators.required]),
});
protected cloudWebVaultURL: string;
protected isSelfHost = false;
protected useLicenseUploaderComponent$ = this.configService.getFeatureFlag$(
FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader,
);
protected estimatedTax: number = 0;
protected readonly familyPlanMaxUserCount = 6;
protected readonly premiumPrice = 10;
protected readonly storageGBPrice = 4;
constructor(
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,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id),
),
);
combineLatest([
this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumPersonally$(account.id),
),
),
this.environmentService.cloudWebVaultUrl$,
])
.pipe(
takeUntilDestroyed(),
concatMap(([hasPremiumPersonally, cloudWebVaultURL]) => {
if (hasPremiumPersonally) {
return from(this.navigateToSubscriptionPage());
}
this.cloudWebVaultURL = cloudWebVaultURL;
return of(true);
}),
)
.subscribe();
this.addOnFormGroup.controls.additionalStorage.valueChanges
.pipe(debounceTime(1000), takeUntilDestroyed())
.subscribe(() => {
this.refreshSalesTax();
});
}
finalizeUpgrade = async () => {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
};
postFinalizeUpgrade = async () => {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("premiumUpdated"),
});
await this.navigateToSubscriptionPage();
};
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) {
return;
}
const { type, token } = await this.paymentComponent.tokenize();
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);
await this.apiService.postPremium(formData);
await this.finalizeUpgrade();
await this.postFinalizeUpgrade();
};
protected get additionalStorageCost(): number {
return this.storageGBPrice * this.addOnFormGroup.value.additionalStorage;
}
protected get premiumURL(): string {
return `${this.cloudWebVaultURL}/#/settings/subscription/premium`;
}
protected get subtotal(): number {
return this.premiumPrice + this.additionalStorageCost;
}
protected get total(): number {
return this.subtotal + this.estimatedTax;
}
protected async onLicenseFileSelectedChanged(): Promise<void> {
await this.postFinalizeUpgrade();
}
private refreshSalesTax(): void {
if (!this.taxInfoComponent.country || !this.taxInfoComponent.postalCode) {
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),
});
});
}
protected onTaxInformationChanged(): void {
this.refreshSalesTax();
}
}

View File

@ -1,8 +1,8 @@
<bit-section>
<h2 *ngIf="!selfHosted" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="canAccessPremium$ | async"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
@ -40,7 +40,7 @@
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !selfHosted }">
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
@ -49,49 +49,58 @@
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>{{ "bitwardenFamiliesPlan" | i18n }}</a
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ this.cloudWebVaultUrl }}/#/settings/subscription/premium"
href="{{ premiumURL }}}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="selfHosted"
*ngIf="isSelfHost"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="selfHosted">
<p bitTypography="body1">{{ "uploadLicenseFilePremium" | i18n }}</p>
<form [formGroup]="licenseForm" [bitSubmit]="submit">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div class="tw-pt-2 tw-pb-1">
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{ this.licenseFile ? this.licenseFile.name : ("noFileChosen" | i18n) }}
</div>
<input
bitInput
#fileSelector
type="file"
formControlName="file"
(change)="setSelectedFile($event)"
hidden
class="tw-hidden"
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }}</bit-hint>
</bit-form-field>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</form>
<bit-section *ngIf="isSelfHost">
<ng-container *ngIf="!(useLicenseUploaderComponent$ | async)">
<p bitTypography="body1">{{ "uploadLicenseFilePremium" | i18n }}</p>
<form [formGroup]="licenseFormGroup" [bitSubmit]="submitPremiumLicense">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div>
<button type="button" bitButton buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{
licenseFormGroup.value.file ? licenseFormGroup.value.file.name : ("noFileChosen" | i18n)
}}
</div>
<input
bitInput
#fileSelector
type="file"
formControlName="file"
(change)="onLicenseFileSelected($event)"
hidden
class="tw-hidden"
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }}</bit-hint>
</bit-form-field>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</form>
</ng-container>
<individual-self-hosting-license-uploader
*ngIf="useLicenseUploaderComponent$ | async"
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form [formGroup]="addonForm" [bitSubmit]="submit" *ngIf="!selfHosted">
<form *ngIf="!isSelfHost" [formGroup]="addOnFormGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
@ -106,7 +115,7 @@
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storageGbPrice | currency: "$") : ("year" | i18n)
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
@ -114,30 +123,26 @@
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ additionalStorage || 0 }} GB &times;
{{ storageGbPrice | currency: "$" }} =
{{ additionalStorageTotal | currency: "$" }}
{{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB &times;
{{ storageGBPrice | currency: "$" }} =
{{ additionalStorageCost | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<app-payment [hideBank]="true"></app-payment>
<app-tax-info (taxInformationChanged)="onTaxInformationChanged()" />
<div id="price" class="tw-my-4">
<div class="tw-text-muted tw-text-sm">
{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}
<br />
<ng-container>
{{ "estimatedTax" | i18n }}: {{ taxCharges | currency: "USD $" }}
</ng-container>
<app-payment [showBankAccount]="false"></app-payment>
<app-tax-info (taxInformationChanged)="onTaxInformationChanged()"></app-tax-info>
<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>
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
</div>
<p bitTypography="body2">{{ "paymentChargedAnnually" | i18n }}</p>
<button type="submit" bitButton bitFormButton>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</bit-section>

View File

@ -1,187 +1,197 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit, ViewChild } from "@angular/core";
import { Component, ViewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { firstValueFrom, Observable, switchMap } from "rxjs";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, concatMap, from, Observable, of, switchMap } from "rxjs";
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/account/billing-account-profile-state.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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { PaymentComponent, TaxInfoComponent } from "../../shared";
import { PaymentComponent } from "../../shared/payment/payment.component";
import { TaxInfoComponent } from "../../shared/tax-info.component";
@Component({
templateUrl: "premium.component.html",
templateUrl: "./premium.component.html",
})
export class PremiumComponent implements OnInit {
export class PremiumComponent {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
canAccessPremium$: Observable<boolean>;
selfHosted = false;
premiumPrice = 10;
familyPlanMaxUserCount = 6;
storageGbPrice = 4;
cloudWebVaultUrl: string;
licenseFile: File = null;
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
formPromise: Promise<any>;
protected licenseForm = new FormGroup({
file: new FormControl(null, [Validators.required]),
});
protected addonForm = new FormGroup({
additionalStorage: new FormControl(0, [Validators.max(99), Validators.min(0)]),
protected addOnFormGroup = new FormGroup({
additionalStorage: new FormControl<number>(0, [Validators.min(0), Validators.max(99)]),
});
private estimatedTax: number = 0;
protected licenseFormGroup = new FormGroup({
file: new FormControl<File>(null, [Validators.required]),
});
protected cloudWebVaultURL: string;
protected isSelfHost = false;
protected useLicenseUploaderComponent$ = this.configService.getFeatureFlag$(
FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader,
);
protected estimatedTax: number = 0;
protected readonly familyPlanMaxUserCount = 6;
protected readonly premiumPrice = 10;
protected readonly storageGBPrice = 4;
constructor(
private activatedRoute: ActivatedRoute,
private apiService: ApiService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private configService: ConfigService,
private environmentService: EnvironmentService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private tokenService: TokenService,
private router: Router,
private messagingService: MessagingService,
private syncService: SyncService,
private environmentService: EnvironmentService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private toastService: ToastService,
private tokenService: TokenService,
private taxService: TaxServiceAbstraction,
private accountService: AccountService,
) {
this.selfHosted = platformUtilsService.isSelfHost();
this.canAccessPremium$ = this.accountService.activeAccount$.pipe(
this.isSelfHost = this.platformUtilsService.isSelfHost();
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id),
),
);
this.addonForm.controls.additionalStorage.valueChanges
combineLatest([
this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumPersonally$(account.id),
),
),
this.environmentService.cloudWebVaultUrl$,
])
.pipe(
takeUntilDestroyed(),
concatMap(([hasPremiumPersonally, cloudWebVaultURL]) => {
if (hasPremiumPersonally) {
return from(this.navigateToSubscriptionPage());
}
this.cloudWebVaultURL = cloudWebVaultURL;
return of(true);
}),
)
.subscribe();
this.addOnFormGroup.controls.additionalStorage.valueChanges
.pipe(debounceTime(1000), takeUntilDestroyed())
.subscribe(() => {
this.refreshSalesTax();
});
}
protected setSelectedFile(event: Event) {
const fileInputEl = <HTMLInputElement>event.target;
const file: File = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null;
this.licenseFile = file;
}
async ngOnInit() {
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
const account = await firstValueFrom(this.accountService.activeAccount$);
if (
await firstValueFrom(this.billingAccountProfileStateService.hasPremiumPersonally$(account.id))
) {
// 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/user-subscription"]);
return;
}
}
submit = async () => {
if (this.taxInfoComponent) {
if (!this.taxInfoComponent?.taxFormGroup.valid) {
this.taxInfoComponent.taxFormGroup.markAllAsTouched();
return;
}
}
this.licenseForm.markAllAsTouched();
this.addonForm.markAllAsTouched();
if (this.selfHosted) {
if (this.licenseFile == null) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("selectFile"),
});
return;
}
}
if (this.selfHosted) {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
if (!this.tokenService.getEmailVerified()) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("verifyEmailFirst"),
});
return;
}
const fd = new FormData();
fd.append("license", this.licenseFile);
await this.apiService.postAccountLicense(fd).then(() => {
return this.finalizePremium();
});
} else {
await this.paymentComponent
.createPaymentToken()
.then((result) => {
const fd = new FormData();
fd.append("paymentMethodType", result[1].toString());
if (result[0] != null) {
fd.append("paymentToken", result[0]);
}
fd.append("additionalStorageGb", (this.additionalStorage || 0).toString());
fd.append("country", this.taxInfoComponent?.taxFormGroup?.value.country);
fd.append("postalCode", this.taxInfoComponent?.taxFormGroup?.value.postalCode);
return this.apiService.postPremium(fd);
})
.then((paymentResponse) => {
if (!paymentResponse.success && paymentResponse.paymentIntentClientSecret != null) {
return this.paymentComponent.handleStripeCardPayment(
paymentResponse.paymentIntentClientSecret,
() => this.finalizePremium(),
);
} else {
return this.finalizePremium();
}
});
}
};
async finalizePremium() {
finalizeUpgrade = async () => {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
};
postFinalizeUpgrade = async () => {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("premiumUpdated"),
});
await this.router.navigate(["/settings/subscription/user-subscription"]);
await this.navigateToSubscriptionPage();
};
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) {
return;
}
const { type, token } = await this.paymentComponent.tokenize();
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);
await this.apiService.postPremium(formData);
await this.finalizeUpgrade();
await this.postFinalizeUpgrade();
};
protected get additionalStorageCost(): number {
return this.storageGBPrice * this.addOnFormGroup.value.additionalStorage;
}
get additionalStorage(): number {
return this.addonForm.get("additionalStorage").value;
}
get additionalStorageTotal(): number {
return this.storageGbPrice * Math.abs(this.additionalStorage || 0);
protected get premiumURL(): string {
return `${this.cloudWebVaultURL}/#/settings/subscription/premium`;
}
get subtotal(): number {
return this.premiumPrice + this.additionalStorageTotal;
protected get subtotal(): number {
return this.premiumPrice + this.additionalStorageCost;
}
get taxCharges(): number {
return this.estimatedTax;
protected get total(): number {
return this.subtotal + this.estimatedTax;
}
get total(): number {
return this.subtotal + this.taxCharges || 0;
protected async onLicenseFileSelectedChanged(): Promise<void> {
await this.postFinalizeUpgrade();
}
private refreshSalesTax(): void {
@ -190,7 +200,7 @@ export class PremiumComponent implements OnInit {
}
const request: PreviewIndividualInvoiceRequest = {
passwordManager: {
additionalStorage: this.addonForm.value.additionalStorage,
additionalStorage: this.addOnFormGroup.value.additionalStorage,
},
taxInformation: {
postalCode: this.taxInfoComponent.postalCode,

View File

@ -8,8 +8,6 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -18,12 +16,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { DialogService, ToastService } from "@bitwarden/components";
import {
AdjustStorageDialogV2Component,
AdjustStorageDialogV2ResultType,
} from "../shared/adjust-storage-dialog/adjust-storage-dialog-v2.component";
import {
AdjustStorageDialogResult,
openAdjustStorageDialog,
AdjustStorageDialogComponent,
AdjustStorageDialogResultType,
} from "../shared/adjust-storage-dialog/adjust-storage-dialog.component";
import {
OffboardingSurveyDialogResultType,
@ -45,10 +39,6 @@ export class UserSubscriptionComponent implements OnInit {
cancelPromise: Promise<any>;
reinstatePromise: Promise<any>;
protected deprecateStripeSourcesAPI$ = this.configService.getFeatureFlag$(
FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
);
constructor(
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
@ -60,7 +50,6 @@ export class UserSubscriptionComponent implements OnInit {
private environmentService: EnvironmentService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private toastService: ToastService,
private configService: ConfigService,
private accountService: AccountService,
) {
this.selfHosted = this.platformUtilsService.isSelfHost();
@ -166,33 +155,18 @@ export class UserSubscriptionComponent implements OnInit {
};
adjustStorage = async (add: boolean) => {
const deprecateStripeSourcesAPI = await firstValueFrom(this.deprecateStripeSourcesAPI$);
const dialogRef = AdjustStorageDialogComponent.open(this.dialogService, {
data: {
price: 4,
cadence: "year",
type: add ? "Add" : "Remove",
},
});
if (deprecateStripeSourcesAPI) {
const dialogRef = AdjustStorageDialogV2Component.open(this.dialogService, {
data: {
price: 4,
cadence: "year",
type: add ? "Add" : "Remove",
},
});
const result = await lastValueFrom(dialogRef.closed);
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustStorageDialogV2ResultType.Submitted) {
await this.load();
}
} else {
const dialogRef = openAdjustStorageDialog(this.dialogService, {
data: {
storageGbPrice: 4,
add: add,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustStorageDialogResult.Adjusted) {
await this.load();
}
if (result === AdjustStorageDialogResultType.Submitted) {
await this.load();
}
};

View File

@ -346,25 +346,20 @@
"
>
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
{{
deprecateStripeSourcesAPI
? paymentSource?.description
: billing?.paymentSource?.description
}}
{{ paymentSource?.description }}
<span class="ml-2 tw-text-primary-600 tw-cursor-pointer" (click)="toggleShowPayment()">
{{ "changePaymentMethod" | i18n }}
</span>
<a></a>
</p>
<ng-container *ngIf="canUpdatePaymentInformation()">
<app-payment *ngIf="!deprecateStripeSourcesAPI" [hideCredit]="true" />
<app-payment-v2 *ngIf="deprecateStripeSourcesAPI" [showAccountCredit]="false" />
<app-payment [showAccountCredit]="false" />
<app-manage-tax-information
[startWith]="taxInformation"
(taxInformationChanged)="taxInformationChanged($event)"
></app-manage-tax-information>
</ng-container>
<div id="price" class="tw-mt-4">
<div class="tw-mt-4">
<p class="tw-text-lg tw-mb-1">
<span class="tw-font-semibold"
>{{ "total" | i18n }}:
@ -962,7 +957,7 @@
</p>
</bit-hint>
</div>
<div *ngIf="totalOpened" id="price" class="row tw-mt-4">
<div *ngIf="totalOpened" class="row tw-mt-4">
<bit-hint class="col-6">
<p
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"

View File

@ -45,16 +45,13 @@ import {
} 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 { 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 { 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
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 { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
@ -62,7 +59,6 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { BillingSharedModule } from "../shared/billing-shared.module";
import { PaymentV2Component } from "../shared/payment/payment-v2.component";
import { PaymentComponent } from "../shared/payment/payment.component";
type ChangePlanDialogParams = {
@ -107,7 +103,6 @@ interface OnSuccessArgs {
})
export class ChangePlanDialogComponent implements OnInit, OnDestroy {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(PaymentV2Component) paymentV2Component: PaymentV2Component;
@ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent;
@Input() acceptingSponsorship = false;
@ -183,13 +178,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
showPayment: boolean = false;
totalOpened: boolean = false;
currentPlan: PlanResponse;
currentFocusIndex = 0;
isCardStateDisabled = false;
focusedIndex: number | null = null;
accountCredit: number;
paymentSource?: PaymentSourceResponse;
plans: ListResponse<PlanResponse>;
deprecateStripeSourcesAPI: boolean;
isSubscriptionCanceled: boolean = false;
private destroy$ = new Subject<void>();
@ -210,7 +203,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
private messagingService: MessagingService,
private formBuilder: FormBuilder,
private organizationApiService: OrganizationApiServiceAbstraction,
private configService: ConfigService,
private billingApiService: BillingApiServiceAbstraction,
private taxService: TaxServiceAbstraction,
private accountService: AccountService,
@ -218,10 +210,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
) {}
async ngOnInit(): Promise<void> {
this.deprecateStripeSourcesAPI = await this.configService.getFeatureFlag(
FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
);
if (this.dialogParams.organizationId) {
this.currentPlanName = this.resolvePlanName(this.dialogParams.productTierType);
this.sub =
@ -239,14 +227,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId)),
);
if (this.deprecateStripeSourcesAPI) {
const { accountCredit, paymentSource } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
} else {
this.billing = await this.organizationApiService.getBilling(this.organizationId);
}
const { accountCredit, paymentSource } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
}
if (!this.selfHosted) {
@ -333,16 +317,8 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
return this.selectableProducts.find((product) => product.productTier === productTier);
}
secretsManagerTrialDiscount() {
return this.sub?.customerDiscount?.appliesTo?.includes("sm-standalone")
? this.discountPercentage
: this.discountPercentageFromSub + this.discountPercentage;
}
isPaymentSourceEmpty() {
return this.deprecateStripeSourcesAPI
? this.paymentSource === null || this.paymentSource === undefined
: this.billing?.paymentSource === null || this.billing?.paymentSource === undefined;
return this.paymentSource === null || this.paymentSource === undefined;
}
isSecretsManagerTrial(): boolean {
@ -486,9 +462,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
get upgradeRequiresPaymentMethod() {
const isFreeTier = this.organization?.productTierType === ProductTierType.Free;
const shouldHideFree = !this.showFree;
const hasNoPaymentSource = this.deprecateStripeSourcesAPI
? !this.paymentSource
: !this.billing?.paymentSource;
const hasNoPaymentSource = !this.paymentSource;
return isFreeTier && shouldHideFree && hasNoPaymentSource;
}
@ -721,25 +695,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
changedCountry() {
if (this.deprecateStripeSourcesAPI && this.paymentV2Component) {
this.paymentV2Component.showBankAccount = this.taxInformation.country === "US";
this.paymentComponent.showBankAccount = this.taxInformation.country === "US";
if (
!this.paymentV2Component.showBankAccount &&
this.paymentV2Component.selected === PaymentMethodType.BankAccount
) {
this.paymentV2Component.select(PaymentMethodType.Card);
}
} else if (this.paymentComponent && this.taxInformation) {
this.paymentComponent!.hideBank = this.taxInformation.country !== "US";
// Bank Account payments are only available for US customers
if (
this.paymentComponent.hideBank &&
this.paymentComponent.method === PaymentMethodType.BankAccount
) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
if (
!this.paymentComponent.showBankAccount &&
this.paymentComponent.selected === PaymentMethodType.BankAccount
) {
this.paymentComponent.select(PaymentMethodType.Card);
}
}
@ -821,14 +783,8 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
plan.secretsManagerSeats = org.smSeats;
}
let paymentMethod: [string, PaymentMethodType];
if (this.deprecateStripeSourcesAPI) {
const { type, token } = await this.paymentV2Component.tokenize();
paymentMethod = [token, type];
} else {
paymentMethod = await this.paymentComponent.createPaymentToken();
}
const { type, token } = await this.paymentComponent.tokenize();
const paymentMethod: [string, PaymentMethodType] = [token, type];
const payment: PaymentInformation = {
paymentMethod,
@ -864,27 +820,17 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
this.buildSecretsManagerRequest(request);
if (this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty()) {
if (this.deprecateStripeSourcesAPI) {
const tokenizedPaymentSource = await this.paymentV2Component.tokenize();
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
updatePaymentMethodRequest.paymentSource = tokenizedPaymentSource;
updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From(
this.taxInformation,
);
const tokenizedPaymentSource = await this.paymentComponent.tokenize();
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
updatePaymentMethodRequest.paymentSource = tokenizedPaymentSource;
updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From(
this.taxInformation,
);
await this.billingApiService.updateOrganizationPaymentMethod(
this.organizationId,
updatePaymentMethodRequest,
);
} else {
const tokenResult = await this.paymentComponent.createPaymentToken();
const paymentRequest = new PaymentRequest();
paymentRequest.paymentToken = tokenResult[0];
paymentRequest.paymentMethodType = tokenResult[1];
paymentRequest.country = this.taxInformation.country;
paymentRequest.postalCode = this.taxInformation.postalCode;
await this.organizationApiService.updatePayment(this.organizationId, paymentRequest);
}
await this.billingApiService.updateOrganizationPaymentMethod(
this.organizationId,
updatePaymentMethodRequest,
);
}
// Backfill pub/priv key if necessary
@ -894,10 +840,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
}
const result = await this.organizationApiService.upgrade(this.organizationId, request);
if (!result.success && result.paymentIntentClientSecret != null) {
await this.paymentComponent.handleStripeCardPayment(result.paymentIntentClientSecret, null);
}
await this.organizationApiService.upgrade(this.organizationId, request);
return this.organizationId;
}
@ -994,38 +937,20 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
get paymentSourceClasses() {
if (this.deprecateStripeSourcesAPI) {
if (this.paymentSource == null) {
if (this.paymentSource == null) {
return [];
}
switch (this.paymentSource.type) {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
return ["bwi-bank"];
case PaymentMethodType.Check:
return ["bwi-money"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:
return [];
}
switch (this.paymentSource.type) {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
return ["bwi-bank"];
case PaymentMethodType.Check:
return ["bwi-money"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:
return [];
}
} else {
if (this.billing.paymentSource == null) {
return [];
}
switch (this.billing.paymentSource.type) {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
return ["bwi-bank"];
case PaymentMethodType.Check:
return ["bwi-money"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:
return [];
}
}
}

View File

@ -1,14 +1,11 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { canAccessBillingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { organizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard";
import { organizationIsUnmanaged } from "../../billing/guards/organization-is-unmanaged.guard";
import { WebPlatformUtilsService } from "../../core/web-platform-utils.service";
import { PaymentMethodComponent } from "../shared";
import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component";
import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component";
@ -28,21 +25,17 @@ const routes: Routes = [
: OrganizationSubscriptionCloudComponent,
data: { titleId: "subscription" },
},
...featureFlaggedRoute({
defaultComponent: PaymentMethodComponent,
flaggedComponent: OrganizationPaymentMethodComponent,
featureFlag: FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
routeOptions: {
path: "payment-method",
canActivate: [
organizationPermissionsGuard((org) => org.canEditPaymentMethods),
organizationIsUnmanaged,
],
data: {
titleId: "paymentMethod",
},
{
path: "payment-method",
component: OrganizationPaymentMethodComponent,
canActivate: [
organizationPermissionsGuard((org) => org.canEditPaymentMethods),
organizationIsUnmanaged,
],
data: {
titleId: "paymentMethod",
},
}),
},
{
path: "history",
component: OrgBillingHistoryViewComponent,

View File

@ -433,13 +433,7 @@
<p class="tw-text-muted tw-italic tw-mb-3 tw-block" bitTypography="body2">
{{ paymentDesc }}
</p>
<app-payment
*ngIf="!deprecateStripeSourcesAPI && (createOrganization || upgradeRequiresPaymentMethod)"
[hideCredit]="true"
></app-payment>
<app-payment-v2
*ngIf="deprecateStripeSourcesAPI && (createOrganization || upgradeRequiresPaymentMethod)"
></app-payment-v2>
<app-payment *ngIf="createOrganization || upgradeRequiresPaymentMethod"></app-payment>
<app-manage-tax-information
class="tw-my-4"
[showTaxIdField]="showTaxIdField"
@ -465,9 +459,6 @@
}}
</p>
</div>
<ng-container *ngIf="!createOrganization">
<app-payment [showMethods]="false"></app-payment>
</ng-container>
</bit-section>
<bit-section *ngIf="singleOrgPolicyBlock">
<bit-callout type="danger" [title]="'error' | i18n">

View File

@ -36,7 +36,6 @@ import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/ta
import { PaymentMethodType, 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 { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.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";
@ -57,7 +56,6 @@ import { KeyService } from "@bitwarden/key-management";
import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module";
import { BillingSharedModule, secretsManagerSubscribeFormFactory } from "../shared";
import { PaymentV2Component } from "../shared/payment/payment-v2.component";
import { PaymentComponent } from "../shared/payment/payment.component";
interface OnSuccessArgs {
@ -79,7 +77,6 @@ const Allowed2020PlansForLegacyProviders = [
})
export class OrganizationPlansComponent implements OnInit, OnDestroy {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(PaymentV2Component) paymentV2Component: PaymentV2Component;
@ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent;
@Input() organizationId?: string;
@ -128,7 +125,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
singleOrgPolicyAppliesToActiveUser = false;
isInTrialFlow = false;
discount = 0;
deprecateStripeSourcesAPI: boolean;
protected useLicenseUploaderComponent$ = this.configService.getFeatureFlag$(
FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader,
@ -189,10 +185,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
}
async ngOnInit() {
this.deprecateStripeSourcesAPI = await this.configService.getFeatureFlag(
FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
);
if (this.organizationId) {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
@ -580,23 +572,12 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
}
protected changedCountry(): void {
if (this.deprecateStripeSourcesAPI) {
this.paymentV2Component.showBankAccount = this.taxInformation?.country === "US";
if (
!this.paymentV2Component.showBankAccount &&
this.paymentV2Component.selected === PaymentMethodType.BankAccount
) {
this.paymentV2Component.select(PaymentMethodType.Card);
}
} else {
this.paymentComponent.hideBank = this.taxInformation?.country !== "US";
if (
this.paymentComponent.hideBank &&
this.paymentComponent.method === PaymentMethodType.BankAccount
) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
this.paymentComponent.showBankAccount = this.taxInformation?.country === "US";
if (
!this.paymentComponent.showBankAccount &&
this.paymentComponent.selected === PaymentMethodType.BankAccount
) {
this.paymentComponent.select(PaymentMethodType.Card);
}
}
@ -751,25 +732,15 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.buildSecretsManagerRequest(request);
if (this.upgradeRequiresPaymentMethod) {
if (this.deprecateStripeSourcesAPI) {
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
updatePaymentMethodRequest.paymentSource = await this.paymentV2Component.tokenize();
updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From(
this.taxInformation,
);
await this.billingApiService.updateOrganizationPaymentMethod(
this.organizationId,
updatePaymentMethodRequest,
);
} else {
const [paymentToken, paymentMethodType] = await this.paymentComponent.createPaymentToken();
const paymentRequest = new PaymentRequest();
paymentRequest.paymentToken = paymentToken;
paymentRequest.paymentMethodType = paymentMethodType;
paymentRequest.country = this.taxInformation?.country;
paymentRequest.postalCode = this.taxInformation?.postalCode;
await this.organizationApiService.updatePayment(this.organizationId, paymentRequest);
}
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
updatePaymentMethodRequest.paymentSource = await this.paymentComponent.tokenize();
updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From(
this.taxInformation,
);
await this.billingApiService.updateOrganizationPaymentMethod(
this.organizationId,
updatePaymentMethodRequest,
);
}
// Backfill pub/priv key if necessary
@ -779,10 +750,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
}
const result = await this.organizationApiService.upgrade(this.organizationId, request);
if (!result.success && result.paymentIntentClientSecret != null) {
await this.paymentComponent.handleStripeCardPayment(result.paymentIntentClientSecret, null);
}
await this.organizationApiService.upgrade(this.organizationId, request);
return this.organizationId;
}
@ -803,14 +771,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
if (this.selectedPlan.type === PlanType.Free) {
request.planType = PlanType.Free;
} else {
let type: PaymentMethodType;
let token: string;
if (this.deprecateStripeSourcesAPI) {
({ type, token } = await this.paymentV2Component.tokenize());
} else {
[token, type] = await this.paymentComponent.createPaymentToken();
}
const { type, token } = await this.paymentComponent.tokenize();
request.paymentToken = token;
request.paymentMethodType = type;

View File

@ -25,12 +25,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { DialogService, ToastService } from "@bitwarden/components";
import {
AdjustStorageDialogV2Component,
AdjustStorageDialogV2ResultType,
} from "../shared/adjust-storage-dialog/adjust-storage-dialog-v2.component";
import {
AdjustStorageDialogResult,
openAdjustStorageDialog,
AdjustStorageDialogComponent,
AdjustStorageDialogResultType,
} from "../shared/adjust-storage-dialog/adjust-storage-dialog.component";
import {
OffboardingSurveyDialogResultType,
@ -55,7 +51,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
organizationId: string;
userOrg: Organization;
showChangePlan = false;
showDownloadLicense = false;
hasBillingSyncToken: boolean;
showAdjustSecretsManager = false;
showSecretsManagerSubscribe = false;
@ -70,10 +65,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
protected readonly subscriptionHiddenIcon = SubscriptionHiddenIcon;
protected readonly teamsStarter = ProductTierType.TeamsStarter;
protected deprecateStripeSourcesAPI$ = this.configService.getFeatureFlag$(
FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
);
private destroy$ = new Subject<void>();
constructor(
@ -426,36 +417,19 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
adjustStorage = (add: boolean) => {
return async () => {
const deprecateStripeSourcesAPI = await firstValueFrom(this.deprecateStripeSourcesAPI$);
const dialogRef = AdjustStorageDialogComponent.open(this.dialogService, {
data: {
price: this.storageGbPrice,
cadence: this.billingInterval,
type: add ? "Add" : "Remove",
organizationId: this.organizationId,
},
});
if (deprecateStripeSourcesAPI) {
const dialogRef = AdjustStorageDialogV2Component.open(this.dialogService, {
data: {
price: this.storageGbPrice,
cadence: this.billingInterval,
type: add ? "Add" : "Remove",
organizationId: this.organizationId,
},
});
const result = await lastValueFrom(dialogRef.closed);
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustStorageDialogV2ResultType.Submitted) {
await this.load();
}
} else {
const dialogRef = openAdjustStorageDialog(this.dialogService, {
data: {
storageGbPrice: this.storageGbPrice,
add: add,
organizationId: this.organizationId,
interval: this.billingInterval,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustStorageDialogResult.Adjusted) {
await this.load();
}
if (result === AdjustStorageDialogResultType.Submitted) {
await this.load();
}
};
};

View File

@ -30,9 +30,9 @@ import {
openAddCreditDialog,
} from "../../shared/add-credit-dialog.component";
import {
AdjustPaymentDialogV2Component,
AdjustPaymentDialogV2ResultType,
} from "../../shared/adjust-payment-dialog/adjust-payment-dialog-v2.component";
AdjustPaymentDialogComponent,
AdjustPaymentDialogResultType,
} from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component";
@Component({
templateUrl: "./organization-payment-method.component.html",
@ -159,7 +159,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
};
protected updatePaymentMethod = async (): Promise<void> => {
const dialogRef = AdjustPaymentDialogV2Component.open(this.dialogService, {
const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, {
data: {
initialPaymentMethod: this.paymentSource?.type,
organizationId: this.organizationId,
@ -169,13 +169,13 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustPaymentDialogV2ResultType.Submitted) {
if (result === AdjustPaymentDialogResultType.Submitted) {
await this.load();
}
};
changePayment = async () => {
const dialogRef = AdjustPaymentDialogV2Component.open(this.dialogService, {
const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, {
data: {
initialPaymentMethod: this.paymentSource?.type,
organizationId: this.organizationId,
@ -183,7 +183,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustPaymentDialogV2ResultType.Submitted) {
if (result === AdjustPaymentDialogResultType.Submitted) {
this.location.replaceState(this.location.path(), "", {});
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
await this.syncService.fullSync(true);

View File

@ -1,29 +0,0 @@
<bit-dialog dialogSize="large" [title]="dialogHeader">
<ng-container bitDialogContent>
<app-payment-v2
[showAccountCredit]="false"
[showBankAccount]="!!organizationId"
[initialPaymentMethod]="initialPaymentMethod"
></app-payment-v2>
<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>

View File

@ -1,179 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
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 { DialogService, ToastService } from "@bitwarden/components";
import { PaymentV2Component } from "../payment/payment-v2.component";
export interface AdjustPaymentDialogV2Params {
initialPaymentMethod?: PaymentMethodType;
organizationId?: string;
productTier?: ProductTierType;
}
export enum AdjustPaymentDialogV2ResultType {
Closed = "closed",
Submitted = "submitted",
}
@Component({
templateUrl: "./adjust-payment-dialog-v2.component.html",
})
export class AdjustPaymentDialogV2Component implements OnInit {
@ViewChild(PaymentV2Component) paymentComponent: PaymentV2Component;
@ViewChild(forwardRef(() => ManageTaxInformationComponent))
taxInfoComponent: ManageTaxInformationComponent;
protected readonly PaymentMethodType = PaymentMethodType;
protected readonly ResultType = AdjustPaymentDialogV2ResultType;
protected dialogHeader: string;
protected initialPaymentMethod: PaymentMethodType;
protected organizationId?: string;
protected productTier?: ProductTierType;
protected taxInformation: TaxInformation;
constructor(
private apiService: ApiService,
private billingApiService: BillingApiServiceAbstraction,
private organizationApiService: OrganizationApiServiceAbstraction,
@Inject(DIALOG_DATA) protected dialogParams: AdjustPaymentDialogV2Params,
private dialogRef: DialogRef<AdjustPaymentDialogV2ResultType>,
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;
}
ngOnInit(): void {
if (this.organizationId) {
this.organizationApiService
.getTaxInfo(this.organizationId)
.then((response: TaxInfoResponse) => {
this.taxInformation = TaxInformation.from(response);
})
.catch(() => {
this.taxInformation = new TaxInformation();
});
} else {
this.apiService
.getTaxInfo()
.then((response: TaxInfoResponse) => {
this.taxInformation = TaxInformation.from(response);
})
.catch(() => {
this.taxInformation = new TaxInformation();
});
}
}
taxInformationChanged(event: TaxInformation) {
this.taxInformation = event;
if (event.country === "US") {
this.paymentComponent.showBankAccount = !!this.organizationId;
} 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.updatePremiumUserPaymentMethod();
} else {
await this.updateOrganizationPaymentMethod();
}
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("updatedPaymentMethod"),
});
this.dialogRef.close(AdjustPaymentDialogV2ResultType.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);
};
protected get showTaxIdField(): boolean {
if (!this.organizationId) {
return false;
}
switch (this.productTier) {
case ProductTierType.Free:
case ProductTierType.Families:
return false;
default:
return true;
}
}
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);
};
static open = (
dialogService: DialogService,
dialogConfig: DialogConfig<AdjustPaymentDialogV2Params>,
) =>
dialogService.open<AdjustPaymentDialogV2ResultType>(
AdjustPaymentDialogV2Component,
dialogConfig,
);
}

View File

@ -1,30 +1,29 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog
dialogSize="large"
[title]="(currentType != null ? 'changePaymentMethod' : 'addPaymentMethod') | i18n"
>
<ng-container bitDialogContent>
<app-payment [hideBank]="!organizationId" [hideCredit]="true"></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">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="DialogResult.Cancelled"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>
<bit-dialog dialogSize="large" [title]="dialogHeader">
<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)"
/>
</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>

View File

@ -1,59 +1,66 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnInit, ViewChild } from "@angular/core";
import { FormGroup } from "@angular/forms";
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 { PaymentMethodType } from "@bitwarden/common/billing/enums";
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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { PaymentComponent } from "../payment/payment.component";
export interface AdjustPaymentDialogData {
organizationId: string;
currentType: PaymentMethodType;
export interface AdjustPaymentDialogParams {
initialPaymentMethod?: PaymentMethodType;
organizationId?: string;
productTier?: ProductTierType;
}
export enum AdjustPaymentDialogResult {
Adjusted = "adjusted",
Cancelled = "cancelled",
export enum AdjustPaymentDialogResultType {
Closed = "closed",
Submitted = "submitted",
}
@Component({
templateUrl: "adjust-payment-dialog.component.html",
templateUrl: "./adjust-payment-dialog.component.html",
})
export class AdjustPaymentDialogComponent implements OnInit {
@ViewChild(PaymentComponent, { static: true }) paymentComponent: PaymentComponent;
@ViewChild(ManageTaxInformationComponent) taxInfoComponent: ManageTaxInformationComponent;
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(forwardRef(() => ManageTaxInformationComponent))
taxInfoComponent: ManageTaxInformationComponent;
organizationId: string;
currentType: PaymentMethodType;
paymentMethodType = PaymentMethodType;
protected readonly PaymentMethodType = PaymentMethodType;
protected readonly ResultType = AdjustPaymentDialogResultType;
protected DialogResult = AdjustPaymentDialogResult;
protected formGroup = new FormGroup({});
protected dialogHeader: string;
protected initialPaymentMethod: PaymentMethodType;
protected organizationId?: string;
protected productTier?: ProductTierType;
protected taxInformation: TaxInformation;
constructor(
private dialogRef: DialogRef,
@Inject(DIALOG_DATA) protected data: AdjustPaymentDialogData,
private apiService: ApiService,
private i18nService: I18nService,
private billingApiService: BillingApiServiceAbstraction,
private organizationApiService: OrganizationApiServiceAbstraction,
private configService: ConfigService,
@Inject(DIALOG_DATA) protected dialogParams: AdjustPaymentDialogParams,
private dialogRef: DialogRef<AdjustPaymentDialogResultType>,
private i18nService: I18nService,
private toastService: ToastService,
) {
this.organizationId = data.organizationId;
this.currentType = data.currentType;
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;
}
ngOnInit(): void {
@ -78,65 +85,92 @@ export class AdjustPaymentDialogComponent implements OnInit {
}
}
submit = async () => {
if (!this.taxInfoComponent?.validate()) {
return;
}
const request = new PaymentRequest();
const response = this.paymentComponent.createPaymentToken().then((result) => {
request.paymentToken = result[0];
request.paymentMethodType = result[1];
request.postalCode = this.taxInformation?.postalCode;
request.country = this.taxInformation?.country;
request.taxId = this.taxInformation?.taxId;
if (this.organizationId == null) {
return this.apiService.postAccountPayment(request);
} else {
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;
return this.organizationApiService.updatePayment(this.organizationId, request);
}
});
await response;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("updatedPaymentMethod"),
});
this.dialogRef.close(AdjustPaymentDialogResult.Adjusted);
};
taxInformationChanged(event: TaxInformation) {
this.taxInformation = event;
if (event.country === "US") {
this.paymentComponent.hideBank = !this.organizationId;
this.paymentComponent.showBankAccount = !!this.organizationId;
} else {
this.paymentComponent.hideBank = true;
if (this.paymentComponent.method === PaymentMethodType.BankAccount) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
this.paymentComponent.showBankAccount = false;
if (this.paymentComponent.selected === PaymentMethodType.BankAccount) {
this.paymentComponent.select(PaymentMethodType.Card);
}
}
}
protected get showTaxIdField(): boolean {
return !!this.organizationId;
}
}
submit = async (): Promise<void> => {
if (!this.taxInfoComponent.validate()) {
this.taxInfoComponent.markAllAsTouched();
return;
}
/**
* Strongly typed helper to open a AdjustPaymentDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export function openAdjustPaymentDialog(
dialogService: DialogService,
config: DialogConfig<AdjustPaymentDialogData>,
) {
return dialogService.open<AdjustPaymentDialogResult>(AdjustPaymentDialogComponent, config);
try {
if (!this.organizationId) {
await this.updatePremiumUserPaymentMethod();
} else {
await this.updateOrganizationPaymentMethod();
}
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);
};
protected get showTaxIdField(): boolean {
if (!this.organizationId) {
return false;
}
switch (this.productTier) {
case ProductTierType.Free:
case ProductTierType.Families:
return false;
default:
return true;
}
}
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);
};
static open = (
dialogService: DialogService,
dialogConfig: DialogConfig<AdjustPaymentDialogParams>,
) =>
dialogService.open<AdjustPaymentDialogResultType>(AdjustPaymentDialogComponent, dialogConfig);
}

View File

@ -1,34 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [title]="title">
<ng-container bitDialogContent>
<p bitTypography="body1">{{ body }}</p>
<div class="tw-grid two-grid-cols-12">
<bit-form-field class="tw-col-span-7">
<bit-label>{{ storageFieldLabel }}</bit-label>
<input bitInput type="number" formControlName="storage" />
<bit-hint *ngIf="dialogParams.type === 'Add'">
<!-- Total: 10 GB × $0.50 = $5.00 /month -->
<strong>{{ "total" | i18n }}</strong>
{{ this.formGroup.value.storage }} GB &times; {{ this.price | currency: "$" }} =
{{ this.price * this.formGroup.value.storage | currency: "$" }} /
{{ this.cadence | i18n }}
</bit-hint>
</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]="ResultType.Closed"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -1,106 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { StorageRequest } from "@bitwarden/common/models/request/storage.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
export interface AdjustStorageDialogV2Params {
price: number;
cadence: "month" | "year";
type: "Add" | "Remove";
organizationId?: string;
}
export enum AdjustStorageDialogV2ResultType {
Submitted = "submitted",
Closed = "closed",
}
@Component({
templateUrl: "./adjust-storage-dialog-v2.component.html",
})
export class AdjustStorageDialogV2Component {
protected formGroup = new FormGroup({
storage: new FormControl<number>(0, [
Validators.required,
Validators.min(0),
Validators.max(99),
]),
});
protected organizationId?: string;
protected price: number;
protected cadence: "month" | "year";
protected title: string;
protected body: string;
protected storageFieldLabel: string;
protected ResultType = AdjustStorageDialogV2ResultType;
constructor(
private apiService: ApiService,
@Inject(DIALOG_DATA) protected dialogParams: AdjustStorageDialogV2Params,
private dialogRef: DialogRef<AdjustStorageDialogV2ResultType>,
private i18nService: I18nService,
private organizationApiService: OrganizationApiServiceAbstraction,
private toastService: ToastService,
) {
this.price = this.dialogParams.price;
this.cadence = this.dialogParams.cadence;
this.organizationId = this.dialogParams.organizationId;
switch (this.dialogParams.type) {
case "Add":
this.title = this.i18nService.t("addStorage");
this.body = this.i18nService.t("storageAddNote");
this.storageFieldLabel = this.i18nService.t("gbStorageAdd");
break;
case "Remove":
this.title = this.i18nService.t("removeStorage");
this.body = this.i18nService.t("storageRemoveNote");
this.storageFieldLabel = this.i18nService.t("gbStorageRemove");
break;
}
}
submit = async () => {
const request = new StorageRequest();
switch (this.dialogParams.type) {
case "Add":
request.storageGbAdjustment = this.formGroup.value.storage;
break;
case "Remove":
request.storageGbAdjustment = this.formGroup.value.storage * -1;
break;
}
if (this.organizationId) {
await this.organizationApiService.updateStorage(this.organizationId, request);
} else {
await this.apiService.postAccountStorage(request);
}
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("adjustedStorage", request.storageGbAdjustment.toString()),
});
this.dialogRef.close(this.ResultType.Submitted);
};
static open = (
dialogService: DialogService,
dialogConfig: DialogConfig<AdjustStorageDialogV2Params>,
) =>
dialogService.open<AdjustStorageDialogV2ResultType>(
AdjustStorageDialogV2Component,
dialogConfig,
);
}

View File

@ -1,17 +1,17 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="default" [title]="(add ? 'addStorage' : 'removeStorage') | i18n">
<bit-dialog [title]="title">
<ng-container bitDialogContent>
<p bitTypography="body1">{{ (add ? "storageAddNote" : "storageRemoveNote") | i18n }}</p>
<p bitTypography="body1">{{ body }}</p>
<div class="tw-grid tw-grid-cols-12">
<bit-form-field class="tw-col-span-7">
<bit-label>{{ (add ? "gbStorageAdd" : "gbStorageRemove") | i18n }}</bit-label>
<input bitInput type="number" formControlName="storageAdjustment" />
<bit-hint *ngIf="add">
<strong>{{ "total" | i18n }}:</strong>
{{ formGroup.get("storageAdjustment").value || 0 }} GB &times;
{{ storageGbPrice | currency: "$" }} = {{ adjustedStorageTotal | currency: "$" }} /{{
interval | i18n
}}
<bit-label>{{ storageFieldLabel }}</bit-label>
<input bitInput type="number" formControlName="storage" />
<bit-hint *ngIf="dialogParams.type === 'Add'">
<!-- Total: 10 GB × $0.50 = $5.00 /month -->
<strong>{{ "total" | i18n }}</strong>
{{ this.formGroup.value.storage }} GB &times; {{ this.price | currency: "$" }} =
{{ this.price * this.formGroup.value.storage | currency: "$" }} /
{{ this.cadence | i18n }}
</bit-hint>
</bit-form-field>
</div>
@ -25,11 +25,10 @@
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="DialogResult.Cancelled"
[bitDialogClose]="ResultType.Closed"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>
<app-payment [showMethods]="false"></app-payment>

View File

@ -1,132 +1,103 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, ViewChild } from "@angular/core";
import { Component, Inject } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PaymentResponse } from "@bitwarden/common/billing/models/response/payment.response";
import { StorageRequest } from "@bitwarden/common/models/request/storage.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { PaymentComponent } from "../payment/payment.component";
export interface AdjustStorageDialogData {
storageGbPrice: number;
add: boolean;
export interface AdjustStorageDialogParams {
price: number;
cadence: "month" | "year";
type: "Add" | "Remove";
organizationId?: string;
interval?: string;
}
export enum AdjustStorageDialogResult {
Adjusted = "adjusted",
Cancelled = "cancelled",
export enum AdjustStorageDialogResultType {
Submitted = "submitted",
Closed = "closed",
}
@Component({
templateUrl: "adjust-storage-dialog.component.html",
templateUrl: "./adjust-storage-dialog.component.html",
})
export class AdjustStorageDialogComponent {
storageGbPrice: number;
add: boolean;
organizationId: string;
interval: string;
@ViewChild(PaymentComponent, { static: true }) paymentComponent: PaymentComponent;
protected DialogResult = AdjustStorageDialogResult;
protected formGroup = new FormGroup({
storageAdjustment: new FormControl(0, [
storage: new FormControl<number>(0, [
Validators.required,
Validators.min(0),
Validators.max(99),
]),
});
protected organizationId?: string;
protected price: number;
protected cadence: "month" | "year";
protected title: string;
protected body: string;
protected storageFieldLabel: string;
protected ResultType = AdjustStorageDialogResultType;
constructor(
private dialogRef: DialogRef,
@Inject(DIALOG_DATA) protected data: AdjustStorageDialogData,
private apiService: ApiService,
@Inject(DIALOG_DATA) protected dialogParams: AdjustStorageDialogParams,
private dialogRef: DialogRef<AdjustStorageDialogResultType>,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
private activatedRoute: ActivatedRoute,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction,
private toastService: ToastService,
) {
this.storageGbPrice = data.storageGbPrice;
this.add = data.add;
this.organizationId = data.organizationId;
this.interval = data.interval || "year";
this.price = this.dialogParams.price;
this.cadence = this.dialogParams.cadence;
this.organizationId = this.dialogParams.organizationId;
switch (this.dialogParams.type) {
case "Add":
this.title = this.i18nService.t("addStorage");
this.body = this.i18nService.t("storageAddNote");
this.storageFieldLabel = this.i18nService.t("gbStorageAdd");
break;
case "Remove":
this.title = this.i18nService.t("removeStorage");
this.body = this.i18nService.t("storageRemoveNote");
this.storageFieldLabel = this.i18nService.t("gbStorageRemove");
break;
}
}
submit = async () => {
const request = new StorageRequest();
request.storageGbAdjustment = this.formGroup.value.storageAdjustment;
if (!this.add) {
request.storageGbAdjustment *= -1;
switch (this.dialogParams.type) {
case "Add":
request.storageGbAdjustment = this.formGroup.value.storage;
break;
case "Remove":
request.storageGbAdjustment = this.formGroup.value.storage * -1;
break;
}
let paymentFailed = false;
const action = async () => {
let response: Promise<PaymentResponse>;
if (this.organizationId == null) {
response = this.apiService.postAccountStorage(request);
} else {
response = this.organizationApiService.updateStorage(this.organizationId, request);
}
const result = await response;
if (result != null && result.paymentIntentClientSecret != null) {
try {
await this.paymentComponent.handleStripeCardPayment(
result.paymentIntentClientSecret,
null,
);
} catch {
paymentFailed = true;
}
}
};
await action();
this.dialogRef.close(AdjustStorageDialogResult.Adjusted);
if (paymentFailed) {
this.toastService.showToast({
variant: "warning",
title: null,
message: this.i18nService.t("couldNotChargeCardPayInvoice"),
timeout: 10000,
});
// 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(["../billing"], { relativeTo: this.activatedRoute });
if (this.organizationId) {
await this.organizationApiService.updateStorage(this.organizationId, request);
} else {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("adjustedStorage", request.storageGbAdjustment.toString()),
});
await this.apiService.postAccountStorage(request);
}
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("adjustedStorage", request.storageGbAdjustment.toString()),
});
this.dialogRef.close(this.ResultType.Submitted);
};
get adjustedStorageTotal(): number {
return this.storageGbPrice * this.formGroup.value.storageAdjustment;
}
}
/**
* Strongly typed helper to open an AdjustStorageDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export function openAdjustStorageDialog(
dialogService: DialogService,
config: DialogConfig<AdjustStorageDialogData>,
) {
return dialogService.open<AdjustStorageDialogResult>(AdjustStorageDialogComponent, config);
static open = (
dialogService: DialogService,
dialogConfig: DialogConfig<AdjustStorageDialogParams>,
) =>
dialogService.open<AdjustStorageDialogResultType>(AdjustStorageDialogComponent, dialogConfig);
}

View File

@ -6,13 +6,10 @@ import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared";
import { AddCreditDialogComponent } from "./add-credit-dialog.component";
import { AdjustPaymentDialogV2Component } from "./adjust-payment-dialog/adjust-payment-dialog-v2.component";
import { AdjustPaymentDialogComponent } from "./adjust-payment-dialog/adjust-payment-dialog.component";
import { AdjustStorageDialogV2Component } from "./adjust-storage-dialog/adjust-storage-dialog-v2.component";
import { AdjustStorageDialogComponent } from "./adjust-storage-dialog/adjust-storage-dialog.component";
import { BillingHistoryComponent } from "./billing-history.component";
import { OffboardingSurveyComponent } from "./offboarding-survey.component";
import { PaymentV2Component } from "./payment/payment-v2.component";
import { PaymentComponent } from "./payment/payment.component";
import { PaymentMethodComponent } from "./payment-method.component";
import { IndividualSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/individual-self-hosting-license-uploader.component";
@ -26,40 +23,35 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
@NgModule({
imports: [
SharedModule,
PaymentComponent,
TaxInfoComponent,
HeaderModule,
BannerModule,
PaymentV2Component,
PaymentComponent,
VerifyBankAccountComponent,
],
declarations: [
AddCreditDialogComponent,
AdjustPaymentDialogComponent,
AdjustStorageDialogComponent,
BillingHistoryComponent,
PaymentMethodComponent,
SecretsManagerSubscribeComponent,
UpdateLicenseComponent,
UpdateLicenseDialogComponent,
OffboardingSurveyComponent,
AdjustPaymentDialogV2Component,
AdjustStorageDialogV2Component,
AdjustPaymentDialogComponent,
AdjustStorageDialogComponent,
IndividualSelfHostingLicenseUploaderComponent,
OrganizationSelfHostingLicenseUploaderComponent,
],
exports: [
SharedModule,
PaymentComponent,
TaxInfoComponent,
AdjustStorageDialogComponent,
BillingHistoryComponent,
SecretsManagerSubscribeComponent,
UpdateLicenseComponent,
UpdateLicenseDialogComponent,
OffboardingSurveyComponent,
VerifyBankAccountComponent,
PaymentV2Component,
PaymentComponent,
IndividualSelfHostingLicenseUploaderComponent,
OrganizationSelfHostingLicenseUploaderComponent,
],

View File

@ -1,5 +1,4 @@
export * from "./billing-shared.module";
export * from "./payment-method.component";
export * from "./payment/payment.component";
export * from "./sm-subscribe.component";
export * from "./tax-info.component";

View File

@ -29,8 +29,8 @@ import { TrialFlowService } from "../services/trial-flow.service";
import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component";
import {
AdjustPaymentDialogResult,
openAdjustPaymentDialog,
AdjustPaymentDialogComponent,
AdjustPaymentDialogResultType,
} from "./adjust-payment-dialog/adjust-payment-dialog.component";
@Component({
@ -170,14 +170,16 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
};
changePayment = async () => {
const dialogRef = openAdjustPaymentDialog(this.dialogService, {
const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, {
data: {
organizationId: this.organizationId,
currentType: this.paymentSource !== null ? this.paymentSource.type : null,
initialPaymentMethod: this.paymentSource !== null ? this.paymentSource.type : null,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustPaymentDialogResult.Adjusted) {
if (result === AdjustPaymentDialogResultType.Submitted) {
this.location.replaceState(this.location.path(), "", {});
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
await this.syncService.fullSync(true);

View File

@ -15,12 +15,12 @@ import { SharedModule } from "../../../shared";
* the `ExtensionRefresh` flag is set.
*/
@Component({
selector: "app-payment-label-v2",
templateUrl: "./payment-label-v2.component.html",
selector: "app-payment-label",
templateUrl: "./payment-label.component.html",
standalone: true,
imports: [FormFieldModule, SharedModule],
})
export class PaymentLabelV2 implements OnInit {
export class PaymentLabelComponent implements OnInit {
/** `id` of the associated input */
@Input({ required: true }) for: string;
/** Displays required text on the label */

View File

@ -1,152 +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-bank" 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-v2 for="stripe-card-number" required>
{{ "number" | i18n }}
</app-payment-label-v2>
<div id="stripe-card-number" class="form-control 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-v2 for="stripe-card-expiry" required>
{{ "expiration" | i18n }}
</app-payment-label-v2>
<div id="stripe-card-expiry" class="form-control stripe-form-control"></div>
</div>
<div class="tw-col-span-1">
<app-payment-label-v2 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-v2>
<div id="stripe-card-cvc" class="form-control stripe-form-control"></div>
</div>
</div>
</ng-container>
<!-- Bank Account -->
<ng-container *ngIf="showBankAccount && usingBankAccount">
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
{{ "verifyBankAccountWithStatementDescriptorWarning" | 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>

View File

@ -1,205 +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 { SharedModule } from "../../../shared";
import { BillingServicesModule, BraintreeService, StripeService } from "../../services";
import { PaymentLabelV2 } from "./payment-label-v2.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.
*
* This component is meant to replace the existing {@link PaymentComponent} which is using the deprecated Stripe Sources API.
*/
@Component({
selector: "app-payment-v2",
templateUrl: "./payment-v2.component.html",
standalone: true,
imports: [BillingServicesModule, SharedModule, PaymentLabelV2],
})
export class PaymentV2Component 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>;
@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 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);
};
/**
* 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;
}
}

View File

@ -1,96 +1,125 @@
<div [formGroup]="paymentForm">
<div class="tw-mb-4 tw-text-lg" *ngIf="showOptions && showMethods">
<bit-radio-group formControlName="method">
<bit-radio-button id="method-card" [value]="paymentMethodType.Card">
<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
>
{{ "creditCard" | i18n }}
</bit-label>
</bit-radio-button>
<bit-radio-button id="method-bank" [value]="paymentMethodType.BankAccount" *ngIf="!hideBank">
<bit-radio-button
id="bank-payment-method"
[value]="PaymentMethodType.BankAccount"
*ngIf="showBankAccount"
>
<bit-label>
<i class="bwi bwi-fw bwi-bank" aria-hidden="true"></i>
{{ "bankAccount" | i18n }}</bit-label
>
{{ "bankAccount" | i18n }}
</bit-label>
</bit-radio-button>
<bit-radio-button id="method-paypal" [value]="paymentMethodType.PayPal" *ngIf="!hidePaypal">
<bit-label> <i class="bwi bwi-fw bwi-paypal" aria-hidden="true"></i> PayPal</bit-label>
<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="method-credit" [value]="paymentMethodType.Credit" *ngIf="!hideCredit">
<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
>
{{ "accountCredit" | i18n }}
</bit-label>
</bit-radio-button>
</bit-radio-group>
</div>
<ng-container *ngIf="showMethods && method === paymentMethodType.Card">
<div class="tw-grid tw-grid-cols-12 tw-gap-4 tw-mb-4">
<div [ngClass]="trialFlow ? 'tw-col-span-12' : 'tw-col-span-6'">
<app-payment-label-v2 for="stripe-card-number-element">{{
"number" | i18n
}}</app-payment-label-v2>
<div id="stripe-card-number-element" class="form-control stripe-form-control"></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="form-control stripe-form-control"></div>
</div>
<div *ngIf="!trialFlow" class="tw-col-span-8 tw-flex tw-items-end">
<div class="tw-col-span-1 tw-flex tw-items-end">
<img
src="../../images/cards.png"
src="../../../images/cards.png"
alt="Visa, MasterCard, Discover, AmEx, JCB, Diners Club, UnionPay"
width="323"
height="32"
class="tw-max-w-full"
/>
</div>
<div [ngClass]="trialFlow ? 'tw-col-span-6' : 'tw-col-span-6'">
<app-payment-label-v2 for="stripe-card-expiry-element">{{
"expiration" | i18n
}}</app-payment-label-v2>
<div id="stripe-card-expiry-element" class="form-control stripe-form-control"></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="form-control stripe-form-control"></div>
</div>
<div [ngClass]="trialFlow ? 'tw-col-span-6' : 'tw-col-span-6'">
<app-payment-label-v2 for="stripe-card-cvc-element">
<div class="tw-col-span-1">
<app-payment-label for="stripe-card-cvc" required>
{{ "securityCodeSlashCVV" | i18n }}
<a
href="https://www.cvvnumber.com/cvv.html"
tabindex="-1"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
class="hover:tw-no-underline"
appA11yTitle="{{ 'whatIsACvvNumber' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</app-payment-label-v2>
<div id="stripe-card-cvc-element" class="form-control stripe-form-control"></div>
</app-payment-label>
<div id="stripe-card-cvc" class="form-control stripe-form-control"></div>
</div>
</div>
</ng-container>
<ng-container *ngIf="showMethods && method === paymentMethodType.BankAccount">
<!-- Bank Account -->
<ng-container *ngIf="showBankAccount && usingBankAccount">
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
{{ "verifyBankAccountInitialDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}
{{ "verifyBankAccountWithStatementDescriptorWarning" | i18n }}
</bit-callout>
<div class="tw-grid tw-grid-cols-12 tw-gap-4" formGroupName="bank">
<bit-form-field class="tw-col-span-6">
<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 type="text" formControlName="routing_number" required appInputVerbatim />
</bit-form-field>
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "accountNumber" | i18n }}</bit-label>
<input bitInput type="text" formControlName="account_number" required appInputVerbatim />
</bit-form-field>
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "accountHolderName" | i18n }}</bit-label>
<input
bitInput
id="routingNumber"
type="text"
formControlName="account_holder_name"
formControlName="routingNumber"
required
appInputVerbatim
/>
</bit-form-field>
<bit-form-field class="tw-col-span-6">
<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 formControlName="account_holder_type" required>
<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
@ -101,15 +130,20 @@
</bit-form-field>
</div>
</ng-container>
<ng-container *ngIf="showMethods && method === paymentMethodType.PayPal">
<!-- PayPal -->
<ng-container *ngIf="showPayPal && usingPayPal">
<div class="tw-mb-3">
<div id="bt-dropin-container" class="tw-mb-1"></div>
<div id="braintree-container" class="tw-mb-1 tw-content-center"></div>
<small class="tw-text-muted">{{ "paypalClickSubmit" | i18n }}</small>
</div>
</ng-container>
<ng-container *ngIf="showMethods && method === paymentMethodType.Credit">
<bit-callout>
<!-- Account Credit -->
<ng-container *ngIf="showAccountCredit && usingAccountCredit">
<app-callout type="info">
{{ "makeSureEnoughCredit" | i18n }}
</bit-callout>
</app-callout>
</ng-container>
</div>
<button *ngIf="!!onSubmit" bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>
</form>

View File

@ -1,330 +1,203 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input, OnDestroy, OnInit } from "@angular/core";
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { TokenizedPaymentSourceRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-source.request";
import { SharedModule } from "../../../shared";
import { BillingServicesModule, BraintreeService, StripeService } from "../../services";
import { PaymentLabelV2 } from "./payment-label-v2.component";
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",
templateUrl: "./payment.component.html",
standalone: true,
imports: [SharedModule, PaymentLabelV2],
imports: [BillingServicesModule, SharedModule, PaymentLabelComponent],
})
export class PaymentComponent implements OnInit, OnDestroy {
@Input() showMethods = true;
@Input() showOptions = true;
@Input() hideBank = false;
@Input() hidePaypal = false;
@Input() hideCredit = false;
@Input() trialFlow = false;
/** 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;
@Input()
set method(value: PaymentMethodType) {
this._method = value;
this.paymentForm?.controls.method.setValue(value, { emitEvent: false });
}
/** 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>;
get method(): PaymentMethodType {
return this._method;
}
private _method: PaymentMethodType = PaymentMethodType.Card;
@Output() submitted = new EventEmitter<PaymentMethodType>();
private destroy$ = new Subject<void>();
protected paymentForm = new FormGroup({
method: new FormControl(this.method),
bank: new FormGroup({
routing_number: new FormControl(null, [Validators.required]),
account_number: new FormControl(null, [Validators.required]),
account_holder_name: new FormControl(null, [Validators.required]),
account_holder_type: new FormControl("", [Validators.required]),
currency: new FormControl("USD"),
country: new FormControl("US"),
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]),
}),
});
paymentMethodType = PaymentMethodType;
private btScript: HTMLScriptElement;
private btInstance: any = null;
private stripeScript: HTMLScriptElement;
private stripe: any = null;
private stripeElements: any = null;
private stripeCardNumberElement: any = null;
private stripeCardExpiryElement: any = null;
private stripeCardCvcElement: any = null;
private StripeElementStyle: any;
private StripeElementClasses: any;
protected PaymentMethodType = PaymentMethodType;
constructor(
private apiService: ApiService,
private logService: LogService,
private themingService: AbstractThemingService,
private configService: ConfigService,
) {
this.stripeScript = window.document.createElement("script");
this.stripeScript.src = "https://js.stripe.com/v3/?advancedFraudSignals=false";
this.stripeScript.async = true;
this.stripeScript.onload = async () => {
this.stripe = (window as any).Stripe(process.env.STRIPE_KEY);
this.stripeElements = this.stripe.elements();
await this.setStripeElement();
};
this.btScript = window.document.createElement("script");
this.btScript.src = `scripts/dropin.js?cache=${process.env.CACHE_TAG}`;
this.btScript.async = true;
this.StripeElementStyle = {
base: {
color: null,
fontFamily:
'"DM Sans", "Helvetica Neue", Helvetica, Arial, sans-serif, ' +
'"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
fontSize: "16px",
fontSmoothing: "antialiased",
"::placeholder": {
color: null,
},
private billingApiService: BillingApiServiceAbstraction,
private braintreeService: BraintreeService,
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",
},
invalid: {
color: null,
},
};
this.StripeElementClasses = {
focus: "is-focused",
empty: "is-empty",
invalid: "is-invalid",
};
}
async ngOnInit() {
if (!this.showOptions) {
this.hidePaypal = this.method !== PaymentMethodType.PayPal;
this.hideBank = this.method !== PaymentMethodType.BankAccount;
this.hideCredit = this.method !== PaymentMethodType.Credit;
}
this.subscribeToTheme();
window.document.head.appendChild(this.stripeScript);
if (!this.hidePaypal) {
window.document.head.appendChild(this.btScript);
}
this.paymentForm
.get("method")
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((v) => {
this.method = v;
this.changeMethod();
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
window.document.head.removeChild(this.stripeScript);
window.setTimeout(() => {
Array.from(window.document.querySelectorAll("iframe")).forEach((el) => {
if (el.src != null && el.src.indexOf("stripe") > -1) {
try {
window.document.body.removeChild(el);
} catch (e) {
this.logService.error(e);
}
}
});
}, 500);
if (!this.hidePaypal) {
window.document.head.removeChild(this.btScript);
window.setTimeout(() => {
Array.from(window.document.head.querySelectorAll("script")).forEach((el) => {
if (el.src != null && el.src.indexOf("paypal") > -1) {
try {
window.document.head.removeChild(el);
} catch (e) {
this.logService.error(e);
}
}
});
const btStylesheet = window.document.head.querySelector("#braintree-dropin-stylesheet");
if (btStylesheet != null) {
try {
window.document.head.removeChild(btStylesheet);
} catch (e) {
this.logService.error(e);
}
}
}, 500);
}
}
changeMethod() {
this.btInstance = null;
if (this.method === PaymentMethodType.PayPal) {
window.setTimeout(() => {
(window as any).braintree.dropin.create(
{
authorization: process.env.BRAINTREE_KEY,
container: "#bt-dropin-container",
paymentOptionPriority: ["paypal"],
paypal: {
flow: "vault",
buttonStyle: {
label: "pay",
size: "medium",
shape: "pill",
color: "blue",
tagline: "false",
},
},
},
(createErr: any, instance: any) => {
if (createErr != null) {
// eslint-disable-next-line
console.error(createErr);
return;
}
this.btInstance = instance;
},
);
}, 250);
} else {
void this.setStripeElement();
}
}
createPaymentToken(): Promise<[string, PaymentMethodType]> {
return new Promise((resolve, reject) => {
if (this.method === PaymentMethodType.Credit) {
resolve([null, this.method]);
} else if (this.method === PaymentMethodType.PayPal) {
this.btInstance
.requestPaymentMethod()
.then((payload: any) => {
resolve([payload.nonce, this.method]);
})
.catch((err: any) => {
reject(err.message);
});
} else if (
this.method === PaymentMethodType.Card ||
this.method === PaymentMethodType.BankAccount
) {
if (this.method === PaymentMethodType.Card) {
// 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.apiService
.postSetupPayment()
.then((clientSecret) =>
this.stripe.handleCardSetup(clientSecret, this.stripeCardNumberElement),
)
.then((result: any) => {
if (result.error) {
reject(result.error.message);
} else if (result.setupIntent && result.setupIntent.status === "succeeded") {
resolve([result.setupIntent.payment_method, this.method]);
} else {
reject();
}
});
} else {
this.stripe
.createToken("bank_account", this.paymentForm.get("bank").value)
.then((result: any) => {
if (result.error) {
reject(result.error.message);
} else if (result.token && result.token.id != null) {
resolve([result.token.id, this.method]);
} else {
reject();
}
});
}
}
});
}
handleStripeCardPayment(clientSecret: string, successCallback: () => Promise<any>): Promise<any> {
return new Promise<void>((resolve, reject) => {
if (this.showMethods && this.stripeCardNumberElement == null) {
reject();
return;
}
const handleCardPayment = () =>
this.showMethods
? this.stripe.handleCardSetup(clientSecret, this.stripeCardNumberElement)
: this.stripe.handleCardSetup(clientSecret);
return handleCardPayment().then(async (result: any) => {
if (result.error) {
reject(result.error.message);
} else if (result.paymentIntent && result.paymentIntent.status === "succeeded") {
if (successCallback != null) {
await successCallback();
}
resolve();
} else {
reject();
}
});
});
}
private async setStripeElement() {
const extensionRefreshFlag = await this.configService.getFeatureFlag(
FeatureFlag.ExtensionRefresh,
this.initialPaymentMethod === PaymentMethodType.Card,
);
// Apply unique styles for extension refresh
if (extensionRefreshFlag) {
this.StripeElementStyle.base.fontWeight = "500";
this.StripeElementClasses.base = "v2";
if (this.showPayPal) {
this.braintreeService.loadBraintree(
"#braintree-container",
this.initialPaymentMethod === PaymentMethodType.PayPal,
);
}
window.setTimeout(() => {
if (this.showMethods && this.method === PaymentMethodType.Card) {
if (this.stripeCardNumberElement == null) {
this.stripeCardNumberElement = this.stripeElements.create("cardNumber", {
style: this.StripeElementStyle,
classes: this.StripeElementClasses,
placeholder: "",
});
}
if (this.stripeCardExpiryElement == null) {
this.stripeCardExpiryElement = this.stripeElements.create("cardExpiry", {
style: this.StripeElementStyle,
classes: this.StripeElementClasses,
});
}
if (this.stripeCardCvcElement == null) {
this.stripeCardCvcElement = this.stripeElements.create("cardCvc", {
style: this.StripeElementStyle,
classes: this.StripeElementClasses,
placeholder: "",
});
}
this.stripeCardNumberElement.mount("#stripe-card-number-element");
this.stripeCardExpiryElement.mount("#stripe-card-expiry-element");
this.stripeCardCvcElement.mount("#stripe-card-cvc-element");
}
}, 50);
this.formGroup
.get("paymentMethod")
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((type) => {
this.onPaymentMethodChange(type);
});
}
private subscribeToTheme() {
this.themingService.theme$.pipe(takeUntil(this.destroy$)).subscribe(() => {
const style = getComputedStyle(document.documentElement);
this.StripeElementStyle.base.color = `rgb(${style.getPropertyValue("--color-text-main")})`;
this.StripeElementStyle.base["::placeholder"].color = `rgb(${style.getPropertyValue(
"--color-text-muted",
)})`;
this.StripeElementStyle.invalid.color = `rgb(${style.getPropertyValue("--color-text-main")})`;
this.StripeElementStyle.invalid.borderColor = `rgb(${style.getPropertyValue(
"--color-danger-600",
)})`;
});
/** 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);
};
/**
* 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;
}
}

View File

@ -1236,7 +1236,6 @@ const safeProviders: SafeProvider[] = [
deps: [
ApiServiceAbstraction,
BillingApiServiceAbstraction,
ConfigService,
KeyService,
EncryptService,
I18nServiceAbstraction,

View File

@ -4,7 +4,6 @@
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
import { InitiationPath } from "../../models/request/reference-event.request";
import { PaymentMethodType, PlanType } from "../enums";
import { BillingSourceResponse } from "../models/response/billing.response";
import { PaymentSourceResponse } from "../models/response/payment-source.response";
export type OrganizationInformation = {
@ -46,9 +45,7 @@ export type SubscriptionInformation = {
};
export abstract class OrganizationBillingServiceAbstraction {
getPaymentSource: (
organizationId: string,
) => Promise<BillingSourceResponse | PaymentSourceResponse>;
getPaymentSource: (organizationId: string) => Promise<PaymentSourceResponse>;
purchaseSubscription: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;

View File

@ -7,8 +7,6 @@ import { OrganizationApiServiceAbstraction as OrganizationApiService } from "../
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { OrganizationKeysRequest } from "../../admin-console/models/request/organization-keys.request";
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
import { FeatureFlag } from "../../enums/feature-flag.enum";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { EncString } from "../../platform/models/domain/enc-string";
@ -24,7 +22,6 @@ import {
} from "../abstractions";
import { PlanType } from "../enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
import { BillingSourceResponse } from "../models/response/billing.response";
import { PaymentSourceResponse } from "../models/response/payment-source.response";
interface OrganizationKeys {
@ -38,7 +35,6 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
constructor(
private apiService: ApiService,
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
private keyService: KeyService,
private encryptService: EncryptService,
private i18nService: I18nService,
@ -46,21 +42,9 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
private syncService: SyncService,
) {}
async getPaymentSource(
organizationId: string,
): Promise<BillingSourceResponse | PaymentSourceResponse> {
const deprecateStripeSourcesAPI = await this.configService.getFeatureFlag(
FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
);
if (deprecateStripeSourcesAPI) {
const paymentMethod =
await this.billingApiService.getOrganizationPaymentMethod(organizationId);
return paymentMethod.paymentSource;
} else {
const billing = await this.organizationApiService.getBilling(organizationId);
return billing.paymentSource;
}
async getPaymentSource(organizationId: string): Promise<PaymentSourceResponse> {
const paymentMethod = await this.billingApiService.getOrganizationPaymentMethod(organizationId);
return paymentMethod.paymentSource;
}
async purchaseSubscription(subscription: SubscriptionInformation): Promise<OrganizationResponse> {

View File

@ -34,7 +34,6 @@ export enum FeatureFlag {
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
SSHKeyVaultItem = "ssh-key-vault-item",
SSHAgent = "ssh-agent",
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
CipherKeyEncryption = "cipher-key-encryption",
PM11901_RefactorSelfHostingLicenseUploader = "PM-11901-refactor-self-hosting-license-uploader",
CriticalApps = "pm-14466-risk-insights-critical-application",
@ -92,7 +91,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.SSHKeyVaultItem]: FALSE,
[FeatureFlag.SSHAgent]: FALSE,
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader]: FALSE,
[FeatureFlag.CriticalApps]: FALSE,