Add "Canceled" banner
parent
c06bfb989e
commit
7faed3ee1e
|
@ -44,6 +44,8 @@ import (
|
|||
- delete subscription when account deleted
|
||||
- remove tier.paid
|
||||
- add tier.visible
|
||||
- fix tier selection boxes
|
||||
- account sync after switching tiers
|
||||
|
||||
Limits & rate limiting:
|
||||
users without tier: should the stats be persisted? are they meaningful?
|
||||
|
|
|
@ -97,6 +97,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
|||
Subscription: v.user.Billing.StripeSubscriptionID != "",
|
||||
Status: string(v.user.Billing.StripeSubscriptionStatus),
|
||||
PaidUntil: v.user.Billing.StripeSubscriptionPaidUntil.Unix(),
|
||||
CancelAt: v.user.Billing.StripeSubscriptionCancelAt.Unix(),
|
||||
}
|
||||
}
|
||||
reservations, err := s.userManager.Reservations(v.user.Name)
|
||||
|
|
|
@ -62,6 +62,7 @@ func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r
|
|||
v.user.Billing.StripeSubscriptionID = ""
|
||||
v.user.Billing.StripeSubscriptionStatus = ""
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
@ -170,6 +171,7 @@ func (s *Server) handleAccountCheckoutSessionSuccessGet(w http.ResponseWriter, r
|
|||
u.Billing.StripeSubscriptionID = sub.ID
|
||||
u.Billing.StripeSubscriptionStatus = sub.Status
|
||||
u.Billing.StripeSubscriptionPaidUntil = time.Unix(sub.CurrentPeriodEnd, 0)
|
||||
u.Billing.StripeSubscriptionCancelAt = time.Unix(sub.CancelAt, 0)
|
||||
if err := s.userManager.ChangeBilling(u); err != nil {
|
||||
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 {
|
||||
status := gjson.GetBytes(event, "status")
|
||||
currentPeriodEnd := gjson.GetBytes(event, "current_period_end")
|
||||
cancelAt := gjson.GetBytes(event, "cancel_at")
|
||||
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
|
||||
}
|
||||
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.StripeSubscriptionPaidUntil = time.Unix(currentPeriodEnd.Int(), 0)
|
||||
u.Billing.StripeSubscriptionCancelAt = time.Unix(cancelAt.Int(), 0)
|
||||
if err := s.userManager.ChangeBilling(u); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -280,6 +284,7 @@ func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(stripeCustomerID
|
|||
u.Billing.StripeSubscriptionID = ""
|
||||
u.Billing.StripeSubscriptionStatus = ""
|
||||
u.Billing.StripeSubscriptionPaidUntil = time.Unix(0, 0)
|
||||
u.Billing.StripeSubscriptionCancelAt = time.Unix(0, 0)
|
||||
if err := s.userManager.ChangeBilling(u); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -273,6 +273,7 @@ type apiAccountBilling struct {
|
|||
Subscription bool `json:"subscription"`
|
||||
Status string `json:"status,omitempty"`
|
||||
PaidUntil int64 `json:"paid_until,omitempty"`
|
||||
CancelAt int64 `json:"cancel_at,omitempty"`
|
||||
}
|
||||
|
||||
type apiAccountResponse struct {
|
||||
|
|
|
@ -64,6 +64,7 @@ const (
|
|||
stripe_subscription_id TEXT,
|
||||
stripe_subscription_status TEXT,
|
||||
stripe_subscription_paid_until INT,
|
||||
stripe_subscription_cancel_at INT,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at INT NOT NULL,
|
||||
last_seen INT NOT NULL,
|
||||
|
@ -103,20 +104,20 @@ const (
|
|||
`
|
||||
|
||||
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
|
||||
LEFT JOIN tier p on p.id = u.tier_id
|
||||
WHERE user = ?
|
||||
`
|
||||
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
|
||||
JOIN user_token t on u.id = t.user_id
|
||||
LEFT JOIN tier p on p.id = u.tier_id
|
||||
WHERE t.token = ? AND t.expires >= ?
|
||||
`
|
||||
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
|
||||
LEFT JOIN tier p on p.id = u.tier_id
|
||||
WHERE u.stripe_customer_id = ?
|
||||
|
@ -236,7 +237,7 @@ const (
|
|||
|
||||
updateBillingQuery = `
|
||||
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 = ?
|
||||
`
|
||||
)
|
||||
|
@ -607,11 +608,11 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
|||
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripePriceID, tierCode, tierName sql.NullString
|
||||
var paid sql.NullBool
|
||||
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() {
|
||||
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
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
|
@ -631,6 +632,7 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
|||
StripeSubscriptionID: stripeSubscriptionID.String, // May be empty
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty
|
||||
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 {
|
||||
|
@ -875,7 +877,7 @@ func (a *Manager) CreateTier(tier *Tier) 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 nil
|
||||
|
|
|
@ -90,6 +90,7 @@ type Billing struct {
|
|||
StripeSubscriptionID string
|
||||
StripeSubscriptionStatus stripe.SubscriptionStatus
|
||||
StripeSubscriptionPaidUntil time.Time
|
||||
StripeSubscriptionCancelAt time.Time
|
||||
}
|
||||
|
||||
// Grant is a struct that represents an access control entry to a topic by a user
|
||||
|
|
|
@ -183,7 +183,9 @@
|
|||
"account_usage_tier_none": "Basic",
|
||||
"account_usage_tier_upgrade_button": "Upgrade to Pro",
|
||||
"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_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_messages_title": "Published messages",
|
||||
"account_usage_emails_title": "Emails sent",
|
||||
|
|
|
@ -184,6 +184,11 @@ export const formatShortDateTime = (timestamp) => {
|
|||
.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) => {
|
||||
if (bytes === 0) return '0 bytes';
|
||||
const k = 1024;
|
||||
|
|
|
@ -18,7 +18,7 @@ import TextField from "@mui/material/TextField";
|
|||
import DialogActions from "@mui/material/DialogActions";
|
||||
import routes from "./routes";
|
||||
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 InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import {Pref, PrefGroup} from "./Pref";
|
||||
|
@ -201,7 +201,7 @@ const Stats = () => {
|
|||
</Typography>
|
||||
<PrefGroup>
|
||||
<Pref
|
||||
alignTop={account.billing?.status === "past_due"}
|
||||
alignTop={account.billing?.status === "past_due" || account.billing?.cancel_at > 0}
|
||||
title={t("account_usage_tier_title")}
|
||||
>
|
||||
<div>
|
||||
|
@ -213,6 +213,11 @@ const Stats = () => {
|
|||
}
|
||||
{account.role === "user" && account.tier && account.tier.name}
|
||||
{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) &&
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
@ -246,6 +251,9 @@ const Stats = () => {
|
|||
{account.billing?.status === "past_due" &&
|
||||
<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>
|
||||
{account.role !== "admin" &&
|
||||
<Pref title={t("account_usage_reservations_title")}>
|
||||
|
@ -331,7 +339,7 @@ const Stats = () => {
|
|||
const InfoIcon = () => {
|
||||
return (
|
||||
<InfoOutlinedIcon sx={{
|
||||
verticalAlign: "bottom",
|
||||
verticalAlign: "middle",
|
||||
width: "18px",
|
||||
marginLeft: "4px",
|
||||
color: "gray"
|
||||
|
|
Loading…
Reference in New Issue