diff --git a/backend/btrixcloud/emailsender.py b/backend/btrixcloud/emailsender.py index ab157673..06f5311d 100644 --- a/backend/btrixcloud/emailsender.py +++ b/backend/btrixcloud/emailsender.py @@ -28,6 +28,7 @@ class EmailSender: smtp_port: int smtp_use_tls: bool support_email: str + survey_url: str templates: Jinja2Templates @@ -38,6 +39,7 @@ class EmailSender: self.password = os.environ.get("EMAIL_PASSWORD") or "" self.reply_to = os.environ.get("EMAIL_REPLY_TO") or self.sender self.support_email = os.environ.get("EMAIL_SUPPORT") or self.reply_to + self.survey_url = os.environ.get("USER_SURVEY_URL") or "" self.smtp_server = os.environ.get("EMAIL_SMTP_HOST") self.smtp_port = int(os.environ.get("EMAIL_SMTP_PORT", 587)) self.smtp_use_tls = is_bool(os.environ.get("EMAIL_SMTP_USE_TLS")) @@ -160,3 +162,27 @@ class EmailSender: self._send_encrypted( receiver_email, "failed_bg_job", job=job, org=org, finished=finished ) + + def send_subscription_will_be_canceled( + self, + cancel_date: datetime, + user_name: str, + receiver_email: str, + org: Organization, + headers=None, + ): + """Send email indicating subscription is cancelled and all org data will be deleted""" + + origin = get_origin(headers) + org_url = f"{origin}/orgs/{org.slug}/" + + self._send_encrypted( + receiver_email, + "sub_cancel", + org_url=org_url, + user_name=user_name, + org_name=org.name, + cancel_date=cancel_date, + support_email=self.support_email, + survey_url=self.survey_url, + ) diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index e33c1d76..e5b1ae47 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -276,6 +276,16 @@ class OrgOps: return Organization.from_dict(res) + async def get_users_for_org( + self, org: Organization, min_role=UserRole.VIEWER + ) -> List[User]: + """get users for org""" + uuid_ids = [UUID(id_) for id_, role in org.users.items() if role >= min_role] + users: List[User] = [] + async for user_dict in self.users_db.find({"id": {"$in": uuid_ids}}): + users.append(User(**user_dict)) + return users + async def get_org_by_id(self, oid: UUID) -> Organization: """Get an org by id""" res = await self.orgs.find_one({"_id": oid}) @@ -489,7 +499,7 @@ class OrgOps: org_data = await self.orgs.find_one_and_update( {"subscription.subId": update.subId}, {"$set": query}, - return_document=ReturnDocument.AFTER, + return_document=ReturnDocument.BEFORE, ) if not org_data: return None diff --git a/backend/btrixcloud/subs.py b/backend/btrixcloud/subs.py index 5cb4d779..fd4a3596 100644 --- a/backend/btrixcloud/subs.py +++ b/backend/btrixcloud/subs.py @@ -4,7 +4,9 @@ Subscription API handling from typing import Callable, Union, Any, Optional, Tuple, List import os +import asyncio from uuid import UUID +from datetime import datetime from fastapi import Depends, HTTPException, Request import aiohttp @@ -114,8 +116,38 @@ class SubOps: ) await self.add_sub_event("update", update, org.id) + + if update.futureCancelDate and self.should_send_cancel_email(org, update): + asyncio.create_task(self.send_cancel_emails(update.futureCancelDate, org)) + return {"updated": True} + def should_send_cancel_email(self, org: Organization, update: SubscriptionUpdate): + """Should we sent a cancellation email""" + if not update.futureCancelDate: + return False + + if not org.subscription: + return False + + # new cancel date, send + if update.futureCancelDate != org.subscription.futureCancelDate: + return True + + # if 'trialing_canceled', send + if update.status == "trialing_canceled": + return True + + return False + + async def send_cancel_emails(self, cancel_date: datetime, org: Organization): + """Asynchronously send cancellation emails to all org admins""" + users = await self.org_ops.get_users_for_org(org, UserRole.OWNER) + for user in users: + self.user_manager.email.send_subscription_will_be_canceled( + cancel_date, user.name, user.email, org + ) + async def cancel_subscription(self, cancel: SubscriptionCancel) -> dict[str, bool]: """delete subscription data, and unless if readOnlyOnCancel is true, the entire org""" diff --git a/chart/email-templates/sub_cancel b/chart/email-templates/sub_cancel new file mode 100644 index 00000000..bcb780f6 --- /dev/null +++ b/chart/email-templates/sub_cancel @@ -0,0 +1,43 @@ +Your Browsertrix Subscription Has Been Canceled +~~~ + +
+Hello {{ user_name }},
+ +The Browsertrix subscription for "{{ org_name }}" has been cancelled at the end of this +subscription period.
+ +All data hosted on Browsertrix under: {{ org_url }} will be deleted on {{ cancel_date }}
+ +You can continue to use Browsertrix and download your data before this date. If you change your mind, you can still resubscribe +by going to Settings -> Billing tab after logging in.
+ +{% if survey_url %} +We hope you enjoyed using Browsertrix!
+ +To help us make Browsertrix better, we would be very grateful if you could complete a quick survey about your experience using Browsertrix.
+{% endif %} + +{% if support_email %} +If you'd like us to keep your data longer or have other questions, you can still reach out to us at: {{ support_email }} +{% endif %} +~~~ +Hello {{ name }}, + +The Browsertrix subscription for "{{ org_name }}" has been cancelled at the end of this +subscription period. + +All data hosted on Browsertrix under: {{ org_url }} will be deleted on {{ cancel_date }} + +You can continue to use Browsertrix and download your data before this date. If you change your mind, you can still resubscribe +by going to Settings -> Billing tab after logging in. + +{% if survey_url %} +We hoped you enjoyed using Browsertrix! + +To help us make Browsertrix better, we would be very grateful if you could complete a quick survey about your experience with Browsertrix: {{ survey_url }} +{% endif %} + +{% if support_email %} +If you'd like us to keep your data longer or have other questions, you can still reach out to us at: {{ support_email }} +{% endif %} diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index a027e35e..4da46697 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -75,6 +75,8 @@ data: SALES_EMAIL: "{{ .Values.sales_email }}" + USER_SURVEY_URL: "{{ .Values.user_survey_url }}" + LOG_SENT_EMAILS: "{{ .Values.email.log_sent_emails }}" BACKEND_IMAGE: "{{ .Values.backend_image }}" @@ -194,7 +196,7 @@ metadata: data: {{- $email_templates := .Values.email.templates | default dict }} -{{- range tuple "failed_bg_job" "invite" "password_reset" "validate" }} +{{- range tuple "failed_bg_job" "invite" "password_reset" "validate" "sub_cancel" }} {{ . }}: | {{ ((get $email_templates . ) | default ($.Files.Get (printf "%s/%s" "email-templates" . ))) | indent 4 }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index d8d62c48..2caeb1c5 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -122,6 +122,11 @@ sign_up_url: "" # set e-mail to show for subscriptions related info sales_email: "" + +# survey e-mail +# if set, subscription cancellation e-mails will include a link to this survey +user_survey_url: "" + # if set, print last 'log_failed_crawl_lines' of each failed # crawl pod to backend operator stdout # mostly intended for debugging / testing diff --git a/frontend/src/features/org/org-status-banner.ts b/frontend/src/features/org/org-status-banner.ts index 90ed61a8..947f93a4 100644 --- a/frontend/src/features/org/org-status-banner.ts +++ b/frontend/src/features/org/org-status-banner.ts @@ -80,7 +80,11 @@ export class OrgStatusBanner extends BtrixElement { }); } - const isTrial = subscription?.status === SubscriptionStatus.Trialing; + const isTrialingCanceled = + subscription?.status == SubscriptionStatus.TrialingCanceled; + const isTrial = + subscription?.status === SubscriptionStatus.Trialing || + isTrialingCanceled; // show banner if < this many days of trial is left const MAX_TRIAL_DAYS_SHOW_BANNER = 4; @@ -120,8 +124,8 @@ export class OrgStatusBanner extends BtrixElement { !readOnly && !readOnlyOnCancel && !!futureCancelDate && - isTrial && - daysDiff < MAX_TRIAL_DAYS_SHOW_BANNER, + ((isTrial && daysDiff < MAX_TRIAL_DAYS_SHOW_BANNER) || + isTrialingCanceled), content: () => { return { @@ -138,7 +142,7 @@ export class OrgStatusBanner extends BtrixElement {
${msg( html`Your free trial ends on ${dateStr}. To continue using - Browsertrix, select Choose Plan in + Browsertrix, select Subscribe Now in ${billingTabLink}.`, )}
diff --git a/frontend/src/pages/org/settings/components/billing.ts b/frontend/src/pages/org/settings/components/billing.ts index 1011defe..dbb62943 100644 --- a/frontend/src/pages/org/settings/components/billing.ts +++ b/frontend/src/pages/org/settings/components/billing.ts @@ -41,6 +41,7 @@ export class OrgSettingsBilling extends BtrixElement { let label = msg("Manage Billing"); switch (subscription.status) { + case SubscriptionStatus.TrialingCanceled: case SubscriptionStatus.Trialing: { label = msg("Subscribe Now"); break; @@ -140,7 +141,9 @@ export class OrgSettingsBilling extends BtrixElement { >${org.subscription.status === - SubscriptionStatus.Trialing + SubscriptionStatus.Trialing || + org.subscription.status === + SubscriptionStatus.TrialingCanceled ? msg( str`To continue using Browsertrix at the end of your trial, click “${this.portalUrlLabel}”.`, ) @@ -269,6 +274,7 @@ export class OrgSettingsBilling extends BtrixElement { `; break; } + case SubscriptionStatus.TrialingCanceled: case SubscriptionStatus.Trialing: { statusLabel = html` ${msg("Free Trial")} diff --git a/frontend/src/types/billing.ts b/frontend/src/types/billing.ts index 24d1dfa9..2aaa5011 100644 --- a/frontend/src/types/billing.ts +++ b/frontend/src/types/billing.ts @@ -5,6 +5,7 @@ import { apiDateSchema } from "./api"; export enum SubscriptionStatus { Active = "active", Trialing = "trialing", + TrialingCanceled = "trialing_canceled", PausedPaymentFailed = "paused_payment_failed", Cancelled = "cancelled", }