diff --git a/backend/src/services/subscription.js b/backend/src/services/subscription.js index 985d53f..64241a6 100644 --- a/backend/src/services/subscription.js +++ b/backend/src/services/subscription.js @@ -8,6 +8,7 @@ const { const DEFAULT_PLAN_ID = 'starter'; const DEFAULT_STATUS = 'trialing'; +const INTERNAL_ADMIN_ROLE_NAMES = ['Administrator']; const DAY_IN_MS = 24 * 60 * 60 * 1000; const PAYMENT_CONNECTOR_FIELDS = [ 'stripe_connected', @@ -103,6 +104,38 @@ function getEffectiveSubscription(user, referenceDate = new Date()) { }; } +function getUserRoleName(user) { + return user?.app_role?.name || user?.app_role?.dataValues?.name || ''; +} + +async function isSubscriptionLimitExemptUser(user, options = {}) { + if (!user) { + return false; + } + + if (user.email === 'admin@flatlogic.com' || user.dataValues?.email === 'admin@flatlogic.com') { + return true; + } + + const roleName = getUserRoleName(user); + + if (INTERNAL_ADMIN_ROLE_NAMES.includes(roleName)) { + return true; + } + + const roleId = user.app_roleId || user.dataValues?.app_roleId; + + if (!roleId) { + return false; + } + + const role = await db.roles.findByPk(roleId, { + transaction: options.transaction || undefined, + }); + + return INTERNAL_ADMIN_ROLE_NAMES.includes(role?.name); +} + function getLimitMessage(plan, usageCount, limit, unit, options = {}) { const baseMessage = `${plan.name} includes ${limit.toLocaleString()} ${unit}. You have already used ${usageCount.toLocaleString()}.`; const upgradePrefix = plan.id === 'starter' ? 'Upgrade to Pro or ' : ''; @@ -568,6 +601,15 @@ module.exports = class SubscriptionService { const user = await getUserRecord(currentUserOrId, options); const subscription = getEffectiveSubscription(user); + if (await isSubscriptionLimitExemptUser(user, options)) { + return { + allowed: true, + usage: null, + subscription, + subscriptionExempt: true, + }; + } + if (!subscription.isActive) { return { allowed: false, @@ -610,11 +652,20 @@ module.exports = class SubscriptionService { const user = await getUserRecord(currentUserOrId, options); const subscription = getEffectiveSubscription(user); + if (await isSubscriptionLimitExemptUser(user, options)) { + return { + allowed: true, + usage: null, + subscription, + subscriptionExempt: true, + }; + } + if (!subscription.isActive) { return { allowed: false, code: 403, - message: 'Your Review Flow trial has ended. Choose a plan to keep adding businesses.', + message: 'Your Review Flow trial has ended. Choose a plan to keep adding business profiles.', }; } @@ -629,8 +680,12 @@ module.exports = class SubscriptionService { subscription.plan, usage.businesses, limit, - 'businesses/locations', - { remediation: 'remove an existing business/location before adding another.' }, + limit === 1 ? 'business profile' : 'business profiles', + { + remediation: limit === 1 + ? 'remove your existing business profile before adding another.' + : 'remove an existing business profile before adding another.', + }, ), }; } @@ -652,6 +707,15 @@ module.exports = class SubscriptionService { const user = await getUserRecord(currentUserOrId, options); const subscription = getEffectiveSubscription(user); + if (await isSubscriptionLimitExemptUser(user, options)) { + return { + allowed: true, + usage: null, + subscription, + subscriptionExempt: true, + }; + } + if (!subscription.isActive) { return { allowed: false, @@ -694,6 +758,15 @@ module.exports = class SubscriptionService { const user = await getUserRecord(currentUserOrId, options); const subscription = getEffectiveSubscription(user); + if (await isSubscriptionLimitExemptUser(user, options)) { + return { + allowed: true, + usage: null, + subscription, + subscriptionExempt: true, + }; + } + if (!subscription.isActive) { return { allowed: false, @@ -740,6 +813,10 @@ module.exports = class SubscriptionService { const user = await getUserRecord(currentUserOrId, options); const subscription = getEffectiveSubscription(user); + if (await isSubscriptionLimitExemptUser(user, options)) { + return true; + } + if (!subscription.isActive) { throw httpError('Your Review Flow trial has ended. Choose a plan to keep using this feature.', 403); } diff --git a/backend/src/services/subscriptionPlans.js b/backend/src/services/subscriptionPlans.js index a244087..272ad1d 100644 --- a/backend/src/services/subscriptionPlans.js +++ b/backend/src/services/subscriptionPlans.js @@ -19,7 +19,7 @@ const subscriptionPlans = [ 'Manual review request creation', 'Hosted public review form', 'Customer management', - 'Business/location management', + 'Business profile management', 'Transaction tracking', 'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake', 'Review request status tracking', diff --git a/frontend/src/components/Businesses/CardBusinesses.tsx b/frontend/src/components/Businesses/CardBusinesses.tsx index e7af85e..174968b 100644 --- a/frontend/src/components/Businesses/CardBusinesses.tsx +++ b/frontend/src/components/Businesses/CardBusinesses.tsx @@ -90,7 +90,7 @@ const CardBusinesses = ({
BusinessName
+Business name
{ item.name }
GoogleReviewLink
+Google review link
{ item.google_review_link }
YelpReviewLink
+Yelp review link
{ item.yelp_review_link }
FacebookReviewLink
+Facebook review link
{ item.facebook_review_link }
DelayDays
+Review delay days
{ item.delay_days }
EmailSubjectTemplate
+Email subject template
{ item.email_subject_template }
EmailBodyTemplate
+Email body template
{ item.email_body_template }
IsActive
+Active
{ dataFormatter.booleanFormatter(item.is_active) }
StripeAccountReference
+Stripe account reference
{ item.stripe_account_reference }
StripeConnected
+Stripe connected
{ dataFormatter.booleanFormatter(item.stripe_connected) }
StripeConnectedAt
+Stripe connected at
{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }
DefaultReviewPlatform
+Default review platform
{ item.default_review_platform }
CustomReviewLink
+Custom review link
{ item.custom_review_link }
{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. + : `${subscriptionStatus?.subscription.planName} currently uses ${connectorUsage.toLocaleString()} / ${connectorLimit.toLocaleString()} provider connectors and ${getBusinessProfileUsageLabel(businessUsage, businessLimit)}.`} + {' '}Updating an already connected provider may still work, but new providers or new business profiles can be blocked.
{businessPageTitle} setup
++ {isAdminPortal + ? 'Manage customer business profiles used for review links, email templates, delay timing, and payment/webhook settings. Internal admin management is not limited by customer subscription plans.' + : 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Starter accounts manage one business profile; Pro accounts can manage up to ten.'} +
+{businessPageTitle} setup
++ {isAdminPortal + ? 'Manage customer business profiles used for review links, email templates, delay timing, and payment/webhook settings. Internal admin management is not limited by customer subscription plans.' + : 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Starter accounts manage one business profile; Pro accounts can manage up to ten.'} +
+BusinessName
+Business name
{businesses?.name}
GoogleReviewLink
+Google review link
{businesses?.google_review_link}
YelpReviewLink
+Yelp review link
{businesses?.yelp_review_link}
FacebookReviewLink
+Facebook review link
{businesses?.facebook_review_link}
DelayDays
+Review delay days
{businesses?.delay_days || 'No data'}
EmailSubjectTemplate
+Email subject template
{businesses?.email_subject_template}
EmailBodyTemplate
+Email body template
{businesses.email_body_template ? :No data
@@ -355,7 +350,7 @@ const BusinessesView = () => { -StripeAccountReference
+Stripe account reference
{businesses?.stripe_account_reference}
No StripeConnectedAt
} + /> :No Stripe connection date
}DefaultReviewPlatform
+Default review platform
{businesses?.default_review_platform ?? 'No data'}
CustomReviewLink
+Custom review link
{businesses?.custom_review_link}
Customers Business
+Customers for this business
+ {card.description} +
+{group.description}
++ {portalLabel} +
++ {adminPortal + ? 'This internal area is for running the SaaS business: customer accounts, business profiles, billing events, review operations, and access control.' + : 'This customer workspace is for setting up your business profile, connecting review automation, managing customers, tracking transactions, and handling your subscription.'} +
+Signed in as
+{roleName}
++ {adminPortal + ? 'Customer workspace setup links are intentionally hidden from this portal.' + : 'Internal platform administration links are intentionally hidden from this workspace.'} +
+- {`${widgetsRole?.role?.label || 'Users'}'s widgets`} -
- )} + /> + )} + {!!rolesWidgets.length && showCustomerWidgets && ( ++ {`${widgetsRole?.role?.label || 'Users'}'s widgets`} +
+ )} -{plan.limits.businesses}
-businesses/locations
+{getBusinessProfileNoun(plan.limits.businesses)}
{plan.limits.teamMembers}
@@ -230,7 +231,7 @@ export default function Starter() {First MVP slice
- The admin workspace lets a user connect payment webhooks, receive events, create transactions and customers, queue review requests, browse recent activity, and inspect the generated message. + The customer workspace lets an account owner connect payment webhooks, receive events, create transactions and customers, queue review requests, browse recent activity, and inspect the generated message. Internal admin users stay separate for support and operations.
fc6e39e3{' / '}
- to login as Admin
+ to login as Internal Admin
Use setLogin(e.target)}>client@hello.com{' / '}
+ onClick={(e) => setLogin(e.target)}>john@doe.com{' / '}
874c3b951385{' / '}
- to login as User
- Starter keeps the core review workflow running. Pro raises limits and unlocks the next automation, AI, and marketing modules as they are enabled. + Starter keeps the core review workflow running. Pro raises limits to 10 business profiles and unlocks the next automation, AI, and marketing modules as they are enabled.
{item.label}
- {formatLimit(used)} / {formatLimit(limit)} + {item.limitKey === 'businesses' + ? getBusinessProfileUsageLabel(used, limit) + : `${formatLimit(used)} / ${formatLimit(limit)}`}
{formatLimit(plan.limits.businesses)}
-businesses
+{getBusinessProfileNoun(plan.limits.businesses)}
{formatLimit(plan.limits.teamMembers)}
@@ -405,5 +408,5 @@ export default function SubscriptionPage() { } SubscriptionPage.getLayout = function getLayout(page: ReactElement) { - returnBusinesses Owner
+Business profiles owned