1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +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:
Stephon Brown
2025-10-23 15:05:50 -04:00
committed by GitHub
parent d6785037ba
commit c80e8d1d8b
6 changed files with 298 additions and 74 deletions

View File

@@ -2,7 +2,11 @@ import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAddress, TokenizedPaymentMethod } from "../payment/types";
import {
BillingAddress,
NonTokenizedPaymentMethod,
TokenizedPaymentMethod,
} from "../payment/types";
@Injectable()
export class AccountBillingClient {
@@ -14,11 +18,17 @@ export class AccountBillingClient {
}
purchasePremiumSubscription = async (
paymentMethod: TokenizedPaymentMethod,
paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod,
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
): Promise<void> => {
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);
};
}

View File

@@ -15,8 +15,18 @@ import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { LogService } from "@bitwarden/logging";
import { AccountBillingClient, TaxAmounts, TaxClient } from "../../../../clients";
import { BillingAddress, TokenizedPaymentMethod } from "../../../../payment/types";
import {
AccountBillingClient,
SubscriberBillingClient,
TaxAmounts,
TaxClient,
} from "../../../../clients";
import {
BillingAddress,
NonTokenizablePaymentMethods,
NonTokenizedPaymentMethod,
TokenizedPaymentMethod,
} from "../../../../payment/types";
import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier";
import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";
@@ -30,6 +40,7 @@ describe("UpgradePaymentService", () => {
const mockSyncService = mock<SyncService>();
const mockOrganizationService = mock<OrganizationService>();
const mockAccountService = mock<AccountService>();
const mockSubscriberBillingClient = mock<SubscriberBillingClient>();
mockApiService.refreshIdentityToken.mockResolvedValue({});
mockSyncService.fullSync.mockResolvedValue(true);
@@ -104,6 +115,7 @@ describe("UpgradePaymentService", () => {
mockReset(mockLogService);
mockReset(mockOrganizationService);
mockReset(mockAccountService);
mockReset(mockSubscriberBillingClient);
mockAccountService.activeAccount$ = of(null);
mockOrganizationService.organizations$.mockReturnValue(of([]));
@@ -111,7 +123,10 @@ describe("UpgradePaymentService", () => {
TestBed.configureTestingModule({
providers: [
UpgradePaymentService,
{
provide: SubscriberBillingClient,
useValue: mockSubscriberBillingClient,
},
{
provide: OrganizationBillingServiceAbstraction,
useValue: mockOrganizationBillingService,
@@ -172,6 +187,7 @@ describe("UpgradePaymentService", () => {
mockSyncService,
mockOrganizationService,
mockAccountService,
mockSubscriberBillingClient,
);
// Act & Assert
@@ -223,6 +239,7 @@ describe("UpgradePaymentService", () => {
mockSyncService,
mockOrganizationService,
mockAccountService,
mockSubscriberBillingClient,
);
// Act & Assert
@@ -256,6 +273,7 @@ describe("UpgradePaymentService", () => {
mockSyncService,
mockOrganizationService,
mockAccountService,
mockSubscriberBillingClient,
);
// 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$", () => {
it("should return the admin console route for the first free organization the user owns", (done) => {
// Arrange
@@ -309,6 +389,7 @@ describe("UpgradePaymentService", () => {
mockSyncService,
mockOrganizationService,
mockAccountService,
mockSubscriberBillingClient,
);
// Act & Assert
@@ -405,24 +486,58 @@ describe("UpgradePaymentService", () => {
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
const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod;
const accountCreditPaymentMethod: NonTokenizedPaymentMethod = {
type: NonTokenizablePaymentMethods.accountCredit,
};
mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue();
// Act & Assert
await expect(
sut.upgradeToPremium(incompletePaymentMethod, mockBillingAddress),
).rejects.toThrow("Payment method type or token is missing");
// Act
await sut.upgradeToPremium(accountCreditPaymentMethod, mockBillingAddress);
// 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
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
await expect(
sut.upgradeToPremium(mockTokenizedPaymentMethod, incompleteBillingAddress),
sut.upgradeToPremium(mockTokenizedPaymentMethod, missingCountry),
).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);
});
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;
await expect(
@@ -512,7 +627,15 @@ describe("UpgradePaymentService", () => {
organizationName: "Test Organization",
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");
});
});
});

View File

@@ -11,21 +11,25 @@ import {
OrganizationBillingServiceAbstraction,
SubscriptionInformation,
} 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 { LogService } from "@bitwarden/logging";
import {
AccountBillingClient,
OrganizationSubscriptionPurchase,
SubscriberBillingClient,
TaxAmounts,
TaxClient,
} from "../../../../clients";
import {
BillingAddress,
NonTokenizablePaymentMethods,
NonTokenizedPaymentMethod,
tokenizablePaymentMethodToLegacyEnum,
TokenizedPaymentMethod,
} from "../../../../payment/types";
import { mapAccountToSubscriber } from "../../../../types";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
@@ -59,6 +63,7 @@ export class UpgradePaymentService {
private syncService: SyncService,
private organizationService: OrganizationService,
private accountService: AccountService,
private subscriberBillingClient: SubscriberBillingClient,
) {}
userIsOwnerOfFreeOrg$: Observable<boolean> = this.accountService.activeAccount$.pipe(
@@ -79,6 +84,12 @@ export class UpgradePaymentService {
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
*/
@@ -130,7 +141,7 @@ export class UpgradePaymentService {
* Process premium upgrade
*/
async upgradeToPremium(
paymentMethod: TokenizedPaymentMethod,
paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod,
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
): Promise<void> {
this.validatePaymentAndBillingInfo(paymentMethod, billingAddress);
@@ -169,10 +180,7 @@ export class UpgradePaymentService {
passwordManagerSeats: passwordManagerSeats,
},
payment: {
paymentMethod: [
paymentMethod.token,
tokenizablePaymentMethodToLegacyEnum(paymentMethod.type),
],
paymentMethod: [paymentMethod.token, this.getPaymentMethodType(paymentMethod)],
billing: {
country: billingAddress.country,
postalCode: billingAddress.postalCode,
@@ -195,11 +203,19 @@ export class UpgradePaymentService {
}
private validatePaymentAndBillingInfo(
paymentMethod: TokenizedPaymentMethod,
paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod,
billingAddress: { country: string; postalCode: string },
): void {
if (!paymentMethod?.token || !paymentMethod?.type) {
throw new Error("Payment method type or token is missing");
if (!paymentMethod?.type) {
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) {
@@ -211,4 +227,12 @@ export class UpgradePaymentService {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
}
private getPaymentMethodType(
paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod,
): PaymentMethodType {
return paymentMethod.type === NonTokenizablePaymentMethods.accountCredit
? PaymentMethodType.Credit
: tokenizablePaymentMethodToLegacyEnum(paymentMethod.type);
}
}

View File

@@ -34,8 +34,10 @@
<h5 bitTypography="h5">{{ "paymentMethod" | i18n }}</h5>
<app-enter-payment-method
[showBankAccount]="isFamiliesPlan"
[showAccountCredit]="!isFamiliesPlan"
[group]="formGroup.controls.paymentForm"
[includeBillingAddress]="false"
[hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async"
#paymentComponent
></app-enter-payment-method>
<h5 bitTypography="h5" class="tw-pt-4 tw-pb-2">{{ "billingAddress" | i18n }}</h5>

View File

@@ -10,7 +10,16 @@ import {
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -23,7 +32,14 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
getBillingAddressFromForm,
} from "../../../payment/components";
import {
BillingAddress,
NonTokenizablePaymentMethods,
NonTokenizedPaymentMethod,
TokenizedPaymentMethod,
} from "../../../payment/types";
import { BillingServicesModule } from "../../../services";
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
import { BitwardenSubscriber } from "../../../types";
@@ -33,7 +49,11 @@ import {
PersonalSubscriptionPricingTierIds,
} 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
@@ -80,6 +100,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
protected goBack = output<void>();
protected complete = output<UpgradePaymentResult>();
protected selectedPlan: PlanDetails | null = null;
protected hasEnoughAccountCredit$!: Observable<boolean>;
@ViewChild(EnterPaymentMethodComponent) paymentComponent!: EnterPaymentMethodComponent;
@ViewChild(CartSummaryComponent) cartSummaryComponent!: CartSummaryComponent;
@@ -155,6 +176,22 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
.subscribe((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);
}
@@ -210,76 +247,98 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
}
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) {
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");
}
// Validate organization name for Families plan
const organizationName = this.formGroup.value?.organizationName;
if (this.isFamiliesPlan && !organizationName) {
throw new Error("Organization name is required");
}
// Get payment method
const tokenizedPaymentMethod = await this.paymentComponent?.tokenize();
const paymentMethod = await this.getPaymentMethod();
if (!tokenizedPaymentMethod) {
if (!paymentMethod) {
throw new Error("Payment method is required");
}
// Process the upgrade based on plan type
if (this.isFamiliesPlan) {
const paymentFormValues = {
organizationName,
billingAddress: { country, postalCode },
};
const isTokenizedPayment = "token" in paymentMethod;
const response = await this.upgradePaymentService.upgradeToFamilies(
this.account(),
this.selectedPlan,
tokenizedPaymentMethod,
paymentFormValues,
);
return { status: UpgradePaymentStatus.UpgradedToFamilies, organizationId: response.id };
} else {
await this.upgradePaymentService.upgradeToPremium(tokenizedPaymentMethod, {
country,
postalCode,
});
return { status: UpgradePaymentStatus.UpgradedToPremium, organizationId: null };
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,
billingAddress,
};
const response = await this.upgradePaymentService.upgradeToFamilies(
this.account(),
this.selectedPlan!,
paymentMethod,
paymentFormValues,
);
return { status: UpgradePaymentStatus.UpgradedToFamilies, organizationId: response.id };
}
private async processPremiumUpgrade(
paymentMethod: NonTokenizedPaymentMethod | TokenizedPaymentMethod,
billingAddress: BillingAddress,
): Promise<UpgradePaymentResult> {
await this.upgradePaymentService.upgradeToPremium(paymentMethod, billingAddress);
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
private refreshSalesTax$(): Observable<number> {
const billingAddress = {
country: this.formGroup.value?.billingAddress?.country,
postalCode: this.formGroup.value?.billingAddress?.postalCode,
};
if (!this.selectedPlan || !billingAddress.country || !billingAddress.postalCode) {
if (this.formGroup.invalid || !this.selectedPlan) {
return of(0);
}
// Convert Promise to Observable
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
return from(
this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan, {
line1: null,
line2: null,
city: null,
state: null,
country: billingAddress.country,
postalCode: billingAddress.postalCode,
taxId: null,
}),
this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan, billingAddress),
).pipe(
catchError((error: unknown) => {
this.logService.error("Tax calculation failed:", error);

View File

@@ -17,6 +17,8 @@ export type AccountCreditPaymentMethod = typeof NonTokenizablePaymentMethods.acc
export type TokenizablePaymentMethod =
(typeof TokenizablePaymentMethods)[keyof typeof TokenizablePaymentMethods];
export type NonTokenizablePaymentMethod =
(typeof NonTokenizablePaymentMethods)[keyof typeof NonTokenizablePaymentMethods];
export const isTokenizablePaymentMethod = (value: string): value is TokenizablePaymentMethod => {
const valid = Object.values(TokenizablePaymentMethods) as readonly string[];
@@ -40,3 +42,7 @@ export type TokenizedPaymentMethod = {
type: TokenizablePaymentMethod;
token: string;
};
export type NonTokenizedPaymentMethod = {
type: NonTokenizablePaymentMethod;
};