Autosave: 20260629-175315

This commit is contained in:
Flatlogic Bot 2026-06-29 17:53:10 +00:00
parent 3dfd47bae8
commit c7ec13b78b
21 changed files with 2615 additions and 114 deletions

View File

@ -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"

View File

@ -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 || '';

View File

@ -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;
}
},
};

View File

@ -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);
});

View File

@ -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);

View 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;

View File

@ -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;

View File

@ -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);

View 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'],
});
}
};

View File

@ -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);

View File

@ -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();

File diff suppressed because it is too large Load Diff

View File

@ -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'

View 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>
)
}

View File

@ -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;

View File

@ -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={

View File

@ -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={{

View File

@ -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'>

View File

@ -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'}>

View File

@ -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={

View File

@ -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: {