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:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user