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_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,
|
||||||
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"""
|
||||||
|
|
||||||
|
|||||||
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 }}"
|
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 }}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user