1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +00:00

[PM-25463] Work towards complete usage of Payments domain (#16532)

* Use payment domain

* Fixing lint and test issue

* Fix organization plans tax issue

* PM-26297: Use existing billing address for tax calculation if it exists

* PM-26344: Check existing payment method on submit
This commit is contained in:
Alex Morask
2025-10-01 10:26:47 -05:00
committed by GitHub
parent 147b511d64
commit d9d8050998
117 changed files with 1505 additions and 5212 deletions

View File

@@ -1,55 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [title]="'addCredit' | i18n">
<ng-container bitDialogContent>
<p bitTypography="body1">{{ "creditDelayed" | i18n }}</p>
<div class="tw-grid tw-grid-cols-2">
<bit-radio-group formControlName="paymentMethod">
<bit-radio-button [value]="paymentMethodType.PayPal">
<bit-label> <i class="bwi bwi-paypal"></i>{{ "payPal" | i18n }}</bit-label>
</bit-radio-button>
<bit-radio-button [value]="paymentMethodType.BitPay">
<bit-label> <i class="bwi bwi-bitcoin"></i>{{ "bitcoin" | i18n }}</bit-label>
</bit-radio-button>
</bit-radio-group>
</div>
<div class="tw-grid tw-grid-cols-2">
<bit-form-field>
<bit-label>{{ "amount" | i18n }}</bit-label>
<input bitInput type="number" formControlName="creditAmount" step="0.01" required />
<span bitPrefix>$USD</span>
</bit-form-field>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="ResultType.Closed"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>
<form #payPalForm action="{{ payPalConfig.buttonAction }}" method="post" target="_top">
<input type="hidden" name="cmd" value="_xclick" />
<input type="hidden" name="business" value="{{ payPalConfig.businessId }}" />
<input type="hidden" name="button_subtype" value="services" />
<input type="hidden" name="no_note" value="1" />
<input type="hidden" name="no_shipping" value="1" />
<input type="hidden" name="rm" value="1" />
<input type="hidden" name="return" value="{{ payPalConfig.returnUrl }}" />
<input type="hidden" name="cancel_return" value="{{ payPalConfig.returnUrl }}" />
<input type="hidden" name="currency_code" value="USD" />
<input type="hidden" name="image_url" value="https://bitwarden.com/images/paypal-banner.png" />
<input type="hidden" name="bn" value="PP-BuyNowBF:btn_buynow_LG.gif:NonHosted" />
<input type="hidden" name="amount" value="{{ formGroup.get('creditAmount').value }}" />
<input type="hidden" name="custom" value="{{ payPalConfig.customField }}" />
<input type="hidden" name="item_name" value="Bitwarden Account Credit" />
<input type="hidden" name="item_number" value="{{ payPalConfig.subject }}" />
</form>

View File

@@ -1,167 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { firstValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService, AccountInfo } from "@bitwarden/common/auth/abstractions/account.service";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
export type AddAccountCreditDialogParams = {
organizationId?: string;
providerId?: string;
};
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum AddAccountCreditDialogResultType {
Closed = "closed",
Submitted = "submitted",
}
export const openAddAccountCreditDialog = (
dialogService: DialogService,
dialogConfig: DialogConfig<AddAccountCreditDialogParams>,
) =>
dialogService.open<AddAccountCreditDialogResultType, AddAccountCreditDialogParams>(
AddAccountCreditDialogComponent,
dialogConfig,
);
type PayPalConfig = {
businessId?: string;
buttonAction?: string;
returnUrl?: string;
customField?: string;
subject?: string;
};
@Component({
templateUrl: "./add-account-credit-dialog.component.html",
standalone: false,
})
export class AddAccountCreditDialogComponent implements OnInit {
@ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm: ElementRef;
protected formGroup = new FormGroup({
paymentMethod: new FormControl<PaymentMethodType>(PaymentMethodType.PayPal),
creditAmount: new FormControl<number>(null, [Validators.required, Validators.min(0.01)]),
});
protected payPalConfig: PayPalConfig;
protected ResultType = AddAccountCreditDialogResultType;
private organization?: Organization;
private provider?: Provider;
private user?: { id: UserId } & AccountInfo;
constructor(
private accountService: AccountService,
private apiService: ApiService,
private configService: ConfigService,
@Inject(DIALOG_DATA) private dialogParams: AddAccountCreditDialogParams,
private dialogRef: DialogRef<AddAccountCreditDialogResultType>,
private organizationService: OrganizationService,
private platformUtilsService: PlatformUtilsService,
private providerService: ProviderService,
) {
this.payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig;
}
protected readonly paymentMethodType = PaymentMethodType;
submit = async () => {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
if (this.formGroup.value.paymentMethod === PaymentMethodType.PayPal) {
this.payPalForm.nativeElement.submit();
return;
}
if (this.formGroup.value.paymentMethod === PaymentMethodType.BitPay) {
const request = this.getBitPayInvoiceRequest();
const bitPayUrl = await this.apiService.postBitPayInvoice(request);
this.platformUtilsService.launchUri(bitPayUrl);
return;
}
this.dialogRef.close(AddAccountCreditDialogResultType.Submitted);
};
async ngOnInit(): Promise<void> {
let payPalCustomField: string;
if (this.dialogParams.organizationId) {
this.formGroup.patchValue({
creditAmount: 20.0,
});
this.user = await firstValueFrom(this.accountService.activeAccount$);
this.organization = await firstValueFrom(
this.organizationService
.organizations$(this.user.id)
.pipe(
map((organizations) =>
organizations.find((org) => org.id === this.dialogParams.organizationId),
),
),
);
payPalCustomField = "organization_id:" + this.organization.id;
this.payPalConfig.subject = this.organization.name;
} else if (this.dialogParams.providerId) {
this.formGroup.patchValue({
creditAmount: 20.0,
});
this.provider = await firstValueFrom(
this.providerService.get$(this.dialogParams.providerId, this.user.id),
);
payPalCustomField = "provider_id:" + this.provider.id;
this.payPalConfig.subject = this.provider.name;
} else {
this.formGroup.patchValue({
creditAmount: 10.0,
});
payPalCustomField = "user_id:" + this.user.id;
this.payPalConfig.subject = this.user.email;
}
const region = await firstValueFrom(this.configService.cloudRegion$);
payPalCustomField += ",account_credit:1";
payPalCustomField += `,region:${region}`;
this.payPalConfig.customField = payPalCustomField;
this.payPalConfig.returnUrl = window.location.href;
}
getBitPayInvoiceRequest(): BitPayInvoiceRequest {
const request = new BitPayInvoiceRequest();
if (this.organization) {
request.name = this.organization.name;
request.organizationId = this.organization.id;
} else if (this.provider) {
request.name = this.provider.name;
request.providerId = this.provider.id;
} else {
request.email = this.user.email;
request.userId = this.user.id;
}
request.credit = true;
request.amount = this.formGroup.value.creditAmount;
request.returnUrl = window.location.href;
return request;
}
}

View File

@@ -1,5 +1 @@
export * from "./add-account-credit-dialog/add-account-credit-dialog.component";
export * from "./invoices/invoices.component";
export * from "./invoices/no-invoices.component";
export * from "./manage-tax-information/manage-tax-information.component";
export * from "./premium.component";

View File

@@ -1,60 +0,0 @@
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<bit-table *ngIf="!loading">
<ng-container header>
<tr>
<th bitCell>{{ "date" | i18n }}</th>
<th bitCell>{{ "invoice" | i18n }}</th>
<th bitCell>{{ "total" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
<th bitCell>{{ "clientDetails" | i18n }}</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let invoice of invoices">
<td bitCell>{{ invoice.date | date: "mediumDate" }}</td>
<td bitCell>
<a
href="{{ invoice.url }}"
target="_blank"
rel="noreferrer"
title="{{ 'viewInvoice' | i18n }}"
>
{{ invoice.number }}
</a>
</td>
<td bitCell>{{ invoice.total | currency: "$" }}</td>
<td bitCell *ngIf="expandInvoiceStatus(invoice) as expandedInvoiceStatus">
<span *ngIf="expandedInvoiceStatus === 'open'">
{{ "open" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'unpaid'">
<i class="bwi bwi-error tw-text-muted" aria-hidden="true"></i>
{{ "unpaid" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'paid'">
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
{{ "paid" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'uncollectible'">
<i class="bwi bwi-error tw-text-muted" aria-hidden="true"></i>
{{ "uncollectible" | i18n | titlecase }}
</span>
</td>
<td bitCell>
<button type="button" bitLink (click)="runExport(invoice.id)">
<span class="tw-font-normal">{{ "downloadCSV" | i18n }}</span>
</button>
</td>
</tr>
</ng-template>
</bit-table>
<div *ngIf="!invoices || invoices.length === 0" class="tw-mt-10">
<app-no-invoices></app-no-invoices>
</div>

View File

@@ -1,67 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input, OnInit } from "@angular/core";
import {
InvoiceResponse,
InvoicesResponse,
} from "@bitwarden/common/billing/models/response/invoices.response";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
@Component({
selector: "app-invoices",
templateUrl: "./invoices.component.html",
standalone: false,
})
export class InvoicesComponent implements OnInit {
@Input() startWith?: InvoicesResponse;
@Input() getInvoices?: () => Promise<InvoicesResponse>;
@Input() getClientInvoiceReport?: (invoiceId: string) => Promise<string>;
@Input() getClientInvoiceReportName?: (invoiceResponse: InvoiceResponse) => string;
protected invoices: InvoiceResponse[] = [];
protected loading = true;
constructor(private fileDownloadService: FileDownloadService) {}
runExport = async (invoiceId: string): Promise<void> => {
const blobData = await this.getClientInvoiceReport(invoiceId);
let fileName = "report.csv";
if (this.getClientInvoiceReportName) {
const invoice = this.invoices.find((invoice) => invoice.id === invoiceId);
fileName = this.getClientInvoiceReportName(invoice);
}
this.fileDownloadService.download({
fileName,
blobData,
blobOptions: {
type: "text/csv",
},
});
};
async ngOnInit(): Promise<void> {
if (this.startWith) {
this.invoices = this.startWith.invoices;
} else if (this.getInvoices) {
const response = await this.getInvoices();
this.invoices = response.invoices;
}
this.loading = false;
}
expandInvoiceStatus = (
invoice: InvoiceResponse,
): "open" | "unpaid" | "paid" | "uncollectible" => {
switch (invoice.status) {
case "open": {
const dueDate = new Date(invoice.dueDate);
return dueDate < new Date() ? "unpaid" : invoice.status;
}
case "paid":
case "uncollectible": {
return invoice.status;
}
}
};
}

View File

@@ -1,14 +0,0 @@
import { Component } from "@angular/core";
import { CreditCardIcon } from "@bitwarden/assets/svg";
@Component({
selector: "app-no-invoices",
template: `<bit-no-items [icon]="icon">
<div slot="title">{{ "noInvoicesToList" | i18n }}</div>
</bit-no-items>`,
standalone: false,
})
export class NoInvoicesComponent {
icon = CreditCardIcon;
}

View File

@@ -1,90 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "country" | i18n }}</bit-label>
<bit-select formControlName="country" data-testid="country">
<bit-option
*ngFor="let country of countries"
[value]="country.value"
[disabled]="country.disabled"
[label]="country.name"
></bit-option>
</bit-select>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="postalCode"
autocomplete="postal-code"
data-testid="postal-code"
/>
</bit-form-field>
</div>
<ng-container *ngIf="isTaxSupported">
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "address1" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="line1"
autocomplete="address-line1"
data-testid="address-line1"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "address2" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="line2"
autocomplete="address-line2"
data-testid="address-line2"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "cityTown" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="city"
autocomplete="address-level2"
data-testid="city"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="state"
autocomplete="address-level1"
data-testid="state"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="showTaxIdField">
<bit-form-field disableMargin>
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label>
<input bitInput type="text" formControlName="taxId" data-testid="tax-id" />
</bit-form-field>
</div>
</ng-container>
<div class="tw-col-span-12" *ngIf="!!onSubmit">
<button bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>
</div>
</div>
</form>

View File

@@ -1,262 +0,0 @@
import { CommonModule } from "@angular/common";
import { SimpleChange } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ReactiveFormsModule } from "@angular/forms";
import { mock, MockProxy } from "jest-mock-extended";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SelectModule, FormFieldModule, BitSubmitDirective } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { ManageTaxInformationComponent } from "./manage-tax-information.component";
describe("ManageTaxInformationComponent", () => {
let sut: ManageTaxInformationComponent;
let fixture: ComponentFixture<ManageTaxInformationComponent>;
let mockTaxService: MockProxy<TaxServiceAbstraction>;
beforeEach(async () => {
mockTaxService = mock();
await TestBed.configureTestingModule({
declarations: [ManageTaxInformationComponent],
providers: [
{ provide: TaxServiceAbstraction, useValue: mockTaxService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
],
imports: [
CommonModule,
ReactiveFormsModule,
SelectModule,
FormFieldModule,
BitSubmitDirective,
I18nPipe,
],
}).compileComponents();
fixture = TestBed.createComponent(ManageTaxInformationComponent);
sut = fixture.componentInstance;
fixture.autoDetectChanges();
});
afterEach(() => {
jest.clearAllMocks();
});
it("creates successfully", () => {
expect(sut).toBeTruthy();
});
it("should initialize with all values empty in startWith", async () => {
// Arrange
sut.startWith = {
country: "",
postalCode: "",
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
};
// Act
fixture.detectChanges();
// Assert
const startWithValue = sut.startWith;
expect(startWithValue.line1).toHaveLength(0);
expect(startWithValue.line2).toHaveLength(0);
expect(startWithValue.city).toHaveLength(0);
expect(startWithValue.state).toHaveLength(0);
expect(startWithValue.postalCode).toHaveLength(0);
expect(startWithValue.country).toHaveLength(0);
expect(startWithValue.taxId).toHaveLength(0);
});
it("should update the tax information protected state when form is updated", async () => {
// Arrange
const line1Value = "123 Street";
const line2Value = "Apt. 5";
const cityValue = "New York";
const stateValue = "NY";
const countryValue = "USA";
const postalCodeValue = "123 Street";
sut.startWith = {
country: countryValue,
postalCode: "",
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
};
sut.showTaxIdField = false;
mockTaxService.isCountrySupported.mockResolvedValue(true);
// Act
await sut.ngOnInit();
fixture.detectChanges();
const line1: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='line1']",
);
const line2: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='line2']",
);
const city: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='city']",
);
const state: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='state']",
);
const postalCode: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='postalCode']",
);
line1.value = line1Value;
line2.value = line2Value;
city.value = cityValue;
state.value = stateValue;
postalCode.value = postalCodeValue;
line1.dispatchEvent(new Event("input"));
line2.dispatchEvent(new Event("input"));
city.dispatchEvent(new Event("input"));
state.dispatchEvent(new Event("input"));
postalCode.dispatchEvent(new Event("input"));
await fixture.whenStable();
// Assert
// Assert that the internal tax information reflects the form
const taxInformation = sut.getTaxInformation();
expect(taxInformation.line1).toBe(line1Value);
expect(taxInformation.line2).toBe(line2Value);
expect(taxInformation.city).toBe(cityValue);
expect(taxInformation.state).toBe(stateValue);
expect(taxInformation.postalCode).toBe(postalCodeValue);
expect(taxInformation.country).toBe(countryValue);
expect(taxInformation.taxId).toHaveLength(0);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(2);
});
it("should not show address fields except postal code if country is not supported for taxes", async () => {
// Arrange
const countryValue = "UNKNOWN";
sut.startWith = {
country: countryValue,
postalCode: "",
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
};
sut.showTaxIdField = false;
mockTaxService.isCountrySupported.mockResolvedValue(false);
// Act
await sut.ngOnInit();
fixture.detectChanges();
const line1: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='line1']",
);
const line2: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='line2']",
);
const city: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='city']",
);
const state: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='state']",
);
const postalCode: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='postalCode']",
);
// Assert
expect(line1).toBeNull();
expect(line2).toBeNull();
expect(city).toBeNull();
expect(state).toBeNull();
//Should be visible
expect(postalCode).toBeTruthy();
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1);
});
it("should not show the tax id field if showTaxIdField is set to false", async () => {
// Arrange
const countryValue = "USA";
sut.startWith = {
country: countryValue,
postalCode: "",
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
};
sut.showTaxIdField = false;
mockTaxService.isCountrySupported.mockResolvedValue(true);
// Act
await sut.ngOnInit();
fixture.detectChanges();
// Assert
const taxId: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='taxId']",
);
expect(taxId).toBeNull();
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1);
});
it("should clear the tax id field if showTaxIdField is set to false after being true", async () => {
// Arrange
const countryValue = "USA";
const taxIdValue = "A12345678";
sut.startWith = {
country: countryValue,
postalCode: "",
taxId: taxIdValue,
line1: "",
line2: "",
city: "",
state: "",
};
sut.showTaxIdField = true;
mockTaxService.isCountrySupported.mockResolvedValue(true);
await sut.ngOnInit();
fixture.detectChanges();
const initialTaxIdValue = fixture.nativeElement.querySelector(
"input[formControlName='taxId']",
).value;
// Act
sut.showTaxIdField = false;
sut.ngOnChanges({ showTaxIdField: new SimpleChange(true, false, false) });
fixture.detectChanges();
// Assert
const taxId = fixture.nativeElement.querySelector("input[formControlName='taxId']");
expect(taxId).toBeNull();
const taxInformation = sut.getTaxInformation();
expect(taxInformation.taxId).toBeNull();
expect(initialTaxIdValue).toEqual(taxIdValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,166 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { debounceTime } from "rxjs/operators";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { CountryListItem, TaxInformation } from "@bitwarden/common/billing/models/domain";
@Component({
selector: "app-manage-tax-information",
templateUrl: "./manage-tax-information.component.html",
standalone: false,
})
export class ManageTaxInformationComponent implements OnInit, OnDestroy, OnChanges {
@Input() startWith: TaxInformation;
@Input() onSubmit?: (taxInformation: TaxInformation) => Promise<void>;
@Input() showTaxIdField: boolean = true;
/**
* Emits when the tax information has changed.
*/
@Output() taxInformationChanged = new EventEmitter<TaxInformation>();
/**
* Emits when the tax information has been updated.
*/
@Output() taxInformationUpdated = new EventEmitter();
private taxInformation: TaxInformation;
protected formGroup = this.formBuilder.group({
country: ["", Validators.required],
postalCode: ["", Validators.required],
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
});
protected isTaxSupported: boolean;
private destroy$ = new Subject<void>();
protected readonly countries: CountryListItem[] = this.taxService.getCountries();
constructor(
private formBuilder: FormBuilder,
private taxService: TaxServiceAbstraction,
) {}
getTaxInformation(): TaxInformation {
return this.taxInformation;
}
submit = async () => {
this.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
await this.onSubmit?.(this.taxInformation);
this.taxInformationUpdated.emit();
};
validate(): boolean {
this.markAllAsTouched();
return this.formGroup.valid;
}
markAllAsTouched() {
this.formGroup.markAllAsTouched();
}
async ngOnInit() {
this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => {
this.taxInformation = {
country: values.country,
postalCode: values.postalCode,
taxId: values.taxId,
line1: values.line1,
line2: values.line2,
city: values.city,
state: values.state,
};
});
if (this.startWith) {
this.formGroup.controls.country.setValue(this.startWith.country);
this.formGroup.controls.postalCode.setValue(this.startWith.postalCode);
this.isTaxSupported =
this.startWith && this.startWith.country
? await this.taxService.isCountrySupported(this.startWith.country)
: false;
if (this.isTaxSupported) {
this.formGroup.controls.taxId.setValue(this.startWith.taxId);
this.formGroup.controls.line1.setValue(this.startWith.line1);
this.formGroup.controls.line2.setValue(this.startWith.line2);
this.formGroup.controls.city.setValue(this.startWith.city);
this.formGroup.controls.state.setValue(this.startWith.state);
}
}
this.formGroup.controls.country.valueChanges
.pipe(debounceTime(1000), takeUntil(this.destroy$))
.subscribe((country: string) => {
this.taxService
.isCountrySupported(country)
.then((isSupported) => (this.isTaxSupported = isSupported))
.catch(() => (this.isTaxSupported = false))
.finally(() => {
if (!this.isTaxSupported) {
this.formGroup.controls.taxId.setValue(null);
this.formGroup.controls.line1.setValue(null);
this.formGroup.controls.line2.setValue(null);
this.formGroup.controls.city.setValue(null);
this.formGroup.controls.state.setValue(null);
}
if (this.taxInformationChanged) {
this.taxInformationChanged.emit(this.taxInformation);
}
});
});
this.formGroup.controls.postalCode.valueChanges
.pipe(debounceTime(1000), takeUntil(this.destroy$))
.subscribe(() => {
if (this.taxInformationChanged) {
this.taxInformationChanged.emit(this.taxInformation);
}
});
this.formGroup.controls.taxId.valueChanges
.pipe(debounceTime(1000), takeUntil(this.destroy$))
.subscribe(() => {
if (this.taxInformationChanged) {
this.taxInformationChanged.emit(this.taxInformation);
}
});
}
ngOnChanges(changes: SimpleChanges): void {
// Clear the value of the tax-id if states have been changed in the parent component
const showTaxIdField = changes["showTaxIdField"];
if (showTaxIdField && !showTaxIdField.currentValue) {
this.formGroup.controls.taxId.setValue(null);
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -2,12 +2,6 @@ import { CommonModule, DatePipe } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import {
AddAccountCreditDialogComponent,
InvoicesComponent,
NoInvoicesComponent,
ManageTaxInformationComponent,
} from "@bitwarden/angular/billing/components";
import {
AsyncActionsModule,
AutofocusDirective,
@@ -112,10 +106,6 @@ import { IconComponent } from "./vault/components/icon.component";
UserTypePipe,
IfFeatureDirective,
FingerprintPipe,
AddAccountCreditDialogComponent,
InvoicesComponent,
NoInvoicesComponent,
ManageTaxInformationComponent,
TwoFactorIconComponent,
],
exports: [
@@ -146,10 +136,6 @@ import { IconComponent } from "./vault/components/icon.component";
UserTypePipe,
IfFeatureDirective,
FingerprintPipe,
AddAccountCreditDialogComponent,
InvoicesComponent,
NoInvoicesComponent,
ManageTaxInformationComponent,
TwoFactorIconComponent,
TextDragDirective,
],

View File

@@ -144,14 +144,12 @@ import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/a
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service";
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { TaxService } from "@bitwarden/common/billing/services/tax.service";
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
import {
DefaultKeyGenerationService,
@@ -1398,11 +1396,6 @@ const safeProviders: SafeProvider[] = [
useClass: BillingApiService,
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: TaxServiceAbstraction,
useClass: TaxService,
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: BillingAccountProfileStateService,
useClass: DefaultBillingAccountProfileStateService,