"""Billing over the dashboard API, plus the Stripe webhook.
Five dashboard endpoints (session-authed) and one machine endpoint:
GET /billing the billing page (plan, usage meters, upgrade/manage)
GET /api/subscription the org's plan + usage, JSON, for the page
POST /api/billing/checkout {tier} -> a Stripe Checkout URL to redirect to
POST /api/billing/portal -> a Stripe customer-portal URL
POST /api/billing/cancel -> wind the plan down at the period's end
POST /api/billing/resume -> take that back
POST /webhooks/stripe Stripe's signed events; the source of truth folds in
Cancel and resume are the same call with a flag, because that is what they are:
one asks Stripe to stop the subscription when the paid period ends, the other
unasks it. Both are the *product's* own buttons rather than a redirect to the
Stripe portal, which stays for the thing only it can do - changing a card.
The webhook is the only one that authenticates by signature rather than a
session (see crud.is_stripe_webhook, which lets it past the guard). Everything
that could call Stripe checks billing.is_enabled() first, so a deployment with no
Stripe key answers a clear "billing unavailable" rather than erroring.
"""
import logging
from flask import jsonify, render_template, request
from app.core import Invalid, Scope
from app.core import billing as billing_core
from app.core import plans as plans_core
from db.db_declarations import Organization
from utils.db_wrapper import db_session
from .crud import endpoint, principal, public_base_url
from .shell import shell_context
logger = logging.getLogger(__name__)
def page_billing():
"""The page itself needs no permission - a member may read the meters that
explain a refusal. Every button on it does, and each route checks its own.
`shell_context()` rather than a bare render: billing.html extends shell.html,
which needs the sidebar's two halves. A page that forgets renders a Jinja
UndefinedError, not a page without a sidebar - which is the cheap version of
a mistake that is otherwise found by a user.
"""
return render_template("billing.html", **shell_context())
def _origin() -> str:
"""The public scheme+host to send a returning customer back to - the
canonical public base URL, the same derivation the verification and reset
links use (crud.public_base_url), never the Host header."""
return public_base_url()
def _org(p) -> Organization:
org = db_session.get(Organization, p.org_id)
if org is None:
raise Invalid("This account is not scoped to an organisation.")
return org
@endpoint
def api_subscription():
"""The org's plan, usage-vs-limits, and feature flags, plus whether billing is
even available on this deployment (so the UI hides upgrade buttons when it is
not configured).
READ, not ADMIN: everyone in the organisation sees the meters that explain
why a create was refused. Only the buttons below need a role."""
p = principal()
data = plans_core.summary(db_session, p.org_id)
data["billing_enabled"] = billing_core.is_enabled()
# plans.py declared what each tier costs; Stripe knows what it charges. Let
# Stripe's number win wherever it answers, so the page and the checkout it
# leads to can never quote two different figures.
billing_core.apply_live_prices(data["tiers"])
return jsonify(data)
@endpoint
def api_billing_checkout():
"""Start a Checkout for the requested paid tier and hand back the URL.
ADMIN is the bar on every write below: committing an organisation to a
recurring payment is an administrative act, and an ordinary member holding
WRITE has no business taking it."""
p = principal()
p.require(Scope.ADMIN)
if not billing_core.is_enabled():
raise Invalid("Billing is not available on this deployment.")
body = request.get_json(silent=True) or {}
tier = (body.get("tier") or "").strip()
if tier not in plans_core.PAID_TIERS:
raise Invalid("Choose the Silver or Gold plan.")
origin = _origin()
url = billing_core.create_checkout_session(
db_session, _org(p), tier,
success_url=f"{origin}/billing?checkout=success",
cancel_url=f"{origin}/billing?checkout=cancel",
)
return jsonify({"url": url})
@endpoint
def api_billing_portal():
"""Open the Stripe customer portal for self-service plan/card/cancel."""
p = principal()
p.require(Scope.ADMIN)
if not billing_core.is_enabled():
raise Invalid("Billing is not available on this deployment.")
url = billing_core.create_portal_session(db_session, _org(p),
return_url=f"{_origin()}/billing")
return jsonify({"url": url})
def _set_cancel(cancel: bool):
"""Cancel and resume, which differ only in the flag."""
p = principal()
p.require(Scope.ADMIN)
if not billing_core.is_enabled():
raise Invalid("Billing is not available on this deployment.")
return jsonify(billing_core.set_cancel_at_period_end(db_session, _org(p), cancel))
@endpoint
def api_billing_cancel():
"""Stop the paid plan at the end of the period already paid for. Reversible
by api_billing_resume until it actually lapses."""
return _set_cancel(True)
@endpoint
def api_billing_resume():
"""Keep the plan after all - undo a pending cancellation."""
return _set_cancel(False)
def api_stripe_webhook():
"""Stripe's events. Verifies the signature, applies the event to the local
Subscription, and answers 2xx so Stripe stops retrying.
Not wrapped in @endpoint: it authenticates by signature rather than a
session, and it must control its own status codes. A bad signature is a 400
and *not* a 500 - Stripe retries a 500, so reporting a forged request as a
server error invites it to be hammered.
"""
sig = request.headers.get("Stripe-Signature", "")
try:
event = billing_core.construct_event(request.get_data(), sig)
except Invalid as e:
logger.warning("Rejected a Stripe webhook: %s", e)
return jsonify({"error": str(e)}), 400
try:
handled = billing_core.sync_from_event(db_session, event)
except Exception:
db_session.rollback()
logger.exception("Failed to apply Stripe event %s", event.get("type"))
# 500 so Stripe retries a genuinely failed apply.
return jsonify({"error": "Could not process the event."}), 500
return jsonify({"received": True, "handled": handled}), 200