mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +00: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:
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>
|
</a>
|
||||||
</bit-banner>
|
</bit-banner>
|
||||||
</ng-container>
|
</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
|
<app-org-vault-header
|
||||||
[filter]="filter"
|
[filter]="filter"
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ import {
|
|||||||
|
|
||||||
import { GroupApiService, GroupView } from "../../admin-console/organizations/core";
|
import { GroupApiService, GroupView } from "../../admin-console/organizations/core";
|
||||||
import { openEntityEventsDialog } from "../../admin-console/organizations/manage/entity-events.component";
|
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 { TrialFlowService } from "../../billing/services/trial-flow.service";
|
||||||
import { FreeTrial } from "../../core/types/free-trial";
|
import { FreeTrial } from "../../core/types/free-trial";
|
||||||
import { SharedModule } from "../../shared";
|
import { SharedModule } from "../../shared";
|
||||||
@@ -187,6 +191,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
private hasSubscription$ = new BehaviorSubject<boolean>(false);
|
private hasSubscription$ = new BehaviorSubject<boolean>(false);
|
||||||
protected currentSearchText$: Observable<string>;
|
protected currentSearchText$: Observable<string>;
|
||||||
protected freeTrial$: Observable<FreeTrial>;
|
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.
|
* A list of collections that the user can assign items to and edit those items within.
|
||||||
* @protected
|
* @protected
|
||||||
@@ -203,6 +208,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
|
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
|
||||||
private extensionRefreshEnabled: boolean;
|
private extensionRefreshEnabled: boolean;
|
||||||
|
private resellerManagedOrgAlert: boolean;
|
||||||
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
|
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
|
||||||
|
|
||||||
private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe(
|
private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe(
|
||||||
@@ -259,6 +265,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
private trialFlowService: TrialFlowService,
|
private trialFlowService: TrialFlowService,
|
||||||
protected billingApiService: BillingApiServiceAbstraction,
|
protected billingApiService: BillingApiServiceAbstraction,
|
||||||
private organizationBillingService: OrganizationBillingServiceAbstraction,
|
private organizationBillingService: OrganizationBillingServiceAbstraction,
|
||||||
|
private resellerWarningService: ResellerWarningService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
@@ -266,6 +273,10 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
FeatureFlag.ExtensionRefresh,
|
FeatureFlag.ExtensionRefresh,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.resellerManagedOrgAlert = await this.configService.getFeatureFlag(
|
||||||
|
FeatureFlag.ResellerManagedOrgAlert,
|
||||||
|
);
|
||||||
|
|
||||||
this.trashCleanupWarning = this.i18nService.t(
|
this.trashCleanupWarning = this.i18nService.t(
|
||||||
this.platformUtilsService.isSelfHost()
|
this.platformUtilsService.isSelfHost()
|
||||||
? "trashCleanupWarningSelfHosted"
|
? "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$
|
firstSetup$
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(() => this.refresh$),
|
switchMap(() => this.refresh$),
|
||||||
|
|||||||
@@ -10011,5 +10011,48 @@
|
|||||||
},
|
},
|
||||||
"organizationNameMaxLength": {
|
"organizationNameMaxLength": {
|
||||||
"message": "Organization name cannot exceed 50 characters."
|
"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;
|
isOnSecretsManagerStandalone: boolean;
|
||||||
isSubscriptionUnpaid: boolean;
|
isSubscriptionUnpaid: boolean;
|
||||||
hasSubscription: boolean;
|
hasSubscription: boolean;
|
||||||
|
hasOpenInvoice: boolean;
|
||||||
|
invoiceDueDate: Date | null;
|
||||||
|
invoiceCreatedDate: Date | null;
|
||||||
|
subPeriodEndDate: Date | null;
|
||||||
|
|
||||||
constructor(response: any) {
|
constructor(response: any) {
|
||||||
super(response);
|
super(response);
|
||||||
@@ -14,5 +18,14 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
|
|||||||
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
|
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
|
||||||
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
|
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
|
||||||
this.hasSubscription = this.getResponseProperty("HasSubscription");
|
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",
|
PM11360RemoveProviderExportPermission = "pm-11360-remove-provider-export-permission",
|
||||||
PM12443RemovePagingLogic = "pm-12443-remove-paging-logic",
|
PM12443RemovePagingLogic = "pm-12443-remove-paging-logic",
|
||||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||||
|
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||||
@@ -96,6 +97,7 @@ export const DefaultFeatureFlagValue = {
|
|||||||
[FeatureFlag.PM11360RemoveProviderExportPermission]: FALSE,
|
[FeatureFlag.PM11360RemoveProviderExportPermission]: FALSE,
|
||||||
[FeatureFlag.PM12443RemovePagingLogic]: FALSE,
|
[FeatureFlag.PM12443RemovePagingLogic]: FALSE,
|
||||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||||
|
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
|
||||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||||
|
|
||||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||||
|
|||||||
Reference in New Issue
Block a user