mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-07 19:07:45 +01:00
[PM-15814]Alert owners of reseller-managed orgs to renewal events (#12607)
* Changes for the reseller alert * Resolve the null error * Refactor the reseller service * Fix the a failing test due to null date * Fix the No overload matches error * Resolve the null error * Resolve the null error * Resolve the null error * Change the date format * Remove unwanted comment * Refactor changes * Add the feature flag
This commit is contained in:
parent
03d0957814
commit
899b16966a
142
apps/web/src/app/billing/services/reseller-warning.service.ts
Normal file
142
apps/web/src/app/billing/services/reseller-warning.service.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
export interface ResellerWarning {
|
||||
type: "info" | "warning";
|
||||
message: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class ResellerWarningService {
|
||||
private readonly RENEWAL_WARNING_DAYS = 14;
|
||||
private readonly GRACE_PERIOD_DAYS = 30;
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
getWarning(
|
||||
organization: Organization,
|
||||
organizationBillingMetadata: OrganizationBillingMetadataResponse,
|
||||
): ResellerWarning | null {
|
||||
if (!organization.hasReseller) {
|
||||
return null; // If no reseller, return null immediately
|
||||
}
|
||||
|
||||
// Check for past due warning first (highest priority)
|
||||
if (this.shouldShowPastDueWarning(organizationBillingMetadata)) {
|
||||
const gracePeriodEnd = this.getGracePeriodEndDate(organizationBillingMetadata.invoiceDueDate);
|
||||
if (!gracePeriodEnd) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "warning",
|
||||
message: this.i18nService.t(
|
||||
"resellerPastDueWarning",
|
||||
organization.providerName,
|
||||
this.formatDate(gracePeriodEnd),
|
||||
),
|
||||
} as ResellerWarning;
|
||||
}
|
||||
|
||||
// Check for open invoice warning
|
||||
if (this.shouldShowInvoiceWarning(organizationBillingMetadata)) {
|
||||
const invoiceCreatedDate = organizationBillingMetadata.invoiceCreatedDate;
|
||||
const invoiceDueDate = organizationBillingMetadata.invoiceDueDate;
|
||||
if (!invoiceCreatedDate || !invoiceDueDate) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "info",
|
||||
message: this.i18nService.t(
|
||||
"resellerOpenInvoiceWarning",
|
||||
organization.providerName,
|
||||
this.formatDate(organizationBillingMetadata.invoiceCreatedDate),
|
||||
this.formatDate(organizationBillingMetadata.invoiceDueDate),
|
||||
),
|
||||
} as ResellerWarning;
|
||||
}
|
||||
|
||||
// Check for renewal warning
|
||||
if (this.shouldShowRenewalWarning(organizationBillingMetadata)) {
|
||||
const subPeriodEndDate = organizationBillingMetadata.subPeriodEndDate;
|
||||
if (!subPeriodEndDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "info",
|
||||
message: this.i18nService.t(
|
||||
"resellerRenewalWarning",
|
||||
organization.providerName,
|
||||
this.formatDate(organizationBillingMetadata.subPeriodEndDate),
|
||||
),
|
||||
} as ResellerWarning;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private shouldShowRenewalWarning(
|
||||
organizationBillingMetadata: OrganizationBillingMetadataResponse,
|
||||
): boolean {
|
||||
if (
|
||||
!organizationBillingMetadata.hasSubscription ||
|
||||
!organizationBillingMetadata.subPeriodEndDate
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const renewalDate = new Date(organizationBillingMetadata.subPeriodEndDate);
|
||||
const daysUntilRenewal = Math.ceil(
|
||||
(renewalDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
return daysUntilRenewal <= this.RENEWAL_WARNING_DAYS;
|
||||
}
|
||||
|
||||
private shouldShowInvoiceWarning(
|
||||
organizationBillingMetadata: OrganizationBillingMetadataResponse,
|
||||
): boolean {
|
||||
if (
|
||||
!organizationBillingMetadata.hasOpenInvoice ||
|
||||
!organizationBillingMetadata.invoiceDueDate
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const invoiceDueDate = new Date(organizationBillingMetadata.invoiceDueDate);
|
||||
return invoiceDueDate > new Date();
|
||||
}
|
||||
|
||||
private shouldShowPastDueWarning(
|
||||
organizationBillingMetadata: OrganizationBillingMetadataResponse,
|
||||
): boolean {
|
||||
if (
|
||||
!organizationBillingMetadata.hasOpenInvoice ||
|
||||
!organizationBillingMetadata.invoiceDueDate
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const invoiceDueDate = new Date(organizationBillingMetadata.invoiceDueDate);
|
||||
return invoiceDueDate <= new Date() && !organizationBillingMetadata.isSubscriptionUnpaid;
|
||||
}
|
||||
|
||||
private getGracePeriodEndDate(dueDate: Date | null): Date | null {
|
||||
if (!dueDate) {
|
||||
return null;
|
||||
}
|
||||
const gracePeriodEnd = new Date(dueDate);
|
||||
gracePeriodEnd.setDate(gracePeriodEnd.getDate() + this.GRACE_PERIOD_DAYS);
|
||||
return gracePeriodEnd;
|
||||
}
|
||||
|
||||
private formatDate(date: Date | null): string {
|
||||
if (!date) {
|
||||
return "N/A";
|
||||
}
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
@ -19,6 +19,18 @@
|
||||
</a>
|
||||
</bit-banner>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="resellerWarning$ | async as resellerWarning">
|
||||
<bit-banner
|
||||
id="reseller-warning-banner"
|
||||
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
|
||||
icon="bwi-billing"
|
||||
bannerType="info"
|
||||
[showClose]="false"
|
||||
*ngIf="!refreshing"
|
||||
>
|
||||
{{ resellerWarning?.message }}
|
||||
</bit-banner>
|
||||
</ng-container>
|
||||
|
||||
<app-org-vault-header
|
||||
[filter]="filter"
|
||||
|
@ -86,6 +86,10 @@ import {
|
||||
|
||||
import { GroupApiService, GroupView } from "../../admin-console/organizations/core";
|
||||
import { openEntityEventsDialog } from "../../admin-console/organizations/manage/entity-events.component";
|
||||
import {
|
||||
ResellerWarning,
|
||||
ResellerWarningService,
|
||||
} from "../../billing/services/reseller-warning.service";
|
||||
import { TrialFlowService } from "../../billing/services/trial-flow.service";
|
||||
import { FreeTrial } from "../../core/types/free-trial";
|
||||
import { SharedModule } from "../../shared";
|
||||
@ -187,6 +191,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
private hasSubscription$ = new BehaviorSubject<boolean>(false);
|
||||
protected currentSearchText$: Observable<string>;
|
||||
protected freeTrial$: Observable<FreeTrial>;
|
||||
protected resellerWarning$: Observable<ResellerWarning | null>;
|
||||
/**
|
||||
* A list of collections that the user can assign items to and edit those items within.
|
||||
* @protected
|
||||
@ -203,6 +208,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
|
||||
private extensionRefreshEnabled: boolean;
|
||||
private resellerManagedOrgAlert: boolean;
|
||||
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
|
||||
|
||||
private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe(
|
||||
@ -259,6 +265,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
private trialFlowService: TrialFlowService,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
private organizationBillingService: OrganizationBillingServiceAbstraction,
|
||||
private resellerWarningService: ResellerWarningService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@ -266,6 +273,10 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
FeatureFlag.ExtensionRefresh,
|
||||
);
|
||||
|
||||
this.resellerManagedOrgAlert = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.ResellerManagedOrgAlert,
|
||||
);
|
||||
|
||||
this.trashCleanupWarning = this.i18nService.t(
|
||||
this.platformUtilsService.isSelfHost()
|
||||
? "trashCleanupWarningSelfHosted"
|
||||
@ -612,6 +623,16 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
}),
|
||||
);
|
||||
|
||||
this.resellerWarning$ = organization$.pipe(
|
||||
filter((org) => org.isOwner && this.resellerManagedOrgAlert),
|
||||
switchMap((org) =>
|
||||
from(this.billingApiService.getOrganizationBillingMetadata(org.id)).pipe(
|
||||
map((metadata) => ({ org, metadata })),
|
||||
),
|
||||
),
|
||||
map(({ org, metadata }) => this.resellerWarningService.getWarning(org, metadata)),
|
||||
);
|
||||
|
||||
firstSetup$
|
||||
.pipe(
|
||||
switchMap(() => this.refresh$),
|
||||
|
@ -10011,5 +10011,48 @@
|
||||
},
|
||||
"organizationNameMaxLength": {
|
||||
"message": "Organization name cannot exceed 50 characters."
|
||||
},
|
||||
"resellerRenewalWarning": {
|
||||
"message": "Your subscription will renew soon. To insure uninterrupted service, contact $RESELLER$ to confirm your renewal before $RENEWAL_DATE$.",
|
||||
"placeholders": {
|
||||
"reseller": {
|
||||
"content": "$1",
|
||||
"example": "Reseller Name"
|
||||
},
|
||||
"renewal_date": {
|
||||
"content": "$2",
|
||||
"example": "01/01/2024"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resellerOpenInvoiceWarning": {
|
||||
"message": "An invoice for your subscription was issued on $ISSUED_DATE$. To insure uninterrupted service, contact $RESELLER$ to confirm your renewal before $DUE_DATE$.",
|
||||
"placeholders": {
|
||||
"reseller": {
|
||||
"content": "$1",
|
||||
"example": "Reseller Name"
|
||||
},
|
||||
"issued_date": {
|
||||
"content": "$2",
|
||||
"example": "01/01/2024"
|
||||
},
|
||||
"due_date": {
|
||||
"content": "$3",
|
||||
"example": "01/15/2024"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resellerPastDueWarning": {
|
||||
"message": "The invoice for your subscription has not been paid. To insure uninterrupted service, contact $RESELLER$ to confirm your renewal before $GRACE_PERIOD_END$.",
|
||||
"placeholders": {
|
||||
"reseller": {
|
||||
"content": "$1",
|
||||
"example": "Reseller Name"
|
||||
},
|
||||
"grace_period_end": {
|
||||
"content": "$2",
|
||||
"example": "02/14/2024"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,10 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
|
||||
isOnSecretsManagerStandalone: boolean;
|
||||
isSubscriptionUnpaid: boolean;
|
||||
hasSubscription: boolean;
|
||||
hasOpenInvoice: boolean;
|
||||
invoiceDueDate: Date | null;
|
||||
invoiceCreatedDate: Date | null;
|
||||
subPeriodEndDate: Date | null;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@ -14,5 +18,14 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
|
||||
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
|
||||
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
|
||||
this.hasSubscription = this.getResponseProperty("HasSubscription");
|
||||
this.hasOpenInvoice = this.getResponseProperty("HasOpenInvoice");
|
||||
|
||||
this.invoiceDueDate = this.parseDate(this.getResponseProperty("InvoiceDueDate"));
|
||||
this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate"));
|
||||
this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate"));
|
||||
}
|
||||
|
||||
private parseDate(dateString: any): Date | null {
|
||||
return dateString ? new Date(dateString) : null;
|
||||
}
|
||||
}
|
||||
|
@ -43,6 +43,7 @@ export enum FeatureFlag {
|
||||
PM11360RemoveProviderExportPermission = "pm-11360-remove-provider-export-permission",
|
||||
PM12443RemovePagingLogic = "pm-12443-remove-paging-logic",
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@ -96,6 +97,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM11360RemoveProviderExportPermission]: FALSE,
|
||||
[FeatureFlag.PM12443RemovePagingLogic]: FALSE,
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
Loading…
Reference in New Issue
Block a user