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": "6.35.2",
"sequelize-json-schema": "^2.1.1", "sequelize-json-schema": "^2.1.1",
"sqlite": "4.0.15", "sqlite": "4.0.15",
"stripe": "^22.3.0",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"tedious": "^18.2.4" "tedious": "^18.2.4"

View File

@ -65,6 +65,12 @@ const config = {
gpt_key: process.env.GPT_KEY || '', 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 || ''; 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 providers = config.providers;
const crypto = require('crypto'); const crypto = require('crypto');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) { module.exports = function(sequelize, DataTypes) {
const users = sequelize.define( 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: { importHash: {
type: DataTypes.STRING(255), type: DataTypes.STRING(255),
allowNull: true, allowNull: true,
@ -236,8 +260,8 @@ subscriptionCanceledAt: {
}; };
users.beforeCreate((users, options) => { users.beforeCreate((users) => {
users = trimStringFields(users); trimStringFields(users);
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) { if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
users.emailVerified = true; users.emailVerified = true;
@ -257,8 +281,8 @@ subscriptionCanceledAt: {
} }
}); });
users.beforeUpdate((users, options) => { users.beforeUpdate((users) => {
users = trimStringFields(users); trimStringFields(users);
}); });

View File

@ -17,6 +17,7 @@ const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels'); const pexelsRoutes = require('./routes/pexels');
const plansRoutes = require('./routes/plans'); const plansRoutes = require('./routes/plans');
const subscriptionRoutes = require('./routes/subscription'); const subscriptionRoutes = require('./routes/subscription');
const subscriptionWebhookRoutes = require('./routes/subscription-webhooks');
const openaiRoutes = require('./routes/openai'); const openaiRoutes = require('./routes/openai');
@ -96,6 +97,8 @@ app.use('/api-docs', function (req, res, next) {
app.use(cors({origin: true})); app.use(cors({origin: true}));
require('./auth/auth'); require('./auth/auth');
app.use('/api/subscription/stripe-webhook', bodyParser.raw({type: 'application/json'}), subscriptionWebhookRoutes);
app.use(bodyParser.json()); app.use(bodyParser.json());
app.use('/api/auth', authRoutes); 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(); 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) => { router.get('/me', wrapAsync(async (req, res) => {
const status = await SubscriptionService.getStatus(req.currentUser); const status = await SubscriptionService.getStatus(req.currentUser);
@ -16,6 +33,26 @@ router.post('/select-plan', wrapAsync(async (req, res) => {
res.status(200).send(status); 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); router.use('/', require('../helpers').commonErrorHandler);
module.exports = router; 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.'); 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) { if (!business) {
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1); 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 db = require('../db/models');
const StripeBillingService = require('./stripeBilling');
const { const {
TRIAL_DAYS, TRIAL_DAYS,
getSubscriptionPlanById, getSubscriptionPlanById,
@ -102,8 +103,119 @@ function getEffectiveSubscription(user, referenceDate = new Date()) {
}; };
} }
function getLimitMessage(plan, usageCount, limit, unit, resetDate) { function getLimitMessage(plan, usageCount, limit, unit, options = {}) {
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.`; 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 = {}) { 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); 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) { if (!shouldLoad) {
return currentUserOrId; return currentUserOrId;
@ -149,14 +261,16 @@ module.exports = class SubscriptionService {
static async getUsageForUserId(userId, options = {}) { static async getUsageForUserId(userId, options = {}) {
const transaction = options.transaction || undefined; const transaction = options.transaction || undefined;
const { periodStart, periodEnd } = getCurrentMonthRange(); const { periodStart, periodEnd } = getCurrentMonthRange();
const teamScope = await getTeamUsageScope(userId, transaction);
const teamMemberFilter = { [db.Sequelize.Op.in]: teamScope.teamMemberIds };
const businesses = await db.businesses.findAll({ const businesses = await db.businesses.findAll({
where: { createdById: userId }, where: { createdById: teamMemberFilter },
attributes: ['id', ...PAYMENT_CONNECTOR_FIELDS], attributes: ['id', ...PAYMENT_CONNECTOR_FIELDS],
transaction, transaction,
}); });
const monthlyReviewRequests = await db.review_requests.count({ const monthlyReviewRequests = await db.review_requests.count({
where: { where: {
createdById: userId, createdById: teamMemberFilter,
createdAt: { createdAt: {
[db.Sequelize.Op.gte]: periodStart, [db.Sequelize.Op.gte]: periodStart,
[db.Sequelize.Op.lt]: periodEnd, [db.Sequelize.Op.lt]: periodEnd,
@ -171,7 +285,7 @@ module.exports = class SubscriptionService {
return { return {
monthlyReviewRequests, monthlyReviewRequests,
businesses: businesses.length, businesses: businesses.length,
teamMembers: 1, teamMembers: teamScope.teamMembers,
paymentConnectors, paymentConnectors,
periodStart, periodStart,
periodEnd, periodEnd,
@ -179,7 +293,7 @@ module.exports = class SubscriptionService {
} }
static async getStatus(currentUserOrId, options = {}) { static async getStatus(currentUserOrId, options = {}) {
const user = await getUserRecord(currentUserOrId, options); const user = await getUserRecord(currentUserOrId, { ...options, forceReload: true });
const subscription = getEffectiveSubscription(user); const subscription = getEffectiveSubscription(user);
const usage = await this.getUsageForUserId(user.id, options); const usage = await this.getUsageForUserId(user.id, options);
const plans = getSubscriptionPlans(); const plans = getSubscriptionPlans();
@ -196,6 +310,14 @@ module.exports = class SubscriptionService {
trialDaysLeft: subscription.trialDaysLeft, trialDaysLeft: subscription.trialDaysLeft,
priceMonthly: subscription.plan.priceMonthly, priceMonthly: subscription.plan.priceMonthly,
currency: subscription.plan.currency, 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, plan: subscription.plan,
usage, usage,
@ -212,16 +334,29 @@ module.exports = class SubscriptionService {
} }
const now = new Date(); const now = new Date();
const targetPlanId = normalizePlanId(planId);
const existingSubscription = getEffectiveSubscription(user, now); 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, trialStartedAt: user.trialStartedAt,
trialEndsAt: user.trialEndsAt, trialEndsAt: user.trialEndsAt,
}; } : buildTrialWindow(now);
await user.update({ await user.update({
subscriptionPlanId: normalizePlanId(planId), subscriptionPlanId: targetPlanId,
subscriptionStatus: user.subscriptionStatus === 'active' ? 'active' : DEFAULT_STATUS, subscriptionStatus: DEFAULT_STATUS,
trialStartedAt: trialWindow.trialStartedAt, trialStartedAt: trialWindow.trialStartedAt,
trialEndsAt: trialWindow.trialEndsAt, trialEndsAt: trialWindow.trialEndsAt,
updatedById: currentUser.id, updatedById: currentUser.id,
@ -230,6 +365,205 @@ module.exports = class SubscriptionService {
return this.getStatus(user.id); 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 = {}) { static async canCreateReviewRequests(currentUserOrId, quantity = 1, options = {}) {
const user = await getUserRecord(currentUserOrId, options); const user = await getUserRecord(currentUserOrId, options);
const subscription = getEffectiveSubscription(user); const subscription = getEffectiveSubscription(user);
@ -254,7 +588,7 @@ module.exports = class SubscriptionService {
usage.monthlyReviewRequests, usage.monthlyReviewRequests,
limit, limit,
'review requests per month', 'review requests per month',
usage.periodEnd, { resetDate: usage.periodEnd },
), ),
}; };
} }
@ -291,7 +625,13 @@ module.exports = class SubscriptionService {
return { return {
allowed: false, allowed: false,
code: 403, 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; 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 = {}) { static async assertFeatureAccess(currentUserOrId, featureKey, options = {}) {
const user = await getUserRecord(currentUserOrId, options); const user = await getUserRecord(currentUserOrId, options);
const subscription = getEffectiveSubscription(user); const subscription = getEffectiveSubscription(user);

View File

@ -3,14 +3,12 @@ const UsersDBApi = require('../db/api/users');
const processFile = require("../middlewares/upload"); const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config'); const config = require('../config');
const stream = require('stream'); const stream = require('stream');
const InvitationEmail = require('./email/list/invitation');
const EmailSender = require('./email');
const AuthService = require('./auth'); const AuthService = require('./auth');
const SubscriptionService = require('./subscription');
module.exports = class UsersService { module.exports = class UsersService {
static async create(data, currentUser, sendInvitationEmails = true, host) { static async create(data, currentUser, sendInvitationEmails = true, host) {
@ -26,6 +24,7 @@ module.exports = class UsersService {
'iam.errors.userAlreadyExists', 'iam.errors.userAlreadyExists',
); );
} else { } else {
await SubscriptionService.assertCanCreateTeamMembers(currentUser, 1, { transaction });
await UsersDBApi.create( await UsersDBApi.create(
{data}, {data},
@ -79,6 +78,8 @@ module.exports = class UsersService {
throw new ValidationError('importer.errors.userEmailMissing'); throw new ValidationError('importer.errors.userEmailMissing');
} }
await SubscriptionService.assertCanCreateTeamMembers(req.currentUser, results.length, { transaction });
await UsersDBApi.bulkImport(results, { await UsersDBApi.bulkImport(results, {
transaction, transaction,
ignoreDuplicates: true, ignoreDuplicates: true,
@ -134,7 +135,7 @@ module.exports = class UsersService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async remove(id, currentUser) { static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction(); 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>; ) => 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 = { const connectorDefaults: ConnectorFormValues = {
provider: 'stripe', provider: 'stripe',
businessName: 'Review Flow Studio', businessName: 'Review Flow Studio',
@ -540,6 +557,8 @@ export default function PaymentProviderConnectors({
const [error, setError] = useState(''); const [error, setError] = useState('');
const [copiedUrl, setCopiedUrl] = useState(''); const [copiedUrl, setCopiedUrl] = useState('');
const [isClientReady, setIsClientReady] = useState(false); const [isClientReady, setIsClientReady] = useState(false);
const [subscriptionStatus, setSubscriptionStatus] =
useState<ConnectorSubscriptionStatus | null>(null);
const selectedProvider = const selectedProvider =
providerOptions.find( 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(() => { useEffect(() => {
setIsClientReady(true); setIsClientReady(true);
@ -656,6 +686,7 @@ export default function PaymentProviderConnectors({
} }
loadConnectors(); loadConnectors();
loadSubscriptionStatus();
}, []); }, []);
const handleConnectorSubmit = async (event: FormEvent<HTMLFormElement>) => { 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.`, `${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) { if (onConnected) {
try { 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 ( return (
<CardBox <CardBox
className={`${className} border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700`} 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 && ( {error && (
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'> <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> </div>
)} )}
@ -974,7 +1053,7 @@ export default function PaymentProviderConnectors({
: `Connect ${selectedProvider.label}` : `Connect ${selectedProvider.label}`
} }
color='info' color='info'
disabled={isConnectorSubmitting} disabled={isConnectorSubmitting || isConnectorSubscriptionInactive}
/> />
<BaseButton <BaseButton
type='button' 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, icon: icon.mdiAccountGroup ?? icon.mdiTable,
permissions: 'READ_USERS', 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', href: '/businesses/businesses-list',
label: 'Businesses', label: 'Businesses',
@ -126,14 +110,6 @@ const menuAside: MenuAsideItem[] = [
label: 'Profile', label: 'Profile',
icon: icon.mdiAccountCircle, icon: icon.mdiAccountCircle,
}, },
{
href: '/api-docs',
target: '_blank',
label: 'Swagger API',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS',
},
]; ];
export default menuAside; export default menuAside;

View File

@ -2,6 +2,7 @@ import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js
import Head from 'next/head' import Head from 'next/head'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import CardBox from '../../components/CardBox' import CardBox from '../../components/CardBox'
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate'
import LayoutAuthenticated from '../../layouts/Authenticated' import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain' import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
@ -277,6 +278,10 @@ const BusinessesNew = () => {
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<SubscriptionLimitGate
limitKey='businesses'
actionLabel='Adding another business/location'
/>
<CardBox> <CardBox>
<Formik <Formik
initialValues={ 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'> <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'> <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='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> </div>
<Formik <Formik
initialValues={{ initialValues={{

View File

@ -6,7 +6,6 @@ import {
mdiRefresh, mdiRefresh,
mdiSend, mdiSend,
mdiStarCircleOutline, mdiStarCircleOutline,
mdiWebhook,
} from '@mdi/js'; } from '@mdi/js';
import axios from 'axios'; import axios from 'axios';
import Head from 'next/head'; import Head from 'next/head';
@ -341,6 +340,14 @@ export default function ReviewFlowWorkspace() {
const isStarterPlan = currentSubscription?.planId === 'starter'; const isStarterPlan = currentSubscription?.planId === 'starter';
const isSubscriptionInactive = const isSubscriptionInactive =
currentSubscription && !currentSubscription.isActive; currentSubscription && !currentSubscription.isActive;
const isReviewRequestLimitReached = Boolean(
currentSubscription &&
reviewRequestsLimit > 0 &&
reviewRequestsUsed >= reviewRequestsLimit,
);
const isReviewRequestBlocked = Boolean(
isSubscriptionInactive || isReviewRequestLimitReached,
);
return ( return (
<> <>
@ -353,12 +360,7 @@ export default function ReviewFlowWorkspace() {
title='Review Flow command center' title='Review Flow command center'
main main
> >
<BaseButton {''}
href='/review_requests/review_requests-list'
icon={mdiOpenInNew}
label='Open CRUD'
color='whiteDark'
/>
</SectionTitleLineWithButton> </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'> <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>
</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}> <form onSubmit={handleSubmit}>
<FormField <FormField
label='Business and review destination' label='Business and review destination'
@ -617,7 +640,7 @@ export default function ReviewFlowWorkspace() {
icon={mdiSend} icon={mdiSend}
label={isSubmitting ? 'Queueing...' : 'Queue review request'} label={isSubmitting ? 'Queueing...' : 'Queue review request'}
color='info' color='info'
disabled={isSubmitting} disabled={isSubmitting || isReviewRequestBlocked}
/> />
<BaseButton <BaseButton
type='button' type='button'
@ -642,12 +665,6 @@ export default function ReviewFlowWorkspace() {
Recent requests Recent requests
</h3> </h3>
</div> </div>
<BaseButton
href='/review_requests/review_requests-list'
label='All'
color='whiteDark'
small
/>
</div> </div>
{isLoading ? ( {isLoading ? (
@ -783,13 +800,6 @@ export default function ReviewFlowWorkspace() {
Recent payment events Recent payment events
</h3> </h3>
</div> </div>
<BaseButton
href='/stripe_events/stripe_events-list'
icon={mdiWebhook}
label='Events'
color='whiteDark'
small
/>
</div> </div>
{recentEvents.length === 0 ? ( {recentEvents.length === 0 ? (
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'> <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 Recent transactions
</h3> </h3>
</div> </div>
<BaseButton
href='/transactions/transactions-list'
icon={mdiCreditCardOutline}
label='Payments'
color='whiteDark'
small
/>
</div> </div>
{recentTransactions.length === 0 ? ( {recentTransactions.length === 0 ? (
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'> <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' } from '@mdi/js'
import axios from 'axios' import axios from 'axios'
import Head from 'next/head' import Head from 'next/head'
import { useRouter } from 'next/router'
import React, { ReactElement, useEffect, useState } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import BaseButton from '../components/BaseButton' import BaseButton from '../components/BaseButton'
import CardBox from '../components/CardBox' import CardBox from '../components/CardBox'
@ -26,6 +27,17 @@ type SubscriptionStatusResponse = {
trialDaysLeft?: number | null trialDaysLeft?: number | null
priceMonthly: number priceMonthly: number
currency: string currency: string
stripeCustomerLinked?: boolean
stripeSubscriptionLinked?: boolean
currentPeriodEndsAt?: string | null
}
billing?: {
checkoutReady: boolean
portalReady: boolean
webhookReady: boolean
hasStripeCustomer: boolean
hasStripeSubscription: boolean
missingConfiguration: string[]
} }
usage: { usage: {
monthlyReviewRequests: number monthlyReviewRequests: number
@ -64,10 +76,28 @@ function formatLimit(value: number) {
return value.toLocaleString() 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() { export default function SubscriptionPage() {
const router = useRouter()
const [status, setStatus] = useState<SubscriptionStatusResponse | null>(null) const [status, setStatus] = useState<SubscriptionStatusResponse | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [selectingPlanId, setSelectingPlanId] = useState('') const [billingActionPlanId, setBillingActionPlanId] = useState('')
const [isOpeningPortal, setIsOpeningPortal] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@ -89,28 +119,76 @@ export default function SubscriptionPage() {
loadStatus() loadStatus()
}, []) }, [])
const selectPlan = async (planId: string) => { useEffect(() => {
setSelectingPlanId(planId) 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('') setError('')
setMessage('') setMessage('')
try { try {
const response = await axios.post('/subscription/select-plan', { planId }) const response = await axios.post('/subscription/create-checkout-session', { planId })
setStatus(response.data) const url = response.data?.url
setMessage(`Your trial plan is now ${response.data.subscription.planName}.`)
} catch (requestError) { if (!url) {
console.error('Failed to select subscription plan:', requestError) throw new Error('Stripe Checkout did not return a redirect URL.')
if (axios.isAxiosError(requestError) && requestError.response?.data) {
setError(String(requestError.response.data))
} else {
setError('Could not update your plan. Please try again.')
} }
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 { } 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 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 ( return (
<> <>
@ -146,6 +224,31 @@ export default function SubscriptionPage() {
<CardBox>Loading subscription details...</CardBox> <CardBox>Loading subscription details...</CardBox>
) : status ? ( ) : 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'> <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 className='grid gap-6 lg:grid-cols-[1fr_0.8fr] lg:items-center'>
<div> <div>
@ -162,6 +265,21 @@ export default function SubscriptionPage() {
: ''} : ''}
. .
</p> </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>
<div className='rounded-3xl bg-white/10 p-6 ring-1 ring-white/15'> <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> <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 used = Number(status.usage[item.key]) || 0
const limit = Number(status.limits[item.limitKey]) || 1 const limit = Number(status.limits[item.limitKey]) || 1
const percent = Math.min(100, Math.round((used / limit) * 100)) 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 ( return (
<CardBox key={item.key} className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'> <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'> <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='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)} {formatLimit(used)} / {formatLimit(limit)}
</p> </p>
</div> </div>
<div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'> <div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'>
<div <div
className={isNearLimit ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'} className={progressClass}
style={{ width: `${percent}%` }} style={{ width: `${percent}%` }}
/> />
</div> </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> </CardBox>
) )
})} })}
@ -201,6 +331,10 @@ export default function SubscriptionPage() {
{status.plans.map((plan) => { {status.plans.map((plan) => {
const isCurrent = currentPlanId === plan.id const isCurrent = currentPlanId === plan.id
const isPro = plan.id === 'pro' 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 ( return (
<CardBox <CardBox
@ -241,11 +375,11 @@ export default function SubscriptionPage() {
</div> </div>
<BaseButton <BaseButton
icon={isCurrent ? mdiCheckCircleOutline : mdiArrowUpBoldCircleOutline} icon={isCurrent ? mdiCheckCircleOutline : mdiArrowUpBoldCircleOutline}
label={isCurrent ? 'Current plan' : `Switch trial to ${plan.name}`} label={buttonLabel}
color={isCurrent ? 'success' : isPro ? 'info' : 'whiteDark'} color={isCurrent ? 'success' : isPro ? 'info' : 'whiteDark'}
className='mt-8 w-full' className='mt-8 w-full'
disabled={isCurrent || Boolean(selectingPlanId)} disabled={isBusy}
onClick={() => selectPlan(plan.id)} onClick={() => (isPaidStripeSubscription ? openBillingPortal() : startCheckout(plan.id))}
/> />
</div> </div>
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}> <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 Head from 'next/head'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import CardBox from '../../components/CardBox' import CardBox from '../../components/CardBox'
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate'
import LayoutAuthenticated from '../../layouts/Authenticated' import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain' import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
@ -180,6 +181,10 @@ const UsersNew = () => {
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<SubscriptionLimitGate
limitKey='teamMembers'
actionLabel='Inviting another team member'
/>
<CardBox> <CardBox>
<Formik <Formik
initialValues={ initialValues={

View File

@ -25,7 +25,7 @@ export const subscriptionPlans: SubscriptionPlan[] = [
priceMonthly: 49, priceMonthly: 49,
currency: 'USD', currency: 'USD',
trialDays, 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', ctaLabel: 'Start Starter trial',
limits: { limits: {
monthlyReviewRequests: 250, monthlyReviewRequests: 250,
@ -37,7 +37,9 @@ export const subscriptionPlans: SubscriptionPlan[] = [
'Review Flow dashboard', 'Review Flow dashboard',
'Manual review request creation', 'Manual review request creation',
'Hosted public review form', 'Hosted public review form',
'Customer and transaction management', 'Customer management',
'Business/location management',
'Transaction tracking',
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake', 'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
'Review request status tracking', 'Review request status tracking',
'Email delivery logs', 'Email delivery logs',
@ -51,7 +53,7 @@ export const subscriptionPlans: SubscriptionPlan[] = [
priceMonthly: 99, priceMonthly: 99,
currency: 'USD', currency: 'USD',
trialDays, 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', highlight: 'Best value',
ctaLabel: 'Start Pro trial', ctaLabel: 'Start Pro trial',
limits: { limits: {