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:
parent
a65ca49ddd
commit
db39333ef4
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"""
|
||||
|
||||
|
||||
43
chart/email-templates/sub_cancel
Normal file
43
chart/email-templates/sub_cancel
Normal 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 %}
|
||||
@ -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 }}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
<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>
|
||||
|
||||
@ -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 {
|
||||
></sl-icon>
|
||||
<div>
|
||||
${org.subscription.status ===
|
||||
SubscriptionStatus.Trialing
|
||||
SubscriptionStatus.Trialing ||
|
||||
org.subscription.status ===
|
||||
SubscriptionStatus.TrialingCanceled
|
||||
? html`
|
||||
<span class="font-medium text-neutral-700">
|
||||
${msg(
|
||||
@ -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}”.`,
|
||||
)
|
||||
@ -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>
|
||||
|
||||
@ -5,6 +5,7 @@ import { apiDateSchema } from "./api";
|
||||
export enum SubscriptionStatus {
|
||||
Active = "active",
|
||||
Trialing = "trialing",
|
||||
TrialingCanceled = "trialing_canceled",
|
||||
PausedPaymentFailed = "paused_payment_failed",
|
||||
Cancelled = "cancelled",
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user