Add "Canceled" banner

This commit is contained in:
binwiederhier 2023-01-16 10:35:12 -05:00
parent c06bfb989e
commit 7faed3ee1e
9 changed files with 39 additions and 12 deletions

View file

@ -44,6 +44,8 @@ import (
- delete subscription when account deleted - delete subscription when account deleted
- remove tier.paid - remove tier.paid
- add tier.visible - add tier.visible
- fix tier selection boxes
- account sync after switching tiers
Limits & rate limiting: Limits & rate limiting:
users without tier: should the stats be persisted? are they meaningful? users without tier: should the stats be persisted? are they meaningful?

View file

@ -97,6 +97,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
Subscription: v.user.Billing.StripeSubscriptionID != "", Subscription: v.user.Billing.StripeSubscriptionID != "",
Status: string(v.user.Billing.StripeSubscriptionStatus), Status: string(v.user.Billing.StripeSubscriptionStatus),
PaidUntil: v.user.Billing.StripeSubscriptionPaidUntil.Unix(), PaidUntil: v.user.Billing.StripeSubscriptionPaidUntil.Unix(),
CancelAt: v.user.Billing.StripeSubscriptionCancelAt.Unix(),
} }
} }
reservations, err := s.userManager.Reservations(v.user.Name) reservations, err := s.userManager.Reservations(v.user.Name)

View file

@ -62,6 +62,7 @@ func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r
v.user.Billing.StripeSubscriptionID = "" v.user.Billing.StripeSubscriptionID = ""
v.user.Billing.StripeSubscriptionStatus = "" v.user.Billing.StripeSubscriptionStatus = ""
v.user.Billing.StripeSubscriptionPaidUntil = time.Unix(0, 0) v.user.Billing.StripeSubscriptionPaidUntil = time.Unix(0, 0)
v.user.Billing.StripeSubscriptionCancelAt = time.Unix(0, 0)
if err := s.userManager.ChangeBilling(v.user); err != nil { if err := s.userManager.ChangeBilling(v.user); err != nil {
return err return err
} }
@ -170,6 +171,7 @@ func (s *Server) handleAccountCheckoutSessionSuccessGet(w http.ResponseWriter, r
u.Billing.StripeSubscriptionID = sub.ID u.Billing.StripeSubscriptionID = sub.ID
u.Billing.StripeSubscriptionStatus = sub.Status u.Billing.StripeSubscriptionStatus = sub.Status
u.Billing.StripeSubscriptionPaidUntil = time.Unix(sub.CurrentPeriodEnd, 0) u.Billing.StripeSubscriptionPaidUntil = time.Unix(sub.CurrentPeriodEnd, 0)
u.Billing.StripeSubscriptionCancelAt = time.Unix(sub.CancelAt, 0)
if err := s.userManager.ChangeBilling(u); err != nil { if err := s.userManager.ChangeBilling(u); err != nil {
return err return err
} }
@ -240,8 +242,9 @@ func (s *Server) handleAccountBillingWebhook(w http.ResponseWriter, r *http.Requ
func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(stripeCustomerID string, event json.RawMessage) error { func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(stripeCustomerID string, event json.RawMessage) error {
status := gjson.GetBytes(event, "status") status := gjson.GetBytes(event, "status")
currentPeriodEnd := gjson.GetBytes(event, "current_period_end") currentPeriodEnd := gjson.GetBytes(event, "current_period_end")
cancelAt := gjson.GetBytes(event, "cancel_at")
priceID := gjson.GetBytes(event, "items.data.0.price.id") priceID := gjson.GetBytes(event, "items.data.0.price.id")
if !status.Exists() || !currentPeriodEnd.Exists() || !priceID.Exists() { if !status.Exists() || !currentPeriodEnd.Exists() || !cancelAt.Exists() || !priceID.Exists() {
return errHTTPBadRequestInvalidStripeRequest return errHTTPBadRequestInvalidStripeRequest
} }
log.Info("Stripe: customer %s: subscription updated to %s, with price %s", stripeCustomerID, status, priceID) log.Info("Stripe: customer %s: subscription updated to %s, with price %s", stripeCustomerID, status, priceID)
@ -258,6 +261,7 @@ func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(stripeCustomerID
} }
u.Billing.StripeSubscriptionStatus = stripe.SubscriptionStatus(status.String()) u.Billing.StripeSubscriptionStatus = stripe.SubscriptionStatus(status.String())
u.Billing.StripeSubscriptionPaidUntil = time.Unix(currentPeriodEnd.Int(), 0) u.Billing.StripeSubscriptionPaidUntil = time.Unix(currentPeriodEnd.Int(), 0)
u.Billing.StripeSubscriptionCancelAt = time.Unix(cancelAt.Int(), 0)
if err := s.userManager.ChangeBilling(u); err != nil { if err := s.userManager.ChangeBilling(u); err != nil {
return err return err
} }
@ -280,6 +284,7 @@ func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(stripeCustomerID
u.Billing.StripeSubscriptionID = "" u.Billing.StripeSubscriptionID = ""
u.Billing.StripeSubscriptionStatus = "" u.Billing.StripeSubscriptionStatus = ""
u.Billing.StripeSubscriptionPaidUntil = time.Unix(0, 0) u.Billing.StripeSubscriptionPaidUntil = time.Unix(0, 0)
u.Billing.StripeSubscriptionCancelAt = time.Unix(0, 0)
if err := s.userManager.ChangeBilling(u); err != nil { if err := s.userManager.ChangeBilling(u); err != nil {
return err return err
} }

View file

@ -273,6 +273,7 @@ type apiAccountBilling struct {
Subscription bool `json:"subscription"` Subscription bool `json:"subscription"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
PaidUntil int64 `json:"paid_until,omitempty"` PaidUntil int64 `json:"paid_until,omitempty"`
CancelAt int64 `json:"cancel_at,omitempty"`
} }
type apiAccountResponse struct { type apiAccountResponse struct {

View file

@ -63,7 +63,8 @@ const (
stripe_customer_id TEXT, stripe_customer_id TEXT,
stripe_subscription_id TEXT, stripe_subscription_id TEXT,
stripe_subscription_status TEXT, stripe_subscription_status TEXT,
stripe_subscription_paid_until INT, stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created_by TEXT NOT NULL, created_by TEXT NOT NULL,
created_at INT NOT NULL, created_at INT NOT NULL,
last_seen INT NOT NULL, last_seen INT NOT NULL,
@ -103,20 +104,20 @@ const (
` `
selectUserByNameQuery = ` selectUserByNameQuery = `
SELECT u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, p.code, p.name, p.paid, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration, p.stripe_price_id SELECT u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, p.code, p.name, p.paid, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration, p.stripe_price_id
FROM user u FROM user u
LEFT JOIN tier p on p.id = u.tier_id LEFT JOIN tier p on p.id = u.tier_id
WHERE user = ? WHERE user = ?
` `
selectUserByTokenQuery = ` selectUserByTokenQuery = `
SELECT u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, p.code, p.name, p.paid, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration, p.stripe_price_id SELECT u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at , p.code, p.name, p.paid, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration, p.stripe_price_id
FROM user u FROM user u
JOIN user_token t on u.id = t.user_id JOIN user_token t on u.id = t.user_id
LEFT JOIN tier p on p.id = u.tier_id LEFT JOIN tier p on p.id = u.tier_id
WHERE t.token = ? AND t.expires >= ? WHERE t.token = ? AND t.expires >= ?
` `
selectUserByStripeCustomerIDQuery = ` selectUserByStripeCustomerIDQuery = `
SELECT u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, p.code, p.name, p.paid, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration, p.stripe_price_id SELECT u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at , p.code, p.name, p.paid, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration, p.stripe_price_id
FROM user u FROM user u
LEFT JOIN tier p on p.id = u.tier_id LEFT JOIN tier p on p.id = u.tier_id
WHERE u.stripe_customer_id = ? WHERE u.stripe_customer_id = ?
@ -236,7 +237,7 @@ const (
updateBillingQuery = ` updateBillingQuery = `
UPDATE user UPDATE user
SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_paid_until = ? SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ?
WHERE user = ? WHERE user = ?
` `
) )
@ -607,11 +608,11 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripePriceID, tierCode, tierName sql.NullString var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripePriceID, tierCode, tierName sql.NullString
var paid sql.NullBool var paid sql.NullBool
var messages, emails int64 var messages, emails int64
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, stripeSubscriptionPaidUntil sql.NullInt64 var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt sql.NullInt64
if !rows.Next() { if !rows.Next() {
return nil, ErrUserNotFound return nil, ErrUserNotFound
} }
if err := rows.Scan(&username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionPaidUntil, &tierCode, &tierName, &paid, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &stripePriceID); err != nil { if err := rows.Scan(&username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &tierCode, &tierName, &paid, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &stripePriceID); err != nil {
return nil, err return nil, err
} else if err := rows.Err(); err != nil { } else if err := rows.Err(); err != nil {
return nil, err return nil, err
@ -631,6 +632,7 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
StripeSubscriptionID: stripeSubscriptionID.String, // May be empty StripeSubscriptionID: stripeSubscriptionID.String, // May be empty
StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty
StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero
StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero
}, },
} }
if err := json.Unmarshal([]byte(prefs), user.Prefs); err != nil { if err := json.Unmarshal([]byte(prefs), user.Prefs); err != nil {
@ -875,7 +877,7 @@ func (a *Manager) CreateTier(tier *Tier) error {
} }
func (a *Manager) ChangeBilling(user *User) error { func (a *Manager) ChangeBilling(user *User) error {
if _, err := a.db.Exec(updateBillingQuery, nullString(user.Billing.StripeCustomerID), nullString(user.Billing.StripeSubscriptionID), nullString(string(user.Billing.StripeSubscriptionStatus)), nullInt64(user.Billing.StripeSubscriptionPaidUntil.Unix()), user.Name); err != nil { if _, err := a.db.Exec(updateBillingQuery, nullString(user.Billing.StripeCustomerID), nullString(user.Billing.StripeSubscriptionID), nullString(string(user.Billing.StripeSubscriptionStatus)), nullInt64(user.Billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(user.Billing.StripeSubscriptionCancelAt.Unix()), user.Name); err != nil {
return err return err
} }
return nil return nil

View file

@ -90,6 +90,7 @@ type Billing struct {
StripeSubscriptionID string StripeSubscriptionID string
StripeSubscriptionStatus stripe.SubscriptionStatus StripeSubscriptionStatus stripe.SubscriptionStatus
StripeSubscriptionPaidUntil time.Time StripeSubscriptionPaidUntil time.Time
StripeSubscriptionCancelAt time.Time
} }
// Grant is a struct that represents an access control entry to a topic by a user // Grant is a struct that represents an access control entry to a topic by a user

View file

@ -183,7 +183,9 @@
"account_usage_tier_none": "Basic", "account_usage_tier_none": "Basic",
"account_usage_tier_upgrade_button": "Upgrade to Pro", "account_usage_tier_upgrade_button": "Upgrade to Pro",
"account_usage_tier_change_button": "Change", "account_usage_tier_change_button": "Change",
"account_usage_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew",
"account_usage_tier_payment_overdue": "Your payment is overdue. Please update your payment method, or your account will be downgraded soon.", "account_usage_tier_payment_overdue": "Your payment is overdue. Please update your payment method, or your account will be downgraded soon.",
"account_usage_tier_canceled_subscription": "Your subscription was canceled and will be downgraded to a free account on {{date}}.",
"account_usage_manage_billing_button": "Manage billing", "account_usage_manage_billing_button": "Manage billing",
"account_usage_messages_title": "Published messages", "account_usage_messages_title": "Published messages",
"account_usage_emails_title": "Emails sent", "account_usage_emails_title": "Emails sent",

View file

@ -184,6 +184,11 @@ export const formatShortDateTime = (timestamp) => {
.format(new Date(timestamp * 1000)); .format(new Date(timestamp * 1000));
} }
export const formatShortDate = (timestamp) => {
return new Intl.DateTimeFormat('default', {dateStyle: 'short'})
.format(new Date(timestamp * 1000));
}
export const formatBytes = (bytes, decimals = 2) => { export const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return '0 bytes'; if (bytes === 0) return '0 bytes';
const k = 1024; const k = 1024;

View file

@ -18,7 +18,7 @@ import TextField from "@mui/material/TextField";
import DialogActions from "@mui/material/DialogActions"; import DialogActions from "@mui/material/DialogActions";
import routes from "./routes"; import routes from "./routes";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import {formatBytes, formatShortDateTime} from "../app/utils"; import {formatBytes, formatShortDate, formatShortDateTime} from "../app/utils";
import accountApi, {UnauthorizedError} from "../app/AccountApi"; import accountApi, {UnauthorizedError} from "../app/AccountApi";
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import {Pref, PrefGroup} from "./Pref"; import {Pref, PrefGroup} from "./Pref";
@ -201,7 +201,7 @@ const Stats = () => {
</Typography> </Typography>
<PrefGroup> <PrefGroup>
<Pref <Pref
alignTop={account.billing?.status === "past_due"} alignTop={account.billing?.status === "past_due" || account.billing?.cancel_at > 0}
title={t("account_usage_tier_title")} title={t("account_usage_tier_title")}
> >
<div> <div>
@ -213,6 +213,11 @@ const Stats = () => {
} }
{account.role === "user" && account.tier && account.tier.name} {account.role === "user" && account.tier && account.tier.name}
{account.role === "user" && !account.tier && t("account_usage_tier_none")} {account.role === "user" && !account.tier && t("account_usage_tier_none")}
{account.billing?.paid_until &&
<Tooltip title={t("account_usage_tier_paid_until", { date: formatShortDate(account.billing?.paid_until) })}>
<span><InfoIcon/></span>
</Tooltip>
}
{config.enable_payments && account.role === "user" && (!account.tier || !account.tier.paid) && {config.enable_payments && account.role === "user" && (!account.tier || !account.tier.paid) &&
<Button <Button
variant="outlined" variant="outlined"
@ -246,6 +251,9 @@ const Stats = () => {
{account.billing?.status === "past_due" && {account.billing?.status === "past_due" &&
<Alert severity="error" sx={{mt: 1}}>{t("account_usage_tier_payment_overdue")}</Alert> <Alert severity="error" sx={{mt: 1}}>{t("account_usage_tier_payment_overdue")}</Alert>
} }
{account.billing?.cancel_at > 0 &&
<Alert severity="info" sx={{mt: 1}}>{t("account_usage_tier_canceled_subscription", { date: formatShortDate(account.billing.cancel_at) })}</Alert>
}
</Pref> </Pref>
{account.role !== "admin" && {account.role !== "admin" &&
<Pref title={t("account_usage_reservations_title")}> <Pref title={t("account_usage_reservations_title")}>
@ -331,7 +339,7 @@ const Stats = () => {
const InfoIcon = () => { const InfoIcon = () => {
return ( return (
<InfoOutlinedIcon sx={{ <InfoOutlinedIcon sx={{
verticalAlign: "bottom", verticalAlign: "middle",
width: "18px", width: "18px",
marginLeft: "4px", marginLeft: "4px",
color: "gray" color: "gray"