diff --git a/src/packages/frontend/browser.ts b/src/packages/frontend/browser.ts index ac8a5dccfb..c2ec74dda0 100644 --- a/src/packages/frontend/browser.ts +++ b/src/packages/frontend/browser.ts @@ -12,10 +12,13 @@ export function notifyCount() { const mentions = redux.getStore("mentions"); const account = redux.getStore("account"); const news = redux.getStore("news"); + // we always count balance_alert as "1", since we don't even + // know how many of them there are until querying stripe. return ( (mentions?.getUnreadSize() ?? 0) + (account?.get("unread_message_count") ?? 0) + - (news?.get("unread") ?? 0) + (news?.get("unread") ?? 0) + + (account?.get("balance_alert") ? 1 : 0) ); } diff --git a/src/packages/frontend/messages/counter.tsx b/src/packages/frontend/messages/counter.tsx index 019da0c613..231f475d09 100644 --- a/src/packages/frontend/messages/counter.tsx +++ b/src/packages/frontend/messages/counter.tsx @@ -17,6 +17,9 @@ export default function Counter({ set_window_title(); }, [unread_message_count]); if (minimal) { + if (!unread_message_count) { + return null; + } return {unread_message_count}; } return ( diff --git a/src/packages/frontend/site-licenses/site-license-public-info-component.tsx b/src/packages/frontend/site-licenses/site-license-public-info-component.tsx index 62fd3b320f..a710283388 100644 --- a/src/packages/frontend/site-licenses/site-license-public-info-component.tsx +++ b/src/packages/frontend/site-licenses/site-license-public-info-component.tsx @@ -238,7 +238,7 @@ export const SiteLicensePublicInfo: React.FC = ( // a subscription will extend it (unless it is canceled) return null; } - const word = expired ? "EXPIRED" : "Paid through"; + const word = expired ? "EXPIRED" : "Valid through"; const when = info?.expires != null ? ( diff --git a/src/packages/frontend/site-licenses/site-license-public-info.tsx b/src/packages/frontend/site-licenses/site-license-public-info.tsx index 35fbba1062..e4ef896cb7 100644 --- a/src/packages/frontend/site-licenses/site-license-public-info.tsx +++ b/src/packages/frontend/site-licenses/site-license-public-info.tsx @@ -353,7 +353,7 @@ export const SiteLicensePublicInfoTable: React.FC = ( return ( <> - {runLimitTxt} Paid through {when}. + {runLimitTxt} Valid through {when}. ); } diff --git a/src/packages/server/purchases/maintain-subscriptions.ts b/src/packages/server/purchases/maintain-subscriptions.ts index 264b4bb74a..aac3d22b73 100644 --- a/src/packages/server/purchases/maintain-subscriptions.ts +++ b/src/packages/server/purchases/maintain-subscriptions.ts @@ -11,11 +11,20 @@ CREATE PAYMENTS: for that subscription, we do the following: - Create a payment intent for the amount to renew the subscription for the next - period. The metadata says exactly what this payment is for and what should happen - when payment is processed. + period. The metadata says what this payment is for and what should happen + when payment is processed. If user has selected to pay from credit on file + and they have enough to covert the entire renewal, subscription is immediately + renewed using available credit. - - Also, when making the payment intent, extend the license expire date for 3 days, - as a free automatic grace period, since payments can take a while to complete. + - After successfully making the payment intent, extend the license so it does not + expires 5 days from now (3 days after expire), as a free automatic grace + period, since payments can take a while to complete. + + ABUSE POTENTIAL: this is slightly DANGEROUS!! The user could maybe cancel everything and + get a prorated refund on the license that would give them credit for these 3 + days. It's not too dangerous though, since this only happens automatically + on the subscription renewal and there is no way for the user to trigger it. + We might want to not allow this... We'll see. - Send message about subscription renewal payment. Including invoice payment link from stripe in that message. @@ -90,6 +99,7 @@ import { getUser } from "@cocalc/server/purchases/statements/email-statement"; import basePath from "@cocalc/backend/base-path"; import { join } from "path"; import { currency } from "@cocalc/util/misc"; +import dayjs from "dayjs"; const logger = getLogger("purchases:maintain-subscriptions"); @@ -210,7 +220,7 @@ export async function createPayments() { SELECT id as subscription_id, account_id FROM subscriptions WHERE status != 'canceled' AND current_period_end <= NOW() + interval '48 hours' AND - payment#>>'{status}' != 'active' + coalesce(payment#>>'{status}','') != 'active' `, ); logger.debug( @@ -231,6 +241,55 @@ export async function createPayments() { `createPayments -- ERROR billing subscription id ${subscription_id} -- ${err}`, ); } + await gracePeriod({ + subscription_id, + until: dayjs().add(5, "days").toDate(), + }); + } +} + +export async function gracePeriod({ + subscription_id, + until, +}: { + subscription_id: number; + until: Date; +}) { + logger.debug("gracePeriod", { subscription_id, until }); + // Check to ensure the license does not expire until after "until". + // It might have been renewed already by the time we get here. + const pool = getPool(); + const { rows: subscriptions } = await pool.query( + "SELECT metadata FROM subscriptions WHERE id=$1", + [subscription_id], + ); + const license_id = subscriptions[0]?.metadata?.license_id; + if (!license_id) { + logger.debug("gracePeriod: no license_id"); + return; + } + logger.debug("gracePeriod:", { license_id }); + const { rows: licenses } = await pool.query( + "SELECT expires FROM site_licenses WHERE id=$1", + [license_id], + ); + if (licenses.length == 0) { + logger.debug("gracePeriod: no such license", { license_id }); + return; + } + const expires = licenses[0].expires; + if (expires == null) { + logger.debug("gracePeriod: suspicious license - no expires set (?)", { + license_id, + }); + return; + } + if (expires < until) { + logger.debug("gracePeriod: adding grace period to license."); + await pool.query("UPDATE site_licenses SET expires=$1 WHERE id=$2", [ + until, + license_id, + ]); } } diff --git a/src/packages/server/purchases/stripe/create-subscription-payment.ts b/src/packages/server/purchases/stripe/create-subscription-payment.ts index ce264bd7c6..505d3f3560 100644 --- a/src/packages/server/purchases/stripe/create-subscription-payment.ts +++ b/src/packages/server/purchases/stripe/create-subscription-payment.ts @@ -16,6 +16,7 @@ import getBalance from "@cocalc/server/purchases/get-balance"; import send from "@cocalc/server/messages/send"; import adminAlert from "@cocalc/server/messages/admin-alert"; import { getServerSettings } from "@cocalc/database/settings/server-settings"; +import base_path from "@cocalc/backend/base-path"; // nothing should ever be this small, but just in case: const MIN_SUBSCRIPTION_AMOUNT = 1; @@ -94,7 +95,7 @@ export default async function createSubscriptionPayment({ } } - const { site_name } = await getServerSettings(); + const { site_name, dns } = await getServerSettings(); if (payNow) { // Instead of trying to charge their credit card (etc.), we just @@ -167,6 +168,8 @@ export default async function createSubscriptionPayment({ new_expires_ms, }; + const site = `https://${dns}${base_path}`; + await pool.query("UPDATE subscriptions SET payment=$1 WHERE id=$2", [ payment1, subscription_id, @@ -174,8 +177,7 @@ export default async function createSubscriptionPayment({ await send({ to_ids: [account_id], subject: `${site_name} Subscription Renewal: Id ${subscription_id}`, - body: `${site_name} has started renewing your subscription (id=${subscription_id}).\n\n -Invoice: ${hosted_invoice_url}`, + body: `${site_name} has started renewing your subscription (id=${subscription_id}).\n\n- [Subscription Status](${site}/subscriptions/${subscription_id})\n\n- Your Account: [Subscriptions](${site}/settings/subscriptions), [Payments](${site}/settings/payments) and [Purchases](${site}/settings/purchases)\n\n- Hosted Invoice: ${hosted_invoice_url}`, }); }