Skip to content

Commit

Permalink
Send subscription cancelation email (#2234)
Browse files Browse the repository at this point in the history
Adds sending a cancellation email when a subscription is cancelled.
- The email may also include an option survey optional survey URL, if
configured in helm chart `survey_url` setting.
- Cancellation e-mail configured in `sub_cancel` e-mail template
- E-mails are sent to all org admins.
- Also adds `trialing_canceled` subscription state to differentiate from
a default `trialing` which will automatically rollover into `active`.
- The email is sent when: a new cancellation date is added for an
`active` subscription, or a `trialing` subscription is changed to to
`trialing_canceled`. (A subscription can be canceled/uncanceled several
times before actual date, and e-mail is sent every time it is canceled.)
- The 'You have X days left of your trial' is also always displayed when
state is in trialing_canceled.

Fixes #2229
---------

Co-authored-by: Tessa Walsh <[email protected]>
  • Loading branch information
ikreymer and tw4l authored Dec 12, 2024
1 parent a65ca49 commit db39333
Show file tree
Hide file tree
Showing 9 changed files with 137 additions and 8 deletions.
26 changes: 26 additions & 0 deletions backend/btrixcloud/emailsender.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class EmailSender:
smtp_port: int
smtp_use_tls: bool
support_email: str
survey_url: str

templates: Jinja2Templates

Expand All @@ -38,6 +39,7 @@ def __init__(self):
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"))
Expand Down Expand Up @@ -160,3 +162,27 @@ def send_background_job_failed(
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,
)
12 changes: 11 additions & 1 deletion backend/btrixcloud/orgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,16 @@ async def get_org_for_user_by_id(

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})
Expand Down Expand Up @@ -489,7 +499,7 @@ async def update_subscription_data(
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
Expand Down
32 changes: 32 additions & 0 deletions backend/btrixcloud/subs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

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
Expand Down Expand Up @@ -114,8 +116,38 @@ async def update_subscription(self, update: SubscriptionUpdate) -> dict[str, boo
)

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

Expand Down
43 changes: 43 additions & 0 deletions chart/email-templates/sub_cancel
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
Your Browsertrix Subscription Has Been Canceled
~~~
<html>
<body>
<p>Hello {{ user_name }},</p>

<p>The Browsertrix subscription for "{{ org_name }}" has been cancelled at the end of this
subscription period.</p>

<p style="font-weight: bold">All data hosted on Browsertrix under: <a href="{{ org_url}}">{{ org_url }}</a> will be deleted on {{ cancel_date }}</p>

<p>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 <i>Settings -> Billing</i> tab after logging in.</p>

{% if survey_url %}
<p>We hope you enjoyed using Browsertrix!</p>

<p>To help us make Browsertrix better, we would be very grateful if you could complete <a href="{{ survey_url }}">a quick survey</a> about your experience using Browsertrix.</p>
{% endif %}

{% if support_email %}
<p>If you'd like us to keep your data longer or have other questions, you can still reach out to us at: <a href="mailto:{{ support_email }}">{{ support_email }}</a>
{% 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 %}
4 changes: 3 additions & 1 deletion chart/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down Expand Up @@ -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 }}
5 changes: 5 additions & 0 deletions chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/features/org/org-status-banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -138,7 +142,7 @@ export class OrgStatusBanner extends BtrixElement {
<p>
${msg(
html`Your free trial ends on ${dateStr}. To continue using
Browsertrix, select <strong>Choose Plan</strong> in
Browsertrix, select <strong>Subscribe Now</strong> in
${billingTabLink}.`,
)}
</p>
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/pages/org/settings/components/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -140,7 +141,9 @@ export class OrgSettingsBilling extends BtrixElement {
></sl-icon>
<div>
${org.subscription.status ===
SubscriptionStatus.Trialing
SubscriptionStatus.Trialing ||
org.subscription.status ===
SubscriptionStatus.TrialingCanceled
? html`
<span class="font-medium text-neutral-700">
${msg(
Expand Down Expand Up @@ -183,7 +186,9 @@ export class OrgSettingsBilling extends BtrixElement {
org.subscription
? html` <p class="mb-3 leading-normal">
${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}”.`,
)
Expand Down Expand Up @@ -269,6 +274,7 @@ export class OrgSettingsBilling extends BtrixElement {
`;
break;
}
case SubscriptionStatus.TrialingCanceled:
case SubscriptionStatus.Trialing: {
statusLabel = html`
<span class="text-success-700">${msg("Free Trial")}</span>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { apiDateSchema } from "./api";
export enum SubscriptionStatus {
Active = "active",
Trialing = "trialing",
TrialingCanceled = "trialing_canceled",
PausedPaymentFailed = "paused_payment_failed",
Cancelled = "cancelled",
}
Expand Down

0 comments on commit db39333

Please sign in to comment.