Appearance
Subscription Plans & Billing ​
Overview ​
- Plan-gated features: Every capability in the DAM is guarded by a feature flag read from
$auth.user.subscription_features. Components check these flags before rendering advanced UI, showing an upgrade prompt when the flag is absent or disabled. - Limit enforcement: Storage, user count, and portal count each have a hard cap that is compared to current usage. When the cap is reached the relevant limit dialog blocks the action.
- Trial lifecycle: New accounts start with a 30-day trial. A series of persistent dialogs guide users through adding a payment method, handling expiry, and cancelling the trial.
- Stripe payment integration: Payment collection is handled via Stripe Elements mounted inside Vuetify dialogs. The
subscription-functionsmixin owns the Stripe setup-intent flow. - Admin-only billing access: The
can-access-dam-subscription-billingmiddleware restricts the billing section to workspace users who hold theadminDAM role. - Real-time plan assignment: When a plan is assigned or changed on the backend a Pusher event fires and
DamNotification.vuerefreshes$auth.userautomatically.
Architecture ​
Subscription state lives entirely in $auth.user — Nuxt Auth fetches the authenticated user object on every login and on demand. The relevant fields are subscription_features (a keyed object where each feature key has an enable boolean), subscription_user (boolean flag indicating the user owns the subscription), and the raw subscription object containing plan_name, price, billing_cycle, and stripe_customer_id.
Feature guards are computed properties that read $auth.user.subscription_features.<feature_key>.enable. Any page, component, or mixin that needs to conditionally show a feature adds one of these computed properties. When the flag is false the component either hides the control entirely or renders UpgradePlanSection.vue in its place.
Limit enforcement is handled at the point of action. Before creating a new portal, for example, the page compares the current portal count against the limit and opens PortalLimitDialog.vue instead of proceeding if the count is at or above the cap.
Stripe Elements are loaded lazily via loadStripe() inside dialog watchers. The Nuxt server-side api/payments.js Express router receives Stripe webhooks and calls the Laravel backend to process plan changes, downgrades, and payment failures.
File Structure ​
JavaScript Files (.js) ​
mixins/subscription-functions.js- Core subscription mixinaddCardDialog(): initialises a Stripe setup intent and mounts the Stripe Elements payment formaddCard(): confirms the setup with Stripe and calls the trial-user endpointonSwitchWorkspace(wp): switches the active workspace and re-fetches the authenticated user
middleware/can-access-dam-subscription-billing.js- Route guard for billing pages- Verifies the current user holds an
adminDAM role - Checks that the workspace has a valid DAM instance before allowing access
- Redirects non-admins and sub-workspaces away from the billing section
- Verifies the current user holds an
api/payments.js- Stripe webhook Express router- Handles
customer.subscription.updatedto trigger plan downgrades - Handles
checkout.session.completedto activate new subscriptions and token credits - Handles
invoice.payment_failedandpayment_intent.succeededevents - Proxies webhook outcomes to the Laravel backend API
- Handles
Vue Component Files (.vue) ​
components/dam/UpgradePlanSection.vue- Generic upgrade prompt- Renders a "Contact Support" CTA that opens a
mailto:link - Displayed in place of locked features across the DAM
- Reads
$brand.support_emailfor the contact address
- Renders a "Contact Support" CTA that opens a
components/dam/Dialogs/subscription/TrialExpiredDialog.vue- Trial expiry blocker- Persistent dialog shown when the trial period ends and no payment method is on file
- Mounts Stripe Elements via
subscription-functionsmixin for payment capture - Provides a workspace-switch menu and a link to delete trial data
components/dam/Dialogs/subscription/NoPaymentMethodDialog.vue- Payment method prompt- Shown during trial when no payment method has been added
- Displays plan name, price, and billing cycle from the subscription prop
- Contains the same Stripe Elements form as the trial-expired dialog
components/dam/Dialogs/subscription/TrialEndingDialog.vue- Trial ending soon warning- Non-blocking reminder dialog displayed as the trial approaches expiry
- Prompts users to add a payment method before the deadline
components/dam/Dialogs/subscription/AlreadyCancelledTrialDialog.vue- Post-cancellation info- Shown after a cancelled trial confirms the plan and upcoming charge
- Displays
subscription.plan_nameandsubscription.price
components/dam/Dialogs/subscription/DeleteTrialAccountDialog.vue- Trial deletion confirmation- Two-step confirmation before deactivating the workspace and clearing data
- Accepts a
flagprop ('trial'or similar) to vary copy
components/dam/Dialogs/PortalLimitDialog.vue- Portal cap enforcement- Persistent dialog shown when the portal count equals
portalLimit - "Contact Support" link pre-fills an email subject requesting an upgrade
- Persistent dialog shown when the portal count equals
components/dam/StaticPlanManageBanner.vue- Plan-locked banner management placeholder- Static read-only UI shown when the banner management feature is not enabled on the current plan
- Renders the table structure without editable controls
components/dam/StaticPlanManageTile.vue- Plan-locked tile management placeholder- Same pattern as
StaticPlanManageBanner.vuefor the tile management screen
- Same pattern as
SVG Icon Components ​
components/svg/CollagePortalIcon.vue- Portal icon used in PortalLimitDialogcomponents/svg/CollageCloseIcon.vue- Close button icon for subscription dialogs
Subscription Functions Mixin ​
File: mixins/subscription-functions.js ​
Shared mixin used by all three Stripe-integrated subscription dialogs (TrialExpiredDialog, NoPaymentMethodDialog, and AlreadyCancelledTrialDialog).
Features ​
- Lazy Stripe initialisation via
loadStripe - Setup-intent flow for saving card without an immediate charge
- Workspace switching with profile update and re-authentication
- Stripe Elements mounting on
$nextTick
Props (consumed from parent dialog) ​
javascript
{
stripeCustomerId: {
type: String,
default: ''
},
stripe_subscription_id: {
type: String,
default: ''
},
subscription_id: {
type: Number,
default: 0
},
subscription: {
type: Object,
required: true
}
}Methods ​
javascript
{
addCardDialog: async () => {}, // Init setup intent, mount Stripe Elements
addCard: async () => {}, // Confirm setup, call trial-user endpoint
onSwitchWorkspace: async (wp) => {} // Update profile, re-auth, reload
}Usage ​
vue
<script>
import subscriptionFunctions from '~/mixins/subscription-functions'
export default {
mixins: [subscriptionFunctions],
data() {
return {
showAddCard: false,
stripeElemLoaded: false,
addCardError: '',
stripeCardElements: null,
stripe: null,
}
},
watch: {
async dialog(newVal) {
this.showTrialDialog = newVal
if (newVal) {
this.stripe = await loadStripe(process.env.STRIPE_PUBLISHABLE_KEY)
}
},
},
}
</script>UpgradePlanSection Component ​
File: components/dam/UpgradePlanSection.vue ​
Drop-in component shown wherever a feature is disabled by the current subscription plan.
Features ​
- Reads
$brand.support_emailto build amailto:link - Zero dependencies beyond the brand plugin
- Self-contained — no props required
Props ​
javascript
// No props — reads support email from $brand global
{}Events ​
javascript
// No events emitted
{}Methods ​
javascript
{
contactSupport: () => {} // Opens mailto: to $brand.support_email
}Usage ​
vue
<template>
<div>
<UpgradePlanSection v-if="!featureEnabled" />
<div v-else>
<!-- Feature content -->
</div>
</div>
</template>
<script>
export default {
components: {
UpgradePlanSection: () => import('~/components/dam/UpgradePlanSection.vue'),
},
computed: {
featureEnabled() {
return !!this.$auth.user?.subscription_features?.system_notifications?.enable
},
},
}
</script>PortalLimitDialog Component ​
File: components/dam/Dialogs/PortalLimitDialog.vue ​
Persistent dialog shown when a workspace has reached its portal quota.
Features ​
- Displays the numeric limit from the
portalLimitprop - "Contact Support" button opens a pre-filled mailto link
- Emits
closeso the parent can reset its state
Props ​
javascript
{
dialog: {
type: Boolean,
default: false
},
portalLimit: {
type: Number,
default: 20
}
}Events ​
javascript
{
close: () => {} // Dialog dismissed — parent resets dialog flag
}Methods ​
javascript
{
handleClose: () => {} // Sets internal modalDialog false, emits 'close'
}Usage ​
vue
<template>
<PortalLimitDialog
:dialog="showPortalLimit"
:portal-limit="currentPortalLimit"
@close="showPortalLimit = false"
/>
</template>
<script>
export default {
components: {
PortalLimitDialog: () => import('~/components/dam/Dialogs/PortalLimitDialog.vue'),
},
data() {
return {
showPortalLimit: false,
currentPortalLimit: 0,
}
},
methods: {
handleAddPortal() {
if (this.portals.length >= this.currentPortalLimit) {
this.showPortalLimit = true
return
}
// proceed with portal creation
},
},
}
</script>Subscription Feature Flags ​
Feature flags are read from $auth.user.subscription_features. Each key maps to an object with an enable boolean:
javascript
// Pattern used across DAM pages and components
computed: {
systemNotificationsEnabled() {
return !!this.$auth.user?.subscription_features?.system_notifications?.enable
},
assetVersioningEnabled() {
return !!this.$auth.user?.subscription_features?.asset_versioning?.enable
},
advanceSearchEnabled() {
return !!this.$auth.user?.subscription_features?.advance_search_functionality?.enable
},
aiAutoTagEnabled() {
return !!this.$auth.user?.subscription_features?.ai_based_asset_auto_tagging?.enable
},
assetConversionEnabled() {
return !!this.$auth.user?.subscription_features?.asset_conversion?.enable
},
assetEmbeddingEnabled() {
return !!this.$auth.user?.subscription_features?.asset_embedding?.enable
},
advanceShareEnabled() {
return !!this.$auth.user?.subscription_features?.advance_share_management?.enable
},
customBrandUrlEnabled() {
return !!this.$auth.user?.subscription_features?.custom_brand_url?.enable
},
}Workflows ​
Trial Activation Workflow ​
1. New workspace created
Backend: sets subscription.status = 'trial'
Backend: sets trial_ends_at (30 days from creation)
↓
2. User logs in
$auth.loginWith('local') → JWT token
$auth.fetchUser() → populates $auth.user
Fields: subscription_user, subscription_features, subscription (plan details)
↓
3. damLayout loads
Layout checks subscription state from $auth.user
If no payment method → NoPaymentMethodDialog is shown
If trial active → users can access all subscription_features flags
↓
4. User adds payment method
NoPaymentMethodDialog: calls addCardDialog()
GET /stripe/init-setup-intent → { client_secret }
Stripe Elements mounted at #payment-element-stripe
User enters card details
↓
5. User submits card
POST /stripe/init-setup-intent confirms setup intent
POST subscription/user-trial updates backend
stripe.confirmSetup() finalises the card
↓
6. Stripe redirects back to page with success
$auth.fetchUser() refreshes subscription state
NoPaymentMethodDialog closesTrial Expiry Workflow ​
1. Trial period ends (trial_ends_at reached)
Backend: subscription.status = 'expired'
↓
2. User navigates to any DAM page
damLayout reads subscription state
subscription.status === 'expired' with no payment on file
→ TrialExpiredDialog rendered (persistent, blocks all content)
↓
3. User options:
a. Add payment method → same Stripe Elements flow as trial activation
b. Logout → $logout()
c. Delete trial data → DeleteTrialAccountDialog opens
User confirms → backend deactivates workspace
d. Switch workspace → onSwitchWorkspace(wp) from subscription-functions mixinPlan-Gated Feature Access Workflow ​
1. Component mounts
computed property reads $auth.user.subscription_features.<key>.enable
↓
2. Feature flag check
If enable === true:
→ Render full feature UI
If enable === false or key missing:
→ Render UpgradePlanSection.vue OR hide control entirely
↓
3. User clicks "Contact Support" in UpgradePlanSection
contactSupport() method
window.location.href = 'mailto:' + $brand.support_email
→ Opens default email client pre-addressed to supportPortal Limit Enforcement Workflow ​
1. User clicks "Add Portal" in portal management
Page method checks current portal count vs. limit
↓
2. Limit check
If portals.length < plan portal limit:
→ Proceed with portal creation flow
If portals.length >= plan portal limit:
→ showPortalLimit = true
→ PortalLimitDialog rendered with portalLimit prop
↓
3. PortalLimitDialog displayed
Persistent dialog blocks further action
User can click "Contact Support" to email upgrade request
Subject pre-filled: "Portal Limit Upgrade Request"
↓
4. Support team upgrades plan
Backend updates subscription_features and limits
$auth.fetchUser() called on next navigation
New limit reflects in portal managementBilling Admin Access Workflow ​
1. User navigates to workspace-settings billing route
Route: /:workspace_id/workspace-settings billing section
↓
2. can-access-dam-subscription-billing middleware runs
Checks: workspace cookie present
Checks: user has DAM access (hasDamAccess === true)
Checks: user.dam.isAdmin === true
If not admin → redirect to workspace-settings general page
↓
3. DAM instance check
If no instance in store → dispatch dam/getInstances
If no instance returned and subscription_user === false → redirect to add-dam-instance
↓
4. Sub-workspace check
GET /view-workspace?workspace_id=...
If workspace has parent_id → redirect to workspace-settings
(Billing only accessible from the root workspace)
↓
5. Billing page renders
Admin can view plan details, upgrade, and manage payment methodsAPI Integration ​
Stripe Endpoints (Express server middleware) ​
| Method | Path | Description |
|---|---|---|
POST | /stripe/init-setup-intent | Create a Stripe setup intent for saving a card |
POST | /stripe/webhook | Receive Stripe webhook events |
Backend Subscription Endpoints ​
| Method | Path | Description |
|---|---|---|
POST | subscription/user-trial | Associate a Stripe customer/subscription with a trial |
POST | subscription/update-event-sent | Acknowledge a plan-assignment Pusher event |
POST | subscription/downgrade-plan | Trigger a plan downgrade (called from webhook) |
Setup Intent Request ​
json
// POST /stripe/init-setup-intent
{
"customer": "cus_xxxxxxxxxxxx"
}Setup Intent Response ​
json
{
"client_secret": "seti_xxxxxxxxxxxx_secret_xxxxxxxxxxxx"
}Trial User Endpoint Request ​
json
// POST subscription/user-trial
{
"stripe_customer_id": "cus_xxxxxxxxxxxx",
"stripe_subscription_id": "sub_xxxxxxxxxxxx",
"subscription_id": 42
}Pusher Plan Assignment Event ​
json
// Received on user.{id}.getUserSubscriptionChannel
// Event: .GetUserSubscriptionEvent
{
"plan_assign": true
}Component Integration ​
Billing Dialog Integration in damLayout ​
vue
<template>
<div>
<!-- Trial expired blocker -->
<TrialExpiredDialog
v-if="trialExpired"
:dialog="trialExpired"
:stripe-customer-id="subscription.stripe_customer_id"
:stripe-subscription-id="subscription.stripe_subscription_id"
:subscription-id="subscription.id"
:subscription="subscription"
/>
<!-- No payment method reminder -->
<NoPaymentMethodDialog
v-if="noPaymentMethod"
:dialog="noPaymentMethod"
:subscription="subscription"
:stripe-customer-id="subscription.stripe_customer_id"
:stripe-subscription-id="subscription.stripe_subscription_id"
:subscription-id="subscription.id"
/>
<!-- Main content -->
<slot v-if="!trialExpired" />
</div>
</template>
<script>
import TrialExpiredDialog from '~/components/dam/Dialogs/subscription/TrialExpiredDialog.vue'
import NoPaymentMethodDialog from '~/components/dam/Dialogs/subscription/NoPaymentMethodDialog.vue'
export default {
components: {
TrialExpiredDialog: () =>
import('~/components/dam/Dialogs/subscription/TrialExpiredDialog.vue'),
NoPaymentMethodDialog: () =>
import('~/components/dam/Dialogs/subscription/NoPaymentMethodDialog.vue'),
},
computed: {
subscription() {
return this.$auth.user?.subscription || {}
},
trialExpired() {
return this.subscription?.status === 'expired' &&
!this.subscription?.stripe_payment_method_id
},
noPaymentMethod() {
return this.subscription?.status === 'trial' &&
!this.subscription?.stripe_payment_method_id
},
},
}
</script>Feature-Gated Component Pattern ​
vue
<template>
<div>
<UpgradePlanSection v-if="!advanceSearchEnabled" />
<SearchBarTypeSense
v-else
:workspace-id="workspaceId"
@result="handleSearchResult"
/>
</div>
</template>
<script>
export default {
components: {
UpgradePlanSection: () => import('~/components/dam/UpgradePlanSection.vue'),
SearchBarTypeSense: () => import('~/components/dam/SearchBarTypeSense.vue'),
},
computed: {
advanceSearchEnabled() {
return !!this.$auth.user?.subscription_features
?.advance_search_functionality?.enable
},
workspaceId() {
return this.$route.params.workspace_id || this.$getWorkspaceId()
},
},
methods: {
handleSearchResult(results) {
// handle Typesense results
},
},
}
</script>Related Documentation ​
- File Upload - Upload feature with AI auto-tagging behind subscription flag
- Real-Time Updates - Pusher channel for plan assignment events
- AWS S3 Integration - Storage limit tracking against subscription quota
- Mixins - Subscription Functions - Stripe setup-intent mixin
- Middleware - can-access-dam-subscription-billing - Admin-only route guard