#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::sync::Arc;
use autumn_web::negotiate::{Format, Negotiate};
use autumn_web::reexports::axum::body::Bytes;
use autumn_web::reexports::axum::extract::{
FromRequest, FromRequestParts, OriginalUri, Request, State,
};
use autumn_web::reexports::axum::response::{IntoResponse, Redirect, Response};
use autumn_web::reexports::axum::routing::{get, post};
use autumn_web::reexports::axum::{Json, Router};
use autumn_web::reexports::http::request::Parts;
use autumn_web::reexports::http::{HeaderMap, header};
use autumn_web::route_listing::{RouteClassification, RouteInfo, RouteSource};
use autumn_web::webhook::SignedWebhook;
use autumn_web::{AppState, AutumnError, AutumnResult};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::BillingService;
use crate::config::BillingConfig;
use crate::error::BillingError;
use crate::gate::{Billing, session_user_id};
use crate::model::{Customer, Subscription, SubscriptionStatus};
use crate::plan::{Plan, PlanId};
use crate::provider::{CheckoutRequest, CustomerRequest, HostedSession, PortalRequest};
use crate::reconcile::{self, ReconcileOutcome};
use crate::store::CustomerUpsert;
pub fn router() -> Router<AppState> {
Router::new()
.route("/checkout", post(checkout))
.route("/portal", post(portal))
.route("/subscription", get(subscription))
.route("/webhook", post(webhook))
}
#[must_use]
pub fn route_infos(config: &BillingConfig) -> Vec<RouteInfo> {
let prefix = config.route_prefix.trim_end_matches('/');
let info =
|method: &str, path: &str, handler: &str, classification: RouteClassification| RouteInfo {
method: method.to_owned(),
path: format!("{prefix}{path}"),
handler: format!("autumn_billing::routes::{handler}"),
source: RouteSource::Plugin("autumn-billing".to_owned()),
classification,
..Default::default()
};
vec![
info("POST", "/checkout", "checkout", RouteClassification::Gated),
info("POST", "/portal", "portal", RouteClassification::Gated),
info(
"GET",
"/subscription",
"subscription",
RouteClassification::Gated,
),
info("POST", "/webhook", "webhook", RouteClassification::Public),
]
}
struct SessionUser(String);
impl FromRequestParts<AppState> for SessionUser {
type Rejection = AutumnError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
session_user_id(parts, state)
.await
.map(Self)
.map_err(BillingError::into_autumn)
}
}
struct NestedWebhook(SignedWebhook);
impl FromRequest<AppState> for NestedWebhook {
type Rejection = AutumnError;
async fn from_request(mut req: Request, state: &AppState) -> Result<Self, Self::Rejection> {
if let Some(OriginalUri(uri)) = req.extensions().get::<OriginalUri>().cloned() {
*req.uri_mut() = uri;
}
SignedWebhook::from_request(req, state).await.map(Self)
}
}
#[derive(Debug, Deserialize)]
struct CheckoutBody {
plan: String,
}
#[derive(Debug, Serialize)]
struct SubscriptionResponse {
subscription: Option<Subscription>,
plan: Option<Plan>,
entitled: bool,
}
fn parse_body<T: DeserializeOwned>(headers: &HeaderMap, body: &[u8]) -> Result<T, AutumnError> {
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if content_type
.trim_start()
.to_ascii_lowercase()
.starts_with("application/json")
{
serde_json::from_slice(body)
.map_err(|e| AutumnError::bad_request_msg(format!("invalid JSON body: {e}")))
} else {
serde_urlencoded::from_bytes(body)
.map_err(|e| AutumnError::bad_request_msg(format!("invalid form body: {e}")))
}
}
fn hosted_response(negotiate: &Negotiate, session: &HostedSession) -> Response {
match negotiate.format() {
Format::Json => Json(json!({ "url": session.url, "id": session.id })).into_response(),
Format::Html => Redirect::to(&session.url).into_response(),
}
}
async fn customer_for(
state: &AppState,
service: &BillingService,
user_id: &str,
) -> Result<Customer, BillingError> {
let store = service.store();
if let Some(customer) = store.customer_by_user(user_id).await? {
return Ok(customer);
}
let provider = service.provider();
let local_id = state.entropy().uuid_v4().to_string();
let raw_user_id = crate::gate::strip_tenant_scope(user_id);
let provider_customer_id = provider
.create_customer(CustomerRequest::new(local_id.clone(), raw_user_id))
.await?;
let now = state.clock().now();
let customer = store
.upsert_customer(
CustomerUpsert::new(local_id, provider.name(), provider_customer_id.clone(), now)
.with_user(user_id),
)
.await?;
if customer.provider_customer_id != provider_customer_id {
tracing::info!(
customer_id = %customer.id,
orphan_provider_customer_id = %provider_customer_id,
"🍂 Autumn Billing: concurrent checkout linked the user; existing customer used"
);
}
Ok(customer)
}
async fn has_live_subscription(
service: &BillingService,
user_id: &str,
) -> Result<bool, BillingError> {
let store = service.store();
let Some(customer) = store.customer_by_user(user_id).await? else {
return Ok(false);
};
let rows = store.subscriptions_for_customer(&customer.id).await?;
Ok(rows
.iter()
.any(|row| row.status.is_live() && row.status != SubscriptionStatus::Incomplete))
}
async fn checkout(
State(state): State<AppState>,
SessionUser(user_id): SessionUser,
negotiate: Negotiate,
headers: HeaderMap,
body: Bytes,
) -> AutumnResult<Response> {
let service = BillingService::require(&state)?;
let body: CheckoutBody = parse_body(&headers, &body)?;
let plan = service
.catalog()
.get(&PlanId::new(body.plan))
.cloned()
.ok_or_else(|| BillingError::NotFound("plan").into_autumn())?;
let Some(_slot) = service.begin_checkout(&user_id) else {
return Err(BillingError::Conflict(
"a checkout for this user is already in progress".to_owned(),
)
.into_autumn());
};
if has_live_subscription(&service, &user_id)
.await
.map_err(BillingError::into_autumn)?
{
return Err(
BillingError::Conflict("the user already has a live subscription".to_owned())
.into_autumn(),
);
}
let customer = customer_for(&state, &service, &user_id)
.await
.map_err(BillingError::into_autumn)?;
let config = service.config();
let request = CheckoutRequest::new(
customer.provider_customer_id,
customer.id,
plan.provider_price_id,
config.success_url.clone(),
config.cancel_url.clone(),
);
let session = service
.provider()
.create_checkout(request)
.await
.map_err(BillingError::into_autumn)?;
Ok(hosted_response(&negotiate, &session))
}
async fn portal(
State(state): State<AppState>,
SessionUser(user_id): SessionUser,
negotiate: Negotiate,
) -> AutumnResult<Response> {
let service = BillingService::require(&state)?;
let customer = service
.store()
.customer_by_user(&user_id)
.await
.map_err(BillingError::into_autumn)?
.ok_or_else(|| BillingError::NotFound("customer").into_autumn())?;
let request = PortalRequest::new(
customer.provider_customer_id,
service.config().portal_return_url.clone(),
);
let session = service
.provider()
.create_portal(request)
.await
.map_err(BillingError::into_autumn)?;
Ok(hosted_response(&negotiate, &session))
}
async fn subscription(
State(state): State<AppState>,
SessionUser(user_id): SessionUser,
) -> AutumnResult<Json<SubscriptionResponse>> {
let billing = Billing::from_state(&state)
.ok_or_else(|| AutumnError::service_unavailable_msg("billing plugin is not started"))?;
let view = billing
.current_subscription(&user_id)
.await
.map_err(BillingError::into_autumn)?;
let response = view.map_or(
SubscriptionResponse {
subscription: None,
plan: None,
entitled: false,
},
|view| SubscriptionResponse {
subscription: Some(view.subscription),
plan: view.plan,
entitled: view.entitled,
},
);
Ok(Json(response))
}
async fn webhook(
State(state): State<AppState>,
NestedWebhook(webhook): NestedWebhook,
) -> AutumnResult<Json<Value>> {
let service: Arc<BillingService> = BillingService::require(&state)?;
let event = match service.provider().parse_event(webhook.raw_body()) {
Ok(event) => event,
Err(BillingError::Malformed(message)) => {
return Err(AutumnError::internal_server_error_msg(format!(
"malformed billing event: {message}"
)));
}
Err(other) => return Err(other.into_autumn()),
};
let event_id = event.id.clone();
let outcome = reconcile::apply(&state, &service, event)
.await
.map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))?;
let label = match outcome {
ReconcileOutcome::Applied { .. } => "applied",
ReconcileOutcome::Duplicate => "duplicate",
ReconcileOutcome::InFlight => {
return Err(AutumnError::service_unavailable_msg(
"billing event is being applied; retry",
));
}
ReconcileOutcome::Ignored { .. } => "ignored",
};
Ok(Json(json!({
"accepted": true,
"outcome": label,
"event_id": event_id,
})))
}