mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 23:33:31 +00:00
[PM-16937] Remove Billing Circular Dependency (#13085)
* Remove circular dependency between billing services and components * Removed `logService` from `billing-api.service.ts` * Resolved failed test * Removed @bitwarden/ui-common * Added optional `title` parameter to `BillingNotificationService` functions * Removed @bitwarden/platform from libs/common/tsconfig.json * Update apps/web/src/app/billing/services/billing-notification.service.spec.ts Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com> * Update apps/web/src/app/billing/services/billing-notification.service.spec.ts Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com> * Resolved build errors * Resolved issue where free trial banner wouldn't display if missing a payment method --------- Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
catchError,
|
||||
} from "rxjs/operators";
|
||||
|
||||
import {
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { BillingNotificationService } from "../../../billing/services/billing-notification.service";
|
||||
import {
|
||||
ResellerWarning,
|
||||
ResellerWarningService,
|
||||
@@ -256,6 +258,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
private organizationBillingService: OrganizationBillingServiceAbstraction,
|
||||
private resellerWarningService: ResellerWarningService,
|
||||
private accountService: AccountService,
|
||||
private billingNotificationService: BillingNotificationService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -636,12 +639,18 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
combineLatest([
|
||||
of(org),
|
||||
this.organizationApiService.getSubscription(org.id),
|
||||
this.organizationBillingService.getPaymentSource(org.id),
|
||||
from(this.organizationBillingService.getPaymentSource(org.id)).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.billingNotificationService.handleError(error);
|
||||
return of(null);
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
map(([org, sub, paymentSource]) => {
|
||||
return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(org, sub, paymentSource);
|
||||
}),
|
||||
map(([org, sub, paymentSource]) =>
|
||||
this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(org, sub, paymentSource),
|
||||
),
|
||||
filter((result) => result !== null),
|
||||
);
|
||||
|
||||
this.resellerWarning$ = organization$.pipe(
|
||||
|
||||
@@ -58,6 +58,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { BillingNotificationService } from "../services/billing-notification.service";
|
||||
import { BillingSharedModule } from "../shared/billing-shared.module";
|
||||
import { PaymentComponent } from "../shared/payment/payment.component";
|
||||
|
||||
@@ -208,6 +209,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
private taxService: TaxServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private organizationBillingService: OrganizationBillingService,
|
||||
private billingNotificationService: BillingNotificationService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@@ -228,10 +230,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(this.organizationId)),
|
||||
);
|
||||
const { accountCredit, paymentSource } =
|
||||
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
|
||||
this.accountCredit = accountCredit;
|
||||
this.paymentSource = paymentSource;
|
||||
try {
|
||||
const { accountCredit, paymentSource } =
|
||||
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
|
||||
this.accountCredit = accountCredit;
|
||||
this.paymentSource = paymentSource;
|
||||
} catch (error) {
|
||||
this.billingNotificationService.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.selfHosted) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { BillingNotificationService } from "../../services/billing-notification.service";
|
||||
import { TrialFlowService } from "../../services/trial-flow.service";
|
||||
import {
|
||||
AddCreditDialogResult,
|
||||
@@ -66,6 +67,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
protected syncService: SyncService,
|
||||
private billingNotificationService: BillingNotificationService,
|
||||
) {
|
||||
this.activatedRoute.params
|
||||
.pipe(
|
||||
@@ -115,47 +117,52 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
|
||||
protected load = async (): Promise<void> => {
|
||||
this.loading = true;
|
||||
const { accountCredit, paymentSource, subscriptionStatus } =
|
||||
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
|
||||
this.accountCredit = accountCredit;
|
||||
this.paymentSource = paymentSource;
|
||||
this.subscriptionStatus = subscriptionStatus;
|
||||
try {
|
||||
const { accountCredit, paymentSource, subscriptionStatus } =
|
||||
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
|
||||
this.accountCredit = accountCredit;
|
||||
this.paymentSource = paymentSource;
|
||||
this.subscriptionStatus = subscriptionStatus;
|
||||
|
||||
if (this.organizationId) {
|
||||
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
|
||||
this.organizationId,
|
||||
);
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
const organizationPromise = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(this.organizationId)),
|
||||
);
|
||||
if (this.organizationId) {
|
||||
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
|
||||
this.organizationId,
|
||||
);
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
const organizationPromise = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(this.organizationId)),
|
||||
);
|
||||
|
||||
[this.organizationSubscriptionResponse, this.organization] = await Promise.all([
|
||||
organizationSubscriptionPromise,
|
||||
organizationPromise,
|
||||
]);
|
||||
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
|
||||
this.organization,
|
||||
this.organizationSubscriptionResponse,
|
||||
paymentSource,
|
||||
);
|
||||
[this.organizationSubscriptionResponse, this.organization] = await Promise.all([
|
||||
organizationSubscriptionPromise,
|
||||
organizationPromise,
|
||||
]);
|
||||
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
|
||||
this.organization,
|
||||
this.organizationSubscriptionResponse,
|
||||
paymentSource,
|
||||
);
|
||||
}
|
||||
this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false;
|
||||
// If the flag `launchPaymentModalAutomatically` is set to true,
|
||||
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
|
||||
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
|
||||
if (this.launchPaymentModalAutomatically) {
|
||||
window.setTimeout(async () => {
|
||||
await this.changePayment();
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
this.location.replaceState(this.location.path(), "", {});
|
||||
}, 800);
|
||||
}
|
||||
} catch (error) {
|
||||
this.billingNotificationService.handleError(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false;
|
||||
// If the flag `launchPaymentModalAutomatically` is set to true,
|
||||
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
|
||||
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
|
||||
if (this.launchPaymentModalAutomatically) {
|
||||
window.setTimeout(async () => {
|
||||
await this.changePayment();
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
this.location.replaceState(this.location.path(), "", {});
|
||||
}, 800);
|
||||
}
|
||||
this.loading = false;
|
||||
};
|
||||
|
||||
protected updatePaymentMethod = async (): Promise<void> => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { BillingNotificationService } from "./billing-notification.service";
|
||||
|
||||
describe("BillingNotificationService", () => {
|
||||
let service: BillingNotificationService;
|
||||
let logService: MockProxy<LogService>;
|
||||
let toastService: MockProxy<ToastService>;
|
||||
|
||||
beforeEach(() => {
|
||||
logService = mock<LogService>();
|
||||
toastService = mock<ToastService>();
|
||||
service = new BillingNotificationService(logService, toastService);
|
||||
});
|
||||
|
||||
describe("handleError", () => {
|
||||
it("should log error and show toast for ErrorResponse", () => {
|
||||
const error = new ErrorResponse(["test error"], 400);
|
||||
|
||||
expect(() => service.handleError(error)).toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(error);
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: error.getSingleMessage(),
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast with the provided error", () => {
|
||||
const error = new ErrorResponse(["test error"], 400);
|
||||
|
||||
expect(() => service.handleError(error, "Test Title")).toThrow();
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "Test Title",
|
||||
message: error.getSingleMessage(),
|
||||
});
|
||||
});
|
||||
|
||||
it("should only log error for non-ErrorResponse", () => {
|
||||
const error = new Error("test error");
|
||||
|
||||
expect(() => service.handleError(error)).toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(error);
|
||||
expect(toastService.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("showSuccess", () => {
|
||||
it("shows success toast with default title when provided title is empty", () => {
|
||||
const message = "test message";
|
||||
service.showSuccess(message);
|
||||
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
it("should show success toast with custom title", () => {
|
||||
const message = "test message";
|
||||
service.showSuccess(message, "Success Title");
|
||||
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
title: "Success Title",
|
||||
message,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class BillingNotificationService {
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
handleError(error: unknown, title: string = "") {
|
||||
this.logService.error(error);
|
||||
if (error instanceof ErrorResponse) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: title,
|
||||
message: error.getSingleMessage(),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
showSuccess(message: string, title: string = "") {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: title,
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
@NgModule({})
|
||||
import { BillingNotificationService } from "./billing-notification.service";
|
||||
|
||||
@NgModule({
|
||||
providers: [BillingNotificationService],
|
||||
})
|
||||
export class BillingServicesModule {}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
catchError,
|
||||
} from "rxjs/operators";
|
||||
|
||||
import {
|
||||
@@ -80,6 +81,7 @@ import {
|
||||
CollectionDialogTabType,
|
||||
openCollectionDialog,
|
||||
} from "../../admin-console/organizations/shared/components/collection-dialog";
|
||||
import { BillingNotificationService } from "../../billing/services/billing-notification.service";
|
||||
import { TrialFlowService } from "../../billing/services/trial-flow.service";
|
||||
import { FreeTrial } from "../../billing/types/free-trial";
|
||||
import { SharedModule } from "../../shared/shared.module";
|
||||
@@ -213,20 +215,25 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
ownerOrgs.map((org) =>
|
||||
combineLatest([
|
||||
this.organizationApiService.getSubscription(org.id),
|
||||
this.organizationBillingService.getPaymentSource(org.id),
|
||||
from(this.organizationBillingService.getPaymentSource(org.id)).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.billingNotificationService.handleError(error);
|
||||
return of(null);
|
||||
}),
|
||||
),
|
||||
]).pipe(
|
||||
map(([subscription, paymentSource]) => {
|
||||
return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
|
||||
map(([subscription, paymentSource]) =>
|
||||
this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
|
||||
org,
|
||||
subscription,
|
||||
paymentSource,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
map((results) => results.filter((result) => result.shownBanner)),
|
||||
map((results) => results.filter((result) => result !== null && result.shownBanner)),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
@@ -262,6 +269,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
private trialFlowService: TrialFlowService,
|
||||
private organizationBillingService: OrganizationBillingServiceAbstraction,
|
||||
private billingNotificationService: BillingNotificationService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
|
||||
Reference in New Issue
Block a user