diff --git a/apps/web/src/app/billing/services/reseller-warning.service.ts b/apps/web/src/app/billing/services/reseller-warning.service.ts
new file mode 100644
index 00000000000..bfd5be3233a
--- /dev/null
+++ b/apps/web/src/app/billing/services/reseller-warning.service.ts
@@ -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",
+ });
+ }
+}
diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html
index 52f3ea026ff..512f97144de 100644
--- a/apps/web/src/app/vault/org-vault/vault.component.html
+++ b/apps/web/src/app/vault/org-vault/vault.component.html
@@ -19,6 +19,18 @@
+
+
+ {{ resellerWarning?.message }}
+
+
(false);
protected currentSearchText$: Observable;
protected freeTrial$: Observable;
+ protected resellerWarning$: Observable;
/**
* 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();
protected addAccessStatus$ = new BehaviorSubject(0);
private extensionRefreshEnabled: boolean;
+ private resellerManagedOrgAlert: boolean;
private vaultItemDialogRef?: DialogRef | 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$),
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index acbb348048c..7c338fc6e97 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -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"
+ }
+ }
}
}
diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts
index d9733aa80f2..c5023cb64c1 100644
--- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts
+++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts
@@ -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;
}
}
diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts
index cc2abed3ba1..135119bf133 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -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;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;