mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 06:13:38 +00:00
[PM-27123] Account Credit not Showing for Premium Upgrade Payment (#16967)
* fix(billing): Add NonTokenizedPaymentMethod type * fix(billing): Add NonTokenizedPayment type as parameter option * fix(billing): Update service for account credit payment and add tests * fix(billing): Add logic to accept account credit and callouts for credit * fix(billing): Add account credit back to premium component * fix(billing): update non-tokenizable payment method and payment service * refactor(billing): update payment component * fix(billing): update premium subscription request * fix(billing): update billing html component account credit logic
This commit is contained in:
@@ -2,7 +2,11 @@ import { Injectable } from "@angular/core";
|
|||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
|
|
||||||
import { BillingAddress, TokenizedPaymentMethod } from "../payment/types";
|
import {
|
||||||
|
BillingAddress,
|
||||||
|
NonTokenizedPaymentMethod,
|
||||||
|
TokenizedPaymentMethod,
|
||||||
|
} from "../payment/types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountBillingClient {
|
export class AccountBillingClient {
|
||||||
@@ -14,11 +18,17 @@ export class AccountBillingClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
purchasePremiumSubscription = async (
|
purchasePremiumSubscription = async (
|
||||||
paymentMethod: TokenizedPaymentMethod,
|
paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod,
|
||||||
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
|
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const path = `${this.endpoint}/subscription`;
|
const path = `${this.endpoint}/subscription`;
|
||||||
const request = { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress };
|
|
||||||
|
// Determine the request payload based on the payment method type
|
||||||
|
const isTokenizedPayment = "token" in paymentMethod;
|
||||||
|
|
||||||
|
const request = isTokenizedPayment
|
||||||
|
? { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress }
|
||||||
|
: { nonTokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress };
|
||||||
await this.apiService.send("POST", path, request, true, true);
|
await this.apiService.send("POST", path, request, true, true);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,18 @@ import { SyncService } from "@bitwarden/common/platform/sync";
|
|||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { LogService } from "@bitwarden/logging";
|
import { LogService } from "@bitwarden/logging";
|
||||||
|
|
||||||
import { AccountBillingClient, TaxAmounts, TaxClient } from "../../../../clients";
|
import {
|
||||||
import { BillingAddress, TokenizedPaymentMethod } from "../../../../payment/types";
|
AccountBillingClient,
|
||||||
|
SubscriberBillingClient,
|
||||||
|
TaxAmounts,
|
||||||
|
TaxClient,
|
||||||
|
} from "../../../../clients";
|
||||||
|
import {
|
||||||
|
BillingAddress,
|
||||||
|
NonTokenizablePaymentMethods,
|
||||||
|
NonTokenizedPaymentMethod,
|
||||||
|
TokenizedPaymentMethod,
|
||||||
|
} from "../../../../payment/types";
|
||||||
import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier";
|
import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier";
|
||||||
|
|
||||||
import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";
|
import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";
|
||||||
@@ -30,6 +40,7 @@ describe("UpgradePaymentService", () => {
|
|||||||
const mockSyncService = mock<SyncService>();
|
const mockSyncService = mock<SyncService>();
|
||||||
const mockOrganizationService = mock<OrganizationService>();
|
const mockOrganizationService = mock<OrganizationService>();
|
||||||
const mockAccountService = mock<AccountService>();
|
const mockAccountService = mock<AccountService>();
|
||||||
|
const mockSubscriberBillingClient = mock<SubscriberBillingClient>();
|
||||||
|
|
||||||
mockApiService.refreshIdentityToken.mockResolvedValue({});
|
mockApiService.refreshIdentityToken.mockResolvedValue({});
|
||||||
mockSyncService.fullSync.mockResolvedValue(true);
|
mockSyncService.fullSync.mockResolvedValue(true);
|
||||||
@@ -104,6 +115,7 @@ describe("UpgradePaymentService", () => {
|
|||||||
mockReset(mockLogService);
|
mockReset(mockLogService);
|
||||||
mockReset(mockOrganizationService);
|
mockReset(mockOrganizationService);
|
||||||
mockReset(mockAccountService);
|
mockReset(mockAccountService);
|
||||||
|
mockReset(mockSubscriberBillingClient);
|
||||||
|
|
||||||
mockAccountService.activeAccount$ = of(null);
|
mockAccountService.activeAccount$ = of(null);
|
||||||
mockOrganizationService.organizations$.mockReturnValue(of([]));
|
mockOrganizationService.organizations$.mockReturnValue(of([]));
|
||||||
@@ -111,7 +123,10 @@ describe("UpgradePaymentService", () => {
|
|||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
UpgradePaymentService,
|
UpgradePaymentService,
|
||||||
|
{
|
||||||
|
provide: SubscriberBillingClient,
|
||||||
|
useValue: mockSubscriberBillingClient,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: OrganizationBillingServiceAbstraction,
|
provide: OrganizationBillingServiceAbstraction,
|
||||||
useValue: mockOrganizationBillingService,
|
useValue: mockOrganizationBillingService,
|
||||||
@@ -172,6 +187,7 @@ describe("UpgradePaymentService", () => {
|
|||||||
mockSyncService,
|
mockSyncService,
|
||||||
mockOrganizationService,
|
mockOrganizationService,
|
||||||
mockAccountService,
|
mockAccountService,
|
||||||
|
mockSubscriberBillingClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
@@ -223,6 +239,7 @@ describe("UpgradePaymentService", () => {
|
|||||||
mockSyncService,
|
mockSyncService,
|
||||||
mockOrganizationService,
|
mockOrganizationService,
|
||||||
mockAccountService,
|
mockAccountService,
|
||||||
|
mockSubscriberBillingClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
@@ -256,6 +273,7 @@ describe("UpgradePaymentService", () => {
|
|||||||
mockSyncService,
|
mockSyncService,
|
||||||
mockOrganizationService,
|
mockOrganizationService,
|
||||||
mockAccountService,
|
mockAccountService,
|
||||||
|
mockSubscriberBillingClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
@@ -266,6 +284,68 @@ describe("UpgradePaymentService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("accountCredit$", () => {
|
||||||
|
it("should correctly fetch account credit for subscriber", (done) => {
|
||||||
|
// Arrange
|
||||||
|
|
||||||
|
const mockAccount: Account = {
|
||||||
|
id: "user-id" as UserId,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
emailVerified: true,
|
||||||
|
};
|
||||||
|
const expectedCredit = 25.5;
|
||||||
|
|
||||||
|
mockAccountService.activeAccount$ = of(mockAccount);
|
||||||
|
mockSubscriberBillingClient.getCredit.mockResolvedValue(expectedCredit);
|
||||||
|
|
||||||
|
const service = new UpgradePaymentService(
|
||||||
|
mockOrganizationBillingService,
|
||||||
|
mockAccountBillingClient,
|
||||||
|
mockTaxClient,
|
||||||
|
mockLogService,
|
||||||
|
mockApiService,
|
||||||
|
mockSyncService,
|
||||||
|
mockOrganizationService,
|
||||||
|
mockAccountService,
|
||||||
|
mockSubscriberBillingClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
service.accountCredit$.subscribe((credit) => {
|
||||||
|
expect(credit).toBe(expectedCredit);
|
||||||
|
expect(mockSubscriberBillingClient.getCredit).toHaveBeenCalledWith({
|
||||||
|
data: mockAccount,
|
||||||
|
type: "account",
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty account", (done) => {
|
||||||
|
// Arrange
|
||||||
|
mockAccountService.activeAccount$ = of(null);
|
||||||
|
const service = new UpgradePaymentService(
|
||||||
|
mockOrganizationBillingService,
|
||||||
|
mockAccountBillingClient,
|
||||||
|
mockTaxClient,
|
||||||
|
mockLogService,
|
||||||
|
mockApiService,
|
||||||
|
mockSyncService,
|
||||||
|
mockOrganizationService,
|
||||||
|
mockAccountService,
|
||||||
|
mockSubscriberBillingClient,
|
||||||
|
);
|
||||||
|
// Act & Assert
|
||||||
|
service?.accountCredit$.subscribe({
|
||||||
|
error: () => {
|
||||||
|
expect(mockSubscriberBillingClient.getCredit).not.toHaveBeenCalled();
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("adminConsoleRouteForOwnedOrganization$", () => {
|
describe("adminConsoleRouteForOwnedOrganization$", () => {
|
||||||
it("should return the admin console route for the first free organization the user owns", (done) => {
|
it("should return the admin console route for the first free organization the user owns", (done) => {
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -309,6 +389,7 @@ describe("UpgradePaymentService", () => {
|
|||||||
mockSyncService,
|
mockSyncService,
|
||||||
mockOrganizationService,
|
mockOrganizationService,
|
||||||
mockAccountService,
|
mockAccountService,
|
||||||
|
mockSubscriberBillingClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
@@ -405,24 +486,58 @@ describe("UpgradePaymentService", () => {
|
|||||||
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
|
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw error if payment method is incomplete", async () => {
|
it("should handle upgrade with account credit payment method and refresh data", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod;
|
const accountCreditPaymentMethod: NonTokenizedPaymentMethod = {
|
||||||
|
type: NonTokenizablePaymentMethods.accountCredit,
|
||||||
|
};
|
||||||
|
mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue();
|
||||||
|
|
||||||
// Act & Assert
|
// Act
|
||||||
await expect(
|
await sut.upgradeToPremium(accountCreditPaymentMethod, mockBillingAddress);
|
||||||
sut.upgradeToPremium(incompletePaymentMethod, mockBillingAddress),
|
|
||||||
).rejects.toThrow("Payment method type or token is missing");
|
// Assert
|
||||||
|
expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith(
|
||||||
|
accountCreditPaymentMethod,
|
||||||
|
mockBillingAddress,
|
||||||
|
);
|
||||||
|
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
|
||||||
|
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw error if billing address is incomplete", async () => {
|
it("should validate payment method type and token", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const incompleteBillingAddress = { country: "US", postalCode: null } as any;
|
const noTypePaymentMethod = { token: "test-token" } as any;
|
||||||
|
const noTokenPaymentMethod = { type: "card" } as TokenizedPaymentMethod;
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(sut.upgradeToPremium(noTypePaymentMethod, mockBillingAddress)).rejects.toThrow(
|
||||||
|
"Payment method type is missing",
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(sut.upgradeToPremium(noTokenPaymentMethod, mockBillingAddress)).rejects.toThrow(
|
||||||
|
"Payment method token is missing",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate billing address fields", async () => {
|
||||||
|
// Arrange
|
||||||
|
const missingCountry = { postalCode: "12345" } as any;
|
||||||
|
const missingPostal = { country: "US" } as any;
|
||||||
|
const nullFields = { country: "US", postalCode: null } as any;
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await expect(
|
await expect(
|
||||||
sut.upgradeToPremium(mockTokenizedPaymentMethod, incompleteBillingAddress),
|
sut.upgradeToPremium(mockTokenizedPaymentMethod, missingCountry),
|
||||||
).rejects.toThrow("Billing address information is incomplete");
|
).rejects.toThrow("Billing address information is incomplete");
|
||||||
|
|
||||||
|
await expect(sut.upgradeToPremium(mockTokenizedPaymentMethod, missingPostal)).rejects.toThrow(
|
||||||
|
"Billing address information is incomplete",
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(sut.upgradeToPremium(mockTokenizedPaymentMethod, nullFields)).rejects.toThrow(
|
||||||
|
"Billing address information is incomplete",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -504,7 +619,7 @@ describe("UpgradePaymentService", () => {
|
|||||||
expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledTimes(1);
|
expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw error if payment method is incomplete", async () => {
|
it("should throw error if payment token is missing with card type", async () => {
|
||||||
const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod;
|
const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod;
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -512,7 +627,15 @@ describe("UpgradePaymentService", () => {
|
|||||||
organizationName: "Test Organization",
|
organizationName: "Test Organization",
|
||||||
billingAddress: mockBillingAddress,
|
billingAddress: mockBillingAddress,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("Payment method type or token is missing");
|
).rejects.toThrow("Payment method token is missing");
|
||||||
|
});
|
||||||
|
it("should throw error if organization name is missing", async () => {
|
||||||
|
await expect(
|
||||||
|
sut.upgradeToFamilies(mockAccount, mockFamiliesPlanDetails, mockTokenizedPaymentMethod, {
|
||||||
|
organizationName: "",
|
||||||
|
billingAddress: mockBillingAddress,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Organization name is required for families upgrade");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,21 +11,25 @@ import {
|
|||||||
OrganizationBillingServiceAbstraction,
|
OrganizationBillingServiceAbstraction,
|
||||||
SubscriptionInformation,
|
SubscriptionInformation,
|
||||||
} from "@bitwarden/common/billing/abstractions";
|
} from "@bitwarden/common/billing/abstractions";
|
||||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
|
||||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||||
import { LogService } from "@bitwarden/logging";
|
import { LogService } from "@bitwarden/logging";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AccountBillingClient,
|
AccountBillingClient,
|
||||||
OrganizationSubscriptionPurchase,
|
OrganizationSubscriptionPurchase,
|
||||||
|
SubscriberBillingClient,
|
||||||
TaxAmounts,
|
TaxAmounts,
|
||||||
TaxClient,
|
TaxClient,
|
||||||
} from "../../../../clients";
|
} from "../../../../clients";
|
||||||
import {
|
import {
|
||||||
BillingAddress,
|
BillingAddress,
|
||||||
|
NonTokenizablePaymentMethods,
|
||||||
|
NonTokenizedPaymentMethod,
|
||||||
tokenizablePaymentMethodToLegacyEnum,
|
tokenizablePaymentMethodToLegacyEnum,
|
||||||
TokenizedPaymentMethod,
|
TokenizedPaymentMethod,
|
||||||
} from "../../../../payment/types";
|
} from "../../../../payment/types";
|
||||||
|
import { mapAccountToSubscriber } from "../../../../types";
|
||||||
import {
|
import {
|
||||||
PersonalSubscriptionPricingTier,
|
PersonalSubscriptionPricingTier,
|
||||||
PersonalSubscriptionPricingTierId,
|
PersonalSubscriptionPricingTierId,
|
||||||
@@ -59,6 +63,7 @@ export class UpgradePaymentService {
|
|||||||
private syncService: SyncService,
|
private syncService: SyncService,
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
|
private subscriberBillingClient: SubscriberBillingClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
userIsOwnerOfFreeOrg$: Observable<boolean> = this.accountService.activeAccount$.pipe(
|
userIsOwnerOfFreeOrg$: Observable<boolean> = this.accountService.activeAccount$.pipe(
|
||||||
@@ -79,6 +84,12 @@ export class UpgradePaymentService {
|
|||||||
map((org) => `/organizations/${org!.id}/billing/subscription`),
|
map((org) => `/organizations/${org!.id}/billing/subscription`),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch account credit
|
||||||
|
accountCredit$: Observable<number | null> = this.accountService.activeAccount$.pipe(
|
||||||
|
mapAccountToSubscriber,
|
||||||
|
switchMap((account) => this.subscriberBillingClient.getCredit(account)),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate estimated tax for the selected plan
|
* Calculate estimated tax for the selected plan
|
||||||
*/
|
*/
|
||||||
@@ -130,7 +141,7 @@ export class UpgradePaymentService {
|
|||||||
* Process premium upgrade
|
* Process premium upgrade
|
||||||
*/
|
*/
|
||||||
async upgradeToPremium(
|
async upgradeToPremium(
|
||||||
paymentMethod: TokenizedPaymentMethod,
|
paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod,
|
||||||
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
|
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.validatePaymentAndBillingInfo(paymentMethod, billingAddress);
|
this.validatePaymentAndBillingInfo(paymentMethod, billingAddress);
|
||||||
@@ -169,10 +180,7 @@ export class UpgradePaymentService {
|
|||||||
passwordManagerSeats: passwordManagerSeats,
|
passwordManagerSeats: passwordManagerSeats,
|
||||||
},
|
},
|
||||||
payment: {
|
payment: {
|
||||||
paymentMethod: [
|
paymentMethod: [paymentMethod.token, this.getPaymentMethodType(paymentMethod)],
|
||||||
paymentMethod.token,
|
|
||||||
tokenizablePaymentMethodToLegacyEnum(paymentMethod.type),
|
|
||||||
],
|
|
||||||
billing: {
|
billing: {
|
||||||
country: billingAddress.country,
|
country: billingAddress.country,
|
||||||
postalCode: billingAddress.postalCode,
|
postalCode: billingAddress.postalCode,
|
||||||
@@ -195,11 +203,19 @@ export class UpgradePaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private validatePaymentAndBillingInfo(
|
private validatePaymentAndBillingInfo(
|
||||||
paymentMethod: TokenizedPaymentMethod,
|
paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod,
|
||||||
billingAddress: { country: string; postalCode: string },
|
billingAddress: { country: string; postalCode: string },
|
||||||
): void {
|
): void {
|
||||||
if (!paymentMethod?.token || !paymentMethod?.type) {
|
if (!paymentMethod?.type) {
|
||||||
throw new Error("Payment method type or token is missing");
|
throw new Error("Payment method type is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account credit does not require a token
|
||||||
|
if (
|
||||||
|
paymentMethod.type !== NonTokenizablePaymentMethods.accountCredit &&
|
||||||
|
!paymentMethod?.token
|
||||||
|
) {
|
||||||
|
throw new Error("Payment method token is missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!billingAddress?.country || !billingAddress?.postalCode) {
|
if (!billingAddress?.country || !billingAddress?.postalCode) {
|
||||||
@@ -211,4 +227,12 @@ export class UpgradePaymentService {
|
|||||||
await this.apiService.refreshIdentityToken();
|
await this.apiService.refreshIdentityToken();
|
||||||
await this.syncService.fullSync(true);
|
await this.syncService.fullSync(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getPaymentMethodType(
|
||||||
|
paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod,
|
||||||
|
): PaymentMethodType {
|
||||||
|
return paymentMethod.type === NonTokenizablePaymentMethods.accountCredit
|
||||||
|
? PaymentMethodType.Credit
|
||||||
|
: tokenizablePaymentMethodToLegacyEnum(paymentMethod.type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,10 @@
|
|||||||
<h5 bitTypography="h5">{{ "paymentMethod" | i18n }}</h5>
|
<h5 bitTypography="h5">{{ "paymentMethod" | i18n }}</h5>
|
||||||
<app-enter-payment-method
|
<app-enter-payment-method
|
||||||
[showBankAccount]="isFamiliesPlan"
|
[showBankAccount]="isFamiliesPlan"
|
||||||
|
[showAccountCredit]="!isFamiliesPlan"
|
||||||
[group]="formGroup.controls.paymentForm"
|
[group]="formGroup.controls.paymentForm"
|
||||||
[includeBillingAddress]="false"
|
[includeBillingAddress]="false"
|
||||||
|
[hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async"
|
||||||
#paymentComponent
|
#paymentComponent
|
||||||
></app-enter-payment-method>
|
></app-enter-payment-method>
|
||||||
<h5 bitTypography="h5" class="tw-pt-4 tw-pb-2">{{ "billingAddress" | i18n }}</h5>
|
<h5 bitTypography="h5" class="tw-pt-4 tw-pb-2">{{ "billingAddress" | i18n }}</h5>
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ import {
|
|||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||||
import { catchError, debounceTime, from, Observable, of, switchMap } from "rxjs";
|
import {
|
||||||
|
debounceTime,
|
||||||
|
Observable,
|
||||||
|
switchMap,
|
||||||
|
startWith,
|
||||||
|
from,
|
||||||
|
catchError,
|
||||||
|
of,
|
||||||
|
combineLatest,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
@@ -23,7 +32,14 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
|||||||
import {
|
import {
|
||||||
EnterBillingAddressComponent,
|
EnterBillingAddressComponent,
|
||||||
EnterPaymentMethodComponent,
|
EnterPaymentMethodComponent,
|
||||||
|
getBillingAddressFromForm,
|
||||||
} from "../../../payment/components";
|
} from "../../../payment/components";
|
||||||
|
import {
|
||||||
|
BillingAddress,
|
||||||
|
NonTokenizablePaymentMethods,
|
||||||
|
NonTokenizedPaymentMethod,
|
||||||
|
TokenizedPaymentMethod,
|
||||||
|
} from "../../../payment/types";
|
||||||
import { BillingServicesModule } from "../../../services";
|
import { BillingServicesModule } from "../../../services";
|
||||||
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
|
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
|
||||||
import { BitwardenSubscriber } from "../../../types";
|
import { BitwardenSubscriber } from "../../../types";
|
||||||
@@ -33,7 +49,11 @@ import {
|
|||||||
PersonalSubscriptionPricingTierIds,
|
PersonalSubscriptionPricingTierIds,
|
||||||
} from "../../../types/subscription-pricing-tier";
|
} from "../../../types/subscription-pricing-tier";
|
||||||
|
|
||||||
import { PlanDetails, UpgradePaymentService } from "./services/upgrade-payment.service";
|
import {
|
||||||
|
PaymentFormValues,
|
||||||
|
PlanDetails,
|
||||||
|
UpgradePaymentService,
|
||||||
|
} from "./services/upgrade-payment.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status types for upgrade payment dialog
|
* Status types for upgrade payment dialog
|
||||||
@@ -80,6 +100,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
|||||||
protected goBack = output<void>();
|
protected goBack = output<void>();
|
||||||
protected complete = output<UpgradePaymentResult>();
|
protected complete = output<UpgradePaymentResult>();
|
||||||
protected selectedPlan: PlanDetails | null = null;
|
protected selectedPlan: PlanDetails | null = null;
|
||||||
|
protected hasEnoughAccountCredit$!: Observable<boolean>;
|
||||||
|
|
||||||
@ViewChild(EnterPaymentMethodComponent) paymentComponent!: EnterPaymentMethodComponent;
|
@ViewChild(EnterPaymentMethodComponent) paymentComponent!: EnterPaymentMethodComponent;
|
||||||
@ViewChild(CartSummaryComponent) cartSummaryComponent!: CartSummaryComponent;
|
@ViewChild(CartSummaryComponent) cartSummaryComponent!: CartSummaryComponent;
|
||||||
@@ -155,6 +176,22 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
|||||||
.subscribe((tax) => {
|
.subscribe((tax) => {
|
||||||
this.estimatedTax = tax;
|
this.estimatedTax = tax;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if user has enough account credit for the purchase
|
||||||
|
this.hasEnoughAccountCredit$ = combineLatest([
|
||||||
|
this.upgradePaymentService.accountCredit$,
|
||||||
|
this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)),
|
||||||
|
]).pipe(
|
||||||
|
switchMap(([credit, formValue]) => {
|
||||||
|
const selectedPaymentType = formValue.paymentForm?.type;
|
||||||
|
if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) {
|
||||||
|
return of(true); // Not using account credit, so this check doesn't apply
|
||||||
|
}
|
||||||
|
|
||||||
|
return credit ? of(credit >= this.cartSummaryComponent.total()) : of(false);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,76 +247,98 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async processUpgrade(): Promise<UpgradePaymentResult> {
|
private async processUpgrade(): Promise<UpgradePaymentResult> {
|
||||||
// Get common values
|
|
||||||
const country = this.formGroup.value?.billingAddress?.country;
|
|
||||||
const postalCode = this.formGroup.value?.billingAddress?.postalCode;
|
|
||||||
|
|
||||||
if (!this.selectedPlan) {
|
if (!this.selectedPlan) {
|
||||||
throw new Error("No plan selected");
|
throw new Error("No plan selected");
|
||||||
}
|
}
|
||||||
if (!country || !postalCode) {
|
|
||||||
|
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
|
||||||
|
const organizationName = this.formGroup.value?.organizationName;
|
||||||
|
|
||||||
|
if (!billingAddress.country || !billingAddress.postalCode) {
|
||||||
throw new Error("Billing address is incomplete");
|
throw new Error("Billing address is incomplete");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate organization name for Families plan
|
|
||||||
const organizationName = this.formGroup.value?.organizationName;
|
|
||||||
if (this.isFamiliesPlan && !organizationName) {
|
if (this.isFamiliesPlan && !organizationName) {
|
||||||
throw new Error("Organization name is required");
|
throw new Error("Organization name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get payment method
|
const paymentMethod = await this.getPaymentMethod();
|
||||||
const tokenizedPaymentMethod = await this.paymentComponent?.tokenize();
|
|
||||||
|
|
||||||
if (!tokenizedPaymentMethod) {
|
if (!paymentMethod) {
|
||||||
throw new Error("Payment method is required");
|
throw new Error("Payment method is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the upgrade based on plan type
|
const isTokenizedPayment = "token" in paymentMethod;
|
||||||
if (this.isFamiliesPlan) {
|
|
||||||
const paymentFormValues = {
|
if (!isTokenizedPayment && this.isFamiliesPlan) {
|
||||||
|
throw new Error("Tokenized payment is required for families plan");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.isFamiliesPlan
|
||||||
|
? this.processFamiliesUpgrade(
|
||||||
|
organizationName!,
|
||||||
|
billingAddress,
|
||||||
|
paymentMethod as TokenizedPaymentMethod,
|
||||||
|
)
|
||||||
|
: this.processPremiumUpgrade(paymentMethod, billingAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processFamiliesUpgrade(
|
||||||
|
organizationName: string,
|
||||||
|
billingAddress: BillingAddress,
|
||||||
|
paymentMethod: TokenizedPaymentMethod,
|
||||||
|
): Promise<UpgradePaymentResult> {
|
||||||
|
const paymentFormValues: PaymentFormValues = {
|
||||||
organizationName,
|
organizationName,
|
||||||
billingAddress: { country, postalCode },
|
billingAddress,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.upgradePaymentService.upgradeToFamilies(
|
const response = await this.upgradePaymentService.upgradeToFamilies(
|
||||||
this.account(),
|
this.account(),
|
||||||
this.selectedPlan,
|
this.selectedPlan!,
|
||||||
tokenizedPaymentMethod,
|
paymentMethod,
|
||||||
paymentFormValues,
|
paymentFormValues,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { status: UpgradePaymentStatus.UpgradedToFamilies, organizationId: response.id };
|
return { status: UpgradePaymentStatus.UpgradedToFamilies, organizationId: response.id };
|
||||||
} else {
|
}
|
||||||
await this.upgradePaymentService.upgradeToPremium(tokenizedPaymentMethod, {
|
|
||||||
country,
|
private async processPremiumUpgrade(
|
||||||
postalCode,
|
paymentMethod: NonTokenizedPaymentMethod | TokenizedPaymentMethod,
|
||||||
});
|
billingAddress: BillingAddress,
|
||||||
|
): Promise<UpgradePaymentResult> {
|
||||||
|
await this.upgradePaymentService.upgradeToPremium(paymentMethod, billingAddress);
|
||||||
return { status: UpgradePaymentStatus.UpgradedToPremium, organizationId: null };
|
return { status: UpgradePaymentStatus.UpgradedToPremium, organizationId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get payment method based on selected type
|
||||||
|
* If using account credit, returns a non-tokenized payment method
|
||||||
|
* Otherwise, tokenizes the payment method from the payment component
|
||||||
|
*/
|
||||||
|
private async getPaymentMethod(): Promise<
|
||||||
|
NonTokenizedPaymentMethod | TokenizedPaymentMethod | null
|
||||||
|
> {
|
||||||
|
const isAccountCreditSelected =
|
||||||
|
this.formGroup.value?.paymentForm?.type === NonTokenizablePaymentMethods.accountCredit;
|
||||||
|
|
||||||
|
if (isAccountCreditSelected) {
|
||||||
|
return { type: NonTokenizablePaymentMethods.accountCredit };
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.paymentComponent?.tokenize();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create an observable for tax calculation
|
// Create an observable for tax calculation
|
||||||
private refreshSalesTax$(): Observable<number> {
|
private refreshSalesTax$(): Observable<number> {
|
||||||
const billingAddress = {
|
if (this.formGroup.invalid || !this.selectedPlan) {
|
||||||
country: this.formGroup.value?.billingAddress?.country,
|
|
||||||
postalCode: this.formGroup.value?.billingAddress?.postalCode,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!this.selectedPlan || !billingAddress.country || !billingAddress.postalCode) {
|
|
||||||
return of(0);
|
return of(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert Promise to Observable
|
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
|
||||||
|
|
||||||
return from(
|
return from(
|
||||||
this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan, {
|
this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan, billingAddress),
|
||||||
line1: null,
|
|
||||||
line2: null,
|
|
||||||
city: null,
|
|
||||||
state: null,
|
|
||||||
country: billingAddress.country,
|
|
||||||
postalCode: billingAddress.postalCode,
|
|
||||||
taxId: null,
|
|
||||||
}),
|
|
||||||
).pipe(
|
).pipe(
|
||||||
catchError((error: unknown) => {
|
catchError((error: unknown) => {
|
||||||
this.logService.error("Tax calculation failed:", error);
|
this.logService.error("Tax calculation failed:", error);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type AccountCreditPaymentMethod = typeof NonTokenizablePaymentMethods.acc
|
|||||||
|
|
||||||
export type TokenizablePaymentMethod =
|
export type TokenizablePaymentMethod =
|
||||||
(typeof TokenizablePaymentMethods)[keyof typeof TokenizablePaymentMethods];
|
(typeof TokenizablePaymentMethods)[keyof typeof TokenizablePaymentMethods];
|
||||||
|
export type NonTokenizablePaymentMethod =
|
||||||
|
(typeof NonTokenizablePaymentMethods)[keyof typeof NonTokenizablePaymentMethods];
|
||||||
|
|
||||||
export const isTokenizablePaymentMethod = (value: string): value is TokenizablePaymentMethod => {
|
export const isTokenizablePaymentMethod = (value: string): value is TokenizablePaymentMethod => {
|
||||||
const valid = Object.values(TokenizablePaymentMethods) as readonly string[];
|
const valid = Object.values(TokenizablePaymentMethods) as readonly string[];
|
||||||
@@ -40,3 +42,7 @@ export type TokenizedPaymentMethod = {
|
|||||||
type: TokenizablePaymentMethod;
|
type: TokenizablePaymentMethod;
|
||||||
token: string;
|
token: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NonTokenizedPaymentMethod = {
|
||||||
|
type: NonTokenizablePaymentMethod;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user