Send subscription cancelation email (#2234)

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 <tessa@bitarchivist.net>
This commit is contained in:
Ilya Kreymer 2024-12-12 11:52:38 -08:00 committed by GitHub
parent a65ca49ddd
commit db39333ef4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 137 additions and 8 deletions

View File

@ -28,6 +28,7 @@ class EmailSender:
smtp_port: int smtp_port: int
smtp_use_tls: bool smtp_use_tls: bool
support_email: str support_email: str
survey_url: str
templates: Jinja2Templates templates: Jinja2Templates
@ -38,6 +39,7 @@ class EmailSender:
self.password = os.environ.get("EMAIL_PASSWORD") or "" self.password = os.environ.get("EMAIL_PASSWORD") or ""
self.reply_to = os.environ.get("EMAIL_REPLY_TO") or self.sender 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.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_server = os.environ.get("EMAIL_SMTP_HOST")
self.smtp_port = int(os.environ.get("EMAIL_SMTP_PORT", 587)) self.smtp_port = int(os.environ.get("EMAIL_SMTP_PORT", 587))
self.smtp_use_tls = is_bool(os.environ.get("EMAIL_SMTP_USE_TLS")) self.smtp_use_tls = is_bool(os.environ.get("EMAIL_SMTP_USE_TLS"))
@ -160,3 +162,27 @@ class EmailSender:
self._send_encrypted( self._send_encrypted(
receiver_email, "failed_bg_job", job=job, org=org, finished=finished 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,
)

View File

@ -276,6 +276,16 @@ class OrgOps:
return Organization.from_dict(res) 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: async def get_org_by_id(self, oid: UUID) -> Organization:
"""Get an org by id""" """Get an org by id"""
res = await self.orgs.find_one({"_id": oid}) res = await self.orgs.find_one({"_id": oid})
@ -489,7 +499,7 @@ class OrgOps:
org_data = await self.orgs.find_one_and_update( org_data = await self.orgs.find_one_and_update(
{"subscription.subId": update.subId}, {"subscription.subId": update.subId},
{"$set": query}, {"$set": query},
return_document=ReturnDocument.AFTER, return_document=ReturnDocument.BEFORE,
) )
if not org_data: if not org_data:
return None return None

View File

@ -4,7 +4,9 @@ Subscription API handling
from typing import Callable, Union, Any, Optional, Tuple, List from typing import Callable, Union, Any, Optional, Tuple, List
import os import os
import asyncio
from uuid import UUID from uuid import UUID
from datetime import datetime
from fastapi import Depends, HTTPException, Request from fastapi import Depends, HTTPException, Request
import aiohttp import aiohttp
@ -114,8 +116,38 @@ class SubOps:
) )
await self.add_sub_event("update", update, org.id) 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} 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]: async def cancel_subscription(self, cancel: SubscriptionCancel) -> dict[str, bool]:
"""delete subscription data, and unless if readOnlyOnCancel is true, the entire org""" """delete subscription data, and unless if readOnlyOnCancel is true, the entire org"""

View File

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

View File

@ -75,6 +75,8 @@ data:
SALES_EMAIL: "{{ .Values.sales_email }}" SALES_EMAIL: "{{ .Values.sales_email }}"
USER_SURVEY_URL: "{{ .Values.user_survey_url }}"
LOG_SENT_EMAILS: "{{ .Values.email.log_sent_emails }}" LOG_SENT_EMAILS: "{{ .Values.email.log_sent_emails }}"
BACKEND_IMAGE: "{{ .Values.backend_image }}" BACKEND_IMAGE: "{{ .Values.backend_image }}"
@ -194,7 +196,7 @@ metadata:
data: data:
{{- $email_templates := .Values.email.templates | default dict }} {{- $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 }} {{ ((get $email_templates . ) | default ($.Files.Get (printf "%s/%s" "email-templates" . ))) | indent 4 }}
{{- end }} {{- end }}

View File

@ -122,6 +122,11 @@ sign_up_url: ""
# set e-mail to show for subscriptions related info # set e-mail to show for subscriptions related info
sales_email: "" 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 # if set, print last 'log_failed_crawl_lines' of each failed
# crawl pod to backend operator stdout # crawl pod to backend operator stdout
# mostly intended for debugging / testing # mostly intended for debugging / testing

View File

@ -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 // show banner if < this many days of trial is left
const MAX_TRIAL_DAYS_SHOW_BANNER = 4; const MAX_TRIAL_DAYS_SHOW_BANNER = 4;
@ -120,8 +124,8 @@ export class OrgStatusBanner extends BtrixElement {
!readOnly && !readOnly &&
!readOnlyOnCancel && !readOnlyOnCancel &&
!!futureCancelDate && !!futureCancelDate &&
isTrial && ((isTrial && daysDiff < MAX_TRIAL_DAYS_SHOW_BANNER) ||
daysDiff < MAX_TRIAL_DAYS_SHOW_BANNER, isTrialingCanceled),
content: () => { content: () => {
return { return {
@ -138,7 +142,7 @@ export class OrgStatusBanner extends BtrixElement {
<p> <p>
${msg( ${msg(
html`Your free trial ends on ${dateStr}. To continue using 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}.`, ${billingTabLink}.`,
)} )}
</p> </p>

View File

@ -41,6 +41,7 @@ export class OrgSettingsBilling extends BtrixElement {
let label = msg("Manage Billing"); let label = msg("Manage Billing");
switch (subscription.status) { switch (subscription.status) {
case SubscriptionStatus.TrialingCanceled:
case SubscriptionStatus.Trialing: { case SubscriptionStatus.Trialing: {
label = msg("Subscribe Now"); label = msg("Subscribe Now");
break; break;
@ -140,7 +141,9 @@ export class OrgSettingsBilling extends BtrixElement {
></sl-icon> ></sl-icon>
<div> <div>
${org.subscription.status === ${org.subscription.status ===
SubscriptionStatus.Trialing SubscriptionStatus.Trialing ||
org.subscription.status ===
SubscriptionStatus.TrialingCanceled
? html` ? html`
<span class="font-medium text-neutral-700"> <span class="font-medium text-neutral-700">
${msg( ${msg(
@ -183,7 +186,9 @@ export class OrgSettingsBilling extends BtrixElement {
org.subscription org.subscription
? html` <p class="mb-3 leading-normal"> ? html` <p class="mb-3 leading-normal">
${org.subscription.status === ${org.subscription.status ===
SubscriptionStatus.Trialing SubscriptionStatus.Trialing ||
org.subscription.status ===
SubscriptionStatus.TrialingCanceled
? msg( ? msg(
str`To continue using Browsertrix at the end of your trial, click “${this.portalUrlLabel}”.`, str`To continue using Browsertrix at the end of your trial, click “${this.portalUrlLabel}”.`,
) )
@ -269,6 +274,7 @@ export class OrgSettingsBilling extends BtrixElement {
`; `;
break; break;
} }
case SubscriptionStatus.TrialingCanceled:
case SubscriptionStatus.Trialing: { case SubscriptionStatus.Trialing: {
statusLabel = html` statusLabel = html`
<span class="text-success-700">${msg("Free Trial")}</span> <span class="text-success-700">${msg("Free Trial")}</span>

View File

@ -5,6 +5,7 @@ import { apiDateSchema } from "./api";
export enum SubscriptionStatus { export enum SubscriptionStatus {
Active = "active", Active = "active",
Trialing = "trialing", Trialing = "trialing",
TrialingCanceled = "trialing_canceled",
PausedPaymentFailed = "paused_payment_failed", PausedPaymentFailed = "paused_payment_failed",
Cancelled = "cancelled", Cancelled = "cancelled",
} }