{% extends "layouts/settings.html" %} {% load static %} {% block title %}Plan & billing · DemandTrail Messaging{% endblock %} {% block page_header %}{% endblock %} {% block content_wrap %}{% endblock %} {% block content %}

Plan & billing

What this organization is on, and what it is using.

{% if checkout_state == "cancelled" %}
Checkout was cancelled. No charge was made.
{% elif checkout_state == "activating" %}
Activating your subscription. Refresh in a few seconds.
{% endif %} {% if stripe_enabled %}
{% for card in plans %}
{% if card.show_logo %} {% endif %} {{ card.name }} {% if card.key == plan %} Current plan {% endif %}

{{ card.tagline }}

{% if card.price %}

{{ card.price.amount }} {{ card.price.note }}

{% endif %} {% comment %} The call to action sits above the feature list. Somebody who already knows they want the plan should not have to travel the whole list to act, and both columns then start their bullets at the same height whatever each one offers. Rendered only when there is something to put in it: Django leaves the whitespace inside a template block, so an empty div is not `:empty` and a CSS rule could not collapse the gap. {% endcomment %} {% if card.key == plan and can_manage_billing and can_manage %}
{% comment %} The Customer Portal. A plain POST, not a link: it mints a single-use session server-side, so the URL cannot be written here in advance. The redirect it answers with lands on billing.stripe.com, and Chrome and Safari check form-action against every hop of a form submission's navigation — so that origin is named in the policy. See OAUTH_FORM_ACTION in config/settings/base.py, and issue #115 for the flow where this was found the hard way. {% endcomment %}
{% csrf_token %}
{% elif card.key == "paid" and card.key != plan and can_manage %}
{% comment %} One call to action, and a toggle that swaps the price beside it. The swap is CSS, not JavaScript: the two radios precede both price blocks, so `input:checked ~ .plan-price` picks the one to show. That keeps the page working with no script at all, which matters here because the CSP is 'self' with a per-request nonce and an inline handler would be refused outright. The radios are moved off-screen rather than display:none — a hidden input is not focusable, and the control would stop working for anybody on a keyboard. Their value posts with the form and the view validates it against the same two-element allowlist it always did, so this is presentation and not a new input to trust. {% endcomment %}
{% csrf_token %}
Billing interval
{% for key, price in paid_prices.items %}

{{ price.amount }} {{ price.cadence }} {{ price.note }}

{% endfor %}
{% endif %}
    {% for feature in card.features %}
  • {% include "partials/_nav_icon.html" with name=feature.icon %}{{ feature.text }}
  • {% endfor %}
{% endfor %}
{% if renews_on %}

Renews on {{ renews_on|date:"j F Y" }}.

{% elif cancels_on %}

Your plan ends on {{ cancels_on|date:"j F Y" }}. Your work stays here.

{% endif %} {% if not can_manage %}

Only organization admins can change the plan.

{% endif %} {% else %} {% comment %} No payment provider configured, which is every self-hosted install. There is deliberately no plan comparison here: rendering a Free column whose limits are fiction on a box that has no limits is the one thing the promise in docs/SPEC.md §1.1 actually forbids. {% endcomment %}

Self-hosted

Every feature is available, with no limits and nothing counted. There is no plan to be on and no payment provider to configure.

{% endif %}

Usage

{% for row in usage_rows %} {% endfor %}
{% include "partials/_nav_icon.html" with name=row.icon %} {{ row.label }} {% if row.limit is None %}{{ row.used }}{% else %}{{ row.used }} of {{ row.limit }}{% endif %}
{% endblock %}