diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 344e70b50a..5b9e7c9c82 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -8361,5 +8361,15 @@ "providerBillingEmailHint": { "message": "This email address will receive all invoices pertaining to this provider", "description": "A hint that shows up on the Provider setup page to inform the admin the billing email will receive the provider's invoices." + }, + "date": { + "message": "Date" + }, + "exportClientReport": { + "message": "Export client report" + }, + "invoiceNumberHeader": { + "message": "Invoice number", + "description": "A table header for an invoice's number" } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index ffcfcd0ad8..7ee6a067d4 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -34,6 +34,7 @@ > + + +

{{ "invoices" | i18n }}

+ +
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/billing-history/provider-billing-history.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/provider-billing-history.component.ts new file mode 100644 index 0000000000..24ca4cf36d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/provider-billing-history.component.ts @@ -0,0 +1,48 @@ +import { DatePipe } from "@angular/common"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { map, Subject, takeUntil } from "rxjs"; + +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { InvoiceResponse } from "@bitwarden/common/billing/models/response/invoices.response"; + +@Component({ + templateUrl: "./provider-billing-history.component.html", +}) +export class ProviderBillingHistoryComponent implements OnInit, OnDestroy { + private providerId: string; + + private destroy$ = new Subject(); + + constructor( + private activatedRoute: ActivatedRoute, + private billingApiService: BillingApiServiceAbstraction, + private datePipe: DatePipe, + ) {} + + getClientInvoiceReport = (invoiceId: string) => + this.billingApiService.getProviderClientInvoiceReport(this.providerId, invoiceId); + + getClientInvoiceReportName = (invoice: InvoiceResponse) => { + const date = this.datePipe.transform(invoice.date, "yyyyMMdd"); + return `bitwarden_provider_${date}_${invoice.number}`; + }; + + getInvoices = async () => await this.billingApiService.getProviderInvoices(this.providerId); + + ngOnInit() { + this.activatedRoute.params + .pipe( + map(({ providerId }) => { + this.providerId = providerId; + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/index.ts index 71af56f7b0..c16862888d 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/index.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/index.ts @@ -1,3 +1,4 @@ +export * from "./billing-history/provider-billing-history.component"; export * from "./clients"; export * from "./guards/has-consolidated-billing.guard"; export * from "./payment-method/provider-select-payment-method-dialog.component"; diff --git a/libs/angular/src/billing/components/index.ts b/libs/angular/src/billing/components/index.ts index 748a005df8..5db97c869d 100644 --- a/libs/angular/src/billing/components/index.ts +++ b/libs/angular/src/billing/components/index.ts @@ -1,4 +1,5 @@ export * from "./add-account-credit-dialog/add-account-credit-dialog.component"; +export * from "./invoices/invoices.component"; export * from "./manage-tax-information/manage-tax-information.component"; export * from "./select-payment-method/select-payment-method.component"; export * from "./verify-bank-account/verify-bank-account.component"; diff --git a/libs/angular/src/billing/components/invoices/invoices.component.html b/libs/angular/src/billing/components/invoices/invoices.component.html new file mode 100644 index 0000000000..c382300554 --- /dev/null +++ b/libs/angular/src/billing/components/invoices/invoices.component.html @@ -0,0 +1,66 @@ + + + {{ "loading" | i18n }} + + + + + {{ "date" | i18n }} + {{ "invoiceNumberHeader" | i18n }} + {{ "total" | i18n }} + {{ "status" | i18n }} + + + + + {{ invoice.date | date: "mediumDate" }} + + + {{ invoice.number }} + + + {{ invoice.total | currency: "$" }} + {{ invoice.status | titlecase }} + + + + + + {{ "viewInvoice" | i18n }} + + + + + + + diff --git a/libs/angular/src/billing/components/invoices/invoices.component.ts b/libs/angular/src/billing/components/invoices/invoices.component.ts new file mode 100644 index 0000000000..3a16bff58e --- /dev/null +++ b/libs/angular/src/billing/components/invoices/invoices.component.ts @@ -0,0 +1,49 @@ +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", +}) +export class InvoicesComponent implements OnInit { + @Input() startWith?: InvoicesResponse; + @Input() getInvoices?: () => Promise; + @Input() getClientInvoiceReport?: (invoiceId: string) => Promise; + @Input() getClientInvoiceReportName?: (invoiceResponse: InvoiceResponse) => string; + + protected invoices: InvoiceResponse[] = []; + protected loading = true; + + constructor(private fileDownloadService: FileDownloadService) {} + + runExport = async (invoiceId: string): Promise => { + 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 { + if (this.startWith) { + this.invoices = this.startWith.invoices; + } else if (this.getInvoices) { + const response = await this.getInvoices(); + this.invoices = response.invoices; + } + this.loading = false; + } +} diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index ccb7446d86..59d50ee389 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { AddAccountCreditDialogComponent, + InvoicesComponent, ManageTaxInformationComponent, SelectPaymentMethodComponent, VerifyBankAccountComponent, @@ -15,8 +16,11 @@ import { CheckboxModule, DialogModule, FormFieldModule, + IconButtonModule, + MenuModule, RadioButtonModule, SelectModule, + TableModule, ToastModule, TypographyModule, } from "@bitwarden/components"; @@ -66,6 +70,9 @@ import { IconComponent } from "./vault/components/icon.component"; CheckboxModule, DialogModule, TypographyModule, + TableModule, + MenuModule, + IconButtonModule, ], declarations: [ A11yInvalidDirective, @@ -96,6 +103,7 @@ import { IconComponent } from "./vault/components/icon.component"; IfFeatureDirective, FingerprintPipe, AddAccountCreditDialogComponent, + InvoicesComponent, ManageTaxInformationComponent, SelectPaymentMethodComponent, VerifyBankAccountComponent, @@ -130,6 +138,7 @@ import { IconComponent } from "./vault/components/icon.component"; IfFeatureDirective, FingerprintPipe, AddAccountCreditDialogComponent, + InvoicesComponent, ManageTaxInformationComponent, SelectPaymentMethodComponent, VerifyBankAccountComponent, diff --git a/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts index 117b318768..de3d6dd1e9 100644 --- a/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts @@ -2,6 +2,7 @@ import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; import { TokenizedPaymentMethodRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-method.request"; import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; +import { InvoicesResponse } from "@bitwarden/common/billing/models/response/invoices.response"; import { PaymentInformationResponse } from "@bitwarden/common/billing/models/response/payment-information.response"; import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; @@ -41,6 +42,10 @@ export abstract class BillingApiServiceAbstraction { getPlans: () => Promise>; + getProviderClientInvoiceReport: (providerId: string, invoiceId: string) => Promise; + + getProviderInvoices: (providerId: string) => Promise; + getProviderPaymentInformation: (providerId: string) => Promise; getProviderSubscription: (providerId: string) => Promise; diff --git a/libs/common/src/billing/models/response/invoices.response.ts b/libs/common/src/billing/models/response/invoices.response.ts new file mode 100644 index 0000000000..73170ff5c9 --- /dev/null +++ b/libs/common/src/billing/models/response/invoices.response.ts @@ -0,0 +1,34 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class InvoicesResponse extends BaseResponse { + invoices: InvoiceResponse[] = []; + + constructor(response: any) { + super(response); + const invoices = this.getResponseProperty("Invoices"); + if (invoices && invoices.length) { + this.invoices = invoices.map((t: any) => new InvoiceResponse(t)); + } + } +} + +export class InvoiceResponse extends BaseResponse { + id: string; + date: string; + number: string; + total: number; + status: string; + url: string; + pdfUrl: string; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.date = this.getResponseProperty("Date"); + this.number = this.getResponseProperty("Number"); + this.total = this.getResponseProperty("Total"); + this.status = this.getResponseProperty("Status"); + this.url = this.getResponseProperty("Url"); + this.pdfUrl = this.getResponseProperty("PdfUrl"); + } +} diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index 13e5205bdd..333c9ab011 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -1,3 +1,5 @@ +import { InvoicesResponse } from "@bitwarden/common/billing/models/response/invoices.response"; + import { ApiService } from "../../abstractions/api.service"; import { BillingApiServiceAbstraction } from "../../billing/abstractions"; import { PaymentMethodType } from "../../billing/enums"; @@ -106,6 +108,28 @@ export class BillingApiService implements BillingApiServiceAbstraction { return new ListResponse(r, PlanResponse); } + async getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise { + const response = await this.apiService.send( + "GET", + "/providers/" + providerId + "/billing/invoices/" + invoiceId, + null, + true, + true, + ); + return response as string; + } + + async getProviderInvoices(providerId: string): Promise { + const response = await this.apiService.send( + "GET", + "/providers/" + providerId + "/billing/invoices", + null, + true, + true, + ); + return new InvoicesResponse(response); + } + async getProviderPaymentInformation(providerId: string): Promise { const response = await this.apiService.send( "GET", diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 61cfcb2583..40ff5e7bef 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1883,9 +1883,12 @@ export class ApiService implements ApiServiceAbstraction { const responseType = response.headers.get("content-type"); const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1; + const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1; if (hasResponse && response.status === 200 && responseIsJson) { const responseJson = await response.json(); return responseJson; + } else if (hasResponse && response.status === 200 && responseIsCsv) { + return await response.text(); } else if (response.status !== 200) { const error = await this.handleError(response, false, authed); return Promise.reject(error);