Autosave: 20260629-175315
This commit is contained in:
parent
3dfd47bae8
commit
c7ec13b78b
@ -36,6 +36,7 @@
|
||||
"sequelize": "6.35.2",
|
||||
"sequelize-json-schema": "^2.1.1",
|
||||
"sqlite": "4.0.15",
|
||||
"stripe": "^22.3.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"tedious": "^18.2.4"
|
||||
|
||||
@ -65,6 +65,12 @@ const config = {
|
||||
|
||||
|
||||
gpt_key: process.env.GPT_KEY || '',
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
starterPriceId: process.env.STRIPE_STARTER_PRICE_ID || '',
|
||||
proPriceId: process.env.STRIPE_PRO_PRICE_ID || '',
|
||||
},
|
||||
};
|
||||
|
||||
config.pexelsKey = process.env.PEXELS_KEY || '';
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
|
||||
const userColumns = {
|
||||
stripeCustomerId: { type: 'TEXT' },
|
||||
stripeSubscriptionId: { type: 'TEXT' },
|
||||
stripePriceId: { type: 'TEXT' },
|
||||
stripeCheckoutSessionId: { type: 'TEXT' },
|
||||
stripeCurrentPeriodEndAt: { type: 'DATE' },
|
||||
};
|
||||
|
||||
function normalizeColumnDefinition(Sequelize, definition) {
|
||||
const normalized = { ...definition };
|
||||
|
||||
if (definition.type === 'TEXT') {
|
||||
normalized.type = Sequelize.DataTypes.TEXT;
|
||||
}
|
||||
|
||||
if (definition.type === 'DATE') {
|
||||
normalized.type = Sequelize.DataTypes.DATE;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
|
||||
const table = await queryInterface.describeTable(tableName);
|
||||
|
||||
for (const [columnName, definition] of Object.entries(columns)) {
|
||||
if (!table[columnName]) {
|
||||
await queryInterface.addColumn(
|
||||
tableName,
|
||||
columnName,
|
||||
normalizeColumnDefinition(Sequelize, definition),
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
|
||||
const table = await queryInterface.describeTable(tableName);
|
||||
|
||||
for (const columnName of Object.keys(columns).reverse()) {
|
||||
if (table[columnName]) {
|
||||
await queryInterface.removeColumn(tableName, columnName, { transaction });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
|
||||
try {
|
||||
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'users', userColumns);
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
|
||||
try {
|
||||
await removeColumnsIfPresent(queryInterface, transaction, 'users', userColumns);
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -2,7 +2,6 @@ const config = require('../../config');
|
||||
const providers = config.providers;
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const moment = require('moment');
|
||||
|
||||
module.exports = function(sequelize, DataTypes) {
|
||||
const users = sequelize.define(
|
||||
@ -145,6 +144,31 @@ subscriptionCanceledAt: {
|
||||
|
||||
},
|
||||
|
||||
stripeCustomerId: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
},
|
||||
|
||||
stripeSubscriptionId: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
},
|
||||
|
||||
stripePriceId: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
},
|
||||
|
||||
stripeCheckoutSessionId: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
},
|
||||
|
||||
stripeCurrentPeriodEndAt: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
},
|
||||
|
||||
importHash: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
@ -236,8 +260,8 @@ subscriptionCanceledAt: {
|
||||
};
|
||||
|
||||
|
||||
users.beforeCreate((users, options) => {
|
||||
users = trimStringFields(users);
|
||||
users.beforeCreate((users) => {
|
||||
trimStringFields(users);
|
||||
|
||||
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
|
||||
users.emailVerified = true;
|
||||
@ -257,8 +281,8 @@ subscriptionCanceledAt: {
|
||||
}
|
||||
});
|
||||
|
||||
users.beforeUpdate((users, options) => {
|
||||
users = trimStringFields(users);
|
||||
users.beforeUpdate((users) => {
|
||||
trimStringFields(users);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ const sqlRoutes = require('./routes/sql');
|
||||
const pexelsRoutes = require('./routes/pexels');
|
||||
const plansRoutes = require('./routes/plans');
|
||||
const subscriptionRoutes = require('./routes/subscription');
|
||||
const subscriptionWebhookRoutes = require('./routes/subscription-webhooks');
|
||||
|
||||
const openaiRoutes = require('./routes/openai');
|
||||
|
||||
@ -96,6 +97,8 @@ app.use('/api-docs', function (req, res, next) {
|
||||
app.use(cors({origin: true}));
|
||||
require('./auth/auth');
|
||||
|
||||
app.use('/api/subscription/stripe-webhook', bodyParser.raw({type: 'application/json'}), subscriptionWebhookRoutes);
|
||||
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
18
backend/src/routes/subscription-webhooks.js
Normal file
18
backend/src/routes/subscription-webhooks.js
Normal file
@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const SubscriptionService = require('../services/subscription');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', wrapAsync(async (req, res) => {
|
||||
const result = await SubscriptionService.handleStripeWebhook(
|
||||
req.body,
|
||||
req.headers['stripe-signature'],
|
||||
);
|
||||
|
||||
res.status(200).send(result);
|
||||
}));
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
@ -4,6 +4,23 @@ const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function getRequestBaseUrl(req) {
|
||||
const origin = req.get('origin');
|
||||
|
||||
if (origin) {
|
||||
return origin;
|
||||
}
|
||||
|
||||
const forwardedProto = req.get('x-forwarded-proto') || req.protocol;
|
||||
const forwardedHost = req.get('x-forwarded-host') || req.get('host');
|
||||
|
||||
if (forwardedHost) {
|
||||
return `${forwardedProto}://${forwardedHost}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
router.get('/me', wrapAsync(async (req, res) => {
|
||||
const status = await SubscriptionService.getStatus(req.currentUser);
|
||||
|
||||
@ -16,6 +33,26 @@ router.post('/select-plan', wrapAsync(async (req, res) => {
|
||||
res.status(200).send(status);
|
||||
}));
|
||||
|
||||
|
||||
router.post('/create-checkout-session', wrapAsync(async (req, res) => {
|
||||
const session = await SubscriptionService.createCheckoutSession(
|
||||
req.currentUser,
|
||||
req.body?.planId || req.body?.plan,
|
||||
getRequestBaseUrl(req),
|
||||
);
|
||||
|
||||
res.status(200).send(session);
|
||||
}));
|
||||
|
||||
router.post('/create-portal-session', wrapAsync(async (req, res) => {
|
||||
const session = await SubscriptionService.createPortalSession(
|
||||
req.currentUser,
|
||||
getRequestBaseUrl(req),
|
||||
);
|
||||
|
||||
res.status(200).send(session);
|
||||
}));
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -742,6 +742,10 @@ async function connectProvider(currentUser, body, req) {
|
||||
validateUrl(reviewLink, 'Enter a valid review page URL before connecting a webhook.');
|
||||
}
|
||||
|
||||
if (!business || !business[config.connectedField]) {
|
||||
await SubscriptionService.assertCanConnectPaymentProvider(currentUser, config.connectedField);
|
||||
}
|
||||
|
||||
if (!business) {
|
||||
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1);
|
||||
|
||||
|
||||
222
backend/src/services/stripeBilling.js
Normal file
222
backend/src/services/stripeBilling.js
Normal file
@ -0,0 +1,222 @@
|
||||
const Stripe = require('stripe');
|
||||
const config = require('../config');
|
||||
|
||||
const PLAN_PRICE_ENV = {
|
||||
starter: 'STRIPE_STARTER_PRICE_ID',
|
||||
pro: 'STRIPE_PRO_PRICE_ID',
|
||||
};
|
||||
|
||||
let cachedStripeClient = null;
|
||||
let cachedSecretKey = null;
|
||||
|
||||
function httpError(message, code = 400) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function compactMissing(items) {
|
||||
return items.filter(Boolean);
|
||||
}
|
||||
|
||||
function getPriceIdForPlan(planId) {
|
||||
if (planId === 'pro') {
|
||||
return config.stripe.proPriceId;
|
||||
}
|
||||
|
||||
if (planId === 'starter') {
|
||||
return config.stripe.starterPriceId;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getPlanIdForPriceId(priceId) {
|
||||
if (!priceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (priceId === config.stripe.proPriceId) {
|
||||
return 'pro';
|
||||
}
|
||||
|
||||
if (priceId === config.stripe.starterPriceId) {
|
||||
return 'starter';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStripeClient() {
|
||||
if (!config.stripe.secretKey) {
|
||||
throw httpError('Stripe billing is not configured yet. Add STRIPE_SECRET_KEY in the backend environment.', 400);
|
||||
}
|
||||
|
||||
if (!cachedStripeClient || cachedSecretKey !== config.stripe.secretKey) {
|
||||
cachedStripeClient = Stripe(config.stripe.secretKey);
|
||||
cachedSecretKey = config.stripe.secretKey;
|
||||
}
|
||||
|
||||
return cachedStripeClient;
|
||||
}
|
||||
|
||||
function formatMissingConfigurationMessage(missing) {
|
||||
return `Stripe billing is not configured yet. Add ${missing.join(', ')} in the backend environment, then reload the backend service.`;
|
||||
}
|
||||
|
||||
module.exports = class StripeBillingService {
|
||||
static getPriceIdForPlan(planId) {
|
||||
return getPriceIdForPlan(planId);
|
||||
}
|
||||
|
||||
static getPlanIdForPriceId(priceId) {
|
||||
return getPlanIdForPriceId(priceId);
|
||||
}
|
||||
|
||||
static getMissingCheckoutConfiguration(planId) {
|
||||
const priceEnvName = PLAN_PRICE_ENV[planId] || PLAN_PRICE_ENV.starter;
|
||||
|
||||
return compactMissing([
|
||||
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
|
||||
getPriceIdForPlan(planId) ? null : priceEnvName,
|
||||
]);
|
||||
}
|
||||
|
||||
static getMissingPortalConfiguration() {
|
||||
return compactMissing([
|
||||
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
|
||||
]);
|
||||
}
|
||||
|
||||
static getMissingWebhookConfiguration() {
|
||||
return compactMissing([
|
||||
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
|
||||
config.stripe.webhookSecret ? null : 'STRIPE_WEBHOOK_SECRET',
|
||||
]);
|
||||
}
|
||||
|
||||
static getSetupStatus(planId = 'starter') {
|
||||
const allMissing = new Set([
|
||||
...this.getMissingCheckoutConfiguration('starter'),
|
||||
...this.getMissingCheckoutConfiguration('pro'),
|
||||
...this.getMissingWebhookConfiguration(),
|
||||
]);
|
||||
|
||||
return {
|
||||
checkoutReady: this.getMissingCheckoutConfiguration(planId).length === 0,
|
||||
portalReady: this.getMissingPortalConfiguration().length === 0,
|
||||
webhookReady: this.getMissingWebhookConfiguration().length === 0,
|
||||
missingConfiguration: Array.from(allMissing),
|
||||
};
|
||||
}
|
||||
|
||||
static assertCheckoutConfigured(planId) {
|
||||
const missing = this.getMissingCheckoutConfiguration(planId);
|
||||
|
||||
if (missing.length) {
|
||||
throw httpError(formatMissingConfigurationMessage(missing), 400);
|
||||
}
|
||||
}
|
||||
|
||||
static assertPortalConfigured() {
|
||||
const missing = this.getMissingPortalConfiguration();
|
||||
|
||||
if (missing.length) {
|
||||
throw httpError(formatMissingConfigurationMessage(missing), 400);
|
||||
}
|
||||
}
|
||||
|
||||
static assertWebhookConfigured() {
|
||||
const missing = this.getMissingWebhookConfiguration();
|
||||
|
||||
if (missing.length) {
|
||||
throw httpError(formatMissingConfigurationMessage(missing), 400);
|
||||
}
|
||||
}
|
||||
|
||||
static async createCheckoutSession(params) {
|
||||
const {
|
||||
user,
|
||||
plan,
|
||||
baseUrl,
|
||||
trialPeriodDays,
|
||||
} = params;
|
||||
const priceId = getPriceIdForPlan(plan.id);
|
||||
const subscriptionData = {
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
planId: plan.id,
|
||||
},
|
||||
};
|
||||
|
||||
this.assertCheckoutConfigured(plan.id);
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
if (trialPeriodDays && trialPeriodDays > 0) {
|
||||
subscriptionData.trial_period_days = trialPeriodDays;
|
||||
}
|
||||
|
||||
return stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
customer: user.stripeCustomerId || undefined,
|
||||
customer_email: user.stripeCustomerId ? undefined : user.email,
|
||||
client_reference_id: user.id,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
allow_promotion_codes: true,
|
||||
billing_address_collection: 'auto',
|
||||
success_url: `${baseUrl}/subscription?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${baseUrl}/subscription?checkout=cancelled`,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
planId: plan.id,
|
||||
},
|
||||
subscription_data: subscriptionData,
|
||||
});
|
||||
}
|
||||
|
||||
static async createPortalSession(params) {
|
||||
const { customerId, baseUrl } = params;
|
||||
this.assertPortalConfigured();
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
return stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: `${baseUrl}/subscription`,
|
||||
});
|
||||
}
|
||||
|
||||
static constructWebhookEvent(rawBody, signature) {
|
||||
this.assertWebhookConfigured();
|
||||
|
||||
if (!signature) {
|
||||
throw httpError('Missing Stripe webhook signature.', 400);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
return stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
signature,
|
||||
config.stripe.webhookSecret,
|
||||
);
|
||||
}
|
||||
|
||||
static async retrieveSubscription(subscriptionId) {
|
||||
if (!subscriptionId || typeof subscriptionId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
return stripe.subscriptions.retrieve(subscriptionId, {
|
||||
expand: ['items.data.price'],
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
const db = require('../db/models');
|
||||
const StripeBillingService = require('./stripeBilling');
|
||||
const {
|
||||
TRIAL_DAYS,
|
||||
getSubscriptionPlanById,
|
||||
@ -102,8 +103,119 @@ function getEffectiveSubscription(user, referenceDate = new Date()) {
|
||||
};
|
||||
}
|
||||
|
||||
function getLimitMessage(plan, usageCount, limit, unit, resetDate) {
|
||||
return `${plan.name} includes ${limit.toLocaleString()} ${unit}. You have already used ${usageCount.toLocaleString()}. Upgrade to Pro or wait until ${resetDate.toISOString().slice(0, 10)} for the monthly limit to reset.`;
|
||||
function getLimitMessage(plan, usageCount, limit, unit, options = {}) {
|
||||
const baseMessage = `${plan.name} includes ${limit.toLocaleString()} ${unit}. You have already used ${usageCount.toLocaleString()}.`;
|
||||
const upgradePrefix = plan.id === 'starter' ? 'Upgrade to Pro or ' : '';
|
||||
|
||||
if (options.resetDate) {
|
||||
return `${baseMessage} ${upgradePrefix}wait until ${options.resetDate.toISOString().slice(0, 10)} for the monthly limit to reset.`;
|
||||
}
|
||||
|
||||
return `${baseMessage} ${upgradePrefix}${options.remediation || 'remove an existing item before adding another.'}`;
|
||||
}
|
||||
|
||||
|
||||
function getUnixDate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = Number(value);
|
||||
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(timestamp * 1000);
|
||||
}
|
||||
|
||||
function getPrimarySubscriptionItem(subscription) {
|
||||
return subscription?.items?.data?.[0] || null;
|
||||
}
|
||||
|
||||
function getSubscriptionPriceId(subscription) {
|
||||
const item = getPrimarySubscriptionItem(subscription);
|
||||
|
||||
return item?.price?.id || subscription?.plan?.id || null;
|
||||
}
|
||||
|
||||
function getCurrentPeriodEnd(subscription) {
|
||||
const item = getPrimarySubscriptionItem(subscription);
|
||||
|
||||
return getUnixDate(subscription?.current_period_end || item?.current_period_end);
|
||||
}
|
||||
|
||||
function getPlanIdFromStripeSubscription(subscription, fallbackPlanId) {
|
||||
const pricePlanId = StripeBillingService.getPlanIdForPriceId(getSubscriptionPriceId(subscription));
|
||||
|
||||
if (pricePlanId) {
|
||||
return pricePlanId;
|
||||
}
|
||||
|
||||
return normalizePlanId(subscription?.metadata?.planId || fallbackPlanId);
|
||||
}
|
||||
|
||||
function getStripeCustomerId(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.id || null;
|
||||
}
|
||||
|
||||
function getStripeSubscriptionId(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.id || null;
|
||||
}
|
||||
|
||||
function getTrialDaysLeftForCheckout(subscription) {
|
||||
if (!subscription.isActive || subscription.status !== DEFAULT_STATUS || !subscription.trialEndsAt) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.ceil((subscription.trialEndsAt.getTime() - Date.now()) / DAY_IN_MS));
|
||||
}
|
||||
|
||||
async function getTeamUsageScope(userId, transaction) {
|
||||
const user = await db.users.findByPk(userId, {
|
||||
attributes: ['id', 'createdById'],
|
||||
transaction,
|
||||
});
|
||||
|
||||
const teamOwnerId = user?.createdById || userId;
|
||||
const teamMembers = await db.users.findAll({
|
||||
attributes: ['id'],
|
||||
where: {
|
||||
[db.Sequelize.Op.or]: [
|
||||
{ id: teamOwnerId },
|
||||
{ createdById: teamOwnerId },
|
||||
],
|
||||
disabled: false,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
const teamMemberIds = teamMembers.map((teamMember) => teamMember.id);
|
||||
|
||||
if (!teamMemberIds.includes(userId)) {
|
||||
teamMemberIds.push(userId);
|
||||
}
|
||||
|
||||
return {
|
||||
teamOwnerId,
|
||||
teamMemberIds,
|
||||
teamMembers: teamMemberIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserRecord(currentUserOrId, options = {}) {
|
||||
@ -114,7 +226,7 @@ async function getUserRecord(currentUserOrId, options = {}) {
|
||||
throw httpError('A signed-in user is required to check subscription limits.', 403);
|
||||
}
|
||||
|
||||
const shouldLoad = typeof currentUserOrId === 'string' || currentUserOrId.subscriptionPlanId === undefined;
|
||||
const shouldLoad = options.forceReload || typeof currentUserOrId === 'string' || currentUserOrId.subscriptionPlanId === undefined;
|
||||
|
||||
if (!shouldLoad) {
|
||||
return currentUserOrId;
|
||||
@ -149,14 +261,16 @@ module.exports = class SubscriptionService {
|
||||
static async getUsageForUserId(userId, options = {}) {
|
||||
const transaction = options.transaction || undefined;
|
||||
const { periodStart, periodEnd } = getCurrentMonthRange();
|
||||
const teamScope = await getTeamUsageScope(userId, transaction);
|
||||
const teamMemberFilter = { [db.Sequelize.Op.in]: teamScope.teamMemberIds };
|
||||
const businesses = await db.businesses.findAll({
|
||||
where: { createdById: userId },
|
||||
where: { createdById: teamMemberFilter },
|
||||
attributes: ['id', ...PAYMENT_CONNECTOR_FIELDS],
|
||||
transaction,
|
||||
});
|
||||
const monthlyReviewRequests = await db.review_requests.count({
|
||||
where: {
|
||||
createdById: userId,
|
||||
createdById: teamMemberFilter,
|
||||
createdAt: {
|
||||
[db.Sequelize.Op.gte]: periodStart,
|
||||
[db.Sequelize.Op.lt]: periodEnd,
|
||||
@ -171,7 +285,7 @@ module.exports = class SubscriptionService {
|
||||
return {
|
||||
monthlyReviewRequests,
|
||||
businesses: businesses.length,
|
||||
teamMembers: 1,
|
||||
teamMembers: teamScope.teamMembers,
|
||||
paymentConnectors,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
@ -179,7 +293,7 @@ module.exports = class SubscriptionService {
|
||||
}
|
||||
|
||||
static async getStatus(currentUserOrId, options = {}) {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const user = await getUserRecord(currentUserOrId, { ...options, forceReload: true });
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
const usage = await this.getUsageForUserId(user.id, options);
|
||||
const plans = getSubscriptionPlans();
|
||||
@ -196,6 +310,14 @@ module.exports = class SubscriptionService {
|
||||
trialDaysLeft: subscription.trialDaysLeft,
|
||||
priceMonthly: subscription.plan.priceMonthly,
|
||||
currency: subscription.plan.currency,
|
||||
stripeCustomerLinked: Boolean(user.stripeCustomerId),
|
||||
stripeSubscriptionLinked: Boolean(user.stripeSubscriptionId),
|
||||
currentPeriodEndsAt: user.stripeCurrentPeriodEndAt || null,
|
||||
},
|
||||
billing: {
|
||||
...StripeBillingService.getSetupStatus(subscription.planId),
|
||||
hasStripeCustomer: Boolean(user.stripeCustomerId),
|
||||
hasStripeSubscription: Boolean(user.stripeSubscriptionId),
|
||||
},
|
||||
plan: subscription.plan,
|
||||
usage,
|
||||
@ -212,16 +334,29 @@ module.exports = class SubscriptionService {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const targetPlanId = normalizePlanId(planId);
|
||||
const existingSubscription = getEffectiveSubscription(user, now);
|
||||
const needsNewTrial = existingSubscription.effectiveStatus === 'expired' || !user.trialStartedAt || !user.trialEndsAt;
|
||||
const trialWindow = needsNewTrial ? buildTrialWindow(now) : {
|
||||
|
||||
if (targetPlanId === existingSubscription.planId) {
|
||||
return this.getStatus(user.id);
|
||||
}
|
||||
|
||||
if (user.stripeCustomerId || user.stripeSubscriptionId || user.subscriptionStatus === 'active') {
|
||||
throw httpError('This account is managed by Stripe. Use Checkout or Manage billing to change plans.', 403);
|
||||
}
|
||||
|
||||
if (existingSubscription.effectiveStatus !== DEFAULT_STATUS) {
|
||||
throw httpError('Your trial is not active. Start Stripe Checkout to choose a paid plan.', 403);
|
||||
}
|
||||
|
||||
const trialWindow = user.trialStartedAt && user.trialEndsAt ? {
|
||||
trialStartedAt: user.trialStartedAt,
|
||||
trialEndsAt: user.trialEndsAt,
|
||||
};
|
||||
} : buildTrialWindow(now);
|
||||
|
||||
await user.update({
|
||||
subscriptionPlanId: normalizePlanId(planId),
|
||||
subscriptionStatus: user.subscriptionStatus === 'active' ? 'active' : DEFAULT_STATUS,
|
||||
subscriptionPlanId: targetPlanId,
|
||||
subscriptionStatus: DEFAULT_STATUS,
|
||||
trialStartedAt: trialWindow.trialStartedAt,
|
||||
trialEndsAt: trialWindow.trialEndsAt,
|
||||
updatedById: currentUser.id,
|
||||
@ -230,6 +365,205 @@ module.exports = class SubscriptionService {
|
||||
return this.getStatus(user.id);
|
||||
}
|
||||
|
||||
|
||||
static async createCheckoutSession(currentUser, planId, baseUrl) {
|
||||
const user = await db.users.findByPk(currentUser?.id);
|
||||
|
||||
if (!user) {
|
||||
throw httpError('Subscription user was not found.', 404);
|
||||
}
|
||||
|
||||
const plan = getPlan(planId);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
const trialPeriodDays = user.stripeSubscriptionId ? 0 : getTrialDaysLeftForCheckout(subscription);
|
||||
const session = await StripeBillingService.createCheckoutSession({
|
||||
user,
|
||||
plan,
|
||||
baseUrl,
|
||||
trialPeriodDays,
|
||||
});
|
||||
|
||||
await user.update({
|
||||
subscriptionPlanId: plan.id,
|
||||
stripeCheckoutSessionId: session.id,
|
||||
updatedById: currentUser.id,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
url: session.url,
|
||||
};
|
||||
}
|
||||
|
||||
static async createPortalSession(currentUser, baseUrl) {
|
||||
const user = await db.users.findByPk(currentUser?.id);
|
||||
|
||||
if (!user) {
|
||||
throw httpError('Subscription user was not found.', 404);
|
||||
}
|
||||
|
||||
if (!user.stripeCustomerId) {
|
||||
throw httpError('No Stripe customer is linked to this account yet. Start Checkout first, then use Manage billing.', 400);
|
||||
}
|
||||
|
||||
const portalSession = await StripeBillingService.createPortalSession({
|
||||
customerId: user.stripeCustomerId,
|
||||
baseUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
url: portalSession.url,
|
||||
};
|
||||
}
|
||||
|
||||
static async syncStripeSubscription(subscription, options = {}) {
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stripeSubscriptionId = getStripeSubscriptionId(subscription.id);
|
||||
const stripeCustomerId = getStripeCustomerId(subscription.customer || options.customerId);
|
||||
const whereClauses = [];
|
||||
|
||||
if (options.userId) {
|
||||
whereClauses.push({ id: options.userId });
|
||||
}
|
||||
|
||||
if (stripeSubscriptionId) {
|
||||
whereClauses.push({ stripeSubscriptionId });
|
||||
}
|
||||
|
||||
if (stripeCustomerId) {
|
||||
whereClauses.push({ stripeCustomerId });
|
||||
}
|
||||
|
||||
if (!whereClauses.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await db.users.findOne({
|
||||
where: {
|
||||
[db.Sequelize.Op.or]: whereClauses,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const planId = getPlanIdFromStripeSubscription(subscription, options.planId || user.subscriptionPlanId);
|
||||
const status = subscription.status || user.subscriptionStatus || DEFAULT_STATUS;
|
||||
const trialStartedAt = getUnixDate(subscription.trial_start) || user.trialStartedAt;
|
||||
const trialEndsAt = getUnixDate(subscription.trial_end) || user.trialEndsAt;
|
||||
const subscriptionStartedAt = getUnixDate(subscription.start_date) || user.subscriptionStartedAt || new Date();
|
||||
const subscriptionEndsAt = getUnixDate(subscription.cancel_at) || getCurrentPeriodEnd(subscription) || user.subscriptionEndsAt;
|
||||
const subscriptionCanceledAt = getUnixDate(subscription.canceled_at) || (status === 'canceled' ? new Date() : user.subscriptionCanceledAt);
|
||||
|
||||
await user.update({
|
||||
subscriptionPlanId: planId,
|
||||
subscriptionStatus: status,
|
||||
trialStartedAt,
|
||||
trialEndsAt,
|
||||
subscriptionStartedAt,
|
||||
subscriptionEndsAt,
|
||||
subscriptionCanceledAt,
|
||||
stripeCustomerId: stripeCustomerId || user.stripeCustomerId,
|
||||
stripeSubscriptionId: stripeSubscriptionId || user.stripeSubscriptionId,
|
||||
stripePriceId: getSubscriptionPriceId(subscription) || user.stripePriceId,
|
||||
stripeCheckoutSessionId: options.checkoutSessionId || user.stripeCheckoutSessionId,
|
||||
stripeCurrentPeriodEndAt: getCurrentPeriodEnd(subscription) || user.stripeCurrentPeriodEndAt,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
static async updateStripeSubscriptionStatusByReference(reference, status) {
|
||||
const whereClauses = [];
|
||||
|
||||
if (reference.subscriptionId) {
|
||||
whereClauses.push({ stripeSubscriptionId: reference.subscriptionId });
|
||||
}
|
||||
|
||||
if (reference.customerId) {
|
||||
whereClauses.push({ stripeCustomerId: reference.customerId });
|
||||
}
|
||||
|
||||
if (!whereClauses.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await db.users.findOne({
|
||||
where: {
|
||||
[db.Sequelize.Op.or]: whereClauses,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await user.update({ subscriptionStatus: status });
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
static async handleStripeWebhook(rawBody, signature) {
|
||||
const event = StripeBillingService.constructWebhookEvent(rawBody, signature);
|
||||
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object;
|
||||
const subscription = await StripeBillingService.retrieveSubscription(getStripeSubscriptionId(session.subscription));
|
||||
|
||||
if (subscription) {
|
||||
await this.syncStripeSubscription(subscription, {
|
||||
userId: session.client_reference_id || session.metadata?.userId,
|
||||
planId: session.metadata?.planId,
|
||||
customerId: getStripeCustomerId(session.customer),
|
||||
checkoutSessionId: session.id,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
case 'customer.subscription.deleted':
|
||||
await this.syncStripeSubscription(event.data.object);
|
||||
break;
|
||||
|
||||
case 'invoice.payment_succeeded':
|
||||
case 'invoice.payment_failed': {
|
||||
const invoice = event.data.object;
|
||||
const subscriptionId = getStripeSubscriptionId(invoice.subscription);
|
||||
const subscription = await StripeBillingService.retrieveSubscription(subscriptionId);
|
||||
|
||||
if (subscription) {
|
||||
await this.syncStripeSubscription(subscription, {
|
||||
customerId: getStripeCustomerId(invoice.customer),
|
||||
});
|
||||
} else if (event.type === 'invoice.payment_failed') {
|
||||
await this.updateStripeSubscriptionStatusByReference({
|
||||
subscriptionId,
|
||||
customerId: getStripeCustomerId(invoice.customer),
|
||||
}, 'past_due');
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
received: true,
|
||||
type: event.type,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
static async canCreateReviewRequests(currentUserOrId, quantity = 1, options = {}) {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
@ -254,7 +588,7 @@ module.exports = class SubscriptionService {
|
||||
usage.monthlyReviewRequests,
|
||||
limit,
|
||||
'review requests per month',
|
||||
usage.periodEnd,
|
||||
{ resetDate: usage.periodEnd },
|
||||
),
|
||||
};
|
||||
}
|
||||
@ -291,7 +625,13 @@ module.exports = class SubscriptionService {
|
||||
return {
|
||||
allowed: false,
|
||||
code: 403,
|
||||
message: getLimitMessage(subscription.plan, usage.businesses, limit, 'businesses/locations', usage.periodEnd),
|
||||
message: getLimitMessage(
|
||||
subscription.plan,
|
||||
usage.businesses,
|
||||
limit,
|
||||
'businesses/locations',
|
||||
{ remediation: 'remove an existing business/location before adding another.' },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -308,6 +648,94 @@ module.exports = class SubscriptionService {
|
||||
return result;
|
||||
}
|
||||
|
||||
static async canCreateTeamMembers(currentUserOrId, quantity = 1, options = {}) {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
|
||||
if (!subscription.isActive) {
|
||||
return {
|
||||
allowed: false,
|
||||
code: 403,
|
||||
message: 'Your Review Flow trial has ended. Choose a plan to keep inviting team members.',
|
||||
};
|
||||
}
|
||||
|
||||
const usage = await this.getUsageForUserId(user.id, options);
|
||||
const limit = subscription.plan.limits.teamMembers;
|
||||
|
||||
if (usage.teamMembers + quantity > limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
code: 403,
|
||||
message: getLimitMessage(
|
||||
subscription.plan,
|
||||
usage.teamMembers,
|
||||
limit,
|
||||
'team members',
|
||||
{ remediation: 'remove or disable a team member before inviting another.' },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true, usage, subscription };
|
||||
}
|
||||
|
||||
static async assertCanCreateTeamMembers(currentUserOrId, quantity = 1, options = {}) {
|
||||
const result = await this.canCreateTeamMembers(currentUserOrId, quantity, options);
|
||||
|
||||
if (!result.allowed) {
|
||||
throw httpError(result.message, result.code);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async canConnectPaymentProvider(currentUserOrId, connectedField, options = {}) {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
|
||||
if (!subscription.isActive) {
|
||||
return {
|
||||
allowed: false,
|
||||
code: 403,
|
||||
message: 'Your Review Flow trial has ended. Choose a plan to keep connecting payment providers.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!PAYMENT_CONNECTOR_FIELDS.includes(connectedField)) {
|
||||
throw httpError('Unknown payment provider connector.', 400);
|
||||
}
|
||||
|
||||
const usage = await this.getUsageForUserId(user.id, options);
|
||||
const limit = subscription.plan.limits.paymentConnectors;
|
||||
|
||||
if (usage.paymentConnectors + 1 > limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
code: 403,
|
||||
message: getLimitMessage(
|
||||
subscription.plan,
|
||||
usage.paymentConnectors,
|
||||
limit,
|
||||
'connected payment providers',
|
||||
{ remediation: 'disconnect a payment provider before connecting another.' },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true, usage, subscription };
|
||||
}
|
||||
|
||||
static async assertCanConnectPaymentProvider(currentUserOrId, connectedField, options = {}) {
|
||||
const result = await this.canConnectPaymentProvider(currentUserOrId, connectedField, options);
|
||||
|
||||
if (!result.allowed) {
|
||||
throw httpError(result.message, result.code);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async assertFeatureAccess(currentUserOrId, featureKey, options = {}) {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
|
||||
@ -3,14 +3,12 @@ const UsersDBApi = require('../db/api/users');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
|
||||
|
||||
const InvitationEmail = require('./email/list/invitation');
|
||||
const EmailSender = require('./email');
|
||||
const AuthService = require('./auth');
|
||||
const SubscriptionService = require('./subscription');
|
||||
|
||||
module.exports = class UsersService {
|
||||
static async create(data, currentUser, sendInvitationEmails = true, host) {
|
||||
@ -26,6 +24,7 @@ module.exports = class UsersService {
|
||||
'iam.errors.userAlreadyExists',
|
||||
);
|
||||
} else {
|
||||
await SubscriptionService.assertCanCreateTeamMembers(currentUser, 1, { transaction });
|
||||
await UsersDBApi.create(
|
||||
{data},
|
||||
|
||||
@ -79,6 +78,8 @@ module.exports = class UsersService {
|
||||
throw new ValidationError('importer.errors.userEmailMissing');
|
||||
}
|
||||
|
||||
await SubscriptionService.assertCanCreateTeamMembers(req.currentUser, results.length, { transaction });
|
||||
|
||||
await UsersDBApi.bulkImport(results, {
|
||||
transaction,
|
||||
ignoreDuplicates: true,
|
||||
@ -134,7 +135,7 @@ module.exports = class UsersService {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async remove(id, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
1370
backend/yarn.lock
1370
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -62,6 +62,23 @@ interface PaymentProviderConnectorsProps {
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
type ConnectorSubscriptionStatus = {
|
||||
subscription: {
|
||||
planId: string;
|
||||
planName: string;
|
||||
effectiveStatus: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
usage: {
|
||||
businesses: number;
|
||||
paymentConnectors: number;
|
||||
};
|
||||
limits: {
|
||||
businesses: number;
|
||||
paymentConnectors: number;
|
||||
};
|
||||
};
|
||||
|
||||
const connectorDefaults: ConnectorFormValues = {
|
||||
provider: 'stripe',
|
||||
businessName: 'Review Flow Studio',
|
||||
@ -540,6 +557,8 @@ export default function PaymentProviderConnectors({
|
||||
const [error, setError] = useState('');
|
||||
const [copiedUrl, setCopiedUrl] = useState('');
|
||||
const [isClientReady, setIsClientReady] = useState(false);
|
||||
const [subscriptionStatus, setSubscriptionStatus] =
|
||||
useState<ConnectorSubscriptionStatus | null>(null);
|
||||
|
||||
const selectedProvider =
|
||||
providerOptions.find(
|
||||
@ -647,6 +666,17 @@ export default function PaymentProviderConnectors({
|
||||
}
|
||||
};
|
||||
|
||||
const loadSubscriptionStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get('/subscription/me');
|
||||
setSubscriptionStatus(response.data);
|
||||
} catch (requestError) {
|
||||
if (!isUnauthorizedError(requestError)) {
|
||||
console.error('Failed to load connector subscription status:', requestError);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsClientReady(true);
|
||||
|
||||
@ -656,6 +686,7 @@ export default function PaymentProviderConnectors({
|
||||
}
|
||||
|
||||
loadConnectors();
|
||||
loadSubscriptionStatus();
|
||||
}, []);
|
||||
|
||||
const handleConnectorSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
@ -679,7 +710,7 @@ export default function PaymentProviderConnectors({
|
||||
`${selectedProvider.label} is connected for ${business.name}. ${selectedReviewDestination.label} is the review destination. Copy the secure webhook URL below into your ${selectedProvider.label} dashboard.`,
|
||||
);
|
||||
|
||||
await loadConnectors();
|
||||
await Promise.all([loadConnectors(), loadSubscriptionStatus()]);
|
||||
|
||||
if (onConnected) {
|
||||
try {
|
||||
@ -747,6 +778,24 @@ export default function PaymentProviderConnectors({
|
||||
}
|
||||
};
|
||||
|
||||
const connectorUsage = subscriptionStatus?.usage.paymentConnectors ?? 0;
|
||||
const connectorLimit = subscriptionStatus?.limits.paymentConnectors ?? 0;
|
||||
const businessUsage = subscriptionStatus?.usage.businesses ?? 0;
|
||||
const businessLimit = subscriptionStatus?.limits.businesses ?? 0;
|
||||
const isConnectorSubscriptionInactive = Boolean(
|
||||
subscriptionStatus && !subscriptionStatus.subscription.isActive,
|
||||
);
|
||||
const isConnectorLimitReached = Boolean(
|
||||
subscriptionStatus && connectorLimit > 0 && connectorUsage >= connectorLimit,
|
||||
);
|
||||
const isBusinessLimitReached = Boolean(
|
||||
subscriptionStatus && businessLimit > 0 && businessUsage >= businessLimit,
|
||||
);
|
||||
const shouldShowConnectorLimitCta =
|
||||
isConnectorSubscriptionInactive || isConnectorLimitReached || isBusinessLimitReached;
|
||||
const connectorLimitButtonLabel =
|
||||
subscriptionStatus?.subscription.planId === 'starter' ? 'Upgrade to Pro' : 'Manage plan';
|
||||
|
||||
return (
|
||||
<CardBox
|
||||
className={`${className} border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700`}
|
||||
@ -788,7 +837,37 @@ export default function PaymentProviderConnectors({
|
||||
)}
|
||||
{error && (
|
||||
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
|
||||
{error}
|
||||
<p>{error}</p>
|
||||
{error.includes('Upgrade to Pro') && (
|
||||
<BaseButton
|
||||
href='/subscription'
|
||||
icon={mdiCreditCardOutline}
|
||||
label='Manage subscription'
|
||||
color='danger'
|
||||
className='mt-3'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowConnectorLimitCta && (
|
||||
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-50'>
|
||||
<p className='text-sm font-black uppercase tracking-[0.25em]'>
|
||||
{isConnectorSubscriptionInactive ? 'Subscription inactive' : 'Plan limit may block new connections'}
|
||||
</p>
|
||||
<p className='mt-2 text-sm leading-6'>
|
||||
{isConnectorSubscriptionInactive
|
||||
? 'Provider connections are paused until this account has an active plan.'
|
||||
: `${subscriptionStatus?.subscription.planName} currently uses ${connectorUsage.toLocaleString()} / ${connectorLimit.toLocaleString()} provider connectors and ${businessUsage.toLocaleString()} / ${businessLimit.toLocaleString()} businesses/locations.`}
|
||||
{' '}Updating an already connected provider may still work, but new providers or new businesses can be blocked.
|
||||
</p>
|
||||
<BaseButton
|
||||
href='/subscription'
|
||||
icon={mdiCreditCardOutline}
|
||||
label={connectorLimitButtonLabel}
|
||||
color='danger'
|
||||
className='mt-3'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -974,7 +1053,7 @@ export default function PaymentProviderConnectors({
|
||||
: `Connect ${selectedProvider.label}`
|
||||
}
|
||||
color='info'
|
||||
disabled={isConnectorSubmitting}
|
||||
disabled={isConnectorSubmitting || isConnectorSubscriptionInactive}
|
||||
/>
|
||||
<BaseButton
|
||||
type='button'
|
||||
|
||||
135
frontend/src/components/SubscriptionLimitGate.tsx
Normal file
135
frontend/src/components/SubscriptionLimitGate.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { mdiCreditCardOutline } from '@mdi/js'
|
||||
import axios from 'axios'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import BaseButton from './BaseButton'
|
||||
import CardBox from './CardBox'
|
||||
|
||||
type LimitKey = 'monthlyReviewRequests' | 'businesses' | 'teamMembers' | 'paymentConnectors'
|
||||
|
||||
type SubscriptionLimitStatus = {
|
||||
subscription: {
|
||||
planId: string
|
||||
planName: string
|
||||
effectiveStatus: string
|
||||
isActive: boolean
|
||||
}
|
||||
usage: Record<LimitKey, number>
|
||||
limits: Record<LimitKey, number>
|
||||
}
|
||||
|
||||
type Props = {
|
||||
limitKey: LimitKey
|
||||
actionLabel: string
|
||||
label?: string
|
||||
className?: string
|
||||
nearLimitPercent?: number
|
||||
}
|
||||
|
||||
const defaultLabels: Record<LimitKey, string> = {
|
||||
monthlyReviewRequests: 'review requests this month',
|
||||
businesses: 'businesses/locations',
|
||||
teamMembers: 'team members',
|
||||
paymentConnectors: 'connected payment providers',
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
export default function SubscriptionLimitGate({
|
||||
limitKey,
|
||||
actionLabel,
|
||||
label,
|
||||
className = 'mb-6',
|
||||
nearLimitPercent = 80,
|
||||
}: Props) {
|
||||
const [status, setStatus] = useState<SubscriptionLimitStatus | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get('/subscription/me')
|
||||
|
||||
if (isMounted) {
|
||||
setStatus(response.data)
|
||||
setError('')
|
||||
}
|
||||
} catch (requestError) {
|
||||
console.error('Failed to load subscription limit status:', requestError)
|
||||
|
||||
if (isMounted) {
|
||||
setError('Could not check plan limits right now. The backend will still enforce them when you submit.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadStatus()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<CardBox className={`${className} border-0 bg-amber-50 text-amber-950 ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:ring-amber-800`}>
|
||||
<p className='text-sm font-black uppercase tracking-[0.25em]'>Plan check unavailable</p>
|
||||
<p className='mt-2 text-sm leading-6'>{error}</p>
|
||||
</CardBox>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
|
||||
const used = Number(status.usage[limitKey]) || 0
|
||||
const limit = Number(status.limits[limitKey]) || 0
|
||||
const limitLabel = label || (limit === 1 && limitKey === 'businesses'
|
||||
? 'business/location'
|
||||
: defaultLabels[limitKey])
|
||||
const percent = limit > 0 ? Math.round((used / limit) * 100) : 0
|
||||
const isInactive = !status.subscription.isActive
|
||||
const isBlocked = isInactive || (limit > 0 && used >= limit)
|
||||
const isNearLimit = !isBlocked && percent >= nearLimitPercent
|
||||
|
||||
if (!isBlocked && !isNearLimit) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cardClass = isBlocked
|
||||
? 'border-0 bg-rose-50 text-rose-950 ring-1 ring-rose-200 dark:bg-rose-950 dark:text-rose-50 dark:ring-rose-800'
|
||||
: 'border-0 bg-amber-50 text-amber-950 ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:ring-amber-800'
|
||||
const buttonLabel = status.subscription.planId === 'starter' ? 'Upgrade to Pro' : 'Manage plan'
|
||||
|
||||
return (
|
||||
<CardBox className={`${className} ${cardClass}`}>
|
||||
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
|
||||
<div>
|
||||
<p className='text-sm font-black uppercase tracking-[0.25em]'>
|
||||
{isBlocked ? 'Plan limit reached' : 'Plan limit almost reached'}
|
||||
</p>
|
||||
<h3 className='mt-2 text-xl font-black'>
|
||||
{actionLabel} {isBlocked ? 'may be blocked' : 'is getting close to the limit'}
|
||||
</h3>
|
||||
<p className='mt-2 text-sm leading-6'>
|
||||
{isInactive
|
||||
? `Your ${status.subscription.planName} plan is ${status.subscription.effectiveStatus}. Reactivate or choose a plan before continuing.`
|
||||
: `${status.subscription.planName} includes ${formatNumber(limit)} ${limitLabel}. This account is using ${formatNumber(used)}.`}
|
||||
{' '}Existing data stays available.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
href='/subscription'
|
||||
icon={mdiCreditCardOutline}
|
||||
label={buttonLabel}
|
||||
color={isBlocked ? 'danger' : 'warning'}
|
||||
className='self-start md:self-center'
|
||||
/>
|
||||
</div>
|
||||
</CardBox>
|
||||
)
|
||||
}
|
||||
@ -28,22 +28,6 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
||||
permissions: 'READ_USERS',
|
||||
},
|
||||
{
|
||||
href: '/roles/roles-list',
|
||||
label: 'Roles',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_ROLES',
|
||||
},
|
||||
{
|
||||
href: '/permissions/permissions-list',
|
||||
label: 'Permissions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_PERMISSIONS',
|
||||
},
|
||||
{
|
||||
href: '/businesses/businesses-list',
|
||||
label: 'Businesses',
|
||||
@ -126,14 +110,6 @@ const menuAside: MenuAsideItem[] = [
|
||||
label: 'Profile',
|
||||
icon: icon.mdiAccountCircle,
|
||||
},
|
||||
|
||||
{
|
||||
href: '/api-docs',
|
||||
target: '_blank',
|
||||
label: 'Swagger API',
|
||||
icon: icon.mdiFileCode,
|
||||
permissions: 'READ_API_DOCS',
|
||||
},
|
||||
];
|
||||
|
||||
export default menuAside;
|
||||
|
||||
@ -2,6 +2,7 @@ import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js
|
||||
import Head from 'next/head'
|
||||
import React, { ReactElement } from 'react'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
@ -277,6 +278,10 @@ const BusinessesNew = () => {
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<SubscriptionLimitGate
|
||||
limitKey='businesses'
|
||||
actionLabel='Adding another business/location'
|
||||
/>
|
||||
<CardBox>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
@ -49,7 +49,7 @@ export default function Register() {
|
||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
||||
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
|
||||
<p className='font-black'>{selectedPlan.name} trial</p>
|
||||
<p className='text-sm'>${selectedPlan.priceMonthly}/month after the {selectedPlan.trialDays}-day free trial. You can change plans from Subscription after signup.</p>
|
||||
<p className='text-sm'>${selectedPlan.priceMonthly}/month after the {selectedPlan.trialDays}-day free trial. You can manage billing from Subscription after signup.</p>
|
||||
</div>
|
||||
<Formik
|
||||
initialValues={{
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
mdiRefresh,
|
||||
mdiSend,
|
||||
mdiStarCircleOutline,
|
||||
mdiWebhook,
|
||||
} from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
@ -341,6 +340,14 @@ export default function ReviewFlowWorkspace() {
|
||||
const isStarterPlan = currentSubscription?.planId === 'starter';
|
||||
const isSubscriptionInactive =
|
||||
currentSubscription && !currentSubscription.isActive;
|
||||
const isReviewRequestLimitReached = Boolean(
|
||||
currentSubscription &&
|
||||
reviewRequestsLimit > 0 &&
|
||||
reviewRequestsUsed >= reviewRequestsLimit,
|
||||
);
|
||||
const isReviewRequestBlocked = Boolean(
|
||||
isSubscriptionInactive || isReviewRequestLimitReached,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -353,12 +360,7 @@ export default function ReviewFlowWorkspace() {
|
||||
title='Review Flow command center'
|
||||
main
|
||||
>
|
||||
<BaseButton
|
||||
href='/review_requests/review_requests-list'
|
||||
icon={mdiOpenInNew}
|
||||
label='Open CRUD'
|
||||
color='whiteDark'
|
||||
/>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 p-6 text-white shadow-2xl'>
|
||||
@ -520,6 +522,27 @@ export default function ReviewFlowWorkspace() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isReviewRequestBlocked && (
|
||||
<div className='mb-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-50'>
|
||||
<p className='text-sm font-black uppercase tracking-[0.25em]'>
|
||||
{isSubscriptionInactive ? 'Subscription inactive' : 'Monthly request limit reached'}
|
||||
</p>
|
||||
<p className='mt-2 text-sm leading-6'>
|
||||
{isSubscriptionInactive
|
||||
? 'Review requests are paused until this account has an active plan.'
|
||||
: `${currentSubscription?.planName} includes ${reviewRequestsLimit.toLocaleString()} review requests per month, and this account has already used ${reviewRequestsUsed.toLocaleString()}.`}
|
||||
{' '}Existing queued requests stay available.
|
||||
</p>
|
||||
<BaseButton
|
||||
href='/subscription'
|
||||
icon={mdiCreditCardOutline}
|
||||
label={isStarterPlan ? 'Upgrade to Pro' : 'Manage plan'}
|
||||
color='danger'
|
||||
className='mt-3'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FormField
|
||||
label='Business and review destination'
|
||||
@ -617,7 +640,7 @@ export default function ReviewFlowWorkspace() {
|
||||
icon={mdiSend}
|
||||
label={isSubmitting ? 'Queueing...' : 'Queue review request'}
|
||||
color='info'
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isReviewRequestBlocked}
|
||||
/>
|
||||
<BaseButton
|
||||
type='button'
|
||||
@ -642,12 +665,6 @@ export default function ReviewFlowWorkspace() {
|
||||
Recent requests
|
||||
</h3>
|
||||
</div>
|
||||
<BaseButton
|
||||
href='/review_requests/review_requests-list'
|
||||
label='All'
|
||||
color='whiteDark'
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@ -783,13 +800,6 @@ export default function ReviewFlowWorkspace() {
|
||||
Recent payment events
|
||||
</h3>
|
||||
</div>
|
||||
<BaseButton
|
||||
href='/stripe_events/stripe_events-list'
|
||||
icon={mdiWebhook}
|
||||
label='Events'
|
||||
color='whiteDark'
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
{recentEvents.length === 0 ? (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
||||
@ -842,13 +852,6 @@ export default function ReviewFlowWorkspace() {
|
||||
Recent transactions
|
||||
</h3>
|
||||
</div>
|
||||
<BaseButton
|
||||
href='/transactions/transactions-list'
|
||||
icon={mdiCreditCardOutline}
|
||||
label='Payments'
|
||||
color='whiteDark'
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
{recentTransactions.length === 0 ? (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
} from '@mdi/js'
|
||||
import axios from 'axios'
|
||||
import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { ReactElement, useEffect, useState } from 'react'
|
||||
import BaseButton from '../components/BaseButton'
|
||||
import CardBox from '../components/CardBox'
|
||||
@ -26,6 +27,17 @@ type SubscriptionStatusResponse = {
|
||||
trialDaysLeft?: number | null
|
||||
priceMonthly: number
|
||||
currency: string
|
||||
stripeCustomerLinked?: boolean
|
||||
stripeSubscriptionLinked?: boolean
|
||||
currentPeriodEndsAt?: string | null
|
||||
}
|
||||
billing?: {
|
||||
checkoutReady: boolean
|
||||
portalReady: boolean
|
||||
webhookReady: boolean
|
||||
hasStripeCustomer: boolean
|
||||
hasStripeSubscription: boolean
|
||||
missingConfiguration: string[]
|
||||
}
|
||||
usage: {
|
||||
monthlyReviewRequests: number
|
||||
@ -64,10 +76,28 @@ function formatLimit(value: number) {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
function getRequestErrorMessage(requestError: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(requestError) && requestError.response?.data) {
|
||||
const data = requestError.response.data
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
|
||||
if (typeof data?.message === 'string') {
|
||||
return data.message
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const router = useRouter()
|
||||
const [status, setStatus] = useState<SubscriptionStatusResponse | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectingPlanId, setSelectingPlanId] = useState('')
|
||||
const [billingActionPlanId, setBillingActionPlanId] = useState('')
|
||||
const [isOpeningPortal, setIsOpeningPortal] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@ -89,28 +119,76 @@ export default function SubscriptionPage() {
|
||||
loadStatus()
|
||||
}, [])
|
||||
|
||||
const selectPlan = async (planId: string) => {
|
||||
setSelectingPlanId(planId)
|
||||
useEffect(() => {
|
||||
if (!router.isReady) {
|
||||
return
|
||||
}
|
||||
|
||||
if (router.query.checkout === 'success') {
|
||||
setMessage('Thanks — Stripe is confirming your subscription. This page will update after the webhook is received.')
|
||||
loadStatus()
|
||||
}
|
||||
|
||||
if (router.query.checkout === 'cancelled') {
|
||||
setMessage('Checkout was cancelled. You can restart checkout whenever you are ready.')
|
||||
}
|
||||
}, [router.isReady, router.query.checkout])
|
||||
|
||||
const startCheckout = async (planId: string) => {
|
||||
setBillingActionPlanId(planId)
|
||||
setError('')
|
||||
setMessage('')
|
||||
|
||||
try {
|
||||
const response = await axios.post('/subscription/select-plan', { planId })
|
||||
setStatus(response.data)
|
||||
setMessage(`Your trial plan is now ${response.data.subscription.planName}.`)
|
||||
} catch (requestError) {
|
||||
console.error('Failed to select subscription plan:', requestError)
|
||||
if (axios.isAxiosError(requestError) && requestError.response?.data) {
|
||||
setError(String(requestError.response.data))
|
||||
} else {
|
||||
setError('Could not update your plan. Please try again.')
|
||||
const response = await axios.post('/subscription/create-checkout-session', { planId })
|
||||
const url = response.data?.url
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Stripe Checkout did not return a redirect URL.')
|
||||
}
|
||||
|
||||
window.location.href = url
|
||||
} catch (requestError) {
|
||||
console.error('Failed to create Stripe Checkout session:', requestError)
|
||||
setError(getRequestErrorMessage(requestError, 'Could not start Stripe Checkout. Please try again.'))
|
||||
} finally {
|
||||
setSelectingPlanId('')
|
||||
setBillingActionPlanId('')
|
||||
}
|
||||
}
|
||||
|
||||
const openBillingPortal = async () => {
|
||||
setIsOpeningPortal(true)
|
||||
setError('')
|
||||
setMessage('')
|
||||
|
||||
try {
|
||||
const response = await axios.post('/subscription/create-portal-session')
|
||||
const url = response.data?.url
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Stripe Customer Portal did not return a redirect URL.')
|
||||
}
|
||||
|
||||
window.location.href = url
|
||||
} catch (requestError) {
|
||||
console.error('Failed to create Stripe Customer Portal session:', requestError)
|
||||
setError(getRequestErrorMessage(requestError, 'Could not open billing management. Please try again.'))
|
||||
} finally {
|
||||
setIsOpeningPortal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const currentPlanId = status?.subscription.planId
|
||||
const isPaidStripeSubscription = status?.subscription.status === 'active' && Boolean(status.billing?.hasStripeCustomer)
|
||||
const missingConfiguration = status?.billing?.missingConfiguration || []
|
||||
const overLimitItems = status
|
||||
? usageLabels.filter((item) => {
|
||||
const used = Number(status.usage[item.key]) || 0
|
||||
const limit = Number(status.limits[item.limitKey]) || 0
|
||||
|
||||
return limit > 0 && used > limit
|
||||
})
|
||||
: []
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -146,6 +224,31 @@ export default function SubscriptionPage() {
|
||||
<CardBox>Loading subscription details...</CardBox>
|
||||
) : status ? (
|
||||
<>
|
||||
{missingConfiguration.length > 0 && (
|
||||
<CardBox className='mb-6 border-0 bg-amber-50 text-amber-950 shadow-xl ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:ring-amber-800'>
|
||||
<p className='text-lg font-black'>Stripe setup needed</p>
|
||||
<p className='mt-2 leading-7'>
|
||||
Billing UI is wired, but Checkout will not launch until these backend environment variables are set:
|
||||
{' '}
|
||||
<strong>{missingConfiguration.join(', ')}</strong>.
|
||||
</p>
|
||||
<p className='mt-2 text-sm font-semibold'>
|
||||
Create monthly Stripe Prices for Starter and Pro, paste their Price IDs into the matching variables, add your webhook secret, then reload the backend.
|
||||
</p>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
{overLimitItems.length > 0 && (
|
||||
<CardBox className='mb-6 border-0 bg-rose-50 text-rose-950 shadow-xl ring-1 ring-rose-200 dark:bg-rose-950 dark:text-rose-50 dark:ring-rose-800'>
|
||||
<p className='text-lg font-black'>Plan limit attention needed</p>
|
||||
<p className='mt-2 leading-7'>
|
||||
This account is currently over the {status.subscription.planName} limit for{' '}
|
||||
<strong>{overLimitItems.map((item) => item.label.toLowerCase()).join(', ')}</strong>.
|
||||
Existing data stays available, but creating more items in those areas will be blocked until usage is reduced or the account moves to a higher plan.
|
||||
</p>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
<CardBox className='mb-6 overflow-hidden border-0 bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 text-white shadow-2xl'>
|
||||
<div className='grid gap-6 lg:grid-cols-[1fr_0.8fr] lg:items-center'>
|
||||
<div>
|
||||
@ -162,6 +265,21 @@ export default function SubscriptionPage() {
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
{status.subscription.currentPeriodEndsAt && (
|
||||
<p className='mt-2 text-sm font-semibold text-slate-300'>
|
||||
Current Stripe billing period ends {formatDate(status.subscription.currentPeriodEndsAt)}.
|
||||
</p>
|
||||
)}
|
||||
{status.billing?.hasStripeCustomer && (
|
||||
<BaseButton
|
||||
icon={mdiCreditCardOutline}
|
||||
label='Manage billing'
|
||||
color='info'
|
||||
className='mt-6'
|
||||
disabled={isOpeningPortal || Boolean(billingActionPlanId)}
|
||||
onClick={openBillingPortal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='rounded-3xl bg-white/10 p-6 ring-1 ring-white/15'>
|
||||
<p className='text-sm font-bold text-slate-300'>Monthly price</p>
|
||||
@ -176,22 +294,34 @@ export default function SubscriptionPage() {
|
||||
const used = Number(status.usage[item.key]) || 0
|
||||
const limit = Number(status.limits[item.limitKey]) || 1
|
||||
const percent = Math.min(100, Math.round((used / limit) * 100))
|
||||
const isNearLimit = percent >= 80
|
||||
const isOverLimit = used > limit
|
||||
const isNearLimit = !isOverLimit && percent >= 80
|
||||
const usageTextClass = isOverLimit
|
||||
? 'font-black text-rose-600'
|
||||
: isNearLimit ? 'font-black text-amber-600' : 'font-black text-emerald-600'
|
||||
const progressClass = isOverLimit
|
||||
? 'h-full rounded-full bg-rose-500'
|
||||
: isNearLimit ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'
|
||||
|
||||
return (
|
||||
<CardBox key={item.key} className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='mb-3 flex items-center justify-between gap-3'>
|
||||
<p className='font-black text-slate-900 dark:text-white'>{item.label}</p>
|
||||
<p className={isNearLimit ? 'font-black text-amber-600' : 'font-black text-emerald-600'}>
|
||||
<p className={usageTextClass}>
|
||||
{formatLimit(used)} / {formatLimit(limit)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'>
|
||||
<div
|
||||
className={isNearLimit ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'}
|
||||
className={progressClass}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{isOverLimit && (
|
||||
<p className='mt-2 text-sm font-semibold text-rose-600'>
|
||||
Over this plan limit. Upgrade or reduce usage before adding more.
|
||||
</p>
|
||||
)}
|
||||
</CardBox>
|
||||
)
|
||||
})}
|
||||
@ -201,6 +331,10 @@ export default function SubscriptionPage() {
|
||||
{status.plans.map((plan) => {
|
||||
const isCurrent = currentPlanId === plan.id
|
||||
const isPro = plan.id === 'pro'
|
||||
const isBusy = billingActionPlanId === plan.id || isOpeningPortal
|
||||
const buttonLabel = isPaidStripeSubscription
|
||||
? isCurrent ? 'Manage billing' : 'Change in billing portal'
|
||||
: isCurrent ? `Start paid ${plan.name}` : `Checkout for ${plan.name}`
|
||||
|
||||
return (
|
||||
<CardBox
|
||||
@ -241,11 +375,11 @@ export default function SubscriptionPage() {
|
||||
</div>
|
||||
<BaseButton
|
||||
icon={isCurrent ? mdiCheckCircleOutline : mdiArrowUpBoldCircleOutline}
|
||||
label={isCurrent ? 'Current plan' : `Switch trial to ${plan.name}`}
|
||||
label={buttonLabel}
|
||||
color={isCurrent ? 'success' : isPro ? 'info' : 'whiteDark'}
|
||||
className='mt-8 w-full'
|
||||
disabled={isCurrent || Boolean(selectingPlanId)}
|
||||
onClick={() => selectPlan(plan.id)}
|
||||
disabled={isBusy}
|
||||
onClick={() => (isPaidStripeSubscription ? openBillingPortal() : startCheckout(plan.id))}
|
||||
/>
|
||||
</div>
|
||||
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
|
||||
|
||||
@ -2,6 +2,7 @@ import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js
|
||||
import Head from 'next/head'
|
||||
import React, { ReactElement } from 'react'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
@ -180,6 +181,10 @@ const UsersNew = () => {
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<SubscriptionLimitGate
|
||||
limitKey='teamMembers'
|
||||
actionLabel='Inviting another team member'
|
||||
/>
|
||||
<CardBox>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
@ -25,7 +25,7 @@ export const subscriptionPlans: SubscriptionPlan[] = [
|
||||
priceMonthly: 49,
|
||||
currency: 'USD',
|
||||
trialDays,
|
||||
tagline: 'For small businesses that want automated review collection up and running quickly.',
|
||||
tagline: 'For small teams that want automated review collection without extra marketing automation.',
|
||||
ctaLabel: 'Start Starter trial',
|
||||
limits: {
|
||||
monthlyReviewRequests: 250,
|
||||
@ -37,7 +37,9 @@ export const subscriptionPlans: SubscriptionPlan[] = [
|
||||
'Review Flow dashboard',
|
||||
'Manual review request creation',
|
||||
'Hosted public review form',
|
||||
'Customer and transaction management',
|
||||
'Customer management',
|
||||
'Business/location management',
|
||||
'Transaction tracking',
|
||||
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
|
||||
'Review request status tracking',
|
||||
'Email delivery logs',
|
||||
@ -51,7 +53,7 @@ export const subscriptionPlans: SubscriptionPlan[] = [
|
||||
priceMonthly: 99,
|
||||
currency: 'USD',
|
||||
trialDays,
|
||||
tagline: 'For growing teams that want advanced automation, AI assistance, and reputation marketing tools.',
|
||||
tagline: 'For growing businesses that want automation, AI assistance, and reputation marketing tools.',
|
||||
highlight: 'Best value',
|
||||
ctaLabel: 'Start Pro trial',
|
||||
limits: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user