Repository: FlowFuse/flowfuse
Pull request #5237: Add option to disable billing requirement for a team type
Author: login: knolleary
Created: 2025-03-04T13:35:40Z | Merged: 2025-03-06T12:46:34Z

Closes #5124 

## Description

This adds a teamType property to disable the requirement for billing to be setup on a team:
 - `TeamType.properties.billing.disabled` - set to true means the team can exist without billing setup

Other changes:
 - Backend checks updated to allow otherwise-billable actions for such a team
 - Update Create Team backend to not create a Stripe checkout session for such a team
 - Renamed the `noBilling` property to `requiresBilling` to be more explicit in its meaning
 - Updates the Change Team Type view to show the right messaging for the different states a team can be in

Paths tested manually:
 - New user creates first team at freemium without setting up billing
 - They can create/delete a remote instance
 - They can suspend/resume the team
 - They can upgrade to Starter - setting up billing as part of the process
 - They can downgrade back to Freemium. At this point, they have a Subscription and we will maintain it with the Freemium $0 product line item (ie the existing behaviour).


**Note**: a team (with Subscription) that downgrades to Freemium will still have a subscription on stripe, just at $0. This will mean the number of subscribes to the Stripe product will *not* represent the total number of Freemium teams - it will represent all of the pre-existing teams, plus any that have downgraded. This was a pragmatic choice to keep this work on schedule. If we decide we should be deleting the subscription entirely on downgrade, we'll need to do more development work.


~Remaining Tasks:~
 - ~Manually test some more paths through team lifecycle~
 - ~Add some UT coverage to the backend for this scenario~

Linked issue #5124: Remove the need for Stripe interaction in Free Tier
### Description

We are losing 60% of users on sign up to the free tier because our existing architecture requires a Stripe subscription in order to have a valid team.

This work will seek to remove that tie, and permit Free Tier teams to operate without a valid Stripe subscription.

### Epic/Story

_No response_

### Have you provided an initial effort estimate for this issue?

I have provided an initial effort estimate
Linked issue comments: user: login: knolleary
created_at: 2025-03-03T16:02:40Z
body: After some exploration, I think I'm 80-90% of the way there on this task. I suspect there's another days worth of effort to test various edge case paths through the app, but the critical checks were nicely centralised (more so than when I last looked at the frontend). Will need to write some tests for it as well.
author_association: MEMBER

Diff:
diff --git a/forge/ee/lib/billing/Team.js b/forge/ee/lib/billing/Team.js
index 33c5cd729b..c25a6ff063 100644
--- a/forge/ee/lib/billing/Team.js
+++ b/forge/ee/lib/billing/Team.js
@@ -106,6 +106,12 @@ module.exports = function (app) {
             return true
         }
 
+        await this.ensureTeamTypeExists()
+        const teamTypeBillingDisabled = await this.TeamType.getProperty('billing.disabled', false)
+        if (teamTypeBillingDisabled) {
+            return
+        }
+
         // Next, check if we're in trial mode and this instanceType is valid
         // for trial mode.
         const subscription = await this.getSubscription()
@@ -163,6 +169,12 @@ module.exports = function (app) {
         // First do base checks
         await this._checkInstanceStartAllowed()
 
+        await this.ensureTeamTypeExists()
+        const teamTypeBillingDisabled = await this.TeamType.getProperty('billing.disabled', false)
+        if (teamTypeBillingDisabled) {
+            return
+        }
+
         const subscription = await this.getSubscription()
         if (subscription) {
             if (subscription.isActive() || subscription.isUnmanaged()) {
@@ -224,6 +236,11 @@ module.exports = function (app) {
         // has been reached
         await this._checkDeviceCreateAllowed()
 
+        await this.ensureTeamTypeExists()
+        const teamTypeBillingDisabled = await this.TeamType.getProperty('billing.disabled', false)
+        if (teamTypeBillingDisabled) {
+            return
+        }
         const subscription = await this.getSubscription()
         if (subscription) {
             if (subscription.isActive() || subscription.isUnmanaged()) {
diff --git a/forge/routes/api/team.js b/forge/routes/api/team.js
index 9106fa70c9..5257b103ff 100644
--- a/forge/routes/api/team.js
+++ b/forge/routes/api/team.js
@@ -519,9 +519,12 @@ module.exports = async function (app) {
                         }
                     }
                 } else {
-                    const session = await app.billing.createSubscriptionSession(team, request.session.User)
-                    app.auditLog.Team.billing.session.created(request.session.User, null, team, session)
-                    teamView.billingURL = session.url
+                    const teamBillingDisabled = await teamType.getProperty('billing.disabled', false)
+                    if (!teamBillingDisabled) {
+                        const session = await app.billing.createSubscriptionSession(team, request.session.User)
+                        app.auditLog.Team.billing.session.created(request.session.User, null, team, session)
+                        teamView.billingURL = session.url
+                    }
                 }
             }
             // Haven't created an application yet, but settings say we should
diff --git a/frontend/src/api/teamTypes.js b/frontend/src/api/teamTypes.js
index 4bf91afda2..2c50623440 100644
--- a/frontend/src/api/teamTypes.js
+++ b/frontend/src/api/teamTypes.js
@@ -24,8 +24,9 @@ const getTeamTypes = async (cursor, limit, filter) => {
             pt.htmlDescription = marked.parse(pt.description || '')
             // TeamType is considered 'free' if:
             // - no billing settings
+            // - billing is explicitly disabled
             // - billing.description is blank or equal 'free'
-            pt.isFree = !pt.properties?.billing?.description || pt.properties?.billing?.description === 'free'
+            pt.isFree = pt.properties?.billing?.disabled || !pt.properties?.billing?.description || pt.properties?.billing?.description === 'free'
             if (!pt.isFree) {
                 const [price, interval] = pt.properties?.billing?.description.split('/')
                 pt.billingPrice = price
diff --git a/frontend/src/components/drawers/navigation/MainNav.vue b/frontend/src/components/drawers/navigation/MainNav.vue
index e6bd7f628c..477474a03e 100644
--- a/frontend/src/components/drawers/navigation/MainNav.vue
+++ b/frontend/src/components/drawers/navigation/MainNav.vue
@@ -42,7 +42,7 @@ export default {
     computed: {
         ...mapState('account', ['user', 'team', 'teamMembership', 'features', 'notifications']),
         ...mapState('ux', ['mainNav']),
-        ...mapGetters('account', ['noBilling']),
+        ...mapGetters('account', ['requiresBilling']),
         ...mapGetters('ux', ['mainNavContexts', 'mainNavContext']),
         nearestMetaMenu () {
             if (this.$route?.meta?.menu) {
diff --git a/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue b/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue
index f4eb248b46..0107ac7646 100644
--- a/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue
+++ b/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue
@@ -22,37 +22,41 @@
                 <template v-if="billingEnabled">
                     <FormHeading>Billing</FormHeading>
                     <div class="space-y-2">
-                        <FormRow v-model="input.properties.billing.requireContact" type="checkbox" class="mb-4">Require contact to upgrade</FormRow>
-                        <div v-if="input.properties.billing.requireContact" class="grid gap-2 grid-cols-2 pl-4">
-                            <FormRow v-model="input.properties.billing.contactHSPortalId" :type="editDisabled?'uneditable':''">HubSpot Portal Id</FormRow>
-                            <FormRow v-model="input.properties.billing.contactHSFormId" :type="editDisabled?'uneditable':''">HubSpot Form Id</FormRow>
-                        </div>
-                    </div>
-
-                    <div class="grid gap-2 grid-cols-3">
-                        <FormRow v-model="input.properties.billing.productId" :type="editDisabled?'uneditable':''">Product Id</FormRow>
-                        <FormRow v-model="input.properties.billing.priceId" :type="editDisabled?'uneditable':''">Price Id</FormRow>
-                        <FormRow v-model="input.properties.billing.description" placeholder="eg. $10/month" :type="editDisabled?'uneditable':''">Description</FormRow>
-                    </div>
-                    <FormRow v-model="input.properties.billing.proration" :options="prorationOptions" class="mb-4">Invoicing</FormRow>
-                    <div class="space-y-2">
-                        <FormRow v-model="input.properties.trial.active" type="checkbox" class="mb-4">Enable trial mode for personal teams</FormRow>
-                        <FormRow v-if="input.properties.trial.active" v-model="input.properties.trial.sendEmail" type="checkbox" class="pl-4 mb-4">Send trial emails</FormRow>
-                        <div v-if="input.properties.trial.active" class="grid gap-2 grid-cols-3 pl-4">
-                            <FormRow v-model="input.properties.trial.duration" :type="editDisabled?'uneditable':''" placeholder="days">Duration</FormRow>
-                            <div class="col-span-2">
-                                <FormRow v-model="input.properties.trial.instanceType" :options="trialInstanceTypes">Trial Features</FormRow>
+                        <FormRow v-model="input.properties.billing.disabled" type="checkbox" class="mb-4">Do not require billing</FormRow>
+                        <template v-if="!input.properties.billing.disabled">
+                            <FormRow v-model="input.properties.billing.requireContact" type="checkbox" class="mb-4">Require contact to upgrade</FormRow>
+                            <div v-if="input.properties.billing.requireContact" class="grid gap-2 grid-cols-2 pl-4">
+                                <FormRow v-model="input.properties.billing.contactHSPortalId" :type="editDisabled?'uneditable':''">HubSpot Portal Id</FormRow>
+                                <FormRow v-model="input.properties.billing.contactHSFormId" :type="editDisabled?'uneditable':''">HubSpot Form Id</FormRow>
                             </div>
+                        </template>
+                    </div>
+                    <template v-if="!input.properties.billing.disabled">
+                        <div class="grid gap-2 grid-cols-3">
+                            <FormRow v-model="input.properties.billing.productId" :type="editDisabled?'uneditable':''">Product Id</FormRow>
+                            <FormRow v-model="input.properties.billing.priceId" :type="editDisabled?'uneditable':''">Price Id</FormRow>
+                            <FormRow v-model="input.properties.billing.description" placeholder="eg. $10/month" :type="editDisabled?'uneditable':''">Description</FormRow>
                         </div>
-                        <div v-if="input.properties.trial.active" class="grid gap-2 grid-cols-3 pl-4">
-                            <FormRow v-model="input.properties.trial.usersLimit" :type="editDisabled?'uneditable':''">Limit # Users</FormRow>
-                            <FormRow v-model="input.properties.trial.runtimesLimit" :type="editDisabled?'uneditable':''">Limit # Instances + Devices</FormRow>
-                        </div>
-                        <div v-if="input.properties.trial.active" class="grid gap-2 grid-cols-3 pl-4">
-                            <FormRow v-model="input.properties.trial.productId" :type="editDisabled?'uneditable':''">Trial Product Id</FormRow>
-                            <FormRow v-model="input.properties.trial.priceId" :type="editDisabled?'uneditable':''">Trial Price Id</FormRow>
+                        <FormRow v-model="input.properties.billing.proration" :options="prorationOptions" class="mb-4">Invoicing</FormRow>
+                        <div class="space-y-2">
+                            <FormRow v-model="input.properties.trial.active" type="checkbox" class="mb-4">Enable trial mode for personal teams</FormRow>
+                            <FormRow v-if="input.properties.trial.active" v-model="input.properties.trial.sendEmail" type="checkbox" class="pl-4 mb-4">Send trial emails</FormRow>
+                            <div v-if="input.properties.trial.active" class="grid gap-2 grid-cols-3 pl-4">
+                                <FormRow v-model="input.properties.trial.duration" :type="editDisabled?'uneditable':''" placeholder="days">Duration</FormRow>
+                                <div class="col-span-2">
+                                    <FormRow v-model="input.properties.trial.instanceType" :options="trialInstanceTypes">Trial Features</FormRow>
+                                </div>
+                            </div>
+                            <div v-if="input.properties.trial.active" class="grid gap-2 grid-cols-3 pl-4">
+                                <FormRow v-model="input.properties.trial.usersLimit" :type="editDisabled?'uneditable':''">Limit # Users</FormRow>
+                                <FormRow v-model="input.properties.trial.runtimesLimit" :type="editDisabled?'uneditable':''">Limit # Instances + Devices</FormRow>
+                            </div>
+                            <div v-if="input.properties.trial.active" class="grid gap-2 grid-cols-3 pl-4">
+                                <FormRow v-model="input.properties.trial.productId" :type="editDisabled?'uneditable':''">Trial Product Id</FormRow>
+                                <FormRow v-model="input.properties.trial.priceId" :type="editDisabled?'uneditable':''">Trial Price Id</FormRow>
+                            </div>
                         </div>
-                    </div>
+                    </template>
                 </template>
                 <FormHeading>Limits</FormHeading>
                 <div class="grid gap-3 grid-cols-3">
@@ -68,24 +72,24 @@
                     <div v-if="input.properties.instances[instanceType.id].active" class="grid gap-3 grid-cols-4 pl-4">
                         <div class="grid gap-3 grid-cols-2">
                             <FormRow v-model="input.properties.instances[instanceType.id].limit"># Limit</FormRow>
-                            <FormRow v-if="billingEnabled" v-model="input.properties.instances[instanceType.id].free"># Free</FormRow>
+                            <FormRow v-if="billingEnabled && !input.properties.billing.disabled" v-model="input.properties.instances[instanceType.id].free"># Free</FormRow>
                         </div>
-                        <FormRow v-if="billingEnabled" v-model="input.properties.instances[instanceType.id].productId" :type="editDisabled?'uneditable':''">Product Id</FormRow>
-                        <FormRow v-if="billingEnabled" v-model="input.properties.instances[instanceType.id].priceId" :type="editDisabled?'uneditable':''">Price Id</FormRow>
-                        <FormRow v-if="billingEnabled" v-model="input.properties.instances[instanceType.id].description" placeholder="eg. $10/month" :type="editDisabled?'uneditable':''">Description</FormRow>
+                        <FormRow v-if="billingEnabled && !input.properties.billing.disabled" v-model="input.properties.instances[instanceType.id].productId" :type="editDisabled?'uneditable':''">Product Id</FormRow>
+                        <FormRow v-if="billingEnabled && !input.properties.billing.disabled" v-model="input.properties.instances[instanceType.id].priceId" :type="editDisabled?'uneditable':''">Price Id</FormRow>
+                        <FormRow v-if="billingEnabled && !input.properties.billing.disabled" v-model="input.properties.instances[instanceType.id].description" placeholder="eg. $10/month" :type="editDisabled?'uneditable':''">Description</FormRow>
                     </div>
                 </div>
                 <FormHeading>Devices</FormHeading>
                 <div class="grid gap-3 grid-cols-4">
                     <div class="grid gap-3 grid-cols-2">
                         <FormRow v-model="input.properties.devices.limit"># Limit</FormRow>
-                        <FormRow v-if="billingEnabled" v-model="input.properties.devices.free" :disabled="input.properties.devices.combinedFreeType !== '_'"># Free</FormRow>
+                        <FormRow v-if="billingEnabled && !input.properties.billing.disabled" v-model="input.properties.devices.free" :disabled="input.properties.devices.combinedFreeType !== '_'"># Free</FormRow>
                     </div>
-                    <FormRow v-if="billingEnabled" v-model="input.properties.devices.productId" :type="editDisabled?'uneditable':''">Product Id</FormRow>
-                    <FormRow v-if="billingEnabled" v-model="input.properties.devices.priceId" :type="editDisabled?'uneditable':''">Price Id</FormRow>
-                    <FormRow v-if="billingEnabled" v-model="input.properties.devices.description" placeholder="eg. $10/month" :type="editDisabled?'uneditable':''">Description</FormRow>
+                    <FormRow v-if="billingEnabled && !input.properties.billing.disabled" v-model="input.properties.devices.productId" :type="editDisabled?'uneditable':''">Product Id</FormRow>
+                    <FormRow v-if="billingEnabled && !input.properties.billing.disabled" v-model="input.properties.devices.priceId" :type="editDisabled?'uneditable':''">Price Id</FormRow>
+                    <FormRow v-if="billingEnabled && !input.properties.billing.disabled" v-model="input.properties.devices.description" placeholder="eg. $10/month" :type="editDisabled?'uneditable':''">Description</FormRow>
                 </div>
-                <div v-if="billingEnabled" class="grid gap-3 grid-cols-1">
+                <div v-if="billingEnabled && !input.properties.billing.disabled" class="grid gap-3 grid-cols-1">
                     <FormRow v-model="input.properties.devices.combinedFreeType" :options="deviceFreeOptions" class="mb-4">Share free allocation with instance type:</FormRow>
                 </div>
 
diff --git a/frontend/src/pages/instance/components/InstanceForm.vue b/frontend/src/pages/instance/components/InstanceForm.vue
index 96fba71934..77bb7b2065 100644
--- a/frontend/src/pages/instance/components/InstanceForm.vue
+++ b/frontend/src/pages/instance/components/InstanceForm.vue
@@ -551,7 +551,7 @@ export default {
         })
 
         if (this.billingEnabled) {
-            if (!this.team.billing?.unmanaged) {
+            if (!this.team.billing?.unmanaged && !this.team.type.properties?.billing?.disabled) {
                 try {
                     this.subscription = await billingApi.getSubscriptionInfo(this.team.id)
                 } catch (err) {
diff --git a/frontend/src/pages/team/Billing.vue b/frontend/src/pages/team/Billing.vue
index a3fa3854b8..b1f429ad15 100644
--- a/frontend/src/pages/team/Billing.vue
+++ b/frontend/src/pages/team/Billing.vue
@@ -22,7 +22,7 @@
                     </template>
                     <template #tools>
                         <div class="flex flex-row gap-x-4">
-                            <ff-button v-if="subscription" data-action="change-team-type" :to="{name: 'TeamChangeType'}">Upgrade Team</ff-button>
+                            <ff-button v-if="!isUnmanaged" data-action="change-team-type" :to="{name: 'TeamChangeType'}">Upgrade Team</ff-button>
                             <ff-button v-if="subscription" @click="customerPortal()">
                                 <template #icon-right><ExternalLinkIcon /></template>
                                 Stripe Customer Portal
@@ -51,7 +51,32 @@
                     Something went wrong loading your subscription information, please try again.
                 </div>
             </div>
-            <EmptyState v-else-if="!isUnmanaged">
+            <EmptyState v-else-if="billingDisabledForTeam">
+                <template #img>
+                    <img src="../../images/empty-states/team-instances.png">
+                </template>
+                <template #header>Team Billing</template>
+                <template #message>
+                    <p>
+                        Your team does not require billing to be setup.
+                    </p>
+                </template>
+            </EmptyState>
+            <EmptyState v-else-if="isUnmanaged">
+                <template #img>
+                    <img src="../../images/empty-states/team-instances.png">
+                </template>
+                <template #header>Team Billing</template>
+                <template #message>
+                    <p>
+                        Your team billing cannot currently be managed from the dashboard.
+                    </p>
+                    <p>
+                        Please contact <a href="https://flowfuse.com/support/" class="underline" target="_blank">Support</a> for help.
+                    </p>
+                </template>
+            </EmptyState>
+            <EmptyState v-else>
                 <template #img>
                     <img src="../../images/empty-states/team-instances.png">
                 </template>
@@ -75,20 +100,6 @@
                     <ff-button v-if="hasPermission('team:edit')" data-action="change-team-type" :to="{name: 'TeamChangeType'}">Setup Billing</ff-button>
                 </template>
             </EmptyState>
-            <EmptyState v-else>
-                <template #img>
-                    <img src="../../images/empty-states/team-instances.png">
-                </template>
-                <template #header>Team Billing</template>
-                <template #message>
-                    <p>
-                        Your team billing cannot currently be managed from the dashboard.
-                    </p>
-                    <p>
-                        Please contact <a href="https://flowfuse.com/support/" class="underline" target="_blank">Support</a> for help.
-                    </p>
-                </template>
-            </EmptyState>
         </ff-page>
     </div>
 </template>
@@ -181,6 +192,10 @@ export default {
         isUnmanaged () {
             return this.team.billing?.unmanaged
         },
+        billingDisabledForTeam () {
+            // This team's type has billing disabled
+            return this.team.type?.properties?.billing?.disabled
+        },
         trialMode () {
             return this.team.billing?.trial
         },
diff --git a/frontend/src/pages/team/changeType.vue b/frontend/src/pages/team/changeType.vue
index 0dde682187..f49ef48a25 100644
--- a/frontend/src/pages/team/changeType.vue
+++ b/frontend/src/pages/team/changeType.vue
@@ -4,14 +4,7 @@
         <div v-else class="m-auto">
             <form class="space-y-6">
                 <div>
-                    <FormHeading>
-                        <template v-if="isTypeChange">
-                            Change your team type
-                        </template>
-                        <template v-else>
-                            Select your team type
-                        </template>
-                    </FormHeading>
+                    <FormHeading>Change your team type</FormHeading>
                     <div v-if="isUnmanaged" class="space-y-2">
                         <p>
                             Your team type cannot currently be managed from the dashboard.
@@ -54,12 +47,12 @@
                             <p v-if="isContactRequired">To learn more about our {{ input.teamType?.name }} plan, click below to contact our sales team.</p>
                             <p v-if="trialMode && !trialHasEnded">Setting up billing will bring your free trial to an end</p>
                             <p v-if="!isContactRequired && team.suspended">Setting up billing will unsuspend your team</p>
-                            <p v-if="isTypeChange">Your billing subscription will be updated to reflect the new costs</p>
+                            <p v-if="!isContactRequired"> Your billing subscription will be updated to reflect the new costs</p>
                         </div>
                     </template>
                     <div class="flex gap-x-4">
                         <template v-if="!isContactRequired">
-                            <ff-button v-if="isTypeChange" :disabled="!formValid" data-action="change-team-type" @click="updateTeam()">
+                            <ff-button v-if="!billingEnabled || billingDisabledForSelectedTeamType" :disabled="!formValid" data-action="change-team-type" @click="updateTeam()">
                                 Change team type
                             </ff-button>
                             <ff-button v-else :disabled="!formValid" data-action="setup-team-billing" @click="setupBilling()">
@@ -124,7 +117,7 @@ export default {
             return !this.isUnmanaged &&
                     this.input.teamTypeId &&
                     this.isSelectionAvailable &&
-                    (!this.isTypeChange || this.input.teamTypeId !== this.team.type.id) &&
+                    (this.billingMissing || this.input.teamTypeId !== this.team.type.id) &&
                     this.upgradeErrors.length === 0
         },
         billingEnabled () {
@@ -136,11 +129,15 @@ export default {
         trialHasEnded () {
             return this.team.billing?.trialEnded
         },
-        billingSetup () {
-            return this.billingEnabled && this.team.billing?.active
+        billingDisabledForSelectedTeamType () {
+            // This team's type has billing disabled
+            return this.input.teamType?.properties?.billing?.disabled
         },
-        isTypeChange () {
-            return !this.billingEnabled || this.billingSetup
+        billingMissing () {
+            // True if the team is missing billing information (and its required)
+            return this.billingEnabled &&
+                !this.billingDisabledForSelectedTeamType &&
+                !this.team.billing?.active
         },
         isUnmanaged () {
             return this.team.billing?.unmanaged
@@ -254,7 +251,7 @@ export default {
     },
     async created () {
         this.teamTypes = (await teamTypesApi.getTeamTypes()).types.map(teamType => {
-            if (this.isTypeChange && teamType.id === this.team.type.id) {
+            if (teamType.id === this.team.type.id) {
                 teamType.name = `${teamType.name} (current)`
             }
             return teamType
diff --git a/frontend/src/pages/team/create.vue b/frontend/src/pages/team/create.vue
index 2adc997b78..d0dc733095 100644
--- a/frontend/src/pages/team/create.vue
+++ b/frontend/src/pages/team/create.vue
@@ -44,16 +44,18 @@
 
                     <template v-if="billingEnabled">
                         <div class="mb-8 text-sm text-gray-500 space-y-2">
-                            <p v-if="!presetTeamType || (!isSelectionTrial && !presetTeamType.isFree)">To create the team we need to setup payment details via Stripe, our secure payment provider.</p>
-                            <p v-else-if="!isSelectionTrial">Please note that, whilst we do require credit card details, this Team is free of charge, and no charges will be made.</p>
+                            <p v-if="(!presetTeamType && isBillingRequired) || (presetTeamType && !isSelectionTrial && !presetTeamType.isFree)">To create the team we need to setup payment details via Stripe, our secure payment provider.</p>
                         </div>
-                        <ff-button v-if="!isSelectionTrial" :disabled="!formValid" @click="createTeam()">
+                        <ff-button v-if="isBillingRequired" :disabled="!formValid" @click="createTeam()">
                             <template #icon-right><ExternalLinkIcon /></template>
                             Create team and setup payment details
                         </ff-button>
-                        <ff-button v-else :disabled="!formValid" @click="createTeam()">
+                        <ff-button v-else-if="isSelectionTrial" :disabled="!formValid" @click="createTeam()">
                             Start Free Trial
                         </ff-button>
+                        <ff-button v-else :disabled="!formValid" @click="createTeam()">
+                            Create team
+                        </ff-button>
                     </template>
                     <ff-button v-else :disabled="!formValid" @click="createTeam()">
                         <template v-if="billingEnabled && isSelectionTrial">Start Free Trial</template>
@@ -106,7 +108,6 @@ export default {
                 defaultSlug: '',
                 slugError: ''
             },
-            needsBilling: false,
             newTeam: null,
             errors: {},
             pendingSlugCheck: null,
@@ -164,7 +165,14 @@ export default {
                 (Date.now() - (new Date(this.user.createdAt)).getTime()) < 1000 * 60 * 60 * 24 * 7 &&
             // 3. TeamType meta data says so
                 this.input.teamType?.properties?.trial?.active
+        },
+        isBillingRequired () {
+            return this.billingEnabled &&
+                   !this.isSelectionTrial &&
+                   ((this.input.teamType && !this.input.teamType.properties?.billing?.disabled) ||
+                   (this.presetTeamType && !this.presetTeamType.properties?.billing?.disabled))
         }
+
     },
     async created () {
         const teamTypesPromise = await teamTypesApi.getTeamTypes()
diff --git a/frontend/src/pages/team/index.vue b/frontend/src/pages/team/index.vue
index c2a7fb33b5..7a4a714599 100644
--- a/frontend/src/pages/team/index.vue
+++ b/frontend/src/pages/team/index.vue
@@ -51,7 +51,7 @@ export default {
     },
     computed: {
         ...mapState('account', ['user', 'team', 'teamMembership', 'pendingTeamChange', 'features']),
-        ...mapGetters('account', ['noBilling', 'isAdminUser']),
+        ...mapGetters('account', ['requiresBilling', 'isAdminUser']),
         isVisitingAdmin: function () {
             return (this.teamMembership.role === Roles.Admin)
         },
@@ -99,7 +99,7 @@ export default {
         },
         checkBilling: async function () {
             // Team Billing
-            if (this.noBilling) {
+            if (this.requiresBilling) {
                 this.$router.push({
                     path: `/team/${this.team.slug}/billing`
                 })
diff --git a/frontend/src/store/account.js b/frontend/src/store/account.js
index 170cda6731..b39ef4940f 100644
--- a/frontend/src/store/account.js
+++ b/frontend/src/store/account.js
@@ -101,11 +101,12 @@ const getters = {
     offline (state) {
         return state.offline
     },
-    noBilling (state, getters) {
+    requiresBilling (state, getters) {
         return !state.user.admin &&
         state.features.billing &&
         (!state.team?.billing?.unmanaged) &&
         (!getters.isTrialAccount || state.team?.billing?.trialEnded) &&
+        !state.team?.type?.properties?.billing?.disabled &&
         !state.team?.billing?.active
     },
     isTrialAccount (state) {
diff --git a/frontend/src/store/ux.js b/frontend/src/store/ux.js
index ff55eeaba3..a15ae5bcf7 100644
--- a/frontend/src/store/ux.js
+++ b/frontend/src/store/ux.js
@@ -67,7 +67,7 @@ const getters = {
         const { hasALowerOrEqualTeamRoleThan, hasAMinimumTeamRoleOf, hasPermission } = usePermissions()
         const team = rootState.account.team
         const accountFeatures = rootState.account.features
-        const noBilling = rootGetters['account/noBilling']
+        const requiresBilling = rootGetters['account/requiresBilling']
         const features = rootGetters['account/featuresCheck']
 
         const adminContext = [
@@ -215,7 +215,7 @@ const getters = {
                             },
                             tag: 'team-applications',
                             icon: TemplateIcon,
-                            disabled: noBilling
+                            disabled: requiresBilling
                         }
                     ]
                 },
@@ -232,7 +232,7 @@ const getters = {
                             tag: 'team-instances',
                             icon: ProjectsIcon,
                             featureUnavailable: !features.isHostedInstancesEnabledForTeam,
-                            disabled: noBilling
+                            disabled: requiresBilling
                         },
                         {
                             label: 'Remote Instances',
@@ -242,7 +242,7 @@ const getters = {
                             },
                             tag: 'team-devices',
                             icon: ChipIcon,
-                            disabled: noBilling,
+                            disabled: requiresBilling,
                             alert: state.isNewlyCreatedUser && !state.userActions.hasOpenedDeviceEditor
                                 ? {
                                     title: 'Connect to Device Agent',
@@ -264,7 +264,7 @@ const getters = {
                             },
                             tag: 'device-groups',
                             icon: DeviceGroupOutlineIcon,
-                            disabled: noBilling,
+                            disabled: requiresBilling,
                             featureUnavailable: !features.isDeviceGroupsFeatureEnabled,
                             hidden: hasALowerOrEqualTeamRoleThan(Roles.Member)
                         },
@@ -276,7 +276,7 @@ const getters = {
                             },
                             tag: 'team-pipelines',
                             icon: PipelinesIcon,
-                            disabled: noBilling,
+                            disabled: requiresBilling,
                             featureUnavailable: !features.devOpsPipelinesFeatureEnabled,
                             hidden: hasALowerOrEqualTeamRoleThan(Roles.Member)
                         },
@@ -288,7 +288,7 @@ const getters = {
                             },
                             tag: 'team-bom',
                             icon: TableIcon,
-                            disabled: noBilling,
+                            disabled: requiresBilling,
                             featureUnavailable: !features.isBOMFeatureEnabled,
                             hidden: hasALowerOrEqualTeamRoleThan(Roles.Owner)
                         },
@@ -297,7 +297,7 @@ const getters = {
                             to: { name: 'team-brokers', params: { team_slug: team.slug } },
                             tag: 'team-brokers',
                             icon: RssIcon,
-                            disabled: noBilling,
+                            disabled: requiresBilling,
                             featureUnavailable: !features.isMqttBrokerFeatureEnabled,
                             hidden: hasALowerOrEqualTeamRoleThan(Roles.Member) && features.isMqttBrokerFeatureEnabledForPlatform
                         }
@@ -315,7 +315,7 @@ const getters = {
                             },
                             tag: 'shared-library',
                             icon: BookOpenIcon,
-                            disabled: noBilling,
+                            disabled: requiresBilling,
                             featureUnavailable: !features.isSharedLibraryFeatureEnabledForPlatform || !features.isSharedLibraryFeatureEnabledForTeam
                         },
                         {
@@ -326,7 +326,7 @@ const getters = {
                             },
                             tag: 'team-members',
                             icon: UsersIcon,
-                            disabled: noBilling
+                            disabled: requiresBilling
                         }
                     ]
                 },
@@ -343,7 +343,7 @@ const getters = {
                             },
                             tag: 'team-audit',
                             icon: DatabaseIcon,
-                            disabled: noBilling,
+                            disabled: requiresBilling,
                             permission: 'team:edit'
                         },
                         {
@@ -356,7 +356,7 @@ const getters = {
                             icon: CurrencyDollarIcon,
                             hidden: (() => {
                                 // hide menu entry for non-billing setups
-                                if (noBilling) {
+                                if (requiresBilling) {
                                     return true
                                 }
 
diff --git a/test/e2e/frontend/cypress/tests-ee/trials.spec.js b/test/e2e/frontend/cypress/tests-ee/trials.spec.js
index b456a2bea8..dd0a593884 100644
--- a/test/e2e/frontend/cypress/tests-ee/trials.spec.js
+++ b/test/e2e/frontend/cypress/tests-ee/trials.spec.js
@@ -53,7 +53,8 @@ describe('FlowForge - Trial Users', () => {
         cy.intercept('GET', '/api/*/project-types*').as('getInstanceTypes')
         cy.get('[data-nav="team-billing"]').click()
         cy.url().should('include', 'team/tteam/billing')
-        cy.get('[data-action="change-team-type"]').click()
+        cy.get('[data-action="change-team-type"]').should('have.length', 2) // checking that we also have the page header btn
+        cy.get('[data-el="empty-state"] [data-action="change-team-type"]').click()
         cy.url().should('include', 'team/tteam/settings/change-type')
         cy.wait('@getTeamTypes').then(response => {
             // We will eventually submit with the 3rd team type
diff --git a/test/unit/forge/ee/db/models/PiplineStage_spec.js b/test/unit/forge/ee/db/models/PipelineStage_spec.js
similarity index 100%
rename from test/unit/forge/ee/db/models/PiplineStage_spec.js
rename to test/unit/forge/ee/db/models/PipelineStage_spec.js
diff --git a/test/unit/forge/ee/lib/billing/Team_spec.js b/test/unit/forge/ee/lib/billing/Team_spec.js
new file mode 100644
index 0000000000..cf7aca30ea
--- /dev/null
+++ b/test/unit/forge/ee/lib/billing/Team_spec.js
@@ -0,0 +1,134 @@
+const should = require('should') // eslint-disable-line
+const setup = require('../../setup')
+
+describe('Team model - ee extensions', function () {
+    // Use standard test data.
+    let app
+
+    after(async function () {
+        await app.close()
+        setup.resetStripe()
+    })
+
+    let billableTeamType
+    let disabledBillingTeamType
+    let billingEnabledTeam
+    let billingMissingTeam
+    let billingDisabledTeam
+    let billingManualTeam
+
+    before(async function () {
+        setup.setupStripe()
+        app = await setup({
+            billing: {
+                stripe: {
+                    key: 1234,
+                    team_product: 'defaultteamproduct',
+                    team_price: 'defaultteamprice',
+                    device_price: 'defaultdeviceprice',
+                    device_product: 'defaultdeviceproduct'
+                }
+            }
+        })
+
+        billableTeamType = await app.db.models.TeamType.create({
+            name: 'billableTeamType',
+            properties: {
+                devices: {
+                    productId: 'prod_device',
+                    priceId: 'price_device',
+                    description: '$5/month'
+                },
+                instances: {
+                    [app.projectType.hashid]: {
+                        active: true,
+                        productId: 'prod_instance',
+                        priceId: 'price_instance',
+                        description: '$20/month'
+                    }
+                },
+                billing: {
+                    productId: 'prod_team',
+                    priceId: 'price_team',
+                    description: '$10/month',
+                    proration: 'always_invoice'
+                },
+                trial: {
+                    active: false
+                }
+            },
+            active: true
+        })
+
+        disabledBillingTeamType = await app.db.models.TeamType.create({
+            name: 'disabledBillingTeamType',
+            properties: {
+                instances: {
+                    [app.projectType.hashid]: {
+                        active: true
+                    }
+                },
+                billing: {
+                    disabled: true
+                }
+            },
+            active: true
+        })
+
+        billingEnabledTeam = await app.factory.createTeam({ name: 'billingEnabledTeam', TeamTypeId: billableTeamType.id })
+        await app.factory.createSubscription(billingEnabledTeam)
+
+        billingMissingTeam = await app.factory.createTeam({ name: 'billingMissingTeam', TeamTypeId: billableTeamType.id })
+
+        billingManualTeam = await app.factory.createTeam({ name: 'billingManualTeam', TeamTypeId: billableTeamType.id })
+        await app.billing.enableManualBilling(billingManualTeam)
+
+        billingDisabledTeam = await app.factory.createTeam({ name: 'billingDisabledTeam', TeamTypeId: disabledBillingTeamType.id })
+    })
+
+    async function shouldResolve (promise) {
+        try {
+            await promise
+        } catch (err) {
+            should.not.exist(err)
+        }
+    }
+
+    async function shouldReject (promise, code) {
+        let result
+        try {
+            await promise
+        } catch (err) {
+            result = err
+        }
+        should.exist(result)
+        result.should.have.property('code', code)
+    }
+
+    describe('Billing checks', function () {
+        it('Billing Enabled Team', async function () {
+            // All should pass as billing is properly configured for this team
+            await shouldResolve(billingEnabledTeam.checkInstanceTypeCreateAllowed(app.projectType))
+            await shouldResolve(billingEnabledTeam.checkInstanceStartAllowed())
+            await shouldResolve(billingEnabledTeam.checkDeviceCreateAllowed())
+        })
+        it('Billing Missing Team', async function () {
+            // All should reject as billing is missing
+            await shouldReject(billingMissingTeam.checkInstanceTypeCreateAllowed(app.projectType), 'billing_required')
+            await shouldReject(billingMissingTeam.checkInstanceStartAllowed(), 'billing_required')
+            await shouldReject(billingMissingTeam.checkDeviceCreateAllowed(), 'billing_required')
+        })
+        it('Billing Manual Team', async function () {
+            // All should pass as billing is in manual mode
+            await shouldResolve(billingManualTeam.checkInstanceTypeCreateAllowed(app.projectType))
+            await shouldResolve(billingManualTeam.checkInstanceStartAllowed())
+            await shouldResolve(billingManualTeam.checkDeviceCreateAllowed())
+        })
+        it('Billing Disabled Team', async function () {
+            // All should pass as billing is disabled for this team type
+            await shouldResolve(billingDisabledTeam.checkInstanceTypeCreateAllowed(app.projectType))
+            await shouldResolve(billingDisabledTeam.checkInstanceStartAllowed())
+            await shouldResolve(billingDisabledTeam.checkDeviceCreateAllowed())
+        })
+    })
+})


Reviews:
user: login: cstns
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T10:46:34Z
user: login: cstns
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T10:47:20Z
user: login: knolleary
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T11:13:30Z
user: login: knolleary
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T11:20:00Z
user: login: knolleary
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T11:30:28Z
user: login: cstns
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T12:00:56Z

Review comments:
diff_hunk: @@ -51,7 +51,32 @@
                     Something went wrong loading your subscription information, please try again.
                 </div>
             </div>
-            <EmptyState v-else-if="!isUnmanaged">
+            <EmptyState v-else-if="billingDisabledForTeam">
+                <template #img>
+                    <img src="../../images/empty-states/team-instances.png">
+                </template>
+                <template #header>Team Billing</template>
+                <template #message>
+                    <p>
+                        Your team does not require billing to be setup.
path: frontend/src/pages/team/Billing.vue
user: login: cstns
body: Other teams with billing can change their team type from this page, we should offer the possibility for freemium team types as well
created_at: 2025-03-06T09:53:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,7 +51,32 @@
                     Something went wrong loading your subscription information, please try again.
                 </div>
             </div>
-            <EmptyState v-else-if="!isUnmanaged">
+            <EmptyState v-else-if="billingDisabledForTeam">
+                <template #img>
+                    <img src="../../images/empty-states/team-instances.png">
+                </template>
+                <template #header>Team Billing</template>
+                <template #message>
+                    <p>
+                        Your team does not require billing to be setup.
path: frontend/src/pages/team/Billing.vue
user: login: cstns
body: or upgrade their team type 
![image](https://github.com/user-attachments/assets/edbcfcb0-0525-4c1e-a225-2b1162d07d14)
this is only visible after downgrading from a higher tier and a subscription is present, I think the upgrade button should be visible at all times
created_at: 2025-03-06T10:37:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,7 +51,32 @@
                     Something went wrong loading your subscription information, please try again.
                 </div>
             </div>
-            <EmptyState v-else-if="!isUnmanaged">
+            <EmptyState v-else-if="billingDisabledForTeam">
+                <template #img>
+                    <img src="../../images/empty-states/team-instances.png">
+                </template>
+                <template #header>Team Billing</template>
+                <template #message>
+                    <p>
+                        Your team does not require billing to be setup.
+                    </p>
+                </template>
+            </EmptyState>
+            <EmptyState v-else-if="isUnmanaged">
+                <template #img>
+                    <img src="../../images/empty-states/team-instances.png">
+                </template>
+                <template #header>Team Billing</template>
+                <template #message>
+                    <p>
+                        Your team billing cannot currently be managed from the dashboard.
+                    </p>
+                    <p>
+                        Please contact <a href="https://flowfuse.com/support/" class="underline" target="_blank">Support</a> for help.
+                    </p>
+                </template>
+            </EmptyState>
+            <EmptyState v-else>
                 <template #img>
                     <img src="../../images/empty-states/team-instances.png">
                 </template>
path: frontend/src/pages/team/Billing.vue
user: login: cstns
body: For a lack of a better place to mention this, ill do it here.

Two things come to mind after creating a new freemium tier, upgrading to team tier, downgrading to freemium:
- not sure what to make out of my stripe customer portal billing info (could be development inconsistencies and the subscriptions i configured my local teams). What is a `small project`, `team plan and small project` and `team plan`? I only have a single team with properly configure billing details locally
![image](https://github.com/user-attachments/assets/7fc301c5-ab25-4cc1-868b-4812d52c6db7)
- billing wise, how can we apply the credit to the next invoice if the current team plan is free ?
![image](https://github.com/user-attachments/assets/12ce46a4-9da0-412c-853f-d6f53c137e08)




created_at: 2025-03-06T10:43:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -54,12 +47,12 @@
                             <p v-if="isContactRequired">To learn more about our {{ input.teamType?.name }} plan, click below to contact our sales team.</p>
                             <p v-if="trialMode && !trialHasEnded">Setting up billing will bring your free trial to an end</p>
                             <p v-if="!isContactRequired && team.suspended">Setting up billing will unsuspend your team</p>
-                            <p v-if="isTypeChange">Your billing subscription will be updated to reflect the new costs</p>
+                            <p v-if="!isContactRequired"> Your billing subscription will be updated to reflect the new costs</p>
                         </div>
                     </template>
                     <div class="flex gap-x-4">
                         <template v-if="!isContactRequired">
-                            <ff-button v-if="isTypeChange" :disabled="!formValid" data-action="change-team-type" @click="updateTeam()">
+                            <ff-button v-if="!billingEnabled || billingDisabledForSelectedTeamType" :disabled="!formValid" data-action="change-team-type" @click="updateTeam()">
path: frontend/src/pages/team/changeType.vue
user: login: cstns
body: Why do we need to hide the change their team button? It basically allows users to upgrade their team type
created_at: 2025-03-06T10:45:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,7 +51,32 @@
                     Something went wrong loading your subscription information, please try again.
                 </div>
             </div>
-            <EmptyState v-else-if="!isUnmanaged">
+            <EmptyState v-else-if="billingDisabledForTeam">
+                <template #img>
+                    <img src="../../images/empty-states/team-instances.png">
+                </template>
+                <template #header>Team Billing</template>
+                <template #message>
+                    <p>
+                        Your team does not require billing to be setup.
path: frontend/src/pages/team/Billing.vue
user: login: cstns
body: NB: unless enterprise tier
created_at: 2025-03-06T10:47:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,7 +51,32 @@
                     Something went wrong loading your subscription information, please try again.
                 </div>
             </div>
-            <EmptyState v-else-if="!isUnmanaged">
+            <EmptyState v-else-if="billingDisabledForTeam">
+                <template #img>
+                    <img src="../../images/empty-states/team-instances.png">
+                </template>
+                <template #header>Team Billing</template>
+                <template #message>
+                    <p>
+                        Your team does not require billing to be setup.
+                    </p>
+                </template>
+            </EmptyState>
+            <EmptyState v-else-if="isUnmanaged">
+                <template #img>
+                    <img src="../../images/empty-states/team-instances.png">
+                </template>
+                <template #header>Team Billing</template>
+                <template #message>
+                    <p>
+                        Your team billing cannot currently be managed from the dashboard.
+                    </p>
+                    <p>
+                        Please contact <a href="https://flowfuse.com/support/" class="underline" target="_blank">Support</a> for help.
+                    </p>
+                </template>
+            </EmptyState>
+            <EmptyState v-else>
                 <template #img>
                     <img src="../../images/empty-states/team-instances.png">
                 </template>
path: frontend/src/pages/team/Billing.vue
user: login: knolleary
body: > What is a small project, team plan and small project and team plan? I only have a single team with properly configure billing details locally

Those are the stripe product names. The naming in the test catalogue needs updating to match the more explicit and consistent naming we have in the production catalogue.

> billing wise, how can we apply the credit to the next invoice if the current team plan is free ?

The credit will get applied towards their $0 invoice... the credit will only get spent when they have a non-$0 invoice.
created_at: 2025-03-06T11:13:30Z
author_association: MEMBER
diff_hunk: @@ -54,12 +47,12 @@
                             <p v-if="isContactRequired">To learn more about our {{ input.teamType?.name }} plan, click below to contact our sales team.</p>
                             <p v-if="trialMode && !trialHasEnded">Setting up billing will bring your free trial to an end</p>
                             <p v-if="!isContactRequired && team.suspended">Setting up billing will unsuspend your team</p>
-                            <p v-if="isTypeChange">Your billing subscription will be updated to reflect the new costs</p>
+                            <p v-if="!isContactRequired"> Your billing subscription will be updated to reflect the new costs</p>
                         </div>
                     </template>
                     <div class="flex gap-x-4">
                         <template v-if="!isContactRequired">
-                            <ff-button v-if="isTypeChange" :disabled="!formValid" data-action="change-team-type" @click="updateTeam()">
+                            <ff-button v-if="!billingEnabled || billingDisabledForSelectedTeamType" :disabled="!formValid" data-action="change-team-type" @click="updateTeam()">
path: frontend/src/pages/team/changeType.vue
user: login: knolleary
body: There are two possible cases:
 - if the team already has billing setup (or billing is not enabled for the platform at all), we show the 'Change team type' button. This will trigger the type change directly.
 - otherwise, we show the 'Setup Payment Details' button (line 58) which takes them via stripe first - and the type change only gets applied on the backend once the stripe checkout completes

created_at: 2025-03-06T11:20:00Z
author_association: MEMBER
diff_hunk: @@ -51,7 +51,32 @@
                     Something went wrong loading your subscription information, please try again.
                 </div>
             </div>
-            <EmptyState v-else-if="!isUnmanaged">
+            <EmptyState v-else-if="billingDisabledForTeam">
+                <template #img>
+                    <img src="../../images/empty-states/team-instances.png">
+                </template>
+                <template #header>Team Billing</template>
+                <template #message>
+                    <p>
+                        Your team does not require billing to be setup.
path: frontend/src/pages/team/Billing.vue
user: login: knolleary
body: Have done the quick fix to show that button for all teams, unless the billing state is 'unmanaged' - which is how we handle Enterprise teams on FFC.
created_at: 2025-03-06T11:30:28Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-03-04T14:04:00Z
body: ## [Codecov](https://app.codecov.io/gh/FlowFuse/flowfuse/pull/5237?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FlowFuse) Report
Attention: Patch coverage is `70.58824%` with `5 lines` in your changes missing coverage. Please review.
> Project coverage is 78.24%. Comparing base [(`67e910c`)](https://app.codecov.io/gh/FlowFuse/flowfuse/commit/67e910c94a1ea9d1100b74fcc83220426a477933?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FlowFuse) to head [(`6ef0fc5`)](https://app.codecov.io/gh/FlowFuse/flowfuse/commit/6ef0fc59130b63bf4489cbdd3bd33a7ef15272a2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FlowFuse).
> Report is 12 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/FlowFuse/flowfuse/pull/5237?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FlowFuse) | Patch % | Lines |
|---|---|---|
| [forge/routes/api/team.js](https://app.codecov.io/gh/FlowFuse/flowfuse/pull/5237?src=pr&el=tree&filepath=forge%2Froutes%2Fapi%2Fteam.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FlowFuse#diff-Zm9yZ2Uvcm91dGVzL2FwaS90ZWFtLmpz) | 0.00% | [5 Missing :warning: ](https://app.codecov.io/gh/FlowFuse/flowfuse/pull/5237?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FlowFuse) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #5237      +/-   ##
==========================================
- Coverage   78.24%   78.24%   -0.01%     
==========================================
  Files         340      340              
  Lines       16063    16074      +11     
  Branches     3715     3716       +1     
==========================================
+ Hits        12569    12577       +8     
- Misses       3494     3497       +3     
```

| [Flag](https://app.codecov.io/gh/FlowFuse/flowfuse/pull/5237/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FlowFuse) | Coverage Δ | |
|---|---|---|
| [backend](https://app.codecov.io/gh/FlowFuse/flowfuse/pull/5237/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FlowFuse) | `78.24% <70.58%> (-0.01%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FlowFuse#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/FlowFuse/flowfuse/pull/5237?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FlowFuse).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FlowFuse).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: knolleary
created_at: 2025-03-05T15:50:44Z
body: @cstns have tagged you to review this one as it is largely frontend work to handle this new billing state a team can be in. The backend changes are very self-contained.

Let me know if you want to step through it with me.
author_association: MEMBER
user: login: knolleary
created_at: 2025-03-06T11:31:22Z
body: @cstns pushed a fix for the missing upgrade button. Have responded to your other two questions - both working as intended (at least as far as this PR is concerned)
author_association: MEMBER
user: login: knolleary
created_at: 2025-03-06T11:45:27Z
body: UI Test now failing due to the upgrade button being present
author_association: MEMBER
user: login: cstns
created_at: 2025-03-06T12:01:28Z
body: LGTM, will merge after actions pass
author_association: CONTRIBUTOR