1
0
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:
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 { 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);
}; };
} }

View File

@@ -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");
}); });
}); });
}); });

View File

@@ -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);
}
} }

View File

@@ -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>

View File

@@ -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);

View File

@@ -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;
};