agilentics / boiler
"""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