Autosave: 20260629-175315
This commit is contained in:
parent
3dfd47bae8
commit
c7ec13b78b
@ -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"
|
||||||
|
|||||||
@ -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 || '';
|
||||||
|
|||||||
@ -0,0 +1,74 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const userColumns = {
|
||||||
|
stripeCustomerId: { type: 'TEXT' },
|
||||||
|
stripeSubscriptionId: { type: 'TEXT' },
|
||||||
|
stripePriceId: { type: 'TEXT' },
|
||||||
|
stripeCheckoutSessionId: { type: 'TEXT' },
|
||||||
|
stripeCurrentPeriodEndAt: { type: 'DATE' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeColumnDefinition(Sequelize, definition) {
|
||||||
|
const normalized = { ...definition };
|
||||||
|
|
||||||
|
if (definition.type === 'TEXT') {
|
||||||
|
normalized.type = Sequelize.DataTypes.TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'DATE') {
|
||||||
|
normalized.type = Sequelize.DataTypes.DATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
|
||||||
|
const table = await queryInterface.describeTable(tableName);
|
||||||
|
|
||||||
|
for (const [columnName, definition] of Object.entries(columns)) {
|
||||||
|
if (!table[columnName]) {
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
normalizeColumnDefinition(Sequelize, definition),
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
|
||||||
|
const table = await queryInterface.describeTable(tableName);
|
||||||
|
|
||||||
|
for (const columnName of Object.keys(columns).reverse()) {
|
||||||
|
if (table[columnName]) {
|
||||||
|
await queryInterface.removeColumn(tableName, columnName, { transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'users', userColumns);
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeColumnsIfPresent(queryInterface, transaction, 'users', userColumns);
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -2,7 +2,6 @@ const config = require('../../config');
|
|||||||
const providers = config.providers;
|
const 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
18
backend/src/routes/subscription-webhooks.js
Normal file
18
backend/src/routes/subscription-webhooks.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const SubscriptionService = require('../services/subscription');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/', wrapAsync(async (req, res) => {
|
||||||
|
const result = await SubscriptionService.handleStripeWebhook(
|
||||||
|
req.body,
|
||||||
|
req.headers['stripe-signature'],
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send(result);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.use('/', require('../helpers').commonErrorHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@ -4,6 +4,23 @@ const wrapAsync = require('../helpers').wrapAsync;
|
|||||||
|
|
||||||
const router = express.Router();
|
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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
222
backend/src/services/stripeBilling.js
Normal file
222
backend/src/services/stripeBilling.js
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
const Stripe = require('stripe');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
const PLAN_PRICE_ENV = {
|
||||||
|
starter: 'STRIPE_STARTER_PRICE_ID',
|
||||||
|
pro: 'STRIPE_PRO_PRICE_ID',
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedStripeClient = null;
|
||||||
|
let cachedSecretKey = null;
|
||||||
|
|
||||||
|
function httpError(message, code = 400) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactMissing(items) {
|
||||||
|
return items.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriceIdForPlan(planId) {
|
||||||
|
if (planId === 'pro') {
|
||||||
|
return config.stripe.proPriceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planId === 'starter') {
|
||||||
|
return config.stripe.starterPriceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlanIdForPriceId(priceId) {
|
||||||
|
if (!priceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priceId === config.stripe.proPriceId) {
|
||||||
|
return 'pro';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priceId === config.stripe.starterPriceId) {
|
||||||
|
return 'starter';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStripeClient() {
|
||||||
|
if (!config.stripe.secretKey) {
|
||||||
|
throw httpError('Stripe billing is not configured yet. Add STRIPE_SECRET_KEY in the backend environment.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cachedStripeClient || cachedSecretKey !== config.stripe.secretKey) {
|
||||||
|
cachedStripeClient = Stripe(config.stripe.secretKey);
|
||||||
|
cachedSecretKey = config.stripe.secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedStripeClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMissingConfigurationMessage(missing) {
|
||||||
|
return `Stripe billing is not configured yet. Add ${missing.join(', ')} in the backend environment, then reload the backend service.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = class StripeBillingService {
|
||||||
|
static getPriceIdForPlan(planId) {
|
||||||
|
return getPriceIdForPlan(planId);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPlanIdForPriceId(priceId) {
|
||||||
|
return getPlanIdForPriceId(priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getMissingCheckoutConfiguration(planId) {
|
||||||
|
const priceEnvName = PLAN_PRICE_ENV[planId] || PLAN_PRICE_ENV.starter;
|
||||||
|
|
||||||
|
return compactMissing([
|
||||||
|
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
|
||||||
|
getPriceIdForPlan(planId) ? null : priceEnvName,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getMissingPortalConfiguration() {
|
||||||
|
return compactMissing([
|
||||||
|
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getMissingWebhookConfiguration() {
|
||||||
|
return compactMissing([
|
||||||
|
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
|
||||||
|
config.stripe.webhookSecret ? null : 'STRIPE_WEBHOOK_SECRET',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSetupStatus(planId = 'starter') {
|
||||||
|
const allMissing = new Set([
|
||||||
|
...this.getMissingCheckoutConfiguration('starter'),
|
||||||
|
...this.getMissingCheckoutConfiguration('pro'),
|
||||||
|
...this.getMissingWebhookConfiguration(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkoutReady: this.getMissingCheckoutConfiguration(planId).length === 0,
|
||||||
|
portalReady: this.getMissingPortalConfiguration().length === 0,
|
||||||
|
webhookReady: this.getMissingWebhookConfiguration().length === 0,
|
||||||
|
missingConfiguration: Array.from(allMissing),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static assertCheckoutConfigured(planId) {
|
||||||
|
const missing = this.getMissingCheckoutConfiguration(planId);
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
throw httpError(formatMissingConfigurationMessage(missing), 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static assertPortalConfigured() {
|
||||||
|
const missing = this.getMissingPortalConfiguration();
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
throw httpError(formatMissingConfigurationMessage(missing), 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static assertWebhookConfigured() {
|
||||||
|
const missing = this.getMissingWebhookConfiguration();
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
throw httpError(formatMissingConfigurationMessage(missing), 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createCheckoutSession(params) {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
plan,
|
||||||
|
baseUrl,
|
||||||
|
trialPeriodDays,
|
||||||
|
} = params;
|
||||||
|
const priceId = getPriceIdForPlan(plan.id);
|
||||||
|
const subscriptionData = {
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
planId: plan.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.assertCheckoutConfigured(plan.id);
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
|
||||||
|
if (trialPeriodDays && trialPeriodDays > 0) {
|
||||||
|
subscriptionData.trial_period_days = trialPeriodDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripe.checkout.sessions.create({
|
||||||
|
mode: 'subscription',
|
||||||
|
customer: user.stripeCustomerId || undefined,
|
||||||
|
customer_email: user.stripeCustomerId ? undefined : user.email,
|
||||||
|
client_reference_id: user.id,
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: priceId,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
billing_address_collection: 'auto',
|
||||||
|
success_url: `${baseUrl}/subscription?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${baseUrl}/subscription?checkout=cancelled`,
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
planId: plan.id,
|
||||||
|
},
|
||||||
|
subscription_data: subscriptionData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createPortalSession(params) {
|
||||||
|
const { customerId, baseUrl } = params;
|
||||||
|
this.assertPortalConfigured();
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
|
||||||
|
return stripe.billingPortal.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
return_url: `${baseUrl}/subscription`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static constructWebhookEvent(rawBody, signature) {
|
||||||
|
this.assertWebhookConfigured();
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
throw httpError('Missing Stripe webhook signature.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
|
||||||
|
return stripe.webhooks.constructEvent(
|
||||||
|
rawBody,
|
||||||
|
signature,
|
||||||
|
config.stripe.webhookSecret,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async retrieveSubscription(subscriptionId) {
|
||||||
|
if (!subscriptionId || typeof subscriptionId !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
|
||||||
|
return stripe.subscriptions.retrieve(subscriptionId, {
|
||||||
|
expand: ['items.data.price'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,4 +1,5 @@
|
|||||||
const db = require('../db/models');
|
const 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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
1370
backend/yarn.lock
1370
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -62,6 +62,23 @@ interface PaymentProviderConnectorsProps {
|
|||||||
) => void | Promise<void>;
|
) => 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'
|
||||||
|
|||||||
135
frontend/src/components/SubscriptionLimitGate.tsx
Normal file
135
frontend/src/components/SubscriptionLimitGate.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { mdiCreditCardOutline } from '@mdi/js'
|
||||||
|
import axios from 'axios'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import BaseButton from './BaseButton'
|
||||||
|
import CardBox from './CardBox'
|
||||||
|
|
||||||
|
type LimitKey = 'monthlyReviewRequests' | 'businesses' | 'teamMembers' | 'paymentConnectors'
|
||||||
|
|
||||||
|
type SubscriptionLimitStatus = {
|
||||||
|
subscription: {
|
||||||
|
planId: string
|
||||||
|
planName: string
|
||||||
|
effectiveStatus: string
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
usage: Record<LimitKey, number>
|
||||||
|
limits: Record<LimitKey, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
limitKey: LimitKey
|
||||||
|
actionLabel: string
|
||||||
|
label?: string
|
||||||
|
className?: string
|
||||||
|
nearLimitPercent?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultLabels: Record<LimitKey, string> = {
|
||||||
|
monthlyReviewRequests: 'review requests this month',
|
||||||
|
businesses: 'businesses/locations',
|
||||||
|
teamMembers: 'team members',
|
||||||
|
paymentConnectors: 'connected payment providers',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number) {
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubscriptionLimitGate({
|
||||||
|
limitKey,
|
||||||
|
actionLabel,
|
||||||
|
label,
|
||||||
|
className = 'mb-6',
|
||||||
|
nearLimitPercent = 80,
|
||||||
|
}: Props) {
|
||||||
|
const [status, setStatus] = useState<SubscriptionLimitStatus | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/subscription/me')
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setStatus(response.data)
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
} catch (requestError) {
|
||||||
|
console.error('Failed to load subscription limit status:', requestError)
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setError('Could not check plan limits right now. The backend will still enforce them when you submit.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStatus()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<CardBox className={`${className} border-0 bg-amber-50 text-amber-950 ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:ring-amber-800`}>
|
||||||
|
<p className='text-sm font-black uppercase tracking-[0.25em]'>Plan check unavailable</p>
|
||||||
|
<p className='mt-2 text-sm leading-6'>{error}</p>
|
||||||
|
</CardBox>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = Number(status.usage[limitKey]) || 0
|
||||||
|
const limit = Number(status.limits[limitKey]) || 0
|
||||||
|
const limitLabel = label || (limit === 1 && limitKey === 'businesses'
|
||||||
|
? 'business/location'
|
||||||
|
: defaultLabels[limitKey])
|
||||||
|
const percent = limit > 0 ? Math.round((used / limit) * 100) : 0
|
||||||
|
const isInactive = !status.subscription.isActive
|
||||||
|
const isBlocked = isInactive || (limit > 0 && used >= limit)
|
||||||
|
const isNearLimit = !isBlocked && percent >= nearLimitPercent
|
||||||
|
|
||||||
|
if (!isBlocked && !isNearLimit) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = isBlocked
|
||||||
|
? 'border-0 bg-rose-50 text-rose-950 ring-1 ring-rose-200 dark:bg-rose-950 dark:text-rose-50 dark:ring-rose-800'
|
||||||
|
: 'border-0 bg-amber-50 text-amber-950 ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:ring-amber-800'
|
||||||
|
const buttonLabel = status.subscription.planId === 'starter' ? 'Upgrade to Pro' : 'Manage plan'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBox className={`${className} ${cardClass}`}>
|
||||||
|
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
|
||||||
|
<div>
|
||||||
|
<p className='text-sm font-black uppercase tracking-[0.25em]'>
|
||||||
|
{isBlocked ? 'Plan limit reached' : 'Plan limit almost reached'}
|
||||||
|
</p>
|
||||||
|
<h3 className='mt-2 text-xl font-black'>
|
||||||
|
{actionLabel} {isBlocked ? 'may be blocked' : 'is getting close to the limit'}
|
||||||
|
</h3>
|
||||||
|
<p className='mt-2 text-sm leading-6'>
|
||||||
|
{isInactive
|
||||||
|
? `Your ${status.subscription.planName} plan is ${status.subscription.effectiveStatus}. Reactivate or choose a plan before continuing.`
|
||||||
|
: `${status.subscription.planName} includes ${formatNumber(limit)} ${limitLabel}. This account is using ${formatNumber(used)}.`}
|
||||||
|
{' '}Existing data stays available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<BaseButton
|
||||||
|
href='/subscription'
|
||||||
|
icon={mdiCreditCardOutline}
|
||||||
|
label={buttonLabel}
|
||||||
|
color={isBlocked ? 'danger' : 'warning'}
|
||||||
|
className='self-start md:self-center'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -28,22 +28,6 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
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;
|
||||||
|
|||||||
@ -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={
|
||||||
|
|||||||
@ -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={{
|
||||||
|
|||||||
@ -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'>
|
||||||
|
|||||||
@ -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'}>
|
||||||
|
|||||||
@ -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={
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user