Skip to content

Subscription Plans & Billing ​

Overview ​

  1. 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.
  2. 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.
  3. 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.
  4. Stripe payment integration: Payment collection is handled via Stripe Elements mounted inside Vuetify dialogs. The subscription-functions mixin owns the Stripe setup-intent flow.
  5. Admin-only billing access: The can-access-dam-subscription-billing middleware restricts the billing section to workspace users who hold the admin DAM role.
  6. Real-time plan assignment: When a plan is assigned or changed on the backend a Pusher event fires and DamNotification.vue refreshes $auth.user automatically.

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 mixin

    • addCardDialog(): initialises a Stripe setup intent and mounts the Stripe Elements payment form
    • addCard(): confirms the setup with Stripe and calls the trial-user endpoint
    • onSwitchWorkspace(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 admin DAM role
    • Checks that the workspace has a valid DAM instance before allowing access
    • Redirects non-admins and sub-workspaces away from the billing section
  • api/payments.js - Stripe webhook Express router

    • Handles customer.subscription.updated to trigger plan downgrades
    • Handles checkout.session.completed to activate new subscriptions and token credits
    • Handles invoice.payment_failed and payment_intent.succeeded events
    • Proxies webhook outcomes to the Laravel backend API

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_email for the contact address
  • 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-functions mixin 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_name and subscription.price
  • components/dam/Dialogs/subscription/DeleteTrialAccountDialog.vue - Trial deletion confirmation

    • Two-step confirmation before deactivating the workspace and clearing data
    • Accepts a flag prop ('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
  • 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.vue for the tile management screen

SVG Icon Components ​

  • components/svg/CollagePortalIcon.vue - Portal icon used in PortalLimitDialog
  • components/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_email to build a mailto: 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 portalLimit prop
  • "Contact Support" button opens a pre-filled mailto link
  • Emits close so 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 closes

Trial 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 mixin

Plan-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 support

Portal 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 management

Billing 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 methods

API Integration ​

Stripe Endpoints (Express server middleware) ​

MethodPathDescription
POST/stripe/init-setup-intentCreate a Stripe setup intent for saving a card
POST/stripe/webhookReceive Stripe webhook events

Backend Subscription Endpoints ​

MethodPathDescription
POSTsubscription/user-trialAssociate a Stripe customer/subscription with a trial
POSTsubscription/update-event-sentAcknowledge a plan-assignment Pusher event
POSTsubscription/downgrade-planTrigger 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>