1
0
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:
cyprain-okeke 2024-12-31 18:06:45 +01:00 committed by GitHub
parent 03d0957814
commit 899b16966a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 233 additions and 0 deletions

View 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",
});
}
}

View File

@ -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"

View File

@ -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$),

View File

@ -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"
}
}
}
}

View File

@ -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;
}
}

View File

@ -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;