# Subscriptions

The subscriptions feature allows for your users to sign up and pay for subscriptions to your application at varying product and price points.

# Enabling

The subscriptions feature is enabled by default, but if you do not plan on offering subscriptions to your users (i.e. if you are offering a free service or an ecommerce site, etc.), you can disable this feature by modifying the features.wood key in app/config/app.js and omitting the subscriptions feature:

  features: {
    app: [
      // your app features here
    ],

    wood: [
      'users',
      'admin',
      // 'subscriptions',
      'teams',
    ],
  },

# Configuring

Currently, all subscriptions and subscription payments are handled through Stripe, so if you don't yet have an account you'll need to sign up for Stripe (opens new window) before you can process subscriptions.

If you do have an account, you will want to make sure you use a separate sub-account for your new application, as Nodewood automatically manages your products, prices, coupons, and taxes, and you won't want existing application values to get in the way. To create a new sub-account, click the name of your existing business in the upper-left, choose "+ New Account", and fill out the fields to generate your new sub-account.

Once your account has been created, you'll need to add your test keys (to find these, click "Developers" in the sidebar, then "API Keys", and make sure the "Viewing test data" toggle is on) to your .env file:

VUE_APP_STRIPE_PK=pk_test_PUBLIC_KEY_HERE
STRIPE_SK=sk_test_SECRET_KEY_HERE

# Important URLs

# /app/subscription

This is the default "subscription" URL, and is where a user lands when they click "Subscription" in the profile dropdown menu. If a user has not yet subscribed, this page will display the CreateSubscription component, allowing the user to subscribe to your service. If the user has already subscribed, their subscription details and a list of paid invoices will be displayed.

When a user attempts to visit an endpoint that they do not have access to because of a lack of subscription or a product that does not provide the capabilities to access it, they will be redirected to this page.

# /app/subscription/change

From here, users can change their subscription by choosing a new product or price. A preview of the invoice that they will be sent is displayed before they confirm the changes. The current interval is prorated for the remaining time, and the full price of the new change is applied.

# /app/subscription/cancel

Users wishing to cancel their subscription will be sent to this URL. They will first be provided an opportunity to send a support ticket, in case they have a problem that could be solved and their subscription saved. If they still wish to cancel, they are provided a small form to optionally complete to tell you why they are leaving. Ideally this provides useful information about how to improve your product and reduce churn rates.

# CLI Commands

The Nodewood CLI has a selection commands that are designed to ensure your Stripe account has the correct products, prices, coupons, and taxes. They use local JSON configuration files to both display choices in the app and manipulate your test-mode and live-mode configuration, much like how database migrations work. Once you have your test-mode configuration right, running these commands against your live-mode keys will ensure that these exact, tested objects are also available in production. In fact, you can build them into your deploy process in much the same way!

These commands must all be issued from the root of your Nodewood project, and all use the keys from your .env file, so if you use test keys, it will show you test comparisons, and if you use production keys, it will show you production comparisons.

# nodewood stripe:diff

This command will show you the difference between your local configuration and what exists on Stripe. This can be handy as a sanity check or while building out your configuration files, to ensure that the changes you intend are correct.

# nodewood stripe:sync

This command will attempt to synchronize your configuration files up to Stripe. Any new products, prices, coupons, and taxes you have created will be created on Stripe. Any changes will be applied to existing object. Any deletions from your configuration file will set those corresponding objects to inactive on Stripe (where applicable - coupons, for example, cannot be made inactive and will be deleted).

# nodewood stripe:import

If you already have products, prices, coupons, and/or taxes you want to use, this command will take your existing Stripe configuration and write it into your local JSON configuration files. This can be handy in case you prefer using Stripe's online tools for creating or editing objects, just make sure to import the changes when you're complete!

# JSON Configuration Files

The various Stripe objects have been split up into three JSON configuration files. This is to keep things more organized and simpler to edit, but also so that the coupons.json file can be excluded from the UI build and people poking around in your javascript source cannot simply discover the coupon codes lurking there.

# config/stripe/products.json

This file contains a list of all of the products and prices that users can use to sign up to your service:

[
  {
    "id": "prod_Ec3K4GGsBlCV4b",
    "name": "Standard",
    "description": "Standard plan.",
    "active": true,
    "metadata": {
      "order": "1",
      "bullets": "[\"100 credits/month.\",\"24h support.\"]",
      "capabilities": "[\"general-access\"]",
      "max_members": "10",
    },
    "prices": [
      {
        "id": "price_5B2q7MS2BpSgMlMdW5Cg7XKl",
        "nickname": "Standard Monthly",
        "active": true,
        "unit_amount": 10000,
        "currency": "usd",
        "interval": "month",
        "interval_count": 1,
        "metadata": {
          "order": "1"
        }
      },
      {
        "id": "price_3H4v2IW4CsPrCsMklesVapgE",
        "nickname": "Standard Annual",
        "active": true,
        "unit_amount": 100000,
        "currency": "usd",
        "interval": "year",
        "interval_count": 1,
        "metadata": {
          "order": "2"
        }
      }
    ]
  }
]

# Fields

  • id: This is the Stripe identifier for the product or price. When creating a new product or price, leave this blank, and it will be filled in when created on Stripe's side.
  • name: This is the name of the Product and will be displayed to the user when choosing their plan.
  • description: This is a user-visible description of the plan and will be displayed in the ChooseSubscription component below the price.
  • active: If the product or price is active on Stripe's end.
  • metadata: This is a collection of extra fields that Stripe stores that Nodewood uses:
    • order: The order the product or price appears in in the ChooseSubscription component dropdowns.
    • bullets: Product only. This is a JSON-stringify'd array of bullet points used to describe your product that appears in the ChooseSubscription component.
    • capabilities: Product only. This is a JSON-stringify'd array of capabilities that this product grants the user. For more information on how this works, see Limiting access to endpoints based on subscription
    • max_members: Product only. Optional. If the Teams feature is enabled, this setting configures the maximum number of members that can be added to this team. If omitted, this product does not restrict the number of members that can be added to this team.
  • prices: An array of objects that describe the price points that a product can be subscribed to at.
  • nickname: A nickname for the price that you can use when viewing objects in the Stripe interface. Not visible to the user.
  • unit_amount: The price for this subscription, in cents.
  • currency: The currency this price is charged in.
  • interval: The interval at which the price is charged.
  • interval_count: The amount of times the interval must pass before the price is charged.

An important field to take note of is currency, since only prices that match the user's team's currency will be shown. This currency is set at time of team creation, and is taken from #config/subscriptions.js -> defaultCurrency. Further, users cannot see products that do not have any prices in their team's currency.

# config/stripe/coupons.json

This file contains a list of all the coupons that users can use to get a discount on your subscription prices:

[
  {
    "id": "3-off",
    "name": "$3 off",
    "duration": "repeating",
    "duration_in_months": 6,
    "currency": "usd",
    "amount_off": 300,
    "percent_off": null,
    "max_redemptions": 30,
    "redeem_by": null
  },
  {
    "id": "50-percent",
    "name": "50 percent",
    "duration": "forever",
    "duration_in_months": null,
    "currency": null,
    "amount_off": null,
    "percent_off": 50,
    "max_redemptions": 50,
    "redeem_by": null
  }
]

# Fields

  • id: This is both the Stripe identifier for this coupon and what users will type into the "coupon" box. Unlike the other objects, this may be set manually, but if it is not provided, it will be created for you automatically.
  • name: This is a descriptive name displayed for this coupon.
  • duration: Either once if the coupon only applies to the first month of the subscription, forever if the coupon applies for the full length of the subscription, or repeating if there are a specific number of months it applies for.
  • duration_in_months: If duration is repeating, the number of months the coupon applies for, otherwise null.
  • currency: The currency code the coupon is valid in. Subscriptions that charge in a different currency from this coupon cannot have this code applied.
  • amount_off: If this coupon provides a flat discount, this field should be set to that value, in cents. Otherwise, left as null.
  • percent_off: If this coupon provides a percentage discount, this field should be set to that value, out of 100. Otherwise, left as null.
  • max_redemptions: The total number of times this coupon can be redeemed by anyone.
  • redeem_by: A UTC timestamp indicating the date after which this coupon will no longer be valid.

# config/stripe/taxes.json

This file contains a list of all the taxes that can apply to users' subscriptions:

{
  "countries": {
    "CA": [
      {
        "id": "txr_1H9v4vK9LiOdKpMrWLh98uB7",
        "display_name": "GST",
        "description": "General Sales Tax",
        "active": true,
        "inclusive": false,
        "percentage": 5,
        "metadata": {}
      }
    ]
  },
  "states": {
    "CA": {
      "Ontario": [
        {
          "id": "txr_1H9jBGK9LiOdKpMrQfaNpWCS",
          "display_name": "HST",
          "description": "Ontario Harmonized Sales Tax",
          "active": true,
          "inclusive": false,
          "percentage": 13,
          "metadata": {
            "override": "true"
          }
        }
      ]
    }
  }
}

# Fields

  • countries: This is a list of country-level taxes to apply. The keys to this object are the top-level country codes available in #config/geography.js.
  • states: This is a list of state-level taxes to apply. It is organized first by country, then by the states in the country. The keys to the country-level object are the top-level country codes available in #config/geography.js, and the keys to the state-level object are the state names in that same file.
  • id: This is the Stripe ID of this tax rate. When creating a new tax rate, leave this blank, and it will be filled in when created on Stripe's side.
  • display_name: This is a user-visible name for this tax rate.
  • description: This is a description for this tax rate. This is not visible to the user.
  • active: Whether this tax rate is active on Stripe's end.
  • inclusive: Whether this tax is calculated inclusively (i.e. the total price remains the same after the tax, but the subtotal is modified instead). For most taxes, you want to leave this to false.
  • percentage: The percentage of the subtotal to charge for this tax.
  • override: Only valid for state-level taxes. If set to true, this tax will replace any country-level taxes that would otherwise apply. This is useful for when some of your states charge an additional tax _on top) of the country-level tax but some of them charge a harmonized tax instead of the country tax (such as in 🇨🇦).

# Trial periods

To set a trial period for your subscription, set trialDays in #config/subscription to the number of days you want your trial period to last for. If you don't wish to have a trial period, set this to null.

# Limiting access to endpoints based on subscription

Subscriptions have a metadata field called capabilities, which is a JSON array of string identifiers for the capabilities a subscription grants a user. This could be something as broad as general-access for applications with just one subscription level, to finer-grained capabilities for applications with multiple subscription levels.

This is a preferable method to testing against the actual subscription level everywhere, as you may wish to change up your plan structure as your application evolves, and checking subscription capabilities is easier than the mess of logic involved finding and adding/removing plan names throughout your code when you decide you want to adjust things.

An example of a finer-grained capabilities structure:

  • Bronze plans have ['general-access'] as their only capability.
  • Silver plans have `['general-access', 'bulk-import'], granting the same access as bronze plans, but also access to the "bulk import" feature.
  • Gold plans have ['general-access', 'bulk-import', 'bulk-export'], granting the same access as silver plans, but also access to the "bulk export" feature.
  • Emerald plans have ['general-access', 'extra-storage']. This shows how not all plans have to extend the level below, but can have entirely different capabilities not displayed in other plans.

# API

To limit an API endpoint to only users with a subscription with specific capabilities, you can use a helper function from the parent PrivateController:

this.router.get(
  '/users/restricted',
  this.requireSubscription(['general-access']),
  this.restrictedFunction.bind(this),
);

# UI

To limit a UI page route to only users with a subscription with specific capabilities, you can add the capabilities option to the route's meta field:

async initRoutes(router) {
  const routes = [];

  routes.push({
    path: '/metaroute',
    name: 'metaroute',
    component: () => import(/* webpackChunkName: "meta" */ '#features/users/ui/pages/Meta'),
    meta: {
      capabilities: ['general-access', 'special-section-access'],
    }
  });

  return routes;
}

A user without a subscription, or with a subscription that does not grant them these capabilities, will be redirected to /app/subscription, where they can subscribe or upgrade their subscription.

You will want to pair these restrictions with API Controller-level restrictions or advanced users can potentially work around your protections by accessing your API directly.

To ensure an entry in the user dropdown (top-right of the screen) doesn't even appear if the user doesn't have access, add the required capabilities to the entry in #config/ui.js:

  dropdown: [{
    name: 'Restricted menu item',
    path: '/restricted',
    capabilities: ['restricted-access'],
  }],

# App Sidebar

To ensure an entry in the application sidebar (left of the screen) doesn't even appear if the user doesn't have access, add the required capabilities to the entry in #config/ui.js:

  appSidebar: [{
    name: 'Restricted sidebar item',
    path: '/restricted',
    icon: 'fa-user-lock',
    capabilities: ['restricted-access'],
  }],

# Changing the starting selected plan

When signing up for a new subscription, the products will be sorted according to their metadata.order value, with the lowest value being first. The first product in this order will be selected by default.

However, it is a common pattern to have a pricing grid with individual buttons on your marketing site, and you would want that user's choice to be honored when they reach your subscriptions page. To save the user's plan choice, append ?plan-id=XXX to your signup link, where XXX is the Stripe ID of the product they have clicked. For example, if you have a "Gold" plan with ID prod_Ha6J5IJqGlNM2q, you want the signup link for that button to be /app/signup?plan-id=prod_Ha6J5IJqGlNM2q.

This saves the Plan ID in localStorage as signup-plan-id, which is read on the subscriptions page to select the "default" product for the page. Alternatively, you may also set signup-plan-id in localStorage yourself to trigger this effect.

# Sending Receipts to Customers

Stripe will automatically send subscription & payment receipts to your customers, provided you enable the option:

  1. Visit the Emails Configuration Section (opens new window).
  2. Enable "Successful payments" (and optionally "Refunds") under "Email customers about...".
  3. Click "Save".