Skip to main content

codoseo_web/routes/
billing.rs

1//! `/billing`: the plan, upgrading through Dodo Payments checkout, the customer portal, and
2//! choosing which sites stay monitored after a downgrade. Cloud only: on a self-hosted
3//! instance every route here is a 404 (and nothing links to them).
4
5use askama::Template;
6use axum::body::Bytes;
7use axum::extract::{FromRequestParts, Query, State};
8use axum::http::StatusCode;
9use axum::http::request::Parts;
10use axum::response::{IntoResponse, Redirect, Response};
11use axum::routing::{get, post};
12use axum::{Form, Router};
13use codoseo_core::plan::{ManualAllowance, Plan, PlanLimits};
14use codoseo_store::billing::{self, SetMonitored};
15use serde::Deserialize;
16use uuid::Uuid;
17
18use crate::auth::{AuthRejection, CurrentUser};
19use crate::billing::dodo;
20use crate::billing::webhook;
21use crate::config::Mode;
22use crate::error::AppError;
23use crate::fmt;
24use crate::layout::{Screen, Shell};
25use crate::render::html;
26use crate::state::AppState;
27
28pub fn routes() -> Router<AppState> {
29    Router::new()
30        .route("/billing", get(page))
31        .route("/billing/checkout", post(checkout))
32        .route("/billing/portal", post(portal))
33        .route("/billing/sites", get(sites_page).post(save_sites))
34        .route("/billing/webhook", post(webhook::receive))
35}
36
37/// A signed-in user on the cloud. Self-hosted instances answer 404 before anything else (even
38/// to a signed-out visitor), so the billing routes look absent there.
39pub struct CloudUser(pub CurrentUser);
40
41impl FromRequestParts<AppState> for CloudUser {
42    type Rejection = AuthRejection;
43
44    async fn from_request_parts(
45        parts: &mut Parts,
46        state: &AppState,
47    ) -> Result<CloudUser, AuthRejection> {
48        if state.config.mode != Mode::Cloud {
49            return Err(AuthRejection::Error(AppError::NotFound));
50        }
51        CurrentUser::from_request_parts(parts, state)
52            .await
53            .map(CloudUser)
54    }
55}
56
57// ---- the billing page -------------------------------------------------------------------------
58
59pub struct PlanCard {
60    pub slug: &'static str,
61    pub name: &'static str,
62    pub features: Vec<String>,
63    /// `upgrade` (checkout), `current`, or `portal` (a paying account changes plan there).
64    pub action: &'static str,
65}
66
67#[derive(Template)]
68#[template(path = "billing/index.html")]
69pub struct BillingPage {
70    pub shell: Shell,
71    pub configured: bool,
72    pub plan_name: &'static str,
73    pub paid: bool,
74    /// When the paid period ends (the next billing date), for a paid plan.
75    pub period_end: Option<String>,
76    /// The subscription was cancelled: the plan ends on `period_end` instead of renewing.
77    pub cancelled: bool,
78    pub has_customer: bool,
79    pub cards: Vec<PlanCard>,
80    pub success: bool,
81    pub error: Option<String>,
82    pub stopped_sites: bool,
83}
84
85fn plan_name(plan: Plan) -> &'static str {
86    match plan {
87        Plan::Free => "Free",
88        Plan::Pro => "Pro",
89        Plan::Agency => "Agency",
90        Plan::SelfHosted => "Self-hosted",
91    }
92}
93
94fn features(plan: Plan) -> Vec<String> {
95    let l = PlanLimits::for_plan(plan);
96    let mut out = Vec::new();
97    if let Some(n) = l.max_sites {
98        out.push(format!(
99            "{n} monitored site{}",
100            if n == 1 { "" } else { "s" }
101        ));
102    }
103    if let Some(n) = l.max_pages {
104        out.push(format!("Up to {} pages per crawl", fmt::thousands(n)));
105    }
106    out.push("A crawl every day".to_owned());
107    out.push(match l.manual_crawls {
108        ManualAllowance::PerDay(n) => format!("{n} manual crawl a day"),
109        ManualAllowance::PerWeek(n) => format!("{n} manual crawl a week"),
110        ManualAllowance::Unlimited => "Unlimited manual crawls".to_owned(),
111    });
112    if let Some(d) = l.history_days {
113        out.push(format!("{d} days of history"));
114    }
115    out.push("Email, Slack, Discord and webhook alerts".to_owned());
116    out
117}
118
119async fn render_page(
120    state: &AppState,
121    user: &CurrentUser,
122    status: StatusCode,
123    success: bool,
124    error: Option<String>,
125) -> Result<Response, AppError> {
126    let shell = Shell::load(state, user, None, Screen::Billing).await?;
127    let info = billing::account_billing(&state.pool, user.id()).await?;
128    let plan = user.account.plan;
129    let paid = matches!(plan, Plan::Pro | Plan::Agency);
130    let configured = state.config.billing.is_some();
131    let sites = codoseo_store::sites::list_for_account(&state.pool, user.id()).await?;
132    let card = |p: Plan, slug, name| PlanCard {
133        slug,
134        name,
135        features: features(p),
136        action: if plan == p {
137            "current"
138        } else if paid {
139            "portal"
140        } else {
141            "upgrade"
142        },
143    };
144    let page = BillingPage {
145        shell,
146        configured,
147        plan_name: plan_name(plan),
148        paid,
149        period_end: info
150            .as_ref()
151            .and_then(|i| i.plan_expires_at)
152            .filter(|_| paid)
153            .map(|at| fmt::date(at - billing::GRACE)),
154        cancelled: info.as_ref().is_some_and(|i| i.cancelled_at.is_some()),
155        has_customer: info.as_ref().is_some_and(|i| i.customer_id.is_some()),
156        cards: vec![
157            card(Plan::Pro, "pro", "Pro"),
158            card(Plan::Agency, "agency", "Agency"),
159        ],
160        success,
161        error,
162        stopped_sites: sites.iter().any(|s| !s.monitoring_active),
163    };
164    let mut res = html(&page)?.into_response();
165    *res.status_mut() = status;
166    Ok(res)
167}
168
169#[derive(Deserialize)]
170struct PageQuery {
171    status: Option<String>,
172}
173
174async fn page(
175    State(state): State<AppState>,
176    CloudUser(user): CloudUser,
177    Query(q): Query<PageQuery>,
178) -> Result<Response, AppError> {
179    let success = q.status.as_deref() == Some("success");
180    render_page(&state, &user, StatusCode::OK, success, None).await
181}
182
183const UNAVAILABLE: &str = "Billing isn't configured on this server, so checkout is off.";
184const PROVIDER_DOWN: &str = "We couldn't reach our payment provider. Nothing was charged. \
185                             Try again in a moment.";
186
187#[derive(Deserialize)]
188struct CheckoutForm {
189    plan: String,
190}
191
192async fn checkout(
193    State(state): State<AppState>,
194    CloudUser(user): CloudUser,
195    Form(form): Form<CheckoutForm>,
196) -> Result<Response, AppError> {
197    let Some(cfg) = state.config.billing.as_ref() else {
198        return render_page(
199            &state,
200            &user,
201            StatusCode::SERVICE_UNAVAILABLE,
202            false,
203            Some(UNAVAILABLE.to_owned()),
204        )
205        .await;
206    };
207    let plan = match form.plan.as_str() {
208        "pro" => Plan::Pro,
209        "agency" => Plan::Agency,
210        _ => return Err(AppError::BadRequest("Choose Pro or Agency.".to_owned())),
211    };
212    if matches!(user.account.plan, Plan::Pro | Plan::Agency) {
213        return Err(AppError::Conflict(
214            "You already have a paid plan. Change or cancel it with Manage billing.".to_owned(),
215        ));
216    }
217    let product = cfg.product_for(plan).ok_or(AppError::NotFound)?;
218    let return_url = state
219        .config
220        .base_url
221        .join("billing?status=success")
222        .map_err(AppError::internal)?;
223    match dodo::create_checkout(
224        &state.http,
225        cfg,
226        product,
227        &user.account.email,
228        user.id(),
229        return_url.as_str(),
230    )
231    .await
232    {
233        Ok(url) => Ok(Redirect::to(&url).into_response()),
234        Err(error) => {
235            tracing::warn!(%error, account = %user.id(), "Dodo checkout failed");
236            render_page(
237                &state,
238                &user,
239                StatusCode::BAD_GATEWAY,
240                false,
241                Some(PROVIDER_DOWN.to_owned()),
242            )
243            .await
244        }
245    }
246}
247
248async fn portal(
249    State(state): State<AppState>,
250    CloudUser(user): CloudUser,
251) -> Result<Response, AppError> {
252    let Some(cfg) = state.config.billing.as_ref() else {
253        return render_page(
254            &state,
255            &user,
256            StatusCode::SERVICE_UNAVAILABLE,
257            false,
258            Some(UNAVAILABLE.to_owned()),
259        )
260        .await;
261    };
262    let customer = billing::account_billing(&state.pool, user.id())
263        .await?
264        .and_then(|i| i.customer_id)
265        .ok_or_else(|| {
266            AppError::BadRequest(
267                "There's no billing account to manage yet. Upgrade first.".to_owned(),
268            )
269        })?;
270    let return_url = state
271        .config
272        .base_url
273        .join("billing")
274        .map_err(AppError::internal)?;
275    match dodo::create_portal_session(&state.http, cfg, &customer, return_url.as_str()).await {
276        Ok(link) => Ok(Redirect::to(&link).into_response()),
277        Err(error) => {
278            tracing::warn!(%error, account = %user.id(), "Dodo portal failed");
279            render_page(
280                &state,
281                &user,
282                StatusCode::BAD_GATEWAY,
283                false,
284                Some(PROVIDER_DOWN.to_owned()),
285            )
286            .await
287        }
288    }
289}
290
291// ---- which sites stay monitored ---------------------------------------------------------------
292
293pub struct SiteChoice {
294    pub id: Uuid,
295    pub domain: String,
296    pub added: String,
297    pub active: bool,
298}
299
300#[derive(Template)]
301#[template(path = "billing/sites.html")]
302pub struct SitesChoicePage {
303    pub shell: Shell,
304    pub plan_name: &'static str,
305    /// `None`: no limit.
306    pub max: Option<u32>,
307    pub sites: Vec<SiteChoice>,
308    pub active_count: usize,
309}
310
311async fn sites_page(
312    State(state): State<AppState>,
313    CloudUser(user): CloudUser,
314) -> Result<Response, AppError> {
315    let shell = Shell::load(&state, &user, None, Screen::Billing).await?;
316    let sites = codoseo_store::sites::list_for_account(&state.pool, user.id()).await?;
317    let active_count = sites.iter().filter(|s| s.monitoring_active).count();
318    Ok(html(&SitesChoicePage {
319        shell,
320        plan_name: plan_name(user.account.plan),
321        max: PlanLimits::for_plan(user.account.plan).max_sites,
322        sites: sites
323            .iter()
324            .map(|s| SiteChoice {
325                id: s.id,
326                domain: s.domain.clone(),
327                added: fmt::date(s.created_at),
328                active: s.monitoring_active,
329            })
330            .collect(),
331        active_count,
332    })?
333    .into_response())
334}
335
336async fn save_sites(
337    State(state): State<AppState>,
338    CloudUser(user): CloudUser,
339    body: Bytes,
340) -> Result<Response, AppError> {
341    // The form repeats `keep` once per ticked site, which `Form<struct>` can't read.
342    let mut keep = Vec::new();
343    for (name, value) in url::form_urlencoded::parse(&body) {
344        if name == "keep" {
345            keep.push(
346                Uuid::parse_str(&value)
347                    .map_err(|_| AppError::BadRequest("That isn't a site.".to_owned()))?,
348            );
349        }
350    }
351    match billing::set_monitored(&state.pool, user.id(), &keep).await? {
352        SetMonitored::Saved => Ok(Redirect::to("/sites").into_response()),
353        SetMonitored::UnknownSite => Err(AppError::NotFound),
354        SetMonitored::TooMany { max } => Err(AppError::Limit(format!(
355            "Your plan monitors {max} site{}. Untick some, or upgrade to keep more.",
356            if max == 1 { "" } else { "s" }
357        ))),
358    }
359}
360
361/// True when `sites` holds a site the plan stopped monitoring (shown as a banner on `/sites`).
362pub fn has_stopped_sites(sites: &[codoseo_store::sites::Site]) -> bool {
363    sites.iter().any(|s| !s.monitoring_active)
364}