Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-15814]Alert owners of reseller-managed orgs to renewal events #12607

Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions apps/web/src/app/billing/services/reseller-warning.service.ts
Original file line number Diff line number Diff line change
@@ -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",
});
}
}
12 changes: 12 additions & 0 deletions apps/web/src/app/vault/org-vault/vault.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/app/vault/org-vault/vault.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -187,6 +191,8 @@ 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>;
protected resellerWarning: ResellerWarning;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this variable is used anywhere

/**
* A list of collections that the user can assign items to and edit those items within.
* @protected
Expand Down Expand Up @@ -259,6 +265,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private trialFlowService: TrialFlowService,
protected billingApiService: BillingApiServiceAbstraction,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private resellerWarningService: ResellerWarningService,
) {}

async ngOnInit() {
Expand Down Expand Up @@ -592,6 +599,7 @@ export class VaultComponent implements OnInit, OnDestroy {
.subscribe();

this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe();
//this.resellerWarning1$.pipe(takeUntil(this.destroy$)).subscribe();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be removed if it's not needed anymore

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like leftover commented out code, okay to remove?


this.freeTrial$ = combineLatest([
organization$,
Expand All @@ -612,6 +620,16 @@ export class VaultComponent implements OnInit, OnDestroy {
}),
);

this.resellerWarning$ = combineLatest([organization$]).pipe(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we only need combineLatest when we have multiple independent streams. This can be simplified a bit

Suggested change
this.resellerWarning$ = combineLatest([organization$]).pipe(
this.resellerWarning$ = organization$.pipe(
filter(org => org.isOwner),
switchMap(org => this.billingApiService
.getOrganizationBillingMetadata(org.id)
.pipe(map(metadata => ({org, metadata})))
),
map()

filter(([org]) => org.isOwner),
switchMap(([org]) =>
combineLatest([of(org), this.billingApiService.getOrganizationBillingMetadata(org.id)]),
),
map(([org, organizationMetaData]) => {
return this.resellerWarningService.getWarning(org, organizationMetaData);
}),
);

firstSetup$
.pipe(
switchMap(() => this.refresh$),
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
}
Loading