1use 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
37pub 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
57pub struct PlanCard {
60 pub slug: &'static str,
61 pub name: &'static str,
62 pub features: Vec<String>,
63 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 pub period_end: Option<String>,
76 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
291pub 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 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 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
361pub fn has_stopped_sites(sites: &[codoseo_store::sites::Site]) -> bool {
363 sites.iter().any(|s| !s.monitoring_active)
364}